Compare commits
8
Commits
f2681cb3de
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3299672ad8 | ||
|
|
e7e3e2bb47 | ||
|
|
b404a81003 | ||
|
|
1440ce066f | ||
|
|
c4ac36fc03 | ||
|
|
b02f23729b | ||
|
|
689c8b2d86 | ||
|
|
b864b2903a |
@@ -1,51 +1,74 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# deploy.sh — 本地运行,构建并上传到 VPS
|
# deploy.sh — 本地运行,构建并上传到 VPS
|
||||||
|
# 架构:Caddy (host) + Node.js systemd service (host)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VPS_USER="root"
|
VPS_HOST="mikivl"
|
||||||
VPS_HOST="your.vps.ip" # ← 替换为你的 VPS IP 或域名
|
|
||||||
VPS_DIR="/opt/mikivl"
|
VPS_DIR="/opt/mikivl"
|
||||||
|
# 主页文件位置(使用 mikivl.online 主仓库的最新版本)
|
||||||
|
HOMEPAGE_SRC="../mikivl.online/index.html"
|
||||||
|
|
||||||
info() { printf "\033[36m[INFO]\033[0m %s\n" "$*"; }
|
info() { printf "\033[36m[INFO]\033[0m %s\n" "$*"; }
|
||||||
ok() { printf "\033[32m[OK]\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; }
|
die() { printf "\033[31m[ERROR]\033[0m %s\n" "$*" >&2; exit 1; }
|
||||||
|
|
||||||
# ── Step 1: 本地构建 ──────────────────────────────────────────────────────────
|
# ── Step 1: 本地构建 ──────────────────────────────────────────────────────────
|
||||||
info "Building Vite app (base=/app/) ..."
|
info "Building Vite app ..."
|
||||||
npm run build
|
npm run build
|
||||||
ok "Build complete → dist/"
|
ok "Build complete → dist/"
|
||||||
|
|
||||||
# ── Step 2: 上传文件 ──────────────────────────────────────────────────────────
|
# ── Step 2: 上传文件 ──────────────────────────────────────────────────────────
|
||||||
info "Uploading to ${VPS_USER}@${VPS_HOST}:${VPS_DIR} ..."
|
info "Uploading to ${VPS_HOST}:${VPS_DIR} ..."
|
||||||
|
|
||||||
ssh "${VPS_USER}@${VPS_HOST}" "mkdir -p ${VPS_DIR}/{homepage,app,nginx,program1/server,program1/node_modules}"
|
# 个人主页(从主仓库上传最新版本)
|
||||||
|
if [ -f "${HOMEPAGE_SRC}" ]; then
|
||||||
|
scp "${HOMEPAGE_SRC}" "${VPS_HOST}:${VPS_DIR}/homepage/index.html"
|
||||||
|
ok "Homepage uploaded from ${HOMEPAGE_SRC}"
|
||||||
|
else
|
||||||
|
die "Homepage source not found: ${HOMEPAGE_SRC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# 个人主页
|
# App 构建产物 → note 目录(Caddy 的 note.mikivl.online root)
|
||||||
scp homepage/index.html "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/homepage/"
|
rsync -az --delete dist/ "${VPS_HOST}:${VPS_DIR}/note/"
|
||||||
|
|
||||||
# App 构建产物
|
|
||||||
rsync -az --delete dist/ "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/app/"
|
|
||||||
|
|
||||||
# 后端源码
|
# 后端源码
|
||||||
rsync -az server/ "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/program1/server/"
|
rsync -az server/ "${VPS_HOST}:${VPS_DIR}/program1/server/"
|
||||||
scp package.json package-lock.json "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/program1/"
|
scp package.json package-lock.json "${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."
|
ok "Upload complete."
|
||||||
|
|
||||||
# ── Step 3: VPS 上安装依赖并重启服务 ─────────────────────────────────────────
|
# ── Step 3: VPS 上安装依赖并重启服务 ─────────────────────────────────────────
|
||||||
info "Restarting services on VPS ..."
|
info "Installing dependencies and restarting program1 service on VPS ..."
|
||||||
|
|
||||||
ssh "${VPS_USER}@${VPS_HOST}" bash <<'REMOTE'
|
ssh "${VPS_HOST}" bash <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
NODE=/root/.nvm/versions/node/v24.15.0/bin/node
|
||||||
|
NPM=/root/.nvm/versions/node/v24.15.0/bin/npm
|
||||||
|
|
||||||
cd /opt/mikivl/program1
|
cd /opt/mikivl/program1
|
||||||
npm ci --omit=dev
|
${NPM} ci --ignore-scripts
|
||||||
cd /opt/mikivl
|
|
||||||
docker compose up -d --remove-orphans
|
# 检查 better-sqlite3 是否需要重新编译
|
||||||
docker compose ps
|
REQUIRED_VERSION=$(${NODE} -e "process.stdout.write(String(process.versions.modules))")
|
||||||
|
BUILT_VERSION=$(${NODE} -e "
|
||||||
|
try {
|
||||||
|
const b = require('./node_modules/better-sqlite3');
|
||||||
|
process.stdout.write('ok');
|
||||||
|
} catch(e) {
|
||||||
|
process.stdout.write('rebuild');
|
||||||
|
}
|
||||||
|
" 2>/dev/null || echo "rebuild")
|
||||||
|
|
||||||
|
if [ "${BUILT_VERSION}" = "rebuild" ]; then
|
||||||
|
echo "[INFO] Rebuilding better-sqlite3 for Node $(${NODE} -v) ..."
|
||||||
|
cd node_modules/better-sqlite3
|
||||||
|
${NODE} /root/.nvm/versions/node/v24.15.0/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild
|
||||||
|
cd ../..
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl restart program1.service
|
||||||
|
sleep 3
|
||||||
|
systemctl is-active program1.service && echo "[OK] program1.service is running"
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
ok "Deployment complete! → https://www.mikivl.online"
|
ok "Deployment complete! → https://note.mikivl.online"
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ services:
|
|||||||
- MODELS_FILE=/app/models_data/models.json
|
- MODELS_FILE=/app/models_data/models.json
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
volumes:
|
volumes:
|
||||||
- ./program1:/app:ro
|
- ./program1:/app
|
||||||
- models_data:/app/models_data
|
- models_data:/app/models_data
|
||||||
expose:
|
expose:
|
||||||
- "3001"
|
- "3001"
|
||||||
|
|||||||
@@ -1,624 +0,0 @@
|
|||||||
<!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>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet"/>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
:root {
|
|
||||||
--bg: #06080f;
|
|
||||||
--border: rgba(255,255,255,0.06);
|
|
||||||
--text: #d4d8e8;
|
|
||||||
--muted: #5a6480;
|
|
||||||
--faint: #2a3050;
|
|
||||||
--accent: #7c83ff;
|
|
||||||
--accent2: #4af0b0;
|
|
||||||
}
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
#starfield { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
|
|
||||||
#wrapper { position: relative; z-index: 1; display: flex; min-height: 100vh; }
|
|
||||||
|
|
||||||
/* ── 侧边栏 ── */
|
|
||||||
#sidebar {
|
|
||||||
width: 220px; flex-shrink: 0;
|
|
||||||
position: fixed; top: 0; left: 0; bottom: 0;
|
|
||||||
background: rgba(8,11,20,0.88);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
padding: 2.5rem 1.5rem; z-index: 10;
|
|
||||||
}
|
|
||||||
.site-title {
|
|
||||||
font-size: 1.05rem; font-weight: 800; color: var(--text);
|
|
||||||
letter-spacing: -0.03em; margin-bottom: 0.25rem; text-decoration: none;
|
|
||||||
}
|
|
||||||
.site-title em { color: var(--accent); font-style: normal; }
|
|
||||||
.site-desc { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.06em; margin-bottom: 2.5rem; }
|
|
||||||
.sidebar-constellation { width: 100%; height: 80px; margin-bottom: 2rem; }
|
|
||||||
.sidebar-constellation canvas { width: 100%; height: 100%; }
|
|
||||||
nav ul { list-style: none; }
|
|
||||||
nav ul li { margin-bottom: 0.1rem; }
|
|
||||||
nav ul li a {
|
|
||||||
display: block; padding: 0.4rem 0.6rem;
|
|
||||||
color: var(--muted); text-decoration: none;
|
|
||||||
font-size: 0.82rem; border-radius: 4px;
|
|
||||||
transition: all 0.15s; position: relative;
|
|
||||||
}
|
|
||||||
nav ul li a:hover, nav ul li a.active { color: var(--text); background: rgba(124,131,255,0.08); }
|
|
||||||
nav ul li a.active::before {
|
|
||||||
content: ''; position: absolute; left: 0; top: 25%; bottom: 25%;
|
|
||||||
width: 2px; background: var(--accent); border-radius: 1px;
|
|
||||||
}
|
|
||||||
.sidebar-games { margin-top: auto; padding-top: 1.5rem; border-top: 1px solid var(--border); }
|
|
||||||
.sidebar-games-label { font-size: 0.6rem; letter-spacing: 0.15em; text-transform: uppercase; color: var(--faint); margin-bottom: 0.75rem; }
|
|
||||||
.game-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
|
||||||
.game-tag {
|
|
||||||
font-size: 0.65rem; padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 3px; border: 1px solid var(--border);
|
|
||||||
color: var(--muted); transition: all 0.15s; cursor: default; user-select: none;
|
|
||||||
}
|
|
||||||
.game-tag:hover { border-color: var(--accent); color: var(--accent); }
|
|
||||||
.game-tag.mc { border-color: rgba(134,197,100,0.3); color: #86c564; }
|
|
||||||
.game-tag.oni { border-color: rgba(74,240,176,0.3); color: #4af0b0; }
|
|
||||||
.game-tag.er { border-color: rgba(234,179,8,0.3); color: #eab308; }
|
|
||||||
.game-tag.cp { border-color: rgba(0,255,200,0.3); color: #00ffc8; }
|
|
||||||
.game-tag.ak { border-color: rgba(0,180,255,0.3); color: #00b4ff; }
|
|
||||||
|
|
||||||
/* ── 主内容 ── */
|
|
||||||
#main { margin-left: 220px; flex: 1; padding: 3rem 3rem 4rem; max-width: 800px; }
|
|
||||||
|
|
||||||
/* ── Hero ── */
|
|
||||||
.hero { padding: 2rem 0 3rem; border-bottom: 1px solid var(--border); margin-bottom: 3rem; position: relative; }
|
|
||||||
.hero-eyebrow {
|
|
||||||
font-size: 0.7rem; letter-spacing: 0.15em; text-transform: uppercase;
|
|
||||||
color: var(--accent2); margin-bottom: 0.75rem;
|
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.hero-eyebrow::before { content: ''; width: 20px; height: 1px; background: var(--accent2); flex-shrink: 0; }
|
|
||||||
.hero-name { font-size: clamp(2rem, 5vw, 3rem); font-weight: 800; letter-spacing: -0.04em; line-height: 1.05; color: var(--text); margin-bottom: 1rem; }
|
|
||||||
.hero-name em { color: var(--accent); font-style: normal; }
|
|
||||||
.hero-bio { font-size: 0.9rem; color: var(--muted); max-width: 480px; line-height: 1.85; font-weight: 300; margin-bottom: 1.5rem; }
|
|
||||||
.hero-status {
|
|
||||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
|
||||||
font-size: 0.72rem; color: var(--muted); padding: 0.3rem 0.75rem;
|
|
||||||
background: rgba(74,240,176,0.05); border: 1px solid rgba(74,240,176,0.15); border-radius: 20px;
|
|
||||||
}
|
|
||||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
|
|
||||||
@keyframes pulse {
|
|
||||||
0%,100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74,240,176,0.4); }
|
|
||||||
50% { opacity: 0.7; box-shadow: 0 0 0 4px rgba(74,240,176,0); }
|
|
||||||
}
|
|
||||||
.hero-deco { position: absolute; right: 0; top: 2rem; display: flex; flex-direction: column; gap: 0.5rem; opacity: 0.15; pointer-events: none; }
|
|
||||||
.hero-deco-char { font-size: 1.8rem; color: var(--accent); line-height: 1; }
|
|
||||||
|
|
||||||
/* ── Section 标题 ── */
|
|
||||||
.section-label {
|
|
||||||
font-size: 0.65rem; letter-spacing: 0.18em; text-transform: uppercase;
|
|
||||||
color: var(--accent); margin-bottom: 1.5rem;
|
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.section-label::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--border), transparent); }
|
|
||||||
|
|
||||||
/* ── Flow 时间线 ── */
|
|
||||||
.flow-list { display: flex; flex-direction: column; }
|
|
||||||
.flow-item {
|
|
||||||
display: flex; gap: 1.5rem; padding: 1.75rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
transition: transform 0.2s; cursor: default;
|
|
||||||
}
|
|
||||||
.flow-item:first-child { padding-top: 0; }
|
|
||||||
.flow-item:last-child { border-bottom: none; }
|
|
||||||
.flow-item:hover { transform: translateX(4px); }
|
|
||||||
.flow-item:hover .flow-title { color: var(--accent); }
|
|
||||||
.flow-item:hover .flow-dot { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 8px rgba(124,131,255,0.5); }
|
|
||||||
.flow-timeline { width: 48px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; padding-top: 4px; }
|
|
||||||
.flow-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; transition: all 0.2s; }
|
|
||||||
.flow-line { width: 1px; flex: 1; background: var(--border); margin-top: 6px; }
|
|
||||||
.flow-item:last-child .flow-line { display: none; }
|
|
||||||
.flow-content { flex: 1; min-width: 0; }
|
|
||||||
.flow-meta { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.4rem; flex-wrap: wrap; }
|
|
||||||
.flow-category { font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.15rem 0.5rem; border-radius: 3px; }
|
|
||||||
.cat-ai { background: rgba(124,131,255,0.1); color: var(--accent); }
|
|
||||||
.cat-dev { background: rgba(74,240,176,0.1); color: var(--accent2); }
|
|
||||||
.cat-ops { background: rgba(0,180,255,0.1); color: #00b4ff; }
|
|
||||||
.cat-game { background: rgba(234,179,8,0.1); color: #eab308; }
|
|
||||||
.flow-date { font-size: 0.68rem; color: var(--muted); }
|
|
||||||
.flow-title { font-size: 1rem; font-weight: 600; color: var(--text); letter-spacing: -0.02em; line-height: 1.4; margin-bottom: 0.5rem; transition: color 0.15s; }
|
|
||||||
.flow-excerpt { font-size: 0.82rem; color: var(--muted); line-height: 1.75; font-weight: 300; }
|
|
||||||
.flow-game-ref {
|
|
||||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
|
||||||
font-size: 0.65rem; color: var(--faint); margin-top: 0.6rem;
|
|
||||||
padding: 0.2rem 0.5rem; border: 1px solid var(--border); border-radius: 3px;
|
|
||||||
}
|
|
||||||
.flow-game-ref span { color: var(--muted); }
|
|
||||||
|
|
||||||
/* ── 项目 ── */
|
|
||||||
.projects-section { margin-top: 3.5rem; }
|
|
||||||
.project-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
||||||
.project-card {
|
|
||||||
background: rgba(255,255,255,0.02); border: 1px solid var(--border);
|
|
||||||
border-radius: 8px; padding: 1.25rem 1.4rem;
|
|
||||||
text-decoration: none; display: block;
|
|
||||||
transition: all 0.2s; position: relative; overflow: hidden;
|
|
||||||
}
|
|
||||||
.project-card.full { grid-column: 1 / -1; }
|
|
||||||
.project-card::after {
|
|
||||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
|
||||||
background: linear-gradient(90deg, var(--accent), var(--accent2)); opacity: 0; transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
.project-card:hover { background: rgba(124,131,255,0.04); border-color: rgba(124,131,255,0.2); transform: translateY(-2px); }
|
|
||||||
.project-card:hover::after { opacity: 1; }
|
|
||||||
.project-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.4rem; }
|
|
||||||
.project-name { font-size: 0.9rem; font-weight: 600; color: var(--text); }
|
|
||||||
.project-status { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 20px; white-space: nowrap; background: rgba(74,240,176,0.08); color: var(--accent2); border: 1px solid rgba(74,240,176,0.2); margin-top: 1px; }
|
|
||||||
.project-desc { font-size: 0.77rem; color: var(--muted); line-height: 1.7; font-weight: 300; margin-bottom: 0.75rem; }
|
|
||||||
.project-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
|
||||||
.ptag { font-size: 0.62rem; padding: 0.15rem 0.4rem; background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; }
|
|
||||||
.ptag.sky { background: rgba(8,47,73,0.6); color: #7dd3fc; border-color: rgba(125,211,252,0.15); }
|
|
||||||
.ptag.green { background: rgba(5,46,22,0.6); color: #6ee7b7; border-color: rgba(110,231,183,0.15); }
|
|
||||||
.ptag.purple { background: rgba(46,16,101,0.6); color: #c4b5fd; border-color: rgba(196,181,253,0.15); }
|
|
||||||
.ptag.orange { background: rgba(67,20,7,0.6); color: #fdba74; border-color: rgba(253,186,116,0.15); }
|
|
||||||
|
|
||||||
/* ── 联系 ── */
|
|
||||||
.contact-section { margin-top: 3.5rem; }
|
|
||||||
.contact-link {
|
|
||||||
display: inline-flex; align-items: center; gap: 0.6rem;
|
|
||||||
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); width: 1.1rem; text-align: center; }
|
|
||||||
|
|
||||||
/* ── 评论区 ── */
|
|
||||||
.comments-section { margin-left: 220px; padding: 2.5rem 3rem; border-top: 1px solid var(--border); }
|
|
||||||
@media (max-width: 768px) { .comments-section { margin-left: 0; padding: 2rem 1.25rem; } }
|
|
||||||
.comment-item { padding: 0.75rem 0; border-bottom: 1px solid var(--border); }
|
|
||||||
.comment-item:last-child { border-bottom: none; }
|
|
||||||
.comment-avatar {
|
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
|
||||||
background: var(--accent); color: #fff;
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 0.65rem; font-weight: 700; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer ── */
|
|
||||||
footer {
|
|
||||||
margin-left: 220px; padding: 1.5rem 3rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
font-size: 0.72rem; color: var(--faint);
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
}
|
|
||||||
footer a { color: var(--faint); text-decoration: none; transition: color 0.15s; }
|
|
||||||
footer a:hover { color: var(--muted); }
|
|
||||||
|
|
||||||
/* ── 响应式 ── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#sidebar { display: none; }
|
|
||||||
#main { margin-left: 0; padding: 2rem 1.25rem 3rem; max-width: 100%; }
|
|
||||||
footer { margin-left: 0; padding: 1.25rem; }
|
|
||||||
.project-grid { grid-template-columns: 1fr; }
|
|
||||||
.project-card.full { grid-column: auto; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<canvas id="starfield"></canvas>
|
|
||||||
|
|
||||||
<div id="wrapper">
|
|
||||||
|
|
||||||
<aside id="sidebar">
|
|
||||||
<a class="site-title" href="/">MikiVL<em>.</em></a>
|
|
||||||
<div class="site-desc">独立开发者 · 游戏玩家</div>
|
|
||||||
<div class="sidebar-constellation">
|
|
||||||
<canvas id="const-canvas"></canvas>
|
|
||||||
</div>
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#about" class="active">首页</a></li>
|
|
||||||
<li><a href="#updates">动态</a></li>
|
|
||||||
<li><a href="#projects">项目</a></li>
|
|
||||||
<li><a href="#contact">联系</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-games">
|
|
||||||
<div class="sidebar-games-label">正在游玩</div>
|
|
||||||
<div class="game-tags">
|
|
||||||
<span class="game-tag mc">⛏ MC</span>
|
|
||||||
<span class="game-tag oni">⚗ ONI</span>
|
|
||||||
<span class="game-tag er">⚔ ER</span>
|
|
||||||
<span class="game-tag cp">◈ CP77</span>
|
|
||||||
<span class="game-tag ak">▣ AK</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main id="main">
|
|
||||||
|
|
||||||
<section class="hero" id="about">
|
|
||||||
<div class="hero-deco" aria-hidden="true">
|
|
||||||
<span class="hero-deco-char">✦</span>
|
|
||||||
<span class="hero-deco-char">᚛</span>
|
|
||||||
<span class="hero-deco-char">⚔</span>
|
|
||||||
</div>
|
|
||||||
<div class="hero-eyebrow">独立开发者 · AI 工具构建者</div>
|
|
||||||
<h1 class="hero-name">MikiVL<em>.</em></h1>
|
|
||||||
<p class="hero-bio">
|
|
||||||
专注于构建 AI 驱动的生产力工具。<br>
|
|
||||||
探索代码与星空之间的可能性,用像素砌出真实的解决方案。
|
|
||||||
</p>
|
|
||||||
<div class="hero-status">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
当前在线 · 持续迭代中
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="updates">
|
|
||||||
<div class="section-label">// 最近动态</div>
|
|
||||||
<div class="flow-list">
|
|
||||||
|
|
||||||
<div class="flow-item">
|
|
||||||
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
|
|
||||||
<div class="flow-content">
|
|
||||||
<div class="flow-meta">
|
|
||||||
<span class="flow-category cat-dev">新项目</span>
|
|
||||||
<span class="flow-date">2026年5月</span>
|
|
||||||
</div>
|
|
||||||
<div class="flow-title">GitHub Trending 追踪器上线</div>
|
|
||||||
<div class="flow-excerpt">自动抓取 GitHub 每日热门仓库,支持按时间筛选与收藏,7 天自动清理,保持列表干净。Cheerio 爬取、SQLite 持久化、Express 提供 API。</div>
|
|
||||||
<div class="flow-game-ref">⚗ <span>像缺氧里建自动化流水线,数据进来就会自己跑</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flow-item">
|
|
||||||
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
|
|
||||||
<div class="flow-content">
|
|
||||||
<div class="flow-meta">
|
|
||||||
<span class="flow-category cat-ai">AI 工具</span>
|
|
||||||
<span class="flow-date">2026年4月</span>
|
|
||||||
</div>
|
|
||||||
<div class="flow-title">笔记 v2.0 — 重构编辑器,引入 TipTap</div>
|
|
||||||
<div class="flow-excerpt">全面迁移至 TipTap + ProseMirror。支持 AI 续写、润色、摘要、翻译,多文件夹管理,标签系统,导入导出 MD / DOCX / PDF,IndexedDB 本地存储,隐私优先。</div>
|
|
||||||
<div class="flow-game-ref">⛏ <span>像在我的世界里一层一层往下挖,终于找到了钻石层</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flow-item">
|
|
||||||
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
|
|
||||||
<div class="flow-content">
|
|
||||||
<div class="flow-meta">
|
|
||||||
<span class="flow-category cat-ops">运维</span>
|
|
||||||
<span class="flow-date">2026年3月</span>
|
|
||||||
</div>
|
|
||||||
<div class="flow-title">迁移到 Caddy + PM2,重建部署流水线</div>
|
|
||||||
<div class="flow-excerpt">抛弃 Nginx 配置地狱,拥抱 Caddy 自动 HTTPS。PM2 管理 Hono 后端进程,rsync 增量同步,整套流程一条命令跑通。</div>
|
|
||||||
<div class="flow-game-ref">▣ <span>罗德岛的后勤系统——每个节点都要保持在线</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flow-item">
|
|
||||||
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
|
|
||||||
<div class="flow-content">
|
|
||||||
<div class="flow-meta">
|
|
||||||
<span class="flow-category cat-game">游戏 · 随想</span>
|
|
||||||
<span class="flow-date">2026年2月</span>
|
|
||||||
</div>
|
|
||||||
<div class="flow-title">艾尔登法环与软件架构——褪色者的地图思维</div>
|
|
||||||
<div class="flow-excerpt">开放世界设计与模块化系统有什么共同点?在交界地迷路,和在遗留代码里迷路,感受惊人地相似。</div>
|
|
||||||
<div class="flow-game-ref">⚔ <span>黄金树的光芒照不到的地方,才是真正有趣的地方</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="projects-section" id="projects">
|
|
||||||
<div class="section-label">// 项目</div>
|
|
||||||
<div class="project-grid">
|
|
||||||
|
|
||||||
<a class="project-card" href="/app/" target="_blank" rel="noopener">
|
|
||||||
<div class="project-header">
|
|
||||||
<span class="project-name">笔记</span>
|
|
||||||
<span class="project-status">● 上线中</span>
|
|
||||||
</div>
|
|
||||||
<p class="project-desc">AI 驱动的个人笔记工具。富文本编辑器,支持 AI 续写、润色、摘要、翻译,多文件夹管理,标签系统,导入导出(MD / DOCX / PDF),数据本地存储,隐私优先。</p>
|
|
||||||
<div class="project-tags">
|
|
||||||
<span class="ptag">React 19</span>
|
|
||||||
<span class="ptag">TypeScript</span>
|
|
||||||
<span class="ptag sky">Vite</span>
|
|
||||||
<span class="ptag green">Hono</span>
|
|
||||||
<span class="ptag purple">Claude API</span>
|
|
||||||
<span class="ptag orange">TipTap</span>
|
|
||||||
<span class="ptag">IndexedDB</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="project-card" href="/sushu/" target="_blank" rel="noopener">
|
|
||||||
<div class="project-header">
|
|
||||||
<span class="project-name">素数计算器</span>
|
|
||||||
<span class="project-status">● 上线中</span>
|
|
||||||
</div>
|
|
||||||
<p class="project-desc">在线素数计算工具。支持判断任意正整数是否为素数,以及生成指定范围内的素数列表。</p>
|
|
||||||
<div class="project-tags">
|
|
||||||
<span class="ptag sky">Vite</span>
|
|
||||||
<span class="ptag">Vanilla JS</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="project-card full" href="/github/" target="_blank" rel="noopener">
|
|
||||||
<div class="project-header">
|
|
||||||
<span class="project-name">GitHub Trending</span>
|
|
||||||
<span class="project-status">● 上线中</span>
|
|
||||||
</div>
|
|
||||||
<p class="project-desc">GitHub 热门仓库追踪器。自动抓取每日 Trending 数据,支持按时间筛选、收藏,7 天内自动清理未收藏条目,保持列表简洁。</p>
|
|
||||||
<div class="project-tags">
|
|
||||||
<span class="ptag">React</span>
|
|
||||||
<span class="ptag sky">Vite</span>
|
|
||||||
<span class="ptag green">Express</span>
|
|
||||||
<span class="ptag orange">SQLite</span>
|
|
||||||
<span class="ptag">Cheerio</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="contact-section" id="contact">
|
|
||||||
<div class="section-label">// 联系</div>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:0.75rem;">
|
|
||||||
<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>
|
|
||||||
<a class="contact-link" href="mailto:2569798878@qq.com">
|
|
||||||
<span class="contact-icon">✉</span>2569798878@qq.com
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 评论区 -->
|
|
||||||
<section class="comments-section" id="comments">
|
|
||||||
<div class="section-label">// 留言</div>
|
|
||||||
|
|
||||||
<div id="comment-form-area" style="margin-bottom:1.5rem;">
|
|
||||||
<div id="comment-login-hint" style="font-size:0.78rem;color:var(--muted);">
|
|
||||||
<a href="javascript:void(0)" onclick="openLoginModal()" style="color:var(--accent);text-decoration:none;">登录</a> 后发表留言
|
|
||||||
</div>
|
|
||||||
<form id="comment-form" style="display:none;flex-direction:column;gap:0.75rem;">
|
|
||||||
<textarea id="comment-input" rows="3" maxlength="500" placeholder="写点什么…(最多 500 字)"
|
|
||||||
style="width:100%;padding:0.6rem 0.8rem;background:rgba(255,255,255,0.03);
|
|
||||||
border:1px solid var(--border);border-radius:8px;color:var(--text);
|
|
||||||
font-size:0.82rem;font-family:inherit;resize:vertical;line-height:1.6;outline:none;"></textarea>
|
|
||||||
<div style="display:flex;justify-content:flex-end;">
|
|
||||||
<button type="submit"
|
|
||||||
style="padding:0.4rem 1rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:0.8rem;cursor:pointer;">
|
|
||||||
发表
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="comments-list" style="display:flex;flex-direction:column;"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 登录弹窗(首页专用) -->
|
|
||||||
<div id="hp-login-modal" style="display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;">
|
|
||||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:1.5rem;width:300px;display:flex;flex-direction:column;gap:1rem;">
|
|
||||||
<h3 style="font-size:0.9rem;font-weight:600;color:var(--text);margin:0;">登录 / 注册</h3>
|
|
||||||
<div style="display:flex;gap:0.5rem;padding:0.25rem;background:rgba(255,255,255,0.04);border-radius:8px;">
|
|
||||||
<button id="hp-tab-login" onclick="hpSetTab('login')"
|
|
||||||
style="flex:1;padding:0.3rem;border:none;border-radius:6px;font-size:0.75rem;background:rgba(255,255,255,0.08);color:var(--text);cursor:pointer;">登录</button>
|
|
||||||
<button id="hp-tab-register" onclick="hpSetTab('register')"
|
|
||||||
style="flex:1;padding:0.3rem;border:none;border-radius:6px;font-size:0.75rem;background:transparent;color:var(--muted);cursor:pointer;">注册</button>
|
|
||||||
</div>
|
|
||||||
<input id="hp-username" type="text" placeholder="用户名"
|
|
||||||
style="padding:0.5rem 0.75rem;background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:0.82rem;outline:none;"/>
|
|
||||||
<input id="hp-password" type="password" placeholder="密码"
|
|
||||||
style="padding:0.5rem 0.75rem;background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:0.82rem;outline:none;"/>
|
|
||||||
<p id="hp-login-error" style="color:#ef4444;font-size:0.75rem;display:none;margin:0;"></p>
|
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
|
|
||||||
<button onclick="closeLoginModal()"
|
|
||||||
style="padding:0.4rem 0.8rem;background:transparent;border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:0.8rem;cursor:pointer;">取消</button>
|
|
||||||
<button id="hp-login-submit" onclick="hpSubmit()"
|
|
||||||
style="padding:0.4rem 1rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:0.8rem;cursor:pointer;">登录</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<span>© 2026 MikiVL</span>
|
|
||||||
<span>✦ built with <a href="https://claude.ai/code" target="_blank" rel="noopener">Claude Code</a></span>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const canvas = document.getElementById('starfield');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
let stars = [], W, H;
|
|
||||||
function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
|
|
||||||
function initStars() {
|
|
||||||
stars = [];
|
|
||||||
const n = Math.floor((W * H) / 4000);
|
|
||||||
for (let i = 0; i < n; i++) stars.push({ x: Math.random()*W, y: Math.random()*H, r: Math.random()*1.2+0.2, o: Math.random()*0.6+0.2, spd: Math.random()*0.3+0.05, ph: Math.random()*Math.PI*2 });
|
|
||||||
}
|
|
||||||
let t = 0;
|
|
||||||
function draw() {
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
t += 0.008;
|
|
||||||
for (const s of stars) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI*2);
|
|
||||||
ctx.fillStyle = `rgba(255,255,255,${s.o*(0.7+0.3*Math.sin(t*s.spd+s.ph))})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
if (Math.random() < 0.002) {
|
|
||||||
const mx = Math.random()*W, my = Math.random()*H*0.5, len = 40+Math.random()*60;
|
|
||||||
const g = ctx.createLinearGradient(mx,my,mx+len,my+len*0.4);
|
|
||||||
g.addColorStop(0,'rgba(255,255,255,0)'); g.addColorStop(0.4,'rgba(200,210,255,0.45)'); g.addColorStop(1,'rgba(255,255,255,0)');
|
|
||||||
ctx.beginPath(); ctx.moveTo(mx,my); ctx.lineTo(mx+len,my+len*0.4);
|
|
||||||
ctx.strokeStyle=g; ctx.lineWidth=1; ctx.stroke();
|
|
||||||
}
|
|
||||||
requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
resize(); initStars(); draw();
|
|
||||||
window.addEventListener('resize', () => { resize(); initStars(); });
|
|
||||||
})();
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
const canvas = document.getElementById('const-canvas');
|
|
||||||
if (!canvas) return;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = canvas.offsetWidth * dpr;
|
|
||||||
canvas.height = canvas.offsetHeight * dpr;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const W = canvas.width, H = canvas.height;
|
|
||||||
// 摩羯座主星坐标(按真实星图比例归一化)
|
|
||||||
// 0:α²Cap(Algedi) 1:β Cap(Dabih) 2:ψCap 3:ωCap 4:24Cap
|
|
||||||
// 5:δCap(Deneb Algedi) 6:γCap(Nashira) 7:ιCap 8:θCap 9:ζCap 10:εCap 11:ηCap 12:ρCap
|
|
||||||
const rawPts = [
|
|
||||||
[0.08,0.22],[0.18,0.28],[0.30,0.18],[0.42,0.14],[0.52,0.20],
|
|
||||||
[0.92,0.55],[0.82,0.48],[0.68,0.70],[0.58,0.78],[0.72,0.38],
|
|
||||||
[0.62,0.32],[0.50,0.58],[0.38,0.64]
|
|
||||||
];
|
|
||||||
const sizes = [2.2,1.8,1.2,1.0,1.0, 2.4,1.4,1.0,0.9,1.1,0.9,0.9,0.8];
|
|
||||||
const pts = rawPts.map(([rx,ry],i) => ({
|
|
||||||
x: rx*W, y: ry*H,
|
|
||||||
r: sizes[i] * dpr,
|
|
||||||
o: 0.55 + sizes[i] * 0.12
|
|
||||||
}));
|
|
||||||
// 连线:摩羯座的弓形轮廓
|
|
||||||
const lines = [
|
|
||||||
[0,1],[1,2],[2,3],[3,4],[4,9], // 上弧(头部 → 背部)
|
|
||||||
[9,10],[10,5], // 背部 → 尾部
|
|
||||||
[5,6],[6,7],[7,8],[8,12],[12,11], // 腹部弧线
|
|
||||||
[11,1] // 闭合(腹 → 头)
|
|
||||||
];
|
|
||||||
ctx.strokeStyle = 'rgba(124,131,255,0.15)'; ctx.lineWidth = dpr;
|
|
||||||
for (const [a,b] of lines) { ctx.beginPath(); ctx.moveTo(pts[a].x,pts[a].y); ctx.lineTo(pts[b].x,pts[b].y); ctx.stroke(); }
|
|
||||||
for (const p of pts) { ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2); ctx.fillStyle=`rgba(200,210,255,${p.o})`; ctx.fill(); }
|
|
||||||
})();
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
const sections = document.querySelectorAll('section[id]');
|
|
||||||
const links = document.querySelectorAll('nav a');
|
|
||||||
const obs = new IntersectionObserver(entries => {
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e.isIntersecting) {
|
|
||||||
links.forEach(l => l.classList.remove('active'));
|
|
||||||
const a = document.querySelector(`nav a[href="#${e.target.id}"]`);
|
|
||||||
if (a) a.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { threshold: 0.4 });
|
|
||||||
sections.forEach(s => obs.observe(s));
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── 评论系统 ──
|
|
||||||
const HP_TOKEN_KEY = 'mikivl_token'
|
|
||||||
let hpUser = null
|
|
||||||
let hpTab = 'login'
|
|
||||||
|
|
||||||
function hpGetToken() { return localStorage.getItem(HP_TOKEN_KEY) }
|
|
||||||
function hpSetToken(t) { localStorage.setItem(HP_TOKEN_KEY, t) }
|
|
||||||
|
|
||||||
function hpParseToken(token) {
|
|
||||||
try { const p = JSON.parse(atob(token.split('.')[1])); return { id: p.userId, username: p.username } }
|
|
||||||
catch { return null }
|
|
||||||
}
|
|
||||||
|
|
||||||
function hpSetTab(tab) {
|
|
||||||
hpTab = tab
|
|
||||||
document.getElementById('hp-tab-login').style.background = tab === 'login' ? 'rgba(255,255,255,0.08)' : 'transparent'
|
|
||||||
document.getElementById('hp-tab-login').style.color = tab === 'login' ? 'var(--text)' : 'var(--muted)'
|
|
||||||
document.getElementById('hp-tab-register').style.background = tab === 'register' ? 'rgba(255,255,255,0.08)' : 'transparent'
|
|
||||||
document.getElementById('hp-tab-register').style.color = tab === 'register' ? 'var(--text)' : 'var(--muted)'
|
|
||||||
document.getElementById('hp-login-submit').textContent = tab === 'login' ? '登录' : '注册'
|
|
||||||
}
|
|
||||||
|
|
||||||
function openLoginModal() { document.getElementById('hp-login-modal').style.display = 'flex' }
|
|
||||||
function closeLoginModal() { document.getElementById('hp-login-modal').style.display = 'none' }
|
|
||||||
|
|
||||||
async function hpSubmit() {
|
|
||||||
const username = document.getElementById('hp-username').value.trim()
|
|
||||||
const password = document.getElementById('hp-password').value
|
|
||||||
const errEl = document.getElementById('hp-login-error')
|
|
||||||
errEl.style.display = 'none'
|
|
||||||
if (!username || !password) { errEl.textContent = '请填写用户名和密码'; errEl.style.display = 'block'; return }
|
|
||||||
try {
|
|
||||||
const endpoint = hpTab === 'login' ? '/api/auth/login' : '/api/auth/register'
|
|
||||||
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) { errEl.textContent = data.error || '操作失败'; errEl.style.display = 'block'; return }
|
|
||||||
hpSetToken(data.token)
|
|
||||||
hpUser = data.user
|
|
||||||
closeLoginModal()
|
|
||||||
updateCommentUI()
|
|
||||||
} catch { errEl.textContent = '网络错误'; errEl.style.display = 'block' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCommentUI() {
|
|
||||||
const hint = document.getElementById('comment-login-hint')
|
|
||||||
const form = document.getElementById('comment-form')
|
|
||||||
if (hpUser) { hint.style.display = 'none'; form.style.display = 'flex' }
|
|
||||||
else { hint.style.display = 'block'; form.style.display = 'none' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadComments() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/comments')
|
|
||||||
const list = await res.json()
|
|
||||||
const el = document.getElementById('comments-list')
|
|
||||||
if (!Array.isArray(list) || !list.length) {
|
|
||||||
el.innerHTML = '<p style="font-size:0.78rem;color:var(--muted);padding:0.5rem 0;">还没有留言,来第一个吧!</p>'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
el.innerHTML = list.map(c => `
|
|
||||||
<div class="comment-item">
|
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;">
|
|
||||||
<span class="comment-avatar">${c.username[0].toUpperCase()}</span>
|
|
||||||
<span style="font-size:0.78rem;font-weight:600;color:var(--text);">${c.username}</span>
|
|
||||||
<span style="font-size:0.68rem;color:var(--muted);">${new Date(c.createdAt).toLocaleDateString('zh-CN')}</span>
|
|
||||||
</div>
|
|
||||||
<p style="font-size:0.82rem;color:var(--muted);line-height:1.7;margin:0 0 0 1.6rem;">${c.content.replace(/&/g,'&').replace(/</g,'<')}</p>
|
|
||||||
</div>
|
|
||||||
`).join('')
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('comment-form').addEventListener('submit', async e => {
|
|
||||||
e.preventDefault()
|
|
||||||
const input = document.getElementById('comment-input')
|
|
||||||
const content = input.value.trim()
|
|
||||||
if (!content) return
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${hpGetToken()}` }, body: JSON.stringify({ content }) })
|
|
||||||
if (res.ok) { input.value = ''; loadComments() }
|
|
||||||
else if (res.status === 401) { hpUser = null; updateCommentUI() }
|
|
||||||
} catch {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const _token = hpGetToken()
|
|
||||||
if (_token) hpUser = hpParseToken(_token)
|
|
||||||
updateCommentUI()
|
|
||||||
loadComments()
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Generated
+21
@@ -55,6 +55,7 @@
|
|||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@@ -4115,6 +4117,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -8132,6 +8144,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|||||||
+9
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 724 B |
@@ -18,6 +18,17 @@ export const users = sqliteTable('users', {
|
|||||||
username: text('username').notNull().unique(),
|
username: text('username').notNull().unique(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
role: text('role').notNull().default('user'),
|
||||||
|
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
nickname: text('nickname'),
|
||||||
|
avatar: text('avatar'),
|
||||||
|
email: text('email'),
|
||||||
|
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
pendingEmail: text('pending_email'),
|
||||||
|
emailVerifyCode: text('email_verify_code'),
|
||||||
|
emailVerifyExpiry: integer('email_verify_expiry'),
|
||||||
|
resetCode: text('reset_code'),
|
||||||
|
resetCodeExpiry: integer('reset_code_expiry'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -57,6 +68,11 @@ export const inviteCodes = sqliteTable('invite_codes', {
|
|||||||
usedAt: integer('used_at'),
|
usedAt: integer('used_at'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const siteContent = sqliteTable('site_content', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export function initDb() {
|
export function initDb() {
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -64,6 +80,12 @@ export function initDb() {
|
|||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
cloud_enabled INTEGER NOT NULL DEFAULT 0,
|
cloud_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
email TEXT,
|
||||||
|
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
email_verify_code TEXT,
|
||||||
|
email_verify_expiry INTEGER,
|
||||||
|
reset_code TEXT,
|
||||||
|
reset_code_expiry INTEGER,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS notes (
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
@@ -108,4 +130,45 @@ export function initDb() {
|
|||||||
]
|
]
|
||||||
const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`)
|
const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`)
|
||||||
for (const code of INVITE_CODES) insert.run(code)
|
for (const code of INVITE_CODES) insert.run(code)
|
||||||
|
|
||||||
|
// 迁移:为已有数据库添加邮箱相关字段
|
||||||
|
const cols = sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]
|
||||||
|
const colNames = cols.map(c => c.name)
|
||||||
|
if (!colNames.includes('email')) sqlite.exec(`ALTER TABLE users ADD COLUMN email TEXT`)
|
||||||
|
if (!colNames.includes('email_verified')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
if (!colNames.includes('email_verify_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_code TEXT`)
|
||||||
|
if (!colNames.includes('email_verify_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_expiry INTEGER`)
|
||||||
|
if (!colNames.includes('reset_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code TEXT`)
|
||||||
|
if (!colNames.includes('reset_code_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code_expiry INTEGER`)
|
||||||
|
if (!colNames.includes('nickname')) sqlite.exec(`ALTER TABLE users ADD COLUMN nickname TEXT`)
|
||||||
|
if (!colNames.includes('avatar')) sqlite.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`)
|
||||||
|
if (!colNames.includes('pending_email')) sqlite.exec(`ALTER TABLE users ADD COLUMN pending_email TEXT`)
|
||||||
|
if (!colNames.includes('role')) sqlite.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`)
|
||||||
|
if (!colNames.includes('banned')) sqlite.exec(`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
|
||||||
|
// 将 MikiVL 用户设为管理员
|
||||||
|
sqlite.exec(`UPDATE users SET role = 'admin' WHERE username = 'MikiVL'`)
|
||||||
|
|
||||||
|
// 创建站点内容表
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS site_content (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 预设站点内容默认值
|
||||||
|
const siteDefaults = [
|
||||||
|
['tagline', '探索·创造·分享'],
|
||||||
|
['subtitle', '个人项目与技术探索的集合地'],
|
||||||
|
['news', JSON.stringify([
|
||||||
|
{ date: '2025-06', text: '开放笔记系统公测' },
|
||||||
|
{ date: '2025-03', text: '网站正式上线' },
|
||||||
|
])],
|
||||||
|
['projects', JSON.stringify([
|
||||||
|
{ title: 'MikiNote', desc: '基于 AI 的个人笔记系统,支持云同步', link: 'https://note.mikivl.online/app/' },
|
||||||
|
])],
|
||||||
|
]
|
||||||
|
const insertContent = sqlite.prepare(`INSERT OR IGNORE INTO site_content (key, value) VALUES (?, ?)`)
|
||||||
|
for (const [key, value] of siteDefaults) insertContent.run(key, value)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-13
@@ -1,15 +1,17 @@
|
|||||||
import 'dotenv/config'
|
import dotenv from 'dotenv'
|
||||||
|
import path from 'node:path'
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), '.env') })
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { serve } from '@hono/node-server'
|
import { serve } from '@hono/node-server'
|
||||||
import { cors } from 'hono/cors'
|
import { cors } from 'hono/cors'
|
||||||
import Anthropic from '@anthropic-ai/sdk'
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import { initDb, db, siteContent } from './db'
|
||||||
import { initDb } from './db'
|
|
||||||
import { authRouter } from './routes/auth'
|
import { authRouter } from './routes/auth'
|
||||||
import { notesRouter } from './routes/notes'
|
import { notesRouter } from './routes/notes'
|
||||||
import { foldersRouter } from './routes/folders'
|
import { foldersRouter } from './routes/folders'
|
||||||
import { commentsRouter } from './routes/comments'
|
import { commentsRouter } from './routes/comments'
|
||||||
|
import { adminRouter } from './routes/admin'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
app.use('*', cors())
|
app.use('*', cors())
|
||||||
@@ -64,17 +66,26 @@ function getActiveModel(): string {
|
|||||||
|
|
||||||
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
|
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
|
||||||
app.route('/api/auth', authRouter)
|
app.route('/api/auth', authRouter)
|
||||||
app.route('/api/notes', notesRouter)
|
app.route('/api/admin', adminRouter)
|
||||||
app.route('/api/folders', foldersRouter)
|
app.route('/api/note/notes', notesRouter)
|
||||||
app.route('/api/comments', commentsRouter)
|
app.route('/api/note/folders', foldersRouter)
|
||||||
|
app.route('/api/note/comments', commentsRouter)
|
||||||
|
|
||||||
app.get('/api/models', (c) => {
|
// 公共站点内容接口(无需认证)
|
||||||
|
app.get('/api/site-content', (c) => {
|
||||||
|
const rows = db.select().from(siteContent).all()
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
for (const row of rows) result[row.key] = row.value
|
||||||
|
return c.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/note/models', (c) => {
|
||||||
const models = readModels()
|
const models = readModels()
|
||||||
// strip apiKey from response for security
|
// strip apiKey from response for security
|
||||||
return c.json(models.map(({ apiKey: _, ...rest }) => rest))
|
return c.json(models.map(({ apiKey: _, ...rest }) => rest))
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/models', async (c) => {
|
app.post('/api/note/models', async (c) => {
|
||||||
const body = await c.req.json<{ name: string; apiKey: string; baseURL: string; modelId?: string }>()
|
const body = await c.req.json<{ name: string; apiKey: string; baseURL: string; modelId?: string }>()
|
||||||
const models = readModels()
|
const models = readModels()
|
||||||
const newModel: ModelConfig = {
|
const newModel: ModelConfig = {
|
||||||
@@ -91,7 +102,7 @@ app.post('/api/models', async (c) => {
|
|||||||
return c.json(safe)
|
return c.json(safe)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.patch('/api/models/:id/activate', (c) => {
|
app.patch('/api/note/models/:id/activate', (c) => {
|
||||||
const { id } = c.req.param()
|
const { id } = c.req.param()
|
||||||
const models = readModels()
|
const models = readModels()
|
||||||
models.forEach(m => { m.isActive = m.id === id })
|
models.forEach(m => { m.isActive = m.id === id })
|
||||||
@@ -99,7 +110,7 @@ app.patch('/api/models/:id/activate', (c) => {
|
|||||||
return c.json({ ok: true })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/models/:id', (c) => {
|
app.delete('/api/note/models/:id', (c) => {
|
||||||
const { id } = c.req.param()
|
const { id } = c.req.param()
|
||||||
let models = readModels()
|
let models = readModels()
|
||||||
const target = models.find(m => m.id === id)
|
const target = models.find(m => m.id === id)
|
||||||
@@ -156,7 +167,7 @@ function buildSystemPrompt(type: AIStreamRequest['type'], noteContent: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Stream endpoint ───────────────────────────────────────────────────────────
|
// ── Stream endpoint ───────────────────────────────────────────────────────────
|
||||||
app.post('/api/ai/stream', async (c) => {
|
app.post('/api/note/ai/stream', async (c) => {
|
||||||
const req = await c.req.json<AIStreamRequest>()
|
const req = await c.req.json<AIStreamRequest>()
|
||||||
const aiClient = getActiveClient()
|
const aiClient = getActiveClient()
|
||||||
const modelId = getActiveModel()
|
const modelId = getActiveModel()
|
||||||
@@ -203,6 +214,6 @@ app.post('/api/ai/stream', async (c) => {
|
|||||||
|
|
||||||
initDb()
|
initDb()
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: 3001 }, () => {
|
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3003) }, () => {
|
||||||
console.log('AI proxy server running on http://localhost:3001')
|
console.log(`AI proxy server running on http://localhost:${process.env.PORT ?? 3003}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
function createTransporter() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: 'smtp.126.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: 'mikivl@126.com',
|
||||||
|
pass: process.env.EMAIL_PASS!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendVerifyCode(to: string, code: string) {
|
||||||
|
await createTransporter().sendMail({
|
||||||
|
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||||
|
to,
|
||||||
|
subject: '邮箱验证码',
|
||||||
|
text: `你的验证码是:${code},10 分钟内有效。`,
|
||||||
|
html: `<p style="font-family:sans-serif">你的验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。</p>`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendResetCode(to: string, code: string) {
|
||||||
|
await createTransporter().sendMail({
|
||||||
|
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||||
|
to,
|
||||||
|
subject: '重置密码验证码',
|
||||||
|
text: `你的重置密码验证码是:${code},10 分钟内有效。如非本人操作请忽略。`,
|
||||||
|
html: `<p style="font-family:sans-serif">你的重置密码验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。如非本人操作请忽略。</p>`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRegisterCode(to: string, code: string) {
|
||||||
|
await createTransporter().sendMail({
|
||||||
|
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||||
|
to,
|
||||||
|
subject: '注册验证码',
|
||||||
|
text: `你的注册验证码是:${code},10 分钟内有效。如非本人操作请忽略。`,
|
||||||
|
html: `<p style="font-family:sans-serif">你的注册验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。如非本人操作请忽略。</p>`,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createMiddleware } from 'hono/factory'
|
import { createMiddleware } from 'hono/factory'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { db, users } from '../db'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
export type JwtPayload = { userId: string; username: string }
|
export type JwtPayload = { userId: string; username: string }
|
||||||
|
|
||||||
@@ -25,3 +27,21 @@ export const requireAuth = createMiddleware(async (c, next) => {
|
|||||||
return c.json({ error: 'Token 无效或已过期' }, 401)
|
return c.json({ error: 'Token 无效或已过期' }, 401)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const requireAdmin = createMiddleware(async (c, next) => {
|
||||||
|
const header = c.req.header('Authorization')
|
||||||
|
if (!header?.startsWith('Bearer ')) {
|
||||||
|
return c.json({ error: '未登录' }, 401)
|
||||||
|
}
|
||||||
|
const token = header.slice(7)
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload
|
||||||
|
const [user] = db.select({ role: users.role }).from(users).where(eq(users.id, payload.userId)).all()
|
||||||
|
if (!user || user.role !== 'admin') return c.json({ error: '权限不足' }, 403)
|
||||||
|
c.set('userId', payload.userId)
|
||||||
|
c.set('username', payload.username)
|
||||||
|
await next()
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Token 无效或已过期' }, 401)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Hono } from 'hono'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { db, users, comments, inviteCodes, siteContent } from '../db'
|
||||||
|
import { requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
export const adminRouter = new Hono()
|
||||||
|
adminRouter.use('*', requireAdmin)
|
||||||
|
|
||||||
|
function nanoid() {
|
||||||
|
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站点内容读取
|
||||||
|
adminRouter.get('/site-content', (c) => {
|
||||||
|
const rows = db.select().from(siteContent).all()
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
for (const row of rows) result[row.key] = row.value
|
||||||
|
return c.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 站点内容批量更新
|
||||||
|
adminRouter.put('/site-content', async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, string>>()
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
if (typeof value !== 'string') continue
|
||||||
|
db.insert(siteContent).values({ key, value }).onConflictDoUpdate({ target: siteContent.key, set: { value } }).run()
|
||||||
|
}
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 用户列表
|
||||||
|
adminRouter.get('/users', (c) => {
|
||||||
|
const rows = db.select({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
role: users.role,
|
||||||
|
banned: users.banned,
|
||||||
|
cloudEnabled: users.cloudEnabled,
|
||||||
|
email: users.email,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
}).from(users).all()
|
||||||
|
return c.json(rows)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 封禁用户
|
||||||
|
adminRouter.post('/users/:id/ban', (c) => {
|
||||||
|
const { id } = c.req.param()
|
||||||
|
const [user] = db.select({ role: users.role }).from(users).where(eq(users.id, id)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
if (user.role === 'admin') return c.json({ error: '不能封禁管理员' }, 403)
|
||||||
|
db.update(users).set({ banned: true }).where(eq(users.id, id)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解封用户
|
||||||
|
adminRouter.post('/users/:id/unban', (c) => {
|
||||||
|
const { id } = c.req.param()
|
||||||
|
const [user] = db.select({ id: users.id }).from(users).where(eq(users.id, id)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
db.update(users).set({ banned: false }).where(eq(users.id, id)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 评论列表
|
||||||
|
adminRouter.get('/comments', (c) => {
|
||||||
|
const rows = db.select({
|
||||||
|
id: comments.id,
|
||||||
|
content: comments.content,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
userId: comments.userId,
|
||||||
|
username: users.username,
|
||||||
|
}).from(comments).leftJoin(users, eq(comments.userId, users.id)).all()
|
||||||
|
return c.json(rows)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除评论
|
||||||
|
adminRouter.delete('/comments/:id', (c) => {
|
||||||
|
const { id } = c.req.param()
|
||||||
|
db.delete(comments).where(eq(comments.id, id)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 邀请码列表
|
||||||
|
adminRouter.get('/invite-codes', (c) => {
|
||||||
|
const rows = db.select().from(inviteCodes).all()
|
||||||
|
return c.json(rows)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成新邀请码
|
||||||
|
adminRouter.post('/invite-codes', (c) => {
|
||||||
|
const code = 'MIKI-' + nanoid().toUpperCase().slice(0, 4) + '-' + nanoid().toUpperCase().slice(0, 4)
|
||||||
|
db.insert(inviteCodes).values({ code }).run()
|
||||||
|
return c.json({ code })
|
||||||
|
})
|
||||||
+204
-10
@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { db, users, inviteCodes } from '../db'
|
import { db, users, inviteCodes } from '../db'
|
||||||
import { requireAuth } from '../middleware/auth'
|
import { requireAuth } from '../middleware/auth'
|
||||||
|
import { sendVerifyCode, sendResetCode, sendRegisterCode } from '../lib/email'
|
||||||
|
|
||||||
export const authRouter = new Hono()
|
export const authRouter = new Hono()
|
||||||
|
|
||||||
@@ -11,21 +12,84 @@ function nanoid() {
|
|||||||
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
|
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
|
||||||
}
|
}
|
||||||
|
|
||||||
authRouter.post('/register', async (c) => {
|
function randomCode() {
|
||||||
const { username, password } = await c.req.json<{ username: string; password: string }>()
|
return String(Math.floor(100000 + Math.random() * 900000))
|
||||||
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
|
}
|
||||||
|
|
||||||
|
// 待注册信息临时存储(key 为邮箱)
|
||||||
|
const registerPending = new Map<string, {
|
||||||
|
username: string
|
||||||
|
passwordHash: string
|
||||||
|
code: string
|
||||||
|
expiry: number
|
||||||
|
sentAt: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 发送注册验证码
|
||||||
|
authRouter.post('/register/send-code', async (c) => {
|
||||||
|
const { username, password, email } = await c.req.json<{ username: string; password: string; email: string }>()
|
||||||
|
if (!username || !password || !email) return c.json({ error: '请填写所有字段' }, 400)
|
||||||
if (username.length < 2 || username.length > 20) return c.json({ error: '用户名长度 2-20 位' }, 400)
|
if (username.length < 2 || username.length > 20) return c.json({ error: '用户名长度 2-20 位' }, 400)
|
||||||
if (password.length < 6) return c.json({ error: '密码至少 6 位' }, 400)
|
if (password.length < 6) return c.json({ error: '密码至少 6 位' }, 400)
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
|
||||||
|
|
||||||
const existing = db.select().from(users).where(eq(users.username, username)).all()
|
// 检查用户名是否已存在
|
||||||
if (existing.length > 0) return c.json({ error: '用户名已存在' }, 409)
|
const existingUser = db.select().from(users).where(eq(users.username, username)).all()
|
||||||
|
if (existingUser.length > 0) return c.json({ error: '用户名已存在' }, 409)
|
||||||
|
|
||||||
|
// 检查邮箱是否已被验证用户使用
|
||||||
|
const existingEmail = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
|
if (existingEmail.some(u => u.emailVerified)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
|
||||||
|
|
||||||
|
// 60秒冷却
|
||||||
|
const pending = registerPending.get(email)
|
||||||
|
if (pending && Date.now() - pending.sentAt < 60 * 1000) {
|
||||||
|
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
|
||||||
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10)
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
const id = nanoid()
|
const code = randomCode()
|
||||||
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
|
registerPending.set(email, { username, passwordHash, code, expiry: Date.now() + 10 * 60 * 1000, sentAt: Date.now() })
|
||||||
|
|
||||||
const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
try {
|
||||||
return c.json({ token, user: { id, username, cloudEnabled: false } })
|
await sendRegisterCode(email, code)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[email]', err)
|
||||||
|
registerPending.delete(email)
|
||||||
|
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||||
|
}
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 完成注册(验证验证码)
|
||||||
|
authRouter.post('/register', async (c) => {
|
||||||
|
const { email, code } = await c.req.json<{ email: string; code: string }>()
|
||||||
|
if (!email || !code) return c.json({ error: '参数不完整' }, 400)
|
||||||
|
|
||||||
|
const pending = registerPending.get(email)
|
||||||
|
if (!pending) return c.json({ error: '请先发送验证码' }, 400)
|
||||||
|
if (Date.now() > pending.expiry) {
|
||||||
|
registerPending.delete(email)
|
||||||
|
return c.json({ error: '验证码已过期,请重新发送' }, 400)
|
||||||
|
}
|
||||||
|
if (pending.code !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
||||||
|
|
||||||
|
// 再次检查用户名/邮箱唯一性(防止并发)
|
||||||
|
const existingUser = db.select().from(users).where(eq(users.username, pending.username)).all()
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
registerPending.delete(email)
|
||||||
|
return c.json({ error: '用户名已被注册,请更换用户名重新发送验证码' }, 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = nanoid()
|
||||||
|
db.insert(users).values({
|
||||||
|
id, username: pending.username, passwordHash: pending.passwordHash,
|
||||||
|
email, emailVerified: true, cloudEnabled: false, createdAt: Date.now(),
|
||||||
|
}).run()
|
||||||
|
registerPending.delete(email)
|
||||||
|
|
||||||
|
const token = jwt.sign({ userId: id, username: pending.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||||
|
return c.json({ token, user: { id, username: pending.username, cloudEnabled: false, role: 'user', email, emailVerified: true, nickname: null, avatar: null } })
|
||||||
})
|
})
|
||||||
|
|
||||||
authRouter.post('/login', async (c) => {
|
authRouter.post('/login', async (c) => {
|
||||||
@@ -37,9 +101,10 @@ authRouter.post('/login', async (c) => {
|
|||||||
|
|
||||||
const ok = await bcrypt.compare(password, user.passwordHash)
|
const ok = await bcrypt.compare(password, user.passwordHash)
|
||||||
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
|
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
|
||||||
|
if (user.banned) return c.json({ error: '账号已被封禁,请联系管理员' }, 403)
|
||||||
|
|
||||||
const token = jwt.sign({ userId: user.id, username: user.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
const token = jwt.sign({ userId: user.id, username: user.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||||
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled } })
|
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } })
|
||||||
})
|
})
|
||||||
|
|
||||||
authRouter.post('/activate', requireAuth, async (c) => {
|
authRouter.post('/activate', requireAuth, async (c) => {
|
||||||
@@ -56,3 +121,132 @@ authRouter.post('/activate', requireAuth, async (c) => {
|
|||||||
|
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
authRouter.get('/me', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新个人资料(昵称、头像)
|
||||||
|
authRouter.put('/me', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { nickname, avatar } = await c.req.json<{ nickname?: string; avatar?: string }>()
|
||||||
|
const updates: Record<string, any> = {}
|
||||||
|
if (nickname !== undefined) {
|
||||||
|
if (nickname.length > 20) return c.json({ error: '昵称不超过 20 字' }, 400)
|
||||||
|
updates.nickname = nickname.trim() || null
|
||||||
|
}
|
||||||
|
if (avatar !== undefined) {
|
||||||
|
// 限制头像大小(base64 约 100KB)
|
||||||
|
if (avatar && avatar.length > 150000) return c.json({ error: '头像不能超过 100KB' }, 400)
|
||||||
|
updates.avatar = avatar || null
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length === 0) return c.json({ error: '无更新内容' }, 400)
|
||||||
|
db.update(users).set(updates).where(eq(users.id, userId)).run()
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送邮箱绑定验证码
|
||||||
|
authRouter.post('/email/send-verify', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { email } = await c.req.json<{ email: string }>()
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
|
||||||
|
|
||||||
|
// 检查邮箱是否已被其他已验证用户绑定
|
||||||
|
const existing = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
|
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
|
||||||
|
|
||||||
|
// 60 秒冷却:上次发送时间在 60 秒内则拒绝
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (user?.emailVerifyExpiry && user.emailVerifyExpiry - Date.now() > 9 * 60 * 1000) {
|
||||||
|
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = randomCode()
|
||||||
|
const expiry = Date.now() + 10 * 60 * 1000
|
||||||
|
// 只写入 pendingEmail,不修改 email 字段
|
||||||
|
db.update(users).set({ pendingEmail: email, emailVerifyCode: code, emailVerifyExpiry: expiry }).where(eq(users.id, userId)).run()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendVerifyCode(email, code)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[email]', err)
|
||||||
|
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||||
|
}
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证邮箱绑定验证码
|
||||||
|
authRouter.post('/email/verify', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { code } = await c.req.json<{ code: string }>()
|
||||||
|
if (!code) return c.json({ error: '请输入验证码' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
||||||
|
if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) return c.json({ error: '验证码已过期,请重新发送' }, 400)
|
||||||
|
|
||||||
|
// 验证成功:将 pendingEmail 写入正式 email 字段
|
||||||
|
db.update(users).set({ email: user.pendingEmail, emailVerified: true, pendingEmail: null, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 忘记密码:发送重置验证码
|
||||||
|
authRouter.post('/forgot-password', async (c) => {
|
||||||
|
const { email } = await c.req.json<{ email: string }>()
|
||||||
|
if (!email) return c.json({ error: '请输入邮箱' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
|
// 无论是否找到用户都返回成功,避免枚举攻击
|
||||||
|
if (!user || !user.emailVerified) return c.json({ success: true })
|
||||||
|
|
||||||
|
const code = randomCode()
|
||||||
|
const expiry = Date.now() + 10 * 60 * 1000
|
||||||
|
db.update(users).set({ resetCode: code, resetCodeExpiry: expiry }).where(eq(users.id, user.id)).run()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendResetCode(email, code)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||||
|
}
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置密码:验证码 + 新密码
|
||||||
|
authRouter.post('/reset-password', async (c) => {
|
||||||
|
const { email, code, newPassword } = await c.req.json<{ email: string; code: string; newPassword: string }>()
|
||||||
|
if (!email || !code || !newPassword) return c.json({ error: '参数不完整' }, 400)
|
||||||
|
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
|
if (!user) return c.json({ error: '邮箱未绑定任何账号' }, 404)
|
||||||
|
if (!user.resetCode || user.resetCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
||||||
|
if (!user.resetCodeExpiry || Date.now() > user.resetCodeExpiry) return c.json({ error: '验证码已过期,请重新获取' }, 400)
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||||
|
db.update(users).set({ passwordHash, resetCode: null, resetCodeExpiry: null }).where(eq(users.id, user.id)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修改密码(需要旧密码)
|
||||||
|
authRouter.post('/change-password', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { oldPassword, newPassword } = await c.req.json<{ oldPassword: string; newPassword: string }>()
|
||||||
|
if (!oldPassword || !newPassword) return c.json({ error: '参数不完整' }, 400)
|
||||||
|
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
const ok = await bcrypt.compare(oldPassword, user.passwordHash)
|
||||||
|
if (!ok) return c.json({ error: '当前密码错误' }, 400)
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||||
|
db.update(users).set({ passwordHash }).where(eq(users.id, userId)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore'
|
|||||||
import { seedIfEmpty, deduplicateDB } from './db'
|
import { seedIfEmpty, deduplicateDB } from './db'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
|
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId, refreshCurrentUser } = useAppStore()
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -17,6 +17,7 @@ export default function App() {
|
|||||||
seedIfEmpty()
|
seedIfEmpty()
|
||||||
.then(() => deduplicateDB())
|
.then(() => deduplicateDB())
|
||||||
.then(() => loadAll())
|
.then(() => loadAll())
|
||||||
|
refreshCurrentUser()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { X, Users, MessageSquare, Key, FileText, Plus, Ban, CheckCircle, Trash2, RefreshCw } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
apiAdminGetSiteContent, apiAdminUpdateSiteContent,
|
||||||
|
apiAdminGetUsers, apiAdminBanUser, apiAdminUnbanUser,
|
||||||
|
apiAdminGetComments, apiAdminDeleteComment,
|
||||||
|
apiAdminGetInviteCodes, apiAdminCreateInviteCode,
|
||||||
|
type AdminUser, type AdminComment, type InviteCode,
|
||||||
|
} from '../../lib/auth'
|
||||||
|
|
||||||
|
type Tab = 'site' | 'users' | 'comments' | 'invites'
|
||||||
|
|
||||||
|
export function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const [tab, setTab] = useState<Tab>('site')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex flex-col rounded-xl shadow-2xl overflow-hidden"
|
||||||
|
style={{ width: 720, maxWidth: '96vw', maxHeight: '88vh', background: 'var(--bg)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>管理员面板</span>
|
||||||
|
<button onClick={onClose} style={{ color: 'var(--text-faint)' }} className="hover:opacity-70 transition-opacity">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 导航 */}
|
||||||
|
<div className="flex gap-1 px-4 pt-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{([
|
||||||
|
['site', FileText, '站点内容'],
|
||||||
|
['users', Users, '用户管理'],
|
||||||
|
['comments', MessageSquare, '评论管理'],
|
||||||
|
['invites', Key, '邀请码'],
|
||||||
|
] as [Tab, typeof FileText, string][]).map(([id, Icon, label]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-xs rounded-t transition-colors"
|
||||||
|
style={{
|
||||||
|
color: tab === id ? 'var(--accent)' : 'var(--text-faint)',
|
||||||
|
borderBottom: tab === id ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={12} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{tab === 'site' && <SiteContentTab />}
|
||||||
|
{tab === 'users' && <UsersTab />}
|
||||||
|
{tab === 'comments' && <CommentsTab />}
|
||||||
|
{tab === 'invites' && <InvitesTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 站点内容 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SiteContentTab() {
|
||||||
|
const [content, setContent] = useState<Record<string, string>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiAdminGetSiteContent().then(setContent).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
try {
|
||||||
|
await apiAdminUpdateSiteContent(content)
|
||||||
|
setMsg('已保存')
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
|
||||||
|
|
||||||
|
const fields: [string, string, boolean][] = [
|
||||||
|
['tagline', '标语', false],
|
||||||
|
['subtitle', '副标题', false],
|
||||||
|
['news', '动态列表(JSON 数组)', true],
|
||||||
|
['projects', '项目卡片(JSON 数组)', true],
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{fields.map(([key, label, multi]) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>{label}</label>
|
||||||
|
{multi ? (
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
value={content[key] ?? ''}
|
||||||
|
onChange={e => setContent(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="text-xs rounded-lg p-2.5 font-mono resize-y"
|
||||||
|
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={content[key] ?? ''}
|
||||||
|
onChange={e => setContent(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="text-xs rounded-lg px-3 py-2"
|
||||||
|
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-1.5 rounded-lg text-xs font-medium transition-opacity"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: saving ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="text-xs" style={{ color: msg === '已保存' ? '#22c55e' : '#ef4444' }}>{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 用户管理 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UsersTab() {
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
apiAdminGetUsers().then(setUsers).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function toggleBan(user: AdminUser) {
|
||||||
|
if (user.banned) await apiAdminUnbanUser(user.id)
|
||||||
|
else await apiAdminBanUser(user.id)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>共 {users.length} 位用户</span>
|
||||||
|
<button onClick={load} className="flex items-center gap-1 text-xs" style={{ color: 'var(--text-faint)' }}><RefreshCw size={11} />刷新</button>
|
||||||
|
</div>
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.id} className="flex items-center gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{u.username}</span>
|
||||||
|
{u.role === 'admin' && <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#7c3aed22', color: '#a855f7' }}>管理员</span>}
|
||||||
|
{u.banned && <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#ef444422', color: '#ef4444' }}>已封禁</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{u.email ?? '未绑定邮箱'} · {new Date(u.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{u.role !== 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleBan(u)}
|
||||||
|
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded transition-opacity hover:opacity-70"
|
||||||
|
style={{ background: u.banned ? '#22c55e22' : '#ef444422', color: u.banned ? '#22c55e' : '#ef4444' }}
|
||||||
|
>
|
||||||
|
{u.banned ? <><CheckCircle size={10} />解封</> : <><Ban size={10} />封禁</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 评论管理 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CommentsTab() {
|
||||||
|
const [items, setItems] = useState<AdminComment[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
apiAdminGetComments().then(setItems).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function del(id: string) {
|
||||||
|
await apiAdminDeleteComment(id)
|
||||||
|
setItems(prev => prev.filter(i => i.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
|
||||||
|
if (!items.length) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>暂无评论</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{items.map(item => (
|
||||||
|
<div key={item.id} className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{item.username ?? '匿名'}</span>
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{new Date(item.createdAt).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-faint)', whiteSpace: 'pre-wrap' }}>{item.content}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => del(item.id)} className="flex-shrink-0 hover:opacity-70 transition-opacity" style={{ color: '#ef4444' }}>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 邀请码管理 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function InvitesTab() {
|
||||||
|
const [codes, setCodes] = useState<InviteCode[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
apiAdminGetInviteCodes().then(setCodes).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await apiAdminCreateInviteCode()
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>共 {codes.length} 个邀请码</span>
|
||||||
|
<button
|
||||||
|
onClick={create}
|
||||||
|
disabled={creating}
|
||||||
|
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg transition-opacity"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: creating ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
<Plus size={11} />生成邀请码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{codes.map(c => (
|
||||||
|
<div key={c.code} className="flex items-center gap-3 px-3 py-2 rounded-lg font-mono text-xs" style={{ background: 'var(--bg-muted)' }}>
|
||||||
|
<span className="flex-1" style={{ color: c.usedByUserId ? 'var(--text-faint)' : 'var(--text)' }}>{c.code}</span>
|
||||||
|
{c.usedByUserId ? (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#6b728022', color: 'var(--text-faint)' }}>已使用</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#22c55e22', color: '#22c55e' }}>可用</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
const fetchModels = async () => {
|
const fetchModels = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/models')
|
const res = await fetch('https://www.mikivl.online/api/note/models')
|
||||||
setModels(await res.json())
|
setModels(await res.json())
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -32,12 +32,12 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
useEffect(() => { fetchModels() }, [])
|
useEffect(() => { fetchModels() }, [])
|
||||||
|
|
||||||
const activate = async (id: string) => {
|
const activate = async (id: string) => {
|
||||||
await fetch(`/api/models/${id}/activate`, { method: 'PATCH' })
|
await fetch(`https://www.mikivl.online/api/note/models/${id}/activate`, { method: 'PATCH' })
|
||||||
setModels(prev => prev.map(m => ({ ...m, isActive: m.id === id })))
|
setModels(prev => prev.map(m => ({ ...m, isActive: m.id === id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' })
|
const res = await fetch(`https://www.mikivl.online/api/note/models/${id}`, { method: 'DELETE' })
|
||||||
if (res.ok) setModels(prev => {
|
if (res.ok) setModels(prev => {
|
||||||
const next = prev.filter(m => m.id !== id)
|
const next = prev.filter(m => m.id !== id)
|
||||||
if (prev.find(m => m.id === id)?.isActive && next.length > 0) next[0].isActive = true
|
if (prev.find(m => m.id === id)?.isActive && next.length > 0) next[0].isActive = true
|
||||||
@@ -52,7 +52,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
setFormError('')
|
setFormError('')
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/models', {
|
const res = await fetch('https://www.mikivl.online/api/note/models', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { X, CheckCircle } from 'lucide-react'
|
||||||
|
import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth'
|
||||||
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
|
type Step = 'enter-email' | 'enter-code'
|
||||||
|
|
||||||
|
export function AccountModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { currentUser, setCurrentUser } = useAppStore()
|
||||||
|
const [step, setStep] = useState<Step>('enter-email')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [countdown, setCountdown] = useState(0)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
setCountdown(60)
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setCountdown(n => {
|
||||||
|
if (n <= 1) { clearInterval(timerRef.current!); return 0 }
|
||||||
|
return n - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendCode(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await apiSendEmailVerify(email)
|
||||||
|
setStep('enter-code')
|
||||||
|
startCountdown()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await apiSendEmailVerify(email)
|
||||||
|
startCountdown()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await apiVerifyEmail(code)
|
||||||
|
const updated = await apiGetMe()
|
||||||
|
setCurrentUser(updated)
|
||||||
|
onClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
background: 'var(--bg-muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-2xl p-6 w-80 flex flex-col gap-4"
|
||||||
|
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||||
|
{currentUser?.emailVerified ? '邮箱已绑定' : '绑定邮箱'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentUser?.emailVerified ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
|
||||||
|
</div>
|
||||||
|
) : step === 'enter-email' ? (
|
||||||
|
<form onSubmit={handleSendCode} className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="email"
|
||||||
|
placeholder="输入邮箱地址"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '发送中…' : '发送验证码'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleVerify} className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
验证码已发送至 {email},10 分钟内有效
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="输入 6 位验证码"
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '验证中…' : '验证'}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStep('enter-email'); setError(''); setCode('') }}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
重新输入邮箱
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={countdown > 0 || loading}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: countdown > 0 ? 'var(--text-faint)' : 'var(--accent)', cursor: countdown > 0 ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{countdown > 0 ? `${countdown}s 后重发` : '重新发送'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { apiLogin, apiRegister, apiActivate, setToken } from '../../lib/auth'
|
import { apiLogin, apiRegister, apiRegisterSendCode, apiActivate, setToken, apiForgotPassword, apiResetPassword } from '../../lib/auth'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
type Tab = 'login' | 'register' | 'activate'
|
type Tab = 'login' | 'register' | 'activate'
|
||||||
|
type RegisterStep = 'form' | 'verify'
|
||||||
|
type ForgotStep = 'email' | 'reset'
|
||||||
|
|
||||||
export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) {
|
export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) {
|
||||||
const [tab, setTab] = useState<Tab>(initialTab ?? 'login')
|
const [tab, setTab] = useState<Tab>(initialTab ?? 'login')
|
||||||
@@ -14,6 +16,127 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { setCurrentUser, syncFromCloud, currentUser } = useAppStore()
|
const { setCurrentUser, syncFromCloud, currentUser } = useAppStore()
|
||||||
|
|
||||||
|
// 注册两步流程
|
||||||
|
const [registerStep, setRegisterStep] = useState<RegisterStep>('form')
|
||||||
|
const [regUsername, setRegUsername] = useState('')
|
||||||
|
const [regEmail, setRegEmail] = useState('')
|
||||||
|
const [regPassword, setRegPassword] = useState('')
|
||||||
|
const [regCode, setRegCode] = useState('')
|
||||||
|
const [countdown, setCountdown] = useState(0)
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// 忘记密码状态
|
||||||
|
const [forgotMode, setForgotMode] = useState(false)
|
||||||
|
const [forgotStep, setForgotStep] = useState<ForgotStep>('email')
|
||||||
|
const [forgotEmail, setForgotEmail] = useState('')
|
||||||
|
const [forgotCode, setForgotCode] = useState('')
|
||||||
|
const [forgotPassword, setForgotPassword] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => () => { if (countdownRef.current) clearInterval(countdownRef.current) }, [])
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
setCountdown(60)
|
||||||
|
countdownRef.current = setInterval(() => {
|
||||||
|
setCountdown(v => {
|
||||||
|
if (v <= 1) { clearInterval(countdownRef.current!); return 0 }
|
||||||
|
return v - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRegister() {
|
||||||
|
setRegisterStep('form')
|
||||||
|
setRegUsername('')
|
||||||
|
setRegEmail('')
|
||||||
|
setRegPassword('')
|
||||||
|
setRegCode('')
|
||||||
|
setCountdown(0)
|
||||||
|
if (countdownRef.current) clearInterval(countdownRef.current)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForgot() {
|
||||||
|
setForgotMode(false)
|
||||||
|
setForgotStep('email')
|
||||||
|
setForgotEmail('')
|
||||||
|
setForgotCode('')
|
||||||
|
setForgotPassword('')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegisterSend(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await apiRegisterSendCode(regUsername, regPassword, regEmail)
|
||||||
|
setRegisterStep('verify')
|
||||||
|
startCountdown()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegisterVerify(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { token, user } = await apiRegister(regEmail, regCode)
|
||||||
|
setToken(token)
|
||||||
|
setCurrentUser(user)
|
||||||
|
onClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
if (countdown > 0) return
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await apiRegisterSendCode(regUsername, regPassword, regEmail)
|
||||||
|
startCountdown()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForgotSend(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await apiForgotPassword(forgotEmail)
|
||||||
|
setForgotStep('reset')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForgotReset(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await apiResetPassword(forgotEmail, forgotCode, forgotPassword)
|
||||||
|
resetForgot()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -25,11 +148,6 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
setCurrentUser(user)
|
setCurrentUser(user)
|
||||||
if (user.cloudEnabled) await syncFromCloud()
|
if (user.cloudEnabled) await syncFromCloud()
|
||||||
onClose()
|
onClose()
|
||||||
} else if (tab === 'register') {
|
|
||||||
const { token, user } = await apiRegister(username, password)
|
|
||||||
setToken(token)
|
|
||||||
setCurrentUser(user)
|
|
||||||
onClose()
|
|
||||||
} else {
|
} else {
|
||||||
await apiActivate(code)
|
await apiActivate(code)
|
||||||
setCurrentUser({ ...currentUser!, cloudEnabled: true })
|
setCurrentUser({ ...currentUser!, cloudEnabled: true })
|
||||||
@@ -67,19 +185,189 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||||
{tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储'}
|
{forgotMode
|
||||||
|
? (forgotStep === 'email' ? '找回密码' : '重置密码')
|
||||||
|
: (tab === 'login' ? '登录' : tab === 'register'
|
||||||
|
? (registerStep === 'form' ? '注册' : '验证邮箱')
|
||||||
|
: '激活云存储')}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 忘记密码流程 */}
|
||||||
|
{forgotMode ? (
|
||||||
|
forgotStep === 'email' ? (
|
||||||
|
<form onSubmit={handleForgotSend} className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
输入绑定的邮箱,我们将发送验证码
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="email"
|
||||||
|
placeholder="邮箱地址"
|
||||||
|
value={forgotEmail}
|
||||||
|
onChange={e => setForgotEmail(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetForgot}
|
||||||
|
className="flex-1 py-2 rounded-lg text-sm"
|
||||||
|
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '发送中…' : '发送验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleForgotReset} className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
验证码已发送至 {forgotEmail},10 分钟内有效
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
value={forgotCode}
|
||||||
|
onChange={e => setForgotCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="password"
|
||||||
|
placeholder="新密码(至少 6 位)"
|
||||||
|
value={forgotPassword}
|
||||||
|
onChange={e => setForgotPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setForgotStep('email'); setError('') }}
|
||||||
|
className="flex-1 py-2 rounded-lg text-sm"
|
||||||
|
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '重置中…' : '重置密码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
) : tab === 'register' ? (
|
||||||
|
// 注册两步流程
|
||||||
|
registerStep === 'form' ? (
|
||||||
|
<form onSubmit={handleRegisterSend} className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="用户名(2-20 位)"
|
||||||
|
value={regUsername}
|
||||||
|
onChange={e => setRegUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="email"
|
||||||
|
placeholder="邮箱地址"
|
||||||
|
value={regEmail}
|
||||||
|
onChange={e => setRegEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="password"
|
||||||
|
placeholder="密码(至少 6 位)"
|
||||||
|
value={regPassword}
|
||||||
|
onChange={e => setRegPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setTab('login'); setError('') }}
|
||||||
|
className="flex-1 py-2 rounded-lg text-sm"
|
||||||
|
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
去登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '发送中…' : '发送验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleRegisterVerify} className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
验证码已发送至 <strong style={{ color: 'var(--text)' }}>{regEmail}</strong>,10 分钟内有效
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
value={regCode}
|
||||||
|
onChange={e => setRegCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '注册中…' : '完成注册'}
|
||||||
|
</button>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { resetRegister() }}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
修改信息
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={countdown > 0 || loading}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: countdown > 0 ? 'var(--text-faint)' : 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{countdown > 0 ? `重新发送 (${countdown}s)` : '重新发送'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{tab !== 'activate' && (
|
{tab !== 'activate' && (
|
||||||
<div className="flex gap-1 p-1 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
|
<div className="flex gap-1 p-1 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
|
||||||
{(['login', 'register'] as Tab[]).map(t => (
|
{(['login', 'register'] as Tab[]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => { setTab(t); setError('') }}
|
||||||
className="flex-1 py-1 rounded text-xs font-medium transition-all"
|
className="flex-1 py-1 rounded text-xs font-medium transition-all"
|
||||||
style={{
|
style={{
|
||||||
background: tab === t ? 'var(--bg)' : 'transparent',
|
background: tab === t ? 'var(--bg)' : 'transparent',
|
||||||
@@ -128,9 +416,22 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
className="w-full py-2 rounded-lg text-sm font-medium"
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
>
|
>
|
||||||
{loading ? '请稍候…' : tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活'}
|
{loading ? '请稍候…' : tab === 'login' ? '登录' : '激活'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{tab === 'login' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setForgotMode(true); setError('') }}
|
||||||
|
className="text-xs text-center"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
忘记密码?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
apiUpdateProfile, apiChangePassword,
|
||||||
|
apiSendEmailVerify, apiVerifyEmail, apiActivate, apiGetMe,
|
||||||
|
} from '../../lib/auth'
|
||||||
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
|
type Section = 'main' | 'email' | 'email-code' | 'password' | 'change-password'
|
||||||
|
|
||||||
|
export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { currentUser, setCurrentUser, logout, syncStatus, syncFromCloud } = useAppStore()
|
||||||
|
const [section, setSection] = useState<Section>('main')
|
||||||
|
|
||||||
|
// 头像/昵称
|
||||||
|
const [nickname, setNickname] = useState(currentUser?.nickname ?? '')
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState(currentUser?.avatar ?? null)
|
||||||
|
const [avatarData, setAvatarData] = useState<string | undefined>(undefined)
|
||||||
|
const [profileLoading, setProfileLoading] = useState(false)
|
||||||
|
const [profileMsg, setProfileMsg] = useState('')
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// 邀请码
|
||||||
|
const [inviteCode, setInviteCode] = useState('')
|
||||||
|
const [inviteLoading, setInviteLoading] = useState(false)
|
||||||
|
const [inviteError, setInviteError] = useState('')
|
||||||
|
|
||||||
|
// 邮箱
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [emailCode, setEmailCode] = useState('')
|
||||||
|
const [emailLoading, setEmailLoading] = useState(false)
|
||||||
|
const [emailError, setEmailError] = useState('')
|
||||||
|
const [resendCountdown, setResendCountdown] = useState(0)
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
const [oldPwd, setOldPwd] = useState('')
|
||||||
|
const [newPwd, setNewPwd] = useState('')
|
||||||
|
const [pwdLoading, setPwdLoading] = useState(false)
|
||||||
|
const [pwdError, setPwdError] = useState('')
|
||||||
|
const [pwdOk, setPwdOk] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => () => { if (countdownRef.current) clearInterval(countdownRef.current) }, [])
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
setResendCountdown(60)
|
||||||
|
countdownRef.current = setInterval(() => {
|
||||||
|
setResendCountdown(n => {
|
||||||
|
if (n <= 1) { clearInterval(countdownRef.current!); return 0 }
|
||||||
|
return n - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
background: 'var(--bg-muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = ev => {
|
||||||
|
const result = ev.target?.result as string
|
||||||
|
setAvatarPreview(result)
|
||||||
|
setAvatarData(result)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveProfile(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setProfileLoading(true)
|
||||||
|
setProfileMsg('')
|
||||||
|
try {
|
||||||
|
const updated = await apiUpdateProfile(nickname, avatarData)
|
||||||
|
setCurrentUser(updated)
|
||||||
|
setAvatarData(undefined)
|
||||||
|
setProfileMsg('保存成功')
|
||||||
|
setTimeout(() => setProfileMsg(''), 2000)
|
||||||
|
} catch (err: any) {
|
||||||
|
setProfileMsg(err.message)
|
||||||
|
} finally {
|
||||||
|
setProfileLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleActivate(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setInviteError('')
|
||||||
|
setInviteLoading(true)
|
||||||
|
try {
|
||||||
|
await apiActivate(inviteCode)
|
||||||
|
const updated = await apiGetMe()
|
||||||
|
setCurrentUser(updated)
|
||||||
|
await syncFromCloud()
|
||||||
|
setInviteCode('')
|
||||||
|
} catch (err: any) {
|
||||||
|
setInviteError(err.message)
|
||||||
|
} finally {
|
||||||
|
setInviteLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendEmailCode(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setEmailError('')
|
||||||
|
setEmailLoading(true)
|
||||||
|
try {
|
||||||
|
await apiSendEmailVerify(email)
|
||||||
|
setSection('email-code')
|
||||||
|
startCountdown()
|
||||||
|
} catch (err: any) {
|
||||||
|
setEmailError(err.message)
|
||||||
|
} finally {
|
||||||
|
setEmailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResendEmailCode() {
|
||||||
|
setEmailError('')
|
||||||
|
setEmailLoading(true)
|
||||||
|
try {
|
||||||
|
await apiSendEmailVerify(email)
|
||||||
|
startCountdown()
|
||||||
|
} catch (err: any) {
|
||||||
|
setEmailError(err.message)
|
||||||
|
} finally {
|
||||||
|
setEmailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyEmail(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setEmailError('')
|
||||||
|
setEmailLoading(true)
|
||||||
|
try {
|
||||||
|
await apiVerifyEmail(emailCode)
|
||||||
|
const updated = await apiGetMe()
|
||||||
|
setCurrentUser(updated)
|
||||||
|
setSection('main')
|
||||||
|
setEmailCode('')
|
||||||
|
setEmail('')
|
||||||
|
} catch (err: any) {
|
||||||
|
setEmailError(err.message)
|
||||||
|
} finally {
|
||||||
|
setEmailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setPwdError('')
|
||||||
|
setPwdOk(false)
|
||||||
|
setPwdLoading(true)
|
||||||
|
try {
|
||||||
|
await apiChangePassword(oldPwd, newPwd)
|
||||||
|
setOldPwd('')
|
||||||
|
setNewPwd('')
|
||||||
|
setPwdOk(true)
|
||||||
|
setTimeout(() => { setPwdOk(false); setSection('main') }, 1500)
|
||||||
|
} catch (err: any) {
|
||||||
|
setPwdError(err.message)
|
||||||
|
} finally {
|
||||||
|
setPwdLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = currentUser?.nickname || currentUser?.username || ''
|
||||||
|
const avatarSrc = avatarPreview || currentUser?.avatar
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-2xl p-6 w-96 flex flex-col gap-5 max-h-[90vh] overflow-y-auto"
|
||||||
|
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{section !== 'main' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSection('main'); setEmailError(''); setPwdError('') }}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||||
|
{section === 'main' && '个人中心'}
|
||||||
|
{section === 'email' && '绑定邮箱'}
|
||||||
|
{section === 'email-code' && '验证邮箱'}
|
||||||
|
{section === 'password' && '修改密码'}
|
||||||
|
{section === 'change-password' && '修改密码'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主界面 */}
|
||||||
|
{section === 'main' && (
|
||||||
|
<>
|
||||||
|
{/* 头像 + 昵称 */}
|
||||||
|
<form onSubmit={handleSaveProfile} className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold overflow-hidden cursor-pointer"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff' }}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
{avatarSrc ? (
|
||||||
|
<img src={avatarSrc} alt="头像" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
displayName[0]?.toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="absolute bottom-0 right-0 w-5 h-5 rounded-full flex items-center justify-center"
|
||||||
|
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<Camera size={10} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</button>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs mb-1 block" style={{ color: 'var(--text-faint)' }}>昵称</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder={currentUser?.username}
|
||||||
|
value={nickname}
|
||||||
|
onChange={e => setNickname(e.target.value)}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{profileMsg && (
|
||||||
|
<p className="text-xs" style={{ color: profileMsg === '保存成功' ? 'var(--accent)' : '#ef4444' }}>
|
||||||
|
{profileMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={profileLoading}
|
||||||
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: profileLoading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{profileLoading ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||||
|
|
||||||
|
{/* 云存储 / 邀请码 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>云存储</span>
|
||||||
|
{currentUser?.cloudEnabled ? (
|
||||||
|
<button
|
||||||
|
onClick={syncFromCloud}
|
||||||
|
disabled={syncStatus === 'syncing'}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
style={{ color: syncStatus === 'error' ? '#ef4444' : 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
<Cloud size={13} />
|
||||||
|
{syncStatus === 'syncing' ? '同步中…' : syncStatus === 'error' ? '同步失败,点击重试' : '已开启,点击手动同步'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleActivate} className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="输入邀请码(如 MIKI-A7X2-KP9Q)"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={e => setInviteCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
{inviteError && <p className="text-xs" style={{ color: '#ef4444' }}>{inviteError}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={inviteLoading}
|
||||||
|
className="w-full py-1.5 rounded-lg text-sm"
|
||||||
|
style={{ background: 'var(--bg-muted)', color: 'var(--text)', border: '1px solid var(--border)', opacity: inviteLoading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{inviteLoading ? '激活中…' : '激活云存储'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||||
|
|
||||||
|
{/* 邮箱 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>绑定邮箱</span>
|
||||||
|
{currentUser?.emailVerified ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmail(currentUser.email ?? ''); setSection('email') }}
|
||||||
|
className="text-xs ml-auto"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
更换
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : currentUser?.email ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>{currentUser.email}(未验证)</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmail(currentUser.email ?? ''); setSection('email-code') }}
|
||||||
|
className="text-xs ml-auto"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
输入验证码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setSection('email')}
|
||||||
|
className="text-sm text-left"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
+ 绑定邮箱
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||||
|
|
||||||
|
{/* 修改密码 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSection('change-password')}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
||||||
|
>
|
||||||
|
<Key size={13} />
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 退出登录 */}
|
||||||
|
<button
|
||||||
|
onClick={() => { logout(); onClose() }}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
style={{ color: '#ef4444' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = '0.7')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
|
||||||
|
>
|
||||||
|
<LogOut size={13} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 绑定邮箱 */}
|
||||||
|
{section === 'email' && (
|
||||||
|
<form onSubmit={handleSendEmailCode} className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="email"
|
||||||
|
placeholder="输入邮箱地址"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{emailError && <p className="text-xs" style={{ color: '#ef4444' }}>{emailError}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={emailLoading}
|
||||||
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: emailLoading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{emailLoading ? '发送中…' : '发送验证码'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 验证邮箱验证码 */}
|
||||||
|
{section === 'email-code' && (
|
||||||
|
<form onSubmit={handleVerifyEmail} className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
验证码已发送至 {email},10 分钟内有效
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="输入 6 位验证码"
|
||||||
|
value={emailCode}
|
||||||
|
onChange={e => setEmailCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{emailError && <p className="text-xs" style={{ color: '#ef4444' }}>{emailError}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={emailLoading}
|
||||||
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: emailLoading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{emailLoading ? '验证中…' : '验证'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendEmailCode}
|
||||||
|
disabled={resendCountdown > 0 || emailLoading}
|
||||||
|
className="text-xs text-center"
|
||||||
|
style={{ color: resendCountdown > 0 ? 'var(--text-faint)' : 'var(--accent)', cursor: resendCountdown > 0 ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{resendCountdown > 0 ? `${resendCountdown} 秒后可重新发送` : '重新发送验证码'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 修改密码 */}
|
||||||
|
{section === 'change-password' && (
|
||||||
|
<form onSubmit={handleChangePassword} className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="password"
|
||||||
|
placeholder="当前密码"
|
||||||
|
value={oldPwd}
|
||||||
|
onChange={e => setOldPwd(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="password"
|
||||||
|
placeholder="新密码(至少 6 位)"
|
||||||
|
value={newPwd}
|
||||||
|
onChange={e => setNewPwd(e.target.value)}
|
||||||
|
/>
|
||||||
|
{pwdError && <p className="text-xs" style={{ color: '#ef4444' }}>{pwdError}</p>}
|
||||||
|
{pwdOk && <p className="text-xs" style={{ color: 'var(--accent)' }}>密码修改成功</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pwdLoading}
|
||||||
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff', opacity: pwdLoading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{pwdLoading ? '修改中…' : '确认修改'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { User, LogOut, Cloud, Key } from 'lucide-react'
|
import { User, Cloud, Key } from 'lucide-react'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { LoginModal } from './LoginModal'
|
import { LoginModal } from './LoginModal'
|
||||||
|
import { ProfileModal } from './ProfileModal'
|
||||||
|
|
||||||
export function UserMenu() {
|
export function UserMenu() {
|
||||||
const { currentUser, logout, syncStatus, syncFromCloud } = useAppStore()
|
const { currentUser, syncStatus, syncFromCloud } = useAppStore()
|
||||||
const [showLogin, setShowLogin] = useState(false)
|
const [showLogin, setShowLogin] = useState(false)
|
||||||
const [showActivate, setShowActivate] = useState(false)
|
const [showProfile, setShowProfile] = useState(false)
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
@@ -26,32 +27,32 @@ export function UserMenu() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = currentUser.nickname || currentUser.username
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-2 flex flex-col gap-1">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<button
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => setShowProfile(true)}
|
||||||
|
className="flex items-center gap-2 w-full text-left rounded-lg transition-all"
|
||||||
|
style={{ background: 'transparent' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 overflow-hidden"
|
||||||
style={{ background: 'var(--accent)', color: '#fff' }}
|
style={{ background: 'var(--accent)', color: '#fff' }}
|
||||||
>
|
>
|
||||||
{currentUser.username[0].toUpperCase()}
|
{currentUser.avatar ? (
|
||||||
|
<img src={currentUser.avatar} alt="头像" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
displayName[0]?.toUpperCase()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
|
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
|
||||||
{currentUser.username}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
title="登出"
|
|
||||||
className="p-1 rounded flex-shrink-0"
|
|
||||||
style={{ color: 'var(--text-faint)' }}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text)')}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
|
||||||
>
|
|
||||||
<LogOut size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentUser.cloudEnabled ? (
|
{currentUser.cloudEnabled ? (
|
||||||
<button
|
<button
|
||||||
@@ -65,7 +66,7 @@ export function UserMenu() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowActivate(true)}
|
onClick={() => setShowProfile(true)}
|
||||||
className="flex items-center gap-1.5 text-xs"
|
className="flex items-center gap-1.5 text-xs"
|
||||||
style={{ color: 'var(--text-faint)' }}
|
style={{ color: 'var(--text-faint)' }}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
|
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
|
||||||
@@ -75,11 +76,10 @@ export function UserMenu() {
|
|||||||
<span>输入邀请码开启云存储</span>
|
<span>输入邀请码开启云存储</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showActivate && (
|
{showProfile && <ProfileModal onClose={() => setShowProfile(false)} />}
|
||||||
<LoginModal initialTab="activate" onClose={() => setShowActivate(false)} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { FileText, Plus, Clock, Hash, Folder } from 'lucide-react'
|
||||||
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
import { formatDate } from '../../lib/utils'
|
||||||
|
|
||||||
|
export function AllNotesView() {
|
||||||
|
const { notes, folders, createNote, setActiveNote } = useAppStore()
|
||||||
|
|
||||||
|
const recentNotes = [...notes]
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const totalWords = notes.reduce((sum, n) => sum + (n.wordCount ?? 0), 0)
|
||||||
|
|
||||||
|
const tagCounts: Record<string, number> = {}
|
||||||
|
for (const note of notes) {
|
||||||
|
for (const tag of note.tags) {
|
||||||
|
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topTags = Object.entries(tagCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
|
||||||
|
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6 text-2xl"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
<FileText size={26} />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-4xl font-bold mb-3"
|
||||||
|
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
|
||||||
|
>
|
||||||
|
所有笔记
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
|
||||||
|
共 {notes.length} 篇笔记,{folders.length} 个文件夹,累计约 {totalWords.toLocaleString()} 字。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => createNote(null)}
|
||||||
|
className="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--accent)')}
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
新建笔记
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-10">
|
||||||
|
{[
|
||||||
|
{ label: '全部笔记', value: notes.length, icon: <FileText size={16} /> },
|
||||||
|
{ label: '文件夹', value: folders.length, icon: <Folder size={16} /> },
|
||||||
|
{ label: '累计字数', value: totalWords.toLocaleString(), icon: <Hash size={16} /> },
|
||||||
|
].map(stat => (
|
||||||
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className="rounded-xl p-4"
|
||||||
|
style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-8 h-8 rounded-lg mb-3"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{stat.icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold mb-0.5" style={{ color: 'var(--text)' }}>{stat.value}</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最近修改 */}
|
||||||
|
{recentNotes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-widest mb-4"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
最近修改
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl overflow-hidden mb-10"
|
||||||
|
style={{ border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{recentNotes.map((note, i) => (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => setActiveNote(note.id)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3 text-left transition-colors"
|
||||||
|
style={{
|
||||||
|
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
|
||||||
|
background: 'var(--bg-subtle)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Clock size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span className="text-sm truncate" style={{ color: 'var(--text)' }}>
|
||||||
|
{note.title || '无标题笔记'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs shrink-0 ml-3" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{formatDate(note.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标签云 */}
|
||||||
|
{topTags.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-widest mb-4"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
常用标签
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{topTags.map(([tag, count]) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm"
|
||||||
|
style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<Hash size={11} />
|
||||||
|
{tag}
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>({count})</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<FileText size={40} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-sm mb-2">还没有笔记</p>
|
||||||
|
<p className="text-xs">点击上方「新建笔记」开始记录</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ import { useAppStore } from '../../stores/appStore'
|
|||||||
import { countWords } from '../../lib/utils'
|
import { countWords } from '../../lib/utils'
|
||||||
import { streamAI } from '../../lib/ai'
|
import { streamAI } from '../../lib/ai'
|
||||||
import { WelcomeView } from './WelcomeView'
|
import { WelcomeView } from './WelcomeView'
|
||||||
|
import { AllNotesView } from './AllNotesView'
|
||||||
|
import { StarredView } from './StarredView'
|
||||||
|
import { TrashHomeView } from './TrashHomeView'
|
||||||
import { ExportMenu } from './ExportMenu'
|
import { ExportMenu } from './ExportMenu'
|
||||||
|
|
||||||
const lowlight = createLowlight(common)
|
const lowlight = createLowlight(common)
|
||||||
@@ -80,7 +83,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Editor() {
|
export function Editor() {
|
||||||
const { activeNoteId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore()
|
const { activeNoteId, activeFolderId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore()
|
||||||
const activeNote = notes.find(n => n.id === activeNoteId)
|
const activeNote = notes.find(n => n.id === activeNoteId)
|
||||||
|
|
||||||
const [title, setTitle] = useState(activeNote?.title ?? '')
|
const [title, setTitle] = useState(activeNote?.title ?? '')
|
||||||
@@ -352,7 +355,10 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!activeNote || activeNoteId === '__welcome__') {
|
if (!activeNote || activeNoteId === '__welcome__') {
|
||||||
return <WelcomeView />
|
if (activeNoteId === '__welcome__') return <WelcomeView />
|
||||||
|
if (activeFolderId === 'starred') return <StarredView />
|
||||||
|
if (activeFolderId === 'trash') return <TrashHomeView />
|
||||||
|
return <AllNotesView />
|
||||||
}
|
}
|
||||||
|
|
||||||
const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0
|
const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Star, Plus, Hash, Clock } from 'lucide-react'
|
||||||
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
import { formatDate } from '../../lib/utils'
|
||||||
|
|
||||||
|
export function StarredView() {
|
||||||
|
const { notes, createNote, setActiveNote, toggleStar } = useAppStore()
|
||||||
|
const starredNotes = notes.filter(n => n.starred).sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
|
||||||
|
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: '#f59e0b' }}
|
||||||
|
>
|
||||||
|
<Star size={26} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-4xl font-bold mb-3"
|
||||||
|
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
|
||||||
|
>
|
||||||
|
收藏
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
|
||||||
|
{starredNotes.length > 0
|
||||||
|
? `已收藏 ${starredNotes.length} 篇笔记,点击即可快速访问。`
|
||||||
|
: '还没有收藏任何笔记。在笔记列表或编辑器标题旁点击星标即可收藏。'}
|
||||||
|
</p>
|
||||||
|
{starredNotes.length === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => createNote(null)}
|
||||||
|
className="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
style={{ background: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--accent)')}
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
新建笔记
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
|
||||||
|
|
||||||
|
{starredNotes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-widest mb-4"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
收藏的笔记
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
style={{ border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{starredNotes.map((note, i) => (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => setActiveNote(note.id)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3.5 text-left transition-colors"
|
||||||
|
style={{
|
||||||
|
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
|
||||||
|
background: 'var(--bg-subtle)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<Star size={13} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>
|
||||||
|
{note.title || '无标题笔记'}
|
||||||
|
</div>
|
||||||
|
{note.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||||
|
{note.tags.slice(0, 3).map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full"
|
||||||
|
style={{ background: 'var(--bg-muted)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<Hash size={9} />{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<Clock size={11} className="inline mr-1" />
|
||||||
|
{formatDate(note.updatedAt)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); toggleStar(note.id) }}
|
||||||
|
className="p-1 rounded transition-colors"
|
||||||
|
title="取消收藏"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = '#ef4444')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
||||||
|
>
|
||||||
|
<Star size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<Star size={40} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-sm mb-1">暂无收藏笔记</p>
|
||||||
|
<p className="text-xs">在笔记列表 hover 或编辑器标题旁点击 ★ 收藏</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Trash2, RotateCcw, AlertTriangle } from 'lucide-react'
|
||||||
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
|
function daysLeft(deletedAt: number): number {
|
||||||
|
const elapsed = Date.now() - deletedAt
|
||||||
|
return Math.max(0, 30 - Math.floor(elapsed / (24 * 60 * 60 * 1000)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrashHomeView() {
|
||||||
|
const { trashNotes, restoreNote, emptyTrash } = useAppStore()
|
||||||
|
const notes = trashNotes()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
|
||||||
|
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6"
|
||||||
|
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-4xl font-bold mb-3"
|
||||||
|
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
|
||||||
|
>
|
||||||
|
回收站
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
|
||||||
|
{notes.length > 0
|
||||||
|
? `${notes.length} 篇笔记将在 30 天后永久删除,可随时恢复。`
|
||||||
|
: '回收站为空,已删除的笔记会在这里保留 30 天。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('确认清空回收站?此操作无法撤销。')) emptyTrash()
|
||||||
|
}}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium mt-1 transition-colors"
|
||||||
|
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.18)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.1)')}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
清空回收站
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
|
||||||
|
|
||||||
|
{notes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* 提示 */}
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-xl px-4 py-3 mb-6 text-sm"
|
||||||
|
style={{ background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.2)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={15} style={{ color: '#ef4444', flexShrink: 0, marginTop: 1 }} />
|
||||||
|
笔记将在删除 30 天后自动永久清除,届时无法恢复。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-widest mb-4"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
待清除的笔记
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
style={{ border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{notes.map((note, i) => {
|
||||||
|
const days = daysLeft(note.deletedAt!)
|
||||||
|
const urgent = days <= 3
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="flex items-center justify-between px-5 py-3.5 group"
|
||||||
|
style={{
|
||||||
|
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
|
||||||
|
background: 'var(--bg-subtle)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>
|
||||||
|
{note.title || '无标题笔记'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: urgent ? '#ef4444' : 'var(--text-faint)' }}>
|
||||||
|
{days === 0 ? '今天永久删除' : `${days} 天后永久删除`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => restoreNote(note.id)}
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
恢复
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<Trash2 size={40} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-sm">回收站为空</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export function Sidebar() {
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null)
|
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||||
|
const [deleteFolderConfirm, setDeleteFolderConfirm] = useState<string | null>(null)
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
|
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
|
||||||
@@ -178,6 +179,7 @@ export function Sidebar() {
|
|||||||
if (e.key === 'Enter') handleRenameFolder(folder.id)
|
if (e.key === 'Enter') handleRenameFolder(folder.id)
|
||||||
if (e.key === 'Escape') setEditingFolderId(null)
|
if (e.key === 'Escape') setEditingFolderId(null)
|
||||||
}}
|
}}
|
||||||
|
onDelete={() => setDeleteFolderConfirm(folder.id)}
|
||||||
/>
|
/>
|
||||||
{expandedFolders.has(folder.id) && renderFolder(folder.id, depth + 1)}
|
{expandedFolders.has(folder.id) && renderFolder(folder.id, depth + 1)}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +201,17 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="6" y="4" width="20" height="26" rx="2" stroke="#4fc3f7" strokeWidth="1.5"/>
|
||||||
|
<line x1="10" y1="11" x2="22" y2="11" stroke="#4fc3f7" strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
<line x1="10" y1="16" x2="22" y2="16" stroke="#4fc3f7" strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
<line x1="10" y1="21" x2="17" y2="21" stroke="#4fc3f7" strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
<circle cx="27" cy="27" r="5" fill="var(--bg-subtle)" stroke="#4fc3f7" strokeWidth="1.2"/>
|
||||||
|
<path d="M25 27l1.5 1.5L29 25" stroke="#4fc3f7" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>笔记</span>
|
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>笔记</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
|
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
|
||||||
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}
|
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}
|
||||||
@@ -211,7 +223,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2 shrink-0">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5"
|
className="flex items-center gap-2 rounded-lg px-3 py-1.5"
|
||||||
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)' }}
|
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)' }}
|
||||||
@@ -228,28 +240,28 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav shortcuts */}
|
{/* Nav shortcuts */}
|
||||||
<nav className="px-2 space-y-0.5">
|
<nav className="px-2 space-y-0.5 shrink-0">
|
||||||
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
|
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
|
||||||
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); setActiveNote(null) }} />
|
||||||
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); setActiveNote(null) }} />
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<Trash2 size={14} />}
|
icon={<Trash2 size={14} />}
|
||||||
label="回收站"
|
label="回收站"
|
||||||
count={trashNotes().length || undefined}
|
count={trashNotes().length || undefined}
|
||||||
active={activeFolderId === 'trash'}
|
active={activeFolderId === 'trash'}
|
||||||
onClick={() => { setActiveFolder('trash'); if (activeNoteId === '__welcome__') setActiveNote(null) }}
|
onClick={() => { setActiveFolder('trash'); setActiveNote(null) }}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Folders section */}
|
{/* Folders section */}
|
||||||
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
|
<div className="px-3 pt-3 pb-1 flex items-center justify-between shrink-0">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>文件夹</span>
|
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>文件夹</span>
|
||||||
<button onClick={() => setCreatingFolder(true)} className="toolbar-btn" title="新建文件夹">
|
<button onClick={() => setCreatingFolder(true)} className="toolbar-btn" title="新建文件夹">
|
||||||
<FolderPlus size={13} />
|
<FolderPlus size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5 overflow-y-auto" style={{ maxHeight: '30%' }}>
|
||||||
{renderFolder(null, 0)}
|
{renderFolder(null, 0)}
|
||||||
|
|
||||||
{creatingFolder && (
|
{creatingFolder && (
|
||||||
@@ -472,6 +484,36 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{deleteFolderConfirm && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ background: 'rgba(0,0,0,0.4)' }}
|
||||||
|
onClick={() => setDeleteFolderConfirm(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-2xl p-5 flex flex-col gap-4"
|
||||||
|
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 320 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}>删除文件夹?</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>文件夹内的笔记将移至「所有笔记」,文件夹本身将被永久删除。</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteFolderConfirm(null)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>取消</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { await deleteFolder(deleteFolderConfirm); setDeleteFolderConfirm(null) }}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium"
|
||||||
|
style={{ background: '#ef4444', color: '#fff' }}
|
||||||
|
>永久删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -489,13 +531,15 @@ function NavItem({ icon, label, count, active, onClick }: { icon: React.ReactNod
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DroppableFolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown }: {
|
function DroppableFolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown, onDelete }: {
|
||||||
folder: FolderType; depth: number; expanded: boolean; active: boolean; editing: boolean
|
folder: FolderType; depth: number; expanded: boolean; active: boolean; editing: boolean
|
||||||
editValue: string; editRef: React.RefObject<HTMLInputElement | null>
|
editValue: string; editRef: React.RefObject<HTMLInputElement | null>
|
||||||
onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void
|
onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||||
onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void
|
onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void
|
||||||
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
const { isOver, setNodeRef } = useDroppable({ id: folder.id })
|
const { isOver, setNodeRef } = useDroppable({ id: folder.id })
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -503,6 +547,7 @@ function DroppableFolderItem({ folder, depth, expanded, active, editing, editVal
|
|||||||
className="flex items-center gap-1.5 py-1.5 rounded-lg cursor-pointer text-sm"
|
className="flex items-center gap-1.5 py-1.5 rounded-lg cursor-pointer text-sm"
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: 8 + depth * 16,
|
paddingLeft: 8 + depth * 16,
|
||||||
|
paddingRight: 8,
|
||||||
background: isOver ? 'var(--accent-subtle)' : active ? 'var(--accent-subtle)' : 'transparent',
|
background: isOver ? 'var(--accent-subtle)' : active ? 'var(--accent-subtle)' : 'transparent',
|
||||||
color: active || isOver ? 'var(--accent)' : 'var(--text-muted)',
|
color: active || isOver ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
outline: isOver ? '2px solid var(--accent)' : 'none',
|
outline: isOver ? '2px solid var(--accent)' : 'none',
|
||||||
@@ -510,6 +555,8 @@ function DroppableFolderItem({ folder, depth, expanded, active, editing, editVal
|
|||||||
}}
|
}}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
<button onClick={e => { e.stopPropagation(); onToggle() }} className="toolbar-btn" style={{ width: 16, height: 16, flexShrink: 0 }}>
|
<button onClick={e => { e.stopPropagation(); onToggle() }} className="toolbar-btn" style={{ width: 16, height: 16, flexShrink: 0 }}>
|
||||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
@@ -523,6 +570,18 @@ function DroppableFolderItem({ folder, depth, expanded, active, editing, editVal
|
|||||||
) : (
|
) : (
|
||||||
<span className="flex-1 truncate" style={{ fontWeight: active ? 500 : 400 }}>{folder.name}</span>
|
<span className="flex-1 truncate" style={{ fontWeight: active ? 500 : 400 }}>{folder.name}</span>
|
||||||
)}
|
)}
|
||||||
|
{hovered && !editing && (
|
||||||
|
<button
|
||||||
|
className="toolbar-btn"
|
||||||
|
style={{ width: 20, height: 20, color: '#ef4444', opacity: 0.8, flexShrink: 0 }}
|
||||||
|
title="删除文件夹"
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.8')}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -64,8 +64,12 @@ export function seedIfEmpty(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _doSeed() {
|
async function _doSeed() {
|
||||||
const count = await db.notes.count()
|
// 用文件夹数量做判断,防止 notes 为空但文件夹已存在时重复创建
|
||||||
if (count > 0) return
|
const [noteCount, folderCount] = await Promise.all([
|
||||||
|
db.notes.count(),
|
||||||
|
db.folders.count(),
|
||||||
|
])
|
||||||
|
if (noteCount > 0 || folderCount > 0) return
|
||||||
|
|
||||||
const folderId1 = crypto.randomUUID()
|
const folderId1 = crypto.randomUUID()
|
||||||
const folderId2 = crypto.randomUUID()
|
const folderId2 = crypto.randomUUID()
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ export async function streamAI(
|
|||||||
onChunk: (text: string) => void,
|
onChunk: (text: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch('/api/ai/stream', {
|
const res = await fetch('https://www.mikivl.online/api/note/ai/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
+151
-5
@@ -1,7 +1,7 @@
|
|||||||
const TOKEN_KEY = 'mikivl_token'
|
const TOKEN_KEY = 'mikivl_token'
|
||||||
const API = '/api'
|
const API = 'https://www.mikivl.online/api'
|
||||||
|
|
||||||
export type CurrentUser = { id: string; username: string; cloudEnabled: boolean }
|
export type CurrentUser = { id: string; username: string; cloudEnabled: boolean; role: 'user' | 'admin'; email: string | null; emailVerified: boolean; nickname: string | null; avatar: string | null }
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
@@ -20,11 +20,21 @@ export function authHeaders(): Record<string, string> {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRegister(username: string, password: string): Promise<{ token: string; user: CurrentUser }> {
|
export async function apiRegisterSendCode(username: string, password: string, email: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/auth/register/send-code`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password, email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '发送失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRegister(email: string, code: string): Promise<{ token: string; user: CurrentUser }> {
|
||||||
const res = await fetch(`${API}/auth/register`, {
|
const res = await fetch(`${API}/auth/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ email, code }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error ?? '注册失败')
|
if (!res.ok) throw new Error(data.error ?? '注册失败')
|
||||||
@@ -52,11 +62,147 @@ export async function apiActivate(code: string): Promise<void> {
|
|||||||
if (!res.ok) throw new Error(data.error ?? '激活失败')
|
if (!res.ok) throw new Error(data.error ?? '激活失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiUpdateProfile(nickname?: string, avatar?: string): Promise<CurrentUser> {
|
||||||
|
const res = await fetch(`${API}/auth/me`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ nickname, avatar }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '更新失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiChangePassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/auth/change-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ oldPassword, newPassword }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '修改失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetMe(): Promise<CurrentUser> {
|
||||||
|
const res = await fetch(`${API}/auth/me`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '获取用户信息失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiSendEmailVerify(email: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/auth/email/send-verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '发送失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiVerifyEmail(code: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/auth/email/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '验证失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiForgotPassword(email: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/auth/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '发送失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiResetPassword(email: string, code: string, newPassword: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, code, newPassword }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '重置失败')
|
||||||
|
}
|
||||||
|
|
||||||
export function parseToken(token: string): CurrentUser | null {
|
export function parseToken(token: string): CurrentUser | null {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||||
return { id: payload.userId, username: payload.username, cloudEnabled: false }
|
return { id: payload.userId, username: payload.username, cloudEnabled: false, role: 'user', email: null, emailVerified: false, nickname: null, avatar: null }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminUser = { id: string; username: string; role: string; banned: boolean; cloudEnabled: boolean; email: string | null; createdAt: number }
|
||||||
|
export type AdminComment = { id: string; content: string; createdAt: number; userId: string; username: string | null }
|
||||||
|
export type InviteCode = { code: string; usedByUserId: string | null; usedAt: number | null }
|
||||||
|
|
||||||
|
export async function apiAdminGetSiteContent(): Promise<Record<string, string>> {
|
||||||
|
const res = await fetch(`${API}/admin/site-content`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '获取失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminUpdateSiteContent(content: Record<string, string>): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/admin/site-content`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify(content),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '更新失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminGetUsers(): Promise<AdminUser[]> {
|
||||||
|
const res = await fetch(`${API}/admin/users`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '获取失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminBanUser(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/admin/users/${id}/ban`, { method: 'POST', headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '操作失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminUnbanUser(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/admin/users/${id}/unban`, { method: 'POST', headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '操作失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminGetComments(): Promise<AdminComment[]> {
|
||||||
|
const res = await fetch(`${API}/admin/comments`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '获取失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminDeleteComment(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/admin/comments/${id}`, { method: 'DELETE', headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '删除失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminGetInviteCodes(): Promise<InviteCode[]> {
|
||||||
|
const res = await fetch(`${API}/admin/invite-codes`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '获取失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminCreateInviteCode(): Promise<{ code: string }> {
|
||||||
|
const res = await fetch(`${API}/admin/invite-codes`, { method: 'POST', headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? '生成失败')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { Note, Folder } from '../db'
|
import type { Note, Folder } from '../db'
|
||||||
import { authHeaders } from './auth'
|
import { authHeaders } from './auth'
|
||||||
|
|
||||||
const API = '/api'
|
const API = 'https://www.mikivl.online/api/note'
|
||||||
|
|
||||||
export type SyncPayload = { notes: Note[]; folders: Folder[] }
|
export type SyncPayload = { notes: Note[]; folders: Folder[] }
|
||||||
|
|
||||||
|
|||||||
+32
-8
@@ -1,8 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { db, type Note, type Folder } from '../db'
|
import { db, type Note, type Folder } from '../db'
|
||||||
import { generateId, extractTextFromJSON } from '../lib/utils'
|
import { generateId, extractTextFromJSON } from '../lib/utils'
|
||||||
import { getToken, clearToken, parseToken, type CurrentUser } from '../lib/auth'
|
import { getToken, clearToken, parseToken, apiGetMe, type CurrentUser } from '../lib/auth'
|
||||||
import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder, mergeNotes, mergeFolders } from '../lib/sync'
|
import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder } from '../lib/sync'
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
notes: Note[]
|
notes: Note[]
|
||||||
@@ -49,6 +49,7 @@ interface AppState {
|
|||||||
syncStatus: 'idle' | 'syncing' | 'error'
|
syncStatus: 'idle' | 'syncing' | 'error'
|
||||||
setCurrentUser: (user: CurrentUser | null) => void
|
setCurrentUser: (user: CurrentUser | null) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
refreshCurrentUser: () => Promise<void>
|
||||||
syncFromCloud: () => Promise<void>
|
syncFromCloud: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,18 +265,41 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
set({ currentUser: null, syncStatus: 'idle' })
|
set({ currentUser: null, syncStatus: 'idle' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshCurrentUser: async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const user = await apiGetMe()
|
||||||
|
set({ currentUser: user })
|
||||||
|
if (user.cloudEnabled) get().syncFromCloud()
|
||||||
|
} catch {
|
||||||
|
// token 失效则清除
|
||||||
|
clearToken()
|
||||||
|
set({ currentUser: null })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
syncFromCloud: async () => {
|
syncFromCloud: async () => {
|
||||||
const { notes, folders, currentUser } = get()
|
const { notes, folders, currentUser } = get()
|
||||||
if (!currentUser?.cloudEnabled) return
|
if (!currentUser?.cloudEnabled) return
|
||||||
set({ syncStatus: 'syncing' })
|
set({ syncStatus: 'syncing' })
|
||||||
try {
|
try {
|
||||||
const remote = await pullAll()
|
const remote = await pullAll()
|
||||||
const mergedNotes = mergeNotes(notes, remote.notes)
|
// 远端有数据时以远端为准,避免本地种子数据污染云端
|
||||||
const mergedFolders = mergeFolders(folders, remote.folders)
|
// 只有远端完全为空(首次激活)才把本地数据上传
|
||||||
await Promise.all(mergedNotes.map((n: any) => db.notes.put(n)))
|
if (remote.notes.length > 0 || remote.folders.length > 0) {
|
||||||
await Promise.all(mergedFolders.map((f: any) => db.folders.put(f)))
|
await db.notes.clear()
|
||||||
await pushAll({ notes: mergedNotes, folders: mergedFolders })
|
await db.folders.clear()
|
||||||
set({ notes: mergedNotes, folders: mergedFolders, syncStatus: 'idle', _notesVersion: get()._notesVersion + 1, _filteredCache: null })
|
await Promise.all(remote.notes.map((n: any) => db.notes.put({ ...n, tags: Array.isArray(n.tags) ? n.tags : JSON.parse(n.tags as any) })))
|
||||||
|
await Promise.all(remote.folders.map((f: any) => db.folders.put(f)))
|
||||||
|
set({ notes: remote.notes.map((n: any) => ({ ...n, tags: Array.isArray(n.tags) ? n.tags : JSON.parse(n.tags as any) })), folders: remote.folders, syncStatus: 'idle', _notesVersion: get()._notesVersion + 1, _filteredCache: null })
|
||||||
|
} else {
|
||||||
|
// 远端为空:首次激活,把本地数据推上去
|
||||||
|
const localNotes = notes.filter(n => n.deletedAt === null)
|
||||||
|
const localFolders = folders
|
||||||
|
await pushAll({ notes: localNotes, folders: localFolders })
|
||||||
|
set({ syncStatus: 'idle' })
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
set({ syncStatus: 'error' })
|
set({ syncStatus: 'error' })
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -3,7 +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/',
|
base: '/',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -23,6 +23,15 @@ export default defineConfig({
|
|||||||
if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) {
|
if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) {
|
||||||
return 'vendor-ui'
|
return 'vendor-ui'
|
||||||
}
|
}
|
||||||
|
if (id.includes('node_modules/pdfjs-dist')) {
|
||||||
|
return 'vendor-pdf'
|
||||||
|
}
|
||||||
|
if (id.includes('node_modules/mammoth') || id.includes('node_modules/docx')) {
|
||||||
|
return 'vendor-docx'
|
||||||
|
}
|
||||||
|
if (id.includes('node_modules/zustand') || id.includes('node_modules/dexie')) {
|
||||||
|
return 'vendor-state'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user