mirror of
https://github.com/RhenCloud/Cloud-Home.git
synced 2026-01-22 17:39:07 +08:00
feat: 初始化项目
- 初始化 Vue 3 + Vite 项目结构 - 添加核心组件 (Hero, About, Projects, Friends 等) - 配置页面路由和基础样式 - 添加服务端 API 接口 (邮件发送, 好友请求) - 添加项目配置文件 (vercel.json, vite.config.js)
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
VITE_GITHUB_TOKEN=your-github-token
|
||||||
|
|
||||||
|
# SMTP 服务器地址
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
|
||||||
|
# SMTP 端口(不填写默认465)
|
||||||
|
SMTP_PORT=465
|
||||||
|
|
||||||
|
# SMTP 用户名(一般与SENDER_EMAIL相同)
|
||||||
|
SMTP_USER=your-email@example.com
|
||||||
|
|
||||||
|
# SMTP 密码
|
||||||
|
SMTP_PASS=your-email-password
|
||||||
|
|
||||||
|
# 收件邮箱地址
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# 发件邮箱地址(一般与SMTP_USER相同)
|
||||||
|
SENDER_EMAIL=sender@example.com
|
||||||
|
|
||||||
|
# 是否启用SSL
|
||||||
|
SMTP_SECURE=true
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# builds
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.out/
|
||||||
|
.next/
|
||||||
|
.vercel/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# env & secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# editors & system
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# tests
|
||||||
|
tests/
|
||||||
|
test*
|
||||||
168
README.md
Normal file
168
README.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Cloud Home
|
||||||
|
|
||||||
|
一个基于 Vue 的个人主页模板,内置友链申请、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 🎨 个性化主页:头像、社交链接、技能、站点/项目列表可配置。
|
||||||
|
- 🔗 友链模块:支持申请表单、邮件通知、随机顺序展示。
|
||||||
|
- 📱 响应式设计:适配桌面与移动端。
|
||||||
|
- ⚙️ Serverless 部署支持
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 前端:Vue 3 + HTML + CSS
|
||||||
|
- 构建:Vite
|
||||||
|
- 部署:Vercel(静态构建 + Serverless Functions)
|
||||||
|
|
||||||
|
## 配置指南
|
||||||
|
|
||||||
|
### 站点配置文件 (`src/config/siteConfig.ts`)
|
||||||
|
|
||||||
|
本项目的所有静态内容配置均集中在 `src/config/siteConfig.ts` 文件中。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const siteConfig: SiteConfig = {
|
||||||
|
profile: {
|
||||||
|
name: "Example User", // 你的名字
|
||||||
|
title: "I'm a software developer.", // 你的简介,可为空
|
||||||
|
avatar: "avatar.webp", // 你的头像,可为public目录下的文件或外部链接
|
||||||
|
bio: "Hello World", // 你的喜欢的一句话,可为空
|
||||||
|
},
|
||||||
|
|
||||||
|
// 社交链接,预定义的社交链接可在 `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: "RhenCloud", // 网站标题
|
||||||
|
icon: "favicon.ico", // 网站图标,可为public目录下的文件或外部链接
|
||||||
|
},
|
||||||
|
|
||||||
|
// 技能图标展示,详见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"] },
|
||||||
|
],
|
||||||
|
|
||||||
|
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/", // 备案号链接,一般无需修改
|
||||||
|
showHitokoto: true, // 是否显示一言
|
||||||
|
hitokotoType: "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
|
||||||
|
customHtml: '', // 自定义 HTML 代码,如统计代码等
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 其他配置
|
||||||
|
|
||||||
|
- **404 页面**:修改 `public/404.html` 来自定义 404 错误页面的样式与内容。
|
||||||
|
- **友链展示逻辑**:`FriendsSection.vue` 默认使用随机顺序渲染 `siteConfig.friends`,如需固定顺序请修改该组件。
|
||||||
|
|
||||||
|
## 环境变量(邮件发送)
|
||||||
|
|
||||||
|
在 Vercel 控制台或本地 `.env` 配置:
|
||||||
|
|
||||||
|
- `VITE_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。
|
||||||
|
- `SMTP_HOST`: 邮件服务器主机名
|
||||||
|
- `SMTP_PORT`: 端口(如 465 或 587)
|
||||||
|
- `SMTP_USER`: 发件人邮箱账号
|
||||||
|
- `SMTP_PASS`: 邮箱授权码或密码
|
||||||
|
- `MAIL_FROM`: 发件人地址(通常同 SMTP_USER)
|
||||||
|
- `ADMIN_EMAIL`: 接收通知的邮箱地址
|
||||||
|
- `SMTP_SECURE`:是否启用 SSL/TLS 加密(默认 `true`)
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:5173/`。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
产物输出到 `dist/`。
|
||||||
|
|
||||||
|
## 部署到 Vercel
|
||||||
|
|
||||||
|
1. 导入仓库到 Vercel。
|
||||||
|
2. 设置上文的 SMTP 环境变量。
|
||||||
|
3. 保持 `vercel.json` 中 `distDir: "dist"`。
|
||||||
|
4. 部署后,静态文件从 `dist/` 提供,API 走 `/api/send-mail`。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `POST /api/send-mail`:友链申请邮件发送。请求体示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "RhenCloud",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"desc": "个人博客",
|
||||||
|
"email": "you@example.com",
|
||||||
|
"avatar": "https://example.com/avatar.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
MIT License.
|
||||||
60
api/send-mail.ts
Normal file
60
api/send-mail.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
// CORS 处理
|
||||||
|
// res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
// res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||||
|
// res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
|
||||||
|
// if (req.method === "OPTIONS") {
|
||||||
|
// return res.status(200).end();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (req.method === "GET") {
|
||||||
|
// return res.status(200).json({ status: "ok", message: "use POST to send mail" });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (req.method !== "POST") {
|
||||||
|
// return res.status(405).json({ error: "Method Not Allowed" });
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||||
|
const { name, email, site, desc, avatar } = data || {};
|
||||||
|
if (!name || !email || !desc) {
|
||||||
|
return res.status(400).json({ error: "Missing required fields: name, email, desc" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT || 465),
|
||||||
|
secure: Number(process.env.SMTP_PORT || 465) === 465,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: process.env.SENDER_EMAIL,
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: "友链申请 / 联系表单",
|
||||||
|
html: `
|
||||||
|
<p><strong>名称:</strong>${name}</p>
|
||||||
|
<p><strong>邮箱:</strong>${email}</p>
|
||||||
|
<p><strong>站点:</strong>${site || "未填写"}</p>
|
||||||
|
<p><strong>描述:</strong>${desc}</p>
|
||||||
|
<p><strong>头像:</strong>${avatar || "未填写"}</p>
|
||||||
|
<p><strong>时间:</strong>${new Date().toISOString()}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Mail sent",
|
||||||
|
id: info.messageId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: "Send mail failed", detail: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!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" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "cloud-home",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vercel/node": "^5.5.15",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"he": "^1.2.0",
|
||||||
|
"nodemailer": "^7.0.11",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.6",
|
||||||
|
"vue-tsc": "^3.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
public/404.html
Normal file
80
public/404.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
|
<title>404 - Not Found</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: radial-gradient(circle at 20% 20%, rgba(244, 151, 218, 0.14), transparent 32%),
|
||||||
|
radial-gradient(circle at 80% 10%, rgba(124, 193, 255, 0.16), transparent 32%),
|
||||||
|
#0f1116;
|
||||||
|
color: #e8eefc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(480px, 92vw);
|
||||||
|
padding: 24px 26px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: linear-gradient(135deg, rgba(244, 151, 218, 0.12), rgba(255, 255, 255, 0.05));
|
||||||
|
box-shadow: 0 16px 46px rgba(0, 0, 0, 0.28);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: rgba(232, 238, 252, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(124, 193, 255, 0.5);
|
||||||
|
background: rgba(124, 193, 255, 0.12);
|
||||||
|
color: #e8eefc;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(244, 151, 218, 0.6);
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>页面不见了,或已被移除。</p>
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
BIN
public/avatar.webp
Normal file
BIN
public/avatar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
30
src/App.vue
Normal file
30
src/App.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<main class="app-body">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
<PageSwitcher />
|
||||||
|
<FooterSection :contact="contact" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import PageSwitcher from "./components/PageSwitcher.vue";
|
||||||
|
import FooterSection from "./components/FooterSection.vue";
|
||||||
|
import siteConfig from "./config/siteConfig";
|
||||||
|
|
||||||
|
const contact = siteConfig.footer;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.app-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/components/AboutSection.vue
Normal file
67
src/components/AboutSection.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card panel">
|
||||||
|
<h2>个人简介</h2>
|
||||||
|
<p class="muted">关于我 · About Me</p>
|
||||||
|
<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>
|
||||||
|
defineProps({ items: Array });
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.about-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
80
src/components/FooterSection.vue
Normal file
80
src/components/FooterSection.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<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 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";
|
||||||
|
const props = defineProps({ contact: Object });
|
||||||
|
const quote = ref("");
|
||||||
|
const from = ref("");
|
||||||
|
const showHitokoto = props.contact?.showHitokoto !== false;
|
||||||
|
|
||||||
|
const buildHitokotoUrl = () => {
|
||||||
|
const url = new URL("https://v1.hitokoto.cn/");
|
||||||
|
const type = props.contact?.hitokotoType;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (showHitokoto) fetchHitokoto();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
311
src/components/FriendsSection.vue
Normal file
311
src/components/FriendsSection.vue
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<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>
|
||||||
|
<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: "",
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.friend-form input::placeholder {
|
||||||
|
color: rgba(232, 238, 252, 0.7);
|
||||||
|
}
|
||||||
|
.friend-form input: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);
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
132
src/components/GithubSection.vue
Normal file
132
src/components/GithubSection.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<h2>GitHub</h2>
|
||||||
|
<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>
|
||||||
|
<div class="heatmap">
|
||||||
|
<h3>提交热力图</h3>
|
||||||
|
<p class="muted">我的提交热力图 · Acitivity Heatmap</p>
|
||||||
|
<img :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy" />
|
||||||
|
</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>
|
||||||
30
src/components/HeroSection.vue
Normal file
30
src/components/HeroSection.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<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: 2px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/components/PageSwitcher.vue
Normal file
88
src/components/PageSwitcher.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pager">
|
||||||
|
<button :disabled="currentIndex <= 0" @click="goPrev">上一页</button>
|
||||||
|
<div class="dots">
|
||||||
|
<button
|
||||||
|
v-for="r in ordered"
|
||||||
|
:key="r.name"
|
||||||
|
:class="{ active: r.name === route.name }"
|
||||||
|
@click="router.push({ name: r.name })"
|
||||||
|
>
|
||||||
|
{{ r.meta.label || r.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button :disabled="currentIndex >= ordered.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 ordered = computed(() =>
|
||||||
|
router
|
||||||
|
.getRoutes()
|
||||||
|
.filter((r) => typeof r.meta?.order === "number")
|
||||||
|
.sort((a, b) => a.meta.order - b.meta.order)
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentIndex = computed(() => ordered.value.findIndex((r) => r.name === route.name));
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (currentIndex.value > 0) router.push({ name: ordered.value[currentIndex.value - 1].name });
|
||||||
|
};
|
||||||
|
const goNext = () => {
|
||||||
|
if (currentIndex.value < ordered.value.length - 1)
|
||||||
|
router.push({ name: ordered.value[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: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
src/components/ProjectsSection.vue
Normal file
86
src/components/ProjectsSection.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<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>
|
||||||
97
src/components/SitesSection.vue
Normal file
97
src/components/SitesSection.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<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>
|
||||||
81
src/components/SkillsSection.vue
Normal file
81
src/components/SkillsSection.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<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>
|
||||||
96
src/components/SocialLinks.vue
Normal file
96
src/components/SocialLinks.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<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>
|
||||||
144
src/config/siteConfig.ts
Normal file
144
src/config/siteConfig.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
interface SiteConfig {
|
||||||
|
profile: {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
avatar: string;
|
||||||
|
bio: string;
|
||||||
|
};
|
||||||
|
socialLinks: Array<{
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
github: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
about: Array<{
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
icon: string;
|
||||||
|
}>;
|
||||||
|
siteMeta: {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
skills: Array<{
|
||||||
|
title: string;
|
||||||
|
items: string[];
|
||||||
|
}>;
|
||||||
|
sites: Array<{
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
projects: Array<{
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
desc: string;
|
||||||
|
}>;
|
||||||
|
friends: Array<{
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
url: string;
|
||||||
|
avatar: string;
|
||||||
|
}>;
|
||||||
|
footer: {
|
||||||
|
beian: string;
|
||||||
|
beianLink: string;
|
||||||
|
showHitokoto: boolean;
|
||||||
|
hitokotoType: string;
|
||||||
|
customHtml: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteConfig: SiteConfig = {
|
||||||
|
profile: {
|
||||||
|
name: "RhenCloud",
|
||||||
|
title: "I'm RhenCloud.",
|
||||||
|
avatar: "avatar.webp", // public/avatar.webp
|
||||||
|
bio: "趁世界还未重启之前 约一次爱恋",
|
||||||
|
},
|
||||||
|
|
||||||
|
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.ico", // public/favicon.ico
|
||||||
|
},
|
||||||
|
|
||||||
|
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/",
|
||||||
|
showHitokoto: true,
|
||||||
|
hitokotoType: "a&b&c&d&j",
|
||||||
|
customHtml: '<span style="opacity:.8">© 2025 <a href="https://rhen.cloud">RhenCloud</a></span>',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default siteConfig;
|
||||||
6
src/main.ts
Normal file
6
src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import router from "./router";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createApp(App).use(router).mount("#app");
|
||||||
19
src/router/index.ts
Normal file
19
src/router/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||||
|
import Home from "../views/Home.vue";
|
||||||
|
import About from "../views/About.vue";
|
||||||
|
import Sites from "../views/Sites.vue";
|
||||||
|
import Projects from "../views/Projects.vue";
|
||||||
|
import Friends from "../views/Friends.vue";
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{ path: "/", name: "Home", component: Home, meta: { order: 0, label: "首页" } },
|
||||||
|
{ path: "/about", name: "About", component: About, meta: { order: 1, label: "关于" } },
|
||||||
|
{ path: "/sites", name: "Sites", component: Sites, meta: { order: 2, label: "网站" } },
|
||||||
|
{ path: "/projects", name: "Projects", component: Projects, meta: { order: 3, label: "项目" } },
|
||||||
|
{ path: "/friends", name: "Friends", component: Friends, meta: { order: 4, label: "友链" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
92
src/styles.css
Normal file
92
src/styles.css
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
: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: 100vh;
|
||||||
|
background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
65
src/views/About.vue
Normal file
65
src/views/About.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<HeroSection :profile="profile" />
|
||||||
|
<SkillsSection :skills="skills" />
|
||||||
|
<GithubSection :github="github" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive } from "vue";
|
||||||
|
import HeroSection from "../components/HeroSection.vue";
|
||||||
|
import SkillsSection from "../components/SkillsSection.vue";
|
||||||
|
import GithubSection from "../components/GithubSection.vue";
|
||||||
|
import siteConfig from "../config/siteConfig";
|
||||||
|
|
||||||
|
const profile = siteConfig.profile;
|
||||||
|
const siteMeta = siteConfig.siteMeta;
|
||||||
|
const skills = siteConfig.skills;
|
||||||
|
|
||||||
|
const github = reactive({
|
||||||
|
...siteConfig.github,
|
||||||
|
heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改此处:使用 VITE_ 前缀
|
||||||
|
const githubToken = import.meta.env.VITE_GITHUB_TOKEN ?? "";
|
||||||
|
|
||||||
|
console.log(githubToken);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.title = siteMeta.title;
|
||||||
|
const link = document.querySelector("link[rel~='icon']") || document.createElement("link");
|
||||||
|
link.rel = "icon";
|
||||||
|
link.href = siteMeta.icon;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
fetchGithubMeta();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchGithubMeta() {
|
||||||
|
try {
|
||||||
|
const headers = githubToken ? { Authorization: `Bearer ${githubToken}` } : {};
|
||||||
|
const resp = await fetch(`https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const repos = await resp.json();
|
||||||
|
if (!Array.isArray(repos)) return;
|
||||||
|
const counts = repos.reduce((acc, repo) => {
|
||||||
|
if (!repo.language) return acc;
|
||||||
|
acc[repo.language] = (acc[repo.language] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0) || 1;
|
||||||
|
const parsed = Object.entries(counts)
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 4);
|
||||||
|
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>
|
||||||
12
src/views/Friends.vue
Normal file
12
src/views/Friends.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<FriendsSection :friends="friends" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FriendsSection from "../components/FriendsSection.vue";
|
||||||
|
import siteConfig from "../config/siteConfig";
|
||||||
|
|
||||||
|
const friends = siteConfig.friends;
|
||||||
|
</script>
|
||||||
36
src/views/Home.vue
Normal file
36
src/views/Home.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<HeroSection :profile="profile" />
|
||||||
|
<SocialLinks :links="socialLinks" />
|
||||||
|
<AboutSection :items="about" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive } from "vue";
|
||||||
|
import HeroSection from "../components/HeroSection.vue";
|
||||||
|
import SocialLinks from "../components/SocialLinks.vue";
|
||||||
|
import AboutSection from "../components/AboutSection.vue";
|
||||||
|
import siteConfig from "../config/siteConfig";
|
||||||
|
|
||||||
|
const profile = siteConfig.profile;
|
||||||
|
const socialLinks = siteConfig.socialLinks;
|
||||||
|
const siteMeta = siteConfig.siteMeta;
|
||||||
|
const about = siteConfig.about;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.title = siteMeta.title;
|
||||||
|
const link = document.querySelector("link[rel~='icon']") || document.createElement("link");
|
||||||
|
link.rel = "icon";
|
||||||
|
link.href = siteMeta.icon;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
src/views/Projects.vue
Normal file
12
src/views/Projects.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<ProjectsSection :projects="projects" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ProjectsSection from "../components/ProjectsSection.vue";
|
||||||
|
import siteConfig from "../config/siteConfig";
|
||||||
|
|
||||||
|
const projects = siteConfig.projects;
|
||||||
|
</script>
|
||||||
12
src/views/Sites.vue
Normal file
12
src/views/Sites.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<SitesSection :sites="sites" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SitesSection from "../components/SitesSection.vue";
|
||||||
|
import siteConfig from "../config/siteConfig";
|
||||||
|
|
||||||
|
const sites = siteConfig.sites;
|
||||||
|
</script>
|
||||||
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
vercel.json
Normal file
10
vercel.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"framework": "vite",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/api/(.*)",
|
||||||
|
"dest": "/api/$1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: { port: 5173 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user