初始化
This commit is contained in:
@@ -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>
|
||||
再到聚簇索引查找完整行<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> <span style="color:#999;">// 跳过了 a</span><br>
|
||||
<span style="color:#d93025;">WHERE c = 1</span> <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>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1782197830107}
|
||||
1
.superpowers/brainstorm/1236-1782196029/state/server.pid
Normal file
1
.superpowers/brainstorm/1236-1782196029/state/server.pid
Normal file
@@ -0,0 +1 @@
|
||||
1236
|
||||
@@ -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>
|
||||
<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>
|
||||
169
.superpowers/brainstorm/1938-1782198423/content/layout-v2.html
Normal file
169
.superpowers/brainstorm/1938-1782198423/content/layout-v2.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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="例如: 网址: https://github.com 账号: zhangsan@qq.com 密码: Abc123456 备注: 新注册的开发账号">网址: 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>
|
||||
225
.superpowers/brainstorm/1938-1782198423/content/website-v2.html
Normal file
225
.superpowers/brainstorm/1938-1782198423/content/website-v2.html
Normal 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="格式示例: https://github.com 代码网站 zhangsan_new@qq.com Xyz789!@# 新注册的开发账号">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>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1782200884044}
|
||||
1
.superpowers/brainstorm/1938-1782198423/state/server.pid
Normal file
1
.superpowers/brainstorm/1938-1782198423/state/server.pid
Normal file
@@ -0,0 +1 @@
|
||||
1938
|
||||
2
backend/.dockerignore
Normal file
2
backend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal 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
62
backend/app.py
Normal 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
20
backend/config.py
Normal 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
130
backend/file_service.py
Normal 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
94
backend/gateway-init.sql
Normal 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
59
backend/init_db.py
Normal 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
62
backend/logger.py
Normal 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()
|
||||
1
backend/middleware/__init__.py
Normal file
1
backend/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""中间件包"""
|
||||
94
backend/middleware/user_context.py
Normal file
94
backend/middleware/user_context.py
Normal 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
135
backend/models.py
Normal 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
7
backend/requirements.txt
Normal 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
|
||||
1
backend/routes/__init__.py
Normal file
1
backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""路由包"""
|
||||
61
backend/routes/import_api.py
Normal file
61
backend/routes/import_api.py
Normal 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
159
backend/routes/notes.py
Normal 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
75
backend/routes/share.py
Normal 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
101
backend/routes/upload.py
Normal 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
221
backend/routes/websites.py
Normal 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
32
deploy/deploy.py
Normal 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
30
deploy/docker-compose.yml
Normal 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
310
docs/DEPLOY.md
Normal 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-slim,Uvicorn 运行 FastAPI,端口 4006
|
||||
|
||||
**跨域通信**:前端直接跨域请求后端 API,Nginx **不代理** `/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
345
docs/README.md
Normal 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 # 双模式 API(dev 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号 |
|
||||
1216
docs/superpowers/plans/2026-06-23-mokeetext-gateway-integration.md
Normal file
1216
docs/superpowers/plans/2026-06-23-mokeetext-gateway-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
2115
docs/superpowers/plans/2026-06-23-mokeetext-implementation.md
Normal file
2115
docs/superpowers/plans/2026-06-23-mokeetext-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
173
docs/superpowers/specs/2026-06-23-mokeetext-design.md
Normal file
173
docs/superpowers/specs/2026-06-23-mokeetext-design.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# MoKeeText Web 记事本 — 设计文档
|
||||
|
||||
**日期**: 2026-06-23
|
||||
**状态**: 已确认
|
||||
|
||||
---
|
||||
|
||||
## 项目概述
|
||||
|
||||
一个 Web 记事本工具,包含两个核心模块:
|
||||
1. **Markdown 笔记** — 增强型编辑器,类似飞书文档体验
|
||||
2. **网站账号管理** — 网站密码本,智能导入功能
|
||||
|
||||
数据存储到 MySQL。
|
||||
|
||||
---
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 层级 | 技术 | 理由 |
|
||||
|------|------|------|
|
||||
| 后端框架 | Python FastAPI | 高性能异步,Python 操作 MySQL 成熟 |
|
||||
| 模板引擎 | Jinja2 | FastAPI 内置支持,服务端渲染 |
|
||||
| ORM | SQLAlchemy | 最成熟的 Python ORM,MySQL 支持好 |
|
||||
| 数据库 | 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
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
4
frontend/.env
Normal file
4
frontend/.env
Normal 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
4
frontend/.env.production
Normal 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
|
||||
9
frontend/Dockerfile.frontend
Normal file
9
frontend/Dockerfile.frontend
Normal 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
12
frontend/index.html
Normal 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
11
frontend/nginx.conf
Normal 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
2835
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal 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
127
frontend/src/App.vue
Normal 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>
|
||||
314
frontend/src/assets/main.css
Normal file
314
frontend/src/assets/main.css
Normal 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; }
|
||||
17
frontend/src/components/ConfirmModal.vue
Normal file
17
frontend/src/components/ConfirmModal.vue
Normal 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>
|
||||
25
frontend/src/components/Modal.vue
Normal file
25
frontend/src/components/Modal.vue
Normal 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')">×</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>
|
||||
183
frontend/src/components/NoteEditor.vue
Normal file
183
frontend/src/components/NoteEditor.vue
Normal 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')"></> 代码</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>
|
||||
20
frontend/src/components/PromptModal.vue
Normal file
20
frontend/src/components/PromptModal.vue
Normal 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>
|
||||
79
frontend/src/composables/useApi.js
Normal file
79
frontend/src/composables/useApi.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// API 请求层
|
||||
//
|
||||
// 开发模式(localhost):直连后端 + X-Dev-Mock-User 头模拟用户
|
||||
// 生产模式:走网关 gw.server.zgitm.com,axios 自动携带 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 }) }),
|
||||
}
|
||||
}
|
||||
27
frontend/src/directives/permission.js
Normal file
27
frontend/src/directives/permission.js
Normal 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
10
frontend/src/main.js
Normal 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
38
frontend/src/router.js
Normal 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
|
||||
53
frontend/src/utils/request.js
Normal file
53
frontend/src/utils/request.js
Normal 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(登录后网关写入的跨域 Cookie,domain: .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
|
||||
321
frontend/src/views/NotesView.vue
Normal file
321
frontend/src/views/NotesView.vue
Normal 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>
|
||||
92
frontend/src/views/SharesView.vue
Normal file
92
frontend/src/views/SharesView.vue
Normal 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>
|
||||
439
frontend/src/views/WebsitesView.vue
Normal file
439
frontend/src/views/WebsitesView.vue
Normal 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 代码网站 zhangsan@qq.com Abc123 个人开发账号"></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>
|
||||
701
frontend/static/css/style.css
Normal file
701
frontend/static/css/style.css
Normal 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;
|
||||
}
|
||||
1
frontend/static/dist/assets/index-5uIxZ5nb.css
vendored
Normal file
1
frontend/static/dist/assets/index-5uIxZ5nb.css
vendored
Normal file
File diff suppressed because one or more lines are too long
166
frontend/static/dist/assets/index-BpxV2n8n.js
vendored
Normal file
166
frontend/static/dist/assets/index-BpxV2n8n.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/static/dist/index.html
vendored
Normal file
13
frontend/static/dist/index.html
vendored
Normal 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>
|
||||
133
frontend/static/js/editor.js
Normal file
133
frontend/static/js/editor.js
Normal 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"></> 代码</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
150
frontend/static/js/notes.js
Normal 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""") : ""; }
|
||||
|
||||
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();
|
||||
});
|
||||
155
frontend/static/js/vendor/tiptap-bundle.min.js
vendored
Normal file
155
frontend/static/js/vendor/tiptap-bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/js/vendor/tiptap-core.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-core.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-core.umd.min.js in @tiptap/core.
|
||||
29
frontend/static/js/vendor/tiptap-entry.js
vendored
Normal file
29
frontend/static/js/vendor/tiptap-entry.js
vendored
Normal 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,
|
||||
};
|
||||
1
frontend/static/js/vendor/tiptap-extension-code-block-lowlight.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-code-block-lowlight.umd.min.js
vendored
Normal 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.
|
||||
1
frontend/static/js/vendor/tiptap-extension-color.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-color.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-color.umd.min.js in @tiptap/extension-color.
|
||||
1
frontend/static/js/vendor/tiptap-extension-highlight.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-highlight.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-highlight.umd.min.js in @tiptap/extension-highlight.
|
||||
1
frontend/static/js/vendor/tiptap-extension-image.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-image.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-image.umd.min.js in @tiptap/extension-image.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table-cell.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table-cell.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table-cell.umd.min.js in @tiptap/extension-table-cell.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table-header.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table-header.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table-header.umd.min.js in @tiptap/extension-table-header.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table-row.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table-row.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table-row.umd.min.js in @tiptap/extension-table-row.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table.umd.min.js in @tiptap/extension-table.
|
||||
1
frontend/static/js/vendor/tiptap-extension-task-item.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-task-item.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-task-item.umd.min.js in @tiptap/extension-task-item.
|
||||
1
frontend/static/js/vendor/tiptap-extension-task-list.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-task-list.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-task-list.umd.min.js in @tiptap/extension-task-list.
|
||||
1
frontend/static/js/vendor/tiptap-extension-text-style.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-text-style.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-text-style.umd.min.js in @tiptap/extension-text-style.
|
||||
1
frontend/static/js/vendor/tiptap-starter-kit.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-starter-kit.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-starter-kit.umd.min.js in @tiptap/starter-kit.
|
||||
249
frontend/static/js/websites.js
Normal file
249
frontend/static/js/websites.js
Normal 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
19
frontend/vite.config.js
Normal 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
1
memory/.session-last
Normal 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
4
memory/MEMORY.md
Normal 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) — 添加「切换系统」按钮到顶栏,部署生产环境
|
||||
104
memory/session-20260623-1.md
Normal file
104
memory/session-20260623-1.md
Normal 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
|
||||
- **原因**: 生产环境标准化
|
||||
- **结果**: 前端不代理 API,Vue 直接跨域请求后端 `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 反向代理配置(运维处理)
|
||||
- [ ] 后续可加用户登录/权限系统
|
||||
89
memory/session-20260623-2.md
Normal file
89
memory/session-20260623-2.md
Normal 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)
|
||||
- 前端保留 fetch(dev 模式)+ 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 到服务器)
|
||||
- [ ] 对接测试清单逐项验证
|
||||
46
memory/session-20260624-3.md
Normal file
46
memory/session-20260624-3.md
Normal 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 占位 div,control-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.html,JS 包含正确代码
|
||||
|
||||
## 重要信息
|
||||
- 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 是否正常显示
|
||||
- [ ] 如有问题查看浏览器控制台错误
|
||||
96
memory/session-20260628-4.md
Normal file
96
memory/session-20260628-4.md
Normal 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 存储
|
||||
- 读取优先 blob,file_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
37
templates/base.html
Normal 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
77
templates/notes.html
Normal 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
198
templates/websites.html
Normal 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()">×</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 代码网站 zhangsan@qq.com Abc123 个人开发账号"></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()">×</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()">×</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 %}
|
||||
Reference in New Issue
Block a user