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

2116 lines
67 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!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
/* ===== 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 %}
<!-- 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 初始化**
```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 `<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 — 笔记页面交互**
```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 %}
<!-- 左侧边栏 -->
<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**
```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 = `
<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**
```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 完成后刷新浏览器确认功能正常再继续。