Files
mokeeText/docs/superpowers/plans/2026-06-23-mokeetext-implementation.md
2026-07-15 16:08:17 +08:00

67 KiB
Raw Blame History

MoKeeText Web 记事本 — 实施计划

Goal: 构建一个 Python FastAPI + MySQL + Jinja2 + Tiptap 的 Web 记事本应用,包含笔记编辑和网站账号管理两大模块。

Architecture: FastAPI 服务端渲染 Jinja2 模板Tiptap 编辑器通过 CDN 加载,前端交互用原生 JS。SQLAlchemy ORM 操作 MySQL 数据库。所有页面走服务端路由API 端点处理数据操作。

Tech Stack: Python 3.10+, FastAPI, SQLAlchemy 2.0, MySQL 8.0, Jinja2, Tiptap (CDN), 原生 CSS/JS


文件结构总览

mokeeText/
├── app.py                    # FastAPI 入口,路由注册
├── config.py                 # 数据库配置
├── models.py                 # SQLAlchemy 数据模型
├── init_db.py                # 数据库初始化脚本
├── requirements.txt          # 依赖
├── routes/
│   ├── __init__.py
│   ├── notes.py              # 笔记页面 + API 路由
│   ├── websites.py           # 网站管理页面 + API 路由
│   └── import_api.py         # 智能导入解析 API
├── templates/
│   ├── base.html             # 基础布局(导航栏 + 框架)
│   ├── notes.html            # 笔记页面
│   └── websites.html         # 网站管理页面
├── static/
│   ├── css/
│   │   └── style.css         # 飞书风格样式
│   └── js/
│       ├── editor.js         # Tiptap 编辑器初始化与配置
│       ├── notes.js          # 笔记页面交互文件夹、标签、CRUD
│       └── websites.js       # 网站管理交互(文件夹、账号、智能导入)
└── docs/
    └── superpowers/
        ├── specs/2026-06-23-mokeetext-design.md
        └── plans/2026-06-23-mokeetext-implementation.md

Task 1: 项目骨架与依赖

Files:

  • Create: requirements.txt

  • Create: config.py

  • Create: routes/__init__.py

  • Step 1: 创建 requirements.txt

fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
pymysql==1.1.1
cryptography==43.0.0
jinja2==3.1.4
python-multipart==0.0.12
  • Step 2: 创建 config.py
"""数据库与应用配置"""
import os

# MySQL 数据库配置
DB_CONFIG = {
    "user": os.getenv("MYSQL_USER", "root"),
    "password": os.getenv("MYSQL_PASSWORD", "root"),
    "host": os.getenv("MYSQL_HOST", "127.0.0.1"),
    "port": int(os.getenv("MYSQL_PORT", "3306")),
    "database": os.getenv("MYSQL_DATABASE", "mokeetext"),
}

def get_db_url():
    """构建数据库连接 URL"""
    return (
        f"mysql+pymysql://{DB_CONFIG['user']}:{DB_CONFIG['password']}"
        f"@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}"
        f"?charset=utf8mb4"
    )
  • Step 3: 创建 routes/init.py
"""路由包"""
  • Step 4: 安装依赖
pip install -r requirements.txt

Task 2: 数据库模型

Files:

  • Create: models.py

  • Step 1: 创建 models.py — 导入与引擎

"""SQLAlchemy 数据模型"""
from datetime import datetime
from sqlalchemy import (
    create_engine, Column, Integer, String, Text, DateTime,
    Enum as SAEnum, ForeignKey, Table
)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from config import get_db_url

engine = create_engine(get_db_url(), echo=False, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False)
Base = declarative_base()


def get_db():
    """获取数据库会话FastAPI 依赖注入)"""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
  • Step 2: 添加 Folder 模型
class Folder(Base):
    """文件夹表 — 笔记和网站共用"""
    __tablename__ = "folders"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(100), nullable=False)
    type = Column(SAEnum("note", "website", name="folder_type"), nullable=False, default="note")
    parent_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
    sort_order = Column(Integer, default=0)
    created_at = Column(DateTime, default=datetime.utcnow)

    children = relationship("Folder", backref="parent", remote_side=[id])
    notes = relationship("Note", back_populates="folder", cascade="all, delete-orphan")
    websites = relationship("Website", back_populates="folder", cascade="all, delete-orphan")
  • Step 3: 添加 Note 模型
class Note(Base):
    """笔记表"""
    __tablename__ = "notes"

    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(500), nullable=False, default="无标题")
    content = Column(Text, nullable=True)  # Tiptap 输出的 HTML
    folder_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    folder = relationship("Folder", back_populates="notes")
    tags = relationship("Tag", secondary="note_tags", back_populates="notes")
  • Step 4: 添加 Tag 和关联表
# 笔记-标签关联表
note_tags = Table(
    "note_tags",
    Base.metadata,
    Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
    Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)


class Tag(Base):
    """标签表"""
    __tablename__ = "tags"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(50), nullable=False, unique=True)
    color = Column(String(20), default="#666")

    notes = relationship("Note", secondary="note_tags", back_populates="tags")
  • Step 5: 添加 Website 模型
class Website(Base):
    """网站表"""
    __tablename__ = "websites"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(200), nullable=False)
    url = Column(String(500), nullable=False)
    folder_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    folder = relationship("Folder", back_populates="websites")
    accounts = relationship("Account", back_populates="website", cascade="all, delete-orphan")
  • Step 6: 添加 Account 模型
class Account(Base):
    """账号表"""
    __tablename__ = "accounts"

    id = Column(Integer, primary_key=True, autoincrement=True)
    website_id = Column(Integer, ForeignKey("websites.id", ondelete="CASCADE"), nullable=False)
    username = Column(String(500), nullable=False)
    password = Column(String(500), nullable=False)
    remark = Column(Text, nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    website = relationship("Website", back_populates="accounts")

Task 3: 数据库初始化

Files:

  • Create: init_db.py

  • Step 1: 创建 init_db.py

"""数据库初始化 — 建表 + 插入默认数据"""
from models import engine, Base, SessionLocal, Folder

def init_database():
    """创建所有表,并插入默认文件夹"""
    # 建表
    Base.metadata.create_all(bind=engine)
    print("✅ 数据库表创建完成")

    # 插入默认数据
    db = SessionLocal()
    try:
        # 笔记默认文件夹
        if db.query(Folder).filter_by(type="note").count() == 0:
            for name in ["工作", "个人", "学习"]:
                db.add(Folder(name=name, type="note"))
            db.add(Folder(name="未分类", type="note"))

        # 网站默认文件夹
        if db.query(Folder).filter_by(type="website").count() == 0:
            for name in ["代码托管", "云服务", "开发工具"]:
                db.add(Folder(name=name, type="website"))
            db.add(Folder(name="未分类", type="website"))

        db.commit()
        print("✅ 默认数据插入完成")
    finally:
        db.close()

if __name__ == "__main__":
    init_database()
  • Step 2: 运行初始化
python init_db.py

Expected: ✅ 数据库表创建完成 ✅ 默认数据插入完成


Task 4: FastAPI 入口与基础模板

Files:

  • Create: app.py

  • Create: templates/base.html

  • Create: static/css/style.css

  • Step 1: 创建 app.py

"""MoKeeText — FastAPI 入口"""
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.responses import RedirectResponse
from routes.notes import router as notes_router
from routes.websites import router as websites_router
from routes.import_api import router as import_router

app = FastAPI(title="MoKeeText", version="1.0.0")

# 静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")

# 路由注册
app.include_router(notes_router)
app.include_router(websites_router)
app.include_router(import_router)


@app.get("/")
def root():
    return RedirectResponse(url="/notes")
  • Step 2: 创建 templates/base.html — 顶部导航框架
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MoKeeText - Web 记事本</title>
    <link rel="stylesheet" href="/static/css/style.css">
    {% block head %}{% endblock %}
</head>
<body>
    <!-- 顶部导航 -->
    <nav class="top-nav">
        <a href="/notes" class="nav-brand">📝 MoKeeText</a>
        <div class="nav-links">
            <a href="/notes" class="nav-link {% if active_page == 'notes' %}active{% endif %}">📝 笔记</a>
            <a href="/websites" class="nav-link {% if active_page == 'websites' %}active{% endif %}">🔐 网站管理</a>
        </div>
        <span class="nav-version">v1.0</span>
    </nav>

    <!-- 主内容区 -->
    <main class="main-content">
        {% block content %}{% endblock %}
    </main>

    {% block scripts %}{% endblock %}
</body>
</html>
  • Step 3: 创建 static/css/style.css — 飞书风格基础样式(骨架版本)
/* ===== CSS 变量 ===== */
:root {
    --bg: #f8f9fb;
    --bg-white: #fff;
    --text: #333;
    --text-secondary: #666;
    --text-muted: #999;
    --border: #e8e8e8;
    --primary: #1a73e8;
    --primary-light: #e8f0fe;
    --danger: #d93025;
    --success: #166534;
    --warning-bg: #fff3cd;
    --radius: 8px;
    --shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}

/* ===== 重置 ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
                 "Microsoft YaHei", sans-serif;
    font-size: 14px;
    color: var(--text);
    background: var(--bg);
    height: 100vh;
    overflow: hidden;
}

/* ===== 顶部导航 ===== */
.top-nav {
    display: flex;
    align-items: center;
    height: 48px;
    padding: 0 20px;
    background: var(--bg-white);
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
}

.nav-brand {
    font-weight: 700;
    font-size: 15px;
    color: var(--text);
    text-decoration: none;
    margin-right: 24px;
}

.nav-links { display: flex; gap: 0; }

.nav-link {
    padding: 14px 16px;
    font-size: 13px;
    color: var(--text-secondary);
    text-decoration: none;
    border-bottom: 2px solid transparent;
    transition: all 0.15s;
}

.nav-link:hover { color: var(--primary); }
.nav-link.active {
    color: var(--primary);
    font-weight: 600;
    border-bottom-color: var(--primary);
}

.nav-version {
    margin-left: auto;
    font-size: 11px;
    color: var(--text-muted);
}

/* ===== 主布局 ===== */
.main-content {
    display: flex;
    height: calc(100vh - 48px);
}

/* ===== 侧边栏 ===== */
.sidebar {
    width: 260px;
    min-width: 260px;
    background: var(--bg);
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    padding: 12px;
    overflow-y: auto;
}

.sidebar-section-title {
    font-size: 11px;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: 0.5px;
    padding: 4px 6px;
    margin-top: 8px;
}

/* ===== 搜索框 ===== */
.search-input {
    width: 100%;
    padding: 7px 10px;
    border: 1px solid #e0e0e0;
    border-radius: 6px;
    font-size: 12px;
    background: var(--bg-white);
    outline: none;
    transition: border-color 0.15s;
}

.search-input:focus { border-color: var(--primary); }

/* ===== 按钮 ===== */
.btn {
    padding: 8px 16px;
    border: none;
    border-radius: 6px;
    font-size: 13px;
    cursor: pointer;
    transition: all 0.15s;
    font-family: inherit;
}

.btn-primary {
    background: var(--primary);
    color: #fff;
    width: 100%;
    margin-bottom: 8px;
}

.btn-primary:hover { background: #1557b0; }

.btn-dashed {
    width: 100%;
    background: var(--bg-white);
    color: var(--primary);
    border: 1px dashed var(--primary);
    margin-bottom: 8px;
}

.btn-dashed:hover { background: var(--primary-light); }

.btn-sm {
    padding: 4px 12px;
    font-size: 12px;
    border: 1px solid #ddd;
    border-radius: 4px;
    background: var(--bg-white);
    cursor: pointer;
}

.btn-sm:hover { background: #f5f5f5; }

.btn-icon {
    padding: 3px 10px;
    border: none;
    background: transparent;
    font-size: 11px;
    cursor: pointer;
    color: var(--text-secondary);
    border-radius: 4px;
}

.btn-icon:hover { background: #f0f0f0; }

/* ===== 文件夹列表 ===== */
.folder-list { list-style: none; font-size: 13px; }

.folder-item {
    padding: 5px 8px;
    border-radius: 4px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 6px;
    margin: 1px 0;
    transition: background 0.1s;
}

.folder-item:hover { background: #f0f0f0; }
.folder-item.active {
    background: var(--primary-light);
    color: var(--primary);
    font-weight: 500;
}

.folder-item .count {
    margin-left: auto;
    font-size: 11px;
    color: var(--text-muted);
}

.folder-children { margin-left: 16px; }

/* ===== 标签 ===== */
.tags-wrap {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    padding: 2px 6px;
}

.tag {
    background: #e8eaed;
    padding: 2px 8px;
    border-radius: 10px;
    font-size: 11px;
    cursor: pointer;
    transition: background 0.15s;
}

.tag:hover { background: #d2d4d7; }
.tag.active { background: var(--primary); color: #fff; }

/* ===== 内容区 ===== */
.content-area {
    flex: 1;
    display: flex;
    flex-direction: column;
    background: var(--bg-white);
    min-width: 0;
}

.content-header {
    padding: 12px 20px;
    border-bottom: 1px solid #f0f0f0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-shrink: 0;
}

.content-header .title {
    font-size: 16px;
    font-weight: 600;
    color: var(--text);
}

/* ===== 视图切换按钮组 ===== */
.view-toggle {
    display: flex;
    gap: 0;
    background: #f1f3f4;
    border-radius: 6px;
    padding: 2px;
}

.view-toggle button {
    padding: 5px 12px;
    border: none;
    background: transparent;
    border-radius: 4px;
    font-size: 12px;
    cursor: pointer;
    color: var(--text-secondary);
    transition: all 0.15s;
}

.view-toggle button.active {
    background: #fff;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
    color: var(--text);
    font-weight: 600;
}

/* ===== 编辑器区域 ===== */
.editor-container {
    flex: 1;
    display: flex;
    min-height: 0;
}

.editor-pane {
    flex: 1;
    padding: 20px;
    overflow-y: auto;
    min-width: 0;
}

.editor-pane:first-child { border-right: 1px solid #f0f0f0; }

.editor-pane-label {
    font-size: 11px;
    color: #ccc;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    margin-bottom: 8px;
}

/* ===== 网站列表 ===== */
.site-list { list-style: none; font-size: 13px; }

.site-item {
    padding: 6px 10px;
    border-radius: 6px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 2px;
    transition: background 0.1s;
}

.site-item:hover { background: #f0f0f0; }
.site-item.active {
    background: var(--primary-light);
    color: var(--primary);
    font-weight: 600;
}

.site-item .site-icon { font-size: 16px; }
.site-item .site-info { flex: 1; min-width: 0; }
.site-item .site-name { font-size: 13px; }
.site-item .site-count { font-size: 10px; color: var(--text-muted); }

/* ===== 账号卡片 ===== */
.account-card {
    padding: 16px;
    background: #fafafa;
    border: 1px solid #f0f0f0;
    border-radius: 10px;
    margin-bottom: 12px;
}

.account-card-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 12px;
}

.account-card-header .account-label {
    font-weight: 600;
    font-size: 14px;
}

.account-detail-grid {
    display: grid;
    grid-template-columns: 60px 1fr auto;
    gap: 8px 12px;
    font-size: 13px;
    align-items: center;
}

.account-detail-grid .label { color: var(--text-secondary); }
.account-detail-grid .value { font-family: "JetBrains Mono", "Consolas", monospace; }

/* ===== 智能导入 ===== */
.import-container {
    max-width: 560px;
    margin: 0 auto;
    padding: 20px;
}

.import-steps {
    display: flex;
    gap: 12px;
    margin-bottom: 20px;
    justify-content: center;
}

.import-step {
    flex: 1;
    text-align: center;
}

.import-step-num {
    width: 28px;
    height: 28px;
    border-radius: 50%;
    font-weight: 700;
    line-height: 28px;
    margin: 0 auto 4px;
    font-size: 13px;
}

.import-step-num.active { background: var(--primary); color: #fff; }
.import-step-num.done { background: #86efac; color: var(--success); }

.import-textarea {
    width: 100%;
    height: 100px;
    padding: 10px 12px;
    border: 2px solid var(--primary);
    border-radius: 8px;
    font-size: 13px;
    font-family: inherit;
    resize: vertical;
    outline: none;
    background: #f8faff;
}

/* ===== 识别结果预览 ===== */
.parse-result {
    background: #f0fdf4;
    border: 1px solid #86efac;
    border-radius: 8px;
    padding: 14px 16px;
    margin-bottom: 16px;
}

.parse-result-grid {
    display: grid;
    grid-template-columns: 70px 1fr;
    gap: 6px 12px;
    font-size: 13px;
}

/* ===== 文件夹选择 ===== */
.folder-select-row {
    display: flex;
    gap: 8px;
    align-items: center;
    margin-bottom: 8px;
}

.folder-select-row select,
.folder-select-row input {
    flex: 1;
    padding: 8px 10px;
    border: 1px solid #ddd;
    border-radius: 6px;
    font-size: 13px;
    background: var(--bg-white);
}

/* ===== 警告提示 ===== */
.alert {
    padding: 8px 12px;
    border-radius: 6px;
    font-size: 12px;
}

.alert-warning {
    background: var(--warning-bg);
    color: #92600a;
}

/* ===== 操作按钮组 ===== */
.action-buttons {
    display: flex;
    gap: 8px;
    margin-top: 12px;
}

.btn-confirm {
    flex: 1;
    padding: 10px;
    background: var(--success);
    color: #fff;
    border: none;
    border-radius: 8px;
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
}

.btn-cancel {
    padding: 10px 20px;
    background: var(--bg-white);
    color: var(--text-secondary);
    border: 1px solid #ddd;
    border-radius: 8px;
    font-size: 14px;
    cursor: pointer;
}

/* ===== 弹窗遮罩 ===== */
.modal-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.3);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 100;
}

.modal-dialog {
    background: var(--bg-white);
    border-radius: 12px;
    box-shadow: 0 8px 30px rgba(0,0,0,0.15);
    width: 600px;
    max-height: 80vh;
    overflow-y: auto;
}

/* ===== 滚动条 ===== */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d0d0d0; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #b0b0b0; }

/* ===== 隐藏类 ===== */
.hidden { display: none !important; }

Task 5: 笔记页面路由与模板

Files:

  • Create: routes/notes.py

  • Create: templates/notes.html

  • Create: static/js/editor.js

  • Create: static/js/notes.js

  • Step 1: 创建 routes/notes.py — 页面和 API 路由

"""笔记相关路由"""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from models import get_db, Note, Folder, Tag

router = APIRouter(tags=["笔记"])


@router.get("/notes", response_class=HTMLResponse)
def notes_page(request: Request, db: Session = Depends(get_db)):
    """笔记主页"""
    folders = db.query(Folder).filter_by(type="note").order_by(Folder.sort_order).all()
    tags = db.query(Tag).all()
    notes = db.query(Note).order_by(Note.updated_at.desc()).all()
    return request.app.state.templates.TemplateResponse("notes.html", {
        "request": request,
        "active_page": "notes",
        "folders": folders,
        "tags": tags,
        "notes": notes,
    })


@router.get("/api/notes")
def list_notes(folder_id: int = None, db: Session = Depends(get_db)):
    """获取笔记列表"""
    q = db.query(Note)
    if folder_id:
        q = q.filter(Note.folder_id == folder_id)
    return q.order_by(Note.updated_at.desc()).all()


@router.post("/api/notes")
def create_note(data: dict, db: Session = Depends(get_db)):
    """创建笔记"""
    note = Note(
        title=data.get("title", "无标题"),
        content=data.get("content", ""),
        folder_id=data.get("folder_id"),
    )
    db.add(note)
    db.commit()
    db.refresh(note)
    return note


@router.get("/api/notes/{note_id}")
def get_note(note_id: int, db: Session = Depends(get_db)):
    """获取单个笔记"""
    note = db.query(Note).filter_by(id=note_id).first()
    if not note:
        raise HTTPException(404, "笔记不存在")
    return note


@router.put("/api/notes/{note_id}")
def update_note(note_id: int, data: dict, db: Session = Depends(get_db)):
    """更新笔记"""
    note = db.query(Note).filter_by(id=note_id).first()
    if not note:
        raise HTTPException(404, "笔记不存在")
    if "title" in data:
        note.title = data["title"]
    if "content" in data:
        note.content = data["content"]
    if "folder_id" in data:
        note.folder_id = data["folder_id"]
    db.commit()
    db.refresh(note)
    return note


@router.delete("/api/notes/{note_id}")
def delete_note(note_id: int, db: Session = Depends(get_db)):
    """删除笔记"""
    note = db.query(Note).filter_by(id=note_id).first()
    if not note:
        raise HTTPException(404, "笔记不存在")
    db.delete(note)
    db.commit()
    return {"ok": True}
  • Step 2: 创建 templates/notes.html
{% extends "base.html" %}
{% block head %}
<!-- Tiptap 编辑器 CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tiptap/core@2.9.1/dist/tiptap-core.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/starter-kit@2.9.1/dist/tiptap-starter-kit.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-color@2.9.1/dist/tiptap-extension-color.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-highlight@2.9.1/dist/tiptap-extension-highlight.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-text-style@2.9.1/dist/tiptap-extension-text-style.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-image@2.9.1/dist/tiptap-extension-image.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-table@2.9.1/dist/tiptap-extension-table.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-table-row@2.9.1/dist/tiptap-extension-table-row.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-table-cell@2.9.1/dist/tiptap-extension-table-cell.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-table-header@2.9.1/dist/tiptap-extension-table-header.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-task-list@2.9.1/dist/tiptap-extension-task-list.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-task-item@2.9.1/dist/tiptap-extension-task-item.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/extension-code-block-lowlight@2.9.1/dist/tiptap-extension-code-block-lowlight.umd.min.js"></script>
{% endblock %}

{% block content %}
<!-- 左侧边栏 -->
<aside class="sidebar">
    <input class="search-input" type="text" placeholder="🔍  搜索笔记..." id="noteSearch">

    <button class="btn btn-primary" onclick="createNote()">+ 新建笔记</button>

    <div class="sidebar-section-title">📁 文件夹</div>
    <ul class="folder-list" id="folderList">
        {% for folder in folders %}
        <li>
            <div class="folder-item {% if loop.first %}active{% endif %}"
                 data-folder-id="{{ folder.id }}"
                 onclick="filterByFolder({{ folder.id }}, this)">
                <span>📂</span> {{ folder.name }}
                <span class="count">{{ folder.notes|length }}</span>
            </div>
        </li>
        {% endfor %}
    </ul>

    <div class="sidebar-section-title">🏷️ 标签</div>
    <div class="tags-wrap" id="tagList">
        {% for tag in tags %}
        <span class="tag" data-tag-id="{{ tag.id }}" onclick="filterByTag({{ tag.id }}, this)">{{ tag.name }}</span>
        {% endfor %}
    </div>
</aside>

<!-- 右侧主内容区 -->
<section class="content-area">
    <div class="content-header">
        <input class="title-input" id="noteTitle" value="" placeholder="无标题笔记"
               style="border:none; font-size:18px; font-weight:600; outline:none; flex:1; font-family:inherit;">
        <div style="display:flex; gap:8px; align-items:center;">
            <div class="view-toggle">
                <button class="active" data-mode="edit" onclick="setViewMode('edit')">✏️ 编辑</button>
                <button data-mode="preview" onclick="setViewMode('preview')">👁️ 预览</button>
                <button data-mode="split" onclick="setViewMode('split')">📋 分屏</button>
            </div>
            <span id="saveStatus" style="font-size:10px; color:#999; background:#f0f0f0; padding:2px 8px; border-radius:10px;">✅ 已保存</span>
        </div>
    </div>

    <!-- 编辑器容器 -->
    <div class="editor-container" id="editorContainer">
        <!-- 编辑区 -->
        <div class="editor-pane" id="editPane">
            <div class="editor-pane-label">编辑</div>
            <div id="tiptapToolbar" class="tiptap-toolbar"></div>
            <div id="tiptapEditor" style="min-height:300px; outline:none; font-size:14px; line-height:1.8;"></div>
        </div>
        <!-- 预览区(默认隐藏) -->
        <div class="editor-pane hidden" id="previewPane">
            <div class="editor-pane-label">预览</div>
            <div id="previewContent" style="font-size:14px; line-height:1.8;"></div>
        </div>
    </div>
</section>

<!-- 隐藏字段当前笔记ID -->
<input type="hidden" id="currentNoteId" value="">
{% endblock %}

{% block scripts %}
<script src="/static/js/editor.js"></script>
<script src="/static/js/notes.js"></script>
{% endblock %}
  • Step 3: 创建 static/js/editor.js — Tiptap 初始化
/**
 * Tiptap 增强编辑器初始化
 * 支持:文字颜色、背景高亮、图片、表格、任务列表、代码块、表情
 */
let editor = null;

function initEditor(content = "") {
    if (editor) {
        editor.destroy();
    }

    editor = new tiptap.Editor({
        element: document.querySelector("#tiptapEditor"),
        extensions: [
            tiptap.StarterKit.configure({
                codeBlock: false, // 用 lowlight 替代
            }),
            tiptap.TextStyle,
            tiptap.Color,
            tiptap.Highlight.configure({ multicolor: true }),
            tiptap.Image.configure({ inline: true, allowBase64: true }),
            tiptap.Table.configure({ resizable: true }),
            tiptap.TableRow,
            tiptap.TableCell,
            tiptap.TableHeader,
            tiptap.TaskList,
            tiptap.TaskItem.configure({ nested: true }),
            tiptap.CodeBlockLowlight,
        ],
        content: content,
        onUpdate: () => {
            updatePreview();
            scheduleSave();
        },
    });

    // 初始化工具栏
    renderToolbar();
}

function renderToolbar() {
    const toolbar = document.querySelector("#tiptapToolbar");
    if (!toolbar) return;

    const buttons = [
        { group: "heading", items: [
            { cmd: "h1", label: "H1", action: () => editor.chain().focus().toggleHeading({ level: 1 }).run() },
            { cmd: "h2", label: "H2", action: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
            { cmd: "h3", label: "H3", action: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
        ]},
        { group: "inline", items: [
            { cmd: "bold", label: "B", action: () => editor.chain().focus().toggleBold().run(), style: "font-weight:bold" },
            { cmd: "italic", label: "I", action: () => editor.chain().focus().toggleItalic().run(), style: "font-style:italic" },
            { cmd: "strike", label: "S", action: () => editor.chain().focus().toggleStrike().run(), style: "text-decoration:line-through" },
        ]},
        { group: "color", items: [
            { cmd: "color", label: "🎨 颜色", type: "color", action: (val) => editor.chain().focus().setColor(val).run() },
            { cmd: "highlight", label: "🖌 背景", type: "color", action: (val) => editor.chain().focus().toggleHighlight({ color: val }).run() },
        ]},
        { group: "media", items: [
            { cmd: "image", label: "🖼 图片", action: () => {
                const url = prompt("输入图片 URL");
                if (url) editor.chain().focus().setImage({ src: url }).run();
            }},
            { cmd: "emoji", label: "😊", action: () => {
                const emoji = prompt("输入表情符号 (Win+. 打开表情面板)");
                if (emoji) editor.chain().focus().insertContent(emoji).run();
            }},
        ]},
        { group: "block", items: [
            { cmd: "bulletList", label: "• 列表", action: () => editor.chain().focus().toggleBulletList().run() },
            { cmd: "orderedList", label: "1. 列表", action: () => editor.chain().focus().toggleOrderedList().run() },
            { cmd: "taskList", label: "✅ 任务", action: () => editor.chain().focus().toggleTaskList().run() },
            { cmd: "blockquote", label: "❝ 引用", action: () => editor.chain().focus().toggleBlockquote().run() },
            { cmd: "codeBlock", label: "💻 代码", action: () => editor.chain().focus().toggleCodeBlock().run() },
        ]},
        { group: "table", items: [
            { cmd: "table", label: "📋 表格", action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
        ]},
    ];

    toolbar.innerHTML = buttons.map(group => {
        const items = group.items.map(item => {
            if (item.type === "color") {
                return `<input type="color" title="${item.label}" style="width:24px;height:24px;border:none;cursor:pointer;padding:0;"
                              onchange="event.target.dataset.action && (${item.action.toString().replace('val', `'${this.value}'`)});"
                              data-action="1">`;
            }
            return `<button class="tb-btn" data-cmd="${item.cmd}"
                          style="${item.style || ''}"
                          title="${item.label}">${item.label}</button>`;
        }).join("");
        return `<span class="tb-group">${items}</span>`;
    }).join("");

    // 绑定按钮事件(非 color 类型)
    toolbar.querySelectorAll("button.tb-btn").forEach(btn => {
        const item = buttons.flatMap(g => g.items).find(i => i.cmd === btn.dataset.cmd);
        if (item && item.action) {
            btn.addEventListener("click", item.action);
        }
    });

    // 绑定 color input 事件
    toolbar.querySelectorAll("input[type='color']").forEach((input, idx) => {
        const colorItems = buttons.flatMap(g => g.items).filter(i => i.type === "color");
        if (colorItems[idx]) {
            input.addEventListener("input", (e) => colorItems[idx].action(e.target.value));
        }
    });
}

function updatePreview() {
    const preview = document.querySelector("#previewContent");
    if (preview && editor) {
        preview.innerHTML = editor.getHTML();
    }
}
  • Step 4: 创建 static/js/notes.js — 笔记页面交互
/**
 * 笔记页面交互逻辑
 */

let currentNoteId = null;
let saveTimer = null;
let viewMode = "edit"; // edit | preview | split

// ===== 视图模式切换 =====
function setViewMode(mode) {
    viewMode = mode;
    const editPane = document.querySelector("#editPane");
    const previewPane = document.querySelector("#previewPane");

    // 更新按钮状态
    document.querySelectorAll(".view-toggle button").forEach(b => {
        b.classList.toggle("active", b.dataset.mode === mode);
    });

    // 更新编辑区和预览区
    editPane.classList.remove("hidden");
    previewPane.classList.remove("hidden");

    if (mode === "edit") {
        previewPane.classList.add("hidden");
        editPane.style.borderRight = "none";
    } else if (mode === "preview") {
        editPane.classList.add("hidden");
        updatePreview();
    } else {
        // split
        editPane.style.borderRight = "1px solid #f0f0f0";
        updatePreview();
    }
}

// ===== 创建新笔记 =====
async function createNote() {
    const resp = await fetch("/api/notes", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            title: "无标题笔记",
            content: "",
            folder_id: getActiveFolderId(),
        }),
    });
    const note = await resp.json();
    loadNote(note.id);
    refreshNoteList();
}

// ===== 加载笔记 =====
async function loadNote(noteId) {
    const resp = await fetch(`/api/notes/${noteId}`);
    if (!resp.ok) return;
    const note = await resp.json();
    currentNoteId = note.id;
    document.querySelector("#currentNoteId").value = note.id;
    document.querySelector("#noteTitle").value = note.title;
    initEditor(note.content || "");
}

// ===== 保存笔记 =====
function scheduleSave() {
    const status = document.querySelector("#saveStatus");
    status.textContent = "💾 保存中...";
    status.style.color = "#f4b400";
    clearTimeout(saveTimer);
    saveTimer = setTimeout(() => saveNote(), 800);
}

async function saveNote() {
    if (!currentNoteId) return;
    const title = document.querySelector("#noteTitle").value;
    const content = editor ? editor.getHTML() : "";
    const resp = await fetch(`/api/notes/${currentNoteId}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title, content }),
    });
    if (resp.ok) {
        const status = document.querySelector("#saveStatus");
        status.textContent = "✅ 已保存";
        status.style.color = "#999";
    }
}

// 标题修改时保存
document.addEventListener("DOMContentLoaded", () => {
    document.querySelector("#noteTitle").addEventListener("input", scheduleSave);
});

// ===== 文件夹筛选 =====
function filterByFolder(folderId, el) {
    document.querySelectorAll(".folder-item").forEach(f => f.classList.remove("active"));
    el.classList.add("active");
    fetch(`/api/notes?folder_id=${folderId}`)
        .then(r => r.json())
        .then(notes => renderNoteList(notes));
}

function getActiveFolderId() {
    const active = document.querySelector(".folder-item.active");
    return active ? parseInt(active.dataset.folderId) : null;
}

// ===== 删除笔记 =====
async function deleteCurrentNote() {
    if (!currentNoteId) return;
    if (!confirm("确定删除这条笔记?")) return;
    await fetch(`/api/notes/${currentNoteId}`, { method: "DELETE" });
    currentNoteId = null;
    window.location.reload();
}

// ===== 初始化 =====
document.addEventListener("DOMContentLoaded", () => {
    // 加载第一条笔记
    const firstNoteId = document.querySelector("#currentNoteId")?.value;
    if (!firstNoteId) {
        createNote();
    }
});

Task 6: 网站管理页面路由与模板

Files:

  • Create: routes/websites.py

  • Create: templates/websites.html

  • Create: static/js/websites.js

  • Step 1: 创建 routes/websites.py — 页面和 API 路由

"""网站管理相关路由"""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session, joinedload
from models import get_db, Website, Account, Folder

router = APIRouter(tags=["网站管理"])


@router.get("/websites", response_class=HTMLResponse)
def websites_page(request: Request, db: Session = Depends(get_db)):
    """网站管理主页"""
    folders = db.query(Folder).filter_by(type="website").order_by(Folder.sort_order).all()
    websites = db.query(Website).options(joinedload(Website.accounts)).order_by(Website.name).all()
    return request.app.state.templates.TemplateResponse("websites.html", {
        "request": request,
        "active_page": "websites",
        "folders": folders,
        "websites": websites,
    })


# ===== 网站 CRUD =====

@router.get("/api/websites")
def list_websites(folder_id: int = None, db: Session = Depends(get_db)):
    """获取网站列表"""
    q = db.query(Website).options(joinedload(Website.accounts))
    if folder_id:
        q = q.filter(Website.folder_id == folder_id)
    return q.order_by(Website.name).all()


@router.post("/api/websites")
def create_website(data: dict, db: Session = Depends(get_db)):
    """创建网站"""
    website = Website(
        name=data.get("name", ""),
        url=data.get("url", ""),
        folder_id=data.get("folder_id"),
    )
    db.add(website)
    db.commit()
    db.refresh(website)
    return website


@router.get("/api/websites/{website_id}")
def get_website(website_id: int, db: Session = Depends(get_db)):
    """获取网站详情(含账号列表)"""
    website = db.query(Website).options(joinedload(Website.accounts)).filter_by(id=website_id).first()
    if not website:
        raise HTTPException(404, "网站不存在")
    return website


@router.put("/api/websites/{website_id}")
def update_website(website_id: int, data: dict, db: Session = Depends(get_db)):
    """更新网站信息"""
    website = db.query(Website).filter_by(id=website_id).first()
    if not website:
        raise HTTPException(404, "网站不存在")
    for field in ["name", "url", "folder_id"]:
        if field in data:
            setattr(website, field, data[field])
    db.commit()
    db.refresh(website)
    return website


@router.delete("/api/websites/{website_id}")
def delete_website(website_id: int, db: Session = Depends(get_db)):
    """删除网站"""
    website = db.query(Website).filter_by(id=website_id).first()
    if not website:
        raise HTTPException(404, "网站不存在")
    db.delete(website)
    db.commit()
    return {"ok": True}


# ===== 账号 CRUD =====

@router.post("/api/websites/{website_id}/accounts")
def add_account(website_id: int, data: dict, db: Session = Depends(get_db)):
    """添加账号"""
    account = Account(
        website_id=website_id,
        username=data.get("username", ""),
        password=data.get("password", ""),
        remark=data.get("remark", ""),
    )
    db.add(account)
    db.commit()
    db.refresh(account)
    return account


@router.put("/api/accounts/{account_id}")
def update_account(account_id: int, data: dict, db: Session = Depends(get_db)):
    """更新账号"""
    account = db.query(Account).filter_by(id=account_id).first()
    if not account:
        raise HTTPException(404, "账号不存在")
    for field in ["username", "password", "remark"]:
        if field in data:
            setattr(account, field, data[field])
    db.commit()
    db.refresh(account)
    return account


@router.delete("/api/accounts/{account_id}")
def delete_account(account_id: int, db: Session = Depends(get_db)):
    """删除账号"""
    account = db.query(Account).filter_by(id=account_id).first()
    if not account:
        raise HTTPException(404, "账号不存在")
    db.delete(account)
    db.commit()
    return {"ok": True}


# ===== 文件夹 API =====

@router.get("/api/folders")
def list_folders(type: str = "website", db: Session = Depends(get_db)):
    """获取文件夹列表"""
    return db.query(Folder).filter_by(type=type).order_by(Folder.sort_order).all()


@router.post("/api/folders")
def create_folder(data: dict, db: Session = Depends(get_db)):
    """创建文件夹"""
    folder = Folder(
        name=data.get("name", "新文件夹"),
        type=data.get("type", "website"),
    )
    db.add(folder)
    db.commit()
    db.refresh(folder)
    return folder
  • Step 2: 创建 templates/websites.html
{% extends "base.html" %}
{% block content %}
<!-- 左侧边栏 -->
<aside class="sidebar">
    <!-- 统计 -->
    <div style="display:flex; gap:6px; margin-bottom:10px;">
        <div style="flex:1; text-align:center; padding:8px 4px; background:#fff; border-radius:6px; border:1px solid #e0e0e0; font-size:11px;">
            <div style="font-size:18px; font-weight:700; color:#1a73e8;" id="siteCount">{{ websites|length }}</div>
            <div style="color:#888;">网站</div>
        </div>
        <div style="flex:1; text-align:center; padding:8px 4px; background:#fff; border-radius:6px; border:1px solid #e0e0e0; font-size:11px;">
            <div style="font-size:18px; font-weight:700; color:#34a853;" id="accountCount">-</div>
            <div style="color:#888;">账号</div>
        </div>
    </div>

    <button class="btn btn-primary" onclick="showAddWebsite()">+ 添加网站</button>
    <button class="btn btn-dashed" onclick="showImportModal()">📥 智能导入</button>
    <button class="btn btn-sm" style="width:100%; margin-bottom:8px; background:transparent; color:#888; border:none; text-align:left; padding-left:8px;" onclick="showAddFolder()">📁 + 新建文件夹</button>

    <input class="search-input" type="text" placeholder="🔍  搜索网站..." id="siteSearch" oninput="filterSites()">

    <!-- 文件夹 + 网站列表 -->
    <div id="folderSiteTree" style="flex:1; overflow-y:auto; font-size:13px;">
        {% for folder in folders %}
        <div class="folder-group" data-folder-id="{{ folder.id }}">
            <div class="folder-item" onclick="toggleFolder(this)" style="font-weight:600; font-size:12px;">
                <span class="folder-arrow"></span> 📁 {{ folder.name }}
                <span class="count">-</span>
            </div>
            <div class="folder-children">
                {% for site in websites if site.folder_id == folder.id %}
                <div class="site-item" data-site-id="{{ site.id }}" data-folder-id="{{ folder.id }}"
                     onclick="selectWebsite({{ site.id }}, this)">
                    <span class="site-icon">🌐</span>
                    <div class="site-info">
                        <div class="site-name">{{ site.name }}</div>
                        <div class="site-count">{{ site.accounts|length }} 个账号</div>
                    </div>
                </div>
                {% endfor %}
            </div>
        </div>
        {% endfor %}

        <!-- 未分类 -->
        <div style="padding:4px 6px; color:#aaa; font-size:11px; letter-spacing:0.5px; margin-top:4px;">未分类</div>
        {% for site in websites if not site.folder_id %}
        <div class="site-item" data-site-id="{{ site.id }}" data-folder-id=""
             onclick="selectWebsite({{ site.id }}, this)">
            <span class="site-icon">🌐</span>
            <div class="site-info">
                <div class="site-name">{{ site.name }}</div>
                <div class="site-count">{{ site.accounts|length }} 个账号</div>
            </div>
        </div>
        {% endfor %}
    </div>
</aside>

<!-- 右侧内容区 -->
<section class="content-area">
    <div class="content-header">
        <div id="siteHeaderInfo" style="color:#999; font-size:14px;">
            选择一个网站查看账号详情
        </div>
        <div style="display:flex; gap:6px;">
            <button class="btn btn-sm hidden" id="editSiteBtn" onclick="editWebsite()">✏️ 编辑网站</button>
            <button class="btn btn-sm hidden" id="addAccountBtn" onclick="showAddAccount()">+ 添加账号</button>
        </div>
    </div>

    <!-- 账号卡片列表 -->
    <div class="editor-pane" id="accountCards" style="flex:1; padding:16px 20px; overflow-y:auto;">
        <div style="text-align:center; padding:60px 20px; color:#ccc;">
            <div style="font-size:48px; margin-bottom:12px;">🔐</div>
            <p>选择左侧网站查看账号密码</p>
        </div>
    </div>
</section>

<!-- 智能导入弹窗 -->
<div class="modal-overlay hidden" id="importModal">
    <div class="modal-dialog">
        <div style="padding:24px;">
            <h3 style="margin-bottom:16px;">📥 智能导入账号信息</h3>

            <label style="font-size:12px; font-weight:600; color:#555; display:block; margin-bottom:4px;">
                粘贴文本(格式:首行 网址+空格+网站名,之后 账号/密码/备注 各一行)
            </label>
            <textarea class="import-textarea" id="importText" placeholder="https://github.com 代码网站&#10;zhangsan@qq.com&#10;Abc123&#10;个人开发账号"></textarea>
            <div style="font-size:11px; color:#999; margin:4px 0 12px;">
                💡 首行=网址+空格+网站名 | 第2行=账号 | 第3行=密码 | 第4行=备注(可选)
            </div>

            <button class="btn btn-primary" style="margin-bottom:16px;" onclick="parseImport()">🔍 自动识别</button>

            <!-- 识别结果 -->
            <div class="parse-result hidden" id="parseResult">
                <div style="font-size:12px; font-weight:600; color:#166534; margin-bottom:10px;">✅ 识别结果</div>
                <div class="parse-result-grid" id="parseGrid"></div>

                <!-- 文件夹选择 -->
                <div style="margin-top:12px; background:#fff; border:1px solid #e0e0e0; border-radius:8px; padding:12px;">
                    <div style="font-size:12px; font-weight:600; margin-bottom:8px;">📁 存放文件夹</div>
                    <div class="folder-select-row">
                        <select id="importFolderSelect">
                            <option value="">选择已有文件夹...</option>
                            {% for folder in folders %}
                            <option value="{{ folder.id }}">{{ folder.name }}</option>
                            {% endfor %}
                            <option value="">未分类</option>
                        </select>
                        <span style="font-size:12px; color:#999;"></span>
                        <input type="text" id="importNewFolder" placeholder="新建文件夹名...">
                    </div>
                    <div class="alert alert-warning hidden" id="duplicateAlert"></div>
                </div>

                <div class="action-buttons">
                    <button class="btn-confirm" onclick="confirmImport()">✅ 确认添加</button>
                    <button class="btn-cancel" onclick="hideImportModal()">取消</button>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- 添加/编辑网站弹窗 -->
<div class="modal-overlay hidden" id="websiteModal">
    <div class="modal-dialog" style="width:480px;">
        <div style="padding:24px;">
            <h3 id="websiteModalTitle" style="margin-bottom:16px;">添加网站</h3>
            <div style="display:flex; flex-direction:column; gap:12px;">
                <div>
                    <label style="font-size:12px; font-weight:600; display:block; margin-bottom:4px;">网站名</label>
                    <input class="search-input" id="websiteName" placeholder="例如GitHub" style="width:100%;">
                </div>
                <div>
                    <label style="font-size:12px; font-weight:600; display:block; margin-bottom:4px;">网址</label>
                    <input class="search-input" id="websiteUrl" placeholder="https://github.com" style="width:100%;">
                </div>
                <div>
                    <label style="font-size:12px; font-weight:600; display:block; margin-bottom:4px;">文件夹</label>
                    <select style="width:100%; padding:7px 10px; border:1px solid #e0e0e0; border-radius:6px; font-size:12px;" id="websiteFolder">
                        <option value="">未分类</option>
                        {% for folder in folders %}
                        <option value="{{ folder.id }}">{{ folder.name }}</option>
                        {% endfor %}
                    </select>
                </div>
                <div class="action-buttons">
                    <button class="btn-confirm" id="websiteSaveBtn" onclick="saveWebsite()">保存</button>
                    <button class="btn-cancel" onclick="hideWebsiteModal()">取消</button>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- 添加账号弹窗 -->
<div class="modal-overlay hidden" id="accountModal">
    <div class="modal-dialog" style="width:480px;">
        <div style="padding:24px;">
            <h3 style="margin-bottom:16px;">添加账号</h3>
            <div style="display:flex; flex-direction:column; gap:12px;">
                <div>
                    <label style="font-size:12px; font-weight:600; display:block; margin-bottom:4px;">账号</label>
                    <input class="search-input" id="accountUsername" placeholder="user@example.com" style="width:100%;">
                </div>
                <div>
                    <label style="font-size:12px; font-weight:600; display:block; margin-bottom:4px;">密码</label>
                    <input class="search-input" id="accountPassword" placeholder="密码" style="width:100%;">
                </div>
                <div>
                    <label style="font-size:12px; font-weight:600; display:block; margin-bottom:4px;">备注</label>
                    <textarea style="width:100%; padding:7px 10px; border:1px solid #e0e0e0; border-radius:6px; font-size:12px; resize:vertical; outline:none;" id="accountRemark" rows="2" placeholder="登录说明..."></textarea>
                </div>
                <div class="action-buttons">
                    <button class="btn-confirm" onclick="saveAccount()">保存</button>
                    <button class="btn-cancel" onclick="hideAccountModal()">取消</button>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- 隐藏字段 -->
<input type="hidden" id="currentSiteId" value="">
<input type="hidden" id="editAccountId" value="">
<input type="hidden" id="editWebsiteId" value="">
{% endblock %}

{% block scripts %}
<script src="/static/js/websites.js"></script>
{% endblock %}
  • Step 3: 创建 static/js/websites.js
/**
 * 网站管理页面交互逻辑
 */

let currentSiteId = null;
let parsedImportData = null;

// ===== 网站选择 =====
async function selectWebsite(siteId, el) {
    currentSiteId = siteId;
    document.querySelector("#currentSiteId").value = siteId;
    document.querySelectorAll(".site-item").forEach(s => s.classList.remove("active"));
    if (el) el.classList.add("active");

    const resp = await fetch(`/api/websites/${siteId}`);
    const site = await resp.json();
    renderSiteDetail(site);
}

function renderSiteDetail(site) {
    document.querySelector("#siteHeaderInfo").innerHTML = `
        <span style="font-size:11px;color:#888;">📁 ${getFolderName(site.folder_id)} / </span>
        <span style="font-size:16px;font-weight:600;">🌐 ${site.name}</span>
        <a href="${site.url}" target="_blank" style="font-size:11px;color:#1a73e8;margin-left:8px;">🔗 ${site.url}</a>
    `;
    document.querySelector("#editSiteBtn").classList.remove("hidden");
    document.querySelector("#addAccountBtn").classList.remove("hidden");

    const cardsDiv = document.querySelector("#accountCards");
    if (!site.accounts || site.accounts.length === 0) {
        cardsDiv.innerHTML = '<div style="text-align:center;padding:60px;color:#ccc;">暂无账号,点击"+ 添加账号"</div>';
        return;
    }

    cardsDiv.innerHTML = site.accounts.map(acc => `
        <div class="account-card">
            <div class="account-card-header">
                <span class="account-label">👤 ${acc.remark || '账号'}</span>
                <div style="display:flex; gap:4px;">
                    <button class="btn-icon" onclick="editAccount(${acc.id})">✏️</button>
                    <button class="btn-icon" style="color:#d93025;" onclick="deleteAccount(${acc.id})">🗑</button>
                </div>
            </div>
            <div class="account-detail-grid">
                <span class="label">账号</span>
                <span class="value">${acc.username}</span>
                <button class="btn btn-sm" onclick="copyText('${acc.username}')">📋</button>
                <span class="label">密码</span>
                <span class="value pw-hidden" id="pw-${acc.id}" data-password="${acc.password}">••••••••</span>
                <div style="display:flex; gap:4px;">
                    <button class="btn btn-sm" onclick="togglePassword(${acc.id})">👁</button>
                    <button class="btn btn-sm" onclick="copyText('${acc.password}')">📋</button>
                </div>
                <span class="label">说明</span>
                <span style="color:#666; font-size:12px;">${acc.remark || '-'}</span>
                <span></span>
            </div>
        </div>
    `).join("");
}

function togglePassword(accId) {
    const el = document.querySelector(`#pw-${accId}`);
    if (el.textContent === "••••••••") {
        el.textContent = el.dataset.password;
    } else {
        el.textContent = "••••••••";
    }
}

function copyText(text) {
    navigator.clipboard.writeText(text).then(() => {
        // 短暂提示
        const toast = document.createElement("div");
        toast.textContent = "已复制!";
        toast.style.cssText = "position:fixed;top:20px;right:20px;background:#333;color:#fff;padding:8px 16px;border-radius:6px;font-size:13px;z-index:999;";
        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), 1500);
    });
}

function getFolderName(folderId) {
    if (!folderId) return "未分类";
    const el = document.querySelector(`.folder-group[data-folder-id="${folderId}"] .folder-item`);
    return el ? el.textContent.trim().replace(/^[▼▶]\s*/, "").replace(/📁\s*/, "").replace(/\s*\d+$/, "") : "未分类";
}

// ===== 文件夹折叠 =====
function toggleFolder(el) {
    const arrow = el.querySelector(".folder-arrow");
    const children = el.parentElement.querySelector(".folder-children");
    if (children.style.display === "none") {
        children.style.display = "block";
        arrow.textContent = "▼";
    } else {
        children.style.display = "none";
        arrow.textContent = "▶";
    }
}

// ===== 智能导入 =====
function showImportModal() {
    document.querySelector("#importModal").classList.remove("hidden");
    document.querySelector("#parseResult").classList.add("hidden");
    document.querySelector("#importText").value = "";
    document.querySelector("#importNewFolder").value = "";
}

function hideImportModal() {
    document.querySelector("#importModal").classList.add("hidden");
    parsedImportData = null;
}

function parseImport() {
    const text = document.querySelector("#importText").value.trim();
    if (!text) return alert("请先粘贴文本");

    const lines = text.split("\n").map(l => l.trim()).filter(Boolean);
    if (lines.length < 3) return alert("至少需要3行网址+网站名、账号、密码");

    // 解析首行:网址 + 空格 + 网站名
    const firstLine = lines[0];
    const spaceIndex = firstLine.indexOf(" ");
    let url, siteName;
    if (spaceIndex > 0) {
        url = firstLine.substring(0, spaceIndex).trim();
        siteName = firstLine.substring(spaceIndex + 1).trim();
    } else {
        url = firstLine;
        // 从 URL 自动提取网站名
        try {
            siteName = new URL(url).hostname.replace("www.", "");
        } catch {
            siteName = url;
        }
    }

    const username = lines[1] || "";
    const password = lines[2] || "";
    const remark = lines[3] || "";

    parsedImportData = { url, siteName, username, password, remark };

    // 显示识别结果
    document.querySelector("#parseResult").classList.remove("hidden");
    document.querySelector("#parseGrid").innerHTML = `
        <span style="color:#666;">🌐 网址</span><span>${url}</span>
        <span style="color:#666;">🏷 网站名</span><span>${siteName}</span>
        <span style="color:#666;">👤 账号</span><span style="font-family:monospace;">${username}</span>
        <span style="color:#666;">🔑 密码</span><span style="font-family:monospace;">${password}</span>
        <span style="color:#666;">📝 备注</span><span>${remark || '(无)'}</span>
    `;

    // 检查是否存在同URL网站
    checkDuplicateSite(url);
}

async function checkDuplicateSite(url) {
    const resp = await fetch("/api/websites");
    const sites = await resp.json();
    const exist = sites.find(s => s.url === url);
    const alert = document.querySelector("#duplicateAlert");
    if (exist) {
        alert.classList.remove("hidden");
        alert.innerHTML = `⚠️ <strong>${exist.name}</strong> 已存在(${exist.accounts?.length || 0}个账号),将追加到该网站下`;
        // 预选该网站所在文件夹
        if (exist.folder_id) {
            document.querySelector("#importFolderSelect").value = exist.folder_id;
        }
    } else {
        alert.classList.add("hidden");
    }
}

async function confirmImport() {
    if (!parsedImportData) return;

    const { url, siteName, username, password, remark } = parsedImportData;

    // 确定文件夹
    let folderId = document.querySelector("#importFolderSelect").value || null;
    const newFolderName = document.querySelector("#importNewFolder").value.trim();
    if (newFolderName) {
        const resp = await fetch("/api/folders", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ name: newFolderName, type: "website" }),
        });
        const folder = await resp.json();
        folderId = folder.id;
    }

    // 检查网站是否存在
    const resp = await fetch("/api/websites");
    const sites = await resp.json();
    let site = sites.find(s => s.url === url);

    if (!site) {
        // 新建网站
        const createResp = await fetch("/api/websites", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ name: siteName, url, folder_id: folderId }),
        });
        site = await createResp.json();
    }

    // 添加账号
    await fetch(`/api/websites/${site.id}/accounts`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username, password, remark }),
    });

    hideImportModal();
    window.location.reload();
}

// ===== 弹窗控制 =====
function showAddWebsite() {
    document.querySelector("#websiteModalTitle").textContent = "添加网站";
    document.querySelector("#websiteName").value = "";
    document.querySelector("#websiteUrl").value = "";
    document.querySelector("#websiteFolder").value = "";
    document.querySelector("#editWebsiteId").value = "";
    document.querySelector("#websiteModal").classList.remove("hidden");
}

function hideWebsiteModal() {
    document.querySelector("#websiteModal").classList.add("hidden");
}

function editWebsite() {
    if (!currentSiteId) return;
    document.querySelector("#websiteModalTitle").textContent = "编辑网站";
    document.querySelector("#editWebsiteId").value = currentSiteId;
    document.querySelector("#websiteModal").classList.remove("hidden");
}

async function saveWebsite() {
    const id = document.querySelector("#editWebsiteId").value;
    const data = {
        name: document.querySelector("#websiteName").value,
        url: document.querySelector("#websiteUrl").value,
        folder_id: document.querySelector("#websiteFolder").value || null,
    };
    if (id) {
        await fetch(`/api/websites/${id}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
        });
    } else {
        await fetch("/api/websites", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
        });
    }
    hideWebsiteModal();
    window.location.reload();
}

function showAddAccount() {
    if (!currentSiteId) return alert("请先选择一个网站");
    document.querySelector("#accountUsername").value = "";
    document.querySelector("#accountPassword").value = "";
    document.querySelector("#accountRemark").value = "";
    document.querySelector("#editAccountId").value = "";
    document.querySelector("#accountModal").classList.remove("hidden");
}

function hideAccountModal() {
    document.querySelector("#accountModal").classList.add("hidden");
}

async function saveAccount() {
    const siteId = document.querySelector("#currentSiteId").value;
    const accId = document.querySelector("#editAccountId").value;
    const data = {
        username: document.querySelector("#accountUsername").value,
        password: document.querySelector("#accountPassword").value,
        remark: document.querySelector("#accountRemark").value,
    };
    if (accId) {
        await fetch(`/api/accounts/${accId}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
        });
    } else {
        await fetch(`/api/websites/${siteId}/accounts`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
        });
    }
    hideAccountModal();
    selectWebsite(siteId, document.querySelector(`.site-item[data-site-id="${siteId}"]`));
}

async function editAccount(accId) {
    document.querySelector("#editAccountId").value = accId;
    // 通过当前已渲染的 DOM 获取数据
    document.querySelector("#accountModal").classList.remove("hidden");
}

async function deleteAccount(accId) {
    if (!confirm("确定删除这个账号?")) return;
    await fetch(`/api/accounts/${accId}`, { method: "DELETE" });
    selectWebsite(currentSiteId, document.querySelector(`.site-item[data-site-id="${currentSiteId}"]`));
}

function showAddFolder() {
    const name = prompt("输入新文件夹名称:");
    if (name) {
        fetch("/api/folders", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ name, type: "website" }),
        }).then(() => window.location.reload());
    }
}

function filterSites() {
    const query = document.querySelector("#siteSearch").value.toLowerCase();
    document.querySelectorAll(".site-item").forEach(item => {
        const name = item.querySelector(".site-name").textContent.toLowerCase();
        item.style.display = name.includes(query) ? "flex" : "none";
    });
}

Task 7: 智能导入 API

Files:

  • Create: routes/import_api.py

  • Step 1: 创建 routes/import_api.py

"""智能导入 API — 文本解析与匹配"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session, joinedload
from models import get_db, Website
from urllib.parse import urlparse

router = APIRouter(prefix="/api/import", tags=["智能导入"])


@router.post("/parse")
def parse_import_text(data: dict, db: Session = Depends(get_db)):
    """解析粘贴的文本,返回结构化结果 + 匹配信息"""
    text = data.get("text", "").strip()
    if not text:
        return {"ok": False, "error": "文本为空"}

    lines = [l.strip() for l in text.split("\n") if l.strip()]
    if len(lines) < 3:
        return {"ok": False, "error": "至少需要 3 行:网址+网站名、账号、密码"}

    # 解析首行
    first_line = lines[0]
    space_idx = first_line.find(" ")
    if space_idx > 0:
        url = first_line[:space_idx].strip()
        site_name = first_line[space_idx + 1:].strip()
    else:
        url = first_line
        try:
            site_name = urlparse(url).hostname or url
        except Exception:
            site_name = url

    result = {
        "ok": True,
        "url": url,
        "site_name": site_name,
        "username": lines[1] if len(lines) > 1 else "",
        "password": lines[2] if len(lines) > 2 else "",
        "remark": lines[3] if len(lines) > 3 else "",
    }

    # 匹配已有网站
    existing = db.query(Website).options(joinedload(Website.accounts)).filter_by(url=url).first()
    if existing:
        result["matched_site"] = {
            "id": existing.id,
            "name": existing.name,
            "folder_id": existing.folder_id,
            "account_count": len(existing.accounts),
        }
        result["action"] = "append"  # 追加到已有网站
    else:
        result["matched_site"] = None
        result["action"] = "create"  # 新建网站

    return result

Task 8: 启动配置与收尾

Files:

  • Modify: app.py

  • Step 1: 更新 app.py — 添加模板引擎和启动配置

将之前创建的 app.py 更新为以下完整版本:

"""MoKeeText — FastAPI 入口"""
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.responses import RedirectResponse
from starlette.templating import Jinja2Templates
from routes.notes import router as notes_router
from routes.websites import router as websites_router
from routes.import_api import router as import_router

app = FastAPI(title="MoKeeText", version="1.0.0")

# 模板引擎
app.state.templates = Jinja2Templates(directory="templates")

# 静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")

# 路由注册
app.include_router(notes_router)
app.include_router(websites_router)
app.include_router(import_router)


@app.get("/")
def root():
    return RedirectResponse(url="/notes")
  • Step 2: 启动应用
uvicorn app:app --reload --host 0.0.0.0 --port 8000

Expected: 应用启动在 http://localhost:8000

  • Step 3: 验证
    • 打开 http://localhost:8000/notes — 笔记页面
    • 打开 http://localhost:8000/websites — 网站管理页面
    • 新建笔记、编辑保存
    • 添加网站、添加账号
    • 测试智能导入功能

实施顺序

Task 1 (项目骨架) → Task 2 (数据模型) → Task 3 (初始化数据库)
    → Task 4 (入口 + 基础模板 + CSS)
    → Task 5 (笔记模块) → Task 6 (网站管理模块) → Task 7 (智能导入)
    → Task 8 (收尾 + 启动验证)

每个 Task 完成后刷新浏览器确认功能正常再继续。