初始化

This commit is contained in:
zg
2026-07-15 16:08:17 +08:00
parent f0d70aded4
commit 4e0057ece5
89 changed files with 13512 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
<h2>整体布局方案</h2>
<p class="subtitle">左侧双面板(笔记 + 网站管理) + 右侧内容区,类似飞书文档的简洁风格</p>
<div class="mockup">
<div class="mockup-header">MoKeeText — Web 记事本整体布局</div>
<div class="mockup-body">
<div style="display:flex; height:500px; border:1px solid #e0e0e0; border-radius:8px; overflow:hidden; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<!-- 左侧边栏 -->
<div style="width:260px; background:#f7f8fa; border-right:1px solid #e8e8e8; display:flex; flex-direction:column;">
<!-- 顶部切换标签 -->
<div style="display:flex; border-bottom:1px solid #e8e8e8;">
<div style="flex:1; text-align:center; padding:10px 0; font-size:13px; font-weight:600; color:#1a73e8; background:#fff; border-bottom:2px solid #1a73e8; cursor:pointer;">📝 笔记</div>
<div style="flex:1; text-align:center; padding:10px 0; font-size:13px; font-weight:500; color:#666; cursor:pointer;">🔐 网站</div>
</div>
<!-- 笔记面板 -->
<div style="flex:1; display:flex; flex-direction:column; padding:8px;">
<!-- 搜索框 -->
<div style="padding:4px 0 8px;">
<input placeholder="🔍 搜索笔记..." style="width:100%; padding:6px 10px; border:1px solid #ddd; border-radius:6px; font-size:12px; background:#fff; box-sizing:border-box;">
</div>
<!-- 新建按钮 -->
<button style="width:100%; padding:8px; background:#1a73e8; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:8px;">+ 新建笔记</button>
<!-- 文件夹树 -->
<div style="flex:1; overflow-y:auto; font-size:13px;">
<div style="padding:4px 8px; color:#999; font-size:11px; text-transform:uppercase; letter-spacing:0.5px;">文件夹</div>
<div style="padding:6px 8px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px;">
<span style="color:#f4b400;">📁</span> <span style="font-weight:500;">全部笔记</span> <span style="color:#999; font-size:11px; margin-left:auto;">12</span>
</div>
<div style="padding:6px 8px; margin-left:12px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px; background:#e8f0fe; color:#1a73e8;">
<span>📂</span> <span>工作</span> <span style="font-size:11px; margin-left:auto;">5</span>
</div>
<div style="padding:6px 8px; margin-left:12px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px;">
<span>📂</span> <span>个人</span> <span style="font-size:11px; margin-left:auto;">4</span>
</div>
<div style="padding:6px 8px; margin-left:12px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px;">
<span>📂</span> <span>学习笔记</span> <span style="font-size:11px; margin-left:auto;">3</span>
</div>
<div style="padding:8px 8px 4px; margin-top:12px; color:#999; font-size:11px; text-transform:uppercase; letter-spacing:0.5px;">标签</div>
<div style="display:flex; flex-wrap:wrap; gap:4px; padding:4px 8px;">
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px;">python</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px;">mysql</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px;">前端</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px;">部署</span>
</div>
</div>
</div>
</div>
<!-- 右侧主内容区 -->
<div style="flex:1; display:flex; flex-direction:column; background:#fff;">
<!-- 顶部标题栏 -->
<div style="padding:12px 20px; border-bottom:1px solid #f0f0f0; display:flex; align-items:center; justify-content:space-between;">
<div style="display:flex; align-items:center; gap:10px;">
<span style="font-size:18px; font-weight:600; color:#333;">📄 MySQL 索引优化笔记</span>
<span style="font-size:11px; color:#999; background:#f0f0f0; padding:2px 8px; border-radius:10px;">已自动保存</span>
</div>
<div style="display:flex; gap:8px;">
<span style="font-size:12px; color:#999;">🏷️ python</span>
<span style="font-size:12px; color:#999;">🏷️ mysql</span>
<button style="padding:4px 12px; border:1px solid #ddd; border-radius:4px; background:#fff; font-size:12px; cursor:pointer;">+ 标签</button>
</div>
</div>
<!-- Markdown 编辑器区域 -->
<div style="flex:1; display:flex;">
<!-- 编辑区 -->
<div style="flex:1; padding:20px; border-right:1px solid #f0f0f0; overflow-y:auto;">
<div style="color:#ccc; font-size:12px; margin-bottom:4px;">Markdown 编辑</div>
<div style="font-family:'JetBrains Mono','Consolas',monospace; font-size:14px; line-height:1.8; color:#333;">
<span style="color:#1a73e8;"># MySQL 索引优化</span><br><br>
<span style="color:#1a73e8;">## </span>B+Tree 索引原理<br><br>
<span style="color:#1a73e8;">### </span>聚簇索引 vs 二级索引<br><br>
- 聚簇索引的<span style="background:#fff3cd; padding:1px 4px;">叶子节点存储整行数据</span><br>
- 二级索引叶子节点存储<span style="background:#fff3cd; padding:1px 4px;">主键值</span><br>
- <span style="color:#d93025;">回表查询</span>通过二级索引找到主键<br>
&nbsp;&nbsp;再到聚簇索引查找完整行<br><br>
<span style="color:#1a73e8;">## </span>最左前缀原则<br><br>
<span style="color:#666;">联合索引 (a, b, c) 等价于:</span><br>
- 索引 a<br>
- 索引 (a, b)<br>
- 索引 (a, b, c)<br><br>
<span style="color:#999;">// 无法使用索引的情况:</span><br>
<span style="color:#d93025;">WHERE b = 1</span> &nbsp;&nbsp;<span style="color:#999;">// 跳过了 a</span><br>
<span style="color:#d93025;">WHERE c = 1</span> &nbsp;&nbsp;<span style="color:#999;">// 跳过了 a, b</span>
</div>
</div>
<!-- 预览区 -->
<div style="flex:1; padding:20px; background:#fcfcfc; overflow-y:auto;">
<div style="color:#ccc; font-size:12px; margin-bottom:4px;">预览</div>
<div style="font-family:-apple-system,sans-serif; font-size:14px; line-height:1.8; color:#333;">
<h1 style="font-size:24px; margin:0 0 16px; border-bottom:2px solid #eee; padding-bottom:8px;">MySQL 索引优化</h1>
<h2 style="font-size:18px; margin:16px 0 8px;">B+Tree 索引原理</h2>
<h3 style="font-size:15px; margin:12px 0 6px;">聚簇索引 vs 二级索引</h3>
<ul style="padding-left:20px;">
<li>聚簇索引的 <mark style="background:#fff3cd;">叶子节点存储整行数据</mark></li>
<li>二级索引叶子节点存储 <mark style="background:#fff3cd;">主键值</mark></li>
<li><span style="color:#d93025;">回表查询</span>通过二级索引找到主键再到聚簇索引查找完整行</li>
</ul>
<h2 style="font-size:18px; margin:16px 0 8px;">最左前缀原则</h2>
<p style="color:#666;">联合索引 (a, b, c) 等价于:</p>
<ul style="padding-left:20px;">
<li>索引 a</li>
<li>索引 (a, b)</li>
<li>索引 (a, b, c)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="margin-top:16px; padding:12px 16px; background:#f0f7ff; border-radius:8px; border-left:3px solid #1a73e8;">
<strong>布局说明:</strong>
<ul style="margin:8px 0 0; padding-left:20px; font-size:13px; color:#555;">
<li><strong>左侧</strong>:笔记/网站双面板切换,含搜索、文件夹树、标签列表</li>
<li><strong>右侧</strong>Markdown 编辑器,左半屏编辑 + 右半屏实时预览</li>
<li>风格参考飞书 — 大量留白、圆角卡片、柔和阴影、蓝色强调</li>
</ul>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1782197830107}

View File

@@ -0,0 +1 @@
1236

View File

@@ -0,0 +1,113 @@
<h2>编辑器选型 — 你要的是哪种?</h2>
<p class="subtitle">Markdown 原生有限,增强型编辑器才能满足图片/颜色/表情需求</p>
<div class="cards">
<!-- 方案A -->
<div class="card" data-choice="enhanced" onclick="toggleSelect(this)">
<div class="card-body">
<h3>🌟 增强型编辑器(推荐)— Tiptap / Milkdown</h3>
<p style="color:#666; font-size:13px;">所见即所得,像飞书/Notion 一样。支持所有你想要的。</p>
<div style="background:#fff; border:1px solid #e0e0e0; border-radius:8px; margin-top:12px; overflow:hidden;">
<!-- 工具栏模拟 -->
<div style="display:flex; flex-wrap:wrap; gap:2px; padding:8px 12px; background:#fafafa; border-bottom:1px solid #eee; align-items:center;">
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">H1</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">H2</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">H3</span>
<span style="width:1px; height:16px; background:#ddd; margin:0 4px;"></span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; font-weight:bold; cursor:pointer;">B</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; font-style:italic; cursor:pointer;">I</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; text-decoration:line-through; cursor:pointer;">S</span>
<span style="width:1px; height:16px; background:#ddd; margin:0 4px;"></span>
<span style="padding:3px 6px; background:#fff3cd; border-radius:3px; font-size:11px; cursor:pointer; border:1px solid #f4b400;">🎨 文字颜色</span>
<span style="padding:3px 6px; background:#e8f0fe; border-radius:3px; font-size:11px; cursor:pointer; border:1px solid #1a73e8;">🏷 背景色</span>
<span style="width:1px; height:16px; background:#ddd; margin:0 4px;"></span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">🖼 图片</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">😊 表情</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">📎 附件</span>
<span style="width:1px; height:16px; background:#ddd; margin:0 4px;"></span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">📋 表格</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">💻 代码块</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">📋 列表</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px; cursor:pointer;">✅ 任务</span>
</div>
<!-- 编辑区模拟 -->
<div style="padding:16px; font-size:13px; line-height:1.9;">
<h1 style="font-size:22px; margin:0 0 8px;">MySQL 索引优化</h1>
<p>聚簇索引的 <span style="background:#fff3cd; padding:2px 6px; border-radius:2px;">叶子节点存储整行数据</span></p>
<p style="color:#d93025; font-weight:600;">⚠ 回表查询会导致性能问题</p>
<p>示例代码:</p>
<code style="background:#f5f5f5; padding:2px 6px; border-radius:3px; font-family:monospace; font-size:12px;">SELECT * FROM users WHERE name = 'Tom'</code>
<p style="margin-top:8px;">😊😂🔥✨💯 🖼️ <span style="color:#1a73e8; text-decoration:underline;">[架构图.png]</span></p>
</div>
</div>
</div>
</div>
<!-- 方案B -->
<div class="card" data-choice="pure-md" onclick="toggleSelect(this)">
<div class="card-body">
<h3>📝 纯 Markdown — EasyMDE / SimpleMDE</h3>
<p style="color:#666; font-size:13px;">经典 Markdown 编辑器,简洁但功能受限。</p>
<div style="background:#fff; border:1px solid #e0e0e0; border-radius:8px; margin-top:12px; overflow:hidden;">
<div style="display:flex; gap:2px; padding:8px 12px; background:#fafafa; border-bottom:1px solid #eee;">
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px;">B</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px;">I</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px;">H</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px;">🖼</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px;">🔗</span>
<span style="padding:3px 6px; background:#eee; border-radius:3px; font-size:11px;">📋</span>
</div>
<div style="padding:16px; font-family:monospace; font-size:12px; color:#999; line-height:1.8;">
<span style="color:#aaa;">// 纯文本,没有颜色</span><br>
<span style="color:#aaa;">// 图片要手动填URL</span><br>
<span style="color:#aaa;">// 表情只能用Unicode</span><br><br>
# MySQL 索引优化<br><br>
**回表查询**会导致性能问题<br><br>
![架构图](https://xxx.com/img.png)<br><br>
😊😂🔥
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:16px;">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<tr style="background:#f8f9fb;">
<th style="padding:10px; text-align:left; border-bottom:1px solid #eee;">功能</th>
<th style="padding:10px; text-align:center; border-bottom:1px solid #eee;">🌟 增强型</th>
<th style="padding:10px; text-align:center; border-bottom:1px solid #eee;">📝 纯 Markdown</th>
</tr>
<tr>
<td style="padding:8px 10px; border-bottom:1px solid #f0f0f0;">文字颜色 / 背景色</td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;"></td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;"></td>
</tr>
<tr style="background:#fafafa;">
<td style="padding:8px 10px; border-bottom:1px solid #f0f0f0;">插入图片(上传/粘贴)</td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;">✅ 直接粘贴/拖拽</td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;">⚠ 手动写URL</td>
</tr>
<tr>
<td style="padding:8px 10px; border-bottom:1px solid #f0f0f0;">表情面板</td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;">✅ 选择器</td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;">⚠ 快捷键</td>
</tr>
<tr style="background:#fafafa;">
<td style="padding:8px 10px; border-bottom:1px solid #f0f0f0;">表格编辑</td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;">✅ 可视化</td>
<td style="padding:8px 10px; text-align:center; border-bottom:1px solid #f0f0f0;">⚠ 手写语法</td>
</tr>
<tr>
<td style="padding:8px 10px;">代码高亮</td>
<td style="padding:8px 10px; text-align:center;"></td>
<td style="padding:8px 10px; text-align:center;"></td>
</tr>
</table>
</div>
<div style="margin-top:16px; padding:14px 18px; background:#f0f7ff; border-radius:8px; border-left:3px solid #1a73e8;">
<strong>💡 建议选增强型:</strong>Tiptap 是现代 Web 编辑器的事实标准,飞书/Notion 级别的体验。支持 Markdown 快捷输入(打 <code>#</code> 自动变标题),同时拥有完整的格式化工具栏。完全满足你"设置文字颜色、插入图片表情"的需求。
</div>

View File

@@ -0,0 +1,169 @@
<h2>布局 v2 — 笔记和网站管理分开</h2>
<p class="subtitle">顶部导航切换「笔记」/「网站管理」,各自有独立的侧边栏和内容区</p>
<!-- ============ 笔记视图 ============ -->
<div class="mockup">
<div class="mockup-header">📝 笔记视图 — 纯编辑模式(预览已关闭)</div>
<div class="mockup-body">
<div style="border:1px solid #e0e0e0; border-radius:8px; overflow:hidden; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<!-- 顶部导航 -->
<div style="display:flex; align-items:center; padding:0 20px; background:#fff; border-bottom:1px solid #eee; height:48px;">
<span style="font-weight:700; font-size:15px; color:#333; margin-right:24px;">📝 MoKeeText</span>
<a style="padding:14px 16px; font-size:13px; font-weight:600; color:#1a73e8; border-bottom:2px solid #1a73e8; text-decoration:none;">📝 笔记</a>
<a style="padding:14px 16px; font-size:13px; color:#666; text-decoration:none;">🔐 网站管理</a>
<span style="flex:1;"></span>
<span style="font-size:11px; color:#aaa;">个人版</span>
</div>
<div style="display:flex; height:460px;">
<!-- 左侧笔记专用边栏 -->
<div style="width:240px; min-width:240px; background:#f8f9fb; border-right:1px solid #eee; display:flex; flex-direction:column; padding:12px;">
<input placeholder="🔍 搜索笔记..." style="width:100%; padding:7px 10px; border:1px solid #e0e0e0; border-radius:6px; font-size:12px; box-sizing:border-box; margin-bottom:8px;">
<button style="width:100%; padding:8px; background:#1a73e8; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:10px;">+ 新建笔记</button>
<div style="flex:1; overflow-y:auto; font-size:13px;">
<div style="padding:4px 6px; color:#aaa; font-size:11px; letter-spacing:0.5px; margin-bottom:2px;">📁 文件夹</div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer;">📁 全部笔记 <span style="color:#bbb; font-size:11px; float:right;">12</span></div>
<div style="padding:5px 8px; margin-left:12px; border-radius:4px; cursor:pointer; background:#e8f0fe; color:#1a73e8; font-weight:500;">📂 工作 <span style="font-size:11px; float:right;">5</span></div>
<div style="padding:5px 8px; margin-left:12px; border-radius:4px; cursor:pointer;">📂 个人 <span style="font-size:11px; float:right; color:#bbb;">4</span></div>
<div style="padding:5px 8px; margin-left:12px; border-radius:4px; cursor:pointer;">📂 学习 <span style="font-size:11px; float:right; color:#bbb;">3</span></div>
<div style="padding:8px 6px 4px; margin-top:12px; color:#aaa; font-size:11px; letter-spacing:0.5px;">🏷️ 标签</div>
<div style="display:flex; flex-wrap:wrap; gap:4px; padding:2px 6px;">
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px;">python</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px;">mysql</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px;">前端</span>
</div>
</div>
</div>
<!-- 右侧编辑区(纯编辑模式,无预览) -->
<div style="flex:1; display:flex; flex-direction:column; background:#fff; min-width:0;">
<div style="padding:8px 20px; border-bottom:1px solid #f0f0f0; display:flex; align-items:center; justify-content:space-between;">
<span style="font-size:16px; font-weight:600; color:#333;">MySQL 索引优化笔记</span>
<div style="display:flex; gap:4px; background:#f1f3f4; border-radius:6px; padding:2px;">
<button style="padding:5px 12px; border:none; background:#fff; border-radius:4px; font-size:12px; font-weight:600; box-shadow:0 1px 2px rgba(0,0,0,0.1); cursor:pointer;">✏️ 编辑</button>
<button style="padding:5px 12px; border:none; background:transparent; border-radius:4px; font-size:12px; color:#666; cursor:pointer;">👁️ 预览</button>
<button style="padding:5px 12px; border:none; background:transparent; border-radius:4px; font-size:12px; color:#666; cursor:pointer;">📋 分屏</button>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<span style="font-size:10px; color:#999; background:#f0f0f0; padding:2px 8px; border-radius:10px;">🏷️ mysql</span>
<span style="font-size:10px; color:#999; background:#f0f0f0; padding:2px 8px; border-radius:10px;">🏷️ python</span>
</div>
</div>
<div style="flex:1; padding:20px; overflow-y:auto; font-family:'JetBrains Mono','Consolas',monospace; font-size:13px; line-height:1.9; color:#333;">
<span style="color:#1a73e8;"># MySQL 索引优化</span><br><br>
<span style="color:#1a73e8;">## B+Tree 索引原理</span><br><br>
聚簇索引的<span style="background:#fff3cd; padding:1px 3px;">叶子节点存储整行数据</span><br>
二级索引叶子节点存储<span style="background:#fff3cd; padding:1px 3px;">主键值</span><br>
<span style="color:#d93025;">回表查询</span>通过二级索引找到主键再到聚簇索引查找完整行<br><br>
<span style="color:#1a73e8;">## 最左前缀原则</span><br><br>
联合索引 (a, b, c) 等价于:<br>
- 索引 a<br>
- 索引 (a, b)<br>
- 索引 (a, b, c)
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============ 网站管理视图 ============ -->
<div class="mockup" style="margin-top:20px;">
<div class="mockup-header">🔐 网站管理视图 — 账号密码列表</div>
<div class="mockup-body">
<div style="border:1px solid #e0e0e0; border-radius:8px; overflow:hidden; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<!-- 顶部导航 -->
<div style="display:flex; align-items:center; padding:0 20px; background:#fff; border-bottom:1px solid #eee; height:48px;">
<span style="font-weight:700; font-size:15px; color:#333; margin-right:24px;">📝 MoKeeText</span>
<a style="padding:14px 16px; font-size:13px; color:#666; text-decoration:none;">📝 笔记</a>
<a style="padding:14px 16px; font-size:13px; font-weight:600; color:#1a73e8; border-bottom:2px solid #1a73e8; text-decoration:none;">🔐 网站管理</a>
</div>
<div style="display:flex; height:420px;">
<!-- 左侧网站列表 -->
<div style="width:240px; min-width:240px; background:#f8f9fb; border-right:1px solid #eee; display:flex; flex-direction:column; padding:12px;">
<button style="width:100%; padding:8px; background:#1a73e8; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:4px;">+ 添加网站</button>
<button style="width:100%; padding:8px; background:#fff; color:#1a73e8; border:1px dashed #1a73e8; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:8px;">📥 智能导入</button>
<input placeholder="🔍 搜索网站..." style="width:100%; padding:7px 10px; border:1px solid #e0e0e0; border-radius:6px; font-size:12px; box-sizing:border-box; margin-bottom:8px;">
<div style="flex:1; overflow-y:auto; font-size:13px;">
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; background:#e8f0fe; color:#1a73e8; font-weight:500; margin:1px 0;">🌐 github.com <span style="font-size:10px; color:#999; float:right;">2个账号</span></div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; margin:1px 0;">🌐 gitee.com <span style="font-size:10px; color:#bbb; float:right;">1个账号</span></div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; margin:1px 0;">🌐 aliyun.com <span style="font-size:10px; color:#bbb; float:right;">1个账号</span></div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; margin:1px 0;">🌐 tencentcloud <span style="font-size:10px; color:#bbb; float:right;">2个账号</span></div>
</div>
</div>
<!-- 右侧账号详情 -->
<div style="flex:1; display:flex; flex-direction:column; background:#fff; min-width:0;">
<div style="padding:12px 20px; border-bottom:1px solid #f0f0f0;">
<span style="font-size:15px; font-weight:600;">🌐 github.com</span>
<span style="font-size:11px; color:#999; margin-left:8px;">https://github.com</span>
</div>
<!-- 账号卡片列表 -->
<div style="flex:1; overflow-y:auto; padding:16px 20px;">
<div style="display:flex; flex-direction:column; gap:12px;">
<!-- 账号卡片 1 -->
<div style="padding:14px 16px; background:#fafafa; border:1px solid #eee; border-radius:8px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-weight:600; font-size:14px;">👤 主账号</span>
<span style="font-size:11px; color:#999;">备注: 个人开发用</span>
</div>
<div style="display:grid; grid-template-columns:80px 1fr; gap:6px 12px; font-size:13px;">
<span style="color:#888;">账号</span><span style="font-family:monospace;">zhangsan@gmail.com</span>
<span style="color:#888;">密码</span><span style="font-family:monospace;">•••••••• <span style="color:#1a73e8; cursor:pointer; font-size:11px;">👁 显示</span> <span style="color:#888; cursor:pointer; font-size:11px;">📋 复制</span></span>
<span style="color:#888;">说明</span><span style="color:#666;">GitHub 个人账号,用于开源项目</span>
</div>
</div>
<!-- 账号卡片 2 -->
<div style="padding:14px 16px; background:#fafafa; border:1px solid #eee; border-radius:8px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-weight:600; font-size:14px;">👤 工作账号</span>
<span style="font-size:11px; color:#999;">备注: 公司项目</span>
</div>
<div style="display:grid; grid-template-columns:80px 1fr; gap:6px 12px; font-size:13px;">
<span style="color:#888;">账号</span><span style="font-family:monospace;">zhangsan@company.com</span>
<span style="color:#888;">密码</span><span style="font-family:monospace;">•••••••• <span style="color:#1a73e8; cursor:pointer; font-size:11px;">👁 显示</span> <span style="color:#888; cursor:pointer; font-size:11px;">📋 复制</span></span>
<span style="color:#888;">说明</span><span style="color:#666;">公司 GitHub 账号</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:16px;">
<h3>📐 结构调整说明</h3>
<div style="display:flex; gap:16px;">
<div style="flex:1; padding:12px; background:#f0f7ff; border-radius:8px;">
<strong>📝 笔记</strong>
<ul style="margin:6px 0 0; padding-left:18px; font-size:13px; color:#555;">
<li>顶部导航栏切换</li>
<li>左侧:文件夹树 + 标签</li>
<li>右侧:编辑器,可切换 编辑/预览/分屏 三种模式</li>
</ul>
</div>
<div style="flex:1; padding:12px; background:#f0f7ff; border-radius:8px;">
<strong>🔐 网站管理</strong>
<ul style="margin:6px 0 0; padding-left:18px; font-size:13px; color:#555;">
<li>左侧:网站列表 + 添加/导入按钮</li>
<li>右侧:选中网站的账号密码卡片</li>
<li>密码默认隐藏,点击显示/复制</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,131 @@
<h2>整体布局方案 — MoKeeText 记事本</h2>
<p class="subtitle">左侧双面板切换(笔记 + 网站管理) + 右侧 Markdown 编辑/预览分屏</p>
<div class="mockup">
<div class="mockup-header">📝 笔记编辑模式 — 整体布局预览</div>
<div class="mockup-body">
<div style="display:flex; height:520px; border:1px solid #e0e0e0; border-radius:8px; overflow:hidden; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<!-- 左侧边栏 -->
<div style="width:250px; min-width:250px; background:#f7f8fa; border-right:1px solid #e8e8e8; display:flex; flex-direction:column;">
<!-- 顶部切换标签 -->
<div style="display:flex; border-bottom:1px solid #e8e8e8;">
<div style="flex:1; text-align:center; padding:10px 0; font-size:13px; font-weight:600; color:#1a73e8; background:#fff; border-bottom:2px solid #1a73e8; cursor:pointer;">📝 笔记</div>
<div style="flex:1; text-align:center; padding:10px 0; font-size:13px; font-weight:500; color:#888; cursor:pointer;">🔐 网站</div>
</div>
<!-- 笔记面板内容 -->
<div style="flex:1; display:flex; flex-direction:column; padding:10px; overflow:hidden;">
<input placeholder="🔍 搜索笔记..." style="width:100%; padding:7px 10px; border:1px solid #ddd; border-radius:6px; font-size:12px; background:#fff; box-sizing:border-box; margin-bottom:8px;">
<button style="width:100%; padding:8px; background:#1a73e8; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:10px;">+ 新建笔记</button>
<div style="flex:1; overflow-y:auto; font-size:13px;">
<div style="padding:4px 8px; color:#aaa; font-size:11px; text-transform:uppercase; letter-spacing:0.5px;">📁 文件夹</div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px; margin:1px 0;"><span>📁</span> 全部笔记 <span style="color:#bbb; font-size:11px; margin-left:auto;">12</span></div>
<div style="padding:5px 8px; margin-left:12px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px; background:#e8f0fe; color:#1a73e8; font-weight:500;"><span>📂</span> 工作 <span style="font-size:11px; margin-left:auto;">5</span></div>
<div style="padding:5px 8px; margin-left:12px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px;"><span>📂</span> 个人 <span style="font-size:11px; margin-left:auto; color:#bbb;">4</span></div>
<div style="padding:5px 8px; margin-left:12px; border-radius:4px; cursor:pointer; display:flex; align-items:center; gap:6px;"><span>📂</span> 学习 <span style="font-size:11px; margin-left:auto; color:#bbb;">3</span></div>
<div style="padding:8px 8px 4px; margin-top:12px; color:#aaa; font-size:11px; text-transform:uppercase; letter-spacing:0.5px;">🏷️ 标签</div>
<div style="display:flex; flex-wrap:wrap; gap:4px; padding:2px 8px;">
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px; cursor:pointer;">python</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px; cursor:pointer;">mysql</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px; cursor:pointer;">前端</span>
<span style="background:#e8eaed; padding:2px 8px; border-radius:10px; font-size:11px; cursor:pointer;">部署</span>
</div>
</div>
</div>
</div>
<!-- 右侧主内容区 -->
<div style="flex:1; display:flex; flex-direction:column; background:#fff; min-width:0;">
<!-- 标题栏 -->
<div style="padding:10px 20px; border-bottom:1px solid #f0f0f0; display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
<div style="display:flex; align-items:center; gap:10px;">
<span style="font-size:16px; font-weight:600; color:#333;">MySQL 索引优化笔记</span>
<span style="font-size:10px; color:#999; background:#f0f0f0; padding:2px 8px; border-radius:10px;">✅ 已保存</span>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<span style="font-size:11px; color:#888; background:#f5f5f5; padding:2px 8px; border-radius:10px;">🏷️ python</span>
<span style="font-size:11px; color:#888; background:#f5f5f5; padding:2px 8px; border-radius:10px;">🏷️ mysql</span>
<span style="font-size:11px; color:#1a73e8; cursor:pointer;">+ 标签</span>
</div>
</div>
<!-- 编辑+预览 分屏 -->
<div style="flex:1; display:flex; min-height:0;">
<!-- 编辑区 -->
<div style="flex:1; padding:16px 20px; border-right:1px solid #f0f0f0; overflow-y:auto;">
<div style="color:#ccc; font-size:11px; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.5px;">✏️ 编辑</div>
<div style="font-family:'JetBrains Mono','Consolas',monospace; font-size:13px; line-height:1.9; color:#333;">
<span style="color:#1a73e8;"># MySQL 索引优化</span><br><br>
<span style="color:#1a73e8;">## B+Tree 索引原理</span><br><br>
聚簇索引的<span style="background:#fff3cd; padding:1px 4px;">叶子节点存储整行数据</span><br>
二级索引叶子节点存储<span style="background:#fff3cd; padding:1px 4px;">主键值</span><br><br>
<span style="color:#d93025;">回表查询</span>通过二级索引找到主键<br>
再到聚簇索引查找完整行<br><br>
<span style="color:#1a73e8;">## 最左前缀原则</span><br><br>
<span style="color:#666;">联合索引 (a, b, c) 等价于:</span><br>
- 索引 a<br>
- 索引 (a, b)<br>
- 索引 (a, b, c)<br><br>
<span style="color:#999;">// 无法使用索引的情况:</span><br>
<span style="color:#d93025;">WHERE b = 1</span> — 跳过了 a<br>
<span style="color:#d93025;">WHERE c = 1</span> — 跳过了 a, b
</div>
</div>
<!-- 预览区 -->
<div style="flex:1; padding:16px 20px; background:#fcfcfc; overflow-y:auto;">
<div style="color:#ccc; font-size:11px; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.5px;">👁️ 预览</div>
<div style="font-family:-apple-system,sans-serif; font-size:14px; line-height:1.8; color:#333;">
<h1 style="font-size:24px; margin:0 0 16px; border-bottom:2px solid #eee; padding-bottom:8px;">MySQL 索引优化</h1>
<h2 style="font-size:18px; margin:16px 0 8px;">B+Tree 索引原理</h2>
<p>聚簇索引的 <mark style="background:#fff3cd; padding:1px 4px;">叶子节点存储整行数据</mark></p>
<p>二级索引叶子节点存储 <mark style="background:#fff3cd; padding:1px 4px;">主键值</mark></p>
<p><span style="color:#d93025;">回表查询</span>通过二级索引找到主键再到聚簇索引查找完整行</p>
<h2 style="font-size:18px; margin:16px 0 8px;">最左前缀原则</h2>
<p style="color:#666;">联合索引 (a, b, c) 等价于:</p>
<ul style="padding-left:20px;">
<li>索引 a</li>
<li>索引 (a, b)</li>
<li>索引 (a, b, c)</li>
</ul>
<p style="color:#d93025;">⚠ WHERE b = 1</p>
<p style="color:#d93025;">⚠ WHERE c = 1</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:16px;">
<div class="pros-cons">
<div class="pros">
<h4>✅ 布局要点</h4>
<ul>
<li>左侧 250px 固定宽度,笔记/网站一键切换</li>
<li>文件夹树 + 标签筛选,双重组织</li>
<li>右侧 Markdown 编辑/预览 左右分屏</li>
<li>飞书风格:大量留白、轻量阴影、蓝色强调</li>
</ul>
</div>
<div class="cons">
<h4>❓ 待确认</h4>
<ul>
<li>分屏比例是否需要可调?</li>
<li>是否需要纯编辑/纯预览的切换按钮?</li>
<li>左侧面板宽度是否合适?</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,276 @@
<h2>🔐 网站管理 — 详细页面</h2>
<p class="subtitle">网站列表 + 账号详情 + 智能导入,完整交互流程</p>
<!-- ===== 主界面:网站列表 + 详情 ===== -->
<div class="mockup">
<div class="mockup-header">网站管理主界面 — 左侧网站列表,右侧账号详情</div>
<div class="mockup-body">
<div style="border:1px solid #e0e0e0; border-radius:8px; overflow:hidden; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<!-- 顶部导航 -->
<div style="display:flex; align-items:center; padding:0 20px; background:#fff; border-bottom:1px solid #eee; height:48px;">
<span style="font-weight:700; font-size:15px; color:#333; margin-right:24px;">📝 MoKeeText</span>
<a style="padding:14px 16px; font-size:13px; color:#666; text-decoration:none;">📝 笔记</a>
<a style="padding:14px 16px; font-size:13px; font-weight:600; color:#1a73e8; border-bottom:2px solid #1a73e8; text-decoration:none;">🔐 网站管理</a>
</div>
<div style="display:flex; height:480px;">
<!-- 左侧网站列表 -->
<div style="width:260px; min-width:260px; background:#f8f9fb; border-right:1px solid #eee; display:flex; flex-direction:column; padding:12px;">
<!-- 顶部分组统计 -->
<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;">12</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;">18</div>
<div style="color:#888;">账号</div>
</div>
</div>
<button style="width:100%; padding:8px; background:#1a73e8; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:4px;">+ 添加网站</button>
<button style="width:100%; padding:8px; background:#fff; color:#1a73e8; border:1px dashed #1a73e8; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:8px;">📥 智能导入</button>
<input placeholder="🔍 搜索网站..." style="width:100%; padding:7px 10px; border:1px solid #e0e0e0; border-radius:6px; font-size:12px; box-sizing:border-box; margin-bottom:8px;">
<!-- 网站列表 -->
<div style="flex:1; overflow-y:auto; font-size:13px;">
<div style="padding:6px 10px; border-radius:6px; cursor:pointer; background:#e8f0fe; margin-bottom:2px; display:flex; align-items:center; gap:8px;">
<span style="font-size:16px;">🌐</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:600; color:#1a73e8; font-size:13px;">github.com</div>
<div style="font-size:10px; color:#888;">2 个账号</div>
</div>
<span style="color:#1a73e8;"></span>
</div>
<div style="padding:6px 10px; border-radius:6px; cursor:pointer; margin-bottom:2px; display:flex; align-items:center; gap:8px;">
<span style="font-size:16px;">🌐</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:13px;">gitee.com</div>
<div style="font-size:10px; color:#bbb;">1 个账号</div>
</div>
</div>
<div style="padding:6px 10px; border-radius:6px; cursor:pointer; margin-bottom:2px; display:flex; align-items:center; gap:8px;">
<span style="font-size:16px;">☁️</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:13px;">aliyun.com</div>
<div style="font-size:10px; color:#bbb;">1 个账号</div>
</div>
</div>
<div style="padding:6px 10px; border-radius:6px; cursor:pointer; margin-bottom:2px; display:flex; align-items:center; gap:8px;">
<span style="font-size:16px;">☁️</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:13px;">cloud.tencent.com</div>
<div style="font-size:10px; color:#bbb;">2 个账号</div>
</div>
</div>
<div style="padding:6px 10px; border-radius:6px; cursor:pointer; margin-bottom:2px; display:flex; align-items:center; gap:8px;">
<span style="font-size:16px;">📦</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:13px;">npmjs.com</div>
<div style="font-size:10px; color:#bbb;">1 个账号</div>
</div>
</div>
<div style="padding:6px 10px; border-radius:6px; cursor:pointer; margin-bottom:2px; display:flex; align-items:center; gap:8px;">
<span style="font-size:16px;">🐳</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:13px;">hub.docker.com</div>
<div style="font-size:10px; color:#bbb;">1 个账号</div>
</div>
</div>
</div>
</div>
<!-- 右侧账号详情 -->
<div style="flex:1; display:flex; flex-direction:column; background:#fff; min-width:0;">
<!-- 网站信息头 -->
<div style="padding:14px 20px; border-bottom:1px solid #f0f0f0; display:flex; align-items:center; justify-content:space-between;">
<div>
<span style="font-size:16px; font-weight:600;">🌐 github.com</span>
<a href="#" style="font-size:11px; color:#1a73e8; margin-left:8px; text-decoration:none;">🔗 https://github.com</a>
</div>
<div style="display:flex; gap:6px;">
<button style="padding:5px 12px; border:1px solid #ddd; border-radius:4px; background:#fff; font-size:12px; cursor:pointer;">✏️ 编辑</button>
<button style="padding:5px 12px; border:1px solid #ddd; border-radius:4px; background:#fff; font-size:12px; cursor:pointer;">+ 添加账号</button>
</div>
</div>
<!-- 账号卡片列表 -->
<div style="flex:1; overflow-y:auto; padding:16px 20px;">
<div style="display:flex; flex-direction:column; gap:12px;">
<!-- 账号卡片 -->
<div style="padding:16px; background:#fafafa; border:1px solid #f0f0f0; border-radius:10px; transition:all 0.2s;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px;">
<div>
<span style="font-weight:600; font-size:14px;">👤 主账号</span>
<span style="font-size:11px; color:#999; margin-left:8px;">备注: 个人开发用</span>
</div>
<div style="display:flex; gap:4px;">
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#888; cursor:pointer;">✏️</button>
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#d93025; cursor:pointer;">🗑</button>
</div>
</div>
<div style="display:grid; grid-template-columns:60px 1fr auto; gap:8px 12px; font-size:13px; align-items:center;">
<span style="color:#888;">账号</span>
<span style="font-family:'JetBrains Mono',monospace;">zhangsan@gmail.com</span>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋 复制</button>
<span style="color:#888;">密码</span>
<span style="font-family:'JetBrains Mono',monospace;">
<span class="pw-hidden">••••••••••</span>
</span>
<div style="display:flex; gap:4px;">
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">👁</button>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋</button>
</div>
<span style="color:#888;">登录说明</span>
<span style="color:#666; font-size:12px;">GitHub 个人账号,用于开源项目和 Issue 管理。开启了两步验证。</span>
<span></span>
<span style="color:#888;">添加时间</span>
<span style="color:#bbb; font-size:11px;">2025-06-15</span>
<span></span>
</div>
</div>
<!-- 第二张账号卡片 -->
<div style="padding:16px; background:#fafafa; border:1px solid #f0f0f0; border-radius:10px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px;">
<div>
<span style="font-weight:600; font-size:14px;">👤 工作账号</span>
<span style="font-size:11px; color:#999; margin-left:8px;">备注: 公司项目</span>
</div>
<div style="display:flex; gap:4px;">
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#888; cursor:pointer;">✏️</button>
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#d93025; cursor:pointer;">🗑</button>
</div>
</div>
<div style="display:grid; grid-template-columns:60px 1fr auto; gap:8px 12px; font-size:13px; align-items:center;">
<span style="color:#888;">账号</span>
<span style="font-family:'JetBrains MS',monospace;">zhangsan@company.com</span>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋</button>
<span style="color:#888;">密码</span>
<span style="font-family:monospace;">••••••••••</span>
<div style="display:flex; gap:4px;">
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">👁</button>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋</button>
</div>
<span style="color:#888;">登录说明</span>
<span style="color:#666; font-size:12px;">公司 GitHub 组织账号,用于私有仓库和企业项目</span>
<span></span>
<span style="color:#888;">添加时间</span>
<span style="color:#bbb; font-size:11px;">2025-08-20</span>
<span></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===== 智能导入弹窗 ===== -->
<div class="mockup" style="margin-top:20px;">
<div class="mockup-header">📥 智能导入 — 粘贴文本自动识别</div>
<div class="mockup-body">
<div style="border:1px solid #e0e0e0; border-radius:8px; padding:24px; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="max-width:520px; margin:0 auto;">
<!-- 步骤说明 -->
<div style="display:flex; gap:12px; margin-bottom:20px;">
<div style="flex:1; text-align:center;">
<div style="width:28px; height:28px; border-radius:50%; background:#1a73e8; color:#fff; font-weight:700; line-height:28px; margin:0 auto 4px; font-size:13px;">1</div>
<div style="font-size:11px; color:#666;">粘贴原始文本</div>
</div>
<div style="color:#ddd; font-size:20px; padding-top:4px;"></div>
<div style="flex:1; text-align:center;">
<div style="width:28px; height:28px; border-radius:50%; background:#e8eaed; color:#666; font-weight:700; line-height:28px; margin:0 auto 4px; font-size:13px;">2</div>
<div style="font-size:11px; color:#666;">自动识别解析</div>
</div>
<div style="color:#ddd; font-size:20px; padding-top:4px;"></div>
<div style="flex:1; text-align:center;">
<div style="width:28px; height:28px; border-radius:50%; background:#e8eaed; color:#666; font-weight:700; line-height:28px; margin:0 auto 4px; font-size:13px;">3</div>
<div style="font-size:11px; color:#666;">确认插入</div>
</div>
</div>
<!-- 输入框 -->
<div style="margin-bottom:12px;">
<label style="font-size:12px; font-weight:600; color:#555; display:block; margin-bottom:4px;">📋 粘贴你收到的账号信息(任意格式)</label>
<textarea style="width:100%; height:90px; padding:10px 12px; border:2px solid #1a73e8; border-radius:8px; font-size:13px; font-family:-apple-system,sans-serif; resize:vertical; box-sizing:border-box; outline:none; background:#f8faff;" placeholder="例如:&#10;网址: https://github.com&#10;账号: zhangsan@qq.com&#10;密码: Abc123456&#10;备注: 新注册的开发账号">网址: https://github.com
账号: zhangsan_new@qq.com
密码: Xyz789!@#
备注: 新注册的开发账号</textarea>
</div>
<button style="width:100%; padding:10px; background:#1a73e8; color:#fff; border:none; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; margin-bottom:16px;">🔍 自动识别</button>
<!-- 识别结果预览 -->
<div style="background:#f0fdf4; border:1px solid #86efac; border-radius:8px; padding:14px 16px;">
<div style="font-size:12px; font-weight:600; color:#166534; margin-bottom:10px;">✅ 识别结果预览</div>
<div style="display:grid; grid-template-columns:80px 1fr; gap:6px 12px; font-size:13px;">
<span style="color:#666;">🌐 网址</span>
<span style="font-weight:500;">https://github.com</span>
<span style="color:#666;">🏷 网站名</span>
<span style="font-weight:500;">github.com</span>
<span style="color:#666;">👤 账号</span>
<span style="font-weight:500; font-family:monospace;">zhangsan_new@qq.com</span>
<span style="color:#666;">🔑 密码</span>
<span style="font-weight:500; font-family:monospace;">Xyz789!@#</span>
<span style="color:#666;">📝 备注</span>
<span style="font-weight:500;">新注册的开发账号</span>
</div>
<!-- 匹配提示 -->
<div style="margin-top:10px; padding:8px 12px; background:#fff3cd; border-radius:6px; font-size:12px; color:#92600a;">
⚠️ <strong>github.com</strong> 已存在2个账号此账号将被添加到该网站下
</div>
<!-- 操作按钮 -->
<div style="display:flex; gap:8px; margin-top:12px;">
<button style="flex:1; padding:8px; background:#166534; color:#fff; border:none; border-radius:6px; font-size:13px; font-weight:600; cursor:pointer;">✅ 确认添加</button>
<button style="padding:8px 16px; background:#fff; color:#666; border:1px solid #ddd; border-radius:6px; font-size:13px; cursor:pointer;">取消</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:16px;">
<div class="pros-cons">
<div class="pros">
<h4>✅ 交互流程</h4>
<ul>
<li>左侧网站列表,显示网站名 + 账号数量</li>
<li>点击网站,右侧显示该网站所有账号卡片</li>
<li>密码默认隐藏,点击👁显示,📋复制</li>
<li>每个账号卡片独立编辑/删除</li>
</ul>
</div>
<div class="cons">
<h4>💡 智能导入逻辑</h4>
<ul>
<li>粘贴任意格式文本 → 自动提取字段</li>
<li>根据网址匹配已有网站 → 存在则追加账号,不存在则新建</li>
<li>确认前预览,防止解析错误</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,225 @@
<h2>🔐 网站管理 v2 — 文件夹 + 智能识别优化</h2>
<p class="subtitle">网站按文件夹分组,导入支持自定义格式 + 选取文件夹</p>
<!-- ===== 主界面 ===== -->
<div class="mockup">
<div class="mockup-header">网站管理 — 左侧文件夹 + 网站列表,右侧账号详情</div>
<div class="mockup-body">
<div style="border:1px solid #e0e0e0; border-radius:8px; overflow:hidden; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<!-- 顶部导航 -->
<div style="display:flex; align-items:center; padding:0 20px; background:#fff; border-bottom:1px solid #eee; height:48px;">
<span style="font-weight:700; font-size:15px; color:#333; margin-right:24px;">📝 MoKeeText</span>
<a style="padding:14px 16px; font-size:13px; color:#666; text-decoration:none;">📝 笔记</a>
<a style="padding:14px 16px; font-size:13px; font-weight:600; color:#1a73e8; border-bottom:2px solid #1a73e8; text-decoration:none;">🔐 网站管理</a>
</div>
<div style="display:flex; height:500px;">
<!-- 左侧:文件夹 + 网站列表 -->
<div style="width:270px; min-width:270px; background:#f8f9fb; border-right:1px solid #eee; display:flex; flex-direction:column; padding:12px;">
<button style="width:100%; padding:8px; background:#1a73e8; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:4px;">+ 添加网站</button>
<button style="width:100%; padding:8px; background:#fff; color:#1a73e8; border:1px dashed #1a73e8; border-radius:6px; font-size:13px; cursor:pointer; margin-bottom:4px;">📥 智能导入</button>
<button style="width:100%; padding:6px; background:transparent; color:#888; border:none; border-radius:6px; font-size:12px; cursor:pointer; margin-bottom:8px;">📁 + 新建文件夹</button>
<input placeholder="🔍 搜索网站..." style="width:100%; padding:7px 10px; border:1px solid #e0e0e0; border-radius:6px; font-size:12px; box-sizing:border-box; margin-bottom:8px;">
<!-- 文件夹 + 网站树 -->
<div style="flex:1; overflow-y:auto; font-size:13px;">
<!-- 文件夹:代码托管 -->
<div style="margin-bottom:6px;">
<div style="padding:4px 6px; display:flex; align-items:center; gap:4px; cursor:pointer;">
<span style="font-size:10px; color:#888;"></span> 📁 <span style="font-weight:600; font-size:12px;">代码托管</span>
<span style="color:#bbb; font-size:10px; margin-left:auto;">3</span>
</div>
<!-- 文件夹内的网站 -->
<div style="margin-left:16px;">
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; background:#e8f0fe; color:#1a73e8; font-weight:500; font-size:12px; margin:1px 0;">🌐 github.com <span style="color:#999; font-weight:400; font-size:10px; float:right;">2</span></div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; font-size:12px; margin:1px 0;">🌐 gitee.com <span style="color:#bbb; font-size:10px; float:right;">1</span></div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; font-size:12px; margin:1px 0;">🌐 gitlab.com <span style="color:#bbb; font-size:10px; float:right;">1</span></div>
</div>
</div>
<!-- 文件夹:云服务 -->
<div style="margin-bottom:6px;">
<div style="padding:4px 6px; display:flex; align-items:center; gap:4px; cursor:pointer;">
<span style="font-size:10px; color:#888;"></span> ☁️ <span style="font-weight:600; font-size:12px;">云服务</span>
<span style="color:#bbb; font-size:10px; margin-left:auto;">3</span>
</div>
</div>
<!-- 文件夹:开发工具 -->
<div style="margin-bottom:6px;">
<div style="padding:4px 6px; display:flex; align-items:center; gap:4px; cursor:pointer;">
<span style="font-size:10px; color:#888;"></span> 🛠 <span style="font-weight:600; font-size:12px;">开发工具</span>
<span style="color:#bbb; font-size:10px; margin-left:auto;">2</span>
</div>
</div>
<!-- 未分类 -->
<div style="padding:4px 6px; color:#aaa; font-size:11px; letter-spacing:0.5px; margin-top:4px;">未分类</div>
<div style="padding:5px 8px; border-radius:4px; cursor:pointer; font-size:12px; margin:1px 0;">🌐 example.com <span style="color:#bbb; font-size:10px; float:right;">1</span></div>
</div>
</div>
<!-- 右侧账号详情(同上) -->
<div style="flex:1; display:flex; flex-direction:column; background:#fff; min-width:0;">
<div style="padding:14px 20px; border-bottom:1px solid #f0f0f0; display:flex; align-items:center; justify-content:space-between;">
<div>
<span style="font-size:11px; color:#888;">📁 代码托管 / </span><span style="font-size:16px; font-weight:600;">🌐 github.com</span>
<a href="#" style="font-size:11px; color:#1a73e8; margin-left:8px; text-decoration:none;">🔗 https://github.com</a>
</div>
<div style="display:flex; gap:6px;">
<button style="padding:5px 12px; border:1px solid #ddd; border-radius:4px; background:#fff; font-size:12px; cursor:pointer;">✏️ 编辑</button>
<button style="padding:5px 12px; border:1px solid #ddd; border-radius:4px; background:#fff; font-size:12px; cursor:pointer;">+ 添加账号</button>
</div>
</div>
<div style="flex:1; overflow-y:auto; padding:16px 20px;">
<div style="display:flex; flex-direction:column; gap:12px;">
<div style="padding:16px; background:#fafafa; border:1px solid #f0f0f0; border-radius:10px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px;">
<span style="font-weight:600; font-size:14px;">👤 主账号</span>
<div style="display:flex; gap:4px;">
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#888; cursor:pointer;">✏️</button>
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#d93025; cursor:pointer;">🗑</button>
</div>
</div>
<div style="display:grid; grid-template-columns:60px 1fr auto; gap:8px 12px; font-size:13px; align-items:center;">
<span style="color:#888;">账号</span>
<span style="font-family:'JetBrains Mono',monospace;">zhangsan@gmail.com</span>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋</button>
<span style="color:#888;">密码</span>
<span style="font-family:monospace;">••••••••••</span>
<div style="display:flex; gap:4px;">
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">👁</button>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋</button>
</div>
<span style="color:#888;">说明</span>
<span style="color:#666; font-size:12px;">个人开发用</span>
<span></span>
</div>
</div>
<div style="padding:16px; background:#fafafa; border:1px solid #f0f0f0; border-radius:10px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px;">
<span style="font-weight:600; font-size:14px;">👤 工作账号</span>
<div style="display:flex; gap:4px;">
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#888; cursor:pointer;">✏️</button>
<button style="padding:3px 10px; border:none; background:transparent; font-size:11px; color:#d93025; cursor:pointer;">🗑</button>
</div>
</div>
<div style="display:grid; grid-template-columns:60px 1fr auto; gap:8px 12px; font-size:13px; align-items:center;">
<span style="color:#888;">账号</span>
<span style="font-family:monospace;">zhangsan@company.com</span>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋</button>
<span style="color:#888;">密码</span>
<span style="font-family:monospace;">••••••••••</span>
<div style="display:flex; gap:4px;">
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">👁</button>
<button style="padding:2px 10px; border:1px solid #e0e0e0; border-radius:4px; background:#fff; font-size:11px; cursor:pointer;">📋</button>
</div>
<span style="color:#888;">说明</span>
<span style="color:#666; font-size:12px;">公司项目</span>
<span></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===== 智能导入 v2 ===== -->
<div class="mockup" style="margin-top:20px;">
<div class="mockup-header">📥 智能导入 v2 — 新格式 + 文件夹选择</div>
<div class="mockup-body">
<div style="border:1px solid #e0e0e0; border-radius:8px; padding:24px; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="max-width:560px; margin:0 auto;">
<!-- 输入区 -->
<div style="margin-bottom:12px;">
<label style="font-size:12px; font-weight:600; color:#555; display:block; margin-bottom:4px;">📋 粘贴原始文本(格式:首行 网址+空格+网站名,后续 账号/密码/备注各一行)</label>
<textarea style="width:100%; height:100px; padding:10px 12px; border:2px solid #1a73e8; border-radius:8px; font-size:13px; font-family:-apple-system,sans-serif; resize:vertical; box-sizing:border-box; outline:none; background:#f8faff;" placeholder="格式示例:&#10;https://github.com 代码网站&#10;zhangsan_new@qq.com&#10;Xyz789!@#&#10;新注册的开发账号">https://github.com 代码网站
zhangsan_new@qq.com
Xyz789!@#
新注册的开发账号</textarea>
<div style="font-size:11px; color:#999; margin-top:4px;">
💡 <strong>识别规则:</strong>首行 = 网址 + 空格 + 网站名第2行 = 账号第3行 = 密码第4行 = 备注(可选)
</div>
</div>
<button style="width:100%; padding:10px; background:#1a73e8; color:#fff; border:none; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; margin-bottom:14px;">🔍 自动识别</button>
<!-- 识别结果 -->
<div style="background:#f0fdf4; border:1px solid #86efac; border-radius:8px; padding:14px 16px; margin-bottom:16px;">
<div style="font-size:12px; font-weight:600; color:#166534; margin-bottom:10px;">✅ 识别结果</div>
<div style="display:grid; grid-template-columns:70px 1fr; gap:6px 12px; font-size:13px;">
<span style="color:#666;">🌐 网址</span><span style="font-weight:500;">https://github.com</span>
<span style="color:#666;">🏷 网站名</span><span style="font-weight:500;">代码网站</span>
<span style="color:#666;">👤 账号</span><span style="font-weight:500; font-family:monospace;">zhangsan_new@qq.com</span>
<span style="color:#666;">🔑 密码</span><span style="font-weight:500; font-family:monospace;">Xyz789!@#</span>
<span style="color:#666;">📝 备注</span><span style="font-weight:500;">新注册的开发账号</span>
</div>
</div>
<!-- 文件夹选择 -->
<div style="background:#fff; border:1px solid #e0e0e0; border-radius:8px; padding:14px 16px; margin-bottom:16px;">
<div style="font-size:12px; font-weight:600; color:#555; margin-bottom:10px;">📁 选择存放文件夹</div>
<div style="display:flex; gap:8px; margin-bottom:8px;">
<select style="flex:1; padding:8px 10px; border:1px solid #ddd; border-radius:6px; font-size:13px; background:#fff;">
<option>代码托管已存在github.com 在此文件夹)</option>
<option>云服务</option>
<option>开发工具</option>
<option>未分类</option>
</select>
<span style="padding:8px 0; font-size:12px; color:#999;"></span>
<input placeholder="新建文件夹名..." style="flex:1; padding:8px 10px; border:1px solid #ddd; border-radius:6px; font-size:13px; box-sizing:border-box;">
</div>
<!-- 匹配提示 -->
<div style="padding:8px 12px; background:#fff3cd; border-radius:6px; font-size:12px; color:#92600a;">
⚠️ <strong>github.com</strong> 已在「代码托管」文件夹中存在2个账号将追加第3个账号
</div>
</div>
<!-- 操作 -->
<div style="display:flex; gap:8px;">
<button style="flex:1; padding:10px; background:#166534; color:#fff; border:none; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer;">✅ 确认添加</button>
<button style="padding:10px 20px; background:#fff; color:#666; border:1px solid #ddd; border-radius:8px; font-size:14px; cursor:pointer;">取消</button>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:16px;">
<h3>📐 v2 改动总结</h3>
<div style="display:flex; gap:16px;">
<div style="flex:1; padding:12px; background:#f0f7ff; border-radius:8px;">
<strong>📁 网站文件夹</strong>
<ul style="margin:6px 0 0; padding-left:18px; font-size:13px; color:#555;">
<li>网站按文件夹展开/折叠</li>
<li>「添加网站」时选文件夹</li>
<li>拖拽网站到其他文件夹</li>
<li>未分类网站单独显示</li>
</ul>
</div>
<div style="flex:1; padding:12px; background:#f0fdf4; border-radius:8px;">
<strong>📥 导入格式</strong>
<ul style="margin:6px 0 0; padding-left:18px; font-size:13px; color:#555;">
<li><code>网址 + 空格 + 网站名</code></li>
<li>逐行:账号 → 密码 → 备注</li>
<li>识别后选已有文件夹或新建</li>
<li>同网址自动追加、不同网址自动新建</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1782200884044}

View File

@@ -0,0 +1 @@
1938

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
__pycache__
*.pyc

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
COPY app.py config.py models.py logger.py init_db.py file_service.py ./
COPY routes/ ./routes/
COPY middleware/ ./middleware/
RUN mkdir -p /data/mokee/mokeetext/logs && mkdir -p static
EXPOSE 4006
CMD sh -c "python init_db.py && uvicorn app:app --host 0.0.0.0 --port 4006"

62
backend/app.py Normal file
View File

@@ -0,0 +1,62 @@
"""MoKeeText — FastAPI 入口"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from logger import logger
from middleware.user_context import UserContextMiddleware
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
from routes.share import router as share_router
from routes.upload import router as upload_router
app = FastAPI(title="MoKeeText", version="2.1.0")
# CORS — 允许网关域名、前端域名、后端域名跨域
# 注意allow_credentials=True 时不能使用 allow_origins=["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://mokeetext.zgitm.com",
"https://mokeetext.server.zgitm.com", # 分享页 + 图片直连
"https://gw.server.zgitm.com",
"http://localhost:5173",
],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
# 用户上下文中间件 — 在 CORS 之后注册,读取网关转发的 X-User-* 请求头
app.add_middleware(UserContextMiddleware)
@app.on_event("startup")
async def startup():
logger.info("MoKeeText 服务启动")
@app.on_event("shutdown")
async def shutdown():
logger.info("MoKeeText 服务关闭")
# 请求日志中间件
@app.middleware("http")
async def log_requests(request: Request, call_next):
response = await call_next(request)
logger.info(f"{request.method} {request.url.path}{response.status_code}")
return response
# 静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")
# API 路由
app.include_router(notes_router)
app.include_router(websites_router)
app.include_router(import_router)
app.include_router(share_router)
app.include_router(upload_router)
@app.get("/favicon.ico")
async def favicon():
return FileResponse("static/favicon.ico") if __import__('os').path.exists("static/favicon.ico") else Response(status_code=204)

20
backend/config.py Normal file
View File

@@ -0,0 +1,20 @@
"""数据库与应用配置"""
import os
# MySQL 数据库配置
DB_CONFIG = {
"user": os.getenv("MYSQL_USER", "mokeetext"),
"password": os.getenv("MYSQL_PASSWORD", "mokee."),
"host": os.getenv("MYSQL_HOST", "10.20.1.122"),
"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"
)

130
backend/file_service.py Normal file
View File

@@ -0,0 +1,130 @@
"""文件对象存储服务客户端 — https://file.wg.zgitm.com
认证方式:一次性 Token每次操作前获取
系统编码mokeetext-edit
"""
import requests
BASE_URL = "https://file.wg.zgitm.com"
SYSTEM_CODE = "mokeetext-edit"
FOLDER_PATH = "/mokeetext-images/" # 图片统一存放目录
def get_token(system_code: str = SYSTEM_CODE) -> str:
"""获取一次性 Token"""
resp = requests.post(
f"{BASE_URL}/api/token",
json={"systemCode": system_code},
timeout=15,
)
data = resp.json()
if data.get("code") != 200:
raise RuntimeError(f"获取文件服务 Token 失败: {data.get('message')}")
return data["data"]["token"]
def upload_file(
file_data: bytes,
filename: str,
content_type: str,
upload_user: str = "",
) -> dict:
"""
上传文件到文件服务
返回: {"fileId": "...", "fileName": "...", "fileSize": 123}
"""
token = get_token()
resp = requests.post(
f"{BASE_URL}/api/upload",
headers={"Authorization": f"Bearer {token}"},
files={"file": (filename, file_data, content_type)},
data={
"folderPath": FOLDER_PATH,
"uploadUser": upload_user,
},
timeout=60,
)
data = resp.json()
if data.get("code") != 200:
raise RuntimeError(f"上传文件失败: {data.get('message')}")
return data["data"]
def download_file(file_id: str) -> tuple[bytes, str, str]:
"""
从文件服务下载文件
返回: (binary_data, content_type, filename)
"""
token = get_token()
resp = requests.get(
f"{BASE_URL}/api/download/{file_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if resp.status_code == 404:
raise FileNotFoundError(f"文件不存在: {file_id}")
if resp.status_code != 200:
try:
err = resp.json()
raise RuntimeError(f"下载文件失败: {err.get('message', resp.text)}")
except ValueError:
raise RuntimeError(f"下载文件失败: HTTP {resp.status_code}")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
# 从 Content-Disposition 提取文件名
filename = file_id
disposition = resp.headers.get("Content-Disposition", "")
if "filename*=" in disposition:
# filename*=UTF-8''原始文件名
parts = disposition.split("filename*=")
if len(parts) > 1:
fname = parts[1].strip()
if "''" in fname:
filename = fname.split("''", 1)[1]
elif 'filename="' in disposition:
filename = disposition.split('filename="')[1].rstrip('"')
return resp.content, content_type, filename
def delete_file(file_id: str) -> bool:
"""删除文件服务中的文件"""
token = get_token()
resp = requests.get(
f"{BASE_URL}/api/delete/{file_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
data = resp.json()
if data.get("code") == 404:
return False # 文件已不存在,也算删除成功
if data.get("code") != 200:
raise RuntimeError(f"删除文件失败: {data.get('message')}")
return True
def ensure_folder() -> bool:
"""确保图片存放目录存在"""
token = get_token()
resp = requests.post(
f"{BASE_URL}/api/folder/create",
headers={"Authorization": f"Bearer {token}"},
json={
"folderPath": FOLDER_PATH,
"createdBy": SYSTEM_CODE,
},
timeout=15,
)
data = resp.json()
if data.get("code") == 400 and "已存在" in data.get("message", ""):
return True # 文件夹已存在,没问题
if data.get("code") != 200:
raise RuntimeError(f"创建文件夹失败: {data.get('message')}")
return True

94
backend/gateway-init.sql Normal file
View File

@@ -0,0 +1,94 @@
-- =============================================
-- mokee编辑器 菜单 & 角色初始化 SQL
-- 系统编码: mokeetext-edit
-- 执行位置: 网关平台数据库
-- =============================================
-- 0. 获取系统 ID
SET @system_id = (SELECT id FROM sys_system WHERE system_code = 'mokeetext-edit' AND is_deleted = 0 LIMIT 1);
-- 1. 创建目录
SET @menu_sys = REPLACE(UUID(),'-','');
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
(@menu_sys, @system_id, '0', '内容管理', '/', 'Layout', 'Document', 1, 1, 1, NULL);
-- 2. 创建页面菜单(对应实际路由: /notes, /websites, /shares
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '笔记', '/notes', 'notes/index', 'Edit', 2, 1, 1, 'notes:list'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '网站管理', '/websites', 'websites/index', 'Monitor', 2, 2, 1, 'websites:list'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '分享管理', '/shares', 'shares/index', 'Link', 2, 3, 1, 'shares:list');
-- 3. 创建按钮权限
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建笔记', NULL, NULL, '', 3, 1, 1, 'notes:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑笔记', NULL, NULL, '', 3, 2, 1, 'notes:edit'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除笔记', NULL, NULL, '', 3, 3, 1, 'notes:delete'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建网站', NULL, NULL, '', 3, 1, 1, 'websites:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑网站', NULL, NULL, '', 3, 2, 1, 'websites:edit'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除网站', NULL, NULL, '', 3, 3, 1, 'websites:delete'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建分享', NULL, NULL, '', 3, 1, 1, 'shares:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除分享', NULL, NULL, '', 3, 2, 1, 'shares:delete');
-- 4. 创建角色
SET @role_admin = REPLACE(UUID(),'-','');
SET @role_user = REPLACE(UUID(),'-','');
INSERT INTO sys_role (id, system_id, role_name, role_code, description, status) VALUES
(@role_admin, @system_id, '管理员', 'admin', '拥有全部权限', 1),
(@role_user, @system_id, '普通用户', 'user', '拥有基本权限', 1);
-- 5. 管理员获得全部菜单权限
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT REPLACE(UUID(),'-',''), @role_admin, m.id
FROM sys_menu m WHERE m.system_id = @system_id;
-- 6. 普通用户只分配页面菜单(不含按钮权限)
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT REPLACE(UUID(),'-',''), @role_user, m.id
FROM sys_menu m WHERE m.system_id = @system_id
AND m.menu_type != 3;
-- 7. 创建管理员账号(密码 admin123 的 BCrypt 密文)
INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted)
SELECT REPLACE(UUID(),'-',''), 'admin_mokeetext-edit',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi',
'mokee编辑器管理员', 'admin@mokeetext-edit.com', NULL, 1, 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_mokeetext-edit');
-- 8. 绑定到 gateway 系统(用于登录管理后台)
INSERT INTO sys_user_system (id, user_id, system_id)
SELECT REPLACE(UUID(),'-',''), u.id, s.id
FROM sys_user u, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND s.system_code = 'gateway'
AND NOT EXISTS (SELECT 1 FROM sys_user_system us WHERE us.user_id = u.id AND us.system_id = s.id);
-- 9. 分配 gateway-app-admin 角色
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(),'-',''), u.id, r.id
FROM sys_user u, sys_role r, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND r.role_code = 'gateway-app-admin'
AND r.system_id = s.id
AND s.system_code = 'gateway'
AND NOT EXISTS (SELECT 1 FROM sys_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id);
-- 10. 绑定到 mokeetext-edit 系统
INSERT INTO sys_user_system (id, user_id, system_id)
SELECT REPLACE(UUID(),'-',''), u.id, s.id
FROM sys_user u, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND s.system_code = 'mokeetext-edit'
AND NOT EXISTS (SELECT 1 FROM sys_user_system us WHERE us.user_id = u.id AND us.system_id = s.id);
-- 11. 分配本系统 admin 角色
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(),'-',''), u.id, r.id
FROM sys_user u, sys_role r, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND r.role_code = 'admin'
AND r.system_id = s.id
AND s.system_code = 'mokeetext-edit'
AND NOT EXISTS (SELECT 1 FROM sys_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id);

59
backend/init_db.py Normal file
View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""数据库初始化 — 建表 + 插入默认数据"""
import sys
sys.stdout.reconfigure(encoding='utf-8')
from models import engine, Base, SessionLocal, Folder
def migrate():
"""数据库迁移 — 处理表结构变更"""
from sqlalchemy import text
with engine.connect() as conn:
# V2.2: uploads 表新增 file_id 列data 改为可空
try:
conn.execute(text("ALTER TABLE uploads ADD COLUMN file_id VARCHAR(64) NULL AFTER content_type"))
except Exception:
pass # 列已存在则跳过
try:
conn.execute(text("ALTER TABLE uploads MODIFY COLUMN data LONGBLOB NULL"))
except Exception:
pass
conn.commit()
print("[OK] 数据库迁移完成")
def init_database():
"""创建所有表,并插入默认文件夹"""
# 建表
Base.metadata.create_all(bind=engine)
print("[OK] 数据库表创建完成")
# 执行迁移
migrate()
# 插入默认数据
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"))
print("[OK] 笔记文件夹已创建: 工作、个人、学习、未分类")
# 网站默认文件夹
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"))
print("[OK] 网站文件夹已创建: 代码托管、云服务、开发工具、未分类")
db.commit()
print("[OK] 默认数据插入完成")
finally:
db.close()
if __name__ == "__main__":
init_database()

62
backend/logger.py Normal file
View File

@@ -0,0 +1,62 @@
"""日志配置 — 按天拆分 + 当前日志"""
import logging
import os
from logging.handlers import TimedRotatingFileHandler
from datetime import datetime
LOG_DIR = os.getenv("LOG_DIR", os.path.join(os.path.dirname(__file__), "logs"))
def setup_logger(name="mokeetext"):
"""配置日志:一天一个文件 + current.log 始终为当天日志"""
os.makedirs(LOG_DIR, exist_ok=True)
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
# 避免重复添加 handler
if logger.handlers:
return logger
# 格式
formatter = logging.Formatter(
"[%(asctime)s] %(levelname)s %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 按天轮转:每天午夜切一个新文件,保留 30 天
daily_handler = TimedRotatingFileHandler(
filename=os.path.join(LOG_DIR, "mokeetext.log"),
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8",
)
daily_handler.suffix = "%Y-%m-%d"
daily_handler.setFormatter(formatter)
daily_handler.setLevel(logging.INFO)
logger.addHandler(daily_handler)
# 当前日志软链接(始终指向今天的日志)
current_log = os.path.join(LOG_DIR, "current.log")
# 控制台也输出
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setLevel(logging.INFO)
logger.addHandler(console_handler)
# 记录日志文件路径
logger.info(f"日志目录: {LOG_DIR}")
return logger
def get_log_path():
"""返回今天的日志文件路径"""
today = datetime.now().strftime("%Y-%m-%d")
return os.path.join(LOG_DIR, f"mokeetext.log{today}")
# 单例
logger = setup_logger()

View File

@@ -0,0 +1 @@
"""中间件包"""

View File

@@ -0,0 +1,94 @@
"""Mokee Gateway 用户上下文中间件
网关转发请求时注入以下请求头,本中间件读取并存入 request.state.user
X-User-Id, X-User-Name, X-User-Account, X-User-Email,
X-User-Post, X-User-Roles, X-System-Id, X-System-Code, X-System-Name
本地开发时(无网关),使用 DEV_MODE=true 环境变量或 X-Dev-Mock-User 请求头
启用默认 admin 用户,无需启动网关即可调试。
"""
import os
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
# 网关注入的所有请求头
GATEWAY_HEADERS = [
"X-User-Id",
"X-User-Name",
"X-User-Account",
"X-User-Email",
"X-User-Post",
"X-User-Roles",
"X-System-Id",
"X-System-Code",
"X-System-Name",
]
# 本地开发默认用户(模拟网关注入)
DEV_DEFAULT_USER = {
"X-User-Id": "admin",
"X-User-Name": "管理员",
"X-User-Account": "admin",
"X-User-Email": "admin@zgitm.com",
"X-User-Post": "",
"X-User-Roles": '[{"roleId":"1","roleName":"管理员","roleCode":"admin"}]',
"X-System-Id": "1",
"X-System-Code": "mokeetext-edit",
"X-System-Name": "mokee编辑器",
}
class UserContext:
"""用户上下文 — 提供快捷访问方法"""
@staticmethod
def from_request(request: Request) -> dict:
"""从 request.state 获取用户信息"""
return getattr(request.state, "user", {})
@staticmethod
def get_user_id(request: Request) -> str:
"""获取当前用户账号名(用于 created_by 显示),未登录返回 'admin'"""
user = UserContext.from_request(request)
# 优先用登录账号名(如 admin其次用用户名最后用 ID
return user.get("X-User-Account") or user.get("X-User-Name") or user.get("X-User-Id", "admin")
@staticmethod
def get_user_name(request: Request) -> str:
return UserContext.from_request(request).get("X-User-Name", "")
@staticmethod
def get_user_account(request: Request) -> str:
return UserContext.from_request(request).get("X-User-Account", "")
@staticmethod
def get_system_code(request: Request) -> str:
return UserContext.from_request(request).get("X-System-Code", "")
class UserContextMiddleware(BaseHTTPMiddleware):
"""将网关注入的用户信息存入 request.state.user
生产环境:读取网关转发的 X-User-* 请求头
本地开发DEV_MODE=true 时使用 DEV_DEFAULT_USER
"""
async def dispatch(self, request: Request, call_next):
user_info = {}
# 读取网关注入的请求头
for header in GATEWAY_HEADERS:
value = request.headers.get(header)
if value:
user_info[header] = value
# 本地开发:无网关时使用默认用户
if not user_info.get("X-User-Id"):
dev_mode = os.getenv("DEV_MODE", "false").lower() == "true"
if dev_mode or request.headers.get("X-Dev-Mock-User"):
user_info = DEV_DEFAULT_USER.copy()
request.state.user = user_info
response = await call_next(request)
return response

135
backend/models.py Normal file
View File

@@ -0,0 +1,135 @@
"""SQLAlchemy 数据模型"""
from datetime import datetime
from sqlalchemy import (
create_engine, Column, Integer, String, Text, DateTime, LargeBinary,
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()
# ===== 文件夹 =====
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_by = Column(String(100), nullable=False, default="admin")
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")
# ===== 笔记 =====
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_by = Column(String(100), nullable=False, default="admin")
updated_by = Column(String(100), nullable=False, default="admin")
share_token = Column(String(64), nullable=True, unique=True)
share_expires_at = Column(DateTime, 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")
# ===== 笔记-标签关联 =====
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="#666666")
notes = relationship("Note", secondary="note_tags", back_populates="tags")
# ===== 网站 =====
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_by = Column(String(100), nullable=False, default="admin")
updated_by = Column(String(100), nullable=False, default="admin")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
folder = relationship("Folder", back_populates="websites")
accounts = relationship("Account", back_populates="website", cascade="all, delete-orphan",
order_by="Account.sort_order")
# ===== 账号 =====
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_by = Column(String(100), nullable=False, default="admin")
updated_by = Column(String(100), nullable=False, default="admin")
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
website = relationship("Website", back_populates="accounts")
# ===== 上传文件 =====
class Upload(Base):
"""文件上传表 — 图片存储(新文件存文件服务,旧文件兼容 DB blob"""
__tablename__ = "uploads"
id = Column(Integer, primary_key=True, autoincrement=True)
filename = Column(String(255), nullable=False)
content_type = Column(String(100), nullable=False)
data = Column(LargeBinary(length=2**32-1), nullable=True) # 旧图片 LONGBLOB兼容新文件为 NULL
file_id = Column(String(64), nullable=True) # 文件服务返回的 fileId新文件
size = Column(Integer, default=0)
created_by = Column(String(100), nullable=False, default="admin")
created_at = Column(DateTime, default=datetime.utcnow)

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlalchemy>=2.0.35
pymysql>=1.1.0
cryptography>=43.0.0
python-multipart>=0.0.12
requests>=2.31.0

View File

@@ -0,0 +1 @@
"""路由包"""

View File

@@ -0,0 +1,61 @@
"""智能导入 API — 文本解析与匹配"""
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session, joinedload
from models import get_db, Website
from middleware.user_context import UserContext
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), request: Request = None):
"""解析粘贴的文本,返回结构化结果 + 匹配信息"""
user_id = UserContext.get_user_id(request)
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(
Website.url == url, Website.created_by == user_id
).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

159
backend/routes/notes.py Normal file
View File

@@ -0,0 +1,159 @@
"""笔记相关路由"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from models import get_db, Note
from middleware.user_context import UserContext
router = APIRouter(tags=["笔记"])
@router.get("/api/notes")
def list_notes(folder_id: int = None, db: Session = Depends(get_db), request: Request = None):
"""获取笔记列表(仅当前用户)"""
user_id = UserContext.get_user_id(request)
q = db.query(Note).filter(Note.created_by == user_id)
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), request: Request = None):
"""创建笔记"""
user_id = UserContext.get_user_id(request)
note = Note(
title=data.get("title", "无标题"),
content=data.get("content", ""),
folder_id=data.get("folder_id"),
created_by=user_id,
updated_by=user_id,
)
db.add(note)
db.commit()
db.refresh(note)
return {"id": note.id, "title": note.title, "content": note.content,
"folder_id": note.folder_id, "created_by": note.created_by, "updated_by": note.updated_by,
"created_at": str(note.created_at), "updated_at": str(note.updated_at)}
@router.get("/api/notes/{note_id}")
def get_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
"""获取单个笔记"""
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
return {"id": note.id, "title": note.title, "content": note.content,
"folder_id": note.folder_id, "created_by": note.created_by, "updated_by": note.updated_by,
"created_at": str(note.created_at), "updated_at": str(note.updated_at)}
@router.put("/api/notes/{note_id}")
def update_note(note_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
"""更新笔记"""
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_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"]
note.updated_by = user_id
db.commit()
db.refresh(note)
return {"ok": True}
@router.delete("/api/notes/{note_id}")
def delete_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
"""删除笔记"""
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
db.delete(note)
db.commit()
return {"ok": True}
# ===== 分享 =====
import secrets
@router.post("/api/notes/{note_id}/share")
def share_note(note_id: int, data: dict = {}, db: Session = Depends(get_db), request: Request = None):
"""生成/刷新分享链接,可设置有效期"""
from datetime import datetime, timedelta
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
note.share_token = secrets.token_urlsafe(16)
# 有效期: 7d / 30d / 1y / forever
ttl = data.get("ttl", "forever")
if ttl == "7d":
note.share_expires_at = datetime.utcnow() + timedelta(days=7)
elif ttl == "30d":
note.share_expires_at = datetime.utcnow() + timedelta(days=30)
elif ttl == "1y":
note.share_expires_at = datetime.utcnow() + timedelta(days=365)
else:
note.share_expires_at = None # 永久
db.commit()
return {
"share_token": note.share_token,
"url": f"/share/{note.share_token}",
"expires_at": note.share_expires_at.strftime("%Y-%m-%d %H:%M") if note.share_expires_at else "永久有效",
}
@router.delete("/api/notes/{note_id}/share")
def unshare_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
"""取消分享"""
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
note.share_token = None
db.commit()
return {"ok": True}
# ===== 移动 =====
@router.put("/api/notes/{note_id}/move")
def move_note(note_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
"""移动笔记到另一个文件夹"""
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
note.folder_id = data.get("folder_id")
db.commit()
return {"ok": True, "folder_id": note.folder_id}
# ===== 分享管理 =====
@router.get("/api/shares")
def list_shares(db: Session = Depends(get_db), request: Request = None):
"""列出当前用户已分享的笔记"""
user_id = UserContext.get_user_id(request)
notes = db.query(Note).filter(
Note.share_token.isnot(None),
Note.created_by == user_id,
).order_by(Note.updated_at.desc()).all()
result = []
for n in notes:
result.append({
"id": n.id,
"title": n.title,
"share_token": n.share_token,
"share_url": f"/share/{n.share_token}",
"share_expires_at": n.share_expires_at.strftime("%Y-%m-%d %H:%M") if n.share_expires_at else "永久有效",
"created_by": n.created_by,
"updated_at": str(n.updated_at),
})
return result

75
backend/routes/share.py Normal file
View File

@@ -0,0 +1,75 @@
"""分享笔记公开查看"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from models import get_db, Note
router = APIRouter(tags=["分享"])
CSS = """
:root{--text:#1F2329;--text2:#646A73;--text3:#8F959E;--border:#E5E6EB;--brand:#3370FF;--bg:#F5F6F8;}
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--text);background:var(--bg);min-height:100vh;}
.wrap{max-width:1100px;margin:0 auto;padding:32px 16px;}
.card{background:#fff;border-radius:12px;padding:36px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow-x:auto;word-wrap:break-word;overflow-wrap:break-word;max-width:100%;}
h1{font-size:26px;font-weight:700;padding-bottom:16px;margin-bottom:16px;border-bottom:2px solid var(--border);line-height:1.3;}
.meta{font-size:12px;color:var(--text3);margin-bottom:28px;display:flex;gap:20px;}
.content{font-size:15px;line-height:1.9;color:var(--text);overflow-x:auto;word-wrap:break-word;overflow-wrap:break-word;}
.content h2{font-size:20px;font-weight:600;margin:24px 0 10px;}
.content h3{font-size:17px;font-weight:600;margin:18px 0 8px;}
.content p{margin:8px 0;}
.content ul,.content ol{padding-left:22px;margin:8px 0;}
.content li{margin:4px 0;}
.content blockquote{border-left:3px solid var(--brand);padding:4px 14px;margin:12px 0;color:var(--text2);}
.content pre{background:#F7F8FA;padding:14px 18px;border-radius:8px;overflow-x:auto;font-size:13px;line-height:1.7;font-family:"SF Mono","Fira Code","Consolas",monospace;}
.content code{background:#F2F3F5;padding:2px 6px;border-radius:3px;font-size:13px;font-family:"SF Mono","Fira Code","Consolas",monospace;}
.content pre code{background:none;padding:0;}
.content table{border-collapse:collapse;width:100%;margin:12px 0;}
.content th,.content td{border:1px solid var(--border);padding:8px 14px;text-align:left;}
.content th{background:#F7F8FA;font-weight:600;font-size:13px;}
.content img{max-width:100%;border-radius:8px;}
.content mark{background:#FFF3CD;padding:2px 4px;border-radius:2px;}
.footer{text-align:center;margin-top:24px;font-size:11px;color:var(--text3);}
.footer a{color:var(--brand);text-decoration:none;}
"""
TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{title} - MoKeeText</title>
<style>{css}</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>{title}</h1>
<div class="meta">
<span>创建于 {created_at}</span>
<span>{created_by}</span>
</div>
<div class="content">{content}</div>
</div>
<div class="footer">Powered by <a href="/">MoKeeText</a></div>
</div>
</body>
</html>"""
@router.get("/share/{token}")
def view_shared(token: str, db: Session = Depends(get_db)):
note = db.query(Note).filter_by(share_token=token).first()
if not note:
raise HTTPException(404, "链接无效或已取消分享")
if note.share_expires_at and note.share_expires_at < datetime.utcnow():
raise HTTPException(410, "分享链接已过期")
html = TEMPLATE.format(
title=note.title,
created_at=note.created_at.strftime('%Y-%m-%d %H:%M:%S') if note.created_at else '-',
created_by=note.created_by or 'admin',
content=note.content or '<p style="color:var(--text3);">暂无内容</p>',
css=CSS,
)
return HTMLResponse(html)

101
backend/routes/upload.py Normal file
View File

@@ -0,0 +1,101 @@
"""文件上传/读取 — 数据库存储file_service.py 为可选备用方案,保留不删)"""
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy.orm import Session
from logger import logger
from models import get_db, Upload
from middleware.user_context import UserContext
# 文件服务模块保留备用(不删除),如需切换取消下面注释即可
# from file_service import upload_file, download_file, delete_file
router = APIRouter(tags=["上传"])
ALLOWED = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"}
MAX_SIZE = 20 * 1024 * 1024 # 20MB
@router.post("/api/upload/image")
async def upload_image(file: UploadFile = File(...), db: Session = Depends(get_db), request: Request = None):
"""上传图片 — 存入数据库 LONGBLOB"""
user_id = UserContext.get_user_id(request)
try:
if file.content_type not in ALLOWED:
raise HTTPException(400, f"不支持的图片格式: {file.content_type}")
data = await file.read()
if len(data) > MAX_SIZE:
raise HTTPException(400, f"图片不能超过 20MB当前 {len(data)} 字节")
filename = file.filename or "img"
logger.info(f"上传图片: {filename}, {len(data)} bytes, {file.content_type}, user={user_id}")
upload = Upload(
filename=filename,
content_type=file.content_type,
data=data,
size=len(data),
created_by=user_id,
)
db.add(upload)
db.commit()
db.refresh(upload)
logger.info(f"图片已存入 DB, id={upload.id}")
return {"url": f"/api/file/{upload.id}"}
except HTTPException:
raise
except Exception as e:
logger.error(f"上传失败: {e}")
raise HTTPException(500, f"上传失败: {str(e)}")
@router.get("/api/file/{file_id}")
def get_file(file_id: int, db: Session = Depends(get_db)):
"""读取图片 — 公开访问(不走网关,供编辑器 <img> 标签和分享页加载)
优先从 DB blob 读取;如为文件服务存储则代理下载
"""
upload = db.query(Upload).filter_by(id=file_id).first()
if not upload:
raise HTTPException(404, "文件不存在")
# DB blob主存储方式
if upload.data:
return Response(content=upload.data, media_type=upload.content_type)
# 文件服务存储(兼容之前上传的文件)
if upload.file_id:
from file_service import download_file
try:
data, content_type, _ = download_file(upload.file_id)
return Response(content=data, media_type=content_type)
except FileNotFoundError:
raise HTTPException(404, "文件在文件服务中不存在")
except Exception as e:
logger.error(f"从文件服务下载失败: {e}")
raise HTTPException(500, f"下载文件失败: {str(e)}")
raise HTTPException(404, "文件数据不存在")
@router.delete("/api/file/{file_id}")
def delete_file_api(file_id: int, db: Session = Depends(get_db), request: Request = None):
"""删除图片 — 从 DB 和文件服务中删除"""
user_id = UserContext.get_user_id(request)
upload = db.query(Upload).filter_by(id=file_id).first()
if not upload:
raise HTTPException(404, "文件不存在")
# 如果存在文件服务记录,同步删除
if upload.file_id:
from file_service import delete_file
try:
delete_file(upload.file_id)
logger.info(f"文件服务删除成功, fileId={upload.file_id}")
except Exception as e:
logger.error(f"文件服务删除失败: {e}")
db.delete(upload)
db.commit()
logger.info(f"图片记录已删除, db_id={file_id}, user={user_id}")
return {"ok": True}

221
backend/routes/websites.py Normal file
View File

@@ -0,0 +1,221 @@
"""网站管理相关路由"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session, joinedload
from models import get_db, Website, Account, Folder
from middleware.user_context import UserContext
router = APIRouter(tags=["网站管理"])
# ===== 网站 CRUD =====
@router.get("/api/websites")
def list_websites(folder_id: int = None, db: Session = Depends(get_db), request: Request = None):
"""获取网站列表(仅当前用户)"""
user_id = UserContext.get_user_id(request)
q = db.query(Website).options(joinedload(Website.accounts)).filter(Website.created_by == user_id)
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), request: Request = None):
"""创建网站"""
user_id = UserContext.get_user_id(request)
website = Website(
name=data.get("name", ""),
url=data.get("url", ""),
folder_id=data.get("folder_id"),
created_by=user_id,
updated_by=user_id,
)
db.add(website)
db.commit()
db.refresh(website)
return {"id": website.id, "name": website.name, "url": website.url,
"folder_id": website.folder_id}
@router.get("/api/websites/{website_id}")
def get_website(website_id: int, db: Session = Depends(get_db), request: Request = None):
"""获取网站详情(含账号列表)"""
user_id = UserContext.get_user_id(request)
website = db.query(Website).options(joinedload(Website.accounts)).filter(
Website.id == website_id, Website.created_by == user_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), request: Request = None):
"""更新网站信息"""
user_id = UserContext.get_user_id(request)
website = db.query(Website).filter(Website.id == website_id, Website.created_by == user_id).first()
if not website:
raise HTTPException(404, "网站不存在")
for field in ["name", "url", "folder_id"]:
if field in data:
setattr(website, field, data[field])
website.updated_by = user_id
db.commit()
db.refresh(website)
return {"ok": True}
@router.delete("/api/websites/{website_id}")
def delete_website(website_id: int, db: Session = Depends(get_db), request: Request = None):
"""删除网站"""
user_id = UserContext.get_user_id(request)
website = db.query(Website).filter(Website.id == website_id, Website.created_by == user_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), request: Request = None):
"""添加账号"""
user_id = UserContext.get_user_id(request)
account = Account(
website_id=website_id,
username=data.get("username", ""),
password=data.get("password", ""),
remark=data.get("remark", ""),
sort_order=data.get("sort_order", 0),
created_by=user_id,
updated_by=user_id,
)
db.add(account)
db.commit()
db.refresh(account)
return {"id": account.id, "username": account.username, "remark": account.remark, "sort_order": account.sort_order}
@router.get("/api/accounts/{account_id}")
def get_account(account_id: int, db: Session = Depends(get_db), request: Request = None):
"""获取单个账号"""
user_id = UserContext.get_user_id(request)
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
if not account:
raise HTTPException(404, "账号不存在")
return {"id": account.id, "username": account.username,
"password": account.password, "remark": account.remark}
@router.put("/api/accounts/{account_id}")
def update_account(account_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
"""更新账号"""
user_id = UserContext.get_user_id(request)
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
if not account:
raise HTTPException(404, "账号不存在")
for field in ["username", "password", "remark", "sort_order"]:
if field in data:
setattr(account, field, data[field])
account.updated_by = user_id
db.commit()
db.refresh(account)
return {"ok": True}
@router.delete("/api/accounts/{account_id}")
def delete_account(account_id: int, db: Session = Depends(get_db), request: Request = None):
"""删除账号"""
user_id = UserContext.get_user_id(request)
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
if not account:
raise HTTPException(404, "账号不存在")
db.delete(account)
db.commit()
return {"ok": True}
@router.put("/api/accounts/{account_id}/move")
def move_account(account_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
"""移动账号顺序: direction = 'up' | 'down'"""
user_id = UserContext.get_user_id(request)
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
if not account:
raise HTTPException(404, "账号不存在")
direction = data.get("direction", "down")
website_id = account.website_id
# 同网站下按 sort_order 排序的所有账号
siblings = db.query(Account).filter_by(website_id=website_id).order_by(Account.sort_order).all()
idx = next((i for i, a in enumerate(siblings) if a.id == account_id), None)
if idx is None:
return {"ok": False}
if direction == "up" and idx > 0:
siblings[idx].sort_order, siblings[idx-1].sort_order = siblings[idx-1].sort_order, siblings[idx].sort_order
elif direction == "down" and idx < len(siblings) - 1:
siblings[idx].sort_order, siblings[idx+1].sort_order = siblings[idx+1].sort_order, siblings[idx].sort_order
db.commit()
return {"ok": True}
# ===== 文件夹 API =====
@router.get("/api/folders")
def list_folders(type: str = "website", db: Session = Depends(get_db), request: Request = None):
"""获取文件夹列表(仅当前用户)"""
user_id = UserContext.get_user_id(request)
return db.query(Folder).filter(
Folder.type == type,
Folder.created_by == user_id,
).order_by(Folder.sort_order).all()
@router.post("/api/folders")
def create_folder(data: dict, db: Session = Depends(get_db), request: Request = None):
"""创建文件夹"""
user_id = UserContext.get_user_id(request)
folder = Folder(
name=data.get("name", "新文件夹"),
type=data.get("type", "website"),
created_by=user_id,
)
db.add(folder)
db.commit()
db.refresh(folder)
return {"id": folder.id, "name": folder.name, "type": folder.type}
@router.put("/api/folders/{folder_id}")
def update_folder(folder_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
"""重命名文件夹"""
user_id = UserContext.get_user_id(request)
folder = db.query(Folder).filter(Folder.id == folder_id, Folder.created_by == user_id).first()
if not folder:
raise HTTPException(404, "文件夹不存在")
if "name" in data:
folder.name = data["name"]
db.commit()
return {"ok": True, "id": folder.id, "name": folder.name}
@router.delete("/api/folders/{folder_id}")
def delete_folder(folder_id: int, db: Session = Depends(get_db), request: Request = None):
"""删除文件夹(文件夹下的内容变为未分类)"""
user_id = UserContext.get_user_id(request)
folder = db.query(Folder).filter(Folder.id == folder_id, Folder.created_by == user_id).first()
if not folder:
raise HTTPException(404, "文件夹不存在")
# 将该文件夹下的网站/笔记移到未分类(仅限当前用户)
from models import Website, Note
if folder.type == "website":
db.query(Website).filter_by(folder_id=folder_id, created_by=user_id).update({"folder_id": None})
else:
db.query(Note).filter_by(folder_id=folder_id, created_by=user_id).update({"folder_id": None})
db.delete(folder)
db.commit()
return {"ok": True}

32
deploy/deploy.py Normal file
View File

@@ -0,0 +1,32 @@
"""MoKeeText 部署脚本 — 上传构建产物并重建前端容器"""
import paramiko, os
key_path = os.path.expanduser('~/.ssh/id_rsa')
key = paramiko.RSAKey.from_private_key_file(key_path)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('101.132.183.138', username='root', pkey=key, timeout=15)
# 清理旧文件
c.exec_command('rm -f /data/mokee/mokeetext/frontend/static/dist/assets/index-*.js /data/mokee/mokeetext/frontend/static/dist/assets/index-*.css 2>/dev/null')
# 上传新文件
dist = 'E:/AI/claude/new/mokeeText/frontend/static/dist'
sftp = c.open_sftp()
sftp.put(f'{dist}/index.html', '/data/mokee/mokeetext/frontend/static/dist/index.html')
for f in os.listdir(f'{dist}/assets'):
sftp.put(f'{dist}/assets/{f}', f'/data/mokee/mokeetext/frontend/static/dist/assets/{f}')
print(f'{f}')
sftp.close()
# 重建容器
_, stdout, stderr = c.exec_command('cd /data/mokee/mokeetext && docker compose up -d --build frontend 2>&1')
out = stdout.read().decode()
err = stderr.read().decode()
print(out)
if err:
print(err)
c.close()
print('部署完成')

30
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: "3.8"
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "4006:4006"
environment:
- MYSQL_HOST=${MYSQL_HOST:-10.20.1.122}
- MYSQL_PORT=${MYSQL_PORT:-3306}
- MYSQL_USER=${MYSQL_USER:-mokeetext}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-mokee.}
- MYSQL_DATABASE=${MYSQL_DATABASE:-mokeetext}
- LOG_DIR=/data/mokee/mokeetext/logs
volumes:
# 日志挂到宿主机
- /data/mokee/mokeetext/logs:/data/mokee/mokeetext/logs
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.frontend
ports:
- "3006:3006"
depends_on:
- backend
restart: unless-stopped

310
docs/DEPLOY.md Normal file
View File

@@ -0,0 +1,310 @@
# MoKeeText 部署文档
## 部署架构
```
Internet
┌───────┴───────┐
│ 反向代理/Nginx │ (运维侧管理SSL 终端)
└───────┬───────┘
┌─────────────┼─────────────┐
│ │ │
:3006 (前端) :4006 (后端) :3306 (MySQL)
│ │ │
┌─────────┴──┐ ┌──────┴──────┐ ┌──┴──────────┐
│ Nginx │ │ FastAPI │ │ MySQL 8.0 │
│ (Alpine) │ │ (Python3.12)│ │ (远程) │
│ │ │ │ │ │
│ 静态文件 │ │ API + ORM │ │ 10.20.1.122 │
└────────────┘ └─────────────┘ └──────────────┘
容器: frontend 容器: backend 外部服务
```
**两个容器**
- **frontend** — Nginx Alpine挂载 Vue 构建产物,端口 3006
- **backend** — Python 3.12-slimUvicorn 运行 FastAPI端口 4006
**跨域通信**:前端直接跨域请求后端 APINginx **不代理** `/api/`。CORS 在后端 FastAPI 中配置。
---
## 环境信息
| 项目 | 值 |
|------|-----|
| 服务器 IP | `101.132.183.138` |
| SSH 用户 | `root` |
| SSH 认证 | 密钥(~/.ssh/id_rsa |
| 项目路径 | `/data/mokee/mokeetext` |
| 前端域名 | `https://mokeetext.zgitm.com` → 端口 `3006` |
| 后端域名 | `https://mokeetext.server.zgitm.com` → 端口 `4006` |
| 数据库 | MySQL `10.20.1.122:3306` / 库 `mokeetext` |
| 数据库用户 | `mokeetext` |
---
## 服务器目录结构
```
/data/mokee/mokeetext/
├── docker-compose.yml # 容器编排(由 deploy.py 上传)
├── backend/
│ ├── Dockerfile
│ ├── app.py
│ ├── models.py
│ ├── config.py
│ ├── logger.py
│ ├── init_db.py
│ ├── requirements.txt
│ ├── routes/
│ │ ├── notes.py
│ │ ├── websites.py
│ │ ├── import_api.py
│ │ ├── upload.py
│ │ └── share.py
│ └── middleware/
│ └── user_context.py
├── frontend/
│ ├── Dockerfile.frontend
│ ├── nginx.conf
│ └── static/
│ └── dist/ # Vue 构建产物Nginx 托管)
└── logs/ # 应用日志(挂载到宿主机)
├── mokeetext.log # 按天轮转
└── current.log
```
---
## 日常部署流程
### 前置条件
1. SSH 密钥已配置到服务器
2. 本地已安装 Python 3.12 + paramiko
### 快速部署(仅前端改动)
大多数改动只需要重新构建前端并重建 frontend 容器:
```bash
# 1. 构建前端
cd frontend
npx vite build
# 2. 部署
cd ..
python deploy/deploy.py
```
`deploy.py` 会自动:
1. SSH 连接到服务器
2. 清理旧的 JS/CSS 文件
3. SFTP 上传新的 `static/dist/` 文件
4. 执行 `docker compose up -d --build frontend`
### 后端改动部署
如果修改了后端代码:
```bash
# 1. 上传后端文件
scp backend/*.py root@101.132.183.138:/data/mokee/mokeetext/backend/
scp backend/routes/*.py root@101.132.183.138:/data/mokee/mokeetext/backend/routes/
scp backend/middleware/*.py root@101.132.183.138:/data/mokee/mokeetext/backend/middleware/
# 2. 重建后端容器
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose up -d --build backend'
```
### Docker Compose 配置变更
如果修改了 `docker-compose.yml`
```bash
scp deploy/docker-compose.yml root@101.132.183.138:/data/mokee/mokeetext/docker-compose.yml
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose up -d --build'
```
---
## 首次部署(从零搭建)
### 1. 服务器准备
```bash
# SSH 到服务器
ssh root@101.132.183.138
# 安装 Docker如果尚未安装
curl -fsSL https://get.docker.com | sh
# 创建项目目录
mkdir -p /data/mokee/mokeetext/{backend/routes,backend/middleware,frontend/static/dist,logs}
```
### 2. 上传文件
```bash
# 在本地上传所有文件
scp deploy/docker-compose.yml root@101.132.183.138:/data/mokee/mokeetext/
scp backend/*.py backend/*.txt backend/Dockerfile backend/.dockerignore \
root@101.132.183.138:/data/mokee/mokeetext/backend/
scp backend/routes/*.py root@101.132.183.138:/data/mokee/mokeetext/backend/routes/
scp backend/middleware/*.py root@101.132.183.138:/data/mokee/mokeetext/backend/middleware/
scp frontend/Dockerfile.frontend frontend/nginx.conf frontend/.dockerignore \
root@101.132.183.138:/data/mokee/mokeetext/frontend/
# 构建前端并上传
cd frontend && npx vite build && cd ..
scp -r frontend/static/dist/* root@101.132.183.138:/data/mokee/mokeetext/frontend/static/dist/
```
### 3. 启动服务
```bash
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose up -d --build'
```
### 4. 验证
```bash
# 检查容器状态
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose ps'
# 检查后端健康
curl https://mokeetext.server.zgitm.com/api/notes
# 检查前端
curl https://mokeetext.zgitm.com
```
---
## 常用运维命令
### 容器管理
```bash
# 查看运行状态
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose ps'
# 查看日志
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose logs --tail=50 backend'
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose logs --tail=50 frontend'
# 重启单个服务
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose restart backend'
# 停止所有服务
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose down'
# 完全重建(清除缓存)
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose build --no-cache && docker compose up -d'
```
### 日志查看
```bash
# 应用日志(按天拆分)
ssh root@101.132.183.138 'ls -la /data/mokee/mokeetext/logs/'
ssh root@101.132.183.138 'tail -100 /data/mokee/mokeetext/logs/current.log'
# 实时跟踪
ssh root@101.132.183.138 'tail -f /data/mokee/mokeetext/logs/current.log'
```
### 数据库操作
```bash
# 进入 MySQL
ssh root@101.132.183.138 'docker exec -it mokeetext-backend-1 python -c "
import pymysql
conn = pymysql.connect(host=\"10.20.1.122\", user=\"mokeetext\", password=\"mokee.\", database=\"mokeetext\")
print(\"connected\")
conn.close()
"'
```
---
## 环境变量
### Backend 容器
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `MYSQL_HOST` | `10.20.1.122` | 数据库主机 |
| `MYSQL_PORT` | `3306` | 数据库端口 |
| `MYSQL_USER` | `mokeetext` | 数据库用户 |
| `MYSQL_PASSWORD` | `mokee.` | 数据库密码 |
| `MYSQL_DATABASE` | `mokeetext` | 数据库名 |
| `LOG_DIR` | `/data/mokee/mokeetext/logs` | 日志目录 |
这些在 `docker-compose.yml` 中定义,可通过 `.env` 文件覆盖。
### Frontend 构建
| 变量 | 说明 |
|------|------|
| `VITE_API_BASE` | 生产环境 API 地址 |
| `VITE_LOGIN_URL` | 统一登录页地址 |
| `VITE_SYSTEM_CODE` | 系统编码 |
定义在 `frontend/.env.production` 中。
---
## 回滚
如果部署出现问题:
```bash
# 1. 回到上一个前端镜像
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose up -d frontend'
# Docker 会使用缓存的镜像层
# 2. 如果有备份的 dist 文件
scp backup-dist/* root@101.132.183.138:/data/mokee/mokeetext/frontend/static/dist/
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose restart frontend'
# 3. 极端情况:重建所有容器
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose down && docker compose up -d --build'
```
---
## 故障排查
### 前端页面空白
1. 检查 Nginx 容器是否运行:`docker compose ps frontend`
2. 检查 dist 文件是否存在:`ls /data/mokee/mokeetext/frontend/static/dist/`
3. 检查 Nginx 日志:`docker compose logs frontend`
### API 返回 500
1. 检查后端容器日志:`docker compose logs backend --tail=100`
2. 检查数据库连接:确认 MySQL 可访问
3. 检查应用日志:`tail -100 /data/mokee/mokeetext/logs/current.log`
### CORS 错误
1. 确认前端请求的 API 地址正确
2. 确认 `app.py``allow_origins` 包含当前域名
3. 确认请求未经过 Nginx 代理(前端直接调后端)
### 容器无法启动
```bash
# 查看构建日志
ssh root@101.132.183.138 'cd /data/mokee/mokeetext && docker compose build --progress=plain'
# 检查端口占用
ssh root@101.132.183.138 'netstat -tlnp | grep -E "3006|4006"'
```

345
docs/README.md Normal file
View File

@@ -0,0 +1,345 @@
# MoKeeText 项目文档
## 概述
**MoKeeText** 是一个 Web 端富文本笔记管理应用,同时具备网站账号密码管理功能,集成 Mokee Gateway 统一登录网关。
### 核心功能
| 模块 | 功能 |
|------|------|
| 📝 **笔记编辑** | 基于 Tiptap 的增强型富文本编辑器,支持文字颜色、图片粘贴/拖拽上传、表格、代码高亮、任务列表 |
| 🔐 **网站管理** | 按文件夹分类管理网站及其登录账号密码 |
| 🔗 **分享链接** | 笔记可生成分享链接,支持 7天/30天/1年/永久 四种有效期 |
| 📥 **智能导入** | 自动解析粘贴的网站账号信息,一键导入 |
| 🖼️ **图片上传** | 粘贴/拖拽图片自动上传,存数据库 LONGBLOB点击全屏预览 |
| 👤 **统一登录** | 接入 Mokee Gateway通过网关转发用户身份支持 API 级数据隔离 |
---
## 技术栈
| 层 | 技术 | 版本 |
|----|------|------|
| 后端框架 | FastAPI (Python) | ≥0.115 |
| 服务器 | Uvicorn | ≥0.30 |
| ORM | SQLAlchemy | ≥2.0 |
| 数据库 | MySQL | 8.0 |
| 前端框架 | Vue 3 + Vite | 3.5 / 8.x |
| 路由 | Vue Router | 4.6 |
| 编辑器 | Tiptap | 3.27 |
| 代码高亮 | highlight.js (lowlight) | - |
| HTTP 客户端 | Axios生产/ fetch本地开发 | 1.18 |
| 容器化 | Docker + Docker Compose | - |
| Web 服务器 | Nginx (Alpine) | - |
---
## 项目结构
```
mokeeText/
├── backend/ # 后端 — FastAPI 应用
│ ├── app.py # 入口CORS、中间件、路由注册
│ ├── models.py # SQLAlchemy 数据模型6 张表)
│ ├── config.py # 数据库连接配置(环境变量)
│ ├── logger.py # 日志系统(按天轮转,保留 30 天)
│ ├── init_db.py # 建表脚本
│ ├── requirements.txt # Python 依赖
│ ├── Dockerfile # 后端 Docker 镜像
│ ├── .dockerignore
│ ├── routes/ # API 路由
│ │ ├── notes.py # 笔记 CRUD + 分享 + 移动
│ │ ├── websites.py # 网站/账号/文件夹 CRUD + 排序
│ │ ├── import_api.py # 智能导入解析
│ │ ├── upload.py # 图片上传/读取
│ │ └── share.py # 公开分享查看页
│ └── middleware/
│ └── user_context.py # 网关用户上下文中间件
├── frontend/ # 前端 — Vue 3 SPA
│ ├── index.html # Vite 入口 HTML
│ ├── package.json # 前端依赖
│ ├── vite.config.js # Vite 构建配置
│ ├── nginx.conf # Nginx 配置
│ ├── Dockerfile.frontend # 前端 Nginx Docker 镜像
│ ├── .dockerignore
│ ├── .env / .env.production # 环境变量
│ ├── src/
│ │ ├── main.js # Vue 入口 → 挂载 Router + v-permission
│ │ ├── App.vue # 根组件:左导航 + 顶栏 + UserChip + 底栏
│ │ ├── router.js # Vue Router 配置 + 路由守卫
│ │ ├── utils/request.js # Axios 实例 + 拦截器
│ │ ├── composables/useApi.js # 双模式 APIdev fetch / prod axios
│ │ ├── directives/permission.js # v-permission 按钮权限指令
│ │ ├── views/
│ │ │ ├── NotesView.vue # 笔记:文件夹侧边栏 + 列表 + 编辑器
│ │ │ ├── WebsitesView.vue # 网站管理:列表 + CRUD 弹窗
│ │ │ └── SharesView.vue # 分享管理
│ │ └── components/
│ │ ├── NoteEditor.vue # Tiptap 增强编辑器
│ │ ├── Modal.vue # 通用弹窗组件
│ │ ├── ConfirmModal.vue
│ │ └── PromptModal.vue
│ └── static/
│ ├── dist/ # Vite 构建产物Nginx 托管)
│ ├── js/vendor/ # Tiptap UMD 扩展文件
│ ├── css/
│ └── uploads/
├── deploy/ # 部署
│ ├── docker-compose.yml # 双容器编排
│ ├── deploy.py # 日常部署脚本
│ └── migrate_server.py # 服务器迁移脚本(一次性)
├── docs/ # 文档
│ ├── README.md # 本文档
│ ├── DEPLOY.md # 部署文档
│ └── superpowers/ # 设计规范 + 实施计划
└── memory/ # 会话记忆
```
---
## 数据库设计
### ER 图
```
folders ──┬── notes ──┬── note_tags ── tags
└── websites ── accounts
uploads独立图片存 DB
```
### 表结构
#### folders — 文件夹(笔记和网站共用)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 自增主键 |
| name | VARCHAR(100) | 文件夹名称 |
| type | ENUM('note','website') | 类型 |
| parent_id | INT FK→folders.id | 父文件夹(树形结构) |
| sort_order | INT | 排序 |
| created_by | VARCHAR(100) | 创建者账号 |
| created_at | DATETIME | 创建时间 |
#### notes — 笔记
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 自增主键 |
| title | VARCHAR(500) | 标题 |
| content | TEXT | Tiptap 输出的 HTML |
| folder_id | INT FK→folders.id | 所属文件夹 |
| created_by | VARCHAR(100) | 创建者 |
| updated_by | VARCHAR(100) | 最后修改者 |
| share_token | VARCHAR(64) UNIQUE | 分享令牌 |
| share_expires_at | DATETIME | 分享过期时间 |
| created_at / updated_at | DATETIME | 时间戳 |
#### tags — 标签
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 自增主键 |
| name | VARCHAR(50) UNIQUE | 标签名 |
| color | VARCHAR(20) | 颜色 |
#### note_tags — 笔记-标签关联(多对多)
| 字段 | 类型 | 说明 |
|------|------|------|
| note_id | INT FK | 笔记 ID |
| tag_id | INT FK | 标签 ID |
#### websites — 网站
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 自增主键 |
| name | VARCHAR(200) | 网站名称 |
| url | VARCHAR(500) | 网址 |
| folder_id | INT FK | 所属文件夹 |
| created_by / updated_by | VARCHAR(100) | 操作者 |
| created_at / updated_at | DATETIME | 时间戳 |
#### accounts — 账号
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 自增主键 |
| website_id | INT FK | 所属网站 |
| username | VARCHAR(500) | 用户名 |
| password | VARCHAR(500) | 密码(明文,待加密) |
| remark | TEXT | 备注 |
| sort_order | INT | 排序 |
| created_by / updated_by | VARCHAR(100) | 操作者 |
#### uploads — 上传文件
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 自增主键 |
| filename | VARCHAR(255) | 原始文件名 |
| content_type | VARCHAR(100) | MIME 类型 |
| data | LONGBLOB | 二进制数据(最大 4GB |
| size | INT | 文件大小(字节) |
| created_by | VARCHAR(100) | 上传者 |
| created_at | DATETIME | 上传时间 |
---
## API 接口
所有接口均以 `/api/` 为前缀。除分享页和图片读取外,所有操作需要网关注入用户身份。
### 笔记
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/notes` | 获取笔记列表(支持 `?folder_id=` 筛选) |
| POST | `/api/notes` | 创建笔记 |
| GET | `/api/notes/{id}` | 获取笔记详情 |
| PUT | `/api/notes/{id}` | 修改笔记 |
| DELETE | `/api/notes/{id}` | 删除笔记 |
| PUT | `/api/notes/{id}/move` | 移动笔记到文件夹 |
| POST | `/api/notes/{id}/share` | 生成分享链接 |
### 文件夹
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/folders` | 获取文件夹列表(支持 `?type=note\|website` |
| POST | `/api/folders` | 创建文件夹 |
| PUT | `/api/folders/{id}` | 重命名文件夹 |
| DELETE | `/api/folders/{id}` | 删除文件夹 |
### 网站与账号
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/websites` | 获取网站列表(支持 `?folder_id=` |
| POST | `/api/websites` | 创建网站 |
| PUT | `/api/websites/{id}` | 修改网站 |
| DELETE | `/api/websites/{id}` | 删除网站 |
| GET | `/api/websites/{id}/accounts` | 获取账号列表 |
| POST | `/api/websites/{id}/accounts` | 创建账号 |
| PUT | `/api/accounts/{id}` | 修改账号 |
| DELETE | `/api/accounts/{id}` | 删除账号 |
| PUT | `/api/accounts/sort` | 账号排序 |
### 文件
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/upload/image` | 上传图片(网关鉴权) |
| GET | `/api/file/{id}` | 读取图片(公开,无需鉴权) |
### 分享
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/share/{token}` | 公开查看分享的笔记(不走网关) |
| GET | `/api/shares` | 获取我的分享列表 |
| DELETE | `/api/shares/{note_id}` | 取消分享 |
### 智能导入
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/import/parse` | 解析粘贴的文本,提取网站+账号信息 |
---
## 认证架构
```
┌─────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐
│ 浏览器 │ ──→ │ 统一登录页 │ ──→ │ Mokee │ ──→ │ FastAPI │
│ │ │ login.user. │ │ Gateway │ │ 后端 │
│ │ │ zgitm.com │ │ gw.server. │ │ :4006 │
│ │ │ │ │ zgitm.com │ │ │
└─────────┘ └──────────────┘ └──────┬───────┘ └──────────┘
注入 X-User-* 头
X-User-Id, X-User-Account,
X-User-Roles, X-System-Code ...
```
### 请求流程
1. **用户访问** → 前端路由守卫检查 Cookie 中是否有 `token`
2. **无 Token** → 重定向到 `login.user.zgitm.com` 统一登录
3. **登录成功** → 网关设置 Cookie用户回到应用
4. **API 请求** → Axios 拦截器从 Cookie 取 Token 放入 Authorization 头
5. **网关转发** → 验证 Token 后注入 `X-User-*` 系列请求头
6. **后端中间件**`UserContextMiddleware` 提取请求头存入 `request.state.user`
7. **业务路由** → 通过 `UserContext.get_user_id()` 获取当前用户,按 `created_by` 过滤数据
### 本地开发模式
设置环境变量 `DEV_MODE=true` 或添加请求头 `X-Dev-Mock-User`,后端绕过网关认证,使用默认 admin 用户。
```bash
# 启动后端
cd backend
DEV_MODE=true uvicorn app:app --port 4006
# 启动前端(另一个终端)
cd frontend
npx vite
```
---
## 前端架构
### 路由
| 路径 | 组件 | 说明 |
|------|------|------|
| `/` | → 重定向 | 默认到 /notes |
| `/notes` | NotesView | 笔记编辑器(文件夹 + 列表 + 编辑器) |
| `/websites` | WebsitesView | 网站账号管理 |
| `/shares` | SharesView | 分享链接管理 |
### API 调用模式
前端通过 `useApi()` 组合式函数发起请求,自动适配环境:
- **生产环境**:使用 Axios 实例,拦截器自动携带 Token → 请求走网关
- **本地开发**:使用 fetch添加 `X-Dev-Mock-User` 头模拟用户
```js
// composables/useApi.js
import { useApi } from '@/composables/useApi'
const { get, post, put, del } = useApi()
const notes = await get('/api/notes')
```
### 图片处理
- **上传**:粘贴/拖拽 → `POST /api/upload/image`(走网关,带 Token
- **显示**`<img src="https://mokeetext.server.zgitm.com/api/file/{id}">`(走后端直连,公开读取)
- **预览**:点击图片 → 全屏灯箱,带关闭按钮
### UI 特性
- 左侧可收起导航栏(展开 "mokee编辑器",收起 "M"
- 文件夹侧边栏可收起
- 笔记列表 + 编辑器双栏布局
- Ctrl+S 保存,顶部 toast 提示
- 弹窗全部为 Vue 组件(非浏览器 alert/confirm
- 移动端响应式适配768px 断点)
- 顶栏右侧「切换系统」按钮 → 跳转网关系统选择页
---
## 生产环境
| 项目 | 值 |
|------|-----|
| 服务器 | 101.132.183.138 (腾讯云) |
| 前端地址 | https://mokeetext.zgitm.com |
| 后端地址 | https://mokeetext.server.zgitm.com |
| 数据库 | MySQL 10.20.1.122:3306/mokeetext |
| 日志路径 | /data/mokee/mokeetext/logs按天轮转保留 30 天) |
| ICP 备案 | 湘ICP备19021539号 |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
# MoKeeText Web 记事本 — 设计文档
**日期**: 2026-06-23
**状态**: 已确认
---
## 项目概述
一个 Web 记事本工具,包含两个核心模块:
1. **Markdown 笔记** — 增强型编辑器,类似飞书文档体验
2. **网站账号管理** — 网站密码本,智能导入功能
数据存储到 MySQL。
---
## 技术选型
| 层级 | 技术 | 理由 |
|------|------|------|
| 后端框架 | Python FastAPI | 高性能异步Python 操作 MySQL 成熟 |
| 模板引擎 | Jinja2 | FastAPI 内置支持,服务端渲染 |
| ORM | SQLAlchemy | 最成熟的 Python ORMMySQL 支持好 |
| 数据库 | MySQL | 用户指定 |
| 编辑器 | Tiptap | 增强型富文本编辑器,支持颜色/图片/表情/表格 |
| 前端交互 | 原生 JS + CDN 库 | 无构建步骤,快速迭代 |
| 样式 | 手写 CSS飞书风格| 简洁现代,大量留白 |
---
## 数据库设计
### 文件夹表 (folders)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 主键 |
| name | VARCHAR(100) | 文件夹名 |
| type | ENUM('note','website') | 文件夹类型 |
| parent_id | INT FK | 父文件夹NULL为根 |
| sort_order | INT | 排序 |
| created_at | DATETIME | 创建时间 |
### 笔记表 (notes)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 主键 |
| title | VARCHAR(500) | 标题 |
| content | LONGTEXT | Tiptap JSON/HTML |
| folder_id | INT FK | 所属文件夹 |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 更新时间 |
### 标签表 (tags)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 主键 |
| name | VARCHAR(50) | 标签名 |
| color | VARCHAR(20) | 颜色 |
### 笔记-标签关联 (note_tags)
| 字段 | 类型 | 说明 |
|------|------|------|
| note_id | INT FK | 笔记ID |
| tag_id | INT FK | 标签ID |
### 网站表 (websites)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 主键 |
| name | VARCHAR(200) | 网站名 |
| url | VARCHAR(500) | 网址 |
| folder_id | INT FK | 所属文件夹 |
| created_at | DATETIME | 创建时间 |
### 账号表 (accounts)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK | 主键 |
| website_id | INT FK | 所属网站 |
| username | VARCHAR(500) | 账号 |
| password | VARCHAR(500) | 密码(明文,后续加密) |
| remark | TEXT | 登录说明/备注 |
| created_at | DATETIME | 创建时间 |
---
## 页面路由
| 路由 | 页面 |
|------|------|
| `/` | 重定向到 /notes |
| `/notes` | 笔记主页 |
| `/websites` | 网站管理主页 |
| `/api/notes/*` | 笔记 CRUD API |
| `/api/websites/*` | 网站 CRUD API |
| `/api/accounts/*` | 账号 CRUD API |
| `/api/folders/*` | 文件夹 CRUD API |
| `/api/tags/*` | 标签 CRUD API |
| `/api/import/parse` | 智能导入解析 |
| `/api/import/save` | 智能导入保存 |
---
## 智能导入解析逻辑
输入格式:
```
https://github.com 代码网站
zhangsan_new@qq.com
Xyz789!@#
新注册的开发账号
```
解析规则:
- 第1行按第一个空格分割 → 前半为URL后半为网站名
- 第2行账号
- 第3行密码
- 第4行备注可选
- 根据URL匹配已有网站 → 存在则返回匹配信息,不存在则标记新建
---
## UI 布局
```
┌──────────────────────────────────────────────┐
│ 📝 MoKeeText [笔记] [网站管理] 个人版 │
├────────────┬─────────────────────────────────┤
│ 搜索... │ 标题 编辑/预览/分屏 标签│
│ + 新建 │ ───────────────────────────── │
│ │ │
│ 📁 文件夹 │ Tiptap 增强编辑器 │
│ ├ 工作 │ (支持颜色/图片/表情/表格) │
│ ├ 个人 │ │
│ └ 学习 │ │
│ │ │
│ 🏷️ 标签 │ │
│ python │ │
│ mysql │ │
└────────────┴─────────────────────────────────┘
```
---
## 项目结构
```
mokeeText/
├── app.py # FastAPI 入口
├── config.py # 数据库配置
├── models.py # SQLAlchemy 模型
├── routes/
│ ├── notes.py # 笔记相关路由
│ ├── websites.py # 网站相关路由
│ └── import_api.py # 智能导入 API
├── templates/
│ ├── base.html # 基础布局
│ ├── notes.html # 笔记页
│ └── websites.html # 网站管理页
├── static/
│ ├── css/
│ │ └── style.css # 飞书风格样式
│ └── js/
│ ├── editor.js # Tiptap 初始化
│ ├── notes.js # 笔记交互
│ └── websites.js # 网站管理交互
├── requirements.txt
└── docs/
└── superpowers/
└── specs/
└── 2026-06-23-mokeetext-design.md
```

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
src

4
frontend/.env Normal file
View File

@@ -0,0 +1,4 @@
# 本地开发环境变量
VITE_GATEWAY_URL = http://localhost:4006
VITE_LOGIN_URL = https://login.user.zgitm.com
VITE_SYSTEM_CODE = mokeetext-edit

4
frontend/.env.production Normal file
View File

@@ -0,0 +1,4 @@
# 生产环境变量
VITE_GATEWAY_URL = https://gw.server.zgitm.com
VITE_LOGIN_URL = https://login.user.zgitm.com
VITE_SYSTEM_CODE = mokeetext-edit

View File

@@ -0,0 +1,9 @@
# ===== 前端 Nginx =====
FROM nginx:alpine
# 先构建好 Vue 再打镜像
COPY static/dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3006

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoKeeText</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

11
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 3006;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

2835
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "mokeetext",
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@tiptap/core": "^3.27.1",
"@tiptap/extension-code-block-lowlight": "^3.27.1",
"@tiptap/extension-color": "^3.27.1",
"@tiptap/extension-highlight": "^3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-table": "^3.27.1",
"@tiptap/extension-table-cell": "^3.27.1",
"@tiptap/extension-table-header": "^3.27.1",
"@tiptap/extension-table-row": "^3.27.1",
"@tiptap/extension-task-item": "^3.27.1",
"@tiptap/extension-task-list": "^3.27.1",
"@tiptap/extension-text-style": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"axios": "^1.18.1"
},
"devDependencies": {
"@tiptap/vue-3": "^3.27.1",
"@vitejs/plugin-vue": "^6.0.7",
"esbuild": "^0.28.1",
"vite": "^8.0.16",
"vue": "^3.5.38",
"vue-router": "^4.6.4"
}
}

127
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,127 @@
<template>
<div class="app-shell">
<!-- Hamburger -->
<button class="hamburger" @click="navOpen=!navOpen"></button>
<!-- Nav overlay (mobile) -->
<div class="nav-overlay" v-if="navOpen" @click="navOpen=false"></div>
<!-- Left nav -->
<aside class="app-nav" :class="{ collapsed: navCollapsed, open: navOpen }">
<div class="nav-brand" @click="$router.push('/')">
<span v-if="navCollapsed">M</span>
<span v-else>mokee编辑器</span>
</div>
<nav class="nav-menu">
<router-link to="/notes" class="nav-item" title="笔记">
<span class="nav-icon">📝</span>
<span class="nav-label">笔记</span>
</router-link>
<router-link to="/websites" class="nav-item" title="网站管理">
<span class="nav-icon">🔐</span>
<span class="nav-label">网站管理</span>
</router-link>
<router-link to="/shares" class="nav-item" title="分享管理">
<span class="nav-icon">🔗</span>
<span class="nav-label">分享管理</span>
</router-link>
</nav>
<div class="nav-bottom">
<div class="nav-item" @click="navCollapsed=!navCollapsed" title="收起菜单">
<span class="nav-icon">{{ navCollapsed ? '▶' : '◀' }}</span>
</div>
</div>
</aside>
<!-- Main area -->
<div class="app-main">
<!-- Top bar -->
<header class="topbar">
<div class="header-left">
<button class="toggle-sidebar-btn" @click="navCollapsed=!navCollapsed" :title="navCollapsed ? '展开侧边栏' : '收起侧边栏'">
{{ navCollapsed ? '☰' : '✕' }}
</button>
<span class="header-title">MoKeeText</span>
</div>
<div style="flex:1;"></div>
<a class="switch-system-btn" href="https://web.gw.zgitm.com/select" title="切换系统">
<span class="switch-icon">🔄</span>
<span class="switch-label">切换系统</span>
</a>
</header>
<router-view />
<footer class="app-footer">
<span>湘ICP备19021539号</span>
<span>MoKeeText v1.0</span>
</footer>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const navCollapsed = ref(false)
const navOpen = ref(false)
// 路由切换时关闭移动端菜单
watch(() => router.currentRoute.value, () => { navOpen.value = false })
</script>
<style scoped>
.app-shell { display:flex; height:100vh; }
.app-nav {
width:200px; min-width:200px;
background:var(--bg-sidebar); border-right:1px solid var(--border-light);
display:flex; flex-direction:column; transition:all 0.2s; overflow:hidden;
}
.app-nav.collapsed { width:56px; min-width:56px; }
.nav-brand {
height:48px; display:flex; align-items:center; justify-content:center;
font-weight:800; font-size:18px; color:var(--brand); cursor:pointer;
border-bottom:1px solid var(--border-light); flex-shrink:0;
}
.nav-menu { flex:1; padding:8px; display:flex; flex-direction:column; gap:2px; }
.nav-item {
display:flex; align-items:center; gap:10px; height:38px; padding:0 10px;
border-radius:var(--r-sm); text-decoration:none; color:var(--text-secondary);
font-size:13px; font-weight:500; transition:all .12s; cursor:pointer; white-space:nowrap;
}
.nav-item:hover { background:var(--bg-hover); color:var(--text-primary); }
.nav-item.active, .nav-item.router-link-exact-active { background:var(--brand-light); color:var(--brand); position:relative; }
.nav-item.active::before, .nav-item.router-link-exact-active::before { content:''; position:absolute; left:0; top:8px; bottom:8px; width:3px; background:var(--brand); border-radius:0 2px 2px 0; }
.nav-icon { font-size:15px; flex-shrink:0; width:22px; text-align:center; opacity:.7; }
.nav-item.active .nav-icon, .nav-item.router-link-exact-active .nav-icon { opacity:1; }
.nav-label { overflow:hidden; }
.collapsed .nav-label { display:none; }
.nav-bottom {
padding:8px; border-top:1px solid var(--border-light);
display:flex; flex-direction:column; gap:4px;
}
.app-main { flex:1; display:flex; flex-direction:column; min-width:0; overflow:visible; }
.topbar { display:flex; align-items:center; height:44px; padding:0 16px; background:var(--bg-surface); border-bottom:1px solid var(--border-light); flex-shrink:0; }
.header-left { display:flex; align-items:center; gap:8px; }
.header-title { font-size:15px; color:var(--text-primary); font-weight:500; }
.toggle-sidebar-btn { width:32px; height:32px; border:none; background:transparent; border-radius:var(--r-sm); font-size:16px; cursor:pointer; color:var(--text-secondary); display:flex; align-items:center; justify-content:center; transition:all .15s; }
.toggle-sidebar-btn:hover { background:var(--bg-hover); color:var(--text-primary); }
.topbar-right { display:flex; align-items:center; flex-shrink:0; }
#mokee-user-chip { flex-shrink: 0; }
.switch-system-btn {
display:flex; align-items:center; gap:4px; height:28px; padding:0 10px;
border-radius:var(--r-sm); text-decoration:none; font-size:12px; font-weight:500;
color:var(--text-secondary); background:transparent; transition:all .15s;
flex-shrink:0;
}
.switch-system-btn:hover { background:var(--bg-hover); color:var(--brand); }
.switch-icon { font-size:13px; }
.switch-label { white-space:nowrap; }
.app-footer { display:flex; align-items:center; justify-content:space-between; height:28px; padding:0 12px; font-size:10px; color:var(--text-tertiary); border-top:1px solid var(--border-light); flex-shrink:0; }
</style>

View File

@@ -0,0 +1,314 @@
/* ===== MoKeeText Design System ===== */
:root {
--bg-page: #F5F6F8;
--bg-sidebar: #FCFCFD;
--bg-surface: #FFFFFF;
--bg-hover: #F2F3F5;
--bg-active: #EEF3FF;
--text-primary: #1F2329;
--text-secondary: #646A73;
--text-tertiary: #8F959E;
--text-placeholder: #BCC0C6;
--border-light: #E5E6EB;
--border-medium: #DEE0E3;
--brand: #3370FF;
--brand-hover: #245BDB;
--brand-light: #EEF3FF;
--danger: #F54A45;
--danger-light: #FFF1F0;
--success: #34C759;
--r-sm: 6px; --r-md: 8px; --r-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0,0,0,.04);
--shadow-md: 0 4px 12px rgba(0,0,0,.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,.12);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px; line-height: 1.5; color: var(--text-primary); background: var(--bg-page);
height: 100vh; overflow: hidden; -webkit-font-smoothing: antialiased;
}
#app { height: 100vh; display: flex; flex-direction: column; }
/* Scrollbar */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #D0D3D7; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #B0B3B7; }
/* ===== Topnav ===== */
.topnav { display:flex; align-items:center; height:48px; padding:0 20px; background:var(--bg-surface); border-bottom:1px solid var(--border-light); flex-shrink:0; }
.topnav-brand { font-weight:700; font-size:15px; color:var(--text-primary); text-decoration:none; margin-right:24px; }
.topnav-link { padding:13px 16px; font-size:13px; font-weight:500; color:var(--text-secondary); text-decoration:none; border-bottom:2px solid transparent; transition:color .15s; }
.topnav-link:hover { color:var(--brand); }
.topnav-link.active, .topnav-link.router-link-exact-active { color:var(--brand); border-bottom-color:var(--brand); }
.user-widget { margin-left:auto; display:flex; align-items:center; gap:8px; padding:4px 10px 4px 4px; border-radius:20px; cursor:pointer; transition:background .15s; user-select:none; }
.user-widget:hover { background:var(--bg-hover); }
.user-avatar { width:28px; height:28px; border-radius:50%; background:var(--brand); color:#fff; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; flex-shrink:0; }
.user-name { font-size:13px; font-weight:500; }
.footer { display:flex; align-items:center; justify-content:space-between; height:32px; padding:0 20px; background:var(--bg-surface); border-top:1px solid var(--border-light); font-size:11px; color:var(--text-tertiary); flex-shrink:0; }
/* ===== Layout ===== */
.layout { display:flex; flex:1; min-height:0; }
.sidebar { width:260px; min-width:260px; background:var(--bg-sidebar); border-right:1px solid var(--border-light); display:flex; flex-direction:column; padding:12px; gap:8px; overflow:hidden; transition:all .2s; }
.sidebar.collapsed { width:40px; min-width:40px; padding:8px 4px; }
.content { flex:1; display:flex; flex-direction:column; background:var(--bg-surface); min-width:0; }
/* ===== Buttons ===== */
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:36px; padding:0 16px; border:none; border-radius:var(--r-sm); font-size:13px; font-weight:500; cursor:pointer; transition:all .15s; font-family:inherit; white-space:nowrap; }
.btn:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
.btn-primary { background:var(--brand); color:#fff; }
.btn-primary:hover { background:var(--brand-hover); }
.btn-outline { background:var(--bg-surface); color:var(--brand); border:1px solid var(--brand); }
.btn-outline:hover { background:var(--brand-light); }
.btn-ghost { background:transparent; color:var(--text-secondary); }
.btn-ghost:hover { background:var(--bg-hover); color:var(--text-primary); }
.btn-danger-ghost { background:transparent; color:var(--text-tertiary); }
.btn-danger-ghost:hover { background:var(--danger-light); color:var(--danger); }
.btn-block { width:100%; }
.btn-sm { height:30px; font-size:12px; padding:0 12px; }
.btn-xs { height:24px; font-size:11px; padding:0 8px; }
/* ===== Input / Select / Textarea ===== */
.input, .select { width:100%; height:36px; padding:0 12px; border:1px solid var(--border-medium); border-radius:var(--r-sm); font-size:13px; color:var(--text-primary); background:var(--bg-surface); outline:none; transition:border .15s; font-family:inherit; }
.input:focus, .select:focus { border-color:var(--brand); box-shadow:0 0 0 2px var(--brand-light); }
.input::placeholder { color:var(--text-placeholder); }
.textarea { width:100%; padding:8px 12px; border:1px solid var(--border-medium); border-radius:var(--r-sm); font-size:13px; color:var(--text-primary); background:var(--bg-surface); outline:none; resize:vertical; font-family:inherit; transition:border .15s; min-height:60px; }
.textarea:focus { border-color:var(--brand); box-shadow:0 0 0 2px var(--brand-light); }
/* ===== Sidebar Items ===== */
.sidebar-label { font-size:10px; font-weight:700; color:var(--text-tertiary); letter-spacing:.06em; text-transform:uppercase; padding:8px 8px 4px 4px; margin-top:8px; display:flex; align-items:center; justify-content:space-between; }
.sidebar-label .add-btn { cursor:pointer; color:var(--text-tertiary); font-size:16px; line-height:1; width:22px; height:22px; display:flex; align-items:center; justify-content:center; border-radius:4px; transition:all .15s; }
.sidebar-label .add-btn:hover { background:var(--bg-hover); color:var(--brand); }
.folder-item { display:flex; align-items:center; gap:8px; height:34px; padding:0 10px; border-radius:var(--r-sm); cursor:pointer; font-size:13px; color:var(--text-secondary); transition:all .12s; user-select:none; position:relative; }
.folder-item:hover { background:var(--bg-hover); color:var(--text-primary); }
.folder-item.active { background:var(--brand-light); color:var(--brand); }
.folder-item .count { margin-left:auto; font-size:10px; font-weight:500; color:var(--text-tertiary); background:var(--bg-hover); padding:1px 6px; border-radius:8px; min-width:20px; text-align:center; }
.folder-item.active .count { background:rgba(51,112,255,.12); color:var(--brand); }
.folder-dot { width:6px; height:6px; border-radius:50%; background:var(--text-tertiary); flex-shrink:0; opacity:.5; }
.folder-item.active .folder-dot { background:var(--brand); opacity:1; }
.folder-more { opacity:0; padding:2px 6px; font-size:16px; font-weight:700; color:var(--text-tertiary); border-radius:4px; cursor:pointer; line-height:1; }
.folder-item:hover .folder-more { opacity:1; }
.folder-more:hover { background:var(--bg-hover); color:var(--text-primary); }
.site-item { display:flex; align-items:center; gap:8px; height:36px; padding:0 8px; border-radius:var(--r-sm); cursor:pointer; font-size:13px; transition:background .1s; user-select:none; }
.site-item:hover { background:var(--bg-hover); }
.site-item.active { background:var(--brand-light); color:var(--brand); font-weight:500; }
.site-item .site-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.site-item .site-count { font-size:10px; color:var(--text-tertiary); flex-shrink:0; }
.note-list-item { display:flex; align-items:center; gap:8px; height:34px; padding:0 8px; border-radius:var(--r-sm); cursor:pointer; font-size:12px; transition:background .1s; }
.note-list-item:hover { background:var(--bg-hover); }
.note-list-item.active { background:var(--brand-light); }
.note-list-item .note-title { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500; }
.note-list-item .note-date { font-size:10px; color:var(--text-tertiary); }
.folder-children { margin-left:16px; }
.folder-arrow { font-size:10px; color:var(--text-tertiary); width:12px; flex-shrink:0; }
/* ===== Card ===== */
.card { background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-lg); padding:16px; transition:box-shadow .15s; margin-bottom:12px; }
.card:hover { box-shadow:var(--shadow-sm); }
.card-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
.card-title { font-size:14px; font-weight:600; }
.card-body { display:grid; grid-template-columns:auto 1fr auto; gap:8px 12px; font-size:13px; align-items:center; }
.card-body .label { color:var(--text-tertiary); font-size:12px; white-space:nowrap; }
.card-btn { height:30px; padding:0 10px; border:1px solid var(--border-light); border-radius:var(--r-sm); background:var(--bg-surface); font-size:12px; cursor:pointer; color:var(--text-secondary); transition:all .15s; font-family:inherit; }
.card-btn:hover { background:var(--bg-hover); border-color:var(--border-medium); }
.card-btn.edit-btn:hover { color:var(--brand); border-color:var(--brand); background:var(--brand-light); }
.card-btn.danger-btn:hover { color:var(--danger); border-color:var(--danger); background:var(--danger-light); }
/* ===== Modal ===== */
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.35); display:flex; align-items:center; justify-content:center; z-index:100; animation:fi .15s ease; }
@keyframes fi { from{opacity:0} to{opacity:1} }
.modal { background:var(--bg-surface); border-radius:var(--r-lg); box-shadow:var(--shadow-lg); width:480px; max-width:90vw; max-height:85vh; display:flex; flex-direction:column; animation:si .15s ease; }
@keyframes si { from{transform:translateY(8px); opacity:0} to{transform:translateY(0); opacity:1} }
.modal-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border-light); flex-shrink:0; }
.modal-title { font-size:15px; font-weight:600; }
.modal-close { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:none; background:transparent; border-radius:var(--r-sm); cursor:pointer; font-size:18px; color:var(--text-tertiary); transition:all .15s; }
.modal-close:hover { background:var(--bg-hover); color:var(--text-primary); }
.modal-body { padding:20px; overflow-y:auto; flex:1; }
.modal-footer { display:flex; justify-content:flex-end; gap:8px; padding:12px 20px; border-top:1px solid var(--border-light); flex-shrink:0; }
/* ===== Form ===== */
.form-group { margin-bottom:16px; }
.form-group:last-child { margin-bottom:0; }
.form-label { display:block; font-size:12px; font-weight:600; color:var(--text-secondary); margin-bottom:4px; }
.form-row { display:flex; gap:8px; align-items:center; }
/* ===== Editor ===== */
.editor-wrap { flex:1; display:flex; min-height:0; }
.editor-pane { flex:1; padding:20px; overflow-y:auto; min-width:0; display:flex; justify-content:center; }
.editor-pane + .editor-pane { border-left:1px solid var(--border-light); }
.editor-inner { width:100%; max-width:960px; }
.content-header { display:flex; align-items:center; justify-content:space-between; padding:12px 20px; border-bottom:1px solid var(--border-light); gap:12px; flex-shrink:0; }
.content-title { font-size:16px; font-weight:600; color:var(--text-primary); border:none; outline:none; background:transparent; flex:1; font-family:inherit; }
.content-title::placeholder { color:var(--text-placeholder); }
.toggle-group { display:flex; background:var(--bg-hover); border-radius:var(--r-sm); padding:2px; }
.toggle-group button { height:28px; padding:0 12px; border:none; background:transparent; border-radius:4px; font-size:12px; cursor:pointer; color:var(--text-secondary); transition:all .15s; font-family:inherit; }
.toggle-group button.active { background:var(--bg-surface); box-shadow:var(--shadow-sm); color:var(--text-primary); font-weight:500; }
.save-badge { font-size:10px; color:var(--text-tertiary); background:var(--bg-hover); padding:2px 8px; border-radius:10px; flex-shrink:0; white-space:nowrap; }
/* ===== Toolbar ===== */
.tb-bar { display:flex; flex-wrap:wrap; align-items:center; gap:2px; padding:4px 6px; background:var(--bg-sidebar); border:1px solid var(--border-light); border-radius:var(--r-md); margin-bottom:16px; }
.tb-divider { width:1px; height:16px; background:var(--border-medium); margin:0 4px; }
.tb-btn { height:28px; padding:0 8px; border:none; background:transparent; border-radius:4px; font-size:12px; cursor:pointer; color:var(--text-secondary); white-space:nowrap; font-family:inherit; transition:all .1s; }
.tb-btn:hover { background:var(--bg-hover); }
.tb-btn.active { background:var(--brand-light); color:var(--brand); }
.tb-color { width:24px; height:24px; border:1px solid var(--border-medium); border-radius:4px; cursor:pointer; padding:0; margin:0 2px; }
/* ProseMirror */
.ProseMirror { outline:none; min-height:300px; font-size:14px; line-height:1.8; }
.ProseMirror h1 { font-size:22px; font-weight:700; margin:16px 0 8px; padding-bottom:6px; border-bottom:1px solid var(--border-light); }
.ProseMirror h2 { font-size:18px; font-weight:600; margin:14px 0 6px; }
.ProseMirror h3 { font-size:15px; font-weight:600; margin:12px 0 4px; }
.ProseMirror p { margin:4px 0; }
.ProseMirror ul, .ProseMirror ol { padding-left:20px; margin:4px 0; }
.ProseMirror li { margin:2px 0; }
.ProseMirror blockquote { border-left:3px solid var(--brand); padding-left:12px; color:var(--text-secondary); margin:8px 0; }
.ProseMirror pre { background:#F7F8FA; padding:12px 16px; border-radius:var(--r-sm); overflow-x:auto; font-family:"SF Mono","Fira Code","Consolas",monospace; font-size:13px; line-height:1.6; }
.ProseMirror code { background:#F2F3F5; padding:2px 6px; border-radius:3px; font-size:12px; font-family:"SF Mono","Fira Code","Consolas",monospace; }
.ProseMirror table { border-collapse:collapse; width:100%; margin:8px 0; }
.ProseMirror th, .ProseMirror td { border:1px solid var(--border-light); padding:8px 12px; text-align:left; }
.ProseMirror th { background:#F7F8FA; font-weight:600; font-size:13px; }
.ProseMirror img { max-width:100%; border-radius:var(--r-sm); }
.ProseMirror mark { background:#FFF3CD; padding:2px 4px; border-radius:2px; }
/* ===== Import ===== */
.import-textarea { width:100%; height:90px; padding:10px 12px; border:2px solid var(--brand); border-radius:var(--r-md); font-size:13px; font-family:inherit; resize:vertical; outline:none; background:var(--brand-light); }
.import-hint { font-size:11px; color:var(--text-tertiary); margin:4px 0 12px; }
.result-box { background:#F0FAF0; border:1px solid #A3E4A7; border-radius:var(--r-md); padding:14px 16px; margin-bottom:16px; }
.result-grid { display:grid; grid-template-columns:70px 1fr; gap:6px 12px; font-size:13px; }
.alert { padding:8px 12px; border-radius:var(--r-sm); font-size:12px; }
.alert-warning { background:#FFF7E6; color:#8A5D00; }
/* ===== Stats ===== */
.stat-row { display:flex; gap:6px; }
.stat { flex:1; text-align:center; padding:8px 4px; background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-sm); font-size:11px; }
.stat-num { font-size:18px; font-weight:700; }
.stat-label { color:var(--text-tertiary); font-size:10px; }
/* ===== Tag Badge ===== */
.tag-badge { display:inline-block; padding:2px 10px; background:var(--bg-hover); border-radius:10px; font-size:11px; cursor:pointer; color:var(--text-secondary); transition:all .15s; }
.tag-badge:hover { background:#DEE0E3; }
.tag-badge.active { background:var(--brand); color:#fff; }
/* ===== Toast ===== */
.toast { position:fixed; top:20px; right:20px; background:#1F2329; color:#fff; padding:8px 16px; border-radius:var(--r-sm); font-size:13px; z-index:9999; animation:fi .2s ease; }
/* ===== Folder Menu ===== */
.folder-menu { position:absolute; right:0; top:100%; background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-md); box-shadow:var(--shadow-md); z-index:10; min-width:120px; padding:4px; }
.folder-menu-item { padding:6px 12px; cursor:pointer; font-size:12px; color:var(--text-primary); border-radius:var(--r-sm); transition:background .1s; }
.folder-menu-item:hover { background:var(--bg-hover); }
.folder-menu-item.danger { color:var(--danger); }
.folder-menu-item.danger:hover { background:var(--danger-light); }
/* ===== Lightbox ===== */
.lightbox { position:fixed; inset:0; background:rgba(0,0,0,.85); display:flex; align-items:center; justify-content:center; z-index:200; cursor:zoom-out; }
.lightbox img { max-width:90vw; max-height:90vh; object-fit:contain; cursor:default; border-radius:4px; }
.lightbox-close { position:fixed; top:16px; right:16px; width:40px; height:40px; border:none; background:rgba(255,255,255,.15); color:#fff; font-size:22px; border-radius:50%; cursor:pointer; z-index:201; display:flex; align-items:center; justify-content:center; transition:background .15s; }
.lightbox-close:hover { background:rgba(255,255,255,.3); }
/* Selection toolbar */
.sel-toolbar { position:fixed; z-index:150; background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-md); box-shadow:var(--shadow-md); padding:6px; display:flex; gap:4px; }
/* ===== Comments Panel ===== */
.comments-panel { width:260px; min-width:260px; border-left:1px solid var(--border-light); background:var(--bg-sidebar); display:flex; flex-direction:column; font-size:13px; }
/* ===== Helpers ===== */
.hidden { display:none !important; }
/* ================================================
Mobile responsive (<=768px)
================================================ */
@media (max-width: 768px) {
/* -- App shell -- */
.app-nav { position:fixed; left:0; top:0; bottom:0; z-index:50;
transform:translateX(-100%); transition:transform .25s; }
.app-nav.open { transform:translateX(0); }
.app-nav.collapsed { width:200px; min-width:200px; }
.app-main { width:100%; }
/* Hamburger */
.hamburger { display:flex !important; }
/* -- Layout: single column -- */
.layout { flex-direction:column; }
/* Sidebar becomes a top filter bar or hidden */
.sidebar { width:100%; min-width:100%; max-height:40vh; flex-shrink:0; border-right:none; border-bottom:1px solid var(--border-light); }
/* -- Content full width -- */
.content { width:100%; }
.content-header { flex-wrap:wrap; gap:8px; padding:10px 12px; }
.content-title { font-size:15px; }
/* -- Tables become cards -- */
.doc-table, .doc-table thead, .doc-table tbody, .doc-table th, .doc-table td, .doc-table tr { display:block; }
.doc-table thead { display:none; }
.doc-table tr { padding:10px 12px; border-bottom:1px solid var(--border-light); }
.doc-table td { padding:4px 0; border:none; }
.doc-table td:before { content:attr(data-label); font-size:10px; font-weight:600; color:var(--text-tertiary); display:block; margin-bottom:2px; text-transform:uppercase; letter-spacing:.04em; }
.doc-title-cell { max-width:none; white-space:normal; }
/* -- Editor -- */
.editor-wrap { flex-direction:column; }
.editor-pane { padding:12px; }
.editor-pane + .editor-pane { border-left:none; border-top:1px solid var(--border-light); }
.editor-inner { max-width:100%; }
.tb-bar { flex-wrap:nowrap; overflow-x:auto; gap:1px; padding:4px; }
/* -- Modals full screen -- */
.modal { width:100vw !important; max-width:100vw; height:100vh; max-height:100vh; border-radius:0; }
.modal-overlay { align-items:flex-end; }
/* -- Account cards -- */
.card-body { grid-template-columns:1fr; gap:4px; }
.card-body .label { margin-top:6px; }
/* -- Filter tags scrollable -- */
.content-header { overflow-x:auto; flex-wrap:nowrap; white-space:nowrap; }
/* -- Topbar -- */
.topbar { padding:0 10px; height:40px; }
.topbar-brand { font-size:13px; }
/* -- Lightbox -- */
.lightbox-close { top:10px; right:10px; width:36px; height:36px; font-size:18px; }
.lightbox img { max-width:95vw; max-height:85vh; }
/* -- Comments panel -- */
.comments-panel { width:100%; min-width:100%; max-height:40vh; border-left:none; border-top:1px solid var(--border-light); }
}
/* Hamburger button (hidden on desktop) */
.hamburger { display:none; position:fixed; top:8px; left:8px; z-index:60; width:36px; height:36px;
background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-sm);
font-size:18px; cursor:pointer; align-items:center; justify-content:center; box-shadow:var(--shadow-sm); }
/* Sidebar overlay for mobile */
@media (max-width: 768px) {
.app-nav.open + .nav-overlay { display:block; }
}
.nav-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.3); z-index:49; }
.flex-1 { flex:1; }
.text-muted { color:var(--text-tertiary); font-size:12px; }
.mb-sm { margin-bottom:8px; }
.mb-md { margin-bottom:16px; }

View File

@@ -0,0 +1,17 @@
<template>
<Modal :show="show" :title="title" :width="420" @close="$emit('cancel')">
<p style="color:var(--text-secondary); font-size:14px; line-height:1.6;">{{ message }}</p>
<template #footer>
<button v-if="cancelText" class="btn btn-ghost" @click="$emit('cancel')">{{ cancelText }}</button>
<button class="btn btn-primary" @click="$emit('confirm')">{{ confirmText || '确定' }}</button>
</template>
</Modal>
</template>
<script setup>
import Modal from './Modal.vue'
defineProps({
show: Boolean, title: String, message: String,
confirmText: String, cancelText: String,
})
defineEmits(['confirm', 'cancel'])
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div class="modal-overlay" v-if="show" @click.self="$emit('close')">
<div class="modal" :style="width ? { width: width + 'px' } : {}">
<div class="modal-header">
<span class="modal-title">{{ title }}</span>
<button class="modal-close" @click="$emit('close')">&times;</button>
</div>
<div class="modal-body">
<slot />
</div>
<div class="modal-footer" v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</div>
</template>
<script setup>
defineProps({
show: Boolean,
title: String,
width: Number,
})
defineEmits(['close'])
</script>

View File

@@ -0,0 +1,183 @@
<template>
<div class="editor-wrap">
<div class="editor-pane" v-show="viewMode !== 'preview'">
<div class="editor-inner">
<div class="tb-bar">
<button class="tb-btn" @click="cmd('toggleHeading',{level:1})">H1</button>
<button class="tb-btn" @click="cmd('toggleHeading',{level:2})">H2</button>
<button class="tb-btn" @click="cmd('toggleHeading',{level:3})">H3</button>
<span class="tb-divider"></span>
<button class="tb-btn" @click="cmd('toggleBold')" style="font-weight:bold;">B</button>
<button class="tb-btn" @click="cmd('toggleItalic')" style="font-style:italic;">I</button>
<button class="tb-btn" @click="cmd('toggleStrike')" style="text-decoration:line-through;">S</button>
<span class="tb-divider"></span>
<input type="color" class="tb-color" title="文字颜色" @input="cmd('setColor',$event.target.value)">
<input type="color" class="tb-color" title="背景高亮" value="#FFF3CD" @input="cmd('toggleHighlight',{color:$event.target.value})">
<span class="tb-divider"></span>
<button class="tb-btn" @click="addImage">🖼 图片</button>
<button class="tb-btn" @click="addEmoji">😊 表情</button>
<span class="tb-divider"></span>
<button class="tb-btn" @click="cmd('toggleBulletList')"> 列表</button>
<button class="tb-btn" @click="cmd('toggleOrderedList')">1. 列表</button>
<button class="tb-btn" @click="cmd('toggleTaskList')"> 任务</button>
<button class="tb-btn" @click="cmd('toggleBlockquote')"> 引用</button>
<button class="tb-btn" @click="cmd('toggleCodeBlock')">&lt;/&gt; 代码</button>
<span class="tb-divider"></span>
<button class="tb-btn" @click="cmd('insertTable',{rows:3,cols:3,withHeaderRow:true})">📋 表格</button>
</div>
<div id="tiptap-editor"></div>
</div>
</div>
<div class="editor-pane" v-show="viewMode !== 'edit'">
<div class="editor-inner" v-html="previewHtml"></div>
</div>
</div>
<!-- Image lightbox -->
<div class="lightbox" v-if="lightboxSrc" @click="lightboxSrc=null">
<button class="lightbox-close" @click="lightboxSrc=null"></button>
<img :src="lightboxSrc" @click.stop>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { Editor } from '@tiptap/core'
import { StarterKit } from '@tiptap/starter-kit'
import { TextStyle } from '@tiptap/extension-text-style'
import { Color } from '@tiptap/extension-color'
import { Highlight } from '@tiptap/extension-highlight'
import { Image } from '@tiptap/extension-image'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TaskList } from '@tiptap/extension-task-list'
import { TaskItem } from '@tiptap/extension-task-item'
const props = defineProps({ viewMode: String })
const emit = defineEmits(['update'])
let editor = null
const previewHtml = ref('')
const lightboxSrc = ref('')
onMounted(() => {
editor = new Editor({
element: document.querySelector('#tiptap-editor'),
extensions: [
StarterKit,
TextStyle, Color, Highlight.configure({ multicolor: true }),
Image.configure({ inline: true }),
Table, TableRow, TableCell, TableHeader,
TaskList, TaskItem.configure({ nested: true }),
],
onUpdate: () => {
previewHtml.value = editor.getHTML()
emit('update', editor.getHTML())
},
editorProps: {
handlePaste: (view, event) => {
const items = event.clipboardData?.items
if (!items) return false
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault()
const file = item.getAsFile()
uploadAndInsert(file)
return true
}
}
return false
},
handleDrop: (view, event) => {
const files = event.dataTransfer?.files
if (!files?.length) return false
for (const file of files) {
if (file.type.startsWith('image/')) {
event.preventDefault()
uploadAndInsert(file)
return true
}
}
return false
},
},
})
// 图片点击预览
editor.view.dom.addEventListener('click', (e) => {
if (e.target.tagName === 'IMG') lightboxSrc.value = e.target.src
})
})
onUnmounted(() => { editor?.destroy() })
function cmd(name, ...args) {
editor?.chain().focus()[name](...args).run()
}
function setContent(html) {
if (editor) editor.commands.setContent(html || '')
}
function getHTML() {
return editor?.getHTML() || ''
}
// 双地址方案:上传走网关(鉴权),读取走后端(公开,供 <img> 标签和分享页加载)
const isDev = window.location.hostname === 'localhost'
const GW_BASE = isDev ? 'http://localhost:4006' : 'https://gw.server.zgitm.com'
const FILE_BASE = isDev ? 'http://localhost:4006' : 'https://mokeetext.server.zgitm.com'
async function uploadAndInsert(file) {
const form = new FormData()
form.append('file', file)
try {
// 上传走网关(需鉴权头)
const headers = {}
if (!isDev) {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (token) headers['Authorization'] = `Bearer ${token}`
headers['X-System-Code'] = 'mokeetext-edit'
} else {
headers['X-Dev-Mock-User'] = 'admin'
}
const res = await fetch(GW_BASE + '/api/upload/image', { method: 'POST', body: form, headers })
if (!res.ok) {
const text = await res.text()
toast(text.substring(0, 100))
return
}
const data = await res.json()
if (data.url) {
// 图片 URL 指向后端直连(公开,<img> 标签无法带鉴权头,分享页也需要加载)
const src = data.url.startsWith('http') ? data.url : FILE_BASE + data.url
editor?.chain().focus().setImage({ src }).run()
}
else toast(data.error || '上传失败')
} catch(e) { toast('上传失败: ' + e.message) }
}
function toast(msg) {
const el = document.createElement('div')
el.className = 'toast'
el.textContent = msg
document.body.appendChild(el)
setTimeout(() => el.remove(), 3000)
}
function addImage() {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => { const f = input.files[0]; if (f) uploadAndInsert(f) }
input.click()
}
function addEmoji() {
// 用户可直接 Win+. 打开系统表情面板插入,工具栏按钮仅作提示
editor?.chain().focus().insertContent('😊').run()
}
defineExpose({ setContent, getHTML })
</script>

View File

@@ -0,0 +1,20 @@
<template>
<Modal :show="show" :title="title" :width="400" @close="$emit('cancel')">
<div class="form-group">
<label class="form-label">{{ label }}</label>
<input class="input" v-model="value" :placeholder="placeholder" @keyup.enter="$emit('confirm', value)">
</div>
<template #footer>
<button class="btn btn-ghost" @click="$emit('cancel')">取消</button>
<button class="btn btn-primary" @click="$emit('confirm', value)">确定</button>
</template>
</Modal>
</template>
<script setup>
import { ref, watch } from 'vue'
import Modal from './Modal.vue'
const props = defineProps({ show: Boolean, title: String, label: String, placeholder: String, initial: String })
defineEmits(['confirm', 'cancel'])
const value = ref(props.initial || '')
watch(() => props.show, (s) => { if (s) value.value = props.initial || '' })
</script>

View File

@@ -0,0 +1,79 @@
// API 请求层
//
// 开发模式localhost直连后端 + X-Dev-Mock-User 头模拟用户
// 生产模式:走网关 gw.server.zgitm.comaxios 自动携带 Authorization + X-System-Code
import request from '../utils/request.js'
const isDev = window.location.hostname === 'localhost'
// 本地开发:直连后端 + mock 用户
const DEV_BASE = 'http://localhost:4006'
async function devRequest(url, options = {}) {
const res = await fetch(DEV_BASE + url, {
headers: {
'Content-Type': 'application/json',
'X-Dev-Mock-User': 'admin', // 触发后端 DEV_DEFAULT_USER
...options.headers,
},
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || res.statusText)
}
return res.json()
}
// 生产:走网关(通过 axios 实例)
async function gwRequest(url, options = {}) {
const method = (options.method || 'GET').toLowerCase()
const config = { method, url }
if (options.headers) config.headers = options.headers
if (options.body) config.data = JSON.parse(options.body)
const res = await request(config)
// 后端返回原始数据时 axios response 的 .data 是实际内容
// 网关返回时拦截器已解包为 {code,data,msg}.data 也是实际内容
return res.data
}
const doRequest = isDev ? devRequest : gwRequest
export function useApi() {
return {
// Notes
listNotes: (folderId) => doRequest(folderId ? `/api/notes?folder_id=${folderId}` : '/api/notes'),
getNote: (id) => doRequest(`/api/notes/${id}`),
createNote: (data) => doRequest('/api/notes', { method: 'POST', body: JSON.stringify(data) }),
updateNote: (id, data) => doRequest(`/api/notes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteNote: (id) => doRequest(`/api/notes/${id}`, { method: 'DELETE' }),
shareNote: (id, ttl) => doRequest(`/api/notes/${id}/share`, { method: 'POST', body: JSON.stringify({ ttl }) }),
unshareNote: (id) => doRequest(`/api/notes/${id}/share`, { method: 'DELETE' }),
moveNote: (id, folderId) => doRequest(`/api/notes/${id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId }) }),
// Websites
listWebsites: (folderId) => doRequest(folderId ? `/api/websites?folder_id=${folderId}` : '/api/websites'),
getWebsite: (id) => doRequest(`/api/websites/${id}`),
createWebsite: (data) => doRequest('/api/websites', { method: 'POST', body: JSON.stringify(data) }),
updateWebsite: (id, data) => doRequest(`/api/websites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteWebsite: (id) => doRequest(`/api/websites/${id}`, { method: 'DELETE' }),
// Accounts
getAccount: (id) => doRequest(`/api/accounts/${id}`),
addAccount: (siteId, data) => doRequest(`/api/websites/${siteId}/accounts`, { method: 'POST', body: JSON.stringify(data) }),
updateAccount: (id, data) => doRequest(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAccount: (id) => doRequest(`/api/accounts/${id}`, { method: 'DELETE' }),
moveAccount: (id, direction) => doRequest(`/api/accounts/${id}/move`, { method: 'PUT', body: JSON.stringify({ direction }) }),
// Folders
listFolders: (type) => doRequest(`/api/folders?type=${type}`),
createFolder: (data) => doRequest('/api/folders', { method: 'POST', body: JSON.stringify(data) }),
updateFolder: (id, data) => doRequest(`/api/folders/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteFolder: (id) => doRequest(`/api/folders/${id}`, { method: 'DELETE' }),
// Import
parseImport: (text) => doRequest('/api/import/parse', { method: 'POST', body: JSON.stringify({ text }) }),
}
}

View File

@@ -0,0 +1,27 @@
// v-permission 指令 — 按钮权限控制
// 从 Cookie 中读取 JWT Token解析 payload 中的 permissions 数组
// 如果当前用户没有所需权限,则从 DOM 中移除该元素
//
// 用法: <el-button v-permission="'system:user:add'">新增用户</el-button>
function getPermissions() {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token) return []
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.permissions ?? []
} catch {
return []
}
}
export const permission = {
mounted(el, binding) {
const permissions = getPermissions()
const required = binding.value
// 没有该权限则从 DOM 中移除元素
if (required && !permissions.includes(required)) {
el.parentNode?.removeChild(el)
}
},
}

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
import { permission } from './directives/permission.js'
import './assets/main.css'
const app = createApp(App)
app.use(router)
app.directive('permission', permission)
app.mount('#app')

38
frontend/src/router.js Normal file
View File

@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
import NotesView from './views/NotesView.vue'
import WebsitesView from './views/WebsitesView.vue'
import SharesView from './views/SharesView.vue'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE || 'mokeetext-edit'
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL || 'https://login.user.zgitm.com'
const WEBSITE_URL = window.location.origin
const routes = [
{ path: '/', redirect: '/notes' },
{ path: '/notes', name: 'notes', component: NotesView },
{ path: '/websites', name: 'websites', component: WebsitesView },
{ path: '/shares', name: 'shares', component: SharesView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:无 Token 则跳转统一登录页
router.beforeEach((to, _from, next) => {
// 本地开发模式:跳过登录检查
if (window.location.hostname === 'localhost') {
next()
return
}
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token) {
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}&redirect=${encodeURIComponent(WEBSITE_URL)}`
return
}
next()
})
export default router

View File

@@ -0,0 +1,53 @@
// Axios 实例 — 统一请求拦截器
// 开发模式localhost直连后端不走网关
// 生产模式:走网关 gw.server.zgitm.com带 Authorization + X-System-Code
import axios from 'axios'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
// 从 Cookie 读取 Token登录后网关写入的跨域 Cookiedomain: .zgitm.com
function getToken() {
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
const request = axios.create({
baseURL: GATEWAY_URL,
timeout: 30000,
})
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 网关根据此头识别业务系统
config.headers['X-System-Code'] = SYSTEM_CODE
return config
})
// 响应拦截器
request.interceptors.response.use(
response => {
const data = response.data
// 网关 API 返回 { code: 200, data: ..., msg: ... },解包返回
// 业务后端返回原始数据(数组/对象),直接透传 axios response
if (data && typeof data === 'object' && (data.code === 200 || data.code === 0)) {
return data
}
return response
},
error => {
if (error.response?.status === 401) {
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
return Promise.reject(error)
}
)
export { getToken }
export default request

View File

@@ -0,0 +1,321 @@
<template>
<div class="layout">
<!-- Left: folder tree -->
<aside class="sidebar" :class="{ collapsed: folderCollapsed }">
<div class="sidebar-label">
<span class="add-btn" @click="folderCollapsed=!folderCollapsed" :title="folderCollapsed?'展开':'收起'" style="cursor:pointer;">
{{ folderCollapsed ? '▶' : '◀' }}
</span>
<span class="add-btn" v-if="!folderCollapsed" @click="addFolder" style="font-size:11px;font-weight:600;width:auto;padding:0 6px;">新增文件夹</span>
</div>
<div style="flex:1; overflow-y:auto;" v-show="!folderCollapsed">
<!-- 全部 -->
<div class="folder-item" :class="{ active: filterFolderId === null }" @click="filterFolderId=null">
<span class="folder-dot" style="opacity:.8; width:8px; height:8px;"></span><span style="flex:1;">全部笔记</span><span class="count">{{ allNotes.length }}</span>
</div>
<!-- 文件夹列表 -->
<div v-for="f in folders" :key="f.id">
<div class="folder-item" :class="{ active: filterFolderId === f.id }"
@click="filterFolderId = filterFolderId === f.id ? null : f.id"
style="position:relative;">
<span class="folder-dot"></span>
<span style="flex:1;">{{ f.name }}</span>
<span class="count">{{ folderNoteCounts[f.id] || 0 }}</span>
<span class="folder-more" @click.stop="toggleMenu(f)"></span>
<div class="folder-menu" v-show="menuFolderId === f.id">
<div class="folder-menu-item" @click.stop="renameFolder(f)"> 重命名</div>
<div class="folder-menu-item danger" @click.stop="deleteFolder(f)">🗑 删除</div>
</div>
</div>
</div>
</div>
</aside>
<!-- Right: document table or editor -->
<section class="content" v-if="!editingNote">
<div class="content-header">
<span style="font-size:15px; font-weight:600;">{{ filterFolderId ? folderName(filterFolderId) : '全部笔记' }}</span>
<div style="display:flex; align-items:center; gap:8px;">
<input class="input" style="width:200px;" type="text" placeholder="搜索标题…" v-model="searchQuery">
<button class="btn btn-primary btn-sm" @click="createNote">+ 新建笔记</button>
</div>
</div>
<div style="flex:1; overflow-y:auto; padding:0 20px 20px;">
<div v-if="!filteredNotes.length" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
<p>暂无笔记</p>
</div>
<table v-else class="doc-table">
<thead>
<tr>
<th style="width:40%;">标题</th>
<th style="width:10%;">创建人</th>
<th style="width:22%;">创建时间</th>
<th style="width:22%;">修改时间</th>
<th style="width:6%;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="n in filteredNotes" :key="n.id" @click="openNote(n)" class="clickable-row">
<td class="doc-title-cell" data-label="标题">{{ n.title || '无标题笔记' }}</td>
<td data-label="创建人" style="font-size:12px; color:var(--text-secondary);">{{ n.created_by || 'admin' }}</td>
<td data-label="创建时间" style="font-size:12px; color:var(--text-tertiary); font-family:monospace;">{{ fmtFull(n.created_at) }}</td>
<td data-label="修改时间" style="font-size:12px; color:var(--text-tertiary); font-family:monospace;">{{ fmtFull(n.updated_at) }}</td>
<td><button class="btn btn-danger-ghost btn-xs" @click.stop="deleteNoteById(n.id)">删除</button></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Editor -->
<section class="content" v-else>
<div class="content-header" style="flex-wrap:wrap; gap:8px;">
<button class="btn btn-ghost btn-sm" @click="backToList"> 返回</button>
<input class="content-title" v-model="editingNote.title" placeholder="无标题笔记" @input="scheduleSave" style="min-width:120px;">
<!-- 当前文件夹 -->
<span style="font-size:11px; color:var(--text-tertiary); white-space:nowrap; display:flex; align-items:center; gap:4px;">
📂 {{ folderName(editingNote.folder_id) }}
</span>
<!-- 移动到 -->
<button class="btn btn-ghost btn-sm" @click="showMoveModal=true" title="移动到其他文件夹">📦 移动</button>
<!-- 分享 -->
<button v-if="!shareUrl" class="btn btn-ghost btn-sm" @click="showSharePicker=true">🔗 分享</button>
<button v-else class="btn btn-ghost btn-sm" @click="copyShareUrl" style="color:var(--success);"> 已分享</button>
<div class="toggle-group">
<button :class="{ active: viewMode==='edit' }" @click="viewMode='edit'">编辑</button>
<button :class="{ active: viewMode==='preview' }" @click="viewMode='preview'">预览</button>
<button :class="{ active: viewMode==='split' }" @click="viewMode='split'">分屏</button>
</div>
<span class="save-badge">{{ saveStatus }}</span>
<button class="btn btn-danger-ghost btn-sm" @click="deleteCurrentNote">删除</button>
</div>
<NoteEditor ref="editorRef" :viewMode="viewMode" @update="onEditorUpdate" />
</section>
<!-- Move modal -->
<Modal :show="showMoveModal" title="移动到文件夹" @close="showMoveModal=false">
<div class="form-group">
<label class="form-label">选择目标文件夹</label>
<select class="select" v-model="moveTargetFolderId">
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</div>
<template #footer>
<button class="btn btn-ghost" @click="showMoveModal=false">取消</button>
<button class="btn btn-primary" @click="doMove">移动</button>
</template>
</Modal>
<!-- Share modal -->
<Modal :show="showSharePicker" title="生成分享链接" :width="420" @close="closeShareModal">
<p style="color:var(--text-secondary); font-size:13px; margin-bottom:12px;">选择有效期</p>
<div style="display:flex; flex-direction:column; gap:6px; margin-bottom:16px;">
<label class="share-radio" :class="{ active: shareTtl==='7d' }" @click="shareTtl='7d'">
<span class="share-radio-dot"></span> 7 <span style="font-size:11px; color:var(--text-tertiary); margin-left:8px;">一周后失效</span>
</label>
<label class="share-radio" :class="{ active: shareTtl==='30d' }" @click="shareTtl='30d'">
<span class="share-radio-dot"></span> 30 <span style="font-size:11px; color:var(--text-tertiary); margin-left:8px;">一个月后失效</span>
</label>
<label class="share-radio" :class="{ active: shareTtl==='1y' }" @click="shareTtl='1y'">
<span class="share-radio-dot"></span> 1 <span style="font-size:11px; color:var(--text-tertiary); margin-left:8px;">一年后失效</span>
</label>
<label class="share-radio" :class="{ active: shareTtl==='forever' }" @click="shareTtl='forever'">
<span class="share-radio-dot"></span> 永久有效
</label>
</div>
<!-- 生成后显示链接 -->
<div v-if="shareUrl" style="background:#F0FAF0; border:1px solid #A3E4A7; border-radius:8px; padding:12px 14px;">
<div style="font-size:12px; font-weight:600; color:#166534; margin-bottom:6px;"> 链接已生成</div>
<div style="display:flex; gap:6px;">
<input class="input" style="font-size:12px;" :value="shareUrl" readonly @focus="$event.target.select()">
<button class="btn btn-primary btn-sm" @click="copyShareUrl">复制</button>
</div>
<div style="font-size:10px; color:var(--text-tertiary); margin-top:4px;">{{ shareExpiresText }}</div>
</div>
<template #footer>
<button class="btn btn-ghost" @click="closeShareModal" v-if="!shareUrl">取消</button>
<button class="btn btn-primary" @click="generateShare(shareTtl)" v-if="!shareUrl">生成链接</button>
<button class="btn btn-primary" @click="closeShareModal" v-else>完成</button>
</template>
</Modal>
<ConfirmModal :show="confirmDlg.show" :title="confirmDlg.title" :message="confirmDlg.message"
@confirm="onConfirmOk" @cancel="onConfirmCancel" />
<PromptModal :show="promptDlg.show" :title="promptDlg.title" :label="promptDlg.label"
:initial="promptDlg.initial"
@confirm="onPromptOk" @cancel="onPromptCancel" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useApi } from '../composables/useApi.js'
import NoteEditor from '../components/NoteEditor.vue'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import PromptModal from '../components/PromptModal.vue'
const api = useApi()
const editorRef = ref(null)
const folders = ref([])
const allNotes = ref([])
const folderNoteCounts = reactive({})
const filterFolderId = ref(null)
const folderCollapsed = ref(false)
const searchQuery = ref('')
const menuFolderId = ref(null)
const editingNote = ref(null)
const viewMode = ref('edit')
const saveStatus = ref('已保存')
const shareUrl = ref('')
const shareTtl = ref('forever')
const shareExpiresText = ref('')
const showSharePicker = ref(false)
const showMoveModal = ref(false)
const moveTargetFolderId = ref(null)
let saveTimer = null
let uncatFolderId = null
const confirmDlg = reactive({ show: false, title: '', message: '' })
let confirmResolve = null
function showConfirm(title, message) { return new Promise(r => { confirmDlg.title=title; confirmDlg.message=message; confirmDlg.show=true; confirmResolve=r }) }
function onConfirmOk() { confirmDlg.show=false; if(confirmResolve) confirmResolve(true) }
function onConfirmCancel() { confirmDlg.show=false; if(confirmResolve) confirmResolve(false) }
const promptDlg = reactive({ show: false, title: '', label: '', initial: '' })
let promptResolve = null
const promptValue = ref('')
function showPrompt(title, label, initial) { return new Promise(r => { promptDlg.title=title; promptDlg.label=label; promptDlg.initial=initial||''; promptDlg.show=true; promptValue.value=initial||''; promptResolve=r }) }
function onPromptOk(v) { promptDlg.show=false; if(promptResolve) promptResolve(v) }
function onPromptCancel() { promptDlg.show=false; if(promptResolve) promptResolve(null) }
const filteredNotes = computed(() => {
let list = allNotes.value
if (filterFolderId.value) list = list.filter(n => (n.folder_id || uncatFolderId) === filterFolderId.value)
if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(n => (n.title||'').toLowerCase().includes(q)) }
return list
})
onMounted(async () => {
const [fs, notes] = await Promise.all([api.listFolders('note'), api.listNotes()])
folders.value = fs; allNotes.value = notes; uncatFolderId = fs.find(f=>f.name==='未分类')?.id||null; recount(notes)
})
function recount(list) {
list=list||allNotes.value; Object.keys(folderNoteCounts).forEach(k=>delete folderNoteCounts[k])
list.forEach(n=>{ const fid=n.folder_id||uncatFolderId||0; folderNoteCounts[fid]=(folderNoteCounts[fid]||0)+1 })
}
function folderName(fid) { if(!fid)return'未分类'; const f=folders.value.find(x=>x.id===fid); return f?f.name:'未分类' }
function fmtFull(d) { return d?d.replace('T',' ').substring(0,19):'-' }
async function openNote(n) { const note=await api.getNote(n.id); editingNote.value=note; await nextTick(); await nextTick(); editorRef.value?.setContent(note.content||'') }
function backToList() { saveNote(); editingNote.value=null; loadAllNotes() }
async function createNote() { const note=await api.createNote({title:'无标题笔记',content:'',folder_id:filterFolderId.value||uncatFolderId}); openNote(note); await loadAllNotes() }
async function loadAllNotes() { allNotes.value=await api.listNotes(); recount() }
function onEditorUpdate(h) { if(editingNote.value)editingNote.value.content=h; scheduleSave() }
function scheduleSave() { saveStatus.value='保存中…'; clearTimeout(saveTimer); saveTimer=setTimeout(saveNote,800) }
async function saveNote() { if(!editingNote.value?.id)return; await api.updateNote(editingNote.value.id,{title:editingNote.value.title,content:editingNote.value.content}); saveStatus.value='已保存' }
async function deleteCurrentNote() {
const t = editingNote.value?.title || '无标题'
if(!editingNote.value?.id||!await showConfirm('删除笔记',`确定删除「${t}」吗?`))return
await api.deleteNote(editingNote.value.id); editingNote.value=null; await loadAllNotes()
}
async function deleteNoteById(id) {
const n = allNotes.value.find(x=>x.id===id); const t = n?.title || '无标题'
if(!await showConfirm('删除笔记',`确定删除「${t}」吗?`))return
await api.deleteNote(id); await loadAllNotes()
}
async function addFolder() { const name=await showPrompt('新建文件夹','名称',''); if(!name)return; const f=await api.createFolder({name,type:'note'}); folders.value.push(f) }
async function renameFolder(f) { const name=await showPrompt('重命名','新名称',f.name); if(!name)return; await api.updateFolder(f.id,{name}); f.name=name }
async function deleteFolder(f) {
if(!await showConfirm('删除文件夹',`确定删除文件夹「${f.name}」吗?\n内容将移至「未分类」`))return
await api.deleteFolder(f.id); folders.value=folders.value.filter(x=>x.id!==f.id)
if(filterFolderId.value===f.id)filterFolderId.value=null; await loadAllNotes()
}
function toggleMenu(f) { menuFolderId.value=menuFolderId.value===f.id?null:f.id }
// ===== 分享 =====
async function generateShare(ttl) {
if (!editingNote.value?.id) return
const result = await api.shareNote(editingNote.value.id, ttl)
const BASE = window.location.hostname === 'localhost' ? '' : 'https://mokeetext.server.zgitm.com'
shareUrl.value = BASE + result.url
shareExpiresText.value = '有效期至 ' + result.expires_at
}
function copyShareUrl() {
if (shareUrl.value) { navigator.clipboard.writeText(shareUrl.value); showToast('链接已复制') }
}
function closeShareModal() {
showSharePicker.value = false
shareTtl.value = 'forever'
shareUrl.value = ''
}
function showAlert(title, msg) {
confirmDlg.title=title; confirmDlg.message=msg; confirmDlg.show=true; confirmResolve=()=>{confirmDlg.show=false}
}
function showToast(msg) {
const t=document.createElement('div'); t.className='toast'; t.textContent=msg; document.body.appendChild(t)
setTimeout(()=>t.remove(), 2000)
}
// ===== 移动 =====
async function doMove() {
if (!editingNote.value?.id || !moveTargetFolderId.value) return
await api.moveNote(editingNote.value.id, moveTargetFolderId.value)
editingNote.value.folder_id = moveTargetFolderId.value
showMoveModal.value = false
showToast('已移动')
await loadAllNotes()
}
// ===== Ctrl+S 快捷键 =====
function onKeydown(e) {
if ((e.ctrlKey||e.metaKey) && e.key==='s') {
e.preventDefault()
saveNote().then(()=>showToast('已保存Ctrl+S'))
}
}
function onDocClick() { menuFolderId.value=null }
onMounted(()=>{
document.addEventListener('click',onDocClick)
document.addEventListener('keydown',onKeydown)
})
onUnmounted(()=>{
document.removeEventListener('click',onDocClick)
document.removeEventListener('keydown',onKeydown)
})
</script>
<style scoped>
.doc-table { width:100%; border-collapse:collapse; }
.doc-table th { text-align:left; padding:10px 12px; font-size:11px; font-weight:600; color:var(--text-tertiary); letter-spacing:0.04em; border-bottom:2px solid var(--border-light); user-select:none; }
.doc-table td { padding:10px 12px; border-bottom:1px solid var(--border-light); }
.clickable-row { cursor:pointer; }
.clickable-row:hover td { background:var(--bg-hover); }
.doc-title-cell { font-size:14px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:0; }
.share-radio {
display:flex; align-items:center; gap:8px; padding:10px 14px;
border:1px solid var(--border-light); border-radius:var(--r-md); background:var(--bg-surface);
cursor:pointer; transition:all 0.15s; font-size:13px; user-select:none;
}
.share-radio:hover { border-color:var(--brand); }
.share-radio.active { border-color:var(--brand); background:var(--brand-light); }
.share-radio-dot {
width:16px; height:16px; border-radius:50%; border:2px solid var(--border-medium);
display:inline-flex; align-items:center; justify-content:center; flex-shrink:0; transition:all 0.15s;
}
.share-radio.active .share-radio-dot { border-color:var(--brand); }
.share-radio.active .share-radio-dot::after { content:''; width:8px; height:8px; border-radius:50%; background:var(--brand); }
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="layout">
<section class="content">
<div class="content-header">
<span style="font-size:15px; font-weight:600;">分享管理</span>
<span class="text-muted">{{ shares.length }} 条分享</span>
</div>
<div style="flex:1; overflow-y:auto; padding:0 20px 20px;">
<div v-if="!shares.length" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
<p>暂无分享记录</p>
</div>
<table v-else class="doc-table">
<thead>
<tr>
<th style="width:25%;">笔记标题</th>
<th style="width:30%;">分享链接</th>
<th style="width:12%;">有效期</th>
<th style="width:8%;">创建人</th>
<th style="width:12%;">更新时间</th>
<th style="width:13%;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in shares" :key="s.id">
<td class="doc-title-cell" data-label="标题">{{ s.title || '无标题' }}</td>
<td data-label="链接" style="font-size:12px; font-family:monospace;">
<a :href="BASE + s.share_url" target="_blank" style="color:var(--brand); text-decoration:none;">{{ BASE + s.share_url }}</a>
</td>
<td data-label="有效期" style="font-size:12px; color:var(--text-secondary);">{{ s.share_expires_at }}</td>
<td data-label="创建人" style="font-size:12px; color:var(--text-secondary);">{{ s.created_by }}</td>
<td data-label="更新时间" style="font-size:12px; color:var(--text-tertiary); font-family:monospace;">{{ fmt(s.updated_at) }}</td>
<td>
<div style="display:flex; gap:4px;">
<button class="card-btn" @click="copyUrl(s)">复制</button>
<button class="card-btn danger-btn" @click="cancelShare(s)">取消分享</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<ConfirmModal :show="confirmDlg.show" :title="confirmDlg.title" :message="confirmDlg.message"
@confirm="onConfirmOk" @cancel="onConfirmCancel" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import ConfirmModal from '../components/ConfirmModal.vue'
const shares = ref([])
const confirmDlg = reactive({ show: false, title: '', message: '' })
let confirmResolve = null
function showConfirm(t, m) { return new Promise(r => { confirmDlg.title=t; confirmDlg.message=m; confirmDlg.show=true; confirmResolve=r }) }
function onConfirmOk() { confirmDlg.show=false; if(confirmResolve) confirmResolve(true) }
function onConfirmCancel() { confirmDlg.show=false; if(confirmResolve) confirmResolve(false) }
const BASE = window.location.hostname === 'localhost' ? '' : 'https://mokeetext.server.zgitm.com'
onMounted(load)
async function load() {
const res = await fetch(BASE + '/api/shares')
shares.value = await res.json()
}
function fmt(d) { return d ? d.replace('T',' ').substring(0,19) : '-' }
function copyUrl(s) {
const url = BASE + s.share_url
navigator.clipboard.writeText(url)
const t = document.createElement('div'); t.className='toast'; t.textContent='已复制'; document.body.appendChild(t)
setTimeout(()=>t.remove(), 1500)
}
async function cancelShare(s) {
if (!await showConfirm('取消分享', `确定取消「${s.title}」的分享链接?`)) return
await fetch(BASE + `/api/notes/${s.id}/share`, { method: 'DELETE' })
await load()
}
</script>
<style scoped>
.doc-table { width:100%; border-collapse:collapse; }
.doc-table th { text-align:left; padding:10px 12px; font-size:11px; font-weight:600; color:var(--text-tertiary); letter-spacing:0.04em; border-bottom:2px solid var(--border-light); user-select:none; }
.doc-table td { padding:10px 12px; border-bottom:1px solid var(--border-light); }
.doc-title-cell { font-size:14px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:0; }
</style>

View File

@@ -0,0 +1,439 @@
<template>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar">
<div class="stat-row">
<div class="stat">
<div class="stat-num" style="color:var(--brand);">{{ websites.length }}</div>
<div class="stat-label">网站</div>
</div>
<div class="stat">
<div class="stat-num" style="color:var(--success);">{{ totalAccounts }}</div>
<div class="stat-label">账号</div>
</div>
</div>
<button class="btn btn-primary btn-block" @click="openWebsiteModal()">添加网站</button>
<button class="btn btn-outline btn-block" @click="showImport = true">智能导入</button>
<div class="sidebar-label">
<span>文件夹</span>
<span class="add-btn" @click="addFolder">+</span>
</div>
<input class="input mb-sm" type="text" placeholder="搜索网站或网址…" v-model="searchQuery">
<div style="flex:1; overflow-y:auto;">
<div v-for="f in folders" :key="f.id" class="folder-group">
<div class="folder-item" @click="toggleExpand(f)" style="position:relative;">
<span class="folder-arrow">{{ expandedFolders[f.id] ? '▼' : '▶' }}</span>
<span style="flex:1; font-weight:500;">📁 {{ f.name }}</span>
<span class="count">{{ folderSiteCounts[f.id] || 0 }}</span>
<span class="folder-more" @click.stop="toggleMenu(f, $event)"></span>
<div class="folder-menu" v-show="menuFolderId === f.id">
<div class="folder-menu-item" @click.stop="renameFolder(f)"> 重命名</div>
<div class="folder-menu-item danger" @click.stop="deleteFolder(f)">🗑 删除</div>
</div>
</div>
<div class="folder-children" v-show="expandedFolders[f.id]">
<div v-for="s in folderSites(f.id)" :key="s.id"
class="site-item" :class="{ active: currentSite?.id === s.id }"
@click="selectSite(s)">
<span>🌐</span>
<span class="site-name">{{ s.name }}</span>
<span class="site-count">{{ s.accounts?.length || 0 }}</span>
</div>
</div>
</div>
<div class="sidebar-label">未分类</div>
<div v-for="s in uncategorizedSites" :key="s.id"
class="site-item" :class="{ active: currentSite?.id === s.id }"
@click="selectSite(s)">
<span>🌐</span>
<span class="site-name">{{ s.name }}</span>
<span class="site-count">{{ s.accounts?.length || 0 }}</span>
</div>
</div>
</aside>
<!-- Detail -->
<section class="content">
<div class="content-header">
<div v-if="currentSite" style="display:flex; align-items:center; gap:8px;">
<span style="font-size:11px;color:var(--text-tertiary);">{{ getFolderName(currentSite.folder_id) }} /</span>
<span style="font-size:15px;font-weight:600;">{{ currentSite.name }}</span>
<a :href="currentSite.url" target="_blank" style="font-size:11px;color:var(--brand);text-decoration:none;">{{ currentSite.url }}</a>
</div>
<div v-else class="text-muted">选择左侧网站查看账号</div>
<div style="display:flex; gap:6px;" v-if="currentSite">
<button class="btn btn-ghost btn-sm" @click="openWebsiteModal(currentSite)"> 编辑</button>
<button class="btn btn-outline btn-sm" @click="openAccountModal()">+ 添加账号</button>
<button class="btn btn-danger-ghost btn-sm" @click="deleteCurrentSite">🗑 删除网站</button>
</div>
</div>
<div class="editor-pane" style="flex:1;">
<div v-if="!currentSite" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
<div style="font-size:48px; margin-bottom:16px; opacity:0.4;">🔐</div>
<p>选择左侧网站查看账号密码</p>
</div>
<div v-else-if="!currentSite.accounts?.length" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
<p>暂无账号</p>
</div>
<div v-else>
<div v-for="(a, i) in currentSite.accounts" :key="a.id" class="card">
<div class="card-header">
<span class="card-title">👤 {{ a.remark || '账号' }}</span>
<div style="display:flex; gap:4px; align-items:center;">
<button class="card-btn" @click="moveAccount(a.id,'up')" :disabled="i===0" title="上移"></button>
<button class="card-btn" @click="moveAccount(a.id,'down')" :disabled="i===currentSite.accounts.length-1" title="下移"></button>
<button class="card-btn edit-btn" @click="openAccountModal(a)"> 编辑</button>
<button class="card-btn danger-btn" @click="deleteAccount(a.id)">🗑 删除</button>
</div>
</div>
<div class="card-body">
<span class="label">账号</span>
<span style="font-family:monospace;">{{ a.username }}</span>
<button class="card-btn" @click="copy(a.username)">复制</button>
<span class="label">密码</span>
<span style="font-family:monospace;">{{ showPw[a.id] ? a.password : '••••••••' }}</span>
<div style="display:flex; gap:4px;">
<button class="card-btn" @click="showPw[a.id]=!showPw[a.id]">{{ showPw[a.id] ? '隐藏' : '显示' }}</button>
<button class="card-btn" @click="copy(a.password)">复制</button>
</div>
<span class="label">说明</span>
<span style="color:var(--text-secondary); font-size:12px;">{{ a.remark || '-' }}</span>
<span></span>
</div>
</div>
</div>
</div>
</section>
<!-- Website Modal -->
<Modal :show="websiteModal" :title="editingWebsite ? '编辑网站' : '添加网站'" @close="websiteModal=false">
<div class="form-group">
<label class="form-label">网站名</label>
<input class="input" v-model="websiteForm.name" placeholder="例如GitHub">
</div>
<div class="form-group">
<label class="form-label">网址</label>
<input class="input" v-model="websiteForm.url" placeholder="https://github.com">
</div>
<div class="form-group">
<label class="form-label">文件夹</label>
<select class="select" v-model="websiteForm.folder_id">
<option :value="null">未分类</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</div>
<template #footer>
<button class="btn btn-ghost" @click="websiteModal=false">取消</button>
<button class="btn btn-primary" @click="saveWebsite">保存</button>
</template>
</Modal>
<!-- Account Modal -->
<Modal :show="accountModal" :title="editingAccount ? '编辑账号' : '添加账号'" @close="accountModal=false">
<div class="form-group">
<label class="form-label">账号 / 用户名</label>
<input class="input" v-model="accountForm.username" placeholder="user@example.com">
</div>
<div class="form-group">
<label class="form-label">密码</label>
<div class="form-row">
<input class="input" :type="accountPwVisible ? 'text' : 'password'" v-model="accountForm.password" placeholder="输入密码" style="flex:1;">
<button class="btn btn-ghost btn-sm" type="button" @click="accountPwVisible=!accountPwVisible">{{ accountPwVisible ? '隐藏' : '显示' }}</button>
</div>
</div>
<div class="form-group">
<label class="form-label">备注</label>
<textarea class="textarea" v-model="accountForm.remark" rows="2" placeholder="例如:主账号、小号…"></textarea>
</div>
<div class="form-group">
<label class="form-label">排序</label>
<input class="input" v-model.number="accountForm.sort_order" type="number" placeholder="数字越小越靠前" style="width:120px;">
</div>
<template #footer>
<button class="btn btn-ghost" @click="accountModal=false">取消</button>
<button class="btn btn-primary" @click="saveAccount">保存</button>
</template>
</Modal>
<!-- Import Modal -->
<Modal :show="showImport" title="智能导入" :width="520" @close="showImport=false">
<div class="form-group">
<label class="form-label">粘贴账号信息</label>
<textarea class="import-textarea" v-model="importText"
placeholder="https://github.com 代码网站&#10;zhangsan@qq.com&#10;Abc123&#10;个人开发账号"></textarea>
<div class="import-hint">首行=网址+空格+网站名 | 第2行=账号 | 第3行=密码 | 第4行=备注</div>
</div>
<button class="btn btn-primary btn-block mb-md" @click="parseImport">🔍 自动识别</button>
<div v-if="importResult" class="result-box">
<div style="font-weight:600; color:#166534; margin-bottom:10px;">识别结果</div>
<div class="result-grid">
<span style="color:var(--text-secondary);">网址</span><span>{{ importResult.url }}</span>
<span style="color:var(--text-secondary);">网站名</span><span>{{ importResult.site_name }}</span>
<span style="color:var(--text-secondary);">账号</span><span style="font-family:monospace;">{{ importResult.username }}</span>
<span style="color:var(--text-secondary);">密码</span><span style="font-family:monospace;">{{ importResult.password }}</span>
<span style="color:var(--text-secondary);">备注</span><span>{{ importResult.remark || '(无)' }}</span>
</div>
<div class="form-group" style="margin-top:12px;">
<label class="form-label">存放文件夹</label>
<div class="form-row">
<select class="select" v-model="importFolderId">
<option :value="null">已有文件夹</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
<span class="text-muted"></span>
<input class="input" v-model="importNewFolder" placeholder="新建文件夹…" style="flex:1;">
</div>
</div>
<div v-if="importResult.matched_site" class="alert alert-warning mb-md">
<strong>{{ importResult.matched_site.name }}</strong> 已存在{{ importResult.matched_site.account_count }}个账号将追加进去
</div>
<div class="modal-footer" style="padding:0; border:none;">
<button class="btn btn-ghost" @click="showImport=false">取消</button>
<button class="btn btn-primary" @click="confirmImport">确认添加</button>
</div>
</div>
</Modal>
<ConfirmModal :show="confirmDlg.show" :title="confirmDlg.title" :message="confirmDlg.message"
@confirm="onConfirmOk" @cancel="onConfirmCancel" />
<PromptModal :show="promptDlg.show" :title="promptDlg.title" :label="promptDlg.label"
:initial="promptDlg.initial"
@confirm="onPromptOk" @cancel="onPromptCancel" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useApi } from '../composables/useApi.js'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import PromptModal from '../components/PromptModal.vue'
const api = useApi()
const websites = ref([])
const folders = ref([])
const currentSite = ref(null)
const searchQuery = ref('')
const expandedFolders = reactive({})
const menuFolderId = ref(null)
const showPw = reactive({})
const totalAccounts = computed(() => websites.value.reduce((s, w) => s + (w.accounts?.length || 0), 0))
const folderSiteCounts = computed(() => {
const m = {}
websites.value.forEach(w => {
const fid = w.folder_id || '__none__'
m[fid] = (m[fid] || 0) + 1
})
return m
})
function folderSites(fid) {
return websites.value.filter(w => w.folder_id === fid)
}
const uncategorizedSites = computed(() => websites.value.filter(w => !w.folder_id))
// Website modal
const websiteModal = ref(false)
const editingWebsite = ref(null)
const websiteForm = reactive({ name: '', url: '', folder_id: null })
// Account modal
const accountModal = ref(false)
const editingAccount = ref(null)
const accountForm = reactive({ username: '', password: '', remark: '', sort_order: 0 })
const accountPwVisible = ref(false)
// Import
const showImport = ref(false)
const importText = ref('')
const importResult = ref(null)
const importFolderId = ref(null)
const importNewFolder = ref('')
// Dialog state
const confirmDlg = reactive({ show: false, title: '', message: '' })
let confirmResolve = null
function showConfirm(title, message) { return new Promise(r => { confirmDlg.title=title; confirmDlg.message=message; confirmDlg.show=true; confirmResolve=r }) }
function onConfirmOk() { confirmDlg.show=false; if(confirmResolve) confirmResolve(true) }
function onConfirmCancel() { confirmDlg.show=false; if(confirmResolve) confirmResolve(false) }
const promptDlg = reactive({ show: false, title: '', label: '', initial: '' })
let promptResolve = null
function showPrompt(title, label, initial) { return new Promise(r => { promptDlg.title=title; promptDlg.label=label; promptDlg.initial=initial||''; promptDlg.show=true; promptResolve=r }) }
function onPromptOk(v) { promptDlg.show=false; if(promptResolve) promptResolve(v) }
function onPromptCancel() { promptDlg.show=false; if(promptResolve) promptResolve(null) }
function showAlert(title, message) { confirmDlg.title=title; confirmDlg.message=message; confirmDlg.show=true; confirmResolve=()=>{confirmDlg.show=false} }
onMounted(async () => {
const [ws, fs] = await Promise.all([api.listWebsites(), api.listFolders('website')])
websites.value = ws
folders.value = fs
fs.forEach(f => { expandedFolders[f.id] = true })
})
function selectSite(site) {
currentSite.value = site
}
function getFolderName(fid) {
if (!fid) return '未分类'
const f = folders.value.find(x => x.id === fid)
return f ? f.name : '未分类'
}
function toggleExpand(f) {
expandedFolders[f.id] = !expandedFolders[f.id]
}
function toggleMenu(f, ev) {
menuFolderId.value = menuFolderId.value === f.id ? null : f.id
}
async function addFolder() {
const name = await showPrompt('新建文件夹', '文件夹名称', '')
if (!name) return
const f = await api.createFolder({ name, type: 'website' })
folders.value.push(f)
expandedFolders[f.id] = true
}
async function renameFolder(f) {
const name = await showPrompt('重命名文件夹', '新名称', f.name)
if (!name) return
await api.updateFolder(f.id, { name })
f.name = name
}
function openWebsiteModal(site) {
editingWebsite.value = site || null
if (site) {
websiteForm.name = site.name
websiteForm.url = site.url
websiteForm.folder_id = site.folder_id
} else {
websiteForm.name = ''
websiteForm.url = ''
websiteForm.folder_id = null
}
websiteModal.value = true
}
async function saveWebsite() {
if (editingWebsite.value) {
await api.updateWebsite(editingWebsite.value.id, { ...websiteForm })
} else {
await api.createWebsite({ ...websiteForm })
}
websiteModal.value = false
await refreshSites()
}
function openAccountModal(acc) {
editingAccount.value = acc || null
accountPwVisible.value = false
if (acc) {
accountForm.username = acc.username
accountForm.password = acc.password
accountForm.remark = acc.remark || ''
accountForm.sort_order = acc.sort_order || 0
} else {
accountForm.username = ''
accountForm.password = ''
accountForm.remark = ''
// 默认排序为当前账号数+1
accountForm.sort_order = (currentSite.value?.accounts?.length || 0) + 1
}
accountModal.value = true
}
async function saveAccount() {
const data = { username: accountForm.username, password: accountForm.password, remark: accountForm.remark, sort_order: accountForm.sort_order }
if (editingAccount.value) {
await api.updateAccount(editingAccount.value.id, data)
} else {
await api.addAccount(currentSite.value.id, data)
}
accountModal.value = false
const site = await api.getWebsite(currentSite.value.id)
currentSite.value = site
}
async function deleteCurrentSite() {
if (!currentSite.value || !await showConfirm('删除网站', `确定删除「${currentSite.value.name}」及其所有账号?`)) return
await api.deleteWebsite(currentSite.value.id)
currentSite.value = null
await refreshSites()
}
async function moveAccount(id, dir) {
await api.moveAccount(id, dir)
const site = await api.getWebsite(currentSite.value.id)
currentSite.value = site
}
async function deleteAccount(id) {
const acc = currentSite.value?.accounts?.find(a => a.id === id)
const label = acc?.remark || acc?.username || '账号'
if (!await showConfirm('删除账号', `确定删除「${label}」吗?`)) return
await api.deleteAccount(id)
const site = await api.getWebsite(currentSite.value.id)
currentSite.value = site
}
async function deleteFolder(f) {
if (!await showConfirm('删除文件夹', `确定删除文件夹「${f.name}」吗?内容将移至「未分类」`)) return
await api.deleteFolder(f.id)
folders.value = folders.value.filter(x => x.id !== f.id)
await refreshSites()
}
async function parseImport() {
const text = importText.value.trim()
if (!text) return showAlert('提示', '请先粘贴文本')
const result = await api.parseImport(text)
if (!result.ok) return showAlert('识别失败', result.error)
importResult.value = result
if (result.matched_site?.folder_id) importFolderId.value = result.matched_site.folder_id
}
async function confirmImport() {
const d = importResult.value
if (!d) return
let fid = importFolderId.value
if (importNewFolder.value) {
const f = await api.createFolder({ name: importNewFolder.value, type: 'website' })
fid = f.id
}
let site = d.matched_site
if (!site) {
site = await api.createWebsite({ name: d.site_name, url: d.url, folder_id: fid })
}
await api.addAccount(site.id, { username: d.username, password: d.password, remark: d.remark })
showImport.value = false
await refreshSites()
}
function copy(text) {
navigator.clipboard.writeText(text)
}
async function refreshSites() {
websites.value = await api.listWebsites()
}
function closeMenu() { menuFolderId.value = null }
onMounted(() => document.addEventListener('click', closeMenu))
onUnmounted(() => document.removeEventListener('click', closeMenu))
</script>

View File

@@ -0,0 +1,701 @@
/* ========================================
MoKeeText — Feishu-inspired Design System
======================================== */
/* ——— Design Tokens ——— */
:root {
/* Surface */
--bg-page: #F5F6F8;
--bg-sidebar: #FCFCFD;
--bg-surface: #FFFFFF;
--bg-hover: #F2F3F5;
--bg-active: #EEF3FF;
/* Text */
--text-primary: #1F2329;
--text-secondary: #646A73;
--text-tertiary: #8F959E;
--text-placeholder:#BCC0C6;
/* Border */
--border-light: #E5E6EB;
--border-medium: #DEE0E3;
/* Brand */
--brand: #3370FF;
--brand-hover: #245BDB;
--brand-light: #EEF3FF;
--brand-ghost: rgba(51,112,255,0.06);
/* Semantic */
--danger: #F54A45;
--danger-light: #FFF1F0;
--success: #34C759;
/* Radius */
--r-sm: 6px;
--r-md: 8px;
--r-lg: 12px;
/* Shadow */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 20px;
--space-2xl: 24px;
}
/* ——— Reset ——— */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-page);
height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ——— Top Navigation ——— */
.topnav {
display: flex;
align-items: center;
height: 48px;
padding: 0 var(--space-xl);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-light);
gap: 0;
flex-shrink: 0;
user-select: none;
}
.topnav-brand {
font-weight: 700;
font-size: 15px;
color: var(--text-primary);
text-decoration: none;
margin-right: var(--space-2xl);
letter-spacing: -0.01em;
}
.topnav-link {
padding: 13px var(--space-lg);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
transition: color 0.15s;
letter-spacing: 0;
}
.topnav-link:hover { color: var(--brand); }
.topnav-link.active {
color: var(--brand);
border-bottom-color: var(--brand);
}
/* ——— User Widget ——— */
.user-widget {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 4px;
border-radius: 20px;
cursor: pointer;
transition: background 0.15s;
user-select: none;
}
.user-widget:hover { background: var(--bg-hover); }
.user-avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--brand);
color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600;
flex-shrink: 0;
}
.user-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
/* ——— Footer ——— */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--space-xl);
background: var(--bg-surface);
border-top: 1px solid var(--border-light);
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.footer-icp { color: var(--text-placeholder); }
/* ——— Main Layout ——— */
.layout { display: flex; height: calc(100vh - 48px - 32px); }
/* ——— Sidebar ——— */
.sidebar {
width: 260px;
min-width: 260px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border-light);
display: flex;
flex-direction: column;
padding: var(--space-md);
gap: var(--space-sm);
overflow: hidden;
}
/* Sidebar section label */
.sidebar-label {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 4px 0;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* ——— Buttons ——— */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 var(--space-lg);
border: none;
border-radius: var(--r-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.btn-primary {
background: var(--brand);
color: #fff;
}
.btn-primary:hover { background: var(--brand-hover); }
.btn-outline {
background: var(--bg-surface);
color: var(--brand);
border: 1px solid var(--brand);
}
.btn-outline:hover { background: var(--brand-light); }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-danger-ghost {
background: transparent;
color: var(--text-tertiary);
}
.btn-danger-ghost:hover { background: var(--danger-light); color: var(--danger); }
.btn-block { width: 100%; }
.btn-sm { height: 30px; font-size: 12px; padding: 0 12px; }
/* ——— Input ——— */
.input {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-medium);
border-radius: var(--r-sm);
font-size: 13px;
color: var(--text-primary);
background: var(--bg-surface);
outline: none;
transition: border 0.15s;
font-family: inherit;
}
.input:focus { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-light); }
.input::placeholder { color: var(--text-placeholder); }
.textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-medium);
border-radius: var(--r-sm);
font-size: 13px;
color: var(--text-primary);
background: var(--bg-surface);
outline: none;
resize: vertical;
font-family: inherit;
transition: border 0.15s;
min-height: 60px;
}
.textarea:focus { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-light); }
.select {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-medium);
border-radius: var(--r-sm);
font-size: 13px;
color: var(--text-primary);
background: var(--bg-surface);
outline: none;
cursor: pointer;
font-family: inherit;
}
/* ——— Folder Tree ——— */
.folder-item {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 8px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
transition: background 0.1s;
user-select: none;
}
.folder-item:hover { background: var(--bg-hover); }
.folder-item.active { background: var(--brand-light); color: var(--brand); font-weight: 500; }
.folder-item .count {
margin-left: auto;
font-size: 11px;
color: var(--text-tertiary);
}
.folder-item.active .count { color: var(--brand); opacity: 0.7; }
/* Folder menu */
.folder-more {
opacity: 0;
padding: 2px 6px;
font-size: 16px;
font-weight: 700;
color: var(--text-tertiary);
border-radius: 4px;
cursor: pointer;
line-height: 1;
}
.folder-item:hover .folder-more { opacity: 1; }
.folder-more:hover { background: var(--bg-hover); color: var(--text-primary); }
.folder-menu {
position: absolute;
right: 0;
top: 100%;
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
z-index: 10;
min-width: 120px;
padding: 4px;
}
.folder-menu-item {
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text-primary);
border-radius: var(--r-sm);
transition: background 0.1s;
}
.folder-menu-item:hover { background: var(--bg-hover); }
.folder-menu-item.danger { color: var(--danger); }
.folder-menu-item.danger:hover { background: var(--danger-light); }
.folder-children { margin-left: 16px; }
.folder-arrow { font-size: 10px; color: var(--text-tertiary); width: 12px; flex-shrink: 0; }
/* ——— Site List ——— */
.site-item {
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
user-select: none;
}
.site-item:hover { background: var(--bg-hover); }
.site-item.active { background: var(--brand-light); color: var(--brand); font-weight: 500; }
.site-item .site-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.site-item .site-count { font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; }
.site-item.active .site-count { color: var(--brand); opacity: 0.7; }
/* ——— Content Area ——— */
.content {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-surface);
min-width: 0;
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px var(--space-xl);
border-bottom: 1px solid var(--border-light);
gap: var(--space-md);
flex-shrink: 0;
}
.content-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
border: none;
outline: none;
background: transparent;
flex: 1;
font-family: inherit;
}
.content-title::placeholder { color: var(--text-placeholder); }
/* ——— View Toggle ——— */
.toggle-group {
display: flex;
background: var(--bg-hover);
border-radius: var(--r-sm);
padding: 2px;
}
.toggle-group button {
height: 28px;
padding: 0 12px;
border: none;
background: transparent;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
font-family: inherit;
}
.toggle-group button.active {
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
color: var(--text-primary);
font-weight: 500;
}
/* ——— Editor Area ——— */
.editor-wrap {
flex: 1;
display: flex;
min-height: 0;
}
.editor-pane {
flex: 1;
padding: var(--space-xl);
overflow-y: auto;
min-width: 0;
}
.editor-pane + .editor-pane { border-left: 1px solid var(--border-light); }
/* ——— Tiptap Toolbar ——— */
.tb-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 4px 6px;
background: var(--bg-sidebar);
border: 1px solid var(--border-light);
border-radius: var(--r-md);
margin-bottom: var(--space-lg);
}
.tb-divider {
width: 1px;
height: 16px;
background: var(--border-medium);
margin: 0 4px;
}
.tb-btn {
height: 28px;
padding: 0 8px;
border: none;
background: transparent;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
color: var(--text-secondary);
white-space: nowrap;
font-family: inherit;
transition: all 0.1s;
}
.tb-btn:hover { background: var(--bg-hover); }
.tb-btn.active { background: var(--brand-light); color: var(--brand); }
.tb-color {
width: 24px;
height: 24px;
border: 1px solid var(--border-medium);
border-radius: 4px;
cursor: pointer;
padding: 0;
margin: 0 2px;
}
/* ProseMirror / Tiptap Content */
.ProseMirror { outline: none; min-height: 300px; font-size: 14px; line-height: 1.8; }
.ProseMirror h1 { font-size: 22px; font-weight: 700; margin: 16px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-light); }
.ProseMirror h2 { font-size: 18px; font-weight: 600; margin: 14px 0 6px; }
.ProseMirror h3 { font-size: 15px; font-weight: 600; margin: 12px 0 4px; }
.ProseMirror p { margin: 4px 0; }
.ProseMirror ul, .ProseMirror ol { padding-left: 20px; margin: 4px 0; }
.ProseMirror li { margin: 2px 0; }
.ProseMirror blockquote { border-left: 3px solid var(--brand); padding-left: 12px; color: var(--text-secondary); margin: 8px 0; }
.ProseMirror pre { background: #F7F8FA; padding: 12px 16px; border-radius: var(--r-sm); overflow-x: auto; font-family: "SF Mono","Fira Code","Consolas",monospace; font-size: 13px; line-height: 1.6; }
.ProseMirror code { background: #F2F3F5; padding: 2px 6px; border-radius: 3px; font-size: 12px; font-family: "SF Mono","Fira Code","Consolas",monospace; }
.ProseMirror table { border-collapse: collapse; width: 100%; margin: 8px 0; }
.ProseMirror th, .ProseMirror td { border: 1px solid var(--border-light); padding: 8px 12px; text-align: left; }
.ProseMirror th { background: #F7F8FA; font-weight: 600; font-size: 13px; }
.ProseMirror img { max-width: 100%; border-radius: var(--r-sm); }
.ProseMirror mark { background: #FFF3CD; padding: 2px 4px; border-radius: 2px; }
/* ——— Note List in Sidebar ——— */
.note-list-item {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 8px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 12px;
transition: background 0.1s;
}
.note-list-item:hover { background: var(--bg-hover); }
.note-list-item.active { background: var(--brand-light); }
.note-list-item .note-title-s {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.note-list-item .note-date { font-size: 10px; color: var(--text-tertiary); }
/* ——— Account Card ——— */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--r-lg);
padding: var(--space-lg);
transition: box-shadow 0.15s;
}
.card:hover { box-shadow: var(--shadow-sm); }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title { font-size: 14px; font-weight: 600; }
.card-body { display: grid; grid-template-columns: auto 1fr auto; gap: 8px 12px; font-size: 13px; align-items: center; }
.card-body .label { color: var(--text-tertiary); font-size: 12px; white-space: nowrap; }
.card-body .value { font-family: "SF Mono","Fira Code","Consolas",monospace; font-size: 13px; word-break: break-all; }
.card-actions { display: flex; gap: 6px; }
.card-btn {
height: 30px;
padding: 0 10px;
border: 1px solid var(--border-light);
border-radius: var(--r-sm);
background: var(--bg-surface);
font-size: 12px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
font-family: inherit;
}
.card-btn:hover { background: var(--bg-hover); border-color: var(--border-medium); }
.card-btn.edit:hover { color: var(--brand); border-color: var(--brand); background: var(--brand-light); }
.card-btn.danger:hover { color: var(--danger); border-color: var(--danger); background: var(--danger-light); }
/* ——— Modal ——— */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fade-in 0.15s ease;
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: var(--bg-surface);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
width: 480px;
max-width: 90vw;
max-height: 85vh;
display: flex;
flex-direction: column;
animation: slide-in 0.15s ease;
}
@keyframes slide-in { from { transform: translateY(8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-lg) var(--space-xl);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.modal-title { font-size: 15px; font-weight: 600; }
.modal-close {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border: none; background: transparent;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 18px; color: var(--text-tertiary);
transition: all 0.15s;
}
.modal-close:hover { background: var(--bg-hover); color: var(--text-primary); }
.modal-body { padding: var(--space-xl); overflow-y: auto; flex: 1; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding: var(--space-md) var(--space-xl);
border-top: 1px solid var(--border-light);
flex-shrink: 0;
}
/* ——— Form ——— */
.form-group { margin-bottom: var(--space-lg); }
.form-group:last-child { margin-bottom: 0; }
.form-label { display: block; font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
.form-row { display: flex; gap: var(--space-sm); align-items: center; }
/* ——— Import Area ——— */
.import-textarea {
width: 100%;
height: 90px;
padding: 10px 12px;
border: 2px solid var(--brand);
border-radius: var(--r-md);
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
background: var(--brand-light);
}
.import-hint { font-size: 11px; color: var(--text-tertiary); margin: 4px 0 12px; }
.result-box {
background: #F0FAF0;
border: 1px solid #A3E4A7;
border-radius: var(--r-md);
padding: 14px 16px;
margin-bottom: var(--space-lg);
}
.result-grid { display: grid; grid-template-columns: 70px 1fr; gap: 6px 12px; font-size: 13px; }
.alert {
padding: 8px 12px;
border-radius: var(--r-sm);
font-size: 12px;
}
.alert-warning { background: #FFF7E6; color: #8A5D00; }
/* ——— Stats ——— */
.stat-row { display: flex; gap: 6px; }
.stat {
flex: 1;
text-align: center;
padding: 8px 4px;
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--r-sm);
font-size: 11px;
}
.stat-num { font-size: 18px; font-weight: 700; }
.stat-label { color: var(--text-tertiary); font-size: 10px; }
/* ——— Toast ——— */
.toast {
position: fixed;
top: 20px;
right: 20px;
background: #1F2329;
color: #fff;
padding: 8px 16px;
border-radius: var(--r-sm);
font-size: 13px;
z-index: 9999;
animation: fade-in 0.2s ease;
}
/* ——— Tag Badge ——— */
.tag-badge {
display: inline-block;
padding: 2px 10px;
background: var(--bg-hover);
border-radius: 10px;
font-size: 11px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
}
.tag-badge:hover { background: #DEE0E3; }
.tag-badge.active { background: var(--brand); color: #fff; }
.tag-add { border: 1px dashed var(--border-medium); background: transparent; color: var(--text-tertiary); }
.tag-add:hover { border-color: var(--brand); color: var(--brand); background: transparent; }
/* ——— Helpers ——— */
.hidden { display: none !important; }
.flex-1 { flex: 1; }
.text-muted { color: var(--text-tertiary); font-size: 12px; }
.text-sm { font-size: 12px; }
.gap-xs { gap: var(--space-xs); }
.gap-sm { gap: var(--space-sm); }
.mb-sm { margin-bottom: var(--space-sm); }
.mb-md { margin-bottom: var(--space-md); }
/* ——— Scrollbar ——— */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #D0D3D7; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #B0B3B7; }
/* ——— Brand wrapper ——— */
.save-badge {
font-size: 10px;
color: var(--text-tertiary);
background: var(--bg-hover);
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
frontend/static/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoKeeText</title>
<script type="module" crossorigin src="/assets/index-BpxV2n8n.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5uIxZ5nb.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,133 @@
/**
* Tiptap 增强编辑器初始化
* 支持:文字颜色、背景高亮、图片、表格、任务列表、代码块
*/
let editor = null;
function initEditor(content) {
content = content || '';
if (editor) {
editor.destroy();
}
editor = new tiptap.Editor({
element: document.querySelector("#tiptapEditor"),
extensions: [
tiptap.StarterKit,
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 }),
],
content: content,
onUpdate: function() {
updatePreview();
scheduleSave();
},
});
renderToolbar();
}
function renderToolbar() {
var toolbar = document.querySelector("#tiptapToolbar");
if (!toolbar) return;
var html = '';
// 标题
html += '<span class="tb-group">';
html += '<button class="tb-btn" data-action="h1">H1</button>';
html += '<button class="tb-btn" data-action="h2">H2</button>';
html += '<button class="tb-btn" data-action="h3">H3</button>';
html += '</span>';
// 内联格式
html += '<span class="tb-group">';
html += '<button class="tb-btn" data-action="bold" style="font-weight:bold;">B</button>';
html += '<button class="tb-btn" data-action="italic" style="font-style:italic;">I</button>';
html += '<button class="tb-btn" data-action="strike" style="text-decoration:line-through;">S</button>';
html += '</span>';
// 颜色
html += '<span class="tb-group">';
html += '<input type="color" id="textColor" title="文字颜色" style="width:22px;height:22px;border:none;cursor:pointer;padding:0;border-radius:3px;" value="#333333">';
html += '<input type="color" id="bgColor" title="背景高亮" style="width:22px;height:22px;border:none;cursor:pointer;padding:0;border-radius:3px;" value="#fff3cd">';
html += '</span>';
// 媒体
html += '<span class="tb-group">';
html += '<button class="tb-btn" data-action="image">🖼 图片</button>';
html += '<button class="tb-btn" data-action="emoji">😊 表情</button>';
html += '</span>';
// 块级元素
html += '<span class="tb-group">';
html += '<button class="tb-btn" data-action="bulletList">• 列表</button>';
html += '<button class="tb-btn" data-action="orderedList">1. 列表</button>';
html += '<button class="tb-btn" data-action="taskList">✅ 任务</button>';
html += '<button class="tb-btn" data-action="blockquote">❝ 引用</button>';
html += '<button class="tb-btn" data-action="codeBlock">&lt;/&gt; 代码</button>';
html += '</span>';
// 表格
html += '<span class="tb-group">';
html += '<button class="tb-btn" data-action="table">📋 表格</button>';
html += '</span>';
toolbar.innerHTML = html;
// 绑定按钮事件
var actions = {
'h1': function() { editor.chain().focus().toggleHeading({ level: 1 }).run(); },
'h2': function() { editor.chain().focus().toggleHeading({ level: 2 }).run(); },
'h3': function() { editor.chain().focus().toggleHeading({ level: 3 }).run(); },
'bold': function() { editor.chain().focus().toggleBold().run(); },
'italic': function() { editor.chain().focus().toggleItalic().run(); },
'strike': function() { editor.chain().focus().toggleStrike().run(); },
'image': function() {
var url = prompt('输入图片 URL支持直接粘贴图片链接');
if (url) editor.chain().focus().setImage({ src: url }).run();
},
'emoji': function() {
var emoji = prompt('输入表情符号Win+. 打开系统表情面板粘贴):');
if (emoji) editor.chain().focus().insertContent(emoji).run();
},
'bulletList': function() { editor.chain().focus().toggleBulletList().run(); },
'orderedList': function() { editor.chain().focus().toggleOrderedList().run(); },
'taskList': function() { editor.chain().focus().toggleTaskList().run(); },
'blockquote': function() { editor.chain().focus().toggleBlockquote().run(); },
'codeBlock': function() { editor.chain().focus().toggleCodeBlock().run(); },
'table': function() { editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); },
};
toolbar.querySelectorAll('.tb-btn').forEach(function(btn) {
var action = btn.dataset.action;
if (actions[action]) {
btn.addEventListener('click', actions[action]);
}
});
// 颜色选择器
var textColor = document.querySelector('#textColor');
var bgColor = document.querySelector('#bgColor');
if (textColor) textColor.addEventListener('input', function(e) {
editor.chain().focus().setColor(e.target.value).run();
});
if (bgColor) bgColor.addEventListener('input', function(e) {
editor.chain().focus().toggleHighlight({ color: e.target.value }).run();
});
}
function updatePreview() {
var preview = document.querySelector("#previewContent");
if (preview && editor) {
preview.innerHTML = editor.getHTML();
}
}

150
frontend/static/js/notes.js Normal file
View File

@@ -0,0 +1,150 @@
/**
* 笔记页面交互逻辑
*/
var currentNoteId = null;
var saveTimer = null;
var viewMode = "edit";
function setViewMode(mode) {
viewMode = mode;
var editPane = document.querySelector("#editPane");
var previewPane = document.querySelector("#previewPane");
document.querySelectorAll(".toggle-group button").forEach(function(b) {
b.classList.toggle("active", b.dataset.mode === mode);
});
editPane.classList.remove("hidden");
previewPane.classList.remove("hidden");
if (mode === "edit") { previewPane.classList.add("hidden"); }
else if (mode === "preview") { editPane.classList.add("hidden"); }
updatePreview();
}
function createNote() {
var folderId = getActiveFolderId();
fetch("/api/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "无标题笔记", content: "", folder_id: folderId }),
}).then(function(r) { return r.json(); })
.then(function(note) { loadNote(note.id); refreshFolderNotes(); });
}
function loadNote(noteId) {
fetch("/api/notes/" + noteId)
.then(function(r) { return r.json(); })
.then(function(note) {
currentNoteId = note.id;
document.querySelector("#currentNoteId").value = note.id;
document.querySelector("#noteTitle").value = note.title;
initEditor(note.content || "");
document.querySelectorAll(".note-list-item").forEach(function(el) {
el.classList.toggle("active", parseInt(el.dataset.noteId) === note.id);
});
});
}
function scheduleSave() {
var s = document.querySelector("#saveStatus");
if (s) { s.textContent = "保存中…"; s.style.color = "#F5A623"; }
clearTimeout(saveTimer);
saveTimer = setTimeout(saveNote, 800);
}
function saveNote() {
if (!currentNoteId) return;
var title = document.querySelector("#noteTitle").value;
var content = editor ? editor.getHTML() : "";
fetch("/api/notes/" + currentNoteId, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title, content: content }),
}).then(function(r) {
if (r.ok) { var s = document.querySelector("#saveStatus"); if (s) { s.textContent = "已保存"; s.style.color = ""; } }
});
}
function filterByFolder(folderId, el) {
document.querySelectorAll(".folder-item").forEach(function(f) { f.classList.remove("active"); });
el.classList.add("active");
fetch("/api/notes?folder_id=" + folderId)
.then(function(r) { return r.json(); })
.then(function(notes) { renderNoteList(notes); });
}
function renderNoteList(notes) {
var listDiv = document.querySelector("#folderNoteList");
if (!listDiv) return;
listDiv.classList.remove("hidden");
var html = '<div style="padding:4px 0; cursor:pointer; color:var(--brand); font-size:11px;" onclick="showFolderList()">← 返回文件夹</div>';
if (!notes.length) {
html += '<div style="color:#ccc; padding:8px; text-align:center; font-size:12px;">暂无笔记</div>';
} else {
notes.forEach(function(note) {
var title = note.title || "无标题";
var date = (note.updated_at || "").substr(0, 10);
html += '<div class="note-list-item" data-note-id="' + note.id + '" onclick="loadNote(' + note.id + ')">' +
'<span class="note-title-s">' + escapeHtml(title) + '</span>' +
'<span class="note-date">' + date + '</span></div>';
});
}
listDiv.innerHTML = html;
}
function showFolderList() {
var listDiv = document.querySelector("#folderNoteList");
if (listDiv) listDiv.classList.add("hidden");
}
function getActiveFolderId() {
var active = document.querySelector(".folder-item.active");
return active ? parseInt(active.dataset.folderId) : null;
}
function refreshFolderNotes() {
var af = document.querySelector(".folder-item.active");
if (af) filterByFolder(parseInt(af.dataset.folderId), af);
}
function addFolder() {
var name = prompt("输入新文件夹名称:");
if (!name) return;
fetch("/api/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, type: "note" }) })
.then(function() { window.location.reload(); });
}
function toggleFolderMenu(btn) {
document.querySelectorAll(".folder-menu").forEach(function(m) { if (m !== btn.nextElementSibling) m.classList.add("hidden"); });
var menu = btn.nextElementSibling;
if (menu) menu.classList.toggle("hidden");
setTimeout(function() {
document.addEventListener("click", function close() { if (menu) menu.classList.add("hidden"); document.removeEventListener("click", close); }, { once: true });
}, 0);
}
function renameFolder(folderId) {
var name = prompt("新名称:"); if (!name) return;
fetch("/api/folders/" + folderId, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name }) })
.then(function() { window.location.reload(); });
}
function deleteFolder(folderId) {
if (!confirm("确定删除此文件夹?内容将移至「未分类」")) return;
fetch("/api/folders/" + folderId, { method: "DELETE" }).then(function() { window.location.reload(); });
}
function deleteCurrentNote() {
if (!currentNoteId || !confirm("确定删除?")) return;
fetch("/api/notes/" + currentNoteId, { method: "DELETE" }).then(function() { window.location.reload(); });
}
function filterByTag(tagId, el) { el.classList.toggle("active"); }
function addTag() { var n = prompt("标签名:"); if (n) window.location.reload(); }
function escapeHtml(s) { return s ? s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;") : ""; }
document.addEventListener("DOMContentLoaded", function() {
document.querySelector("#noteTitle").addEventListener("input", scheduleSave);
var firstId = document.querySelector("#firstNoteId");
if (firstId && firstId.value) loadNote(parseInt(firstId.value)); else createNote();
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-core.umd.min.js in @tiptap/core.

View File

@@ -0,0 +1,29 @@
// Tiptap 打包入口 - 将所有需要的模块挂载到全局 window.tiptap
import { Editor } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { Color } from "@tiptap/extension-color";
import { Highlight } from "@tiptap/extension-highlight";
import { TextStyle } from "@tiptap/extension-text-style";
import { Image } from "@tiptap/extension-image";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import { TaskList } from "@tiptap/extension-task-list";
import { TaskItem } from "@tiptap/extension-task-item";
// 挂载到全局,保持和 UMD 版本一样的 API
window.tiptap = {
Editor,
StarterKit,
Color,
Highlight,
TextStyle,
Image,
Table,
TableRow,
TableCell,
TableHeader,
TaskList,
TaskItem,
};

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-code-block-lowlight.umd.min.js in @tiptap/extension-code-block-lowlight.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-color.umd.min.js in @tiptap/extension-color.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-highlight.umd.min.js in @tiptap/extension-highlight.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-image.umd.min.js in @tiptap/extension-image.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-table-cell.umd.min.js in @tiptap/extension-table-cell.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-table-header.umd.min.js in @tiptap/extension-table-header.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-table-row.umd.min.js in @tiptap/extension-table-row.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-table.umd.min.js in @tiptap/extension-table.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-task-item.umd.min.js in @tiptap/extension-task-item.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-task-list.umd.min.js in @tiptap/extension-task-list.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-extension-text-style.umd.min.js in @tiptap/extension-text-style.

View File

@@ -0,0 +1 @@
Couldn't find the requested file /dist/tiptap-starter-kit.umd.min.js in @tiptap/starter-kit.

View File

@@ -0,0 +1,249 @@
/**
* 网站管理页面交互逻辑
*/
var currentSiteId = null;
var parsedImportData = null;
// ===== 网站选择 =====
function selectWebsite(siteId, el) {
currentSiteId = siteId;
document.querySelector("#currentSiteId").value = siteId;
document.querySelectorAll(".site-item").forEach(function(s) { s.classList.remove("active"); });
if (el) el.classList.add("active");
fetch("/api/websites/" + siteId).then(function(r) { return r.json(); }).then(renderSiteDetail);
}
function renderSiteDetail(site) {
var fName = getFolderName(site.folder_id);
document.querySelector("#siteHeaderInfo").innerHTML =
'<span style="font-size:11px;color:var(--text-tertiary);">' + fName + ' / </span>' +
'<span style="font-size:15px;font-weight:600;">' + site.name + '</span>' +
'<a href="' + site.url + '" target="_blank" style="font-size:11px;color:var(--brand);margin-left:8px;text-decoration:none;">' + site.url + '</a>';
document.querySelector("#editSiteBtn").classList.remove("hidden");
document.querySelector("#addAccountBtn").classList.remove("hidden");
var cards = document.querySelector("#accountCards");
var accounts = site.accounts || [];
if (!accounts.length) {
cards.innerHTML = '<div style="text-align:center;padding:80px 20px;color:var(--text-placeholder);"><p>暂无账号</p></div>';
return;
}
cards.innerHTML = accounts.map(function(a) {
return '<div class="card" style="margin-bottom:12px;">' +
'<div class="card-header">' +
'<span class="card-title">👤 ' + (a.remark || '账号') + '</span>' +
'<div class="card-actions">' +
'<button class="card-btn edit" onclick="editAccount(' + a.id + ')">✏️ 编辑</button>' +
'<button class="card-btn danger" onclick="deleteAccount(' + a.id + ')">🗑 删除</button>' +
'</div>' +
'</div>' +
'<div class="card-body">' +
'<span class="label">账号</span><span class="value">' + esc(a.username) + '</span>' +
'<button class="card-btn" onclick="copyText(\'' + esc(a.username) + '\')">复制</button>' +
'<span class="label">密码</span><span class="value pw-hidden" id="pw-' + a.id + '" data-pw="' + esc(a.password) + '">••••••••</span>' +
'<div style="display:flex;gap:4px;">' +
'<button class="card-btn" onclick="togglePassword(' + a.id + ')">显示</button>' +
'<button class="card-btn" onclick="copyText(\'' + esc(a.password) + '\')">复制</button>' +
'</div>' +
'<span class="label">说明</span><span style="color:var(--text-secondary);font-size:12px;">' + (a.remark || '-') + '</span><span></span>' +
'</div></div>';
}).join("");
}
function esc(s) { return (s||"").replace(/'/g,"\\'"); }
function togglePassword(id) {
var el = document.querySelector("#pw-" + id);
if (!el) return;
el.textContent = el.textContent === "••••••••" ? el.dataset.pw : "••••••••";
}
function copyText(t) {
navigator.clipboard.writeText(t).then(function() {
var toast = document.createElement("div");
toast.className = "toast";
toast.textContent = "已复制";
document.body.appendChild(toast);
setTimeout(function() { toast.remove(); }, 1500);
});
}
function getFolderName(fid) {
if (!fid) return "未分类";
var el = document.querySelector('.folder-group[data-folder-id="' + fid + '"] .folder-item span:nth-child(2)');
return el ? el.textContent.replace(/📁\s*/, "").trim() : "未分类";
}
// ===== 文件夹 =====
function toggleFolder(el) {
var ch = el.parentElement.querySelector(".folder-children");
var arrow = el.querySelector(".folder-arrow");
if (!ch) return;
if (ch.style.display === "none") { ch.style.display = "block"; arrow.textContent = "▼"; }
else { ch.style.display = "none"; arrow.textContent = "▶"; }
}
function toggleFolderMenu(btn) {
document.querySelectorAll(".folder-menu").forEach(function(m) { if (m !== btn.nextElementSibling) m.classList.add("hidden"); });
var menu = btn.nextElementSibling;
if (menu) menu.classList.toggle("hidden");
setTimeout(function() {
document.addEventListener("click", function c() { if (menu) menu.classList.add("hidden"); document.removeEventListener("click", c); }, { once: true });
}, 0);
}
function renameFolder(id) { var n = prompt("新名称:"); if (n) fetch("/api/folders/" + id, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: n }) }).then(function() { window.location.reload(); }); }
function deleteFolder(id) { if (confirm("确定删除?内容将移至「未分类」")) fetch("/api/folders/" + id, { method: "DELETE" }).then(function() { window.location.reload(); }); }
function showAddFolder() { var n = prompt("文件夹名:"); if (n) fetch("/api/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: n, type: "website" }) }).then(function() { window.location.reload(); }); }
// ===== 智能导入 =====
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"); }
function parseImport() {
var text = document.querySelector("#importText").value.trim();
if (!text) return alert("请先粘贴文本");
fetch("/api/import/parse", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: text }) })
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) return alert(d.error);
parsedImportData = d;
document.querySelector("#parseResult").classList.remove("hidden");
document.querySelector("#parseGrid").innerHTML =
'<span style="color:var(--text-secondary);">网址</span><span>' + d.url + '</span>' +
'<span style="color:var(--text-secondary);">网站名</span><span>' + d.site_name + '</span>' +
'<span style="color:var(--text-secondary);">账号</span><span style="font-family:monospace;">' + d.username + '</span>' +
'<span style="color:var(--text-secondary);">密码</span><span style="font-family:monospace;">' + d.password + '</span>' +
'<span style="color:var(--text-secondary);">备注</span><span>' + (d.remark || '(无)') + '</span>';
var alert = document.querySelector("#duplicateAlert");
if (d.action === "append") {
alert.classList.remove("hidden");
alert.innerHTML = '⚠️ <strong>' + d.matched_site.name + '</strong> 已存在(' + d.matched_site.account_count + '个账号),将追加进去';
if (d.matched_site.folder_id) document.querySelector("#importFolderSelect").value = d.matched_site.folder_id;
} else { alert.classList.add("hidden"); }
});
}
function confirmImport() {
if (!parsedImportData) return;
var d = parsedImportData;
var fid = document.querySelector("#importFolderSelect").value || null;
var nf = document.querySelector("#importNewFolder").value.trim();
if (nf) {
fetch("/api/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: nf, type: "website" }) })
.then(function(r) { return r.json(); }).then(function(f) { doImport(d, f.id); });
} else { doImport(d, fid); }
}
function doImport(d, fid) {
fetch("/api/websites").then(function(r) { return r.json(); }).then(function(sites) {
var s = sites.find(function(x) { return x.url === d.url; });
if (s) { addAccountToSite(s.id, d); }
else {
fetch("/api/websites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: d.site_name, url: d.url, folder_id: fid }) })
.then(function(r) { return r.json(); }).then(function(ns) { addAccountToSite(ns.id, d); });
}
});
}
function addAccountToSite(sid, d) {
fetch("/api/websites/" + sid + "/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: d.username, password: d.password, remark: d.remark }) })
.then(function() { 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;
fetch("/api/websites/" + currentSiteId).then(function(r) { return r.json(); }).then(function(s) {
document.querySelector("#websiteName").value = s.name;
document.querySelector("#websiteUrl").value = s.url;
document.querySelector("#websiteFolder").value = s.folder_id || "";
document.querySelector("#websiteModal").classList.remove("hidden");
});
}
function saveWebsite() {
var id = document.querySelector("#editWebsiteId").value;
var data = { name: document.querySelector("#websiteName").value, url: document.querySelector("#websiteUrl").value, folder_id: document.querySelector("#websiteFolder").value || null };
fetch(id ? "/api/websites/" + id : "/api/websites", { method: id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
.then(function() { hideWebsiteModal(); window.location.reload(); });
}
// ===== 账号弹窗 =====
function showAddAccount() {
if (!currentSiteId) return alert("请先选择网站");
document.querySelector("#accountModalTitle").textContent = "添加账号";
document.querySelector("#accountUsername").value = "";
document.querySelector("#accountPassword").value = "";
document.querySelector("#accountRemark").value = "";
document.querySelector("#editAccountId").value = "";
document.querySelector("#accountPassword").type = "password";
document.querySelector("#accountModal").classList.remove("hidden");
}
function hideAccountModal() { document.querySelector("#accountModal").classList.add("hidden"); }
function editAccount(accId) {
document.querySelector("#accountModalTitle").textContent = "编辑账号";
document.querySelector("#editAccountId").value = accId;
document.querySelector("#accountPassword").type = "password";
fetch("/api/accounts/" + accId).then(function(r) { return r.json(); }).then(function(a) {
document.querySelector("#accountUsername").value = a.username || "";
document.querySelector("#accountPassword").value = a.password || "";
document.querySelector("#accountRemark").value = a.remark || "";
document.querySelector("#accountModal").classList.remove("hidden");
});
}
function togglePwVisibility() {
var pw = document.querySelector("#accountPassword");
var btn = pw.parentElement.querySelector("button");
if (pw.type === "password") { pw.type = "text"; btn.textContent = "隐藏"; }
else { pw.type = "password"; btn.textContent = "显示"; }
}
function saveAccount() {
var sid = document.querySelector("#currentSiteId").value;
var aid = document.querySelector("#editAccountId").value;
var data = { username: document.querySelector("#accountUsername").value, password: document.querySelector("#accountPassword").value, remark: document.querySelector("#accountRemark").value };
if (aid) {
fetch("/api/accounts/" + aid, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
.then(function() { hideAccountModal(); selectWebsite(sid); });
} else {
fetch("/api/websites/" + sid + "/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
.then(function() { hideAccountModal(); selectWebsite(sid); });
}
}
function deleteAccount(accId) {
if (!confirm("确定删除?")) return;
fetch("/api/accounts/" + accId, { method: "DELETE" }).then(function() { selectWebsite(currentSiteId); });
}
// ===== 搜索 =====
function filterSites() {
var q = document.querySelector("#siteSearch").value.toLowerCase().trim();
document.querySelectorAll(".site-item").forEach(function(item) {
if (!q) { item.style.display = "flex"; return; }
var name = (item.querySelector(".site-name")?.textContent || "").toLowerCase();
var url = (item.dataset.siteUrl || "").toLowerCase();
item.style.display = (name.indexOf(q) !== -1 || url.indexOf(q) !== -1) ? "flex" : "none";
});
}

19
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
root: '.',
base: '/',
build: {
outDir: 'static/dist',
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'index.html'),
},
},
server: {
port: 5173,
},
})

1
memory/.session-last Normal file
View File

@@ -0,0 +1 @@
{"projectPath": "E:\\AI\\claude\\new\\mokeeText", "endedAt": "2026-06-28T05:40:00+08:00", "sessionId": "session-20260628-4"}

4
memory/MEMORY.md Normal file
View File

@@ -0,0 +1,4 @@
- [2026-06-23 第1次会话](session-20260623-1.md) — MoKeeText 项目从零搭建到 Docker 部署
- [2026-06-23 第2次会话](session-20260623-2.md) — 审查 Mokee Gateway 接入文档 + 编写完整接入方案
- [2026-06-24 第3次会话](session-20260624-3.md) — 修复 UserChip Widget 集成问题,对齐 control-web 成功模式
- [2026-06-28 第4次会话](session-20260628-4.md) — 添加「切换系统」按钮到顶栏,部署生产环境

View File

@@ -0,0 +1,104 @@
---
name: session-20260623-1
description: 2026-06-23 第1次会话 — MoKeeText 项目从零搭建到 Docker 部署
metadata:
type: session
date: 2026-06-23
sessionNumber: 1
startTime: 2026-06-23T14:00:00+08:00
---
# 2026-06-23 第1次会话
## 会话摘要
从零搭建 Web 记事本 MoKeeText包含笔记编辑器Tiptap 增强型、网站账号密码管理、智能导入。技术栈FastAPI + Vue 3 + Vite + MySQL。已部署到生产服务器 Docker。
## 关键决策
### 技术选型
- **做了什么**: 选择 FastAPI + Vue3 + MySQL放弃纯 Jinja2 模板方案
- **原因**: Vue 组件化更适合复杂交互FastAPI 轻量够用
- **结果**: 前后端分离,前端 Vite 构建静态文件,后端纯 API
### 编辑器选型
- **做了什么**: 使用 Tiptap 增强型编辑器
- **原因**: 支持文字颜色、图片粘贴上传、表格、表情,飞书级别体验
- **结果**: Tiptap 通过 esbuild 打包成本地文件(~460KB无需 CDN
### 图片存储
- **做了什么**: 图片存入 MySQL `uploads` 表的 LONGBLOB 字段,不存文件系统
- **原因**: 部署简单,备份只要导出数据库
- **结果**: 上传 API 返回 `/api/file/{id}`,支持 20MB 以内的图片
### 部署架构
- **做了什么**: Docker Compose 部署,前端 Nginx :3006后端 FastAPI :4006
- **原因**: 生产环境标准化
- **结果**: 前端不代理 APIVue 直接跨域请求后端 `https://mokeetext.server.zgitm.com`
## 代码结构
```
mokeeText/
├── app.py # FastAPI 入口 + CORS
├── models.py # 6个模型Folder/Note/Tag/Website/Account/Upload
├── routes/
│ ├── notes.py # 笔记 CRUD + 分享 + 移动
│ ├── websites.py # 网站/账号/文件夹 CRUD + 排序
│ ├── import_api.py # 智能导入解析
│ ├── upload.py # 图片上传/读取存DB
│ └── share.py # 分享公开查看页
├── src/ # Vue 3 源码
│ ├── App.vue # 左导航 + 顶栏 + 底栏
│ ├── views/
│ │ ├── NotesView.vue # 笔记文档列表 + 编辑器
│ │ ├── WebsitesView.vue # 网站管理 + 弹窗
│ │ └── SharesView.vue # 分享管理
│ └── components/
│ ├── NoteEditor.vue # Tiptap 编辑器(图片粘贴/拖拽/预览)
│ ├── Modal.vue # 通用弹窗
│ ├── ConfirmModal.vue # 确认弹窗
│ └── PromptModal.vue # 输入弹窗
├── static/dist/ # Vue 构建产物
├── nginx.conf # 前端 Nginx 配置(纯静态,无代理)
├── Dockerfile # 后端镜像
├── Dockerfile.frontend # 前端镜像
└── docker-compose.yml # 编排
```
## 数据库MySQL 10.20.1.122:3306/mokeetext
- 用户mokeetext / mokee.
- 所有表均有 `created_by` / `updated_by` 字段(默认 admin
- `notes.share_token` + `share_expires_at` 支持分享链接有效期
- `accounts.sort_order` 支持账号排序
## 生产环境
- 服务器101.132.183.138 root/mokee2016.
- 项目目录:/data/mokee/mokeetext
- 前端https://mokeetext.zgitm.com (:3006)
- 后端https://mokeetext.server.zgitm.com (:4006)
- 日志:/data/mokee/mokeetext/logs按天轮转保留30天
- ICP备案湘ICP备19021539号
## UI 特性
- 左侧可收起导航(展开"mokee编辑器",收起"M"
- 笔记页文件夹侧边栏可收起
- 分享链接支持 7天/30天/1年/永久 四种有效期
- 图片点击全屏预览(带关闭按钮)
- Ctrl+S 保存toast 提示
- 弹窗全部 Vue 组件(非浏览器 alert/confirm
- 移动端响应式适配768px 断点)
## 最新改动UI 优化)
- 文件夹用圆点替代 emoji胶囊数量标签
- 左侧导航选中项蓝色左边框指示器
- "mokee编辑器"品牌名(收起显示 M
- 文件夹侧边栏可收起
- 分享页卡片宽度 1100px超宽滚动
- 图片上传存 DB + 粘贴/拖拽自动上传 + 点击预览带关闭按钮
- 前端不代理,直接跨域调后端
- 后端 CORS 已开
- 全站响应式768px 断点)
## 待处理
- [ ] favicon.ico 前端图标
- [ ] mokeetext.server.zgitm.com 反向代理配置(运维处理)
- [ ] 后续可加用户登录/权限系统

View File

@@ -0,0 +1,89 @@
---
name: session-20260623-2
description: 2026-06-23~24 第2次会话 — 审查接入文档 + 编写方案 + 完成代码改造 + 部署到生产服务器
metadata:
type: session
date: 2026-06-23
sessionNumber: 2
startTime: 2026-06-23T16:00:00+08:00
---
# 2026-06-23 第2次会话
## 会话摘要
审查 MoKeeText 接入 Mokee Gateway 统一登录网关的完整文档,发现 8 个问题3 重大 + 5 中等),编写了完整的接入实施方案。
## 关键操作
### 接入文档审查
- **做了什么**: 逐节审查网关接入文档,对比当前 MoKeeText 项目实际情况
- **发现的问题**:
1. 🔴 后端中间件:文档给的是 Flask项目是 FastAPI
2. 🔴 所有 API 缺少用户数据隔离(当前 created_by 硬编码 admin
3. 🔴 前端源码结构分析Vue 3 + Vite + fetch非 axios
4. 🟡 CORS `allow_origins=["*"]``allow_credentials=True` 冲突
5. 🟡 图片存储是否迁移到文件服务(当前存 MySQL LONGBLOB
6. 🟡 本地开发无网关环境如何调试
7. 🟡 现有 admin 数据迁移策略
8. 🟡 前端 API 请求路径变化(直连后端 → 网关)
- **结果**: 用户澄清了关键问题 — 前端源码在当前目录、不修改当前项目结构(作为基线)、现有数据归属 admin、网关有 admin 用户
### 接入方案编写
- **做了什么**: 编写完整 12 个 Task 的接入实施方案,保存到 `docs/superpowers/plans/2026-06-23-mokeetext-gateway-integration.md`
- **方案覆盖**:
- Task 0: 网关数据库初始化(菜单/角色/管理员 SQL
- Task 1-2: 后端 FastAPI 中间件 + CORS 改造
- Task 3: 5 个路由文件用户数据隔离改造
- Task 4-9: 前端 env/axios/路由守卫/UserChip/v-permission
- Task 10: 按钮权限控制
- Task 11-12: 部署更新
- **关键设计决策**:
- 本地开发走 `localhost` 直连后端 + `X-Dev-Mock-User` 头模拟用户
- 生产环境走网关axios 拦截器自动携带 Token + X-System-Code
- 图片读取 API 不鉴权(笔记中 img 标签无法带 Authorization
- 前端保留 fetchdev 模式)+ axios生产模式双通道
- **结果**: 方案已通过 writing-plans 自检spec coverage ✓, placeholder scan ✓, type consistency ✓)
## 重要信息
- 系统编码: `mokeetext-edit`
- 网关地址: `gw.server.zgitm.com`
- 登录地址: `login.user.zgitm.com`
- UserChip CDN: `web.gw.zgitm.com/widgets/user-chip.css|js`
- 文件服务: `file.wg.zgitm.com`(可选,当前项目不用)
- 网关管理员账号: `admin_mokeetext-edit` / `admin123` BCrypt
## 已完成的代码改动
### 后端Task 1-3
-`middleware/__init__.py` + `middleware/user_context.py` — UserContext 辅助类 + FastAPI 中间件
-`app.py` — CORS 更新3 个域名 + localhost + 注册 UserContextMiddleware
-`routes/notes.py` — 所有接口加 created_by 过滤 + request 参数
-`routes/websites.py` — 同上(网站/账号/文件夹)
-`routes/import_api.py` — 同上
-`routes/upload.py` — 上传记录 created_by读取保持公开
-`routes/share.py` — 保持原样(公开访问不走网关)
### 前端Task 4-6
-`.env` / `.env.production` — 环境变量
-`src/utils/request.js` — Axios 实例 + 拦截器
-`src/composables/useApi.js` — 双模式dev (fetch+mock) / production (axios+gateway)
-`src/router.js` — 路由守卫(无 Token → 登录页localhost 豁免)
-`src/directives/permission.js` — v-permission 指令
-`src/main.js` — 注册 permission + 动态加载 UserChip Widget
-`src/App.vue` — UserChip 占位 + 本地 mock 头像
-`index.html` — UserChip CSS
-`src/components/NoteEditor.vue` — 双地址GW_BASE上传+ FILE_BASE读取
-`package.json` — 添加 dev/build scripts安装 axios
- ✅ 构建成功:`static/dist/` 已生成
## 设计决策确认
- X-User-Id = `"admin"` 格式 → 历史数据无需迁移
- 分享页`/share/{token}`走后端直连(公开),`/api/file/{id}`也走后端直连(公开)
- 图片上传走网关(鉴权),读取走后端直连(<img>标签 + 分享页兼容)
- 网关 Authorization 头为空时从 Cookie 取 Token
- 当前项目代码结构保持不变(不重构)
## 待处理
- [ ] Task 0: 网关数据库初始化 SQL需要网关 DB 访问权限)
- [ ] Task 11-12: 生产环境部署(需要 SSH 到服务器)
- [ ] 对接测试清单逐项验证

View File

@@ -0,0 +1,46 @@
---
name: session-20260624-3
description: 2026-06-24 第3次会话 — 修复 UserChip Widget 集成问题,对齐 control-web 成功模式
metadata:
type: session
date: 2026-06-24
sessionNumber: 3
startTime: 2026-06-24T03:35:00+08:00
---
# 2026-06-24 第3次会话
## 会话摘要
用户反馈登录后页面右上角 UserChip Widget 不显示。通过对比正常工作的 control-web 项目,发现 3 个差异,逐一修复后重新构建部署。
## 关键操作
### UserChip Widget 问题诊断
- **做了什么**: 对比 `E:\AI\claude\new\deployServer\control-web`(正常)和当前 mokeeText异常的 Widget 接入代码
- **发现的差异**:
1. 🔴 `main.js``window.process = { env: {} }` 和 widget 加载被 `if (hostname !== 'localhost')` 包裹control-web 是无条件执行
2. 🔴 `App.vue``#mokee-user-chip` 使用自闭合 `<div ... />`control-web 是正常闭合 `<div ...></div>`
3. 🟡 `App.vue` 使用 `v-if/v-else` 条件渲染 widget 占位 divcontrol-web 始终渲染
4. 🟡 `router.js` 登录重定向缺少 `redirect` 参数
- **结果**: 全部对齐到 control-web 的成功模式
### 代码修改3 个文件)
-`src/main.js` — 移除条件判断,无条件执行 polyfill + widget 加载
-`src/App.vue` — 正常闭合标签 + 始终渲染 widget 占位 + 移除 mock 头像
-`src/router.js` — 添加 `WEBSITE_URL` + `redirect` 参数到登录跳转
### 构建部署
-`npx vite build` — 构建成功,生成 `index-D7pnBl5K.js` + `index-CH4J61s9.css`
- ✅ SFTP 上传到 101.132.183.138 `/data/mokee/mokeetext/static/dist/`
-`docker compose up -d --build frontend` — 重建并重启前端容器
- ✅ 验证容器运行正常nginx 返回新 index.htmlJS 包含正确代码
## 重要信息
- control-web 参考项目路径: `E:\AI\claude\new\deployServer\control-web`
- control-web 使用 `createWebHashHistory`mokeeText 使用 `createWebHistory`(保持不变)
- 生产服务器: 101.132.183.138 root/mokee2016.
- 生产地址: https://mokeetext.zgitm.com
## 待处理
- [ ] 用户验证线上 Widget 是否正常显示
- [ ] 如有问题查看浏览器控制台错误

View File

@@ -0,0 +1,96 @@
---
name: session-20260628-4
description: 2026-06-28 第4次会话 — 切换系统按钮 + 目录整理 + 项目/部署文档 + 文件服务接入与回退
metadata:
type: session
date: 2026-06-28
sessionNumber: 4
startTime: 2026-06-28T04:45:00+08:00
---
# 2026-06-28 第4次会话
## 会话摘要
1. 顶栏添加「切换系统」按钮 → `https://web.gw.zgitm.com/select`
2. 项目目录彻底整理backend/ frontend/ deploy/ 三目录分离
3. 编写项目文档 `docs/README.md` 和部署文档 `docs/DEPLOY.md`
4. 图片存储接入文件服务 → 部署排错 → 最终回退到 DB 存储,保留文件服务代码备用
## 代码改动
### 1. src/App.vue — 顶栏右侧加「切换系统」按钮
- **位置**: 顶栏 [☰ MoKeeText] 右侧空白
- **跳转**: `https://web.gw.zgitm.com/select`(外部链接,非 Vue 路由)
- **样式**: ghost 风格 `.switch-system-btn`hover 变蓝
### 2. 项目目录整理
**整理前** → 根目录混在一起app.py / index.html / Dockerfile / routes / src / static 等全在一个目录)
**整理后**:
```
mokeeText/
├── backend/ # FastAPI 后端
│ ├── app.py, models.py, config.py, logger.py, init_db.py
│ ├── file_service.py # 文件服务客户端(备用,当前未启用)
│ ├── requirements.txt, Dockerfile, .dockerignore
│ ├── routes/ # notes, websites, import_api, upload, share
│ └── middleware/ # user_context
├── frontend/ # Vue 3 前端
│ ├── index.html, package.json, vite.config.js
│ ├── Dockerfile.frontend, nginx.conf, .dockerignore
│ ├── src/ # Vue 源码
│ └── static/ # dist + js/vendor + css + uploads
├── deploy/ # 部署
│ ├── docker-compose.yml
│ └── deploy.py # 日常部署SSH 密钥认证)
├── docs/
│ ├── README.md # 项目文档
│ └── DEPLOY.md # 部署文档
├── memory/ # 会话记忆
└── templates/ # 旧 Jinja2 模板(已废弃)
```
### 3. 配置更新
- **docker-compose.yml**: build context → `./backend` / `./frontend`
- **Dockerfile**: 新增 COPY file_service.py
- **deploy.py**: 路径更新 + 改用 SSH 密钥(无密码),运行: `python deploy/deploy.py`
- **.dockerignore**: 分别放在 backend/ 和 frontend/ 下
### 4. 图片存储 — 接入文件服务后又回退
**过程**:
1. 新增 `backend/file_service.py` — 封装文件服务 API系统码 `mokeetext-edit`,目录 `/mokeetext-images/`
2. 修改 models.py — Upload 表新增 `file_id` 字段data 改为 nullable
3. 修改 init_db.py — 新增 migrate() ALTER TABLE
4. 改造 upload.py — 上传走文件服务,读取兼容 file_id + blob 双通道
5. 添加 requests 依赖
**排错过程**:
- 🔴 Dockerfile 遗漏了 COPY file_service.py → 容器内无此文件,上传走旧代码
- 🔴 SCP 时把 routes/upload.py 传到 backend/ 根目录而非 backend/routes/ 子目录 → 容器用的是旧代码
-`--no-cache` 强制重建 + 修正文件路径后成功接入,文件服务出现 2 张图片
**最终决定**: 用户要求恢复 DB 存储,保留文件服务代码
- `upload.py` 改回 DB LONGBLOB 存储
- 读取优先 blobfile_id 存在时走文件服务(兼容之前 2 张图)
- `file_service.py` 保留不删,注释标注备用切换方式
## 当前状态
- **图片存储**: MySQL LONGBLOB + 文件服务兼容读取(备用)
- **file_service.py**: 保留,如需切换取消注释即可
## 部署
- ✅ 目录迁移(服务器同步整理)
- ✅ Docker 重建backend + frontend 均在运行)
- ✅ 数据库迁移file_id 列已添加)
- ✅ deploy.py 改用 SSH 密钥认证
- ✅ 服务器 SSH 密钥认证正常工作
## 重要资产
- 文件服务 API 文档: `E:\AI\claude\new\fileServer\goFileService\docs\api.md`
- 参考项目 control-web: `E:\AI\claude\new\deployServer\control-web`
- 项目文档: `docs/README.md` + `docs/DEPLOY.md`
## 待处理
- [ ]`templates/` 目录可删除Jinja2 已废弃)

37
templates/base.html Normal file
View File

@@ -0,0 +1,37 @@
<!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="topnav">
<a href="/notes" class="topnav-brand">MoKeeText</a>
<a href="/notes" class="topnav-link {% if active_page == 'notes' %}active{% endif %}">笔记</a>
<a href="/websites" class="topnav-link {% if active_page == 'websites' %}active{% endif %}">网站管理</a>
<!-- 用户 Widget后续接入 -->
<div class="user-widget" title="用户">
<span class="user-avatar">M</span>
<span class="user-name">Admin</span>
</div>
</nav>
<!-- 主内容区 -->
<div class="layout">
{% block content %}{% endblock %}
</div>
<!-- 底栏 -->
<footer class="footer">
<span>MoKeeText v1.0</span>
<span class="footer-icp">ICP备案号待接入</span>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

77
templates/notes.html Normal file
View File

@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block head %}
<script src="/static/js/vendor/tiptap-bundle.min.js"></script>
{% endblock %}
{% block content %}
<!-- 左侧边栏 -->
<aside class="sidebar">
<input class="input" type="text" placeholder="搜索笔记…" id="noteSearch">
<button class="btn btn-primary btn-block" onclick="createNote()">新建笔记</button>
<div class="sidebar-label">
<span>文件夹</span>
<span onclick="addFolder()" style="cursor:pointer; color:var(--brand); font-size:15px;" title="新建文件夹">+</span>
</div>
<div style="overflow-y:auto; flex-shrink:1;">
{% for folder in folders %}
<div class="folder-item {% if loop.first %}active{% endif %}" data-folder-id="{{ folder.id }}"
onclick="filterByFolder({{ folder.id }}, this)" style="position:relative;">
<span>📂</span>
<span style="flex:1;">{{ folder.name }}</span>
<span class="count">{{ folder.notes|length }}</span>
<span class="folder-more" onclick="event.stopPropagation();toggleFolderMenu(this)" title="更多"></span>
<div class="folder-menu hidden">
<div class="folder-menu-item" onclick="renameFolder({{ folder.id }})">✏️ 重命名</div>
<div class="folder-menu-item danger" onclick="deleteFolder({{ folder.id }})">🗑 删除</div>
</div>
</div>
{% endfor %}
</div>
<!-- 笔记列表 -->
<div id="folderNoteList" class="hidden" style="flex:1; overflow-y:auto;"></div>
<div class="sidebar-label">标签</div>
<div style="display:flex; flex-wrap:wrap; gap:4px;">
{% for tag in tags %}
<span class="tag-badge" onclick="filterByTag({{ tag.id }}, this)">{{ tag.name }}</span>
{% endfor %}
<span class="tag-badge tag-add" onclick="addTag()">+</span>
</div>
</aside>
<!-- 右侧编辑器 -->
<section class="content">
<div class="content-header">
<input class="content-title" id="noteTitle" value="" placeholder="无标题笔记">
<div class="toggle-group">
<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 class="save-badge" id="saveStatus">已保存</span>
<button class="btn btn-danger-ghost btn-sm" onclick="deleteCurrentNote()">删除</button>
</div>
<div class="editor-wrap" id="editorContainer">
<div class="editor-pane" id="editPane">
<div id="tiptapToolbar" class="tb-bar"></div>
<div id="tiptapEditor"></div>
</div>
<div class="editor-pane hidden" id="previewPane">
<div id="previewContent" style="font-size:14px; line-height:1.8;"></div>
</div>
</div>
</section>
<input type="hidden" id="currentNoteId" value="">
{% if notes %}<input type="hidden" id="firstNoteId" value="{{ notes[0].id }}">{% endif %}
{% endblock %}
{% block scripts %}
<script src="/static/js/editor.js"></script>
<script src="/static/js/notes.js"></script>
{% endblock %}

198
templates/websites.html Normal file
View File

@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block content %}
<!-- 左侧边栏 -->
<aside class="sidebar">
<div class="stat-row">
<div class="stat">
<div class="stat-num" style="color:var(--brand);">{{ websites|length }}</div>
<div class="stat-label">网站</div>
</div>
<div class="stat">
<div class="stat-num" style="color:var(--success);" id="accountCount">-</div>
<div class="stat-label">账号</div>
</div>
</div>
<button class="btn btn-primary btn-block" onclick="showAddWebsite()">添加网站</button>
<button class="btn btn-outline btn-block" onclick="showImportModal()">智能导入</button>
<div class="sidebar-label">
<span>文件夹</span>
<span onclick="showAddFolder()" style="cursor:pointer; color:var(--brand); font-size:15px;">+</span>
</div>
<input class="input mb-sm" type="text" placeholder="搜索网站或网址…" id="siteSearch" oninput="filterSites()">
<div style="flex:1; overflow-y:auto;">
{% for folder in folders %}
<div class="folder-group" data-folder-id="{{ folder.id }}">
<div class="folder-item" onclick="toggleFolder(this)" style="position:relative;">
<span class="folder-arrow"></span>
<span style="flex:1; font-weight:500;">📁 {{ folder.name }}</span>
<span class="count">-</span>
<span class="folder-more" onclick="event.stopPropagation();toggleFolderMenu(this)"></span>
<div class="folder-menu hidden">
<div class="folder-menu-item" onclick="renameFolder({{ folder.id }})">✏️ 重命名</div>
<div class="folder-menu-item danger" onclick="deleteFolder({{ folder.id }})">🗑 删除</div>
</div>
</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-site-url="{{ site.url }}"
onclick="selectWebsite({{ site.id }}, this)">
<span>🌐</span>
<span class="site-name">{{ site.name }}</span>
<span class="site-count">{{ site.accounts|length }}个</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
<!-- 未分类 -->
<div class="sidebar-label">未分类</div>
{% for site in websites if not site.folder_id %}
<div class="site-item" data-site-id="{{ site.id }}" data-site-url="{{ site.url }}"
onclick="selectWebsite({{ site.id }}, this)">
<span>🌐</span>
<span class="site-name">{{ site.name }}</span>
<span class="site-count">{{ site.accounts|length }}个</span>
</div>
{% endfor %}
</div>
</aside>
<!-- 右侧网站详情 -->
<section class="content">
<div class="content-header">
<div id="siteHeaderInfo" class="text-muted">选择左侧网站查看账号</div>
<div style="display:flex; gap:6px;">
<button class="btn btn-ghost btn-sm hidden" id="editSiteBtn" onclick="editWebsite()">✏️ 编辑</button>
<button class="btn btn-outline btn-sm hidden" id="addAccountBtn" onclick="showAddAccount()">+ 添加账号</button>
</div>
</div>
<div class="editor-pane" id="accountCards" style="flex:1;">
<div style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
<div style="font-size:48px; margin-bottom:16px; opacity:0.4;">🔐</div>
<p>选择左侧网站查看账号密码</p>
</div>
</div>
</section>
<!-- ===== 智能导入弹窗 ===== -->
<div class="modal-overlay hidden" id="importModal" onclick="if(event.target===this)hideImportModal()">
<div class="modal" style="width:520px;">
<div class="modal-header">
<span class="modal-title">智能导入</span>
<button class="modal-close" onclick="hideImportModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">粘贴账号信息</label>
<textarea class="import-textarea" id="importText"
placeholder="https://github.com 代码网站&#10;zhangsan@qq.com&#10;Abc123&#10;个人开发账号"></textarea>
<div class="import-hint">首行=网址+空格+网站名 | 第2行=账号 | 第3行=密码 | 第4行=备注</div>
</div>
<button class="btn btn-primary btn-block mb-md" onclick="parseImport()">🔍 自动识别</button>
<div class="result-box hidden" id="parseResult">
<div style="font-weight:600; color:#166534; margin-bottom:10px;">识别结果</div>
<div class="result-grid" id="parseGrid"></div>
<div class="form-group" style="margin-top:12px;">
<label class="form-label">存放文件夹</label>
<div class="form-row">
<select class="select" id="importFolderSelect">
<option value="">已有文件夹…</option>
{% for folder in folders %}
<option value="{{ folder.id }}">{{ folder.name }}</option>
{% endfor %}
</select>
<span class="text-muted"></span>
<input class="input" type="text" id="importNewFolder" placeholder="新建文件夹…">
</div>
</div>
<div class="alert alert-warning hidden mb-md" id="duplicateAlert"></div>
<div class="modal-footer" style="padding:0; border:none;">
<button class="btn btn-ghost" onclick="hideImportModal()">取消</button>
<button class="btn btn-primary" onclick="confirmImport()">确认添加</button>
</div>
</div>
</div>
</div>
</div>
<!-- ===== 网站弹窗 ===== -->
<div class="modal-overlay hidden" id="websiteModal" onclick="if(event.target===this)hideWebsiteModal()">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="websiteModalTitle">添加网站</span>
<button class="modal-close" onclick="hideWebsiteModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">网站名</label>
<input class="input" id="websiteName" placeholder="例如GitHub">
</div>
<div class="form-group">
<label class="form-label">网址</label>
<input class="input" id="websiteUrl" placeholder="https://github.com">
</div>
<div class="form-group">
<label class="form-label">文件夹</label>
<select class="select" id="websiteFolder">
<option value="">未分类</option>
{% for folder in folders %}
<option value="{{ folder.id }}">{{ folder.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="hideWebsiteModal()">取消</button>
<button class="btn btn-primary" onclick="saveWebsite()">保存</button>
</div>
</div>
</div>
<!-- ===== 账号弹窗 ===== -->
<div class="modal-overlay hidden" id="accountModal" onclick="if(event.target===this)hideAccountModal()">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="accountModalTitle">添加账号</span>
<button class="modal-close" onclick="hideAccountModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">账号 / 用户名</label>
<input class="input" id="accountUsername" placeholder="user@example.com">
</div>
<div class="form-group">
<label class="form-label">密码</label>
<div class="form-row">
<input class="input" id="accountPassword" type="password" placeholder="输入密码" style="flex:1;">
<button class="btn btn-ghost btn-sm" type="button" onclick="togglePwVisibility()">显示</button>
</div>
</div>
<div class="form-group">
<label class="form-label">备注</label>
<textarea class="textarea" id="accountRemark" rows="2" placeholder="例如:主账号、小号…"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="hideAccountModal()">取消</button>
<button class="btn btn-primary" onclick="saveAccount()">保存</button>
</div>
</div>
</div>
<input type="hidden" id="currentSiteId" value="">
<input type="hidden" id="editWebsiteId" value="">
<input type="hidden" id="editAccountId" value="">
{% endblock %}
{% block scripts %}
<script src="/static/js/websites.js"></script>
{% endblock %}