mirror of
https://github.com/RhenCloud/Cloud-Home.git
synced 2026-01-22 17:39:07 +08:00
Compare commits
16 Commits
a6d4c8a27b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c9b4d3c8 | |||
| ab58d2e251 | |||
| cb1406661a | |||
| 8a2bbacaa3 | |||
| 20eebcca4f | |||
| f9e624d48e | |||
| 6edb6af6ee | |||
| 53685b1531 | |||
| 67708a34bc | |||
| 1c05fd7b1e | |||
| 4d8644629b | |||
| 618723a689 | |||
| 6b05f7c74e | |||
| bda4281fde | |||
| 6cc98acd88 | |||
| ba95a16f21 |
80
.github/workflows/lint-format.yml
vendored
Normal file
80
.github/workflows/lint-format.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -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
18
.prettierignore
Normal 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
12
.prettierrc
Normal 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"
|
||||||
|
}
|
||||||
265
README.md
265
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Cloud Home
|
# Cloud Home
|
||||||
|
|
||||||
一个基于 Nuxt 3 (Vue 3) 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
|
一款基于 Nuxt 4 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
@@ -11,10 +11,16 @@
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 前端:Nuxt 3(Vue 3)+ HTML + CSS
|
- 前端:Nuxt 4 + Tailwind CSS
|
||||||
- 构建 / 运行:Nuxt 3 + Nitro
|
- 构建 / 运行:Nuxt 4 + Nitro
|
||||||
- 部署:Vercel(Nuxt 构建 + Nitro 函数)
|
- 部署:Vercel(Nuxt 构建 + Nitro 函数)
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] 增加主题色配置
|
||||||
|
- [ ] 增加追番模块
|
||||||
|
- [ ] 增加留言板模块
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
排名不分先后
|
排名不分先后
|
||||||
@@ -32,132 +38,143 @@
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const siteConfig: SiteConfig = {
|
const siteConfig: SiteConfig = {
|
||||||
profile: {
|
profile: {
|
||||||
name: "Example User", // 你的名字
|
name: "Example User", // 你的名字
|
||||||
title: "I'm a software developer.", // 你的简介,可为空
|
title: "I'm a software developer.", // 你的简介,可为空
|
||||||
avatar: "/avatar.webp", // 你的头像,可为public目录下的文件或外部链接
|
avatar: "/avatar.webp", // 你的头像,可为public目录下的文件或外部链接
|
||||||
bio: "Hello World", // 你的喜欢的一句话,可为空
|
bio: "Hello World", // 你的喜欢的一句话,可为空
|
||||||
birthday: "xxxx-xx-xx", // 你的生日,可为空
|
birthday: "xxxx-xx-xx", // 你的生日,可为空
|
||||||
gender: "", // 你的性别,可为空
|
gender: "", // 你的性别,可为空
|
||||||
pronouns: "", // 你希望别人如何称呼你,可为空
|
pronouns: "", // 你希望别人如何称呼你,可为空
|
||||||
location: "", // 你的居住地,可为空
|
location: "", // 你的居住地,可为空
|
||||||
|
},
|
||||||
|
|
||||||
|
// 社交链接,预定义的社交链接可在 `src/components/SocialLink.vue` 中查阅
|
||||||
|
socialLinks: [
|
||||||
|
{ name: "GitHub", url: "https://github.com/ExampleUser" },
|
||||||
|
{ name: "Email", url: "mailto:you@example.com" },
|
||||||
|
{ name: "Telegram", url: "https://t.me/ExampleUser" },
|
||||||
|
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
|
||||||
|
{ name: "Blog", url: "https://blog.example.com" },
|
||||||
|
],
|
||||||
|
|
||||||
|
github: {
|
||||||
|
username: "ExampleUser", // 你的 GitHub 用户名
|
||||||
|
},
|
||||||
|
|
||||||
|
// 个人介绍卡片
|
||||||
|
about: [
|
||||||
|
{ title: "Example", desc: "Example description", icon: "🧠" },
|
||||||
|
{ title: "Example", desc: "Example description", icon: "🛠️" },
|
||||||
|
{ title: "Example", desc: "Example description", icon: "🎬" },
|
||||||
|
{ title: "Example", desc: "Example description", icon: "🎮" },
|
||||||
|
],
|
||||||
|
|
||||||
|
siteMeta: {
|
||||||
|
title: "Example Title", // 网站标题
|
||||||
|
icon: "/favicon.ico", // 网站图标,可为public目录下的文件或外部链接
|
||||||
|
startDate: "xxxx-xx-xx", // 网站创建日期
|
||||||
|
},
|
||||||
|
|
||||||
|
music: {
|
||||||
|
// 是否启用音乐播放器
|
||||||
|
enable: true,
|
||||||
|
// floating - 浮动模式播放器(推荐)- 用于播放网易云歌单
|
||||||
|
// embed - 嵌入模式播放器 - 用于播放网易云单曲
|
||||||
|
mode: "floating", // "floating" 或 "embed"
|
||||||
|
// 歌单ID:从网易云音乐链接获取,如 https://music.163.com/#/playlist?id=14273792576
|
||||||
|
playlistId: undefined, // 例如: "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, // 是否启用 Umami 分析
|
||||||
|
url: "https://cloud.umami.is/script.js", // Umami 分析脚本 URL,一般无需修改
|
||||||
|
websiteId: "YOUR_WEBSITE_ID", // Umami 网站 ID
|
||||||
|
apiBase: "https://api.umami.is", // Umami API 地址,一般无需修改
|
||||||
|
},
|
||||||
|
|
||||||
|
wakatime: {
|
||||||
|
enable: true, // 是否启用 Wakatime 统计
|
||||||
|
apiUrl: "https://wakatime.com/api/v1", // Wakatime API 地址,默认官方地址
|
||||||
|
},
|
||||||
|
|
||||||
|
// 技能图标展示,详见https://github.com/tandpfun/skill-icons#icons-list
|
||||||
|
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"] },
|
||||||
|
],
|
||||||
|
|
||||||
// 社交链接,预定义的社交链接可在 `src/components/SocialLink.vue` 中查阅
|
sites: [
|
||||||
socialLinks: [
|
{
|
||||||
{ name: "GitHub", url: "https://github.com/ExampleUser" },
|
name: "Example Site 1",
|
||||||
{ name: "Email", url: "mailto:you@example.com" },
|
desc: "Example Site 1",
|
||||||
{ name: "Telegram", url: "https://t.me/ExampleUser" },
|
url: "https://example1.com",
|
||||||
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
|
|
||||||
{ name: "Blog", url: "https://blog.example.com" },
|
|
||||||
],
|
|
||||||
|
|
||||||
github: {
|
|
||||||
username: "ExampleUser", // 你的 GitHub 用户名
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
// 个人介绍卡片
|
name: "Example Site 2",
|
||||||
about: [
|
desc: "Example Site 2",
|
||||||
{ title: "Example", desc: "Example description", icon: "🧠" },
|
url: "https://example2.com",
|
||||||
{ title: "Example", desc: "Example description", icon: "🛠️" },
|
|
||||||
{ title: "Example", desc: "Example description", icon: "🎬" },
|
|
||||||
{ title: "Example", desc: "Example description", icon: "🎮" },
|
|
||||||
],
|
|
||||||
|
|
||||||
siteMeta: {
|
|
||||||
title: "Example Title", // 网站标题
|
|
||||||
icon: "/favicon.ico", // 网站图标,可为public目录下的文件或外部链接
|
|
||||||
startDate:"xxxx-xx-xx", // 网站创建日期
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
|
||||||
music: {
|
projects: [
|
||||||
// 是否启用音乐播放器
|
{
|
||||||
enable: true,
|
name: "Example Project 1",
|
||||||
// floating - 浮动模式播放器(推荐)- 用于播放网易云歌单
|
url: "https://github.com/ExampleUser/example-project-1",
|
||||||
// embed - 嵌入模式播放器 - 用于播放网易云单曲
|
desc: "Example Project 1",
|
||||||
mode: "floating", // "floating" 或 "embed"
|
|
||||||
// 歌单ID:从网易云音乐链接获取,如 https://music.163.com/#/playlist?id=14273792576
|
|
||||||
playlistId: undefined, // 例如: "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: {
|
name: "Example Project 2",
|
||||||
enable: true, // 是否启用 Umami 分析
|
url: "https://github.com/ExampleUser/example-project-2",
|
||||||
url: "https://cloud.umami.is/script.js", // Umami 分析脚本 URL,一般无需修改
|
desc: "Example Project 2",
|
||||||
websiteId: "YOUR_WEBSITE_ID", // Umami 网站 ID
|
|
||||||
apiBase: "https://api.umami.is", // Umami API 地址,一般无需修改
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
|
||||||
wakatime: {
|
friends: [
|
||||||
enable: true, // 是否启用 Wakatime 统计
|
{
|
||||||
apiUrl: "https://wakatime.com/api/v1", // Wakatime API 地址,默认官方地址
|
name: "Example Site 1",
|
||||||
|
desc: "Example Site 1",
|
||||||
|
url: "https://example1.com",
|
||||||
|
avatar: "https://example1.com/avatar.png",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
// 技能图标展示,详见https://github.com/tandpfun/skill-icons#icons-list
|
name: "Example Site 2",
|
||||||
skills: [
|
desc: "Example Site 2",
|
||||||
{ title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] },
|
url: "https://example2.com",
|
||||||
{ title: "后端 / 云", items: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"] },
|
avatar: "https://example2.com/avatar.png",
|
||||||
{ title: "工具", items: ["ae", "au", "git", "github", "md", "ps", "pr", "vscode"] },
|
|
||||||
{ title: "操作系统", items: ["arch", "linux", "windows"] },
|
|
||||||
],
|
|
||||||
|
|
||||||
sites: [
|
|
||||||
{
|
|
||||||
name: "Example Site 1",
|
|
||||||
desc: "Example Site 1",
|
|
||||||
url: "https://example1.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Example Site 2",
|
|
||||||
desc: "Example Site 2",
|
|
||||||
url: "https://example2.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
projects: [
|
|
||||||
{ name: "Example Project 1", url: "https://github.com/ExampleUser/example-project-1", desc: "Example Project 1" },
|
|
||||||
{ name: "Example Project 2", url: "https://github.com/ExampleUser/example-project-2", desc: "Example Project 2" },
|
|
||||||
],
|
|
||||||
|
|
||||||
friends: [
|
|
||||||
{
|
|
||||||
name: "Example Site 1",
|
|
||||||
desc: "Example Site 1",
|
|
||||||
url: "https://example1.com",
|
|
||||||
avatar: "https://example1.com/avatar.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Example Site 2",
|
|
||||||
desc: "Example Site 2",
|
|
||||||
url: "https://example2.com",
|
|
||||||
avatar: "https://example2.com/avatar.png",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
footer: {
|
|
||||||
beian: "备案号", // 备案号,留空则不显示
|
|
||||||
beianLink: "https://beian.miit.gov.cn/", // 备案号链接,一般无需修改
|
|
||||||
customHtml: '', // 自定义 HTML 代码,如统计代码等
|
|
||||||
hitokoto: {
|
|
||||||
enable: true, // 是否启用一言
|
|
||||||
type: "a&b&c&d&j", // 一言类型,详见 https://developer.hitokoto.cn/sentence/#%E5%8F%A5%E5%AD%90%E7%B1%BB%E5%9E%8B-%E5%8F%82%E6%95%B0
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
beian: "备案号", // 备案号,留空则不显示
|
||||||
|
beianLink: "https://beian.miit.gov.cn/", // 备案号链接,一般无需修改
|
||||||
|
customHtml: "", // 自定义 HTML 代码,如统计代码等
|
||||||
|
hitokoto: {
|
||||||
|
enable: true, // 是否启用一言
|
||||||
|
type: "a&b&c&d&j", // 一言类型,详见 https://developer.hitokoto.cn/sentence/#%E5%8F%A5%E5%AD%90%E7%B1%BB%E5%9E%8B-%E5%8F%82%E6%95%B0
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,11 +229,11 @@ pnpm build
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "RhenCloud",
|
"name": "RhenCloud",
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"desc": "个人博客",
|
"desc": "个人博客",
|
||||||
"email": "you@example.com",
|
"email": "you@example.com",
|
||||||
"avatar": "https://example.com/avatar.png"
|
"avatar": "https://example.com/avatar.png"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
75
app/app.vue
Normal file
75
app/app.vue
Normal 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> -->
|
||||||
109
app/components/AboutSection.vue
Normal file
109
app/components/AboutSection.vue
Normal 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>
|
||||||
146
app/components/FooterSection.vue
Normal file
146
app/components/FooterSection.vue
Normal 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>
|
||||||
283
app/components/FriendsSection.vue
Normal file
283
app/components/FriendsSection.vue
Normal 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>
|
||||||
69
app/components/GithubSection.vue
Normal file
69
app/components/GithubSection.vue
Normal 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>
|
||||||
32
app/components/HeroSection.vue
Normal file
32
app/components/HeroSection.vue
Normal 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>
|
||||||
32
app/components/MusicPlayer.vue
Normal file
32
app/components/MusicPlayer.vue
Normal 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> -->
|
||||||
65
app/components/PageSwitcher.vue
Normal file
65
app/components/PageSwitcher.vue
Normal 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>
|
||||||
41
app/components/ProjectsSection.vue
Normal file
41
app/components/ProjectsSection.vue
Normal 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>
|
||||||
43
app/components/SitesSection.vue
Normal file
43
app/components/SitesSection.vue
Normal 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>
|
||||||
39
app/components/SkillsSection.vue
Normal file
39
app/components/SkillsSection.vue
Normal 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>
|
||||||
57
app/components/SocialLinks.vue
Normal file
57
app/components/SocialLinks.vue
Normal 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>
|
||||||
434
app/components/StatsSection.vue
Normal file
434
app/components/StatsSection.vue
Normal 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>
|
||||||
319
app/components/WakatimeSection.vue
Normal file
319
app/components/WakatimeSection.vue
Normal 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
185
app/config/siteConfig.ts
Normal 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
87
app/pages/about.vue
Normal 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
82
app/pages/comments.vue
Normal 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> -->
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<FriendsSection :friends="friends" />
|
<FriendsSection :friends="friends" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -11,7 +11,7 @@ import siteConfig from "~/config/siteConfig";
|
|||||||
const friends = siteConfig.friends;
|
const friends = siteConfig.friends;
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
order: 4,
|
order: 4,
|
||||||
label: "友链",
|
label: "友链",
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<HeroSection :profile="profile" />
|
<HeroSection :profile="profile" />
|
||||||
<SocialLinks :links="socialLinks" />
|
<SocialLinks :links="socialLinks" />
|
||||||
<AboutSection :items="about" :profile="profile" />
|
<AboutSection :items="about" :profile="profile" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -17,7 +17,7 @@ const socialLinks = siteConfig.socialLinks;
|
|||||||
const about = siteConfig.about;
|
const about = siteConfig.about;
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
order: 0,
|
order: 0,
|
||||||
label: "首页",
|
label: "首页",
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<ProjectsSection :projects="projects" />
|
<ProjectsSection :projects="projects" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -11,7 +11,7 @@ import siteConfig from "~/config/siteConfig";
|
|||||||
const projects = siteConfig.projects;
|
const projects = siteConfig.projects;
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
order: 3,
|
order: 3,
|
||||||
label: "项目",
|
label: "项目",
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<SitesSection :sites="sites" />
|
<SitesSection :sites="sites" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -11,7 +11,7 @@ import siteConfig from "~/config/siteConfig";
|
|||||||
const sites = siteConfig.sites;
|
const sites = siteConfig.sites;
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
order: 2,
|
order: 2,
|
||||||
label: "网站",
|
label: "网站",
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
102
app/plugins/neteaseMiniPlayer.client.ts
Normal file
102
app/plugins/neteaseMiniPlayer.client.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
app/plugins/umami.client.ts
Normal file
29
app/plugins/umami.client.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineNuxtPlugin } from "#app";
|
||||||
|
import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin";
|
||||||
|
import type { Router } from "vue-router";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
if (!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
340
app/styles.global.css
Normal 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
9
app/utils/cssLoader.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* CSS 加载工具 (已禁用)
|
||||||
|
*
|
||||||
|
* 此工具不再使用,因为Nuxt会自动处理CSS加载和优化
|
||||||
|
* 保留此文件以供参考,但不会被导入
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 所有导出的函数已被删除
|
||||||
|
// 使用 nuxt.config.ts 的 css 配置代替
|
||||||
19
eslint.config.mjs
Normal file
19
eslint.config.mjs
Normal 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",
|
||||||
|
// },
|
||||||
|
});
|
||||||
20
index1.html
20
index1.html
@@ -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
2
netlify.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[images]
|
||||||
|
remote_images = ["https:\\/\\/.*"]
|
||||||
136
nuxt.config.ts
136
nuxt.config.ts
@@ -1,35 +1,113 @@
|
|||||||
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"],
|
|
||||||
app: {
|
modules: [
|
||||||
head: {
|
"@nuxt/image",
|
||||||
title: siteConfig.siteMeta.title,
|
"@nuxt/eslint",
|
||||||
link: [{ rel: "icon", href: siteConfig.siteMeta.icon }],
|
"@nuxtjs/robots",
|
||||||
},
|
"@nuxtjs/sitemap",
|
||||||
|
"@nuxt/icon",
|
||||||
|
"@nuxtjs/seo",
|
||||||
|
],
|
||||||
|
|
||||||
|
// 禁用 Vue Router 的非关键警告
|
||||||
|
vue: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => tag.startsWith("ion-"),
|
||||||
},
|
},
|
||||||
// nitro: {
|
},
|
||||||
// prerender: {
|
|
||||||
// crawlLinks: true,
|
// Tailwind CSS 集成
|
||||||
// routes: ["/sitemap.xml", "/rss.xml"],
|
css: ["~/styles.global.css"],
|
||||||
// },
|
|
||||||
// },
|
vite: {
|
||||||
runtimeConfig: {
|
plugins: [tailwindcss()],
|
||||||
smtpHost: process.env.SMTP_HOST ?? "",
|
build: {
|
||||||
smtpPort: Number(process.env.SMTP_PORT ?? 465),
|
sourcemap: false,
|
||||||
smtpUser: process.env.SMTP_USER ?? "",
|
chunkSizeWarningLimit: 1000,
|
||||||
smtpPass: process.env.SMTP_PASS ?? "",
|
|
||||||
senderEmail: process.env.SENDER_EMAIL ?? "",
|
|
||||||
adminEmail: process.env.ADMIN_EMAIL ?? "",
|
|
||||||
smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : undefined,
|
|
||||||
wakatimeApiKey: process.env.WAKATIME_API_KEY ?? "",
|
|
||||||
wakatimeApiUrl: process.env.WAKATIME_API_URL ?? "https://wakatime.com/api/v1",
|
|
||||||
public: {
|
|
||||||
githubToken: process.env.NUXT_PUBLIC_GITHUB_TOKEN ?? "",
|
|
||||||
umamiApiKey: process.env.NUXT_PUBLIC_UMAMI_API_KEY ?? "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
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: {
|
||||||
|
head: {
|
||||||
|
charset: "utf-8",
|
||||||
|
viewport: "width=device-width,initial-scale=1,maximum-scale=5",
|
||||||
|
title: siteConfig.siteMeta.title,
|
||||||
|
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: {
|
||||||
|
crawlLinks: true,
|
||||||
|
// 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: {
|
||||||
|
smtpHost: process.env.SMTP_HOST ?? "",
|
||||||
|
smtpPort: Number(process.env.SMTP_PORT ?? 465),
|
||||||
|
smtpUser: process.env.SMTP_USER ?? "",
|
||||||
|
smtpPass: process.env.SMTP_PASS ?? "",
|
||||||
|
senderEmail: process.env.SENDER_EMAIL ?? "",
|
||||||
|
adminEmail: process.env.ADMIN_EMAIL ?? "",
|
||||||
|
smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : undefined,
|
||||||
|
githubToken: process.env.NUXT_PUBLIC_GITHUB_TOKEN ?? "",
|
||||||
|
umamiApiKey: process.env.UMAMI_API_KEY ?? "",
|
||||||
|
public: {
|
||||||
|
wakatimeApiKey: process.env.WAKATIME_API_KEY ?? "",
|
||||||
|
wakatimeApiUrl: process.env.WAKATIME_API_URL ?? "https://wakatime.com/api/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
66
package.json
66
package.json
@@ -1,21 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "cloud-home",
|
"name": "cloud-home",
|
||||||
"private": true,
|
"author": {
|
||||||
"type": "module",
|
"name": "RhenCloud",
|
||||||
"scripts": {
|
"email": "i@rhen.cloud",
|
||||||
"dev": "nuxt dev",
|
"url": "https://rhen.cloud"
|
||||||
"build": "nuxt build",
|
},
|
||||||
"generate": "nuxt generate",
|
"private": true,
|
||||||
"preview": "nuxt preview"
|
"type": "module",
|
||||||
},
|
"scripts": {
|
||||||
"dependencies": {
|
"dev": "nuxt dev",
|
||||||
"@jaseeey/vue-umami-plugin": "^1.4.0",
|
"build": "nuxt build",
|
||||||
"nodemailer": "^7.0.11",
|
"generate": "nuxt generate",
|
||||||
"nuxt": "^4.2.2"
|
"preview": "nuxt preview",
|
||||||
},
|
"lint": "eslint .",
|
||||||
"devDependencies": {
|
"lint:fix": "eslint . --fix",
|
||||||
"@types/node": "^24.10.1",
|
"format": "prettier --write .",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"format:check": "prettier --check ."
|
||||||
"typescript": "^5.9.3"
|
},
|
||||||
}
|
"dependencies": {
|
||||||
|
"@giscus/vue": "^3.1.1",
|
||||||
|
"@jaseeey/vue-umami-plugin": "^1.4.0",
|
||||||
|
"@nuxt/icon": "^2.2.0",
|
||||||
|
"@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": {
|
||||||
|
"@iconify-json/fa6-brands": "^1.2.6",
|
||||||
|
"@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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
12552
pnpm-lock.yaml
generated
Normal file
12552
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
|||||||
onlyBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- esbuild
|
- core-js
|
||||||
|
|||||||
130
public/404.html
130
public/404.html
@@ -1,80 +1,86 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
<title>404 - Not Found</title>
|
<title>404 - Not Found</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
}
|
"Inter",
|
||||||
|
"Segoe UI",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: min(480px, 92vw);
|
width: min(480px, 92vw);
|
||||||
padding: 24px 26px;
|
padding: 24px 26px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: linear-gradient(135deg, rgba(244, 151, 218, 0.12), rgba(255, 255, 255, 0.05));
|
background: linear-gradient(135deg, rgba(244, 151, 218, 0.12), rgba(255, 255, 255, 0.05));
|
||||||
box-shadow: 0 16px 46px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 16px 46px rgba(0, 0, 0, 0.28);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0 0 18px;
|
margin: 0 0 18px;
|
||||||
color: rgba(232, 238, 252, 0.82);
|
color: rgba(232, 238, 252, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
a.btn {
|
a.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(124, 193, 255, 0.5);
|
border: 1px solid rgba(124, 193, 255, 0.5);
|
||||||
background: rgba(124, 193, 255, 0.12);
|
background: rgba(124, 193, 255, 0.12);
|
||||||
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 {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: rgba(244, 151, 218, 0.6);
|
border-color: rgba(244, 151, 218, 0.6);
|
||||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main class="card">
|
<main class="card">
|
||||||
<h1>404</h1>
|
<h1>404</h1>
|
||||||
<p>页面不见了,或已被移除。</p>
|
<p>页面不见了,或已被移除。</p>
|
||||||
<a class="btn" href="/">返回首页</a>
|
<a class="btn" href="/">返回首页</a>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
BIN
public/avatar-1.webp
Normal file
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
BIN
public/background.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
167
public/css/giscus.css
Normal file
167
public/css/giscus.css
Normal 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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,75 +4,84 @@ import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
|||||||
import { useRuntimeConfig } from "#imports";
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
|
||||||
type MailConfig = {
|
type MailConfig = {
|
||||||
smtpHost?: string;
|
smtpHost?: string;
|
||||||
smtpPort?: number | string;
|
smtpPort?: number | string;
|
||||||
smtpUser?: string;
|
smtpUser?: string;
|
||||||
smtpPass?: string;
|
smtpPass?: string;
|
||||||
senderEmail?: string;
|
senderEmail?: string;
|
||||||
adminEmail?: string;
|
adminEmail?: string;
|
||||||
smtpSecure?: boolean;
|
smtpSecure?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SendMailPayload = {
|
type SendMailPayload = {
|
||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureValue = (value?: string, fallback = "未填写") => (value?.trim() ? value.trim() : fallback);
|
const ensureValue = (value?: string, fallback = "未填写") =>
|
||||||
|
value?.trim() ? value.trim() : fallback;
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const method = event.node.req.method;
|
const method = event.node.req.method;
|
||||||
if (method === "OPTIONS") {
|
if (method === "OPTIONS") {
|
||||||
event.node.res.statusCode = 200;
|
event.node.res.statusCode = 200;
|
||||||
return { status: "ok" };
|
return { status: "ok" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method !== "POST") {
|
if (method !== "POST") {
|
||||||
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await readBody<SendMailPayload>(event)) || {};
|
const payload = (await readBody<SendMailPayload>(event)) || {};
|
||||||
const { name, url, desc, email, avatar, message } = payload;
|
const { name, url, desc, email, avatar, message } = payload;
|
||||||
|
|
||||||
if (!name?.trim() || !url?.trim() || !email?.trim()) {
|
if (!name?.trim() || !url?.trim() || !email?.trim()) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "Missing required fields: name, url, and email",
|
statusMessage: "Missing required fields: name, url, and email",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
throw createError({ statusCode: 500, statusMessage: "SMTP server is not fully configured" });
|
throw createError({ statusCode: 500, statusMessage: "SMTP server is not fully configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const secure = typeof smtpSecure === "boolean" ? smtpSecure : smtpPort === 465;
|
const secure = typeof smtpSecure === "boolean" ? smtpSecure : smtpPort === 465;
|
||||||
const smtpOptions: SMTPTransport.Options = {
|
const smtpOptions: SMTPTransport.Options = {
|
||||||
host: smtpHost,
|
host: smtpHost,
|
||||||
port: smtpPort,
|
port: smtpPort,
|
||||||
secure,
|
secure,
|
||||||
auth: {
|
auth: {
|
||||||
user: smtpUser,
|
user: smtpUser,
|
||||||
pass: smtpPass,
|
pass: smtpPass,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport(smtpOptions);
|
const transporter = nodemailer.createTransport(smtpOptions);
|
||||||
const friendEntry = `{
|
const friendEntry = `{
|
||||||
name: "${ensureValue(name).replace(/"/g, '\\"')}",
|
name: "${ensureValue(name).replace(/"/g, '\\"')}",
|
||||||
url: "${ensureValue(url).replace(/"/g, '\\"')}",
|
url: "${ensureValue(url).replace(/"/g, '\\"')}",
|
||||||
desc: "${ensureValue(desc).replace(/"/g, '\\"')}",
|
desc: "${ensureValue(desc).replace(/"/g, '\\"')}",
|
||||||
avatar: "${ensureValue(avatar).replace(/"/g, '\\"')}",
|
avatar: "${ensureValue(avatar).replace(/"/g, '\\"')}",
|
||||||
},`;
|
},`;
|
||||||
|
|
||||||
const htmlMessage = `
|
const htmlMessage = `
|
||||||
<p>一个新的友链申请已提交,以下是可直接复制到项目中的配置:</p>
|
<p>一个新的友链申请已提交,以下是可直接复制到项目中的配置:</p>
|
||||||
<pre style="background: #f5f5f5; padding: 12px; border-radius: 4px; overflow: auto;">
|
<pre style="background: #f5f5f5; padding: 12px; border-radius: 4px; overflow: auto;">
|
||||||
<code>${friendEntry}</code>
|
<code>${friendEntry}</code>
|
||||||
@@ -88,16 +97,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
<p><strong>时间:</strong>${new Date().toISOString()}</p>
|
<p><strong>时间:</strong>${new Date().toISOString()}</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: senderEmail,
|
from: senderEmail,
|
||||||
to: adminEmail,
|
to: adminEmail,
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `友链申请 / 联系表单 · ${ensureValue(name)}`,
|
subject: `友链申请 / 联系表单 · ${ensureValue(name)}`,
|
||||||
html: htmlMessage,
|
html: htmlMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: "Mail sent",
|
message: "Mail sent",
|
||||||
id: info.messageId,
|
id: info.messageId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,55 +2,55 @@ import { defineEventHandler, getQuery, createError } from "h3";
|
|||||||
import { useRuntimeConfig } from "#imports";
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const res = event.node.res;
|
const res = event.node.res;
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
|
||||||
if (event.node.req.method === "OPTIONS") {
|
if (event.node.req.method === "OPTIONS") {
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
return "ok";
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.node.req.method !== "GET") {
|
||||||
|
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiKey = config.wakatimeApiKey;
|
||||||
|
if (typeof apiKey !== "string") {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: "Invalid WakaTime API Key configuration" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event);
|
||||||
|
const apiUrl = (query.apiUrl as string) || config.wakatimeApiUrl;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([
|
||||||
|
fetch(`${apiUrl}/users/current/stats/last_7_days`, { headers }),
|
||||||
|
fetch(`${apiUrl}/users/current/stats/all_time`, { headers }),
|
||||||
|
fetch(`${apiUrl}/users/current/status`, { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!weeklyStatsResponse.ok) {
|
||||||
|
throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.node.req.method !== "GET") {
|
const weeklyStatsData = await weeklyStatsResponse.json();
|
||||||
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
const allTimeStatsData = allTimeStatsResponse.ok ? await allTimeStatsResponse.json() : null;
|
||||||
}
|
const statusData = statusResponse.ok ? await statusResponse.json() : null;
|
||||||
|
|
||||||
const config = useRuntimeConfig();
|
return {
|
||||||
const apiKey = config.wakatimeApiKey;
|
weekly: weeklyStatsData.data,
|
||||||
if (typeof apiKey !== "string") {
|
allTime: allTimeStatsData ? allTimeStatsData.data : null,
|
||||||
throw createError({ statusCode: 500, statusMessage: "Invalid WakaTime API Key configuration" });
|
status: statusData,
|
||||||
}
|
|
||||||
|
|
||||||
const query = getQuery(event);
|
|
||||||
const apiUrl = (query.apiUrl as string) || config.wakatimeApiUrl;
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
try {
|
console.error("Wakatime API error:", error);
|
||||||
const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([
|
throw createError({ statusCode: 500, statusMessage: "Failed to fetch Wakatime data" });
|
||||||
fetch(`${apiUrl}/users/current/stats/last_7_days`, { headers }),
|
}
|
||||||
fetch(`${apiUrl}/users/current/stats/all_time`, { headers }),
|
|
||||||
fetch(`${apiUrl}/users/current/status`, { headers }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!weeklyStatsResponse.ok) {
|
|
||||||
throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const weeklyStatsData = await weeklyStatsResponse.json();
|
|
||||||
const allTimeStatsData = allTimeStatsResponse.ok ? await allTimeStatsResponse.json() : null;
|
|
||||||
const statusData = statusResponse.ok ? await statusResponse.json() : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
weekly: weeklyStatsData.data,
|
|
||||||
allTime: allTimeStatsData ? allTimeStatsData.data : null,
|
|
||||||
status: statusData,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Wakatime API error:", error);
|
|
||||||
throw createError({ statusCode: 500, statusMessage: "Failed to fetch Wakatime data" });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
83
src/app.vue
83
src/app.vue
@@ -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> -->
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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> -->
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
289
src/styles.css
289
src/styles.css
@@ -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
7
src/vite-env.d.ts
vendored
@@ -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
51
tailwind.config.ts
Normal 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;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": "./.nuxt/tsconfig.json",
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
"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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["nuxt.config.ts"]
|
"include": ["nuxt.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"framework": "nuxtjs"
|
"framework": "nuxtjs"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user