2116 lines
67 KiB
Markdown
2116 lines
67 KiB
Markdown
# 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 代码网站 zhangsan@qq.com Abc123 个人开发账号"></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 完成后刷新浏览器确认功能正常再继续。
|