feat: 个人主页 + 部署配置(www.mikivl.online)

- vite.config.ts: 加 base: '/app/',App 部署在子路径
- server/index.ts: MODELS_FILE 支持环境变量覆盖(容器化写权限)
- homepage/index.html: 极简开发者风格个人主页(About/Projects/Skills/Contact)
- nginx/default.conf: 反向代理,SSE proxy_buffering off,SPA fallback
- docker-compose.yml: Nginx + Hono 容器编排,models_data volume 持久化
- deploy.sh: 一键本地构建 + rsync 上传 + 远端重启

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-05-03 01:27:39 +08:00
parent 9c534a920d
commit 5e01c8df4a
7 changed files with 504 additions and 2 deletions

51
deploy.sh Executable file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# deploy.sh — 本地运行,构建并上传到 VPS
set -euo pipefail
VPS_USER="root"
VPS_HOST="your.vps.ip" # ← 替换为你的 VPS IP 或域名
VPS_DIR="/opt/mikivl"
info() { printf "\033[36m[INFO]\033[0m %s\n" "$*"; }
ok() { printf "\033[32m[OK]\033[0m %s\n" "$*"; }
die() { printf "\033[31m[ERROR]\033[0m %s\n" "$*" >&2; exit 1; }
# ── Step 1: 本地构建 ──────────────────────────────────────────────────────────
info "Building Vite app (base=/app/) ..."
npm run build
ok "Build complete → dist/"
# ── Step 2: 上传文件 ──────────────────────────────────────────────────────────
info "Uploading to ${VPS_USER}@${VPS_HOST}:${VPS_DIR} ..."
ssh "${VPS_USER}@${VPS_HOST}" "mkdir -p ${VPS_DIR}/{homepage,app,nginx,program1/server,program1/node_modules}"
# 个人主页
scp homepage/index.html "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/homepage/"
# App 构建产物
rsync -az --delete dist/ "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/app/"
# 后端源码
rsync -az server/ "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/program1/server/"
scp package.json package-lock.json "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/program1/"
# Nginx 配置 + Docker Compose
scp nginx/default.conf "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/nginx/"
scp docker-compose.yml "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/"
ok "Upload complete."
# ── Step 3: VPS 上安装依赖并重启服务 ─────────────────────────────────────────
info "Restarting services on VPS ..."
ssh "${VPS_USER}@${VPS_HOST}" bash <<'REMOTE'
set -euo pipefail
cd /opt/mikivl/program1
npm ci --omit=dev
cd /opt/mikivl
docker compose up -d --remove-orphans
docker compose ps
REMOTE
ok "Deployment complete! → https://www.mikivl.online"

38
docker-compose.yml Normal file
View File

@ -0,0 +1,38 @@
services:
hono-server:
image: node:22-alpine
working_dir: /app
command: npx tsx server/index.ts
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- MODELS_FILE=/app/models_data/models.json
- NODE_ENV=production
volumes:
- ./program1:/app:ro
- models_data:/app/models_data
expose:
- "3001"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/models"]
interval: 30s
timeout: 5s
retries: 3
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./homepage:/usr/share/nginx/html/homepage:ro
- ./app:/usr/share/nginx/html/app:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
hono-server:
condition: service_healthy
restart: unless-stopped
volumes:
models_data:

357
homepage/index.html Normal file
View File

@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="mikivl — 独立开发者,专注构建 AI 驱动的生产力工具" />
<title>mikivl — 开发项目</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d11;
--bg-card: #13131a;
--bg-hover: #1a1a24;
--border: #22222e;
--text: #e2e2ec;
--muted: #7070a0;
--faint: #3a3a55;
--accent: #818cf8;
--accent2: #34d399;
--tag-bg: #1e1b4b;
--tag-text: #a5b4fc;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'SF Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 14px;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
/* ── Nav ── */
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 clamp(1.5rem, 5vw, 4rem);
height: 56px;
background: rgba(13,13,17,0.88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-logo {
font-size: 1rem; font-weight: 700; color: var(--accent);
text-decoration: none; letter-spacing: -0.02em;
}
.nav-logo span { color: var(--muted); font-weight: 400; }
.nav-links { display: flex; gap: 2rem; list-style: none; }
.nav-links a {
color: var(--muted); text-decoration: none; font-size: 0.8rem;
letter-spacing: 0.06em; text-transform: uppercase;
transition: color 0.15s;
}
.nav-links a:hover { color: var(--text); }
/* ── Layout ── */
main { max-width: 860px; margin: 0 auto; padding: 0 clamp(1.5rem, 5vw, 2rem); }
section { padding: 6rem 0 2rem; }
section:first-of-type { padding-top: 9rem; }
/* ── Section label ── */
.section-label {
font-size: 0.72rem; letter-spacing: 0.15em; text-transform: uppercase;
color: var(--accent); margin-bottom: 0.5rem;
}
h2 {
font-size: clamp(1.4rem, 3vw, 1.9rem); font-weight: 700;
color: var(--text); margin-bottom: 1.75rem; letter-spacing: -0.03em;
}
/* ── Hero ── */
.hero-greeting {
font-size: 0.85rem; color: var(--accent2); margin-bottom: 0.75rem;
letter-spacing: 0.08em;
}
.hero-name {
font-size: clamp(2.2rem, 7vw, 3.6rem); font-weight: 800;
color: var(--text); letter-spacing: -0.04em; line-height: 1.05;
margin-bottom: 1.25rem;
}
.hero-name em { color: var(--accent); font-style: normal; }
.hero-desc {
font-size: 0.95rem; color: var(--muted); max-width: 500px;
line-height: 1.85; margin-bottom: 2.25rem;
font-family: system-ui, -apple-system, sans-serif;
}
.hero-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.55rem 1.1rem;
border-radius: 6px; font-size: 0.82rem;
text-decoration: none; transition: all 0.15s;
font-family: inherit;
}
.btn-primary {
background: var(--accent); color: #0d0d11; border: 1px solid var(--accent);
font-weight: 600;
}
.btn-primary:hover { background: #a5b4fc; border-color: #a5b4fc; }
.btn-ghost {
background: transparent; color: var(--muted); border: 1px solid var(--border);
}
.btn-ghost:hover { border-color: var(--faint); color: var(--text); background: var(--bg-hover); }
/* ── Divider ── */
.divider { border: none; border-top: 1px solid var(--border); margin: 0; }
/* ── Projects ── */
.projects-grid { display: grid; gap: 1rem; }
.project-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem 1.6rem;
text-decoration: none;
display: block;
transition: border-color 0.15s, background 0.15s, transform 0.18s;
position: relative; overflow: hidden;
}
.project-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
opacity: 0; transition: opacity 0.2s;
}
.project-card:hover {
border-color: var(--faint);
background: var(--bg-hover);
transform: translateY(-2px);
}
.project-card:hover::before { opacity: 1; }
.project-header {
display: flex; align-items: flex-start;
justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem;
}
.project-name {
font-size: 1rem; font-weight: 700; color: var(--text);
letter-spacing: -0.02em;
}
.project-status {
font-size: 0.7rem; padding: 0.15rem 0.5rem;
border-radius: 20px; letter-spacing: 0.05em;
background: #052e16; color: #6ee7b7;
white-space: nowrap; margin-top: 2px;
}
.project-desc {
font-size: 0.85rem; color: var(--muted); line-height: 1.75;
font-family: system-ui, -apple-system, sans-serif;
margin-bottom: 1.1rem;
}
.project-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.tag {
font-size: 0.7rem; padding: 0.18rem 0.5rem;
background: var(--tag-bg); color: var(--tag-text);
border-radius: 4px; letter-spacing: 0.03em;
}
.tag.green { background: #052e16; color: #6ee7b7; }
.tag.purple { background: #2e1065; color: #c4b5fd; }
.tag.orange { background: #431407; color: #fdba74; }
.tag.sky { background: #082f49; color: #7dd3fc; }
/* ── Skills ── */
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1rem;
}
.skill-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.1rem 1.2rem;
}
.skill-group-title {
font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); margin-bottom: 0.8rem;
}
.skill-list { list-style: none; }
.skill-list li {
font-size: 0.83rem; color: var(--muted); padding: 0.22rem 0;
display: flex; align-items: center; gap: 0.5rem;
}
.skill-list li::before { content: '▸'; color: var(--faint); font-size: 0.65rem; }
/* ── Contact ── */
.contact-links { display: flex; flex-direction: column; gap: 0.9rem; }
.contact-link {
display: inline-flex; align-items: center; gap: 0.75rem;
color: var(--muted); text-decoration: none; font-size: 0.88rem;
transition: color 0.15s; width: fit-content;
}
.contact-link:hover { color: var(--accent); }
.contact-icon { color: var(--faint); font-size: 1rem; width: 1.2rem; text-align: center; }
/* ── Footer ── */
footer {
border-top: 1px solid var(--border);
padding: 2rem clamp(1.5rem, 5vw, 4rem);
text-align: center;
color: var(--faint); font-size: 0.75rem;
margin-top: 4rem;
}
/* ── Cursor blink ── */
.cursor {
display: inline-block; width: 3px; height: 0.85em;
background: var(--accent); margin-left: 1px;
vertical-align: text-bottom; border-radius: 1px;
animation: blink 1.1s step-end infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
/* ── Responsive ── */
@media (max-width: 600px) {
.nav-links { gap: 1.2rem; }
.skills-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 400px) {
.nav-links li:nth-child(3), .nav-links li:nth-child(4) { display: none; }
}
</style>
</head>
<body>
<nav>
<a class="nav-logo" href="#about">mikivl<span>.online</span></a>
<ul class="nav-links">
<li><a href="#about">about</a></li>
<li><a href="#projects">projects</a></li>
<li><a href="#skills">skills</a></li>
<li><a href="#contact">contact</a></li>
</ul>
</nav>
<main>
<!-- About -->
<section id="about">
<p class="hero-greeting">// hello, world</p>
<h1 class="hero-name">mikivl<em>.</em><span class="cursor"></span></h1>
<p class="hero-desc">
独立开发者,专注于构建 AI 驱动的生产力工具。<br>
喜欢探索 AI 与实用工具的交叉地带,用代码解决真实问题。
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#projects">查看项目 →</a>
<a class="btn btn-ghost" href="#contact">联系我</a>
</div>
</section>
<hr class="divider" />
<!-- Projects -->
<section id="projects">
<p class="section-label">// projects</p>
<h2>开发项目</h2>
<div class="projects-grid">
<a class="project-card" href="/app/" target="_blank" rel="noopener">
<div class="project-header">
<span class="project-name">读书笔记 App</span>
<span class="project-status">● 上线中</span>
</div>
<p class="project-desc">
AI 驱动的个人读书笔记工具。富文本编辑器,支持 AI 续写、润色、摘要、翻译,
多文件夹管理标签系统导入导出MD / DOCX / PDF数据本地存储隐私优先。
</p>
<div class="project-tags">
<span class="tag">React 19</span>
<span class="tag">TypeScript</span>
<span class="tag sky">Vite</span>
<span class="tag green">Hono</span>
<span class="tag purple">Claude API</span>
<span class="tag orange">TipTap</span>
<span class="tag">IndexedDB</span>
</div>
</a>
</div>
</section>
<hr class="divider" />
<!-- Skills -->
<section id="skills">
<p class="section-label">// skills</p>
<h2>技术栈</h2>
<div class="skills-grid">
<div class="skill-group">
<p class="skill-group-title">Frontend</p>
<ul class="skill-list">
<li>React / TypeScript</li>
<li>Vite / TailwindCSS</li>
<li>TipTap / ProseMirror</li>
<li>Framer Motion</li>
</ul>
</div>
<div class="skill-group">
<p class="skill-group-title">Backend</p>
<ul class="skill-list">
<li>Node.js / Hono</li>
<li>REST / SSE</li>
<li>Docker / Nginx</li>
<li>Linux / VPS</li>
</ul>
</div>
<div class="skill-group">
<p class="skill-group-title">AI</p>
<ul class="skill-list">
<li>Anthropic Claude API</li>
<li>Prompt Engineering</li>
<li>Streaming (SSE)</li>
<li>RAG / Embeddings</li>
</ul>
</div>
<div class="skill-group">
<p class="skill-group-title">Tooling</p>
<ul class="skill-list">
<li>Git / GitHub</li>
<li>Vitest / ESLint</li>
<li>Claude Code</li>
<li>SSH / rsync</li>
</ul>
</div>
</div>
</section>
<hr class="divider" />
<!-- Contact -->
<section id="contact">
<p class="section-label">// contact</p>
<h2>联系方式</h2>
<div class="contact-links">
<a class="contact-link" href="https://github.com/MikiVL" target="_blank" rel="noopener">
<span class="contact-icon"></span>
github.com/MikiVL
</a>
<a class="contact-link" href="mailto:hi@mikivl.online">
<span class="contact-icon">@</span>
hi@mikivl.online
</a>
</div>
</section>
</main>
<footer>
<p>© 2026 mikivl · built with ♥ and <a href="https://claude.ai/code" style="color:var(--faint);text-decoration:none;" target="_blank">Claude Code</a></p>
</footer>
</body>
</html>

55
nginx/default.conf Normal file
View File

@ -0,0 +1,55 @@
server {
listen 80;
server_name www.mikivl.online mikivl.online;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name www.mikivl.online mikivl.online;
ssl_certificate /etc/letsencrypt/live/www.mikivl.online/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.mikivl.online/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy strict-origin-when-cross-origin;
# ── 后端 API反向代理到 Hono/api/ 优先匹配)──
location /api/ {
proxy_pass http://hono-server:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 流式响应必须关闭缓冲
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
chunked_transfer_encoding on;
}
# ── 读书笔记 App/app/ 子路径SPA──
location /app/ {
alias /usr/share/nginx/html/app/;
index index.html;
try_files $uri $uri/ /app/index.html;
}
# ── 个人主页(根路径)──
location / {
root /usr/share/nginx/html/homepage;
index index.html;
try_files $uri $uri/ /index.html;
}
# 静态资源长缓存
location ~* \.(js|css|svg|ico|woff2?|png|jpg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@ -10,7 +10,7 @@ const app = new Hono()
app.use('*', cors()) app.use('*', cors())
// ── Model config persistence ────────────────────────────────────────────────── // ── Model config persistence ──────────────────────────────────────────────────
const MODELS_FILE = path.resolve('models.json') const MODELS_FILE = process.env.MODELS_FILE ?? path.resolve('models.json')
type ModelConfig = { type ModelConfig = {
id: string id: string

View File

@ -169,7 +169,7 @@ function nodesToDocxParagraphs(nodes: TipTapNode[]): Paragraph[] {
}) })
case 'codeBlock': { case 'codeBlock': {
const code = (node.content ?? []).map((n: TipTapNode) => n.text ?? '').join('') const code = (node.content ?? []).map((n: TipTapNode) => n.text ?? '').join('')
return code.split('\n').map(line => new Paragraph({ return code.split('\n').map((line: string) => new Paragraph({
children: [new TextRun({ text: line, font: 'Courier New' })], children: [new TextRun({ text: line, font: 'Courier New' })],
})) }))
} }

View File

@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
base: '/app/',
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { server: {
proxy: { proxy: {