# 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**
```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**
```python
"""数据库与应用配置"""
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**
```python
"""路由包"""
```
- [ ] **Step 4: 安装依赖**
```bash
pip install -r requirements.txt
```
---
### Task 2: 数据库模型
**Files:**
- Create: `models.py`
- [ ] **Step 1: 创建 models.py — 导入与引擎**
```python
"""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 模型**
```python
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 模型**
```python
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 和关联表**
```python
# 笔记-标签关联表
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 模型**
```python
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 模型**
```python
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**
```python
"""数据库初始化 — 建表 + 插入默认数据"""
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: 运行初始化**
```bash
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**
```python
"""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 — 顶部导航框架**
```html
MoKeeText - Web 记事本
{% block head %}{% endblock %}
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
```
- [ ] **Step 3: 创建 static/css/style.css — 飞书风格基础样式(骨架版本)**
```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 路由**
```python
"""笔记相关路由"""
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**
```html
{% extends "base.html" %}
{% block head %}
{% endblock %}
{% block content %}
{% endblock %}
{% block scripts %}
{% endblock %}
```
- [ ] **Step 3: 创建 static/js/editor.js — Tiptap 初始化**
```javascript
/**
* 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 ``;
}
return ``;
}).join("");
return `${items}`;
}).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 — 笔记页面交互**
```javascript
/**
* 笔记页面交互逻辑
*/
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 路由**
```python
"""网站管理相关路由"""
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**
```html
{% extends "base.html" %}
{% block content %}
📥 智能导入账号信息
💡 首行=网址+空格+网站名 | 第2行=账号 | 第3行=密码 | 第4行=备注(可选)
✅ 识别结果
📁 存放文件夹
或
{% endblock %}
{% block scripts %}
{% endblock %}
```
- [ ] **Step 3: 创建 static/js/websites.js**
```javascript
/**
* 网站管理页面交互逻辑
*/
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 = `
📁 ${getFolderName(site.folder_id)} /
🌐 ${site.name}
🔗 ${site.url}
`;
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 = '暂无账号,点击"+ 添加账号"
';
return;
}
cardsDiv.innerHTML = site.accounts.map(acc => `
账号
${acc.username}
密码
••••••••
说明
${acc.remark || '-'}
`).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 = `
🌐 网址${url}
🏷 网站名${siteName}
👤 账号${username}
🔑 密码${password}
📝 备注${remark || '(无)'}
`;
// 检查是否存在同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 = `⚠️ ${exist.name} 已存在(${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**
```python
"""智能导入 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` 更新为以下完整版本:
```python
"""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: 启动应用**
```bash
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 完成后刷新浏览器确认功能正常再继续。