初始化

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

2
frontend/.dockerignore Normal file
View File

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

4
frontend/.env Normal file
View File

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

4
frontend/.env.production Normal file
View File

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

View File

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

12
frontend/index.html Normal file
View File

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

11
frontend/nginx.conf Normal file
View File

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

2835
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

View File

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

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

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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