Compare commits

...

16 Commits

Author SHA1 Message Date
d5c9b4d3c8 feat: 添加站点元数据配置以增强SEO支持 2026-01-16 15:45:15 +08:00
ab58d2e251 feat: 添加 @nuxtjs/robots 模块以支持搜索引擎优化 2026-01-16 14:22:20 +08:00
cb1406661a chore: 更新 Nuxt 配置以禁用 sourcemap 并调整 chunk 大小警告限制 2026-01-16 14:18:48 +08:00
8a2bbacaa3 chore: 移除 Font Awesome 字体优化配置并调整 srcDir 设置 2026-01-16 13:59:50 +08:00
20eebcca4f docs: 更新SocialLinks组件的图标库和package.json的依赖版本 2026-01-15 22:15:23 +08:00
f9e624d48e feat: 使用 @nuxt/icon module 显示图标 2026-01-15 21:11:29 +08:00
6edb6af6ee chore:一些修复 2026-01-15 19:53:17 +08:00
53685b1531 chore: 更新 .gitignore 文件 2025-12-19 21:28:59 +08:00
67708a34bc feat: 添加 Sitemap 模块 2025-12-19 21:19:22 +08:00
1c05fd7b1e feat: 添加 Netlify 配置,调整 Nuxt 配置和 package.json 设置 2025-12-19 21:00:54 +08:00
4d8644629b chore: 格式化代码 2025-12-19 19:38:01 +08:00
618723a689 feat: 添加 ESLint 和 Prettier 配置,集成自动格式化工作流 2025-12-19 19:37:29 +08:00
6b05f7c74e style(app): 使用 NuxtImg 替代 img 标签
在多个组件中将 `img` 标签替换为 `NuxtImg` 标签,提升图片加载的性能和优化。例如,在 `AboutSection.vue`、`FriendsSection.vue`、`HeroSection.vue`、`ProjectsSection.vue`、`SitesSection.vue` 和 `SkillsSection.vue` 中的图片标签。

refactor(app): 扩展 `nuxt.config.ts` 配置

扩展了 `nuxt.config.ts` 配置文件中的模块配置,添加了 `@nuxt/image` 和 `@nuxt/eslint` 模块。同时,优化了 `routeRules` 配置,以支持预渲染和增量静态生成。
2025-12-18 22:13:47 +08:00
bda4281fde feat: 添加留言板功能,集成 Giscus 评论系统并更新相关配置 2025-12-17 22:16:06 +08:00
6cc98acd88 fix: 为 Font Awesome 资源添加 font-display=swap 参数以优化加载 2025-12-15 23:51:39 +08:00
ba95a16f21 refactor:删除已弃用的组件和样式,迁移到 Tailwind CSS
- 改进项目页面、网站页面、友链页面
- 从 styles.css 中移除全局样式。
- 添加 tailwind.config.ts 以配置 Tailwind CSS。
- 更新 tsconfig.json,加入 Vue 组件的新路径映射。
2025-12-15 23:38:44 +08:00
72 changed files with 22124 additions and 6328 deletions

80
.github/workflows/lint-format.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Lint and Format
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Enable corepack and install npm
run: |
corepack enable
corepack prepare npm@latest --activate
- name: Install dependencies
run: npm install --frozen-lockfile
- name: Run ESLint
run: npm lint
- name: Run Prettier check
run: npm format:check
format:
name: Auto-format (eslint --fix)
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Enable corepack and install npm
run: |
corepack enable
corepack prepare npm@latest --activate
- name: Install dependencies
run: npm install --frozen-lockfile
- name: Run Prettier --write
run: npm format
- name: Run ESLint --fix
run: npm lint:fix
- name: Commit & push fixes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet; then
git add -A
git commit -m "chore: auto format by GitHub Actions" || echo "No changes to commit"
git push
else
echo "No formatting changes"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
# dependencies # dependencies
node_modules/ node_modules/
pnpm-lock.yaml # pnpm-lock.yaml
package-lock.json package-lock.json
yarn.lock yarn.lock

18
.prettierignore Normal file
View File

@@ -0,0 +1,18 @@
node_modules/
.output/
.nuxt/
dist/
build/
public/build/
.vscode/
.pnp.*
coverage/
*.log
*.lock
pnpm-lock.yaml
.env
.env.*
.DS_Store
# ignore generated images
public/images/generated/

12
.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css"
}

View File

@@ -1,6 +1,6 @@
# Cloud Home # Cloud Home
基于 Nuxt 3 (Vue 3) 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。 基于 Nuxt 4 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
## 特性 ## 特性
@@ -11,10 +11,16 @@
## 技术栈 ## 技术栈
- 前端Nuxt 3Vue 3+ HTML + CSS - 前端Nuxt 4 + Tailwind CSS
- 构建 / 运行Nuxt 3 + Nitro - 构建 / 运行Nuxt 4 + Nitro
- 部署VercelNuxt 构建 + Nitro 函数) - 部署VercelNuxt 构建 + Nitro 函数)
## TODO
- [ ] 增加主题色配置
- [ ] 增加追番模块
- [ ] 增加留言板模块
## 致谢 ## 致谢
排名不分先后 排名不分先后
@@ -111,7 +117,10 @@ const siteConfig: SiteConfig = {
// 技能图标展示详见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: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"],
},
{ title: "工具", items: ["ae", "au", "git", "github", "md", "ps", "pr", "vscode"] }, { title: "工具", items: ["ae", "au", "git", "github", "md", "ps", "pr", "vscode"] },
{ title: "操作系统", items: ["arch", "linux", "windows"] }, { title: "操作系统", items: ["arch", "linux", "windows"] },
], ],
@@ -130,8 +139,16 @@ const siteConfig: SiteConfig = {
], ],
projects: [ 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" }, 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",
},
], ],
friends: [ friends: [
@@ -152,7 +169,7 @@ const siteConfig: SiteConfig = {
footer: { footer: {
beian: "备案号", // 备案号,留空则不显示 beian: "备案号", // 备案号,留空则不显示
beianLink: "https://beian.miit.gov.cn/", // 备案号链接,一般无需修改 beianLink: "https://beian.miit.gov.cn/", // 备案号链接,一般无需修改
customHtml: '', // 自定义 HTML 代码,如统计代码等 customHtml: "", // 自定义 HTML 代码,如统计代码等
hitokoto: { hitokoto: {
enable: true, // 是否启用一言 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 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

75
app/app.vue Normal file
View File

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

View File

@@ -0,0 +1,109 @@
<template>
<section class="card flex flex-col gap-2.5">
<h2 class="m-0 mb-1">个人简介</h2>
<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">
<article
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"
>
<div class="flex items-center gap-2">
<span class="text-xl leading-none">🎂</span>
<h3 class="m-0 text-sm font-semibold text-white/90">年龄</h3>
</div>
<p
class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
>
{{ 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"
>
<div class="flex items-center gap-2">
<span class="text-xl leading-none"></span>
<h3 class="m-0 text-sm font-semibold text-white/90">性别</h3>
</div>
<p
class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
>
{{ profile.gender }}
</p>
</article>
<article
v-if="profile?.pronouns"
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 class="flex items-center gap-2">
<span class="text-xl leading-none">🗣</span>
<h3 class="m-0 text-sm font-semibold text-white/90">代词</h3>
</div>
<p
class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
>
{{ profile.pronouns }}
</p>
</article>
<article
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"
>
<div class="flex items-center gap-2">
<span class="text-xl leading-none">📍</span>
<h3 class="m-0 text-sm font-semibold text-white/90">地区</h3>
</div>
<p
class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
>
{{ profile.location }}
</p>
</article>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5 mt-2.5">
<article
v-for="item in items"
:key="item.title"
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"
>
<div class="flex items-center gap-2 mb-1.5">
<span class="text-2xl leading-none">{{ item.icon }}</span>
<h3 class="m-0 text-base font-semibold">{{ item.title }}</h3>
</div>
<p class="text-text-muted text-sm m-0">{{ item.desc }}</p>
</article>
</div>
</section>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
items: {
type: Array,
default: () => [],
},
profile: {
type: Object,
default: () => ({}),
},
});
const age = computed(() => {
if (!props.profile?.birthday) return null;
const birthDate = new Date(props.profile.birthday);
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

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

View File

@@ -0,0 +1,283 @@
<template>
<div class="card panel flex flex-col gap-2.5">
<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>
<div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2">
<article
v-for="f in displayedFriends"
:key="f.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-pink-400/50 w-[290px] h-[145px] flex flex-col"
>
<div class="flex items-center justify-between mb-1.5">
<div class="flex items-center gap-2 min-w-0">
<NuxtImg
v-if="f.avatar"
:src="f.avatar"
:alt="f.name"
loading="lazy"
class="w-12 h-12 rounded-full object-cover border border-white/15"
/>
<h3 class="m-0 font-semibold text-base whitespace-nowrap overflow-hidden text-ellipsis">
{{ f.name }}
</h3>
</div>
<span class="rounded-full px-2.5 py-1 text-xs bg-purple-400/15 text-purple-300"
>友链</span
>
</div>
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
{{ f.desc || "一个有趣的站点" }}
</p>
<NuxtLink
: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
type="button"
class="px-3 py-2 rounded-2xl border border-white/10 bg-white/6"
@click="showFormModal = false"
>
取消
</button>
<button
type="submit"
:disabled="loading"
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary disabled:opacity-50"
>
{{ loading ? "提交中..." : "提交" }}
</button>
</div>
<div class="sm:col-span-2">
<span class="text-text-muted text-sm">{{ message }}</span>
</div>
</form>
</div>
</div>
</Teleport>
</template>
<script setup>
import { reactive, ref, watch, computed } from "vue";
import siteConfig from "../config/siteConfig";
const props = defineProps({ friends: { type: Array, default: () => [] } });
const showFormModal = ref(false);
const loading = ref(false);
const message = ref("");
const showDialog = ref(false);
const dialogTitle = ref("");
const dialogText = ref("");
const form = reactive({
name: "",
url: "",
desc: "",
email: "",
avatar: "",
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

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

View File

@@ -0,0 +1,32 @@
<template>
<section class="card grid grid-cols-[120px_1fr] gap-4 items-center hover:shadow-lg-dark group">
<div class="relative">
<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"
/>
<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"
:src="profile.avatar"
alt="avatar"
loading="eager"
/>
</div>
<div class="overflow-hidden">
<h1 class="text-2xl font-bold">{{ profile.name }}</h1>
<p class="text-text-muted text-sm mt-1">{{ profile.title }}</p>
<p class="mt-2 line-clamp-2">{{ profile.bio }}</p>
</div>
</section>
</template>
<script setup>
import siteConfig from "../config/siteConfig";
const { profile } = defineProps({
profile: {
type: Object,
required: false,
default: () => siteConfig.profile || {},
},
});
</script>

View File

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

View File

@@ -0,0 +1,65 @@
<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"
>
<button
: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>
<div class="flex gap-2 flex-wrap justify-center">
<button
v-for="item in pages"
:key="item.name"
:class="{
'bg-primary/30 border-primary/60 text-primary shadow-lg shadow-primary/25':
item.name === route.name,
}"
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 })"
>
{{ item.label }}
</button>
</div>
<button
: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"
>
下一页
</button>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
const router = useRouter();
const route = useRoute();
const pages = [
{ name: "index", label: "首页" },
{ name: "about", label: "关于" },
{ name: "sites", label: "网站" },
{ name: "projects", label: "项目" },
{ name: "friends", label: "友链" },
{ name: "comments", label: "留言" },
];
const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name));
const goPrev = () => {
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

@@ -0,0 +1,41 @@
<template>
<section class="card panel flex flex-col gap-2.5">
<h2 class="m-0 mb-1 text-lg font-semibold">项目作品</h2>
<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">
<article
v-for="p in projects"
: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"
>
<div class="flex items-center justify-between mb-1.5">
<h3 class="font-medium truncate">
{{ p.name }}
</h3>
<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>
</section>
</template>
<script setup>
defineProps({
projects: {
type: Array,
default: () => [],
},
});
</script>

View File

@@ -0,0 +1,43 @@
<template>
<section class="card panel flex flex-col gap-2.5">
<h2 class="m-0 mb-1 text-lg font-semibold">我的网站</h2>
<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">
<article
v-for="site in sites"
: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"
>
<div class="flex items-center justify-between mb-1.5">
<h3 class="font-medium truncate">
{{ site.name }}
</h3>
<span class="rounded-full px-2.5 py-1 text-xs bg-sky-400/15 text-green-300"> 在线 </span>
</div>
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
{{ site.desc }}
</p>
<NuxtLink
: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"
>
查看
</NuxtLink>
</article>
</div>
</section>
</template>
<script setup>
defineProps({
sites: {
type: Array,
default: () => [],
},
});
</script>

View File

@@ -0,0 +1,39 @@
<template>
<section class="card flex flex-col gap-2.5">
<div>
<h2 class="m-0 mb-1 font-semibold">技能专长</h2>
<p class="text-text-muted text-sm m-0 mb-3">我常用的工具与技术 · Skills & Technologies</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<article
v-for="group in skills"
:key="group.title"
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"
>
<header class="mb-3">
<h3 class="text-base font-semibold m-0">{{ group.title }}</h3>
</header>
<div class="flex flex-wrap gap-2">
<span
v-for="item in group.items"
:key="item"
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"
>
<NuxtImg
:src="iconSrc(item)"
:alt="item"
:title="item"
loading="lazy"
class="w-7 h-7 rounded-2xl"
/>
</span>
</div>
</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

@@ -0,0 +1,57 @@
<template>
<section class="card flex flex-col gap-2.5">
<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>
<div class="flex flex-wrap gap-2.5">
<template v-for="link in links" :key="link.url">
<NuxtLink
: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"
>
<span v-if="iconFor(link)" class="inline-flex items-center justify-center w-5 h-5">
<Icon v-if="iconFor(link).name" :name="iconFor(link).name" width="20" height="20" />
</span>
<span>{{ link.name }}</span>
</NuxtLink>
</template>
</div>
</section>
</template>
<script setup>
defineProps({
links: {
type: Array,
required: true,
},
});
const iconMap = {
bilibili: "simple-icons:bilibili",
github: "simple-icons:github",
blog: "fa6-solid:book",
email: "fa6-solid:envelope",
mail: "fa6-solid:envelope",
telegram: "simple-icons:telegram",
twitter: "simple-icons:twitter",
x: "simple-icons:x",
linkedin: "simple-icons:linkedin",
youtube: "simple-icons:youtube",
facebook: "simple-icons:facebook",
instagram: "simple-icons:instagram",
reddit: "simple-icons:reddit",
discord: "simple-icons:discord",
weibo: "simple-icons:sinaweibo",
zhihu: "simple-icons:zhihu",
wechat: "simple-icons:wechat",
weixin: "simple-icons:wechat",
qq: "simple-icons:qq",
};
const iconFor = (link) => {
const key = (link.name || "").toLowerCase();
if (iconMap[key]) return { name: iconMap[key] };
if (link.icon) return { src: link.icon };
return null;
};
</script>

View File

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

@@ -0,0 +1,319 @@
<template>
<section v-if="showComponent && (weeklyData || allTimeData)" class="card">
<div class="header">
<h2>Wakatime</h2>
<div class="tabs">
<button
class="tab-button"
:class="{ active: activeTab === 'weekly' }"
@click="activeTab = 'weekly'"
>
最近7天
</button>
<button
v-if="allTimeData"
class="tab-button"
:class="{ active: activeTab === 'allTime' }"
@click="activeTab = 'allTime'"
>
所有时间
</button>
</div>
</div>
<div class="stats-wrap">
<h3>编码统计</h3>
<p class="muted">
{{ activeTab === "weekly" ? "最近7天 · Last 7 Days" : "所有时间 · All Time" }}
</p>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{
currentData.total_seconds ? formatTime(currentData.total_seconds) : "N/A"
}}</span>
<span class="stat-label">总时间</span>
</div>
<div class="stat-item">
<span class="stat-value">{{
currentData.daily_average ? formatTime(currentData.daily_average) : "N/A"
}}</span>
<span class="stat-label">日均</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ currentData.days_including_holidays || "N/A" }}</span>
<span class="stat-label">活跃天数</span>
</div>
</div>
</div>
<div v-if="currentData.languages && currentData.languages.length" class="lang-wrap">
<h3>编程语言</h3>
<p class="muted">语言使用统计 · Languages</p>
<div class="lang-chart">
<ul class="list lang-list">
<li v-for="lang in topLanguages" :key="lang.name" class="lang-row">
<div class="lang-label">
<span class="dot" :style="{ background: colorFor(lang.name) }" />
<span class="lang-name">{{ lang.name }}</span>
<span class="lang-percent">{{ lang.percent }}%</span>
</div>
<div class="lang-bar">
<span class="lang-bar-fill" :style="barStyle(lang)" />
</div>
</li>
</ul>
</div>
</div>
<div v-if="statusData" class="status-wrap">
<h3>当前状态</h3>
<p class="muted">实时状态 · Current Status</p>
<div class="status-item">
<span class="status-indicator" :class="{ active: statusData.is_coding }" />
<span class="status-text">{{ statusData.is_coding ? "正在编码" : "未在编码" }}</span>
<span v-if="statusData.project" class="status-project">{{ statusData.project }}</span>
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import siteConfig from "~/config/siteConfig";
const wakapi = siteConfig.wakapi;
const weeklyData = ref(null);
const allTimeData = ref(null);
const statusData = ref(null);
const showComponent = ref(true);
const activeTab = ref("weekly");
const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#201a1fff", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
const currentData = computed(() => {
return activeTab.value === "weekly" ? weeklyData.value : allTimeData.value;
});
const topLanguages = computed(() => {
if (!currentData.value || !currentData.value.languages) return [];
return currentData.value.languages.slice(0, 5);
});
const colorFor = (name) => {
const idx = currentData.value.languages.findIndex((l) => l.name === name);
return palette[(idx >= 0 ? idx : 0) % palette.length];
};
const barStyle = (lang) => ({
width: `${Math.max(8, lang.percent)}%`,
background: colorFor(lang.name),
});
const formatTime = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
};
const fetchWakatimeData = async () => {
if (!wakapi.enable) {
console.warn("Wakatime is disabled in siteConfig.");
return;
}
const apiUrl = wakapi.apiUrl || "https://wakatime.com/api/v1";
const username = wakapi.username;
if (!username) {
console.error("Wakatime username is not configured.");
return;
}
try {
const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([
fetch(`${apiUrl}/users/${username}/stats/last_7_days`),
fetch(`${apiUrl}/users/${username}/stats`),
fetch(`${apiUrl}/users/${username}/status`),
]);
if (weeklyStatsResponse.ok) {
weeklyData.value = await weeklyStatsResponse.json();
} else {
console.error("Failed to fetch weekly stats:", weeklyStatsResponse.status);
}
if (allTimeStatsResponse.ok) {
allTimeData.value = await allTimeStatsResponse.json();
} else {
console.warn("All-time stats not available:", allTimeStatsResponse.status);
}
if (statusResponse.ok) {
statusData.value = await statusResponse.json();
} else {
console.warn("Status data not available:", statusResponse.status);
}
} catch (error) {
console.error("Error fetching Wakatime data:", error);
}
};
onMounted(() => {
fetchWakatimeData();
});
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.tabs {
display: flex;
gap: 0.5rem;
}
.tab-button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: var(--card-bg);
color: var(--text);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.tab-button:hover {
background: var(--hover-bg);
}
.tab-button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border);
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: bold;
color: var(--text);
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.875rem;
color: var(--muted);
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border);
margin-top: 1rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--muted);
}
.status-indicator.active {
background: #6bdba6;
}
.status-text {
font-weight: 500;
}
.status-project {
color: var(--muted);
font-size: 0.875rem;
}
.lang-wrap {
margin-top: 12px;
}
.lang-chart {
display: block;
}
.lang-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.lang-row {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 8px 10px;
}
.lang-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.lang-name {
color: #e8eefc;
}
.lang-percent {
color: #a8b3cf;
font-size: 0.9rem;
}
.lang-bar {
margin-top: 6px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.lang-bar-fill {
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;
}
</style>

185
app/config/siteConfig.ts Normal file
View File

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

87
app/pages/about.vue Normal file
View File

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

82
app/pages/comments.vue Normal file
View File

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

View File

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

View File

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

340
app/styles.global.css Normal file
View File

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

9
app/utils/cssLoader.ts Normal file
View File

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

2813
bun.lock Normal file

File diff suppressed because it is too large Load Diff

19
eslint.config.mjs Normal file
View File

@@ -0,0 +1,19 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
// import simpleImportSort from "eslint-plugin-simple-import-sort";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
export default withNuxt([eslintPluginPrettierRecommended], {
files: ["src/**/*.ts", "src/**/*.vue"],
ignores: [".nuxt/", "node_modules/"],
language: "vue",
// plugins: {
// prettier: prettierPlugin,
// },
// rules: {
// "prettier/prettier": "error",
// "simple-import-sort/imports": "error",
// "simple-import-sort/exports": "error",
// },
});

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Home</title>
<link rel="icon" href="/favicon.ico" />
<!-- NeteaseMiniPlayer v2 CSS -->
<link rel="stylesheet" href="https://api.hypcvgm.top/NeteaseMiniPlayer/netease-mini-player-v2.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<!-- NeteaseMiniPlayer v2 JS -->
<script src="/js/netease-mini-player-v2.js"></script>
</body>
</html>

2
netlify.toml Normal file
View File

@@ -0,0 +1,2 @@
[images]
remote_images = ["https:\\/\\/.*"]

View File

@@ -1,22 +1,100 @@
import { defineNuxtConfig } from "nuxt/config"; import { defineNuxtConfig } from "nuxt/config";
import siteConfig from "./src/config/siteConfig"; import siteConfig from "./app/config/siteConfig";
import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2025-12-12", compatibilityDate: "2025-12-12",
srcDir: "src/", srcDir: "app",
css: ["~/styles.css"],
modules: [
"@nuxt/image",
"@nuxt/eslint",
"@nuxtjs/robots",
"@nuxtjs/sitemap",
"@nuxt/icon",
"@nuxtjs/seo",
],
// 禁用 Vue Router 的非关键警告
vue: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith("ion-"),
},
},
// Tailwind CSS 集成
css: ["~/styles.global.css"],
vite: {
plugins: [tailwindcss()],
build: {
sourcemap: false,
chunkSizeWarningLimit: 1000,
},
},
postcss: {
plugins: {
"@tailwindcss/postcss": {},
},
},
routeRules: {
"/": { prerender: true },
"/about": { isr: 3600 },
"/sites": { prerender: true },
"/projects": { prerender: true },
"/friends": { prerender: true },
},
robots: { groups: [{ userAgent: ["GPTBot", "ChatGPT-User"], disallow: ["/"] }] },
sitemap: {
zeroRuntime: true,
},
app: { app: {
head: { head: {
charset: "utf-8",
viewport: "width=device-width,initial-scale=1,maximum-scale=5",
title: siteConfig.siteMeta.title, title: siteConfig.siteMeta.title,
link: [{ rel: "icon", href: siteConfig.siteMeta.icon }], titleTemplate: `%s - ${siteConfig.siteMeta.title}`,
meta: [
{ name: "author", content: siteConfig.siteMeta.author },
{ name: "language", content: "zh-CN" },
{ name: "description", content: siteConfig.siteMeta.description },
],
link: [
{ rel: "icon", href: siteConfig.siteMeta.favicon, type: "image/svg+xml" },
{ rel: "canonical", href: siteConfig.siteMeta.url },
{ rel: "alternate", hreflang: siteConfig.siteMeta.lang, href: siteConfig.siteMeta.url },
{ rel: "dns-prefetch", href: siteConfig.siteMeta.url },
{ rel: "preconnect", href: siteConfig.siteMeta.url },
{ rel: "icon", href: siteConfig.siteMeta.favicon },
],
}, },
pageTransition: { name: "page", mode: "out-in" },
layoutTransition: { name: "layout", mode: "out-in" },
}, },
// nitro: {
// prerender: { nitro: {
// crawlLinks: true, prerender: {
crawlLinks: true,
// routes: ["/sitemap.xml", "/rss.xml"], // routes: ["/sitemap.xml", "/rss.xml"],
// }, },
// }, minify: true,
externals: {
inline: ["unhead"],
},
},
site: {
url: siteConfig.siteMeta.url,
name: siteConfig.siteMeta.title,
description: siteConfig.siteMeta.description,
author: siteConfig.siteMeta.author,
},
runtimeConfig: { runtimeConfig: {
smtpHost: process.env.SMTP_HOST ?? "", smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: Number(process.env.SMTP_PORT ?? 465), smtpPort: Number(process.env.SMTP_PORT ?? 465),
@@ -25,11 +103,11 @@ export default defineNuxtConfig({
senderEmail: process.env.SENDER_EMAIL ?? "", senderEmail: process.env.SENDER_EMAIL ?? "",
adminEmail: process.env.ADMIN_EMAIL ?? "", adminEmail: process.env.ADMIN_EMAIL ?? "",
smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : undefined, smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : undefined,
githubToken: process.env.NUXT_PUBLIC_GITHUB_TOKEN ?? "",
umamiApiKey: process.env.UMAMI_API_KEY ?? "",
public: {
wakatimeApiKey: process.env.WAKATIME_API_KEY ?? "", wakatimeApiKey: process.env.WAKATIME_API_KEY ?? "",
wakatimeApiUrl: process.env.WAKATIME_API_URL ?? "https://wakatime.com/api/v1", wakatimeApiUrl: process.env.WAKATIME_API_URL ?? "https://wakatime.com/api/v1",
public: {
githubToken: process.env.NUXT_PUBLIC_GITHUB_TOKEN ?? "",
umamiApiKey: process.env.NUXT_PUBLIC_UMAMI_API_KEY ?? "",
}, },
}, },
}); });

View File

@@ -1,21 +1,49 @@
{ {
"name": "cloud-home", "name": "cloud-home",
"author": {
"name": "RhenCloud",
"email": "i@rhen.cloud",
"url": "https://rhen.cloud"
},
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "nuxt dev", "dev": "nuxt dev",
"build": "nuxt build", "build": "nuxt build",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview" "preview": "nuxt preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@giscus/vue": "^3.1.1",
"@jaseeey/vue-umami-plugin": "^1.4.0", "@jaseeey/vue-umami-plugin": "^1.4.0",
"nodemailer": "^7.0.11", "@nuxt/icon": "^2.2.0",
"nuxt": "^4.2.2" "@nuxt/image": "2.0.0",
"@nuxtjs/icon": "^2.6.0",
"@nuxtjs/robots": "^5.6.7",
"@nuxtjs/seo": "3.3.0",
"@nuxtjs/sitemap": "^7.5.2",
"nodemailer": "^7.0.12",
"nuxt": "^4.2.2",
"vite-tsconfig-paths": "^6.0.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@iconify-json/fa6-brands": "^1.2.6",
"@types/nodemailer": "^7.0.4", "@iconify-json/simple-icons": "^1.2.66",
"@nuxt/eslint": "1.12.1",
"@tailwindcss/vite": "^4.1.18",
"@types/nodemailer": "^7.0.5",
"@typescript-eslint/parser": "^8.53.0",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"prettier": "^3.8.0",
"prettier-eslint": "^16.4.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

12552
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,2 @@
onlyBuiltDependencies: ignoredBuiltDependencies:
- esbuild - core-js

View File

@@ -1,6 +1,5 @@
<!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" />
@@ -8,7 +7,12 @@
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; font-family:
"Inter",
"Segoe UI",
system-ui,
-apple-system,
sans-serif;
} }
* { * {
@@ -20,9 +24,9 @@
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
background: radial-gradient(circle at 20% 20%, rgba(244, 151, 218, 0.14), transparent 32%), background:
radial-gradient(circle at 80% 10%, rgba(124, 193, 255, 0.16), transparent 32%), radial-gradient(circle at 20% 20%, rgba(244, 151, 218, 0.14), transparent 32%),
#0f1116; radial-gradient(circle at 80% 10%, rgba(124, 193, 255, 0.16), transparent 32%), #0f1116;
color: #e8eefc; color: #e8eefc;
} }
@@ -58,7 +62,10 @@
color: #e8eefc; color: #e8eefc;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; transition:
transform 0.15s ease,
box-shadow 0.15s ease,
border-color 0.15s ease;
} }
a.btn:hover { a.btn:hover {
@@ -76,5 +83,4 @@
<a class="btn" href="/">返回首页</a> <a class="btn" href="/">返回首页</a>
</main> </main>
</body> </body>
</html> </html>

BIN
public/avatar-1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

BIN
public/background.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

167
public/css/giscus.css Normal file
View File

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

View File

@@ -38,7 +38,8 @@
--shadow-outset: 6px 6px 12px var(--shadow-dark), -6px -6px 12px var(--shadow-light); --shadow-outset: 6px 6px 12px var(--shadow-dark), -6px -6px 12px var(--shadow-light);
--border-radius: 12px; --border-radius: 12px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--theme-transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease; --theme-transition:
background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
--flow-color-1: rgba(255, 107, 53, 0.32); --flow-color-1: rgba(255, 107, 53, 0.32);
--flow-color-2: rgba(86, 151, 227, 0.26); --flow-color-2: rgba(86, 151, 227, 0.26);
@@ -158,10 +159,15 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), transition:
border-radius 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1), width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
height 0.5s cubic-bezier(0.4, 0, 0.2, 1),
border-radius 0.5s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.5s cubic-bezier(0.4, 0, 0.2, 1),
var(--theme-transition); var(--theme-transition);
background: 0.5s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1); background:
0.5s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
} }
.netease-mini-player::before { .netease-mini-player::before {
@@ -170,14 +176,25 @@
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
background: radial-gradient(circle at 15% 20%, var(--flow-color-1) 0%, transparent 60%), background:
radial-gradient(circle at 15% 20%, var(--flow-color-1) 0%, transparent 60%),
radial-gradient(circle at 80% 25%, var(--flow-color-2) 0%, transparent 60%), radial-gradient(circle at 80% 25%, var(--flow-color-2) 0%, transparent 60%),
radial-gradient(circle at 30% 85%, var(--flow-color-3) 0%, transparent 60%), radial-gradient(circle at 30% 85%, var(--flow-color-3) 0%, transparent 60%),
radial-gradient(circle at 10% 75%, var(--flow-color-4) 0%, transparent 55%), radial-gradient(circle at 10% 75%, var(--flow-color-4) 0%, transparent 55%),
radial-gradient(circle at 85% 80%, var(--flow-color-5) 0%, transparent 55%); radial-gradient(circle at 85% 80%, var(--flow-color-5) 0%, transparent 55%);
opacity: var(--flow-opacity); opacity: var(--flow-opacity);
background-size: 220% 220%, 220% 220%, 220% 220%, 220% 220%, 220% 220%; background-size:
background-position: 0% 0%, 100% 0%, 0% 100%, 20% 80%, 80% 20%; 220% 220%,
220% 220%,
220% 220%,
220% 220%,
220% 220%;
background-position:
0% 0%,
100% 0%,
0% 100%,
20% 80%,
80% 20%;
filter: saturate(1.02) brightness(1.01); filter: saturate(1.02) brightness(1.01);
transform: scale(1); transform: scale(1);
animation: flow-breathe var(--flow-speed) ease-in-out infinite; animation: flow-breathe var(--flow-speed) ease-in-out infinite;
@@ -190,17 +207,32 @@
@keyframes flow-breathe { @keyframes flow-breathe {
0% { 0% {
background-position: 0% 0%, 100% 0%, 0% 100%, 20% 80%, 80% 20%; background-position:
0% 0%,
100% 0%,
0% 100%,
20% 80%,
80% 20%;
transform: scale(1); transform: scale(1);
filter: saturate(1.02) brightness(1.01); filter: saturate(1.02) brightness(1.01);
} }
50% { 50% {
background-position: 100% 50%, 0% 100%, 50% 0%, 35% 65%, 65% 35%; background-position:
100% 50%,
0% 100%,
50% 0%,
35% 65%,
65% 35%;
transform: scale(1.03); transform: scale(1.03);
filter: saturate(1.15) brightness(1.06); filter: saturate(1.15) brightness(1.06);
} }
100% { 100% {
background-position: 0% 100%, 100% 50%, 100% 0%, 20% 80%, 80% 20%; background-position:
0% 100%,
100% 50%,
100% 0%,
20% 80%,
80% 20%;
transform: scale(1); transform: scale(1);
filter: saturate(1.02) brightness(1.01); filter: saturate(1.02) brightness(1.01);
} }
@@ -211,10 +243,13 @@
height: 80px; height: 80px;
border-radius: 50%; border-radius: 50%;
padding: 0; padding: 0;
background: radial-gradient(120px 120px at 30% 30%, rgba(255, 138, 80, 0.25), transparent 60%), background:
radial-gradient(120px 120px at 30% 30%, rgba(255, 138, 80, 0.25), transparent 60%),
radial-gradient(120px 120px at 70% 70%, rgba(0, 210, 255, 0.22), transparent 60%), radial-gradient(120px 120px at 70% 70%, rgba(0, 210, 255, 0.22), transparent 60%),
linear-gradient(135deg, #151515, #1e1e1e); linear-gradient(135deg, #151515, #1e1e1e);
box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.4), -2px -2px 8px rgba(255, 255, 255, 0.1), box-shadow:
4px 4px 12px rgba(0, 0, 0, 0.4),
-2px -2px 8px rgba(255, 255, 255, 0.1),
inset 0 0 20px rgba(0, 0, 0, 0.3); inset 0 0 20px rgba(0, 0, 0, 0.3);
} }
@@ -223,13 +258,17 @@
.netease-mini-player.minimized .playlist-container { .netease-mini-player.minimized .playlist-container {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease; transition:
opacity 0.3s ease,
visibility 0.3s ease;
} }
.netease-mini-player.minimized .player-bottom { .netease-mini-player.minimized .player-bottom {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease; transition:
opacity 0.3s ease,
visibility 0.3s ease;
} }
.netease-mini-player.minimized .minimize-btn { .netease-mini-player.minimized .minimize-btn {
@@ -261,7 +300,9 @@
.netease-mini-player .player-bottom { .netease-mini-player .player-bottom {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transition: opacity 0.3s ease 0.2s, visibility 0.3s ease 0.2s; transition:
opacity 0.3s ease 0.2s,
visibility 0.3s ease 0.2s;
} }
.netease-mini-player.minimized .album-cover-container { .netease-mini-player.minimized .album-cover-container {
@@ -272,8 +313,11 @@
left: 0; left: 0;
border-radius: 50%; border-radius: 50%;
box-shadow: none; box-shadow: none;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), transition:
top 0.5s cubic-bezier(0.4, 0, 0.2, 1), left 0.5s cubic-bezier(0.4, 0, 0.2, 1), width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
height 0.5s cubic-bezier(0.4, 0, 0.2, 1),
top 0.5s cubic-bezier(0.4, 0, 0.2, 1),
left 0.5s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1); box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -282,7 +326,9 @@
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
filter: brightness(0.8) contrast(1.1); filter: brightness(0.8) contrast(1.1);
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), transition:
width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
height 0.5s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.5s cubic-bezier(0.4, 0, 0.2, 1); filter 0.5s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -306,18 +352,27 @@
rgba(0, 0, 0, 0.15) 46%, rgba(0, 0, 0, 0.15) 46%,
transparent 46% transparent 46%
); );
box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.5), inset 0 0 5px rgba(0, 0, 0, 0.8); box-shadow:
transition: background 0.5s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1); inset 0 0 15px rgba(0, 0, 0, 0.5),
inset 0 0 5px rgba(0, 0, 0, 0.8);
transition:
background 0.5s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
} }
.netease-mini-player.minimized .vinyl-center { .netease-mini-player.minimized .vinyl-center {
width: 16px; width: 16px;
height: 16px; height: 16px;
background: radial-gradient(circle at 30% 30%, #666 0%, #333 50%, #111 100%); background: radial-gradient(circle at 30% 30%, #666 0%, #333 50%, #111 100%);
box-shadow: 0 0 3px rgba(0, 0, 0, 0.8), inset 1px 1px 2px rgba(255, 255, 255, 0.2); box-shadow:
0 0 3px rgba(0, 0, 0, 0.8),
inset 1px 1px 2px rgba(255, 255, 255, 0.2);
border: 1px solid #000; border: 1px solid #000;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), transition:
background 0.5s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1), width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
height 0.5s cubic-bezier(0.4, 0, 0.2, 1),
background 0.5s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1),
border 0.5s cubic-bezier(0.4, 0, 0.2, 1); border 0.5s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -367,8 +422,11 @@
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
background: var(--secondary-bg); background: var(--secondary-bg);
box-shadow: inset 2px 2px 4px var(--shadow-dark), inset -2px -2px 4px var(--shadow-light), box-shadow:
2px 2px 6px rgba(0, 0, 0, 0.2), -1px -1px 3px rgba(255, 255, 255, 0.1); inset 2px 2px 4px var(--shadow-dark),
inset -2px -2px 4px var(--shadow-light),
2px 2px 6px rgba(0, 0, 0, 0.2),
-1px -1px 3px rgba(255, 255, 255, 0.1);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.1); border: 2px solid rgba(0, 0, 0, 0.1);
@@ -437,7 +495,9 @@
rgba(0, 0, 0, 0.2) 100% rgba(0, 0, 0, 0.2) 100%
); );
pointer-events: none; pointer-events: none;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.3), inset 0 0 20px rgba(0, 0, 0, 0.1); box-shadow:
inset 0 0 10px rgba(0, 0, 0, 0.3),
inset 0 0 20px rgba(0, 0, 0, 0.1);
} }
.vinyl-center { .vinyl-center {
@@ -449,7 +509,10 @@
background: radial-gradient(circle at center, #1a1a1a 0%, #333 50%, #1a1a1a 100%); background: radial-gradient(circle at center, #1a1a1a 0%, #333 50%, #1a1a1a 100%);
border-radius: 50%; border-radius: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5), inset 0 0 3px rgba(0, 0, 0, 0.8), inset 0 0 1px rgba(255, 255, 255, 0.1); box-shadow:
0 0 4px rgba(0, 0, 0, 0.5),
inset 0 0 3px rgba(0, 0, 0, 0.8),
inset 0 0 1px rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
} }
@@ -536,7 +599,9 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
width: 100%; width: 100%;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; transition:
opacity 0.2s ease-in-out,
transform 0.2s ease-in-out;
} }
.lyric-line.current { .lyric-line.current {
@@ -602,8 +667,18 @@
} }
.lyric-line.word-lyric-active { .lyric-line.word-lyric-active {
-webkit-mask: linear-gradient(90deg, #000 0%, #000 var(--line-progress, 0%), transparent var(--line-progress, 0%)); -webkit-mask: linear-gradient(
mask: linear-gradient(90deg, #000 0%, #000 var(--line-progress, 0%), transparent var(--line-progress, 0%)); 90deg,
#000 0%,
#000 var(--line-progress, 0%),
transparent var(--line-progress, 0%)
);
mask: linear-gradient(
90deg,
#000 0%,
#000 var(--line-progress, 0%),
transparent var(--line-progress, 0%)
);
} }
.netease-mini-player[data-theme="dark"] .lyric-line.word-lyric-active { .netease-mini-player[data-theme="dark"] .lyric-line.word-lyric-active {
@@ -640,14 +715,18 @@
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: var(--transition); transition: var(--transition);
box-shadow: 2px 2px 4px var(--shadow-dark), -2px -2px 4px var(--shadow-light); box-shadow:
2px 2px 4px var(--shadow-dark),
-2px -2px 4px var(--shadow-light);
font-size: 12px; font-size: 12px;
color: var(--text-primary); color: var(--text-primary);
position: relative; position: relative;
} }
.control-btn:hover { .control-btn:hover {
box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); box-shadow:
inset 1px 1px 2px var(--shadow-dark),
inset -1px -1px 2px var(--shadow-light);
background: var(--secondary-bg); background: var(--secondary-bg);
} }
@@ -656,7 +735,9 @@
height: 30px; height: 30px;
font-size: 14px; font-size: 14px;
background: var(--primary-bg); background: var(--primary-bg);
box-shadow: 2px 2px 4px var(--shadow-dark), -2px -2px 4px var(--shadow-light); box-shadow:
2px 2px 4px var(--shadow-dark),
-2px -2px 4px var(--shadow-light);
} }
.play-icon, .play-icon,
@@ -671,11 +752,15 @@
.play-btn:hover { .play-btn:hover {
background: var(--secondary-bg); background: var(--secondary-bg);
box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); box-shadow:
inset 1px 1px 2px var(--shadow-dark),
inset -1px -1px 2px var(--shadow-light);
} }
.control-btn:active { .control-btn:active {
box-shadow: inset 2px 2px 4px var(--shadow-dark), inset -2px -2px 4px var(--shadow-light); box-shadow:
inset 2px 2px 4px var(--shadow-dark),
inset -2px -2px 4px var(--shadow-light);
background: var(--accent-gradient); background: var(--accent-gradient);
color: white; color: white;
} }
@@ -731,7 +816,9 @@
border-radius: 2px; border-radius: 2px;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); box-shadow:
inset 1px 1px 2px var(--shadow-dark),
inset -1px -1px 2px var(--shadow-light);
} }
.progress-bar { .progress-bar {
@@ -753,7 +840,10 @@
background: var(--accent-color-3); background: var(--accent-color-3);
border-radius: 50%; border-radius: 50%;
transform: translateY(-50%); transform: translateY(-50%);
box-shadow: 0 0 8px rgba(0, 210, 255, 0.6), 1px 1px 2px var(--shadow-dark), -1px -1px 2px var(--shadow-light); box-shadow:
0 0 8px rgba(0, 210, 255, 0.6),
1px 1px 2px var(--shadow-dark),
-1px -1px 2px var(--shadow-light);
} }
.bottom-controls { .bottom-controls {
@@ -785,7 +875,9 @@
border-radius: 2px; border-radius: 2px;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); box-shadow:
inset 1px 1px 2px var(--shadow-dark),
inset -1px -1px 2px var(--shadow-light);
} }
.volume-bar { .volume-bar {
@@ -943,7 +1035,9 @@
top: 100%; top: 100%;
background: var(--primary-bg); background: var(--primary-bg);
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: 4px 4px 8px var(--shadow-dark), -4px -4px 8px var(--shadow-light); box-shadow:
4px 4px 8px var(--shadow-dark),
-4px -4px 8px var(--shadow-light);
max-height: 200px; max-height: 200px;
overflow: hidden; overflow: hidden;
z-index: 1001; z-index: 1001;
@@ -960,14 +1054,25 @@
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
background: radial-gradient(circle at 20% 15%, var(--flow-color-1) 0%, transparent 60%), background:
radial-gradient(circle at 20% 15%, var(--flow-color-1) 0%, transparent 60%),
radial-gradient(circle at 75% 30%, var(--flow-color-2) 0%, transparent 55%), radial-gradient(circle at 75% 30%, var(--flow-color-2) 0%, transparent 55%),
radial-gradient(circle at 35% 85%, var(--flow-color-3) 0%, transparent 55%), radial-gradient(circle at 35% 85%, var(--flow-color-3) 0%, transparent 55%),
radial-gradient(circle at 25% 70%, var(--flow-color-4) 0%, transparent 50%), radial-gradient(circle at 25% 70%, var(--flow-color-4) 0%, transparent 50%),
radial-gradient(circle at 85% 75%, var(--flow-color-5) 0%, transparent 50%); radial-gradient(circle at 85% 75%, var(--flow-color-5) 0%, transparent 50%);
opacity: var(--playlist-flow-opacity); opacity: var(--playlist-flow-opacity);
background-size: 220% 220%, 220% 220%, 220% 220%, 220% 220%, 220% 220%; background-size:
background-position: 0% 0%, 100% 0%, 0% 100%, 20% 80%, 80% 20%; 220% 220%,
220% 220%,
220% 220%,
220% 220%,
220% 220%;
background-position:
0% 0%,
100% 0%,
0% 100%,
20% 80%,
80% 20%;
filter: saturate(1.02) brightness(1.01); filter: saturate(1.02) brightness(1.01);
transform: scale(1); transform: scale(1);
animation: flow-breathe var(--flow-speed) ease-in-out infinite; animation: flow-breathe var(--flow-speed) ease-in-out infinite;
@@ -1021,7 +1126,9 @@
.playlist-item:hover { .playlist-item:hover {
background: rgba(255, 107, 53, 0.08); background: rgba(255, 107, 53, 0.08);
box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); box-shadow:
inset 1px 1px 2px var(--shadow-dark),
inset -1px -1px 2px var(--shadow-light);
transform: translateX(2px); transform: translateX(2px);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@@ -1193,8 +1300,8 @@
} }
.netease-mini-player.minimized.fading-in { .netease-mini-player.minimized.fading-in {
animation: player-fade-in var(--opacity-duration-up, 0.25s) var(--opacity-ease-in, cubic-bezier(0.4, 0, 0.2, 1)) animation: player-fade-in var(--opacity-duration-up, 0.25s)
forwards; var(--opacity-ease-in, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
} }
@keyframes player-fade-out { @keyframes player-fade-out {
@@ -1223,23 +1330,27 @@
} }
.netease-mini-player.minimized.fading-out.docked-right { .netease-mini-player.minimized.fading-out.docked-right {
animation: player-fade-out var(--opacity-duration-down, 0.6s) animation:
player-fade-out var(--opacity-duration-down, 0.6s)
var(--opacity-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) forwards, var(--opacity-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) forwards,
player-dock-right var(--dock-duration, 0.45s) var(--dock-ease-out, cubic-bezier(0.18, 0.9, 0.2, 1)) forwards; player-dock-right var(--dock-duration, 0.45s)
var(--dock-ease-out, cubic-bezier(0.18, 0.9, 0.2, 1)) forwards;
} }
.netease-mini-player.minimized.fading-out.docked-left { .netease-mini-player.minimized.fading-out.docked-left {
animation: player-fade-out var(--opacity-duration-down, 0.6s) animation:
player-fade-out var(--opacity-duration-down, 0.6s)
var(--opacity-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) forwards, var(--opacity-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) forwards,
player-dock-left var(--dock-duration, 0.45s) var(--dock-ease-out, cubic-bezier(0.18, 0.9, 0.2, 1)) forwards; player-dock-left var(--dock-duration, 0.45s)
var(--dock-ease-out, cubic-bezier(0.18, 0.9, 0.2, 1)) forwards;
} }
.netease-mini-player.minimized.popping-right { .netease-mini-player.minimized.popping-right {
animation: player-popout-right var(--popout-duration, 0.28s) var(--popout-ease, cubic-bezier(0.4, 0, 0.2, 1)) animation: player-popout-right var(--popout-duration, 0.28s)
forwards; var(--popout-ease, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
} }
.netease-mini-player.minimized.popping-left { .netease-mini-player.minimized.popping-left {
animation: player-popout-left var(--popout-duration, 0.28s) var(--popout-ease, cubic-bezier(0.4, 0, 0.2, 1)) animation: player-popout-left var(--popout-duration, 0.28s)
forwards; var(--popout-ease, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
} }
@keyframes player-dock-right { @keyframes player-dock-right {

View File

@@ -318,7 +318,10 @@ class NeteaseMiniPlayer {
this.elements.minimizeBtn.addEventListener("click", () => this.toggleMinimize()); this.elements.minimizeBtn.addEventListener("click", () => this.toggleMinimize());
} }
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if (this.elements.playlistContainer && this.elements.playlistContainer.classList.contains("show")) { if (
this.elements.playlistContainer &&
this.elements.playlistContainer.classList.contains("show")
) {
if (!this.element.contains(e.target)) { if (!this.element.contains(e.target)) {
this.togglePlaylist(false); this.togglePlaylist(false);
} }
@@ -463,14 +466,16 @@ class NeteaseMiniPlayer {
const isiOS = isiPhone || isiPadUA || isIOSLikePad; const isiOS = isiPhone || isiPadUA || isIOSLikePad;
const isAndroid = /android/.test(ua); const isAndroid = /android/.test(ua);
const isHarmonyOS = /harmonyos/.test(uaRaw) || /huawei|honor/.test(ua); const isHarmonyOS = /harmonyos/.test(uaRaw) || /huawei|honor/.test(ua);
const isMobileToken = /mobile/.test(ua) || /sm-|mi |redmi|huawei|honor|oppo|vivo|oneplus/.test(ua); const isMobileToken =
/mobile/.test(ua) || /sm-|mi |redmi|huawei|honor|oppo|vivo|oneplus/.test(ua);
const isHarmonyDesktop = isHarmonyOS && !isMobileToken && !isAndroid && !isiOS; const isHarmonyDesktop = isHarmonyOS && !isMobileToken && !isAndroid && !isiOS;
const isPWA = const isPWA =
(typeof window !== "undefined" && (typeof window !== "undefined" &&
((window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) || ((window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
nav.standalone === true)) || nav.standalone === true)) ||
false; false;
const isMobile = isiOS || isAndroid || (isHarmonyOS && !isHarmonyDesktop) || isMobileToken || isInAppWebView; const isMobile =
isiOS || isAndroid || (isHarmonyOS && !isHarmonyDesktop) || isMobileToken || isInAppWebView;
const info = { const info = {
isMobile, isMobile,
isiOS, isiOS,
@@ -497,7 +502,10 @@ class NeteaseMiniPlayer {
if (shouldHideVolume) { if (shouldHideVolume) {
this.elements.volumeContainer.classList.add("sr-visually-hidden"); this.elements.volumeContainer.classList.add("sr-visually-hidden");
this.elements.volumeContainer.setAttribute("aria-hidden", "false"); this.elements.volumeContainer.setAttribute("aria-hidden", "false");
this.elements.volumeSlider?.setAttribute("aria-label", "音量控制(移动端隐藏,仅无障碍可见)"); this.elements.volumeSlider?.setAttribute(
"aria-label",
"音量控制(移动端隐藏,仅无障碍可见)"
);
} else { } else {
this.elements.volumeContainer.classList.remove("sr-visually-hidden"); this.elements.volumeContainer.classList.remove("sr-visually-hidden");
this.elements.volumeContainer.removeAttribute("aria-hidden"); this.elements.volumeContainer.removeAttribute("aria-hidden");
@@ -765,7 +773,8 @@ class NeteaseMiniPlayer {
tempElement.style.visibility = "hidden"; tempElement.style.visibility = "hidden";
tempElement.style.position = "absolute"; tempElement.style.position = "absolute";
tempElement.style.fontSize = "12px"; tempElement.style.fontSize = "12px";
tempElement.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; tempElement.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
tempElement.textContent = artistText; tempElement.textContent = artistText;
document.body.appendChild(tempElement); document.body.appendChild(tempElement);
const fullWidth = tempElement.offsetWidth; const fullWidth = tempElement.offsetWidth;
@@ -830,7 +839,9 @@ class NeteaseMiniPlayer {
// 如果没有原始 URL尝试从 API 获取 // 如果没有原始 URL尝试从 API 获取
if (!urlData) { if (!urlData) {
const baseUrls = this.config.apiUrls; const baseUrls = this.config.apiUrls;
const apiUrls = baseUrls.map((baseUrl) => `${baseUrl}?server=netease&type=song&id=${songId}`); const apiUrls = baseUrls.map(
(baseUrl) => `${baseUrl}?server=netease&type=song&id=${songId}`
);
for (const url of apiUrls) { for (const url of apiUrls) {
try { try {
@@ -925,8 +936,10 @@ class NeteaseMiniPlayer {
return; return;
} }
// 处理 lrc 数据可能是字符串或对象的情况 // 处理 lrc 数据可能是字符串或对象的情况
const lrcContent = typeof lyricData.lrc === "string" ? lyricData.lrc : lyricData.lrc?.lyric || ""; const lrcContent =
const tlyricContent = typeof lyricData.tlyric === "string" ? lyricData.tlyric : lyricData.tlyric?.lyric || ""; typeof lyricData.lrc === "string" ? lyricData.lrc : lyricData.lrc?.lyric || "";
const tlyricContent =
typeof lyricData.tlyric === "string" ? lyricData.tlyric : lyricData.tlyric?.lyric || "";
const lrcLines = lrcContent.split("\n"); const lrcLines = lrcContent.split("\n");
const tlyricLines = tlyricContent ? tlyricContent.split("\n") : []; const tlyricLines = tlyricContent ? tlyricContent.split("\n") : [];
const lrcMap = new Map(); const lrcMap = new Map();
@@ -957,7 +970,9 @@ class NeteaseMiniPlayer {
} }
} }
}); });
const allTimes = Array.from(new Set([...lrcMap.keys(), ...tlyricMap.keys()])).sort((a, b) => a - b); const allTimes = Array.from(new Set([...lrcMap.keys(), ...tlyricMap.keys()])).sort(
(a, b) => a - b
);
this.lyrics = allTimes.map((time) => ({ this.lyrics = allTimes.map((time) => ({
time, time,
text: lrcMap.get(time) || "", text: lrcMap.get(time) || "",
@@ -1018,7 +1033,9 @@ class NeteaseMiniPlayer {
let newIndex; let newIndex;
if (this.playMode === "shuffle") { if (this.playMode === "shuffle") {
const availableIndices = this.playlist.map((_, i) => i).filter((i) => i !== this.currentIndex); const availableIndices = this.playlist
.map((_, i) => i)
.filter((i) => i !== this.currentIndex);
if (availableIndices.length === 0) { if (availableIndices.length === 0) {
newIndex = this.currentIndex; newIndex = this.currentIndex;
@@ -1409,9 +1426,18 @@ class NeteaseMiniPlayer {
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/); const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
if (hex) { if (hex) {
const hexValue = hex[1]; const hexValue = hex[1];
const r = parseInt(hexValue.length === 3 ? hexValue[0] + hexValue[0] : hexValue.substr(0, 2), 16); const r = parseInt(
const g = parseInt(hexValue.length === 3 ? hexValue[1] + hexValue[1] : hexValue.substr(2, 2), 16); hexValue.length === 3 ? hexValue[0] + hexValue[0] : hexValue.substr(0, 2),
const b = parseInt(hexValue.length === 3 ? hexValue[2] + hexValue[2] : hexValue.substr(4, 2), 16); 16
);
const g = parseInt(
hexValue.length === 3 ? hexValue[1] + hexValue[1] : hexValue.substr(2, 2),
16
);
const b = parseInt(
hexValue.length === 3 ? hexValue[2] + hexValue[2] : hexValue.substr(4, 2),
16
);
const brightness = (r * 299 + g * 587 + b * 114) / 1000; const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128; return brightness < 128;
} }
@@ -1564,13 +1590,17 @@ class NMPv2ShortcodeParser {
} }
processExistingElements(container) { processExistingElements(container) {
container.querySelectorAll(".netease-mini-player:not([data-shortcode-processed])").forEach((element) => { container
.querySelectorAll(".netease-mini-player:not([data-shortcode-processed])")
.forEach((element) => {
element.setAttribute("data-shortcode-processed", "true"); element.setAttribute("data-shortcode-processed", "true");
}); });
} }
initializePlayers(container) { initializePlayers(container) {
container.querySelectorAll(".netease-mini-player:not([data-initialized])").forEach((element) => { container
.querySelectorAll(".netease-mini-player:not([data-initialized])")
.forEach((element) => {
element.setAttribute("data-initialized", "true"); element.setAttribute("data-initialized", "true");
NeteaseMiniPlayer.initPlayer(element); NeteaseMiniPlayer.initPlayer(element);
}); });

View File

@@ -22,7 +22,8 @@ type SendMailPayload = {
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) => { export default defineEventHandler(async (event) => {
const method = event.node.req.method; const method = event.node.req.method;
@@ -46,7 +47,15 @@ export default defineEventHandler(async (event) => {
} }
const config = useRuntimeConfig() as MailConfig; const config = useRuntimeConfig() as MailConfig;
const { smtpHost, smtpPort: configSmtpPort, smtpUser, smtpPass, senderEmail, adminEmail, smtpSecure } = config; const {
smtpHost,
smtpPort: configSmtpPort,
smtpUser,
smtpPass,
senderEmail,
adminEmail,
smtpSecure,
} = config;
const smtpPort = Number(configSmtpPort ?? 465); const smtpPort = Number(configSmtpPort ?? 465);
if (!smtpHost || !smtpUser || !smtpPass || !senderEmail || !adminEmail) { if (!smtpHost || !smtpUser || !smtpPass || !senderEmail || !adminEmail) {

View File

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

View File

@@ -1,160 +0,0 @@
<template>
<section class="card panel">
<h2>个人简介</h2>
<p class="muted">关于我 · About Me</p>
<div class="about-grid info-grid">
<article v-if="age" class="about-card info-card">
<div class="about-head">
<span class="icon">🎂</span>
<h3>年龄</h3>
</div>
<p class="muted">{{ age }} </p>
</article>
<article v-if="profile?.gender" class="about-card info-card">
<div class="about-head">
<span class="icon"></span>
<h3>性别</h3>
</div>
<p class="muted">{{ profile.gender }}</p>
</article>
<article v-if="profile?.pronouns" class="about-card info-card">
<div class="about-head">
<span class="icon">🗣</span>
<h3>代词</h3>
</div>
<p class="muted">{{ profile.pronouns }}</p>
</article>
<article v-if="profile?.location" class="about-card info-card">
<div class="about-head">
<span class="icon">📍</span>
<h3>地区</h3>
</div>
<p class="muted">{{ profile.location }}</p>
</article>
</div>
<div class="about-grid">
<article v-for="item in items" :key="item.title" class="about-card">
<div class="about-head">
<span class="icon">{{ item.icon }}</span>
<h3>{{ item.title }}</h3>
</div>
<p class="muted">{{ item.desc }}</p>
</article>
</div>
</section>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
items: Array,
profile: Object,
});
const age = computed(() => {
if (!props.profile?.birthday) return null;
const birthDate = new Date(props.profile.birthday);
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>
<style scoped>
.panel {
display: flex;
flex-direction: column;
gap: 10px;
}
h2 {
margin: 0 0 4px;
}
.muted {
margin: 0 0 12px;
display: block;
}
.about-grid {
display: grid;
grid-template-columns: repeat(2, minmax(240px, 1fr));
gap: 14px;
}
.info-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
}
.about-grid + .about-grid {
margin-top: 10px;
}
@media (max-width: 720px) {
.about-grid {
grid-template-columns: 1fr;
}
.info-grid {
display: flex;
gap: 10px;
}
}
.about-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 12px 14px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.info-card {
flex: 1 1 140px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
}
.info-card .about-head {
margin-bottom: 0;
}
.info-card .about-head h3 {
font-size: 15px;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
margin: 0;
}
.info-card .icon {
font-size: 18px;
}
.info-card .muted {
margin: 0;
text-align: right;
white-space: nowrap;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.about-card:hover {
transform: translateY(-2px);
border-color: rgba(124, 193, 255, 0.4);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.24);
}
.about-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.icon {
font-size: 20px;
line-height: 1;
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<footer class="card footer">
<p class="muted" v-if="showHitokoto && quote">
{{ quote }}<span v-if="from" class="from"> {{ from }}</span>
</p>
<p class="muted stats" v-if="showStats && !statsError">
👁 {{ visitors }} visitors · 📊 {{ pageviews }} pageviews
</p>
<!-- <p class="muted stats" v-if="showStats && statsError">🔒 由于启用了隐私保护拓展禁用状态统计</p> -->
<p class="muted beian" v-if="contact.beian">
<a :href="contact.beianLink || 'https://beian.miit.gov.cn/'" target="_blank" rel="noreferrer">
{{ contact.beian }}
</a>
</p>
<div class="custom-html" v-if="contact.customHtml" v-html="contact.customHtml"></div>
</footer>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRuntimeConfig } from "#imports";
import siteConfig from "~/config/siteConfig";
const props = defineProps({ contact: Object });
const config = useRuntimeConfig();
const quote = ref("");
const from = ref("");
const pageviews = ref(0);
const visitors = ref(0);
const statsError = ref(true);
const showHitokoto = siteConfig.footer?.hitokoto?.enable;
const showStats = ref(siteConfig.umami?.enable);
const buildHitokotoUrl = () => {
const type = siteConfig.footer?.hitokoto?.type;
const url = new URL("https://v1.hitokoto.cn/");
if (Array.isArray(type)) {
type.filter(Boolean).forEach((t) => url.searchParams.append("c", t));
} else if (typeof type === "string") {
type.split("&")
.map((t) => t.trim())
.filter(Boolean)
.forEach((t) => url.searchParams.append("c", t));
}
return url.toString();
};
const fetchHitokoto = async () => {
try {
const resp = await fetch(buildHitokotoUrl());
const data = await resp.json();
quote.value = data.hitokoto || "";
from.value = data.from || "";
} catch (e) {
console.warn("Hitokoto fetch failed", e);
}
};
const fetchStats = async () => {
try {
if (!siteConfig.umami?.apiBase || !siteConfig.umami?.websiteId) {
return;
}
const apiBase = siteConfig.umami.apiBase;
const websiteId = siteConfig.umami.websiteId;
const apiKey = config.public.umamiApiKey;
if (!apiKey) return;
// 获取统计数据
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: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!resp.ok) {
console.warn(`Stats API returned ${resp.status}`);
statsError.value = true;
return;
}
const data = await resp.json();
if (data) {
statsError.value = false;
pageviews.value = data.pageviews;
visitors.value = data.visitors;
}
if (pageviews.value === 0 && visitors.value === 0) {
showStats.value = false;
}
} catch (e) {
statsError.value = true;
console.debug("Stats fetch failed (this is normal if blocked by ad blocker):", e.message);
}
};
onMounted(() => {
if (showHitokoto) fetchHitokoto();
if (showStats.value) fetchStats();
});
</script>
<style scoped>
.footer {
text-align: center;
margin-top: auto;
width: 100%;
}
.from {
margin-left: 6px;
}
.beian {
font-size: 12px;
margin: 6px 0;
letter-spacing: 0.2px;
}
.beian a {
color: inherit;
opacity: 0.85;
padding: 3px 8px;
border-radius: 8px;
transition: color 0.2s ease, background 0.2s ease, opacity 0.2s ease;
}
.beian a:hover {
color: var(--accent, #7cc1ff);
background: rgba(124, 193, 255, 0.1);
opacity: 1;
}
.custom-html {
margin-top: 6px;
}
.stats {
font-size: 12px;
margin: 6px 0;
letter-spacing: 0.2px;
}
</style>

View File

@@ -1,347 +0,0 @@
<template>
<section class="card panel">
<h2>友情链接</h2>
<p class="muted">欢迎互换友链 · Friends</p>
<div class="actions">
<button class="primary" @click="openForm = !openForm">{{ openForm ? "收起申请" : "申请友链" }}</button>
</div>
<form v-if="openForm" class="friend-form" @submit.prevent="submitForm">
<label>
网站名称 *
<input v-model="form.name" required placeholder="Exmaple Site" />
</label>
<label>
网站链接 *
<input v-model="form.url" type="url" required placeholder="https://example.com" />
</label>
<label>
网站描述
<input v-model="form.desc" placeholder="可选" />
</label>
<label>
联系邮箱 *
<input v-model="form.email" type="email" required placeholder="you@example.com" />
</label>
<label>
头像链接
<input v-model="form.avatar" type="url" placeholder="可选,展示头像" />
</label>
<label>
想说的话
<div class="textarea-wrapper">
<textarea v-model="form.message" placeholder="可选最多50字" maxlength="50"></textarea>
<span class="char-count">{{ form.message?.length || 0 }}/50</span>
</div>
</label>
<div class="form-actions">
<button type="submit" class="primary" :disabled="loading">
{{ loading ? "提交中..." : "提交申请" }}
</button>
<span class="muted" v-if="message">{{ message }}</span>
</div>
</form>
<div class="card-grid">
<article v-for="f in displayedFriends" :key="f.url" class="info-card">
<div class="card-head">
<div class="title-wrap">
<img v-if="f.avatar" class="avatar" :src="f.avatar" :alt="f.name" loading="lazy" />
<h3>{{ f.name }}</h3>
</div>
<span class="pill tertiary">友链</span>
</div>
<p class="muted">{{ f.desc || "一个有趣的站点" }}</p>
<a :href="f.url" target="_blank" rel="noreferrer" class="link-btn">访问 </a>
</article>
</div>
</section>
<Teleport to="body">
<div v-if="showDialog" class="dialog-backdrop" @click.self="closeDialog">
<div class="dialog-card">
<h3>{{ dialogTitle }}</h3>
<p class="muted">{{ dialogText }}</p>
<div class="dialog-actions">
<button class="primary" @click="closeDialog">好的</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { reactive, ref, watch } from "vue";
const props = defineProps({ friends: { type: Array, default: () => [] } });
const openForm = ref(false);
const loading = ref(false);
const message = ref("");
const showDialog = ref(false);
const dialogTitle = ref("");
const dialogText = ref("");
const form = reactive({
name: "",
url: "",
desc: "",
email: "",
avatar: "",
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 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({
name: form.name,
url: form.url,
desc: form.desc,
email: form.email,
avatar: form.avatar,
message: form.message,
}),
});
if (!resp.ok) throw new Error("send failed");
Object.keys(form).forEach((k) => (form[k] = ""));
message.value = "提交成功,已发送申请邮件";
openForm.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>
<style scoped>
.panel {
display: flex;
flex-direction: column;
gap: 10px;
}
h2 {
margin: 0 0 4px;
}
.muted {
margin: 0 0 12px;
display: block;
}
.actions {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.primary {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(124, 193, 255, 0.5);
background: rgba(124, 193, 255, 0.12);
color: #e8eefc;
cursor: pointer;
}
.tip {
font-size: 12px;
}
.friend-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
margin-bottom: 12px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(124, 193, 255, 0.06));
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
}
.friend-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
color: #e8eefc;
font-weight: 600;
}
.friend-form input {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
color: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.textarea-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.friend-form textarea {
flex: 1;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
color: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
font-family: inherit;
height: 36px;
resize: none;
overflow: auto;
}
.friend-form input::placeholder,
.friend-form textarea::placeholder {
color: rgba(232, 238, 252, 0.7);
}
.friend-form input:focus,
.friend-form textarea:focus {
outline: none;
border-color: rgba(124, 193, 255, 0.8);
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 2px rgba(124, 193, 255, 0.25);
}
.char-count {
font-size: 12px;
color: rgba(232, 238, 252, 0.6);
white-space: nowrap;
flex-shrink: 0;
}
.form-actions {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
}
.form-actions .primary {
min-width: 120px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(320px, 1fr));
gap: 16px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
}
@media (max-width: 720px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.info-card {
background: linear-gradient(135deg, rgba(244, 151, 218, 0.12), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.info-card:hover {
transform: translateY(-3px);
border-color: rgba(244, 151, 218, 0.55);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.title-wrap h3 {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill {
padding: 4px 10px;
border-radius: 999px;
background: rgba(244, 151, 218, 0.16);
color: #f497da;
font-size: 12px;
}
.pill.tertiary {
background: rgba(155, 140, 252, 0.14);
color: #9b8cfc;
}
.link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
color: #f497da;
font-weight: 600;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.dialog-card {
min-width: 280px;
max-width: 420px;
background: linear-gradient(135deg, rgba(244, 151, 218, 0.12), rgba(255, 255, 255, 0.05));
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
padding: 16px 18px;
box-shadow: 0 16px 46px rgba(0, 0, 0, 0.28);
}
.dialog-card h3 {
margin: 0 0 8px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -1,132 +0,0 @@
<template>
<section class="card">
<h2>GitHub</h2>
<div class="heatmap">
<h3>提交热力图</h3>
<p class="muted">我的提交热力图 · Acitivity Heatmap</p>
<img :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy" />
</div>
<div class="lang-wrap">
<h3>常用语言</h3>
<p class="muted">我常用的语言 · Languages</p>
<div class="lang-chart">
<ul class="list lang-list">
<li v-for="lang in topLanguages" :key="lang.name" class="lang-row">
<div class="lang-label">
<span class="dot" :style="{ background: colorFor(lang.name) }"></span>
<span class="lang-name">{{ lang.name }}</span>
<span class="lang-percent">{{ lang.percent }}%</span>
</div>
<div class="lang-bar">
<span class="lang-bar-fill" :style="barStyle(lang)"></span>
</div>
</li>
</ul>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({ github: Object });
const github = props.github;
const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
const topLanguages = computed(() => (Array.isArray(github.languages) ? github.languages.slice(0, 5) : []));
const colorFor = (name) => {
const idx = github.languages.findIndex((l) => l.name === name);
return palette[(idx >= 0 ? idx : 0) % palette.length];
};
const barStyle = (lang) => ({
width: `${Math.max(8, lang.percent)}%`,
background: colorFor(lang.name),
});
</script>
<style scoped>
.panel {
display: flex;
flex-direction: column;
gap: 10px;
}
h2 {
margin: 0 0 4px;
}
h3 {
margin: 0 0 4px;
}
.muted {
margin: 0 0 12px;
display: block;
}
.lang-wrap {
margin-top: 12px;
}
.lang-chart {
display: block;
}
.lang-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.lang-row {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 8px 10px;
}
.lang-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.lang-name {
color: #e8eefc;
}
.lang-percent {
color: #a8b3cf;
font-size: 0.9rem;
}
.lang-bar {
margin-top: 6px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.lang-bar-fill {
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);
}
@media (max-width: 640px) {
.lang-chart {
grid-template-columns: 1fr;
}
.pie {
margin: 0 auto;
}
}
</style>

View File

@@ -1,32 +0,0 @@
<template>
<section class="card hero">
<img class="avatar" :src="profile.avatar" alt="avatar" />
<div>
<h1>{{ profile.name }}</h1>
<p class="muted">{{ profile.title }}</p>
<p>{{ profile.bio }}</p>
</div>
</section>
</template>
<script setup>
defineProps({ profile: Object });
</script>
<style scoped>
.hero {
display: grid;
grid-template-columns: 120px 1fr;
gap: 16px;
align-items: center;
}
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--accent);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
background: #fff;
}
</style>

View File

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

View File

@@ -1,93 +0,0 @@
<template>
<div class="pager">
<button :disabled="currentIndex <= 0" @click="goPrev">上一页</button>
<div class="dots">
<button
v-for="item in pages"
:key="item.name"
:class="{ active: item.name === route.name }"
@click="router.push({ name: item.name })"
>
{{ item.label }}
</button>
</div>
<button :disabled="currentIndex >= pages.length - 1" @click="goNext">下一页</button>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
const router = useRouter();
const route = useRoute();
const pages = [
{ name: "index", label: "首页" },
{ name: "about", label: "关于" },
{ name: "sites", label: "网站" },
{ name: "projects", label: "项目" },
{ name: "friends", label: "友链" },
];
const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name));
const goPrev = () => {
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>
<style scoped>
.pager {
margin: 16px auto 24px;
max-width: 960px;
width: 100%;
padding: 12px 16px;
box-sizing: border-box;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
}
button {
background: rgba(255, 255, 255, 0.08);
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 8px 12px;
cursor: pointer;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.dots {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.dots button {
padding: 8px 10px;
}
.dots .active {
border-color: var(--accent, #7cc1ff);
color: var(--accent, #7cc1ff);
}
@media (max-width: 640px) {
.pager {
grid-template-columns: 1fr;
}
.dots {
justify-content: center;
}
}
</style>

View File

@@ -1,86 +0,0 @@
<template>
<section class="card panel">
<h2>项目作品</h2>
<p class="muted">一些正在维护或已发布的项目 · Projects</p>
<div class="card-grid">
<article v-for="p in projects" :key="p.url" class="info-card">
<div class="card-head">
<h3>{{ p.name }}</h3>
<span class="pill secondary">项目</span>
</div>
<p class="muted">{{ p.desc }}</p>
<a :href="p.url" target="_blank" rel="noreferrer" class="link-btn">查看仓库 </a>
</article>
</div>
</section>
</template>
<script setup>
defineProps({ projects: Array });
</script>
<style scoped>
.panel {
display: flex;
flex-direction: column;
gap: 10px;
}
h2 {
margin: 0 0 4px;
}
.muted {
margin: 0 0 12px;
display: block;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(320px, 1fr));
gap: 16px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
}
@media (max-width: 720px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.info-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.info-card:hover {
transform: translateY(-3px);
border-color: rgba(255, 209, 102, 0.5);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.pill {
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 209, 102, 0.16);
color: #ffd166;
font-size: 12px;
}
.pill.secondary {
background: rgba(124, 193, 255, 0.14);
color: #7cc1ff;
}
.link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
color: #ffd166;
font-weight: 600;
}
</style>

View File

@@ -1,97 +0,0 @@
<template>
<section class="card panel">
<h2>我的网站</h2>
<p class="muted">正在运行的站点 · Websites</p>
<div class="card-grid">
<article v-for="site in sites" :key="site.url" class="info-card">
<div class="card-head">
<div class="title-wrap">
<h3>{{ site.name }}</h3>
</div>
<span class="pill">在线</span>
</div>
<p class="muted">{{ site.desc || "点击访问了解更多" }}</p>
<a :href="site.url" target="_blank" rel="noreferrer" class="link-btn">查看 </a>
</article>
</div>
</section>
</template>
<script setup>
defineProps({ sites: Array });
</script>
<style scoped>
.panel {
display: flex;
flex-direction: column;
gap: 10px;
}
h2 {
margin: 0 0 4px;
}
.muted {
margin: 0 0 12px;
display: block;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(320px, 1fr));
gap: 16px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
}
@media (max-width: 720px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.info-card {
background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.info-card:hover {
transform: translateY(-3px);
border-color: rgba(124, 193, 255, 0.4);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.title-wrap h3 {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill {
padding: 4px 10px;
border-radius: 999px;
background: rgba(124, 193, 255, 0.12);
color: #7cc1ff;
font-size: 12px;
}
.link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
color: #7cc1ff;
font-weight: 600;
}
</style>

View File

@@ -1,81 +0,0 @@
<template>
<section class="card panel">
<div class="head">
<h2>技能专长</h2>
<p class="muted">我常用的工具与技术 · Skills & Technologies</p>
</div>
<div class="grid">
<article v-for="group in skills" :key="group.title" class="skill-card">
<header>
<h3>{{ group.title }}</h3>
</header>
<div class="tags">
<span v-for="item in group.items" :key="item" class="tag">
<img :src="iconSrc(item)" :alt="item" :title="item" loading="lazy" />
</span>
</div>
</article>
</div>
</section>
</template>
<script setup>
defineProps({ skills: { type: Array, default: () => [] } });
const iconSrc = (id) => `https://skillicons.dev/icons?i=${encodeURIComponent(id)}&theme=dark`;
</script>
<style scoped>
.panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.head h2 {
margin: 0 0 4px;
}
.head p {
margin: 0 0 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 12px;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
.skill-card {
background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 12px 14px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.skill-card:hover {
transform: translateY(-2px);
border-color: rgba(124, 193, 255, 0.4);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.24);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.tag {
display: inline-flex;
align-items: center;
padding: 6px;
border-radius: 12px;
background: rgba(124, 193, 255, 0.14);
border: 1px solid rgba(124, 193, 255, 0.18);
}
.tag img {
width: 28px;
height: 28px;
border-radius: 8px;
}
</style>

View File

@@ -1,96 +0,0 @@
<template>
<section class="card panel">
<h2>社交链接</h2>
<p class="muted">社交账号 · Links</p>
<div class="chips">
<a v-for="link in links" :key="link.url" :href="link.url" target="_blank" rel="noreferrer">
<span v-if="iconFor(link)" class="icon">
<i v-if="iconFor(link).fa" :class="iconFor(link).fa"></i>
<img v-else :src="iconFor(link).src" :alt="link.name" loading="lazy" />
</span>
<span>{{ link.name }}</span>
</a>
</div>
</section>
</template>
<script setup>
import { onMounted } from "vue";
const props = defineProps({ links: Array });
const iconMap = {
bilibili: "fa-brands fa-bilibili",
github: "fa-brands fa-github",
blog: "fa-solid fa-rss",
email: "fa-solid fa-envelope",
mail: "fa-solid fa-envelope",
telegram: "fa-brands fa-telegram",
twitter: "fa-brands fa-x-twitter",
x: "fa-brands fa-x-twitter",
linkedin: "fa-brands fa-linkedin",
youtube: "fa-brands fa-youtube",
facebook: "fa-brands fa-facebook",
instagram: "fa-brands fa-instagram",
reddit: "fa-brands fa-reddit",
discord: "fa-brands fa-discord",
weibo: "fa-brands fa-weibo",
zhihu: "fa-brands fa-zhihu",
wechat: "fa-brands fa-weixin",
weixin: "fa-brands fa-weixin",
qq: "fa-brands fa-qq",
};
const iconFor = (link) => {
const key = (link.name || "").toLowerCase();
if (iconMap[key]) return { fa: iconMap[key] };
if (link.icon) return { src: link.icon };
return null;
};
onMounted(() => {
const id = "fa-cdn";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css";
link.crossOrigin = "anonymous";
link.referrerPolicy = "no-referrer";
document.head.appendChild(link);
});
</script>
<style scoped>
h2 {
margin: 0 0 4px;
}
.muted {
margin: 0 0 12px;
display: block;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
a {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.icon {
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
}
.icon i,
.icon img {
width: 18px;
height: 18px;
}
</style>

View File

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

View File

@@ -1,147 +0,0 @@
const siteConfig = {
profile: {
name: "RhenCloud",
title: "I'm RhenCloud.",
avatar: "/avatar.webp", // public/avatar.webp
bio: "趁世界还未重启之前 约一次爱恋",
birthday: "2010-03-28",
// gender: "女",
pronouns: "她",
location: "中国 · 天津",
},
socialLinks: [
{ name: "GitHub", url: "https://github.com/RhenCloud" },
{ name: "Email", url: "mailto:i@rhen.cloud" },
{ name: "Telegram", url: "https://t.me/RhenCloud" },
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
{ name: "Blog", url: "https://blog.rhen.cloud" },
],
github: {
username: "RhenCloud",
},
about: [
{ title: "Pro-LGBT", desc: "我相信性别多样性是人们应有的自由和权利。", icon: "🧠" },
{ title: "Developer", desc: "专注后端 / 云原生,热爱自动化与高可用。", icon: "🛠️" },
{ title: "Anime Fan", desc: "二次元爱好者,享受故事与想象力。", icon: "🎬" },
{ title: "Just For Fun", desc: "我喜欢尝试新鲜事物,折腾小众技术", icon: "🎮" },
],
siteMeta: {
title: "RhenCloud",
icon: "/favicon.svg", // public/favicon.svg
startDate: "2025-12-06",
},
appearance: {
background: {
enable: true,
// URL 支持:可使用外部 URL 或本地路径
// 例如: "https://example.com/bg.jpg" 或 "background.webp"
image: "background.png", // 背景图片 URL 或本地路径(桌面端)
mobileImage: "https://www.loliapi.com/acg/pe/", // 移动端背景图片(可选,不设置则使用 image
blur: 0, // 背景模糊程度 (0-100)
overlay: "rgba(70, 59, 82, 0.4)", // 背景遮罩颜色与透明度
},
},
music: {
enable: true,
// 浮动模式播放器(推荐)- 用于播放网易云歌单
mode: "floating", // "floating" 或 "embed"
// 歌单ID从网易云音乐链接获取如 https://music.163.com/#/playlist?id=14273792576
playlistId: "14366453940", // 例如: "14273792576"
// 歌曲ID仅在嵌入模式下使用
songId: undefined, // 例如: "554242291"
// 播放器位置(浮动模式): "bottom-left" | "bottom-right" | "top-left" | "top-right"
position: "bottom-left",
// 是否显示歌词
lyric: true,
// 主题: "light" | "dark" | "auto"
theme: "dark",
// 是否自动播放
autoplay: false,
// 是否默认以黑胶唱片状态启动(仅浮动模式)
defaultMinimized: true,
// 标签页非激活时是否自动暂停
autoPause: false,
// Music API 配置
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
},
umami: {
enable: true,
url: "https://cloud.umami.is/script.js",
websiteId: "ddcd51c3-ccc7-45e4-81e6-11567027f69b",
apiBase: "https://api.umami.is",
},
wakatime: {
enable: true,
apiUrl: "https://wakapi.rhen.cloud/api/v1",
},
skills: [
{ 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: "操作系统", items: ["arch", "linux", "windows"] },
],
sites: [
{
name: "个人主页",
desc: "个人主页",
url: "https://rhen.cloud",
},
{
name: "我的博客",
desc: "分享与记录",
url: "https://blog.rhen.cloud",
},
{
name: "来视奸我",
desc: "使用Sleepy项目搭建的视奸网站",
url: "https://sleepy.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: "跨平台、多网站、模块化的小说下载器" },
],
friends: [
{
name: "wuxian",
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: "鈴奈咲桜のBlog",
desc: "一个普普通通的Blog",
url: "https://blog.sakura.ink",
avatar: "https://q2.qlogo.cn/headimg_dl?dst_uin=2731443459&spec=5",
},
],
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,75 +0,0 @@
<template>
<main class="page">
<HeroSection :profile="profile" />
<SkillsSection :skills="skills" />
<StatsSection :github="github" :wakatime="wakatime" />
</main>
</template>
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import { useRuntimeConfig, definePageMeta } from "#imports";
import HeroSection from "~/components/HeroSection.vue";
import SkillsSection from "~/components/SkillsSection.vue";
import StatsSection from "~/components/StatsSection.vue";
import siteConfig from "@/config/siteConfig";
const profile = siteConfig.profile;
const skills = siteConfig.skills;
const wakatime = siteConfig.wakatime;
const config = useRuntimeConfig();
const githubToken = config.public.githubToken ?? "";
type GithubHeatmap = {
username: string;
heatmapUrl: string;
languages?: { name: string; percent: number }[];
};
const github = reactive<GithubHeatmap>({
...siteConfig.github,
heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`,
languages: [],
});
definePageMeta({
order: 1,
label: "关于",
});
onMounted(() => {
fetchGithubMeta();
});
async function fetchGithubMeta() {
try {
const headers: HeadersInit = {};
if (githubToken) {
headers.Authorization = `Bearer ${githubToken}`;
}
const resp = await fetch(`https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`, {
headers,
});
const data = await resp.json();
if (!Array.isArray(data)) return;
type GithubRepo = { language?: string };
const repos = data as GithubRepo[];
const counts: Record<string, number> = {};
repos.forEach((repo) => {
if (!repo.language) return;
counts[repo.language] = (counts[repo.language] || 0) + 1;
});
const total = Object.values(counts).reduce((sum, value) => sum + value, 0) || 1;
const parsed = Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
github.languages = parsed.map((item) => ({
name: item.name,
percent: Math.round((item.count / total) * 100),
}));
} catch (error) {
console.error("Failed to fetch GitHub metadata:", error);
}
}
</script>

View File

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

View File

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

View File

@@ -1,289 +0,0 @@
html {
height: 100%;
}
:root {
color-scheme: light dark;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f1629;
color: #e8eefc;
}
body {
margin: 0;
min-height: 100%;
background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629);
}
/* Layout shell keeps background and toggle visible even when content is hidden */
.app-shell {
position: relative;
min-height: 100vh;
color: #e8eefc;
overflow: hidden;
display: flex;
flex-direction: column;
isolation: isolate;
}
.background-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
background-repeat: no-repeat;
}
.content-stack {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-body {
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
}
a {
color: #7cc1ff;
text-decoration: none;
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 32px 16px 48px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 18px 20px;
backdrop-filter: blur(8px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
}
.hero {
display: grid;
grid-template-columns: 96px 1fr;
gap: 16px;
align-items: center;
}
.avatar {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.2);
}
h1,
h2,
h3 {
margin: 0 0 8px 0;
}
p {
margin: 6px 0;
}
.muted {
color: #a8b3cf;
font-size: 0.95rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chips a {
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.list li {
margin-bottom: 8px;
}
.footer {
text-align: center;
margin-top: 8px;
}
/* Ensure the Netease mini player doesn't occupy page layout space
and stays fixed above content. Use !important to override
styles injected by third-party scripts. */
.netease-mini-player {
position: fixed !important;
bottom: 20px !important;
left: 20px !important;
right: auto !important;
/* do not force width here — let the player's own minimized/expanded
styles control sizing. Only constrain max width as a safety net. */
max-width: calc(100% - 40px) !important;
z-index: 10001 !important;
margin: 0 !important;
transform: none !important;
}
/* If the player injects a full-width bar, make sure it won't
push page content by forcing it out of the normal flow. */
.netease-mini-player > * {
box-sizing: border-box;
}
/* Defensive: hide any accidental full-width injected container from the player
that might occupy vertical space. This targets common helper classes used
by the player script. */
.netease-mini-player-embed,
.nmpv2-player,
.nmpv2-root {
position: fixed !important;
bottom: 20px !important;
left: 20px !important;
right: auto !important;
z-index: 10001 !important;
}
/* Fix: prevent playlist dropdown from increasing document height
by forcing the playlist container to be fixed and clipped to viewport. */
.netease-mini-player[data-position="bottom-left"] .playlist-container,
.netease-mini-player[data-position="bottom-right"] .playlist-container {
position: fixed !important;
bottom: calc(20px + 80px) !important;
left: 20px !important;
right: auto !important;
width: 290px !important;
max-height: 50vh !important;
overflow: auto !important;
z-index: 10002 !important;
}
.netease-mini-player[data-position="top-left"] .playlist-container,
.netease-mini-player[data-position="top-right"] .playlist-container {
position: fixed !important;
top: calc(20px + 80px) !important;
left: 20px !important;
right: auto !important;
width: 290px !important;
max-height: 50vh !important;
overflow: auto !important;
z-index: 10002 !important;
}
/* If player is docked to the right, align playlist to right edge */
.netease-mini-player[data-position="bottom-right"] .playlist-container,
.netease-mini-player[data-position="top-right"] .playlist-container {
left: auto !important;
right: 20px !important;
}
/* Ensure minimized player displays as a circle and its album cover fits */
.netease-mini-player.minimized {
width: 80px !important;
height: 80px !important;
border-radius: 50% !important;
padding: 0 !important;
overflow: hidden !important;
box-shadow: none !important;
}
.netease-mini-player.minimized .album-cover-container {
width: 100% !important;
height: 100% !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
border-radius: 50% !important;
overflow: hidden !important;
}
.netease-mini-player.minimized .album-cover {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
border-radius: 50% !important;
}
/* Floating background show/hide toggle (bottom-right) */
.background-toggle {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 10000;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(10px);
color: #f7fbff;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.background-toggle:hover,
.background-toggle:focus-visible {
background: rgba(124, 193, 255, 0.25);
border-color: rgba(124, 193, 255, 0.65);
box-shadow: 0 14px 36px rgba(124, 193, 255, 0.28);
outline: none;
}
.background-toggle:active {
transform: translateY(1px) scale(0.99);
}
.background-toggle .toggle-icon {
font-size: 18px;
line-height: 1;
}
.background-toggle .toggle-label {
font-size: 14px;
letter-spacing: 0.2px;
}
.background-toggle.active {
background: linear-gradient(135deg, rgba(124, 193, 255, 0.4), rgba(255, 255, 255, 0.2));
border-color: rgba(124, 193, 255, 0.8);
color: #0f1629;
box-shadow: 0 16px 42px rgba(124, 193, 255, 0.32);
}
@media (max-width: 640px) {
.background-toggle {
right: 12px;
bottom: 12px;
padding: 9px 12px;
gap: 5px;
}
.background-toggle .toggle-label {
font-size: 13px;
}
}

7
src/vite-env.d.ts vendored
View File

@@ -1,7 +0,0 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

51
tailwind.config.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./app/**/*.{vue,js,ts}",
"./app/components/**/*.vue",
"./app/pages/**/*.vue",
"./app/layouts/**/*.vue",
],
theme: {
extend: {
colors: {
// 自定义颜色变量(对应现有的 CSS 变量)
primary: "rgb(124, 193, 255)",
accent: "rgb(124, 193, 255)",
"surface-primary": "rgb(15, 22, 41)",
"surface-secondary": "rgb(27, 43, 75)",
"text-primary": "rgb(232, 238, 252)",
"text-secondary": "rgb(159, 172, 200)",
"text-muted": "rgb(104, 120, 152)",
},
fontFamily: {
sans: [
'"Inter"',
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
'"Segoe UI"',
"sans-serif",
],
},
spacing: {
"safe-x": "max(1rem, env(safe-area-inset-left))",
"safe-y": "max(1rem, env(safe-area-inset-top))",
},
boxShadow: {
"sm-dark": "0 4px 12px rgba(0, 0, 0, 0.15)",
"md-dark": "0 8px 24px rgba(0, 0, 0, 0.18)",
"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

@@ -3,5 +3,8 @@
"compilerOptions": { "compilerOptions": {
"types": ["node"] "types": ["node"]
}, },
"include": ["nuxt.config.ts", "src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "server/**/*.ts"] "vueCompilerOptions": {
"globalTypesPath": "./node_modules/.vue-global-types"
},
"include": ["nuxt.config.ts", "app/**/*.ts", "app/**/*.vue", "app/**/*.d.ts", "server/**/*.ts"]
} }