Initial commit

This commit is contained in:
2026-07-24 15:02:45 +08:00
commit 332f9c3c7f
22 changed files with 4144 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/node_modules
/.claude
/dist
agent.md
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO Meta -->
<title>Nanami — 个人主页</title>
<meta name="description" content="Madobi Nanami 的个人主页 — 计算机技术博主 | 全栈开发 | 开源爱好者。探索技术博客、项目作品与更多内容。">
<meta name="keywords" content="Nanami, Madobi Nanami, 技术博客, 全栈开发, 个人主页, 开源项目, 程序员, 开发者, Go, Vue, C++, Python, Rust">
<meta name="author" content="Madobi Nanami">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://nanami.tech/">
<!-- Open Graph -->
<meta property="og:title" content="Nanami — 个人主页">
<meta property="og:description" content="Madobi Nanami 的个人主页 — 计算机技术博主 | 全栈开发 | 开源爱好者">
<meta property="og:type" content="website">
<meta property="og:url" content="https://nanami.tech/">
<meta property="og:site_name" content="Nanami's Homepage">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Nanami — 个人主页">
<meta name="twitter:description" content="Madobi Nanami 的个人主页 — 计算机技术博主 | 全栈开发 | 开源爱好者">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: #f8f8f6;
color: #1a1a1c;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1365
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "nanami-homepage",
"version": "1.0.0",
"description": "Personal homepage of Madobi Nanami - Computer tech blogger",
"main": "index.html",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.2.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.3.1"
},
"repository": {
"type": "git",
"url": "https://gitea.nanami.tech/NanamiWork/NanamiHomePage.git"
},
"keywords": [
"personal-homepage",
"blog",
"portfolio",
"tech-blog"
],
"author": "Madobi Nanami <i@nanami.tech>",
"license": "ISC"
}
+111
View File
@@ -0,0 +1,111 @@
<template>
<div :class="{ 'dark': isDarkMode }">
<div class="bg-grid"></div>
<TopNav />
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<PageFooter />
</div>
</template>
<script>
import { provide } from 'vue';
import { useTheme } from './utils/toggleThemes';
import TopNav from './components/TopNav.vue';
import PageFooter from './components/PageFooter.vue';
export default {
name: 'App',
components: {
TopNav,
PageFooter
},
setup() {
const { isDarkMode, toggleTheme } = useTheme();
provide('isDarkMode', isDarkMode);
provide('toggleTheme', toggleTheme);
return {
isDarkMode,
toggleTheme
};
}
};
</script>
<style>
@import './styles/themes.css';
@import './styles/common.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 17px;
line-height: 1.47;
letter-spacing: -0.374px;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s, color 0.3s;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
min-height: 100vh;
}
.main-content {
min-height: calc(100vh - 280px);
padding-top: 64px;
}
/* Page transition */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.page-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.page-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color-strong);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
</style>
+250
View File
@@ -0,0 +1,250 @@
<template>
<section class="about section">
<div class="container">
<ScrollReveal>
<h2 class="section-title">{{ title }}</h2>
<p class="section-subtitle">{{ subtitle }}</p>
</ScrollReveal>
<div class="about-grid">
<!-- Bio -->
<ScrollReveal :delay="100">
<div class="card about-bio">
<div class="card-header">
<h3><i class="fas fa-user-astronaut"></i> 个人简介</h3>
</div>
<div class="card-body">
<p v-for="(paragraph, i) in bio" :key="i" class="bio-text">{{ paragraph }}</p>
</div>
</div>
</ScrollReveal>
<!-- Personal Tags -->
<ScrollReveal :delay="200">
<div class="card about-tags">
<div class="card-header">
<h3><i class="fas fa-tags"></i> 个人标签</h3>
</div>
<div class="card-body">
<div class="tags-wrap">
<span v-for="tag in personalTags" :key="tag" class="tag accent">{{ tag }}</span>
</div>
</div>
</div>
</ScrollReveal>
<!-- Tech Stack -->
<ScrollReveal :delay="300">
<div class="card about-tech">
<div class="card-header">
<h3><i class="fas fa-microchip"></i> 技术栈</h3>
</div>
<div class="card-body">
<div v-for="group in techStack" :key="group.category" class="tech-group">
<h4 class="tech-category">{{ group.category }}</h4>
<div class="tags-wrap">
<span v-for="tech in group.items" :key="tech.name" class="tech-tag">
<i :class="tech.icon" class="tech-icon"></i>
<span>{{ tech.name }}</span>
</span>
</div>
</div>
</div>
</div>
</ScrollReveal>
</div>
</div>
</section>
</template>
<script>
import ScrollReveal from './ScrollReveal.vue';
export default {
name: 'AboutSection',
components: {
ScrollReveal
},
props: {
title: {
type: String,
default: '关于我'
},
subtitle: {
type: String,
default: '一名热衷于计算机技术和互联网的全栈开发者'
}
},
setup() {
const bio = [
'你好,我是 Madobi Nanami,一名热衷于计算机技术和互联网的全栈开发者。热衷于各类计算机技术的探索与实践。主要研究方向为网络工程、云原生运维。前后端开发也在作为我的一个兴趣爱好之一发展。',
'我十分喜欢折腾 HomeLab, 搭建各种企业级服务和应用,探索各种技术的边界与可能性。通过实践,我积累了丰富的经验,并不断提升自己的技术能力。',
'在工作之余,我热衷于开源社区贡献,撰写技术博客,分享我的经验和见解。我相信技术的力量可以改变世界,也希望通过我的努力为开源社区和技术爱好者们带来价值。'
];
const personalTags = [
'全栈开发者', '开源爱好者', '技术宅', 'Linux Lover',
'云原生运维', '网络工程师', 'Galgame Lover', 'Anime Lover', 'HiFi Player'
];
const techStack = [
{
category: '后端开发',
items: [
{ name: 'C++', icon: 'fas fa-code' },
{ name: 'Go', icon: 'fas fa-cog' },
{ name: 'Node.js', icon: 'fab fa-node-js' }
]
},
{
category: '前端开发',
items: [
{ name: 'Vue.js', icon: 'fas fa-code' },
{ name: 'JavaScript', icon: 'fas fa-code' },
{ name: 'HTML/CSS', icon: 'fab fa-html5' }
]
},
{
category: '基础设施',
items: [
{ name: 'Linux', icon: 'fab fa-linux' },
{ name: 'Windows Server', icon: 'fab fa-windows' },
{ name: 'Nginx', icon: 'fas fa-server' },
{ name: 'Docker', icon: 'fab fa-docker' },
{ name: 'Kubernetes', icon: 'fas fa-cubes' },
{ name: 'Git', icon: 'fab fa-git-alt' }
]
},
{
category: '数据存储',
items: [
{ name: 'MySQL', icon: 'fas fa-database' },
]
}
];
return {
bio,
personalTags,
techStack
};
}
};
</script>
<style scoped>
.about {
padding: 64px 0;
}
.about-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.about-bio {
grid-column: 1 / -1;
}
.about-bio .card-header h3,
.about-tags .card-header h3,
.about-tech .card-header h3 {
display: flex;
align-items: center;
gap: 10px;
}
.about-bio .card-header h3 i,
.about-tags .card-header h3 i,
.about-tech .card-header h3 i {
color: var(--accent);
font-size: 16px;
}
.bio-text {
font-size: 15px;
line-height: 1.7;
color: var(--text-secondary);
margin: 0;
transition: color 0.3s;
}
/* Tags */
.tags-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* Tech Stack */
.tech-group {
margin-bottom: 16px;
}
.tech-group:last-child {
margin-bottom: 0;
}
.tech-category {
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
margin: 0 0 8px 0;
transition: color 0.3s;
}
.tech-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: var(--tag-bg);
transition: all 0.2s;
cursor: default;
}
.tech-tag:hover {
background: var(--accent-subtle);
color: var(--accent);
}
.tech-icon {
font-size: 13px;
width: 16px;
text-align: center;
color: var(--text-tertiary);
transition: color 0.2s;
}
.tech-tag:hover .tech-icon {
color: var(--accent);
}
/* Card header icon */
.about-bio .card-header h3 i,
.about-tags .card-header h3 i,
.about-tech .card-header h3 i {
color: var(--accent);
font-size: 16px;
width: 20px;
text-align: center;
}
@media (max-width: 768px) {
.about {
padding: 48px 0;
}
.about-grid {
grid-template-columns: 1fr;
}
}
</style>
+414
View File
@@ -0,0 +1,414 @@
<template>
<section class="contact section">
<div class="container">
<ScrollReveal>
<h2 class="section-title">{{ title }}</h2>
<p class="section-subtitle">{{ subtitle }}</p>
</ScrollReveal>
<div class="contact-grid">
<!-- Contact Info -->
<ScrollReveal :delay="100">
<div class="contact-info">
<div class="card contact-info-card">
<h3><i class="fas fa-paper-plane"></i> 联系方式</h3>
<div class="contact-list">
<a
v-for="item in contactItems"
:key="item.label"
:href="item.href"
class="contact-item"
:target="item.external ? '_blank' : '_self'"
:rel="item.external ? 'noopener noreferrer' : undefined"
>
<div class="contact-item-icon">
<i :class="item.icon" aria-hidden="true"></i>
</div>
<div class="contact-item-text">
<span class="contact-item-label">{{ item.label }}</span>
<span class="contact-item-value">{{ item.value }}</span>
</div>
</a>
</div>
</div>
<div class="card social-card">
<h3><i class="fas fa-share-nodes"></i> 社交媒体</h3>
<div class="social-grid">
<a
v-for="social in socialLinks"
:key="social.label"
:href="social.url"
:aria-label="social.label"
:title="social.label"
class="social-item"
target="_blank"
rel="noopener noreferrer"
>
<i :class="social.icon" aria-hidden="true"></i>
<span>{{ social.label }}</span>
</a>
</div>
</div>
</div>
</ScrollReveal>
<!-- Contact Form -->
<ScrollReveal :delay="200">
<div class="card contact-form-card">
<h3><i class="fas fa-envelope"></i> 发送消息</h3>
<form class="contact-form" @submit.prevent="handleSubmit">
<div class="form-row">
<div class="form-group">
<label for="contact-name">昵称</label>
<input
id="contact-name"
v-model="form.name"
type="text"
placeholder="你的昵称"
required
>
</div>
<div class="form-group">
<label for="contact-email">邮箱</label>
<input
id="contact-email"
v-model="form.email"
type="email"
placeholder="your@email.com"
required
>
</div>
</div>
<div class="form-group">
<label for="contact-subject">主题</label>
<input
id="contact-subject"
v-model="form.subject"
type="text"
placeholder="消息主题"
required
>
</div>
<div class="form-group">
<label for="contact-message">内容</label>
<textarea
id="contact-message"
v-model="form.message"
rows="5"
placeholder="写下你想说的内容..."
required
></textarea>
</div>
<button type="submit" class="btn btn-primary" :disabled="submitted">
<i :class="submitted ? 'fas fa-check' : 'fas fa-paper-plane'"></i>
{{ submitted ? '已发送' : '发送消息' }}
</button>
<p v-if="submitted" class="form-note success">感谢你的消息!我会尽快回复。</p>
<p class="form-note hint">
<i class="fas fa-info-circle"></i>
或直接发送邮件至 <a href="mailto:i@nanami.tech">i@nanami.tech</a>
</p>
</form>
</div>
</ScrollReveal>
</div>
</div>
</section>
</template>
<script>
import { ref, reactive } from 'vue';
import ScrollReveal from './ScrollReveal.vue';
export default {
name: 'ContactSection',
components: {
ScrollReveal
},
props: {
title: {
type: String,
default: '联系我'
},
subtitle: {
type: String,
default: '欢迎技术交流、合作咨询或友情链接'
}
},
setup() {
const submitted = ref(false);
const form = reactive({
name: '',
email: '',
subject: '',
message: ''
});
const contactItems = [
{
label: '电子邮箱',
value: 'contact@nanami.tech',
icon: 'fas fa-envelope',
href: 'mailto:contact@nanami.tech',
external: false
},
{
label: 'Gitea',
value: 'gitea.nanami.tech/NanamiAdmin',
icon: 'fab fa-github',
href: 'https://gitea.nanami.tech/NanamiAdmin',
external: true
},
{
label: '技术博客',
value: 'blog.nanami.tech',
icon: 'fas fa-blog',
href: 'https://blog.nanami.tech',
external: true
}
];
const socialLinks = [
{ label: 'Gitea', icon: 'fab fa-github', url: 'https://gitea.nanami.tech/NanamiAdmin' },
{ label: 'Telegram', icon: 'fab fa-telegram', url: 'https://t.me/MadobiNanami' },
{ label: 'Email', icon: 'fas fa-envelope', url: 'mailto:contact@nanami.tech' }
];
const handleSubmit = () => {
// Static form — show success state since no backend
submitted.value = true;
setTimeout(() => {
submitted.value = false;
form.name = '';
form.email = '';
form.subject = '';
form.message = '';
}, 5000);
};
return {
form,
submitted,
contactItems,
socialLinks,
handleSubmit
};
}
};
</script>
<style scoped>
.contact {
padding: 64px 0;
}
.contact-grid {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 20px;
align-items: start;
}
/* Contact Info */
.contact-info {
display: flex;
flex-direction: column;
gap: 16px;
}
.contact-info-card h3,
.social-card h3,
.contact-form-card h3 {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.contact-info-card h3 i,
.social-card h3 i,
.contact-form-card h3 i {
color: var(--accent);
font-size: 16px;
width: 20px;
text-align: center;
}
.contact-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.contact-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border-radius: 8px;
text-decoration: none;
transition: background-color 0.2s;
}
.contact-item:hover {
background: var(--surface-light);
}
.contact-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--accent-subtle);
color: var(--accent);
font-size: 15px;
flex-shrink: 0;
}
.contact-item-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.contact-item-label {
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.3px;
transition: color 0.3s;
}
.contact-item-value {
font-size: 14px;
font-weight: 500;
color: var(--text-color);
word-break: break-all;
transition: color 0.3s;
}
/* Social */
.social-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.social-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
text-decoration: none;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.social-item:hover {
background: var(--accent-subtle);
color: var(--accent);
}
.social-item i {
font-size: 16px;
width: 20px;
text-align: center;
}
/* Form */
.contact-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
transition: color 0.3s;
}
.form-group input,
.form-group textarea {
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
padding: 10px 14px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 15px;
line-height: 1.47;
background: var(--surface-light);
color: var(--text-color);
transition: all 0.3s;
resize: vertical;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: var(--text-tertiary);
}
.form-note {
font-size: 13px;
margin: 0;
transition: color 0.3s;
}
.form-note.success {
color: var(--accent);
font-weight: 500;
}
.form-note.hint {
color: var(--text-tertiary);
}
.form-note.hint a {
color: var(--accent);
}
@media (max-width: 768px) {
.contact {
padding: 48px 0;
}
.contact-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.social-grid {
grid-template-columns: 1fr;
}
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<template>
<section class="hero section">
<div class="container hero-inner">
<div class="hero-avatar-wrap">
<div class="hero-avatar-ring">
<div class="hero-avatar">
<img
src="https://dl-allfiles.nanami.tech/img/avatar.jpg"
alt="Nanami's avatar"
class="avatar-img"
@error="onAvatarError"
/>
</div>
</div>
</div>
<div class="hero-text">
<h1 class="hero-name">Madobi <span class="hero-name-accent">Nanami</span></h1>
<p class="hero-slogan">
<span class="slogan-icon"><i class="fas fa-terminal"></i></span>
Full Stack Developer | Open Source Enthusiast | Tech Explorer
</p>
<p class="hero-bio">
Tech flows never end!
</p>
<div class="hero-actions">
<router-link to="/projects" class="btn btn-primary">
<i class="fas fa-code"></i> 查看项目
</router-link>
<router-link to="/about" class="btn btn-outline">
<i class="fas fa-user"></i> 了解更多
</router-link>
</div>
</div>
</div>
</section>
</template>
<script>
import { ref } from 'vue';
export default {
name: 'HeroSection',
setup() {
const avatarError = ref(false);
const onAvatarError = () => {
avatarError.value = true;
};
return {
avatarError,
onAvatarError
};
}
};
</script>
<style scoped>
.hero {
padding: 80px 0 64px;
position: relative;
}
.hero-inner {
display: flex;
align-items: center;
gap: clamp(32px, 6vw, 64px);
}
/* Avatar */
.hero-avatar-wrap {
flex-shrink: 0;
}
.hero-avatar-ring {
position: relative;
padding: 4px;
border-radius: 50%;
background: conic-gradient(
var(--accent),
var(--accent),
transparent 60%,
transparent 70%,
var(--accent) 80%,
var(--accent)
);
animation: avatarSpin 8s linear infinite;
}
@keyframes avatarSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hero-avatar {
width: clamp(96px, 15vw, 128px);
height: clamp(96px, 15vw, 128px);
border-radius: 50%;
overflow: hidden;
background: var(--surface-light);
border: 3px solid var(--card-bg);
position: relative;
z-index: 1;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Text */
.hero-text {
flex: 1;
min-width: 0;
}
.hero-name {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: clamp(28px, 5vw, 44px);
font-weight: 700;
line-height: 1.1;
color: var(--text-color);
margin: 0 0 12px 0;
transition: color 0.3s;
}
.hero-name-accent {
color: var(--accent);
}
.hero-slogan {
display: flex;
align-items: center;
gap: 8px;
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: clamp(15px, 2vw, 18px);
font-weight: 500;
line-height: 1.4;
color: var(--text-secondary);
margin: 0 0 12px 0;
transition: color 0.3s;
}
.slogan-icon {
color: var(--accent);
font-size: 14px;
}
.hero-bio {
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: clamp(14px, 1.8vw, 16px);
line-height: 1.6;
color: var(--text-tertiary);
margin: 0 0 24px 0;
max-width: 520px;
transition: color 0.3s;
}
.hero-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* Responsive */
@media (max-width: 768px) {
.hero {
padding: 48px 0 40px;
}
.hero-inner {
flex-direction: column;
text-align: center;
gap: 24px;
}
.hero-avatar-ring {
margin: 0 auto;
}
.hero-slogan {
justify-content: center;
}
.hero-bio {
margin-left: auto;
margin-right: auto;
}
.hero-actions {
justify-content: center;
}
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<template>
<section class="links section">
<div class="container">
<ScrollReveal>
<h2 class="section-title">探索更多</h2>
<p class="section-subtitle">发现我的技术博客、项目作品与资源分享</p>
</ScrollReveal>
<div class="links-grid">
<ScrollReveal
v-for="(card, index) in linkCards"
:key="card.label"
:delay="index * 100"
>
<a :href="card.url" class="link-card card" :target="card.external ? '_blank' : '_self'" :rel="card.external ? 'noopener noreferrer' : undefined">
<div class="link-card-icon" :class="card.accent">
<i :class="card.icon" aria-hidden="true"></i>
</div>
<div class="link-card-content">
<h3 class="link-card-title">{{ card.label }}</h3>
<p class="link-card-desc">{{ card.description }}</p>
</div>
<div class="link-card-arrow">
<i class="fas fa-arrow-right" aria-hidden="true"></i>
</div>
</a>
</ScrollReveal>
</div>
</div>
</section>
</template>
<script>
import ScrollReveal from './ScrollReveal.vue';
export default {
name: 'LinksSection',
components: {
ScrollReveal
},
setup() {
const linkCards = [
{
label: '技术博客',
description: '阅读技术文章、开发笔记与心得体会',
icon: 'fas fa-blog',
url: 'https://blog.nanami.tech',
external: true,
accent: 'accent-teal'
},
{
label: '个人网盘',
description: '访问开源资源、工具软件与文件分享',
icon: 'fas fa-cloud',
url: 'https://drive.nanami.tech',
external: true,
accent: 'accent-warm'
},
{
label: 'GitHub',
description: '浏览我的开源项目与代码贡献',
icon: 'fab fa-github',
url: 'https://github.com/MadobiNanami',
external: true,
accent: 'accent-mono'
},
{
label: '项目作品',
description: '查看完整的项目展示与技术栈详情',
icon: 'fas fa-code',
url: '/projects',
external: false,
accent: 'accent-teal'
}
];
return {
linkCards
};
}
};
</script>
<style scoped>
.links {
padding: 64px 0;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.link-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
text-decoration: none;
cursor: pointer;
position: relative;
overflow: hidden;
}
.link-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
transform: scaleY(0);
transition: transform 0.3s ease;
}
.link-card:hover::before {
transform: scaleY(1);
}
.link-card.accent-teal::before { background: var(--accent); }
.link-card.accent-warm::before { background: #c7784a; }
.link-card.accent-mono::before { background: var(--text-color); }
.link-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 10px;
font-size: 20px;
flex-shrink: 0;
transition: transform 0.3s ease;
}
.link-card:hover .link-card-icon {
transform: scale(1.1);
}
.accent-teal .link-card-icon {
background: var(--accent-subtle);
color: var(--accent);
}
.accent-warm .link-card-icon {
background: rgba(199, 120, 74, 0.1);
color: #c7784a;
}
.accent-mono .link-card-icon {
background: var(--tag-bg);
color: var(--text-secondary);
}
.link-card-content {
flex: 1;
min-width: 0;
}
.link-card-title {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 1.3;
color: var(--text-color);
margin: 0 0 4px 0;
transition: color 0.3s;
}
.link-card-desc {
font-size: 13px;
line-height: 1.4;
color: var(--text-tertiary);
margin: 0;
transition: color 0.3s;
}
.link-card-arrow {
color: var(--text-tertiary);
font-size: 13px;
transition: all 0.3s ease;
flex-shrink: 0;
}
.link-card:hover .link-card-arrow {
color: var(--accent);
transform: translateX(4px);
}
@media (max-width: 768px) {
.links {
padding: 48px 0;
}
.links-grid {
grid-template-columns: 1fr;
}
.link-card {
padding: 16px;
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<template>
<footer class="page-footer">
<div class="container footer-inner">
<div class="footer-left">
<span class="footer-logo">
<span class="logo-bracket">&lt;</span>Madobi Nanami<span class="logo-bracket"> /&gt;</span>
</span>
<span class="footer-copy">&copy; {{ currentYear }} Madobi Nanami Ver.0.0.0.1</span>
</div>
<div class="footer-right">
<a
v-for="link in socialLinks"
:key="link.label"
:href="link.url"
:aria-label="link.label"
:title="link.label"
class="social-link"
target="_blank"
rel="noopener noreferrer"
>
<i :class="link.icon" aria-hidden="true"></i>
</a>
</div>
</div>
</footer>
</template>
<script>
export default {
name: 'PageFooter',
setup() {
const currentYear = new Date().getFullYear();
const socialLinks = [
{ label: 'Gitea', icon: 'fab fa-github', url: 'https://gitea.nanami.tech/NanamiAdmin' },
{ label: 'Email', icon: 'fas fa-envelope', url: 'mailto:contact@nanami.tech' },
];
return {
currentYear,
socialLinks
};
}
};
</script>
<style scoped>
.page-footer {
border-top: 1px solid var(--border-color);
padding: 28px 0;
background: var(--bg-color);
transition: background-color 0.3s, border-color 0.3s;
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
display: flex;
align-items: center;
gap: 16px;
}
.footer-logo {
font-family: 'SF Mono', 'Courier New', 'Consolas', 'Monaco', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
transition: color 0.3s;
}
.logo-bracket {
color: var(--text-tertiary);
transition: color 0.3s;
}
.footer-copy {
font-size: 13px;
color: var(--text-tertiary);
transition: color 0.3s;
}
.footer-right {
display: flex;
align-items: center;
gap: 16px;
}
.social-link {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
color: var(--text-tertiary);
text-decoration: none;
font-size: 15px;
transition: all 0.2s;
}
.social-link:hover {
color: var(--accent);
background: var(--accent-subtle);
}
@media (max-width: 768px) {
.footer-inner {
flex-direction: column;
gap: 16px;
}
.footer-left {
flex-direction: column;
gap: 8px;
text-align: center;
}
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div ref="el" class="scroll-reveal" :class="{ 'is-visible': isVisible }" :style="{ transitionDelay: delay + 'ms' }">
<slot />
</div>
</template>
<script>
import { ref, onMounted, onBeforeUnmount } from 'vue';
export default {
name: 'ScrollReveal',
props: {
delay: {
type: Number,
default: 0
},
threshold: {
type: Number,
default: 0.15
}
},
setup(props) {
const el = ref(null);
const isVisible = ref(false);
let observer = null;
onMounted(() => {
if (!el.value) return;
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
isVisible.value = true;
observer.unobserve(entry.target);
}
});
},
{
threshold: props.threshold,
rootMargin: '0px 0px -40px 0px'
}
);
observer.observe(el.value);
});
onBeforeUnmount(() => {
if (observer) {
observer.disconnect();
}
});
return {
el,
isVisible
};
}
};
</script>
<style scoped>
.scroll-reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
will-change: opacity, transform;
}
.scroll-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<header class="topnav" :class="{ 'is-scrolled': isScrolled, 'menu-open': menuOpen }">
<div class="topnav-inner container">
<router-link to="/" class="topnav-logo" @click="closeMenu">
<span class="logo-bracket">&lt;</span>
<span class="logo-text">Madobi Nanami</span>
<span class="logo-bracket"> /&gt;</span>
</router-link>
<nav class="topnav-links" :class="{ 'is-open': menuOpen }">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-link"
:class="{ active: isActive(item.path) }"
@click="closeMenu"
>
<i :class="item.icon" class="nav-icon" aria-hidden="true"></i>
<span>{{ item.label }}</span>
</router-link>
<button class="theme-toggle-mobile" @click="handleToggleTheme" :aria-label="isDarkMode ? '切换到浅色模式' : '切换到深色模式'">
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'" aria-hidden="true"></i>
<span>{{ isDarkMode ? '浅色模式' : '深色模式' }}</span>
</button>
</nav>
<div class="topnav-actions">
<button class="theme-toggle-btn" @click="toggleTheme" :aria-label="isDarkMode ? '切换到浅色模式' : '切换到深色模式'">
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'" aria-hidden="true"></i>
</button>
<button class="hamburger" :class="{ 'is-active': menuOpen }" @click="toggleMenu" aria-label="切换菜单">
<span></span>
<span></span>
<span></span>
</button>
</div>
</div>
</header>
</template>
<script>
import { ref, inject, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
export default {
name: 'TopNav',
setup() {
const route = useRoute();
const isDarkMode = inject('isDarkMode');
const toggleTheme = inject('toggleTheme');
const menuOpen = ref(false);
const isScrolled = ref(false);
const navItems = [
{ path: '/', label: '主页', icon: 'fas fa-home' },
{ path: '/projects', label: '项目', icon: 'fas fa-code' },
{ path: '/contact', label: '联系', icon: 'fas fa-envelope' },
{ path: '/about', label: '关于', icon: 'fas fa-user' }
];
const isActive = (path) => {
if (path === '/') return route.path === '/';
return route.path.startsWith(path);
};
const toggleMenu = () => {
menuOpen.value = !menuOpen.value;
if (menuOpen.value) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
};
const closeMenu = () => {
menuOpen.value = false;
document.body.style.overflow = '';
};
const handleToggleTheme = () => {
toggleTheme();
closeMenu();
};
const handleScroll = () => {
isScrolled.value = window.scrollY > 10;
};
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true });
});
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll);
document.body.style.overflow = '';
});
return {
navItems,
isActive,
menuOpen,
toggleMenu,
closeMenu,
isScrolled,
isDarkMode,
toggleTheme,
handleToggleTheme
};
}
};
</script>
<style scoped>
.topnav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 64px;
background: var(--nav-bg);
backdrop-filter: var(--nav-blur);
-webkit-backdrop-filter: var(--nav-blur);
border-bottom: 1px solid transparent;
transition: background-color 0.3s, border-color 0.3s, box-shadow 0.3s;
}
.topnav.is-scrolled {
border-bottom-color: var(--border-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.topnav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
}
/* Logo */
.topnav-logo {
display: flex;
align-items: center;
gap: 2px;
text-decoration: none;
font-family: 'SF Mono', 'Courier New', 'Consolas', 'Monaco', monospace;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.5px;
white-space: nowrap;
z-index: 101;
}
.logo-bracket {
color: var(--text-tertiary);
transition: color 0.3s;
}
.logo-text {
color: var(--text-color);
transition: color 0.3s;
}
.topnav-logo:hover .logo-bracket {
color: var(--accent);
}
.topnav-logo:hover .logo-text {
color: var(--accent);
}
/* Nav Links */
.topnav-links {
display: flex;
align-items: center;
gap: 4px;
}
.nav-link {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.29;
letter-spacing: -0.224px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s;
position: relative;
}
.nav-link:hover {
color: var(--text-color);
background: var(--surface-light);
}
.nav-link.active {
color: var(--accent);
background: var(--accent-subtle);
}
.nav-icon {
font-size: 13px;
width: 16px;
text-align: center;
}
/* Theme Toggle (Desktop) */
.theme-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: var(--surface-light);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 16px;
}
.theme-toggle-btn:hover {
background: var(--surface-hover);
color: var(--text-color);
}
/* Theme Toggle (Mobile menu) */
.theme-toggle-mobile {
display: none;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--surface-light);
color: var(--text-secondary);
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
margin-top: 8px;
}
.theme-toggle-mobile:hover {
background: var(--surface-hover);
color: var(--text-color);
}
/* Hamburger */
.hamburger {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
z-index: 101;
padding: 0;
}
.hamburger span {
display: block;
width: 18px;
height: 2px;
background: var(--text-color);
border-radius: 2px;
transition: all 0.3s;
}
.hamburger.is-active span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.hamburger.is-active span:nth-child(2) {
opacity: 0;
}
.hamburger.is-active span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
.topnav-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Responsive */
@media (max-width: 768px) {
.topnav-links {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 4px;
padding: 80px 24px 40px;
background: var(--bg-color);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
z-index: 100;
}
.topnav-links.is-open {
opacity: 1;
visibility: visible;
}
.nav-link {
padding: 14px 16px;
font-size: 18px;
border-radius: 10px;
}
.nav-icon {
font-size: 16px;
width: 24px;
}
.theme-toggle-mobile {
display: flex;
}
.theme-toggle-btn {
display: none;
}
.hamburger {
display: flex;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router/index.js';
import '@fortawesome/fontawesome-free/css/all.css';
const app = createApp(App);
app.use(router);
app.mount('#app');
+51
View File
@@ -0,0 +1,51 @@
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
meta: { title: 'Nanami — 主页' }
},
{
path: '/projects',
name: 'Projects',
component: () => import('../views/Projects.vue'),
meta: { title: '项目 — Nanami' }
},
{
path: '/contact',
name: 'Contact',
component: () => import('../views/Contact.vue'),
meta: { title: '联系 — Nanami' }
},
{
path: '/about',
name: 'About',
component: () => import('../views/About.vue'),
meta: { title: '关于 — Nanami' }
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
];
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
return { top: 0 };
}
});
router.afterEach((to) => {
if (to.meta && to.meta.title) {
document.title = to.meta.title;
}
});
export default router;
+241
View File
@@ -0,0 +1,241 @@
/* ============ Selection ============ */
::selection {
background: var(--selection-bg);
}
/* ============ Page Header ============ */
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: clamp(28px, 4vw, 40px);
font-weight: 600;
line-height: 1.1;
color: var(--text-color);
margin: 0;
letter-spacing: normal;
transition: color 0.3s;
}
/* ============ Card ============ */
.card {
background: var(--card-bg);
border-radius: 12px;
padding: 24px;
box-shadow: var(--card-shadow);
transition: background-color 0.3s, box-shadow 0.3s, transform 0.3s ease;
border: 1px solid var(--border-color);
}
.card:hover {
box-shadow: var(--card-shadow-hover);
transform: translateY(-4px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-header h3 {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: clamp(18px, 2.5vw, 21px);
font-weight: 700;
line-height: 1.19;
letter-spacing: 0.231px;
color: var(--text-color);
margin: 0;
transition: color 0.3s;
}
.card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ============ Section ============ */
.section {
padding: 80px 0;
}
.section-title {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: clamp(24px, 3.5vw, 32px);
font-weight: 600;
line-height: 1.15;
color: var(--text-color);
margin: 0 0 12px 0;
transition: color 0.3s;
}
.section-subtitle {
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: clamp(15px, 2vw, 17px);
line-height: 1.47;
color: var(--text-tertiary);
margin: 0 0 40px 0;
transition: color 0.3s;
}
/* ============ Container ============ */
.container {
width: 100%;
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
}
/* ============ Tag / Badge ============ */
.tag {
display: inline-block;
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
padding: 4px 12px;
border-radius: 980px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
letter-spacing: -0.08px;
background: var(--tag-bg);
color: var(--tag-color);
transition: background-color 0.3s, color 0.3s;
}
.tag.accent {
background: var(--tag-accent-bg);
color: var(--tag-accent-color);
}
/* ============ Buttons ============ */
.btn {
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 980px;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
}
.btn-primary {
background: var(--accent);
color: #ffffff;
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-outline {
background: transparent;
color: var(--accent);
border: 2px solid var(--accent);
}
.btn-outline:hover {
background: var(--accent-subtle);
}
.btn-ghost {
background: var(--surface-light);
color: var(--text-color);
}
.btn-ghost:hover {
background: var(--surface-hover);
}
/* ============ Links ============ */
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--accent-hover);
}
/* ============ Code / Mono ============ */
code, .mono {
font-family: 'SF Mono', 'Courier New', 'Consolas', 'Monaco', monospace;
background: var(--code-bg);
color: var(--code-color);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* ============ Divider ============ */
.divider {
width: 100%;
height: 1px;
background: var(--border-color);
margin: 0;
transition: background-color 0.3s;
}
/* ============ Grid Background ============ */
.bg-grid {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
opacity: 0.6;
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: 48px 48px;
transition: background-image 0.3s;
}
/* ============ Scroll Reveal Base ============ */
.scroll-reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.scroll-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* ============ Empty State ============ */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-tertiary);
font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 17px;
line-height: 1.47;
}
/* ============ Responsive ============ */
@media (max-width: 768px) {
.section {
padding: 48px 0;
}
.container {
padding: 0 16px;
}
.card {
padding: 16px;
}
}
+95
View File
@@ -0,0 +1,95 @@
:root {
/* Background */
--bg-color: #f8f8f6;
--bg-secondary: #f0f0ee;
/* Text */
--text-color: #1a1a1c;
--text-secondary: rgba(0, 0, 0, 0.65);
--text-tertiary: rgba(0, 0, 0, 0.4);
/* Surface / Card */
--card-bg: #ffffff;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.04);
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.08), 0 8px 32px rgba(0, 0, 0, 0.06);
--surface-light: #f0f0ee;
--surface-hover: #e8e8e5;
/* Accent — Warm Teal */
--accent: #2d9d8e;
--accent-hover: #238b7d;
--accent-subtle: rgba(45, 157, 142, 0.08);
--accent-glow: rgba(45, 157, 142, 0.15);
/* Border */
--border-color: rgba(0, 0, 0, 0.06);
--border-color-strong: rgba(0, 0, 0, 0.1);
/* Navigation */
--nav-bg: rgba(248, 248, 246, 0.8);
--nav-blur: saturate(180%) blur(20px);
/* Avatar ring */
--avatar-ring: rgba(45, 157, 142, 0.2);
/* Tag / Badge */
--tag-bg: rgba(0, 0, 0, 0.04);
--tag-color: rgba(0, 0, 0, 0.6);
--tag-accent-bg: rgba(45, 157, 142, 0.1);
--tag-accent-color: #1f7a6e;
/* Code / Tech elements */
--code-bg: rgba(0, 0, 0, 0.03);
--code-color: #1a7a6a;
/* Grid pattern */
--grid-color: rgba(0, 0, 0, 0.03);
/* Focus ring */
--focus-ring: rgba(45, 157, 142, 0.4);
/* Selection */
--selection-bg: rgba(45, 157, 142, 0.2);
}
.dark {
--bg-color: #0f0f11;
--bg-secondary: #16161a;
--text-color: #e8e8ec;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.4);
--card-bg: #1a1a1e;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.2);
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.4), 0 8px 32px rgba(0, 0, 0, 0.3);
--surface-light: #16161a;
--surface-hover: #222228;
--accent: #4ec9b5;
--accent-hover: #5fd4c2;
--accent-subtle: rgba(78, 201, 181, 0.1);
--accent-glow: rgba(78, 201, 181, 0.15);
--border-color: rgba(255, 255, 255, 0.06);
--border-color-strong: rgba(255, 255, 255, 0.1);
--nav-bg: rgba(15, 15, 17, 0.8);
--nav-blur: saturate(180%) blur(20px);
--avatar-ring: rgba(78, 201, 181, 0.25);
--tag-bg: rgba(255, 255, 255, 0.06);
--tag-color: rgba(255, 255, 255, 0.6);
--tag-accent-bg: rgba(78, 201, 181, 0.12);
--tag-accent-color: #4ec9b5;
--code-bg: rgba(255, 255, 255, 0.04);
--code-color: #4ec9b5;
--grid-color: rgba(255, 255, 255, 0.02);
--focus-ring: rgba(78, 201, 181, 0.4);
--selection-bg: rgba(78, 201, 181, 0.25);
}
+37
View File
@@ -0,0 +1,37 @@
import { ref, onMounted, watch } from 'vue';
export function useTheme() {
const isDarkMode = ref(false);
const updateTheme = () => {
if (isDarkMode.value) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
onMounted(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
isDarkMode.value = savedTheme === 'dark';
} else {
isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
updateTheme();
});
watch(isDarkMode, () => {
updateTheme();
localStorage.setItem('theme', isDarkMode.value ? 'dark' : 'light');
});
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value;
};
return {
isDarkMode,
toggleTheme
};
}
+284
View File
@@ -0,0 +1,284 @@
<template>
<div class="about-page">
<AboutSection
title="关于我"
subtitle="了解我的技术背景、经历与兴趣"
/>
<div class="divider"></div>
<!-- Timeline Section -->
<section class="section">
<div class="container">
<ScrollReveal>
<h2 class="section-title">技术旅程</h2>
<p class="section-subtitle">我的学习与技术成长之路</p>
</ScrollReveal>
<div class="timeline">
<ScrollReveal
v-for="(item, index) in timeline"
:key="item.year"
:delay="index * 100"
>
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content card">
<span class="timeline-year">{{ item.year }}</span>
<h3 class="timeline-title">{{ item.title }}</h3>
<p class="timeline-desc">{{ item.description }}</p>
</div>
</div>
</ScrollReveal>
</div>
</div>
</section>
<div class="divider"></div>
<!-- Interests Section -->
<section class="section">
<div class="container">
<ScrollReveal>
<h2 class="section-title">兴趣领域</h2>
<p class="section-subtitle">在工作之外,我热衷于探索这些技术方向</p>
</ScrollReveal>
<div class="interests-grid">
<ScrollReveal
v-for="interest in interests"
:key="interest.title"
:delay="100"
>
<div class="card interest-card">
<div class="interest-icon">
<i :class="interest.icon" aria-hidden="true"></i>
</div>
<h3 class="interest-title">{{ interest.title }}</h3>
<p class="interest-desc">{{ interest.description }}</p>
</div>
</ScrollReveal>
</div>
</div>
</section>
</div>
</template>
<script>
import AboutSection from '../components/AboutSection.vue';
import ScrollReveal from '../components/ScrollReveal.vue';
export default {
name: 'About',
components: {
AboutSection,
ScrollReveal
},
setup() {
const timeline = [
{
year: '2013',
title: '计算机兴趣启蒙',
description: '从老家的一台老旧笔记本电脑开始,对计算机产生了浓厚的兴趣。并开始以此为方向进行自学,探索计算机的各个领域。'
},
{
year: '2017',
title: '初次接触开发',
description: '以当时较为流行的 "易语言" 作为入门语言,开始学习编程基础,尝试开发简单的桌面应用程序。'
},
{
year: '2018',
title: '坚实基础',
description: '开始频繁地做计算机桌面运维,不断地积累经验,学习计算机网络、操作系统原理等基础知识,为后续的技术发展打下坚实基础。'
},
{
year: '2022',
title: '初识运维',
description: '首次了解企业 Linux 服务器运维,开始接触云计算、Docker、Kubernetes 等技术,逐步深入了解现代运维与云原生架构。'
},
{
year: '2024',
title: 'HomeLab 运维',
description: '搭建了属于自己的 HomeLab,实践企业级服务部署与运维,探索各种技术的边界与可能性。'
},
{
year: 'Future...',
title: '持续探索',
description: '保持对技术的热爱,持续学习云计算、AI 工程化等前沿技术,分享知识与经验。'
}
];
const interests = [
{
title: '系统编程',
description: '深入理解操作系统原理,热衷于用 Go 和 C++ 构建高性能底层软件。',
icon: 'fas fa-gears'
},
{
title: '云原生架构',
description: '研究 Kubernetes、服务网格与可观测性,构建弹性可扩展的云基础设施。',
icon: 'fas fa-cloud'
},
{
title: '开源社区',
description: '参与开源项目,相信知识共享与技术协作的力量。',
icon: 'fas fa-code-branch'
},
{
title: '网络工程',
description: '深入探索网络协议底层知识,构建高效、可靠的网络基础设施。',
icon: 'fas fa-network-wired'
}
];
return {
timeline,
interests
};
}
};
</script>
<style scoped>
.about-page {
overflow: hidden;
}
/* Timeline */
.timeline {
position: relative;
padding-left: 32px;
}
.timeline::before {
content: '';
position: absolute;
left: 7px;
top: 8px;
bottom: 8px;
width: 2px;
background: var(--border-color);
transition: background-color 0.3s;
}
.timeline-item {
position: relative;
margin-bottom: 24px;
}
.timeline-item:last-child {
margin-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -32px;
top: 20px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--card-bg);
border: 3px solid var(--accent);
z-index: 1;
transition: background-color 0.3s;
}
.timeline-content {
position: relative;
}
.timeline-year {
display: inline-block;
font-family: 'SF Mono', 'Courier New', 'Consolas', monospace;
font-size: 13px;
font-weight: 600;
color: var(--accent);
margin-bottom: 6px;
}
.timeline-title {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 17px;
font-weight: 600;
line-height: 1.3;
color: var(--text-color);
margin: 0 0 8px 0;
transition: color 0.3s;
}
.timeline-desc {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
margin: 0;
transition: color 0.3s;
}
/* Interests */
.interests-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.interest-card {
text-align: center;
padding: 28px 20px;
}
.interest-icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 14px;
background: var(--accent-subtle);
color: var(--accent);
font-size: 22px;
margin: 0 auto 16px;
transition: transform 0.3s ease;
}
.interest-card:hover .interest-icon {
transform: scale(1.1);
}
.interest-title {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 1.3;
color: var(--text-color);
margin: 0 0 8px 0;
transition: color 0.3s;
}
.interest-desc {
font-size: 13px;
line-height: 1.5;
color: var(--text-tertiary);
margin: 0;
transition: color 0.3s;
}
@media (max-width: 768px) {
.timeline {
padding-left: 24px;
}
.timeline-dot {
left: -24px;
width: 12px;
height: 12px;
}
.timeline::before {
left: 5px;
}
.interests-grid {
grid-template-columns: 1fr;
}
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<template>
<div class="contact-page">
<ContactSection />
</div>
</template>
<script>
import ContactSection from '../components/ContactSection.vue';
export default {
name: 'Contact',
components: {
ContactSection
}
};
</script>
<style scoped>
.contact-page {
overflow: hidden;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="home-page">
<HeroSection />
<div class="divider"></div>
<LinksSection />
<div class="divider"></div>
<AboutSection />
</div>
</template>
<script>
import HeroSection from '../components/HeroSection.vue';
import LinksSection from '../components/LinksSection.vue';
import AboutSection from '../components/AboutSection.vue';
export default {
name: 'Home',
components: {
HeroSection,
LinksSection,
AboutSection
}
};
</script>
<style scoped>
.home-page {
overflow: hidden;
}
</style>
+194
View File
@@ -0,0 +1,194 @@
<template>
<div class="projects-page">
<section class="section">
<div class="container">
<ScrollReveal>
<h2 class="section-title">项目作品</h2>
<p class="section-subtitle">我参与开发的开源项目与技术实践</p>
</ScrollReveal>
<div class="projects-grid">
<ScrollReveal
v-for="project in projects"
:key="project.name"
:delay="100"
>
<article class="project-card card">
<div class="project-header">
<div class="project-icon">
<i :class="project.icon" aria-hidden="true"></i>
</div>
<div class="project-meta">
<h3 class="project-name">{{ project.name }}</h3>
<span class="project-type">{{ project.type }}</span>
</div>
</div>
<p class="project-desc">{{ project.description }}</p>
<div class="project-tags">
<span v-for="tech in project.tech" :key="tech" class="tag">{{ tech }}</span>
</div>
<div class="project-links">
<a
v-if="project.github"
:href="project.github"
class="btn btn-ghost btn-sm"
target="_blank"
rel="noopener noreferrer"
>
<i class="fab fa-github"></i> Gitea
</a>
<a
v-if="project.demo"
:href="project.demo"
class="btn btn-outline btn-sm"
target="_blank"
rel="noopener noreferrer"
>
<i class="fas fa-external-link-alt"></i> 演示
</a>
</div>
</article>
</ScrollReveal>
</div>
</div>
</section>
</div>
</template>
<script>
import ScrollReveal from '../components/ScrollReveal.vue';
export default {
name: 'Projects',
components: {
ScrollReveal
},
setup() {
const projects = [
{
name: 'Super-frpc',
type: '全栈项目',
description: 'frpc 实例集成管理系统,提供 Web 管理界面用于管理多个 frp 客户端实例,支持实时日志、状态监控与配置管理。',
icon: 'fas fa-network-wired',
tech: ['Go', 'Vue 3', 'frp', 'WebSocket', 'Docker'],
github: 'https://gitea.nanami.tech/Super-frpc'
},
{
name: 'OtogeCompetitionHelper',
type: '全栈项目',
description: '音游竞赛辅助工具,为音乐游戏社区提供比赛管理、成绩追踪与统计数据展示的 Web 平台。',
icon: 'fas fa-music',
tech: ['Go', 'Vue 3', 'WebSocket', 'MySQL', 'Bot API'],
github: 'https://gitea.nanami.tech/OtogeCompetitionHelper'
},
{
name: 'NanamiHomePage',
type: '前端项目',
description: '个人主页项目,使用 Vue 3 构建的纯静态响应式个人主页,支持深色/浅色模式自动切换。',
icon: 'fas fa-home',
tech: ['Vue 3', 'Vite', 'Vue Router', 'CSS Variables'],
github: 'https://gitea.nanami.tech/NanamiWork/HomePage'
}
];
return {
projects
};
}
};
</script>
<style scoped>
.projects-page {
overflow: hidden;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.project-card {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
}
.project-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.project-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--accent-subtle);
color: var(--accent);
font-size: 20px;
flex-shrink: 0;
}
.project-meta {
flex: 1;
min-width: 0;
}
.project-name {
font-family: 'SF Pro Display', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 17px;
font-weight: 600;
line-height: 1.25;
color: var(--text-color);
margin: 0 0 4px 0;
transition: color 0.3s;
}
.project-type {
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.3px;
transition: color 0.3s;
}
.project-desc {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
margin: 0;
flex: 1;
transition: color 0.3s;
}
.project-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.project-links {
display: flex;
gap: 8px;
padding-top: 4px;
}
.btn-sm {
padding: 6px 14px;
font-size: 13px;
border-radius: 6px;
}
@media (max-width: 768px) {
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 3000
}
})