初始化
This commit is contained in:
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
4
frontend/.env
Normal file
4
frontend/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# 本地开发环境变量
|
||||
VITE_GATEWAY_URL = http://localhost:4006
|
||||
VITE_LOGIN_URL = https://login.user.zgitm.com
|
||||
VITE_SYSTEM_CODE = mokeetext-edit
|
||||
4
frontend/.env.production
Normal file
4
frontend/.env.production
Normal file
@@ -0,0 +1,4 @@
|
||||
# 生产环境变量
|
||||
VITE_GATEWAY_URL = https://gw.server.zgitm.com
|
||||
VITE_LOGIN_URL = https://login.user.zgitm.com
|
||||
VITE_SYSTEM_CODE = mokeetext-edit
|
||||
9
frontend/Dockerfile.frontend
Normal file
9
frontend/Dockerfile.frontend
Normal file
@@ -0,0 +1,9 @@
|
||||
# ===== 前端 Nginx =====
|
||||
FROM nginx:alpine
|
||||
|
||||
# 先构建好 Vue 再打镜像
|
||||
COPY static/dist/ /usr/share/nginx/html/
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 3006
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MoKeeText</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
frontend/nginx.conf
Normal file
11
frontend/nginx.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 3006;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
2835
frontend/package-lock.json
generated
Normal file
2835
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "mokeetext",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.27.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.27.1",
|
||||
"@tiptap/extension-color": "^3.27.1",
|
||||
"@tiptap/extension-highlight": "^3.27.1",
|
||||
"@tiptap/extension-image": "^3.27.1",
|
||||
"@tiptap/extension-table": "^3.27.1",
|
||||
"@tiptap/extension-table-cell": "^3.27.1",
|
||||
"@tiptap/extension-table-header": "^3.27.1",
|
||||
"@tiptap/extension-table-row": "^3.27.1",
|
||||
"@tiptap/extension-task-item": "^3.27.1",
|
||||
"@tiptap/extension-task-list": "^3.27.1",
|
||||
"@tiptap/extension-text-style": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"axios": "^1.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/vue-3": "^3.27.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"esbuild": "^0.28.1",
|
||||
"vite": "^8.0.16",
|
||||
"vue": "^3.5.38",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
}
|
||||
127
frontend/src/App.vue
Normal file
127
frontend/src/App.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<!-- Hamburger -->
|
||||
<button class="hamburger" @click="navOpen=!navOpen">☰</button>
|
||||
|
||||
<!-- Nav overlay (mobile) -->
|
||||
<div class="nav-overlay" v-if="navOpen" @click="navOpen=false"></div>
|
||||
|
||||
<!-- Left nav -->
|
||||
<aside class="app-nav" :class="{ collapsed: navCollapsed, open: navOpen }">
|
||||
<div class="nav-brand" @click="$router.push('/')">
|
||||
<span v-if="navCollapsed">M</span>
|
||||
<span v-else>mokee编辑器</span>
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<router-link to="/notes" class="nav-item" title="笔记">
|
||||
<span class="nav-icon">📝</span>
|
||||
<span class="nav-label">笔记</span>
|
||||
</router-link>
|
||||
<router-link to="/websites" class="nav-item" title="网站管理">
|
||||
<span class="nav-icon">🔐</span>
|
||||
<span class="nav-label">网站管理</span>
|
||||
</router-link>
|
||||
<router-link to="/shares" class="nav-item" title="分享管理">
|
||||
<span class="nav-icon">🔗</span>
|
||||
<span class="nav-label">分享管理</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="nav-bottom">
|
||||
<div class="nav-item" @click="navCollapsed=!navCollapsed" title="收起菜单">
|
||||
<span class="nav-icon">{{ navCollapsed ? '▶' : '◀' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main area -->
|
||||
<div class="app-main">
|
||||
<!-- Top bar -->
|
||||
<header class="topbar">
|
||||
<div class="header-left">
|
||||
<button class="toggle-sidebar-btn" @click="navCollapsed=!navCollapsed" :title="navCollapsed ? '展开侧边栏' : '收起侧边栏'">
|
||||
{{ navCollapsed ? '☰' : '✕' }}
|
||||
</button>
|
||||
<span class="header-title">MoKeeText</span>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<a class="switch-system-btn" href="https://web.gw.zgitm.com/select" title="切换系统">
|
||||
<span class="switch-icon">🔄</span>
|
||||
<span class="switch-label">切换系统</span>
|
||||
</a>
|
||||
</header>
|
||||
<router-view />
|
||||
<footer class="app-footer">
|
||||
<span>湘ICP备19021539号</span>
|
||||
<span>MoKeeText v1.0</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
const navCollapsed = ref(false)
|
||||
const navOpen = ref(false)
|
||||
// 路由切换时关闭移动端菜单
|
||||
watch(() => router.currentRoute.value, () => { navOpen.value = false })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-shell { display:flex; height:100vh; }
|
||||
|
||||
.app-nav {
|
||||
width:200px; min-width:200px;
|
||||
background:var(--bg-sidebar); border-right:1px solid var(--border-light);
|
||||
display:flex; flex-direction:column; transition:all 0.2s; overflow:hidden;
|
||||
}
|
||||
.app-nav.collapsed { width:56px; min-width:56px; }
|
||||
|
||||
.nav-brand {
|
||||
height:48px; display:flex; align-items:center; justify-content:center;
|
||||
font-weight:800; font-size:18px; color:var(--brand); cursor:pointer;
|
||||
border-bottom:1px solid var(--border-light); flex-shrink:0;
|
||||
}
|
||||
|
||||
.nav-menu { flex:1; padding:8px; display:flex; flex-direction:column; gap:2px; }
|
||||
|
||||
.nav-item {
|
||||
display:flex; align-items:center; gap:10px; height:38px; padding:0 10px;
|
||||
border-radius:var(--r-sm); text-decoration:none; color:var(--text-secondary);
|
||||
font-size:13px; font-weight:500; transition:all .12s; cursor:pointer; white-space:nowrap;
|
||||
}
|
||||
.nav-item:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||||
.nav-item.active, .nav-item.router-link-exact-active { background:var(--brand-light); color:var(--brand); position:relative; }
|
||||
.nav-item.active::before, .nav-item.router-link-exact-active::before { content:''; position:absolute; left:0; top:8px; bottom:8px; width:3px; background:var(--brand); border-radius:0 2px 2px 0; }
|
||||
|
||||
.nav-icon { font-size:15px; flex-shrink:0; width:22px; text-align:center; opacity:.7; }
|
||||
.nav-item.active .nav-icon, .nav-item.router-link-exact-active .nav-icon { opacity:1; }
|
||||
.nav-label { overflow:hidden; }
|
||||
.collapsed .nav-label { display:none; }
|
||||
|
||||
.nav-bottom {
|
||||
padding:8px; border-top:1px solid var(--border-light);
|
||||
display:flex; flex-direction:column; gap:4px;
|
||||
}
|
||||
|
||||
.app-main { flex:1; display:flex; flex-direction:column; min-width:0; overflow:visible; }
|
||||
.topbar { display:flex; align-items:center; height:44px; padding:0 16px; background:var(--bg-surface); border-bottom:1px solid var(--border-light); flex-shrink:0; }
|
||||
.header-left { display:flex; align-items:center; gap:8px; }
|
||||
.header-title { font-size:15px; color:var(--text-primary); font-weight:500; }
|
||||
.toggle-sidebar-btn { width:32px; height:32px; border:none; background:transparent; border-radius:var(--r-sm); font-size:16px; cursor:pointer; color:var(--text-secondary); display:flex; align-items:center; justify-content:center; transition:all .15s; }
|
||||
.toggle-sidebar-btn:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||||
.topbar-right { display:flex; align-items:center; flex-shrink:0; }
|
||||
#mokee-user-chip { flex-shrink: 0; }
|
||||
.switch-system-btn {
|
||||
display:flex; align-items:center; gap:4px; height:28px; padding:0 10px;
|
||||
border-radius:var(--r-sm); text-decoration:none; font-size:12px; font-weight:500;
|
||||
color:var(--text-secondary); background:transparent; transition:all .15s;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.switch-system-btn:hover { background:var(--bg-hover); color:var(--brand); }
|
||||
.switch-icon { font-size:13px; }
|
||||
.switch-label { white-space:nowrap; }
|
||||
|
||||
.app-footer { display:flex; align-items:center; justify-content:space-between; height:28px; padding:0 12px; font-size:10px; color:var(--text-tertiary); border-top:1px solid var(--border-light); flex-shrink:0; }
|
||||
</style>
|
||||
314
frontend/src/assets/main.css
Normal file
314
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,314 @@
|
||||
/* ===== MoKeeText Design System ===== */
|
||||
:root {
|
||||
--bg-page: #F5F6F8;
|
||||
--bg-sidebar: #FCFCFD;
|
||||
--bg-surface: #FFFFFF;
|
||||
--bg-hover: #F2F3F5;
|
||||
--bg-active: #EEF3FF;
|
||||
|
||||
--text-primary: #1F2329;
|
||||
--text-secondary: #646A73;
|
||||
--text-tertiary: #8F959E;
|
||||
--text-placeholder: #BCC0C6;
|
||||
|
||||
--border-light: #E5E6EB;
|
||||
--border-medium: #DEE0E3;
|
||||
|
||||
--brand: #3370FF;
|
||||
--brand-hover: #245BDB;
|
||||
--brand-light: #EEF3FF;
|
||||
|
||||
--danger: #F54A45;
|
||||
--danger-light: #FFF1F0;
|
||||
--success: #34C759;
|
||||
|
||||
--r-sm: 6px; --r-md: 8px; --r-lg: 12px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
font-size: 14px; line-height: 1.5; color: var(--text-primary); background: var(--bg-page);
|
||||
height: 100vh; overflow: hidden; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app { height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #D0D3D7; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #B0B3B7; }
|
||||
|
||||
/* ===== Topnav ===== */
|
||||
.topnav { display:flex; align-items:center; height:48px; padding:0 20px; background:var(--bg-surface); border-bottom:1px solid var(--border-light); flex-shrink:0; }
|
||||
.topnav-brand { font-weight:700; font-size:15px; color:var(--text-primary); text-decoration:none; margin-right:24px; }
|
||||
.topnav-link { padding:13px 16px; font-size:13px; font-weight:500; color:var(--text-secondary); text-decoration:none; border-bottom:2px solid transparent; transition:color .15s; }
|
||||
.topnav-link:hover { color:var(--brand); }
|
||||
.topnav-link.active, .topnav-link.router-link-exact-active { color:var(--brand); border-bottom-color:var(--brand); }
|
||||
.user-widget { margin-left:auto; display:flex; align-items:center; gap:8px; padding:4px 10px 4px 4px; border-radius:20px; cursor:pointer; transition:background .15s; user-select:none; }
|
||||
.user-widget:hover { background:var(--bg-hover); }
|
||||
.user-avatar { width:28px; height:28px; border-radius:50%; background:var(--brand); color:#fff; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; flex-shrink:0; }
|
||||
.user-name { font-size:13px; font-weight:500; }
|
||||
|
||||
.footer { display:flex; align-items:center; justify-content:space-between; height:32px; padding:0 20px; background:var(--bg-surface); border-top:1px solid var(--border-light); font-size:11px; color:var(--text-tertiary); flex-shrink:0; }
|
||||
|
||||
/* ===== Layout ===== */
|
||||
.layout { display:flex; flex:1; min-height:0; }
|
||||
.sidebar { width:260px; min-width:260px; background:var(--bg-sidebar); border-right:1px solid var(--border-light); display:flex; flex-direction:column; padding:12px; gap:8px; overflow:hidden; transition:all .2s; }
|
||||
.sidebar.collapsed { width:40px; min-width:40px; padding:8px 4px; }
|
||||
.content { flex:1; display:flex; flex-direction:column; background:var(--bg-surface); min-width:0; }
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:36px; padding:0 16px; border:none; border-radius:var(--r-sm); font-size:13px; font-weight:500; cursor:pointer; transition:all .15s; font-family:inherit; white-space:nowrap; }
|
||||
.btn:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
|
||||
.btn-primary { background:var(--brand); color:#fff; }
|
||||
.btn-primary:hover { background:var(--brand-hover); }
|
||||
.btn-outline { background:var(--bg-surface); color:var(--brand); border:1px solid var(--brand); }
|
||||
.btn-outline:hover { background:var(--brand-light); }
|
||||
.btn-ghost { background:transparent; color:var(--text-secondary); }
|
||||
.btn-ghost:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||||
.btn-danger-ghost { background:transparent; color:var(--text-tertiary); }
|
||||
.btn-danger-ghost:hover { background:var(--danger-light); color:var(--danger); }
|
||||
.btn-block { width:100%; }
|
||||
.btn-sm { height:30px; font-size:12px; padding:0 12px; }
|
||||
.btn-xs { height:24px; font-size:11px; padding:0 8px; }
|
||||
|
||||
/* ===== Input / Select / Textarea ===== */
|
||||
.input, .select { width:100%; height:36px; padding:0 12px; border:1px solid var(--border-medium); border-radius:var(--r-sm); font-size:13px; color:var(--text-primary); background:var(--bg-surface); outline:none; transition:border .15s; font-family:inherit; }
|
||||
.input:focus, .select:focus { border-color:var(--brand); box-shadow:0 0 0 2px var(--brand-light); }
|
||||
.input::placeholder { color:var(--text-placeholder); }
|
||||
.textarea { width:100%; padding:8px 12px; border:1px solid var(--border-medium); border-radius:var(--r-sm); font-size:13px; color:var(--text-primary); background:var(--bg-surface); outline:none; resize:vertical; font-family:inherit; transition:border .15s; min-height:60px; }
|
||||
.textarea:focus { border-color:var(--brand); box-shadow:0 0 0 2px var(--brand-light); }
|
||||
|
||||
/* ===== Sidebar Items ===== */
|
||||
.sidebar-label { font-size:10px; font-weight:700; color:var(--text-tertiary); letter-spacing:.06em; text-transform:uppercase; padding:8px 8px 4px 4px; margin-top:8px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.sidebar-label .add-btn { cursor:pointer; color:var(--text-tertiary); font-size:16px; line-height:1; width:22px; height:22px; display:flex; align-items:center; justify-content:center; border-radius:4px; transition:all .15s; }
|
||||
.sidebar-label .add-btn:hover { background:var(--bg-hover); color:var(--brand); }
|
||||
|
||||
.folder-item { display:flex; align-items:center; gap:8px; height:34px; padding:0 10px; border-radius:var(--r-sm); cursor:pointer; font-size:13px; color:var(--text-secondary); transition:all .12s; user-select:none; position:relative; }
|
||||
.folder-item:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||||
.folder-item.active { background:var(--brand-light); color:var(--brand); }
|
||||
.folder-item .count { margin-left:auto; font-size:10px; font-weight:500; color:var(--text-tertiary); background:var(--bg-hover); padding:1px 6px; border-radius:8px; min-width:20px; text-align:center; }
|
||||
.folder-item.active .count { background:rgba(51,112,255,.12); color:var(--brand); }
|
||||
|
||||
.folder-dot { width:6px; height:6px; border-radius:50%; background:var(--text-tertiary); flex-shrink:0; opacity:.5; }
|
||||
.folder-item.active .folder-dot { background:var(--brand); opacity:1; }
|
||||
|
||||
.folder-more { opacity:0; padding:2px 6px; font-size:16px; font-weight:700; color:var(--text-tertiary); border-radius:4px; cursor:pointer; line-height:1; }
|
||||
.folder-item:hover .folder-more { opacity:1; }
|
||||
.folder-more:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||||
|
||||
.site-item { display:flex; align-items:center; gap:8px; height:36px; padding:0 8px; border-radius:var(--r-sm); cursor:pointer; font-size:13px; transition:background .1s; user-select:none; }
|
||||
.site-item:hover { background:var(--bg-hover); }
|
||||
.site-item.active { background:var(--brand-light); color:var(--brand); font-weight:500; }
|
||||
.site-item .site-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.site-item .site-count { font-size:10px; color:var(--text-tertiary); flex-shrink:0; }
|
||||
|
||||
.note-list-item { display:flex; align-items:center; gap:8px; height:34px; padding:0 8px; border-radius:var(--r-sm); cursor:pointer; font-size:12px; transition:background .1s; }
|
||||
.note-list-item:hover { background:var(--bg-hover); }
|
||||
.note-list-item.active { background:var(--brand-light); }
|
||||
.note-list-item .note-title { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500; }
|
||||
.note-list-item .note-date { font-size:10px; color:var(--text-tertiary); }
|
||||
|
||||
.folder-children { margin-left:16px; }
|
||||
.folder-arrow { font-size:10px; color:var(--text-tertiary); width:12px; flex-shrink:0; }
|
||||
|
||||
/* ===== Card ===== */
|
||||
.card { background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-lg); padding:16px; transition:box-shadow .15s; margin-bottom:12px; }
|
||||
.card:hover { box-shadow:var(--shadow-sm); }
|
||||
.card-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
|
||||
.card-title { font-size:14px; font-weight:600; }
|
||||
.card-body { display:grid; grid-template-columns:auto 1fr auto; gap:8px 12px; font-size:13px; align-items:center; }
|
||||
.card-body .label { color:var(--text-tertiary); font-size:12px; white-space:nowrap; }
|
||||
|
||||
.card-btn { height:30px; padding:0 10px; border:1px solid var(--border-light); border-radius:var(--r-sm); background:var(--bg-surface); font-size:12px; cursor:pointer; color:var(--text-secondary); transition:all .15s; font-family:inherit; }
|
||||
.card-btn:hover { background:var(--bg-hover); border-color:var(--border-medium); }
|
||||
.card-btn.edit-btn:hover { color:var(--brand); border-color:var(--brand); background:var(--brand-light); }
|
||||
.card-btn.danger-btn:hover { color:var(--danger); border-color:var(--danger); background:var(--danger-light); }
|
||||
|
||||
/* ===== Modal ===== */
|
||||
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.35); display:flex; align-items:center; justify-content:center; z-index:100; animation:fi .15s ease; }
|
||||
@keyframes fi { from{opacity:0} to{opacity:1} }
|
||||
.modal { background:var(--bg-surface); border-radius:var(--r-lg); box-shadow:var(--shadow-lg); width:480px; max-width:90vw; max-height:85vh; display:flex; flex-direction:column; animation:si .15s ease; }
|
||||
@keyframes si { from{transform:translateY(8px); opacity:0} to{transform:translateY(0); opacity:1} }
|
||||
.modal-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border-light); flex-shrink:0; }
|
||||
.modal-title { font-size:15px; font-weight:600; }
|
||||
.modal-close { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:none; background:transparent; border-radius:var(--r-sm); cursor:pointer; font-size:18px; color:var(--text-tertiary); transition:all .15s; }
|
||||
.modal-close:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||||
.modal-body { padding:20px; overflow-y:auto; flex:1; }
|
||||
.modal-footer { display:flex; justify-content:flex-end; gap:8px; padding:12px 20px; border-top:1px solid var(--border-light); flex-shrink:0; }
|
||||
|
||||
/* ===== Form ===== */
|
||||
.form-group { margin-bottom:16px; }
|
||||
.form-group:last-child { margin-bottom:0; }
|
||||
.form-label { display:block; font-size:12px; font-weight:600; color:var(--text-secondary); margin-bottom:4px; }
|
||||
.form-row { display:flex; gap:8px; align-items:center; }
|
||||
|
||||
/* ===== Editor ===== */
|
||||
.editor-wrap { flex:1; display:flex; min-height:0; }
|
||||
.editor-pane { flex:1; padding:20px; overflow-y:auto; min-width:0; display:flex; justify-content:center; }
|
||||
.editor-pane + .editor-pane { border-left:1px solid var(--border-light); }
|
||||
.editor-inner { width:100%; max-width:960px; }
|
||||
|
||||
.content-header { display:flex; align-items:center; justify-content:space-between; padding:12px 20px; border-bottom:1px solid var(--border-light); gap:12px; flex-shrink:0; }
|
||||
.content-title { font-size:16px; font-weight:600; color:var(--text-primary); border:none; outline:none; background:transparent; flex:1; font-family:inherit; }
|
||||
.content-title::placeholder { color:var(--text-placeholder); }
|
||||
|
||||
.toggle-group { display:flex; background:var(--bg-hover); border-radius:var(--r-sm); padding:2px; }
|
||||
.toggle-group button { height:28px; padding:0 12px; border:none; background:transparent; border-radius:4px; font-size:12px; cursor:pointer; color:var(--text-secondary); transition:all .15s; font-family:inherit; }
|
||||
.toggle-group button.active { background:var(--bg-surface); box-shadow:var(--shadow-sm); color:var(--text-primary); font-weight:500; }
|
||||
|
||||
.save-badge { font-size:10px; color:var(--text-tertiary); background:var(--bg-hover); padding:2px 8px; border-radius:10px; flex-shrink:0; white-space:nowrap; }
|
||||
|
||||
/* ===== Toolbar ===== */
|
||||
.tb-bar { display:flex; flex-wrap:wrap; align-items:center; gap:2px; padding:4px 6px; background:var(--bg-sidebar); border:1px solid var(--border-light); border-radius:var(--r-md); margin-bottom:16px; }
|
||||
.tb-divider { width:1px; height:16px; background:var(--border-medium); margin:0 4px; }
|
||||
.tb-btn { height:28px; padding:0 8px; border:none; background:transparent; border-radius:4px; font-size:12px; cursor:pointer; color:var(--text-secondary); white-space:nowrap; font-family:inherit; transition:all .1s; }
|
||||
.tb-btn:hover { background:var(--bg-hover); }
|
||||
.tb-btn.active { background:var(--brand-light); color:var(--brand); }
|
||||
.tb-color { width:24px; height:24px; border:1px solid var(--border-medium); border-radius:4px; cursor:pointer; padding:0; margin:0 2px; }
|
||||
|
||||
/* ProseMirror */
|
||||
.ProseMirror { outline:none; min-height:300px; font-size:14px; line-height:1.8; }
|
||||
.ProseMirror h1 { font-size:22px; font-weight:700; margin:16px 0 8px; padding-bottom:6px; border-bottom:1px solid var(--border-light); }
|
||||
.ProseMirror h2 { font-size:18px; font-weight:600; margin:14px 0 6px; }
|
||||
.ProseMirror h3 { font-size:15px; font-weight:600; margin:12px 0 4px; }
|
||||
.ProseMirror p { margin:4px 0; }
|
||||
.ProseMirror ul, .ProseMirror ol { padding-left:20px; margin:4px 0; }
|
||||
.ProseMirror li { margin:2px 0; }
|
||||
.ProseMirror blockquote { border-left:3px solid var(--brand); padding-left:12px; color:var(--text-secondary); margin:8px 0; }
|
||||
.ProseMirror pre { background:#F7F8FA; padding:12px 16px; border-radius:var(--r-sm); overflow-x:auto; font-family:"SF Mono","Fira Code","Consolas",monospace; font-size:13px; line-height:1.6; }
|
||||
.ProseMirror code { background:#F2F3F5; padding:2px 6px; border-radius:3px; font-size:12px; font-family:"SF Mono","Fira Code","Consolas",monospace; }
|
||||
.ProseMirror table { border-collapse:collapse; width:100%; margin:8px 0; }
|
||||
.ProseMirror th, .ProseMirror td { border:1px solid var(--border-light); padding:8px 12px; text-align:left; }
|
||||
.ProseMirror th { background:#F7F8FA; font-weight:600; font-size:13px; }
|
||||
.ProseMirror img { max-width:100%; border-radius:var(--r-sm); }
|
||||
.ProseMirror mark { background:#FFF3CD; padding:2px 4px; border-radius:2px; }
|
||||
|
||||
/* ===== Import ===== */
|
||||
.import-textarea { width:100%; height:90px; padding:10px 12px; border:2px solid var(--brand); border-radius:var(--r-md); font-size:13px; font-family:inherit; resize:vertical; outline:none; background:var(--brand-light); }
|
||||
.import-hint { font-size:11px; color:var(--text-tertiary); margin:4px 0 12px; }
|
||||
.result-box { background:#F0FAF0; border:1px solid #A3E4A7; border-radius:var(--r-md); padding:14px 16px; margin-bottom:16px; }
|
||||
.result-grid { display:grid; grid-template-columns:70px 1fr; gap:6px 12px; font-size:13px; }
|
||||
.alert { padding:8px 12px; border-radius:var(--r-sm); font-size:12px; }
|
||||
.alert-warning { background:#FFF7E6; color:#8A5D00; }
|
||||
|
||||
/* ===== Stats ===== */
|
||||
.stat-row { display:flex; gap:6px; }
|
||||
.stat { flex:1; text-align:center; padding:8px 4px; background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-sm); font-size:11px; }
|
||||
.stat-num { font-size:18px; font-weight:700; }
|
||||
.stat-label { color:var(--text-tertiary); font-size:10px; }
|
||||
|
||||
/* ===== Tag Badge ===== */
|
||||
.tag-badge { display:inline-block; padding:2px 10px; background:var(--bg-hover); border-radius:10px; font-size:11px; cursor:pointer; color:var(--text-secondary); transition:all .15s; }
|
||||
.tag-badge:hover { background:#DEE0E3; }
|
||||
.tag-badge.active { background:var(--brand); color:#fff; }
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.toast { position:fixed; top:20px; right:20px; background:#1F2329; color:#fff; padding:8px 16px; border-radius:var(--r-sm); font-size:13px; z-index:9999; animation:fi .2s ease; }
|
||||
|
||||
/* ===== Folder Menu ===== */
|
||||
.folder-menu { position:absolute; right:0; top:100%; background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-md); box-shadow:var(--shadow-md); z-index:10; min-width:120px; padding:4px; }
|
||||
.folder-menu-item { padding:6px 12px; cursor:pointer; font-size:12px; color:var(--text-primary); border-radius:var(--r-sm); transition:background .1s; }
|
||||
.folder-menu-item:hover { background:var(--bg-hover); }
|
||||
.folder-menu-item.danger { color:var(--danger); }
|
||||
.folder-menu-item.danger:hover { background:var(--danger-light); }
|
||||
|
||||
/* ===== Lightbox ===== */
|
||||
.lightbox { position:fixed; inset:0; background:rgba(0,0,0,.85); display:flex; align-items:center; justify-content:center; z-index:200; cursor:zoom-out; }
|
||||
.lightbox img { max-width:90vw; max-height:90vh; object-fit:contain; cursor:default; border-radius:4px; }
|
||||
.lightbox-close { position:fixed; top:16px; right:16px; width:40px; height:40px; border:none; background:rgba(255,255,255,.15); color:#fff; font-size:22px; border-radius:50%; cursor:pointer; z-index:201; display:flex; align-items:center; justify-content:center; transition:background .15s; }
|
||||
.lightbox-close:hover { background:rgba(255,255,255,.3); }
|
||||
|
||||
/* Selection toolbar */
|
||||
.sel-toolbar { position:fixed; z-index:150; background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-md); box-shadow:var(--shadow-md); padding:6px; display:flex; gap:4px; }
|
||||
|
||||
/* ===== Comments Panel ===== */
|
||||
.comments-panel { width:260px; min-width:260px; border-left:1px solid var(--border-light); background:var(--bg-sidebar); display:flex; flex-direction:column; font-size:13px; }
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
.hidden { display:none !important; }
|
||||
|
||||
/* ================================================
|
||||
Mobile responsive (<=768px)
|
||||
================================================ */
|
||||
@media (max-width: 768px) {
|
||||
/* -- App shell -- */
|
||||
.app-nav { position:fixed; left:0; top:0; bottom:0; z-index:50;
|
||||
transform:translateX(-100%); transition:transform .25s; }
|
||||
.app-nav.open { transform:translateX(0); }
|
||||
.app-nav.collapsed { width:200px; min-width:200px; }
|
||||
|
||||
.app-main { width:100%; }
|
||||
|
||||
/* Hamburger */
|
||||
.hamburger { display:flex !important; }
|
||||
|
||||
/* -- Layout: single column -- */
|
||||
.layout { flex-direction:column; }
|
||||
|
||||
/* Sidebar becomes a top filter bar or hidden */
|
||||
.sidebar { width:100%; min-width:100%; max-height:40vh; flex-shrink:0; border-right:none; border-bottom:1px solid var(--border-light); }
|
||||
|
||||
/* -- Content full width -- */
|
||||
.content { width:100%; }
|
||||
.content-header { flex-wrap:wrap; gap:8px; padding:10px 12px; }
|
||||
.content-title { font-size:15px; }
|
||||
|
||||
/* -- Tables become cards -- */
|
||||
.doc-table, .doc-table thead, .doc-table tbody, .doc-table th, .doc-table td, .doc-table tr { display:block; }
|
||||
.doc-table thead { display:none; }
|
||||
.doc-table tr { padding:10px 12px; border-bottom:1px solid var(--border-light); }
|
||||
.doc-table td { padding:4px 0; border:none; }
|
||||
.doc-table td:before { content:attr(data-label); font-size:10px; font-weight:600; color:var(--text-tertiary); display:block; margin-bottom:2px; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.doc-title-cell { max-width:none; white-space:normal; }
|
||||
|
||||
/* -- Editor -- */
|
||||
.editor-wrap { flex-direction:column; }
|
||||
.editor-pane { padding:12px; }
|
||||
.editor-pane + .editor-pane { border-left:none; border-top:1px solid var(--border-light); }
|
||||
.editor-inner { max-width:100%; }
|
||||
.tb-bar { flex-wrap:nowrap; overflow-x:auto; gap:1px; padding:4px; }
|
||||
|
||||
/* -- Modals full screen -- */
|
||||
.modal { width:100vw !important; max-width:100vw; height:100vh; max-height:100vh; border-radius:0; }
|
||||
.modal-overlay { align-items:flex-end; }
|
||||
|
||||
/* -- Account cards -- */
|
||||
.card-body { grid-template-columns:1fr; gap:4px; }
|
||||
.card-body .label { margin-top:6px; }
|
||||
|
||||
/* -- Filter tags scrollable -- */
|
||||
.content-header { overflow-x:auto; flex-wrap:nowrap; white-space:nowrap; }
|
||||
|
||||
/* -- Topbar -- */
|
||||
.topbar { padding:0 10px; height:40px; }
|
||||
.topbar-brand { font-size:13px; }
|
||||
|
||||
/* -- Lightbox -- */
|
||||
.lightbox-close { top:10px; right:10px; width:36px; height:36px; font-size:18px; }
|
||||
.lightbox img { max-width:95vw; max-height:85vh; }
|
||||
|
||||
/* -- Comments panel -- */
|
||||
.comments-panel { width:100%; min-width:100%; max-height:40vh; border-left:none; border-top:1px solid var(--border-light); }
|
||||
}
|
||||
|
||||
/* Hamburger button (hidden on desktop) */
|
||||
.hamburger { display:none; position:fixed; top:8px; left:8px; z-index:60; width:36px; height:36px;
|
||||
background:var(--bg-surface); border:1px solid var(--border-light); border-radius:var(--r-sm);
|
||||
font-size:18px; cursor:pointer; align-items:center; justify-content:center; box-shadow:var(--shadow-sm); }
|
||||
|
||||
/* Sidebar overlay for mobile */
|
||||
@media (max-width: 768px) {
|
||||
.app-nav.open + .nav-overlay { display:block; }
|
||||
}
|
||||
.nav-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.3); z-index:49; }
|
||||
.flex-1 { flex:1; }
|
||||
.text-muted { color:var(--text-tertiary); font-size:12px; }
|
||||
.mb-sm { margin-bottom:8px; }
|
||||
.mb-md { margin-bottom:16px; }
|
||||
17
frontend/src/components/ConfirmModal.vue
Normal file
17
frontend/src/components/ConfirmModal.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<Modal :show="show" :title="title" :width="420" @close="$emit('cancel')">
|
||||
<p style="color:var(--text-secondary); font-size:14px; line-height:1.6;">{{ message }}</p>
|
||||
<template #footer>
|
||||
<button v-if="cancelText" class="btn btn-ghost" @click="$emit('cancel')">{{ cancelText }}</button>
|
||||
<button class="btn btn-primary" @click="$emit('confirm')">{{ confirmText || '确定' }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import Modal from './Modal.vue'
|
||||
defineProps({
|
||||
show: Boolean, title: String, message: String,
|
||||
confirmText: String, cancelText: String,
|
||||
})
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
25
frontend/src/components/Modal.vue
Normal file
25
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="modal-overlay" v-if="show" @click.self="$emit('close')">
|
||||
<div class="modal" :style="width ? { width: width + 'px' } : {}">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">{{ title }}</span>
|
||||
<button class="modal-close" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="modal-footer" v-if="$slots.footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: Boolean,
|
||||
title: String,
|
||||
width: Number,
|
||||
})
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
183
frontend/src/components/NoteEditor.vue
Normal file
183
frontend/src/components/NoteEditor.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="editor-wrap">
|
||||
<div class="editor-pane" v-show="viewMode !== 'preview'">
|
||||
<div class="editor-inner">
|
||||
<div class="tb-bar">
|
||||
<button class="tb-btn" @click="cmd('toggleHeading',{level:1})">H1</button>
|
||||
<button class="tb-btn" @click="cmd('toggleHeading',{level:2})">H2</button>
|
||||
<button class="tb-btn" @click="cmd('toggleHeading',{level:3})">H3</button>
|
||||
<span class="tb-divider"></span>
|
||||
<button class="tb-btn" @click="cmd('toggleBold')" style="font-weight:bold;">B</button>
|
||||
<button class="tb-btn" @click="cmd('toggleItalic')" style="font-style:italic;">I</button>
|
||||
<button class="tb-btn" @click="cmd('toggleStrike')" style="text-decoration:line-through;">S</button>
|
||||
<span class="tb-divider"></span>
|
||||
<input type="color" class="tb-color" title="文字颜色" @input="cmd('setColor',$event.target.value)">
|
||||
<input type="color" class="tb-color" title="背景高亮" value="#FFF3CD" @input="cmd('toggleHighlight',{color:$event.target.value})">
|
||||
<span class="tb-divider"></span>
|
||||
<button class="tb-btn" @click="addImage">🖼 图片</button>
|
||||
<button class="tb-btn" @click="addEmoji">😊 表情</button>
|
||||
<span class="tb-divider"></span>
|
||||
<button class="tb-btn" @click="cmd('toggleBulletList')">• 列表</button>
|
||||
<button class="tb-btn" @click="cmd('toggleOrderedList')">1. 列表</button>
|
||||
<button class="tb-btn" @click="cmd('toggleTaskList')">✅ 任务</button>
|
||||
<button class="tb-btn" @click="cmd('toggleBlockquote')">❝ 引用</button>
|
||||
<button class="tb-btn" @click="cmd('toggleCodeBlock')"></> 代码</button>
|
||||
<span class="tb-divider"></span>
|
||||
<button class="tb-btn" @click="cmd('insertTable',{rows:3,cols:3,withHeaderRow:true})">📋 表格</button>
|
||||
</div>
|
||||
<div id="tiptap-editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-pane" v-show="viewMode !== 'edit'">
|
||||
<div class="editor-inner" v-html="previewHtml"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image lightbox -->
|
||||
<div class="lightbox" v-if="lightboxSrc" @click="lightboxSrc=null">
|
||||
<button class="lightbox-close" @click="lightboxSrc=null">✕</button>
|
||||
<img :src="lightboxSrc" @click.stop>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { StarterKit } from '@tiptap/starter-kit'
|
||||
import { TextStyle } from '@tiptap/extension-text-style'
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import { Highlight } from '@tiptap/extension-highlight'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Table } from '@tiptap/extension-table'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TaskList } from '@tiptap/extension-task-list'
|
||||
import { TaskItem } from '@tiptap/extension-task-item'
|
||||
|
||||
const props = defineProps({ viewMode: String })
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
let editor = null
|
||||
const previewHtml = ref('')
|
||||
const lightboxSrc = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
editor = new Editor({
|
||||
element: document.querySelector('#tiptap-editor'),
|
||||
extensions: [
|
||||
StarterKit,
|
||||
TextStyle, Color, Highlight.configure({ multicolor: true }),
|
||||
Image.configure({ inline: true }),
|
||||
Table, TableRow, TableCell, TableHeader,
|
||||
TaskList, TaskItem.configure({ nested: true }),
|
||||
],
|
||||
onUpdate: () => {
|
||||
previewHtml.value = editor.getHTML()
|
||||
emit('update', editor.getHTML())
|
||||
},
|
||||
editorProps: {
|
||||
handlePaste: (view, event) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return false
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDrop: (view, event) => {
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files?.length) return false
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
// 图片点击预览
|
||||
editor.view.dom.addEventListener('click', (e) => {
|
||||
if (e.target.tagName === 'IMG') lightboxSrc.value = e.target.src
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => { editor?.destroy() })
|
||||
|
||||
function cmd(name, ...args) {
|
||||
editor?.chain().focus()[name](...args).run()
|
||||
}
|
||||
|
||||
function setContent(html) {
|
||||
if (editor) editor.commands.setContent(html || '')
|
||||
}
|
||||
|
||||
function getHTML() {
|
||||
return editor?.getHTML() || ''
|
||||
}
|
||||
|
||||
// 双地址方案:上传走网关(鉴权),读取走后端(公开,供 <img> 标签和分享页加载)
|
||||
const isDev = window.location.hostname === 'localhost'
|
||||
const GW_BASE = isDev ? 'http://localhost:4006' : 'https://gw.server.zgitm.com'
|
||||
const FILE_BASE = isDev ? 'http://localhost:4006' : 'https://mokeetext.server.zgitm.com'
|
||||
|
||||
async function uploadAndInsert(file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
try {
|
||||
// 上传走网关(需鉴权头)
|
||||
const headers = {}
|
||||
if (!isDev) {
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
headers['X-System-Code'] = 'mokeetext-edit'
|
||||
} else {
|
||||
headers['X-Dev-Mock-User'] = 'admin'
|
||||
}
|
||||
const res = await fetch(GW_BASE + '/api/upload/image', { method: 'POST', body: form, headers })
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
toast(text.substring(0, 100))
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
// 图片 URL 指向后端直连(公开,<img> 标签无法带鉴权头,分享页也需要加载)
|
||||
const src = data.url.startsWith('http') ? data.url : FILE_BASE + data.url
|
||||
editor?.chain().focus().setImage({ src }).run()
|
||||
}
|
||||
else toast(data.error || '上传失败')
|
||||
} catch(e) { toast('上传失败: ' + e.message) }
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'toast'
|
||||
el.textContent = msg
|
||||
document.body.appendChild(el)
|
||||
setTimeout(() => el.remove(), 3000)
|
||||
}
|
||||
|
||||
function addImage() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = () => { const f = input.files[0]; if (f) uploadAndInsert(f) }
|
||||
input.click()
|
||||
}
|
||||
|
||||
function addEmoji() {
|
||||
// 用户可直接 Win+. 打开系统表情面板插入,工具栏按钮仅作提示
|
||||
editor?.chain().focus().insertContent('😊').run()
|
||||
}
|
||||
|
||||
defineExpose({ setContent, getHTML })
|
||||
</script>
|
||||
20
frontend/src/components/PromptModal.vue
Normal file
20
frontend/src/components/PromptModal.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<Modal :show="show" :title="title" :width="400" @close="$emit('cancel')">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ label }}</label>
|
||||
<input class="input" v-model="value" :placeholder="placeholder" @keyup.enter="$emit('confirm', value)">
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-ghost" @click="$emit('cancel')">取消</button>
|
||||
<button class="btn btn-primary" @click="$emit('confirm', value)">确定</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import Modal from './Modal.vue'
|
||||
const props = defineProps({ show: Boolean, title: String, label: String, placeholder: String, initial: String })
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
const value = ref(props.initial || '')
|
||||
watch(() => props.show, (s) => { if (s) value.value = props.initial || '' })
|
||||
</script>
|
||||
79
frontend/src/composables/useApi.js
Normal file
79
frontend/src/composables/useApi.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// API 请求层
|
||||
//
|
||||
// 开发模式(localhost):直连后端 + X-Dev-Mock-User 头模拟用户
|
||||
// 生产模式:走网关 gw.server.zgitm.com,axios 自动携带 Authorization + X-System-Code
|
||||
|
||||
import request from '../utils/request.js'
|
||||
|
||||
const isDev = window.location.hostname === 'localhost'
|
||||
|
||||
// 本地开发:直连后端 + mock 用户
|
||||
const DEV_BASE = 'http://localhost:4006'
|
||||
|
||||
async function devRequest(url, options = {}) {
|
||||
const res = await fetch(DEV_BASE + url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Dev-Mock-User': 'admin', // 触发后端 DEV_DEFAULT_USER
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// 生产:走网关(通过 axios 实例)
|
||||
async function gwRequest(url, options = {}) {
|
||||
const method = (options.method || 'GET').toLowerCase()
|
||||
const config = { method, url }
|
||||
if (options.headers) config.headers = options.headers
|
||||
if (options.body) config.data = JSON.parse(options.body)
|
||||
|
||||
const res = await request(config)
|
||||
// 后端返回原始数据时 axios response 的 .data 是实际内容
|
||||
// 网关返回时拦截器已解包为 {code,data,msg},.data 也是实际内容
|
||||
return res.data
|
||||
}
|
||||
|
||||
const doRequest = isDev ? devRequest : gwRequest
|
||||
|
||||
export function useApi() {
|
||||
return {
|
||||
// Notes
|
||||
listNotes: (folderId) => doRequest(folderId ? `/api/notes?folder_id=${folderId}` : '/api/notes'),
|
||||
getNote: (id) => doRequest(`/api/notes/${id}`),
|
||||
createNote: (data) => doRequest('/api/notes', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateNote: (id, data) => doRequest(`/api/notes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteNote: (id) => doRequest(`/api/notes/${id}`, { method: 'DELETE' }),
|
||||
shareNote: (id, ttl) => doRequest(`/api/notes/${id}/share`, { method: 'POST', body: JSON.stringify({ ttl }) }),
|
||||
unshareNote: (id) => doRequest(`/api/notes/${id}/share`, { method: 'DELETE' }),
|
||||
moveNote: (id, folderId) => doRequest(`/api/notes/${id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId }) }),
|
||||
|
||||
// Websites
|
||||
listWebsites: (folderId) => doRequest(folderId ? `/api/websites?folder_id=${folderId}` : '/api/websites'),
|
||||
getWebsite: (id) => doRequest(`/api/websites/${id}`),
|
||||
createWebsite: (data) => doRequest('/api/websites', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateWebsite: (id, data) => doRequest(`/api/websites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteWebsite: (id) => doRequest(`/api/websites/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Accounts
|
||||
getAccount: (id) => doRequest(`/api/accounts/${id}`),
|
||||
addAccount: (siteId, data) => doRequest(`/api/websites/${siteId}/accounts`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateAccount: (id, data) => doRequest(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteAccount: (id) => doRequest(`/api/accounts/${id}`, { method: 'DELETE' }),
|
||||
moveAccount: (id, direction) => doRequest(`/api/accounts/${id}/move`, { method: 'PUT', body: JSON.stringify({ direction }) }),
|
||||
|
||||
// Folders
|
||||
listFolders: (type) => doRequest(`/api/folders?type=${type}`),
|
||||
createFolder: (data) => doRequest('/api/folders', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateFolder: (id, data) => doRequest(`/api/folders/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteFolder: (id) => doRequest(`/api/folders/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Import
|
||||
parseImport: (text) => doRequest('/api/import/parse', { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
}
|
||||
}
|
||||
27
frontend/src/directives/permission.js
Normal file
27
frontend/src/directives/permission.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// v-permission 指令 — 按钮权限控制
|
||||
// 从 Cookie 中读取 JWT Token,解析 payload 中的 permissions 数组
|
||||
// 如果当前用户没有所需权限,则从 DOM 中移除该元素
|
||||
//
|
||||
// 用法: <el-button v-permission="'system:user:add'">新增用户</el-button>
|
||||
|
||||
function getPermissions() {
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
|
||||
if (!token) return []
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
return payload.permissions ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const permission = {
|
||||
mounted(el, binding) {
|
||||
const permissions = getPermissions()
|
||||
const required = binding.value
|
||||
// 没有该权限则从 DOM 中移除元素
|
||||
if (required && !permissions.includes(required)) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
},
|
||||
}
|
||||
10
frontend/src/main.js
Normal file
10
frontend/src/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router.js'
|
||||
import { permission } from './directives/permission.js'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.directive('permission', permission)
|
||||
app.mount('#app')
|
||||
38
frontend/src/router.js
Normal file
38
frontend/src/router.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import NotesView from './views/NotesView.vue'
|
||||
import WebsitesView from './views/WebsitesView.vue'
|
||||
import SharesView from './views/SharesView.vue'
|
||||
|
||||
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE || 'mokeetext-edit'
|
||||
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL || 'https://login.user.zgitm.com'
|
||||
const WEBSITE_URL = window.location.origin
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/notes' },
|
||||
{ path: '/notes', name: 'notes', component: NotesView },
|
||||
{ path: '/websites', name: 'websites', component: WebsitesView },
|
||||
{ path: '/shares', name: 'shares', component: SharesView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫:无 Token 则跳转统一登录页
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 本地开发模式:跳过登录检查
|
||||
if (window.location.hostname === 'localhost') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
|
||||
if (!token) {
|
||||
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}&redirect=${encodeURIComponent(WEBSITE_URL)}`
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
53
frontend/src/utils/request.js
Normal file
53
frontend/src/utils/request.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Axios 实例 — 统一请求拦截器
|
||||
// 开发模式(localhost):直连后端,不走网关
|
||||
// 生产模式:走网关 gw.server.zgitm.com,带 Authorization + X-System-Code
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
|
||||
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL
|
||||
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
|
||||
|
||||
// 从 Cookie 读取 Token(登录后网关写入的跨域 Cookie,domain: .zgitm.com)
|
||||
function getToken() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: GATEWAY_URL,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 请求拦截器:携带 Token 和系统编码
|
||||
request.interceptors.request.use(config => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
// 网关根据此头识别业务系统
|
||||
config.headers['X-System-Code'] = SYSTEM_CODE
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const data = response.data
|
||||
// 网关 API 返回 { code: 200, data: ..., msg: ... },解包返回
|
||||
// 业务后端返回原始数据(数组/对象),直接透传 axios response
|
||||
if (data && typeof data === 'object' && (data.code === 200 || data.code === 0)) {
|
||||
return data
|
||||
}
|
||||
return response
|
||||
},
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export { getToken }
|
||||
export default request
|
||||
321
frontend/src/views/NotesView.vue
Normal file
321
frontend/src/views/NotesView.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Left: folder tree -->
|
||||
<aside class="sidebar" :class="{ collapsed: folderCollapsed }">
|
||||
<div class="sidebar-label">
|
||||
<span class="add-btn" @click="folderCollapsed=!folderCollapsed" :title="folderCollapsed?'展开':'收起'" style="cursor:pointer;">
|
||||
{{ folderCollapsed ? '▶' : '◀' }}
|
||||
</span>
|
||||
<span class="add-btn" v-if="!folderCollapsed" @click="addFolder" style="font-size:11px;font-weight:600;width:auto;padding:0 6px;">新增文件夹</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; overflow-y:auto;" v-show="!folderCollapsed">
|
||||
<!-- 全部 -->
|
||||
<div class="folder-item" :class="{ active: filterFolderId === null }" @click="filterFolderId=null">
|
||||
<span class="folder-dot" style="opacity:.8; width:8px; height:8px;"></span><span style="flex:1;">全部笔记</span><span class="count">{{ allNotes.length }}</span>
|
||||
</div>
|
||||
<!-- 文件夹列表 -->
|
||||
<div v-for="f in folders" :key="f.id">
|
||||
<div class="folder-item" :class="{ active: filterFolderId === f.id }"
|
||||
@click="filterFolderId = filterFolderId === f.id ? null : f.id"
|
||||
style="position:relative;">
|
||||
<span class="folder-dot"></span>
|
||||
<span style="flex:1;">{{ f.name }}</span>
|
||||
<span class="count">{{ folderNoteCounts[f.id] || 0 }}</span>
|
||||
<span class="folder-more" @click.stop="toggleMenu(f)">⋮</span>
|
||||
<div class="folder-menu" v-show="menuFolderId === f.id">
|
||||
<div class="folder-menu-item" @click.stop="renameFolder(f)">✏️ 重命名</div>
|
||||
<div class="folder-menu-item danger" @click.stop="deleteFolder(f)">🗑 删除</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: document table or editor -->
|
||||
<section class="content" v-if="!editingNote">
|
||||
<div class="content-header">
|
||||
<span style="font-size:15px; font-weight:600;">{{ filterFolderId ? folderName(filterFolderId) : '全部笔记' }}</span>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<input class="input" style="width:200px;" type="text" placeholder="搜索标题…" v-model="searchQuery">
|
||||
<button class="btn btn-primary btn-sm" @click="createNote">+ 新建笔记</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; overflow-y:auto; padding:0 20px 20px;">
|
||||
<div v-if="!filteredNotes.length" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
|
||||
<p>暂无笔记</p>
|
||||
</div>
|
||||
<table v-else class="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">标题</th>
|
||||
<th style="width:10%;">创建人</th>
|
||||
<th style="width:22%;">创建时间</th>
|
||||
<th style="width:22%;">修改时间</th>
|
||||
<th style="width:6%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="n in filteredNotes" :key="n.id" @click="openNote(n)" class="clickable-row">
|
||||
<td class="doc-title-cell" data-label="标题">{{ n.title || '无标题笔记' }}</td>
|
||||
<td data-label="创建人" style="font-size:12px; color:var(--text-secondary);">{{ n.created_by || 'admin' }}</td>
|
||||
<td data-label="创建时间" style="font-size:12px; color:var(--text-tertiary); font-family:monospace;">{{ fmtFull(n.created_at) }}</td>
|
||||
<td data-label="修改时间" style="font-size:12px; color:var(--text-tertiary); font-family:monospace;">{{ fmtFull(n.updated_at) }}</td>
|
||||
<td><button class="btn btn-danger-ghost btn-xs" @click.stop="deleteNoteById(n.id)">删除</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Editor -->
|
||||
<section class="content" v-else>
|
||||
<div class="content-header" style="flex-wrap:wrap; gap:8px;">
|
||||
<button class="btn btn-ghost btn-sm" @click="backToList">← 返回</button>
|
||||
<input class="content-title" v-model="editingNote.title" placeholder="无标题笔记" @input="scheduleSave" style="min-width:120px;">
|
||||
<!-- 当前文件夹 -->
|
||||
<span style="font-size:11px; color:var(--text-tertiary); white-space:nowrap; display:flex; align-items:center; gap:4px;">
|
||||
📂 {{ folderName(editingNote.folder_id) }}
|
||||
</span>
|
||||
<!-- 移动到 -->
|
||||
<button class="btn btn-ghost btn-sm" @click="showMoveModal=true" title="移动到其他文件夹">📦 移动</button>
|
||||
<!-- 分享 -->
|
||||
<button v-if="!shareUrl" class="btn btn-ghost btn-sm" @click="showSharePicker=true">🔗 分享</button>
|
||||
<button v-else class="btn btn-ghost btn-sm" @click="copyShareUrl" style="color:var(--success);">✅ 已分享</button>
|
||||
<div class="toggle-group">
|
||||
<button :class="{ active: viewMode==='edit' }" @click="viewMode='edit'">编辑</button>
|
||||
<button :class="{ active: viewMode==='preview' }" @click="viewMode='preview'">预览</button>
|
||||
<button :class="{ active: viewMode==='split' }" @click="viewMode='split'">分屏</button>
|
||||
</div>
|
||||
<span class="save-badge">{{ saveStatus }}</span>
|
||||
<button class="btn btn-danger-ghost btn-sm" @click="deleteCurrentNote">删除</button>
|
||||
</div>
|
||||
<NoteEditor ref="editorRef" :viewMode="viewMode" @update="onEditorUpdate" />
|
||||
</section>
|
||||
|
||||
<!-- Move modal -->
|
||||
<Modal :show="showMoveModal" title="移动到文件夹" @close="showMoveModal=false">
|
||||
<div class="form-group">
|
||||
<label class="form-label">选择目标文件夹</label>
|
||||
<select class="select" v-model="moveTargetFolderId">
|
||||
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-ghost" @click="showMoveModal=false">取消</button>
|
||||
<button class="btn btn-primary" @click="doMove">移动</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Share modal -->
|
||||
<Modal :show="showSharePicker" title="生成分享链接" :width="420" @close="closeShareModal">
|
||||
<p style="color:var(--text-secondary); font-size:13px; margin-bottom:12px;">选择有效期:</p>
|
||||
<div style="display:flex; flex-direction:column; gap:6px; margin-bottom:16px;">
|
||||
<label class="share-radio" :class="{ active: shareTtl==='7d' }" @click="shareTtl='7d'">
|
||||
<span class="share-radio-dot"></span> 7 天<span style="font-size:11px; color:var(--text-tertiary); margin-left:8px;">一周后失效</span>
|
||||
</label>
|
||||
<label class="share-radio" :class="{ active: shareTtl==='30d' }" @click="shareTtl='30d'">
|
||||
<span class="share-radio-dot"></span> 30 天<span style="font-size:11px; color:var(--text-tertiary); margin-left:8px;">一个月后失效</span>
|
||||
</label>
|
||||
<label class="share-radio" :class="{ active: shareTtl==='1y' }" @click="shareTtl='1y'">
|
||||
<span class="share-radio-dot"></span> 1 年<span style="font-size:11px; color:var(--text-tertiary); margin-left:8px;">一年后失效</span>
|
||||
</label>
|
||||
<label class="share-radio" :class="{ active: shareTtl==='forever' }" @click="shareTtl='forever'">
|
||||
<span class="share-radio-dot"></span> 永久有效
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 生成后显示链接 -->
|
||||
<div v-if="shareUrl" style="background:#F0FAF0; border:1px solid #A3E4A7; border-radius:8px; padding:12px 14px;">
|
||||
<div style="font-size:12px; font-weight:600; color:#166534; margin-bottom:6px;">✅ 链接已生成</div>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<input class="input" style="font-size:12px;" :value="shareUrl" readonly @focus="$event.target.select()">
|
||||
<button class="btn btn-primary btn-sm" @click="copyShareUrl">复制</button>
|
||||
</div>
|
||||
<div style="font-size:10px; color:var(--text-tertiary); margin-top:4px;">{{ shareExpiresText }}</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn btn-ghost" @click="closeShareModal" v-if="!shareUrl">取消</button>
|
||||
<button class="btn btn-primary" @click="generateShare(shareTtl)" v-if="!shareUrl">生成链接</button>
|
||||
<button class="btn btn-primary" @click="closeShareModal" v-else>完成</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal :show="confirmDlg.show" :title="confirmDlg.title" :message="confirmDlg.message"
|
||||
@confirm="onConfirmOk" @cancel="onConfirmCancel" />
|
||||
<PromptModal :show="promptDlg.show" :title="promptDlg.title" :label="promptDlg.label"
|
||||
:initial="promptDlg.initial"
|
||||
@confirm="onPromptOk" @cancel="onPromptCancel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import NoteEditor from '../components/NoteEditor.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
import ConfirmModal from '../components/ConfirmModal.vue'
|
||||
import PromptModal from '../components/PromptModal.vue'
|
||||
|
||||
const api = useApi()
|
||||
const editorRef = ref(null)
|
||||
|
||||
const folders = ref([])
|
||||
const allNotes = ref([])
|
||||
const folderNoteCounts = reactive({})
|
||||
const filterFolderId = ref(null)
|
||||
const folderCollapsed = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const menuFolderId = ref(null)
|
||||
const editingNote = ref(null)
|
||||
const viewMode = ref('edit')
|
||||
const saveStatus = ref('已保存')
|
||||
const shareUrl = ref('')
|
||||
const shareTtl = ref('forever')
|
||||
const shareExpiresText = ref('')
|
||||
const showSharePicker = ref(false)
|
||||
const showMoveModal = ref(false)
|
||||
const moveTargetFolderId = ref(null)
|
||||
let saveTimer = null
|
||||
let uncatFolderId = null
|
||||
|
||||
const confirmDlg = reactive({ show: false, title: '', message: '' })
|
||||
let confirmResolve = null
|
||||
function showConfirm(title, message) { return new Promise(r => { confirmDlg.title=title; confirmDlg.message=message; confirmDlg.show=true; confirmResolve=r }) }
|
||||
function onConfirmOk() { confirmDlg.show=false; if(confirmResolve) confirmResolve(true) }
|
||||
function onConfirmCancel() { confirmDlg.show=false; if(confirmResolve) confirmResolve(false) }
|
||||
|
||||
const promptDlg = reactive({ show: false, title: '', label: '', initial: '' })
|
||||
let promptResolve = null
|
||||
const promptValue = ref('')
|
||||
function showPrompt(title, label, initial) { return new Promise(r => { promptDlg.title=title; promptDlg.label=label; promptDlg.initial=initial||''; promptDlg.show=true; promptValue.value=initial||''; promptResolve=r }) }
|
||||
function onPromptOk(v) { promptDlg.show=false; if(promptResolve) promptResolve(v) }
|
||||
function onPromptCancel() { promptDlg.show=false; if(promptResolve) promptResolve(null) }
|
||||
|
||||
const filteredNotes = computed(() => {
|
||||
let list = allNotes.value
|
||||
if (filterFolderId.value) list = list.filter(n => (n.folder_id || uncatFolderId) === filterFolderId.value)
|
||||
if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(n => (n.title||'').toLowerCase().includes(q)) }
|
||||
return list
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const [fs, notes] = await Promise.all([api.listFolders('note'), api.listNotes()])
|
||||
folders.value = fs; allNotes.value = notes; uncatFolderId = fs.find(f=>f.name==='未分类')?.id||null; recount(notes)
|
||||
})
|
||||
|
||||
function recount(list) {
|
||||
list=list||allNotes.value; Object.keys(folderNoteCounts).forEach(k=>delete folderNoteCounts[k])
|
||||
list.forEach(n=>{ const fid=n.folder_id||uncatFolderId||0; folderNoteCounts[fid]=(folderNoteCounts[fid]||0)+1 })
|
||||
}
|
||||
function folderName(fid) { if(!fid)return'未分类'; const f=folders.value.find(x=>x.id===fid); return f?f.name:'未分类' }
|
||||
function fmtFull(d) { return d?d.replace('T',' ').substring(0,19):'-' }
|
||||
|
||||
async function openNote(n) { const note=await api.getNote(n.id); editingNote.value=note; await nextTick(); await nextTick(); editorRef.value?.setContent(note.content||'') }
|
||||
function backToList() { saveNote(); editingNote.value=null; loadAllNotes() }
|
||||
async function createNote() { const note=await api.createNote({title:'无标题笔记',content:'',folder_id:filterFolderId.value||uncatFolderId}); openNote(note); await loadAllNotes() }
|
||||
async function loadAllNotes() { allNotes.value=await api.listNotes(); recount() }
|
||||
function onEditorUpdate(h) { if(editingNote.value)editingNote.value.content=h; scheduleSave() }
|
||||
function scheduleSave() { saveStatus.value='保存中…'; clearTimeout(saveTimer); saveTimer=setTimeout(saveNote,800) }
|
||||
async function saveNote() { if(!editingNote.value?.id)return; await api.updateNote(editingNote.value.id,{title:editingNote.value.title,content:editingNote.value.content}); saveStatus.value='已保存' }
|
||||
|
||||
async function deleteCurrentNote() {
|
||||
const t = editingNote.value?.title || '无标题'
|
||||
if(!editingNote.value?.id||!await showConfirm('删除笔记',`确定删除「${t}」吗?`))return
|
||||
await api.deleteNote(editingNote.value.id); editingNote.value=null; await loadAllNotes()
|
||||
}
|
||||
async function deleteNoteById(id) {
|
||||
const n = allNotes.value.find(x=>x.id===id); const t = n?.title || '无标题'
|
||||
if(!await showConfirm('删除笔记',`确定删除「${t}」吗?`))return
|
||||
await api.deleteNote(id); await loadAllNotes()
|
||||
}
|
||||
async function addFolder() { const name=await showPrompt('新建文件夹','名称',''); if(!name)return; const f=await api.createFolder({name,type:'note'}); folders.value.push(f) }
|
||||
async function renameFolder(f) { const name=await showPrompt('重命名','新名称',f.name); if(!name)return; await api.updateFolder(f.id,{name}); f.name=name }
|
||||
async function deleteFolder(f) {
|
||||
if(!await showConfirm('删除文件夹',`确定删除文件夹「${f.name}」吗?\n内容将移至「未分类」`))return
|
||||
await api.deleteFolder(f.id); folders.value=folders.value.filter(x=>x.id!==f.id)
|
||||
if(filterFolderId.value===f.id)filterFolderId.value=null; await loadAllNotes()
|
||||
}
|
||||
function toggleMenu(f) { menuFolderId.value=menuFolderId.value===f.id?null:f.id }
|
||||
|
||||
// ===== 分享 =====
|
||||
async function generateShare(ttl) {
|
||||
if (!editingNote.value?.id) return
|
||||
const result = await api.shareNote(editingNote.value.id, ttl)
|
||||
const BASE = window.location.hostname === 'localhost' ? '' : 'https://mokeetext.server.zgitm.com'
|
||||
shareUrl.value = BASE + result.url
|
||||
shareExpiresText.value = '有效期至 ' + result.expires_at
|
||||
}
|
||||
|
||||
function copyShareUrl() {
|
||||
if (shareUrl.value) { navigator.clipboard.writeText(shareUrl.value); showToast('链接已复制') }
|
||||
}
|
||||
|
||||
function closeShareModal() {
|
||||
showSharePicker.value = false
|
||||
shareTtl.value = 'forever'
|
||||
shareUrl.value = ''
|
||||
}
|
||||
|
||||
function showAlert(title, msg) {
|
||||
confirmDlg.title=title; confirmDlg.message=msg; confirmDlg.show=true; confirmResolve=()=>{confirmDlg.show=false}
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t=document.createElement('div'); t.className='toast'; t.textContent=msg; document.body.appendChild(t)
|
||||
setTimeout(()=>t.remove(), 2000)
|
||||
}
|
||||
|
||||
// ===== 移动 =====
|
||||
async function doMove() {
|
||||
if (!editingNote.value?.id || !moveTargetFolderId.value) return
|
||||
await api.moveNote(editingNote.value.id, moveTargetFolderId.value)
|
||||
editingNote.value.folder_id = moveTargetFolderId.value
|
||||
showMoveModal.value = false
|
||||
showToast('已移动')
|
||||
await loadAllNotes()
|
||||
}
|
||||
|
||||
// ===== Ctrl+S 快捷键 =====
|
||||
function onKeydown(e) {
|
||||
if ((e.ctrlKey||e.metaKey) && e.key==='s') {
|
||||
e.preventDefault()
|
||||
saveNote().then(()=>showToast('已保存(Ctrl+S)'))
|
||||
}
|
||||
}
|
||||
|
||||
function onDocClick() { menuFolderId.value=null }
|
||||
onMounted(()=>{
|
||||
document.addEventListener('click',onDocClick)
|
||||
document.addEventListener('keydown',onKeydown)
|
||||
})
|
||||
onUnmounted(()=>{
|
||||
document.removeEventListener('click',onDocClick)
|
||||
document.removeEventListener('keydown',onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-table { width:100%; border-collapse:collapse; }
|
||||
.doc-table th { text-align:left; padding:10px 12px; font-size:11px; font-weight:600; color:var(--text-tertiary); letter-spacing:0.04em; border-bottom:2px solid var(--border-light); user-select:none; }
|
||||
.doc-table td { padding:10px 12px; border-bottom:1px solid var(--border-light); }
|
||||
.clickable-row { cursor:pointer; }
|
||||
.clickable-row:hover td { background:var(--bg-hover); }
|
||||
.doc-title-cell { font-size:14px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:0; }
|
||||
|
||||
.share-radio {
|
||||
display:flex; align-items:center; gap:8px; padding:10px 14px;
|
||||
border:1px solid var(--border-light); border-radius:var(--r-md); background:var(--bg-surface);
|
||||
cursor:pointer; transition:all 0.15s; font-size:13px; user-select:none;
|
||||
}
|
||||
.share-radio:hover { border-color:var(--brand); }
|
||||
.share-radio.active { border-color:var(--brand); background:var(--brand-light); }
|
||||
.share-radio-dot {
|
||||
width:16px; height:16px; border-radius:50%; border:2px solid var(--border-medium);
|
||||
display:inline-flex; align-items:center; justify-content:center; flex-shrink:0; transition:all 0.15s;
|
||||
}
|
||||
.share-radio.active .share-radio-dot { border-color:var(--brand); }
|
||||
.share-radio.active .share-radio-dot::after { content:''; width:8px; height:8px; border-radius:50%; background:var(--brand); }
|
||||
</style>
|
||||
92
frontend/src/views/SharesView.vue
Normal file
92
frontend/src/views/SharesView.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<section class="content">
|
||||
<div class="content-header">
|
||||
<span style="font-size:15px; font-weight:600;">分享管理</span>
|
||||
<span class="text-muted">{{ shares.length }} 条分享</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; overflow-y:auto; padding:0 20px 20px;">
|
||||
<div v-if="!shares.length" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
|
||||
<p>暂无分享记录</p>
|
||||
</div>
|
||||
<table v-else class="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:25%;">笔记标题</th>
|
||||
<th style="width:30%;">分享链接</th>
|
||||
<th style="width:12%;">有效期</th>
|
||||
<th style="width:8%;">创建人</th>
|
||||
<th style="width:12%;">更新时间</th>
|
||||
<th style="width:13%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in shares" :key="s.id">
|
||||
<td class="doc-title-cell" data-label="标题">{{ s.title || '无标题' }}</td>
|
||||
<td data-label="链接" style="font-size:12px; font-family:monospace;">
|
||||
<a :href="BASE + s.share_url" target="_blank" style="color:var(--brand); text-decoration:none;">{{ BASE + s.share_url }}</a>
|
||||
</td>
|
||||
<td data-label="有效期" style="font-size:12px; color:var(--text-secondary);">{{ s.share_expires_at }}</td>
|
||||
<td data-label="创建人" style="font-size:12px; color:var(--text-secondary);">{{ s.created_by }}</td>
|
||||
<td data-label="更新时间" style="font-size:12px; color:var(--text-tertiary); font-family:monospace;">{{ fmt(s.updated_at) }}</td>
|
||||
<td>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button class="card-btn" @click="copyUrl(s)">复制</button>
|
||||
<button class="card-btn danger-btn" @click="cancelShare(s)">取消分享</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmModal :show="confirmDlg.show" :title="confirmDlg.title" :message="confirmDlg.message"
|
||||
@confirm="onConfirmOk" @cancel="onConfirmCancel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import ConfirmModal from '../components/ConfirmModal.vue'
|
||||
|
||||
const shares = ref([])
|
||||
|
||||
const confirmDlg = reactive({ show: false, title: '', message: '' })
|
||||
let confirmResolve = null
|
||||
function showConfirm(t, m) { return new Promise(r => { confirmDlg.title=t; confirmDlg.message=m; confirmDlg.show=true; confirmResolve=r }) }
|
||||
function onConfirmOk() { confirmDlg.show=false; if(confirmResolve) confirmResolve(true) }
|
||||
function onConfirmCancel() { confirmDlg.show=false; if(confirmResolve) confirmResolve(false) }
|
||||
|
||||
const BASE = window.location.hostname === 'localhost' ? '' : 'https://mokeetext.server.zgitm.com'
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
const res = await fetch(BASE + '/api/shares')
|
||||
shares.value = await res.json()
|
||||
}
|
||||
|
||||
function fmt(d) { return d ? d.replace('T',' ').substring(0,19) : '-' }
|
||||
|
||||
function copyUrl(s) {
|
||||
const url = BASE + s.share_url
|
||||
navigator.clipboard.writeText(url)
|
||||
const t = document.createElement('div'); t.className='toast'; t.textContent='已复制'; document.body.appendChild(t)
|
||||
setTimeout(()=>t.remove(), 1500)
|
||||
}
|
||||
|
||||
async function cancelShare(s) {
|
||||
if (!await showConfirm('取消分享', `确定取消「${s.title}」的分享链接?`)) return
|
||||
await fetch(BASE + `/api/notes/${s.id}/share`, { method: 'DELETE' })
|
||||
await load()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-table { width:100%; border-collapse:collapse; }
|
||||
.doc-table th { text-align:left; padding:10px 12px; font-size:11px; font-weight:600; color:var(--text-tertiary); letter-spacing:0.04em; border-bottom:2px solid var(--border-light); user-select:none; }
|
||||
.doc-table td { padding:10px 12px; border-bottom:1px solid var(--border-light); }
|
||||
.doc-title-cell { font-size:14px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:0; }
|
||||
</style>
|
||||
439
frontend/src/views/WebsitesView.vue
Normal file
439
frontend/src/views/WebsitesView.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="stat-row">
|
||||
<div class="stat">
|
||||
<div class="stat-num" style="color:var(--brand);">{{ websites.length }}</div>
|
||||
<div class="stat-label">网站</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-num" style="color:var(--success);">{{ totalAccounts }}</div>
|
||||
<div class="stat-label">账号</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" @click="openWebsiteModal()">添加网站</button>
|
||||
<button class="btn btn-outline btn-block" @click="showImport = true">智能导入</button>
|
||||
|
||||
<div class="sidebar-label">
|
||||
<span>文件夹</span>
|
||||
<span class="add-btn" @click="addFolder">+</span>
|
||||
</div>
|
||||
|
||||
<input class="input mb-sm" type="text" placeholder="搜索网站或网址…" v-model="searchQuery">
|
||||
|
||||
<div style="flex:1; overflow-y:auto;">
|
||||
<div v-for="f in folders" :key="f.id" class="folder-group">
|
||||
<div class="folder-item" @click="toggleExpand(f)" style="position:relative;">
|
||||
<span class="folder-arrow">{{ expandedFolders[f.id] ? '▼' : '▶' }}</span>
|
||||
<span style="flex:1; font-weight:500;">📁 {{ f.name }}</span>
|
||||
<span class="count">{{ folderSiteCounts[f.id] || 0 }}</span>
|
||||
<span class="folder-more" @click.stop="toggleMenu(f, $event)">⋮</span>
|
||||
<div class="folder-menu" v-show="menuFolderId === f.id">
|
||||
<div class="folder-menu-item" @click.stop="renameFolder(f)">✏️ 重命名</div>
|
||||
<div class="folder-menu-item danger" @click.stop="deleteFolder(f)">🗑 删除</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-children" v-show="expandedFolders[f.id]">
|
||||
<div v-for="s in folderSites(f.id)" :key="s.id"
|
||||
class="site-item" :class="{ active: currentSite?.id === s.id }"
|
||||
@click="selectSite(s)">
|
||||
<span>🌐</span>
|
||||
<span class="site-name">{{ s.name }}</span>
|
||||
<span class="site-count">{{ s.accounts?.length || 0 }}个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-label">未分类</div>
|
||||
<div v-for="s in uncategorizedSites" :key="s.id"
|
||||
class="site-item" :class="{ active: currentSite?.id === s.id }"
|
||||
@click="selectSite(s)">
|
||||
<span>🌐</span>
|
||||
<span class="site-name">{{ s.name }}</span>
|
||||
<span class="site-count">{{ s.accounts?.length || 0 }}个</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Detail -->
|
||||
<section class="content">
|
||||
<div class="content-header">
|
||||
<div v-if="currentSite" style="display:flex; align-items:center; gap:8px;">
|
||||
<span style="font-size:11px;color:var(--text-tertiary);">{{ getFolderName(currentSite.folder_id) }} /</span>
|
||||
<span style="font-size:15px;font-weight:600;">{{ currentSite.name }}</span>
|
||||
<a :href="currentSite.url" target="_blank" style="font-size:11px;color:var(--brand);text-decoration:none;">{{ currentSite.url }}</a>
|
||||
</div>
|
||||
<div v-else class="text-muted">选择左侧网站查看账号</div>
|
||||
<div style="display:flex; gap:6px;" v-if="currentSite">
|
||||
<button class="btn btn-ghost btn-sm" @click="openWebsiteModal(currentSite)">✏️ 编辑</button>
|
||||
<button class="btn btn-outline btn-sm" @click="openAccountModal()">+ 添加账号</button>
|
||||
<button class="btn btn-danger-ghost btn-sm" @click="deleteCurrentSite">🗑 删除网站</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-pane" style="flex:1;">
|
||||
<div v-if="!currentSite" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
|
||||
<div style="font-size:48px; margin-bottom:16px; opacity:0.4;">🔐</div>
|
||||
<p>选择左侧网站查看账号密码</p>
|
||||
</div>
|
||||
<div v-else-if="!currentSite.accounts?.length" style="text-align:center; padding:80px 20px; color:var(--text-placeholder);">
|
||||
<p>暂无账号</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(a, i) in currentSite.accounts" :key="a.id" class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">👤 {{ a.remark || '账号' }}</span>
|
||||
<div style="display:flex; gap:4px; align-items:center;">
|
||||
<button class="card-btn" @click="moveAccount(a.id,'up')" :disabled="i===0" title="上移">↑</button>
|
||||
<button class="card-btn" @click="moveAccount(a.id,'down')" :disabled="i===currentSite.accounts.length-1" title="下移">↓</button>
|
||||
<button class="card-btn edit-btn" @click="openAccountModal(a)">✏️ 编辑</button>
|
||||
<button class="card-btn danger-btn" @click="deleteAccount(a.id)">🗑 删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span class="label">账号</span>
|
||||
<span style="font-family:monospace;">{{ a.username }}</span>
|
||||
<button class="card-btn" @click="copy(a.username)">复制</button>
|
||||
<span class="label">密码</span>
|
||||
<span style="font-family:monospace;">{{ showPw[a.id] ? a.password : '••••••••' }}</span>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button class="card-btn" @click="showPw[a.id]=!showPw[a.id]">{{ showPw[a.id] ? '隐藏' : '显示' }}</button>
|
||||
<button class="card-btn" @click="copy(a.password)">复制</button>
|
||||
</div>
|
||||
<span class="label">说明</span>
|
||||
<span style="color:var(--text-secondary); font-size:12px;">{{ a.remark || '-' }}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Website Modal -->
|
||||
<Modal :show="websiteModal" :title="editingWebsite ? '编辑网站' : '添加网站'" @close="websiteModal=false">
|
||||
<div class="form-group">
|
||||
<label class="form-label">网站名</label>
|
||||
<input class="input" v-model="websiteForm.name" placeholder="例如:GitHub">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">网址</label>
|
||||
<input class="input" v-model="websiteForm.url" placeholder="https://github.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">文件夹</label>
|
||||
<select class="select" v-model="websiteForm.folder_id">
|
||||
<option :value="null">未分类</option>
|
||||
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-ghost" @click="websiteModal=false">取消</button>
|
||||
<button class="btn btn-primary" @click="saveWebsite">保存</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Account Modal -->
|
||||
<Modal :show="accountModal" :title="editingAccount ? '编辑账号' : '添加账号'" @close="accountModal=false">
|
||||
<div class="form-group">
|
||||
<label class="form-label">账号 / 用户名</label>
|
||||
<input class="input" v-model="accountForm.username" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">密码</label>
|
||||
<div class="form-row">
|
||||
<input class="input" :type="accountPwVisible ? 'text' : 'password'" v-model="accountForm.password" placeholder="输入密码" style="flex:1;">
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="accountPwVisible=!accountPwVisible">{{ accountPwVisible ? '隐藏' : '显示' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">备注</label>
|
||||
<textarea class="textarea" v-model="accountForm.remark" rows="2" placeholder="例如:主账号、小号…"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">排序</label>
|
||||
<input class="input" v-model.number="accountForm.sort_order" type="number" placeholder="数字越小越靠前" style="width:120px;">
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-ghost" @click="accountModal=false">取消</button>
|
||||
<button class="btn btn-primary" @click="saveAccount">保存</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<Modal :show="showImport" title="智能导入" :width="520" @close="showImport=false">
|
||||
<div class="form-group">
|
||||
<label class="form-label">粘贴账号信息</label>
|
||||
<textarea class="import-textarea" v-model="importText"
|
||||
placeholder="https://github.com 代码网站 zhangsan@qq.com Abc123 个人开发账号"></textarea>
|
||||
<div class="import-hint">首行=网址+空格+网站名 | 第2行=账号 | 第3行=密码 | 第4行=备注</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block mb-md" @click="parseImport">🔍 自动识别</button>
|
||||
|
||||
<div v-if="importResult" class="result-box">
|
||||
<div style="font-weight:600; color:#166534; margin-bottom:10px;">识别结果</div>
|
||||
<div class="result-grid">
|
||||
<span style="color:var(--text-secondary);">网址</span><span>{{ importResult.url }}</span>
|
||||
<span style="color:var(--text-secondary);">网站名</span><span>{{ importResult.site_name }}</span>
|
||||
<span style="color:var(--text-secondary);">账号</span><span style="font-family:monospace;">{{ importResult.username }}</span>
|
||||
<span style="color:var(--text-secondary);">密码</span><span style="font-family:monospace;">{{ importResult.password }}</span>
|
||||
<span style="color:var(--text-secondary);">备注</span><span>{{ importResult.remark || '(无)' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:12px;">
|
||||
<label class="form-label">存放文件夹</label>
|
||||
<div class="form-row">
|
||||
<select class="select" v-model="importFolderId">
|
||||
<option :value="null">已有文件夹…</option>
|
||||
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
<span class="text-muted">或</span>
|
||||
<input class="input" v-model="importNewFolder" placeholder="新建文件夹…" style="flex:1;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importResult.matched_site" class="alert alert-warning mb-md">
|
||||
⚠️ <strong>{{ importResult.matched_site.name }}</strong> 已存在({{ importResult.matched_site.account_count }}个账号),将追加进去
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="padding:0; border:none;">
|
||||
<button class="btn btn-ghost" @click="showImport=false">取消</button>
|
||||
<button class="btn btn-primary" @click="confirmImport">确认添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal :show="confirmDlg.show" :title="confirmDlg.title" :message="confirmDlg.message"
|
||||
@confirm="onConfirmOk" @cancel="onConfirmCancel" />
|
||||
<PromptModal :show="promptDlg.show" :title="promptDlg.title" :label="promptDlg.label"
|
||||
:initial="promptDlg.initial"
|
||||
@confirm="onPromptOk" @cancel="onPromptCancel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import Modal from '../components/Modal.vue'
|
||||
import ConfirmModal from '../components/ConfirmModal.vue'
|
||||
import PromptModal from '../components/PromptModal.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
const websites = ref([])
|
||||
const folders = ref([])
|
||||
const currentSite = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const expandedFolders = reactive({})
|
||||
const menuFolderId = ref(null)
|
||||
const showPw = reactive({})
|
||||
const totalAccounts = computed(() => websites.value.reduce((s, w) => s + (w.accounts?.length || 0), 0))
|
||||
const folderSiteCounts = computed(() => {
|
||||
const m = {}
|
||||
websites.value.forEach(w => {
|
||||
const fid = w.folder_id || '__none__'
|
||||
m[fid] = (m[fid] || 0) + 1
|
||||
})
|
||||
return m
|
||||
})
|
||||
|
||||
function folderSites(fid) {
|
||||
return websites.value.filter(w => w.folder_id === fid)
|
||||
}
|
||||
const uncategorizedSites = computed(() => websites.value.filter(w => !w.folder_id))
|
||||
|
||||
// Website modal
|
||||
const websiteModal = ref(false)
|
||||
const editingWebsite = ref(null)
|
||||
const websiteForm = reactive({ name: '', url: '', folder_id: null })
|
||||
|
||||
// Account modal
|
||||
const accountModal = ref(false)
|
||||
const editingAccount = ref(null)
|
||||
const accountForm = reactive({ username: '', password: '', remark: '', sort_order: 0 })
|
||||
const accountPwVisible = ref(false)
|
||||
|
||||
// Import
|
||||
const showImport = ref(false)
|
||||
const importText = ref('')
|
||||
const importResult = ref(null)
|
||||
const importFolderId = ref(null)
|
||||
const importNewFolder = ref('')
|
||||
|
||||
// Dialog state
|
||||
const confirmDlg = reactive({ show: false, title: '', message: '' })
|
||||
let confirmResolve = null
|
||||
function showConfirm(title, message) { return new Promise(r => { confirmDlg.title=title; confirmDlg.message=message; confirmDlg.show=true; confirmResolve=r }) }
|
||||
function onConfirmOk() { confirmDlg.show=false; if(confirmResolve) confirmResolve(true) }
|
||||
function onConfirmCancel() { confirmDlg.show=false; if(confirmResolve) confirmResolve(false) }
|
||||
|
||||
const promptDlg = reactive({ show: false, title: '', label: '', initial: '' })
|
||||
let promptResolve = null
|
||||
function showPrompt(title, label, initial) { return new Promise(r => { promptDlg.title=title; promptDlg.label=label; promptDlg.initial=initial||''; promptDlg.show=true; promptResolve=r }) }
|
||||
function onPromptOk(v) { promptDlg.show=false; if(promptResolve) promptResolve(v) }
|
||||
function onPromptCancel() { promptDlg.show=false; if(promptResolve) promptResolve(null) }
|
||||
|
||||
function showAlert(title, message) { confirmDlg.title=title; confirmDlg.message=message; confirmDlg.show=true; confirmResolve=()=>{confirmDlg.show=false} }
|
||||
|
||||
onMounted(async () => {
|
||||
const [ws, fs] = await Promise.all([api.listWebsites(), api.listFolders('website')])
|
||||
websites.value = ws
|
||||
folders.value = fs
|
||||
fs.forEach(f => { expandedFolders[f.id] = true })
|
||||
})
|
||||
|
||||
function selectSite(site) {
|
||||
currentSite.value = site
|
||||
}
|
||||
|
||||
function getFolderName(fid) {
|
||||
if (!fid) return '未分类'
|
||||
const f = folders.value.find(x => x.id === fid)
|
||||
return f ? f.name : '未分类'
|
||||
}
|
||||
|
||||
function toggleExpand(f) {
|
||||
expandedFolders[f.id] = !expandedFolders[f.id]
|
||||
}
|
||||
|
||||
function toggleMenu(f, ev) {
|
||||
menuFolderId.value = menuFolderId.value === f.id ? null : f.id
|
||||
}
|
||||
|
||||
async function addFolder() {
|
||||
const name = await showPrompt('新建文件夹', '文件夹名称', '')
|
||||
if (!name) return
|
||||
const f = await api.createFolder({ name, type: 'website' })
|
||||
folders.value.push(f)
|
||||
expandedFolders[f.id] = true
|
||||
}
|
||||
|
||||
async function renameFolder(f) {
|
||||
const name = await showPrompt('重命名文件夹', '新名称', f.name)
|
||||
if (!name) return
|
||||
await api.updateFolder(f.id, { name })
|
||||
f.name = name
|
||||
}
|
||||
|
||||
function openWebsiteModal(site) {
|
||||
editingWebsite.value = site || null
|
||||
if (site) {
|
||||
websiteForm.name = site.name
|
||||
websiteForm.url = site.url
|
||||
websiteForm.folder_id = site.folder_id
|
||||
} else {
|
||||
websiteForm.name = ''
|
||||
websiteForm.url = ''
|
||||
websiteForm.folder_id = null
|
||||
}
|
||||
websiteModal.value = true
|
||||
}
|
||||
|
||||
async function saveWebsite() {
|
||||
if (editingWebsite.value) {
|
||||
await api.updateWebsite(editingWebsite.value.id, { ...websiteForm })
|
||||
} else {
|
||||
await api.createWebsite({ ...websiteForm })
|
||||
}
|
||||
websiteModal.value = false
|
||||
await refreshSites()
|
||||
}
|
||||
|
||||
function openAccountModal(acc) {
|
||||
editingAccount.value = acc || null
|
||||
accountPwVisible.value = false
|
||||
if (acc) {
|
||||
accountForm.username = acc.username
|
||||
accountForm.password = acc.password
|
||||
accountForm.remark = acc.remark || ''
|
||||
accountForm.sort_order = acc.sort_order || 0
|
||||
} else {
|
||||
accountForm.username = ''
|
||||
accountForm.password = ''
|
||||
accountForm.remark = ''
|
||||
// 默认排序为当前账号数+1
|
||||
accountForm.sort_order = (currentSite.value?.accounts?.length || 0) + 1
|
||||
}
|
||||
accountModal.value = true
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
const data = { username: accountForm.username, password: accountForm.password, remark: accountForm.remark, sort_order: accountForm.sort_order }
|
||||
if (editingAccount.value) {
|
||||
await api.updateAccount(editingAccount.value.id, data)
|
||||
} else {
|
||||
await api.addAccount(currentSite.value.id, data)
|
||||
}
|
||||
accountModal.value = false
|
||||
const site = await api.getWebsite(currentSite.value.id)
|
||||
currentSite.value = site
|
||||
}
|
||||
|
||||
async function deleteCurrentSite() {
|
||||
if (!currentSite.value || !await showConfirm('删除网站', `确定删除「${currentSite.value.name}」及其所有账号?`)) return
|
||||
await api.deleteWebsite(currentSite.value.id)
|
||||
currentSite.value = null
|
||||
await refreshSites()
|
||||
}
|
||||
|
||||
async function moveAccount(id, dir) {
|
||||
await api.moveAccount(id, dir)
|
||||
const site = await api.getWebsite(currentSite.value.id)
|
||||
currentSite.value = site
|
||||
}
|
||||
|
||||
async function deleteAccount(id) {
|
||||
const acc = currentSite.value?.accounts?.find(a => a.id === id)
|
||||
const label = acc?.remark || acc?.username || '账号'
|
||||
if (!await showConfirm('删除账号', `确定删除「${label}」吗?`)) return
|
||||
await api.deleteAccount(id)
|
||||
const site = await api.getWebsite(currentSite.value.id)
|
||||
currentSite.value = site
|
||||
}
|
||||
|
||||
async function deleteFolder(f) {
|
||||
if (!await showConfirm('删除文件夹', `确定删除文件夹「${f.name}」吗?内容将移至「未分类」`)) return
|
||||
await api.deleteFolder(f.id)
|
||||
folders.value = folders.value.filter(x => x.id !== f.id)
|
||||
await refreshSites()
|
||||
}
|
||||
|
||||
async function parseImport() {
|
||||
const text = importText.value.trim()
|
||||
if (!text) return showAlert('提示', '请先粘贴文本')
|
||||
const result = await api.parseImport(text)
|
||||
if (!result.ok) return showAlert('识别失败', result.error)
|
||||
importResult.value = result
|
||||
if (result.matched_site?.folder_id) importFolderId.value = result.matched_site.folder_id
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
const d = importResult.value
|
||||
if (!d) return
|
||||
let fid = importFolderId.value
|
||||
if (importNewFolder.value) {
|
||||
const f = await api.createFolder({ name: importNewFolder.value, type: 'website' })
|
||||
fid = f.id
|
||||
}
|
||||
let site = d.matched_site
|
||||
if (!site) {
|
||||
site = await api.createWebsite({ name: d.site_name, url: d.url, folder_id: fid })
|
||||
}
|
||||
await api.addAccount(site.id, { username: d.username, password: d.password, remark: d.remark })
|
||||
showImport.value = false
|
||||
await refreshSites()
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
async function refreshSites() {
|
||||
websites.value = await api.listWebsites()
|
||||
}
|
||||
|
||||
function closeMenu() { menuFolderId.value = null }
|
||||
onMounted(() => document.addEventListener('click', closeMenu))
|
||||
onUnmounted(() => document.removeEventListener('click', closeMenu))
|
||||
</script>
|
||||
701
frontend/static/css/style.css
Normal file
701
frontend/static/css/style.css
Normal file
@@ -0,0 +1,701 @@
|
||||
/* ========================================
|
||||
MoKeeText — Feishu-inspired Design System
|
||||
======================================== */
|
||||
|
||||
/* ——— Design Tokens ——— */
|
||||
:root {
|
||||
/* Surface */
|
||||
--bg-page: #F5F6F8;
|
||||
--bg-sidebar: #FCFCFD;
|
||||
--bg-surface: #FFFFFF;
|
||||
--bg-hover: #F2F3F5;
|
||||
--bg-active: #EEF3FF;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #1F2329;
|
||||
--text-secondary: #646A73;
|
||||
--text-tertiary: #8F959E;
|
||||
--text-placeholder:#BCC0C6;
|
||||
|
||||
/* Border */
|
||||
--border-light: #E5E6EB;
|
||||
--border-medium: #DEE0E3;
|
||||
|
||||
/* Brand */
|
||||
--brand: #3370FF;
|
||||
--brand-hover: #245BDB;
|
||||
--brand-light: #EEF3FF;
|
||||
--brand-ghost: rgba(51,112,255,0.06);
|
||||
|
||||
/* Semantic */
|
||||
--danger: #F54A45;
|
||||
--danger-light: #FFF1F0;
|
||||
--success: #34C759;
|
||||
|
||||
/* Radius */
|
||||
--r-sm: 6px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
|
||||
/* Shadow */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 20px;
|
||||
--space-2xl: 24px;
|
||||
}
|
||||
|
||||
/* ——— Reset ——— */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-page);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ——— Top Navigation ——— */
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 var(--space-xl);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
.topnav-brand {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
margin-right: var(--space-2xl);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.topnav-link {
|
||||
padding: 13px var(--space-lg);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.topnav-link:hover { color: var(--brand); }
|
||||
.topnav-link.active {
|
||||
color: var(--brand);
|
||||
border-bottom-color: var(--brand);
|
||||
}
|
||||
/* ——— User Widget ——— */
|
||||
.user-widget {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.user-widget:hover { background: var(--bg-hover); }
|
||||
.user-avatar {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
|
||||
/* ——— Footer ——— */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--space-xl);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-light);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.footer-icp { color: var(--text-placeholder); }
|
||||
|
||||
/* ——— Main Layout ——— */
|
||||
.layout { display: flex; height: calc(100vh - 48px - 32px); }
|
||||
|
||||
/* ——— Sidebar ——— */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar section label */
|
||||
.sidebar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 4px 0;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ——— Buttons ——— */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 var(--space-lg);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--brand-hover); }
|
||||
|
||||
.btn-outline {
|
||||
background: var(--bg-surface);
|
||||
color: var(--brand);
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
.btn-outline:hover { background: var(--brand-light); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.btn-danger-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.btn-danger-ghost:hover { background: var(--danger-light); color: var(--danger); }
|
||||
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.btn-sm { height: 30px; font-size: 12px; padding: 0 12px; }
|
||||
|
||||
/* ——— Input ——— */
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-medium);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface);
|
||||
outline: none;
|
||||
transition: border 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.input:focus { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-light); }
|
||||
.input::placeholder { color: var(--text-placeholder); }
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-medium);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border 0.15s;
|
||||
min-height: 60px;
|
||||
}
|
||||
.textarea:focus { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-light); }
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-medium);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ——— Folder Tree ——— */
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
.folder-item:hover { background: var(--bg-hover); }
|
||||
.folder-item.active { background: var(--brand-light); color: var(--brand); font-weight: 500; }
|
||||
.folder-item .count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.folder-item.active .count { color: var(--brand); opacity: 0.7; }
|
||||
|
||||
/* Folder menu */
|
||||
.folder-more {
|
||||
opacity: 0;
|
||||
padding: 2px 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.folder-item:hover .folder-more { opacity: 1; }
|
||||
.folder-more:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.folder-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
}
|
||||
.folder-menu-item {
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--r-sm);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.folder-menu-item:hover { background: var(--bg-hover); }
|
||||
.folder-menu-item.danger { color: var(--danger); }
|
||||
.folder-menu-item.danger:hover { background: var(--danger-light); }
|
||||
|
||||
.folder-children { margin-left: 16px; }
|
||||
.folder-arrow { font-size: 10px; color: var(--text-tertiary); width: 12px; flex-shrink: 0; }
|
||||
|
||||
/* ——— Site List ——— */
|
||||
.site-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
.site-item:hover { background: var(--bg-hover); }
|
||||
.site-item.active { background: var(--brand-light); color: var(--brand); font-weight: 500; }
|
||||
.site-item .site-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.site-item .site-count { font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; }
|
||||
.site-item.active .site-count { color: var(--brand); opacity: 0.7; }
|
||||
|
||||
/* ——— Content Area ——— */
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px var(--space-xl);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.content-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
.content-title::placeholder { color: var(--text-placeholder); }
|
||||
|
||||
/* ——— View Toggle ——— */
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
background: var(--bg-hover);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
.toggle-group button {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.toggle-group button.active {
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ——— Editor Area ——— */
|
||||
.editor-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
padding: var(--space-xl);
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.editor-pane + .editor-pane { border-left: 1px solid var(--border-light); }
|
||||
|
||||
/* ——— Tiptap Toolbar ——— */
|
||||
.tb-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.tb-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border-medium);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.tb-btn {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.tb-btn:hover { background: var(--bg-hover); }
|
||||
.tb-btn.active { background: var(--brand-light); color: var(--brand); }
|
||||
.tb-color {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--border-medium);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* ProseMirror / Tiptap Content */
|
||||
.ProseMirror { outline: none; min-height: 300px; font-size: 14px; line-height: 1.8; }
|
||||
.ProseMirror h1 { font-size: 22px; font-weight: 700; margin: 16px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-light); }
|
||||
.ProseMirror h2 { font-size: 18px; font-weight: 600; margin: 14px 0 6px; }
|
||||
.ProseMirror h3 { font-size: 15px; font-weight: 600; margin: 12px 0 4px; }
|
||||
.ProseMirror p { margin: 4px 0; }
|
||||
.ProseMirror ul, .ProseMirror ol { padding-left: 20px; margin: 4px 0; }
|
||||
.ProseMirror li { margin: 2px 0; }
|
||||
.ProseMirror blockquote { border-left: 3px solid var(--brand); padding-left: 12px; color: var(--text-secondary); margin: 8px 0; }
|
||||
.ProseMirror pre { background: #F7F8FA; padding: 12px 16px; border-radius: var(--r-sm); overflow-x: auto; font-family: "SF Mono","Fira Code","Consolas",monospace; font-size: 13px; line-height: 1.6; }
|
||||
.ProseMirror code { background: #F2F3F5; padding: 2px 6px; border-radius: 3px; font-size: 12px; font-family: "SF Mono","Fira Code","Consolas",monospace; }
|
||||
.ProseMirror table { border-collapse: collapse; width: 100%; margin: 8px 0; }
|
||||
.ProseMirror th, .ProseMirror td { border: 1px solid var(--border-light); padding: 8px 12px; text-align: left; }
|
||||
.ProseMirror th { background: #F7F8FA; font-weight: 600; font-size: 13px; }
|
||||
.ProseMirror img { max-width: 100%; border-radius: var(--r-sm); }
|
||||
.ProseMirror mark { background: #FFF3CD; padding: 2px 4px; border-radius: 2px; }
|
||||
|
||||
/* ——— Note List in Sidebar ——— */
|
||||
.note-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.note-list-item:hover { background: var(--bg-hover); }
|
||||
.note-list-item.active { background: var(--brand-light); }
|
||||
.note-list-item .note-title-s {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.note-list-item .note-date { font-size: 10px; color: var(--text-tertiary); }
|
||||
|
||||
/* ——— Account Card ——— */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--space-lg);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.card:hover { box-shadow: var(--shadow-sm); }
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-title { font-size: 14px; font-weight: 600; }
|
||||
.card-body { display: grid; grid-template-columns: auto 1fr auto; gap: 8px 12px; font-size: 13px; align-items: center; }
|
||||
.card-body .label { color: var(--text-tertiary); font-size: 12px; white-space: nowrap; }
|
||||
.card-body .value { font-family: "SF Mono","Fira Code","Consolas",monospace; font-size: 13px; word-break: break-all; }
|
||||
|
||||
.card-actions { display: flex; gap: 6px; }
|
||||
.card-btn {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-surface);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.card-btn:hover { background: var(--bg-hover); border-color: var(--border-medium); }
|
||||
.card-btn.edit:hover { color: var(--brand); border-color: var(--brand); background: var(--brand-light); }
|
||||
.card-btn.danger:hover { color: var(--danger); border-color: var(--danger); background: var(--danger-light); }
|
||||
|
||||
/* ——— Modal ——— */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fade-in 0.15s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-in 0.15s ease;
|
||||
}
|
||||
@keyframes slide-in { from { transform: translateY(8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title { font-size: 15px; font-weight: 600; }
|
||||
.modal-close {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: none; background: transparent;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 18px; color: var(--text-tertiary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-close:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.modal-body { padding: var(--space-xl); overflow-y: auto; flex: 1; }
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-top: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ——— Form ——— */
|
||||
.form-group { margin-bottom: var(--space-lg); }
|
||||
.form-group:last-child { margin-bottom: 0; }
|
||||
.form-label { display: block; font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.form-row { display: flex; gap: var(--space-sm); align-items: center; }
|
||||
|
||||
/* ——— Import Area ——— */
|
||||
.import-textarea {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
padding: 10px 12px;
|
||||
border: 2px solid var(--brand);
|
||||
border-radius: var(--r-md);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
background: var(--brand-light);
|
||||
}
|
||||
.import-hint { font-size: 11px; color: var(--text-tertiary); margin: 4px 0 12px; }
|
||||
|
||||
.result-box {
|
||||
background: #F0FAF0;
|
||||
border: 1px solid #A3E4A7;
|
||||
border-radius: var(--r-md);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.result-grid { display: grid; grid-template-columns: 70px 1fr; gap: 6px 12px; font-size: 13px; }
|
||||
|
||||
.alert {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.alert-warning { background: #FFF7E6; color: #8A5D00; }
|
||||
|
||||
/* ——— Stats ——— */
|
||||
.stat-row { display: flex; gap: 6px; }
|
||||
.stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 11px;
|
||||
}
|
||||
.stat-num { font-size: 18px; font-weight: 700; }
|
||||
.stat-label { color: var(--text-tertiary); font-size: 10px; }
|
||||
|
||||
/* ——— Toast ——— */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #1F2329;
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
z-index: 9999;
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
/* ——— Tag Badge ——— */
|
||||
.tag-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tag-badge:hover { background: #DEE0E3; }
|
||||
.tag-badge.active { background: var(--brand); color: #fff; }
|
||||
.tag-add { border: 1px dashed var(--border-medium); background: transparent; color: var(--text-tertiary); }
|
||||
.tag-add:hover { border-color: var(--brand); color: var(--brand); background: transparent; }
|
||||
|
||||
/* ——— Helpers ——— */
|
||||
.hidden { display: none !important; }
|
||||
.flex-1 { flex: 1; }
|
||||
.text-muted { color: var(--text-tertiary); font-size: 12px; }
|
||||
.text-sm { font-size: 12px; }
|
||||
.gap-xs { gap: var(--space-xs); }
|
||||
.gap-sm { gap: var(--space-sm); }
|
||||
.mb-sm { margin-bottom: var(--space-sm); }
|
||||
.mb-md { margin-bottom: var(--space-md); }
|
||||
|
||||
/* ——— Scrollbar ——— */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #D0D3D7; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #B0B3B7; }
|
||||
|
||||
/* ——— Brand wrapper ——— */
|
||||
.save-badge {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
1
frontend/static/dist/assets/index-5uIxZ5nb.css
vendored
Normal file
1
frontend/static/dist/assets/index-5uIxZ5nb.css
vendored
Normal file
File diff suppressed because one or more lines are too long
166
frontend/static/dist/assets/index-BpxV2n8n.js
vendored
Normal file
166
frontend/static/dist/assets/index-BpxV2n8n.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/static/dist/index.html
vendored
Normal file
13
frontend/static/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MoKeeText</title>
|
||||
<script type="module" crossorigin src="/assets/index-BpxV2n8n.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-5uIxZ5nb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
133
frontend/static/js/editor.js
Normal file
133
frontend/static/js/editor.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Tiptap 增强编辑器初始化
|
||||
* 支持:文字颜色、背景高亮、图片、表格、任务列表、代码块
|
||||
*/
|
||||
let editor = null;
|
||||
|
||||
function initEditor(content) {
|
||||
content = content || '';
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
}
|
||||
|
||||
editor = new tiptap.Editor({
|
||||
element: document.querySelector("#tiptapEditor"),
|
||||
extensions: [
|
||||
tiptap.StarterKit,
|
||||
tiptap.TextStyle,
|
||||
tiptap.Color,
|
||||
tiptap.Highlight.configure({ multicolor: true }),
|
||||
tiptap.Image.configure({ inline: true, allowBase64: true }),
|
||||
tiptap.Table.configure({ resizable: true }),
|
||||
tiptap.TableRow,
|
||||
tiptap.TableCell,
|
||||
tiptap.TableHeader,
|
||||
tiptap.TaskList,
|
||||
tiptap.TaskItem.configure({ nested: true }),
|
||||
],
|
||||
content: content,
|
||||
onUpdate: function() {
|
||||
updatePreview();
|
||||
scheduleSave();
|
||||
},
|
||||
});
|
||||
|
||||
renderToolbar();
|
||||
}
|
||||
|
||||
function renderToolbar() {
|
||||
var toolbar = document.querySelector("#tiptapToolbar");
|
||||
if (!toolbar) return;
|
||||
|
||||
var html = '';
|
||||
|
||||
// 标题
|
||||
html += '<span class="tb-group">';
|
||||
html += '<button class="tb-btn" data-action="h1">H1</button>';
|
||||
html += '<button class="tb-btn" data-action="h2">H2</button>';
|
||||
html += '<button class="tb-btn" data-action="h3">H3</button>';
|
||||
html += '</span>';
|
||||
|
||||
// 内联格式
|
||||
html += '<span class="tb-group">';
|
||||
html += '<button class="tb-btn" data-action="bold" style="font-weight:bold;">B</button>';
|
||||
html += '<button class="tb-btn" data-action="italic" style="font-style:italic;">I</button>';
|
||||
html += '<button class="tb-btn" data-action="strike" style="text-decoration:line-through;">S</button>';
|
||||
html += '</span>';
|
||||
|
||||
// 颜色
|
||||
html += '<span class="tb-group">';
|
||||
html += '<input type="color" id="textColor" title="文字颜色" style="width:22px;height:22px;border:none;cursor:pointer;padding:0;border-radius:3px;" value="#333333">';
|
||||
html += '<input type="color" id="bgColor" title="背景高亮" style="width:22px;height:22px;border:none;cursor:pointer;padding:0;border-radius:3px;" value="#fff3cd">';
|
||||
html += '</span>';
|
||||
|
||||
// 媒体
|
||||
html += '<span class="tb-group">';
|
||||
html += '<button class="tb-btn" data-action="image">🖼 图片</button>';
|
||||
html += '<button class="tb-btn" data-action="emoji">😊 表情</button>';
|
||||
html += '</span>';
|
||||
|
||||
// 块级元素
|
||||
html += '<span class="tb-group">';
|
||||
html += '<button class="tb-btn" data-action="bulletList">• 列表</button>';
|
||||
html += '<button class="tb-btn" data-action="orderedList">1. 列表</button>';
|
||||
html += '<button class="tb-btn" data-action="taskList">✅ 任务</button>';
|
||||
html += '<button class="tb-btn" data-action="blockquote">❝ 引用</button>';
|
||||
html += '<button class="tb-btn" data-action="codeBlock"></> 代码</button>';
|
||||
html += '</span>';
|
||||
|
||||
// 表格
|
||||
html += '<span class="tb-group">';
|
||||
html += '<button class="tb-btn" data-action="table">📋 表格</button>';
|
||||
html += '</span>';
|
||||
|
||||
toolbar.innerHTML = html;
|
||||
|
||||
// 绑定按钮事件
|
||||
var actions = {
|
||||
'h1': function() { editor.chain().focus().toggleHeading({ level: 1 }).run(); },
|
||||
'h2': function() { editor.chain().focus().toggleHeading({ level: 2 }).run(); },
|
||||
'h3': function() { editor.chain().focus().toggleHeading({ level: 3 }).run(); },
|
||||
'bold': function() { editor.chain().focus().toggleBold().run(); },
|
||||
'italic': function() { editor.chain().focus().toggleItalic().run(); },
|
||||
'strike': function() { editor.chain().focus().toggleStrike().run(); },
|
||||
'image': function() {
|
||||
var url = prompt('输入图片 URL(支持直接粘贴图片链接):');
|
||||
if (url) editor.chain().focus().setImage({ src: url }).run();
|
||||
},
|
||||
'emoji': function() {
|
||||
var emoji = prompt('输入表情符号(Win+. 打开系统表情面板粘贴):');
|
||||
if (emoji) editor.chain().focus().insertContent(emoji).run();
|
||||
},
|
||||
'bulletList': function() { editor.chain().focus().toggleBulletList().run(); },
|
||||
'orderedList': function() { editor.chain().focus().toggleOrderedList().run(); },
|
||||
'taskList': function() { editor.chain().focus().toggleTaskList().run(); },
|
||||
'blockquote': function() { editor.chain().focus().toggleBlockquote().run(); },
|
||||
'codeBlock': function() { editor.chain().focus().toggleCodeBlock().run(); },
|
||||
'table': function() { editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); },
|
||||
};
|
||||
|
||||
toolbar.querySelectorAll('.tb-btn').forEach(function(btn) {
|
||||
var action = btn.dataset.action;
|
||||
if (actions[action]) {
|
||||
btn.addEventListener('click', actions[action]);
|
||||
}
|
||||
});
|
||||
|
||||
// 颜色选择器
|
||||
var textColor = document.querySelector('#textColor');
|
||||
var bgColor = document.querySelector('#bgColor');
|
||||
if (textColor) textColor.addEventListener('input', function(e) {
|
||||
editor.chain().focus().setColor(e.target.value).run();
|
||||
});
|
||||
if (bgColor) bgColor.addEventListener('input', function(e) {
|
||||
editor.chain().focus().toggleHighlight({ color: e.target.value }).run();
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
var preview = document.querySelector("#previewContent");
|
||||
if (preview && editor) {
|
||||
preview.innerHTML = editor.getHTML();
|
||||
}
|
||||
}
|
||||
150
frontend/static/js/notes.js
Normal file
150
frontend/static/js/notes.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 笔记页面交互逻辑
|
||||
*/
|
||||
var currentNoteId = null;
|
||||
var saveTimer = null;
|
||||
var viewMode = "edit";
|
||||
|
||||
function setViewMode(mode) {
|
||||
viewMode = mode;
|
||||
var editPane = document.querySelector("#editPane");
|
||||
var previewPane = document.querySelector("#previewPane");
|
||||
document.querySelectorAll(".toggle-group button").forEach(function(b) {
|
||||
b.classList.toggle("active", b.dataset.mode === mode);
|
||||
});
|
||||
editPane.classList.remove("hidden");
|
||||
previewPane.classList.remove("hidden");
|
||||
if (mode === "edit") { previewPane.classList.add("hidden"); }
|
||||
else if (mode === "preview") { editPane.classList.add("hidden"); }
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function createNote() {
|
||||
var folderId = getActiveFolderId();
|
||||
fetch("/api/notes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "无标题笔记", content: "", folder_id: folderId }),
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(note) { loadNote(note.id); refreshFolderNotes(); });
|
||||
}
|
||||
|
||||
function loadNote(noteId) {
|
||||
fetch("/api/notes/" + noteId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(note) {
|
||||
currentNoteId = note.id;
|
||||
document.querySelector("#currentNoteId").value = note.id;
|
||||
document.querySelector("#noteTitle").value = note.title;
|
||||
initEditor(note.content || "");
|
||||
document.querySelectorAll(".note-list-item").forEach(function(el) {
|
||||
el.classList.toggle("active", parseInt(el.dataset.noteId) === note.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
var s = document.querySelector("#saveStatus");
|
||||
if (s) { s.textContent = "保存中…"; s.style.color = "#F5A623"; }
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveNote, 800);
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
if (!currentNoteId) return;
|
||||
var title = document.querySelector("#noteTitle").value;
|
||||
var content = editor ? editor.getHTML() : "";
|
||||
fetch("/api/notes/" + currentNoteId, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: title, content: content }),
|
||||
}).then(function(r) {
|
||||
if (r.ok) { var s = document.querySelector("#saveStatus"); if (s) { s.textContent = "已保存"; s.style.color = ""; } }
|
||||
});
|
||||
}
|
||||
|
||||
function filterByFolder(folderId, el) {
|
||||
document.querySelectorAll(".folder-item").forEach(function(f) { f.classList.remove("active"); });
|
||||
el.classList.add("active");
|
||||
fetch("/api/notes?folder_id=" + folderId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(notes) { renderNoteList(notes); });
|
||||
}
|
||||
|
||||
function renderNoteList(notes) {
|
||||
var listDiv = document.querySelector("#folderNoteList");
|
||||
if (!listDiv) return;
|
||||
listDiv.classList.remove("hidden");
|
||||
|
||||
var html = '<div style="padding:4px 0; cursor:pointer; color:var(--brand); font-size:11px;" onclick="showFolderList()">← 返回文件夹</div>';
|
||||
if (!notes.length) {
|
||||
html += '<div style="color:#ccc; padding:8px; text-align:center; font-size:12px;">暂无笔记</div>';
|
||||
} else {
|
||||
notes.forEach(function(note) {
|
||||
var title = note.title || "无标题";
|
||||
var date = (note.updated_at || "").substr(0, 10);
|
||||
html += '<div class="note-list-item" data-note-id="' + note.id + '" onclick="loadNote(' + note.id + ')">' +
|
||||
'<span class="note-title-s">' + escapeHtml(title) + '</span>' +
|
||||
'<span class="note-date">' + date + '</span></div>';
|
||||
});
|
||||
}
|
||||
listDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function showFolderList() {
|
||||
var listDiv = document.querySelector("#folderNoteList");
|
||||
if (listDiv) listDiv.classList.add("hidden");
|
||||
}
|
||||
|
||||
function getActiveFolderId() {
|
||||
var active = document.querySelector(".folder-item.active");
|
||||
return active ? parseInt(active.dataset.folderId) : null;
|
||||
}
|
||||
|
||||
function refreshFolderNotes() {
|
||||
var af = document.querySelector(".folder-item.active");
|
||||
if (af) filterByFolder(parseInt(af.dataset.folderId), af);
|
||||
}
|
||||
|
||||
function addFolder() {
|
||||
var name = prompt("输入新文件夹名称:");
|
||||
if (!name) return;
|
||||
fetch("/api/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, type: "note" }) })
|
||||
.then(function() { window.location.reload(); });
|
||||
}
|
||||
|
||||
function toggleFolderMenu(btn) {
|
||||
document.querySelectorAll(".folder-menu").forEach(function(m) { if (m !== btn.nextElementSibling) m.classList.add("hidden"); });
|
||||
var menu = btn.nextElementSibling;
|
||||
if (menu) menu.classList.toggle("hidden");
|
||||
setTimeout(function() {
|
||||
document.addEventListener("click", function close() { if (menu) menu.classList.add("hidden"); document.removeEventListener("click", close); }, { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function renameFolder(folderId) {
|
||||
var name = prompt("新名称:"); if (!name) return;
|
||||
fetch("/api/folders/" + folderId, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name }) })
|
||||
.then(function() { window.location.reload(); });
|
||||
}
|
||||
|
||||
function deleteFolder(folderId) {
|
||||
if (!confirm("确定删除此文件夹?内容将移至「未分类」")) return;
|
||||
fetch("/api/folders/" + folderId, { method: "DELETE" }).then(function() { window.location.reload(); });
|
||||
}
|
||||
|
||||
function deleteCurrentNote() {
|
||||
if (!currentNoteId || !confirm("确定删除?")) return;
|
||||
fetch("/api/notes/" + currentNoteId, { method: "DELETE" }).then(function() { window.location.reload(); });
|
||||
}
|
||||
|
||||
function filterByTag(tagId, el) { el.classList.toggle("active"); }
|
||||
function addTag() { var n = prompt("标签名:"); if (n) window.location.reload(); }
|
||||
|
||||
function escapeHtml(s) { return s ? s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""") : ""; }
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.querySelector("#noteTitle").addEventListener("input", scheduleSave);
|
||||
var firstId = document.querySelector("#firstNoteId");
|
||||
if (firstId && firstId.value) loadNote(parseInt(firstId.value)); else createNote();
|
||||
});
|
||||
155
frontend/static/js/vendor/tiptap-bundle.min.js
vendored
Normal file
155
frontend/static/js/vendor/tiptap-bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/js/vendor/tiptap-core.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-core.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-core.umd.min.js in @tiptap/core.
|
||||
29
frontend/static/js/vendor/tiptap-entry.js
vendored
Normal file
29
frontend/static/js/vendor/tiptap-entry.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
// Tiptap 打包入口 - 将所有需要的模块挂载到全局 window.tiptap
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { Highlight } from "@tiptap/extension-highlight";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { TaskList } from "@tiptap/extension-task-list";
|
||||
import { TaskItem } from "@tiptap/extension-task-item";
|
||||
|
||||
// 挂载到全局,保持和 UMD 版本一样的 API
|
||||
window.tiptap = {
|
||||
Editor,
|
||||
StarterKit,
|
||||
Color,
|
||||
Highlight,
|
||||
TextStyle,
|
||||
Image,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TaskList,
|
||||
TaskItem,
|
||||
};
|
||||
1
frontend/static/js/vendor/tiptap-extension-code-block-lowlight.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-code-block-lowlight.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-code-block-lowlight.umd.min.js in @tiptap/extension-code-block-lowlight.
|
||||
1
frontend/static/js/vendor/tiptap-extension-color.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-color.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-color.umd.min.js in @tiptap/extension-color.
|
||||
1
frontend/static/js/vendor/tiptap-extension-highlight.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-highlight.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-highlight.umd.min.js in @tiptap/extension-highlight.
|
||||
1
frontend/static/js/vendor/tiptap-extension-image.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-image.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-image.umd.min.js in @tiptap/extension-image.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table-cell.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table-cell.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table-cell.umd.min.js in @tiptap/extension-table-cell.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table-header.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table-header.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table-header.umd.min.js in @tiptap/extension-table-header.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table-row.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table-row.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table-row.umd.min.js in @tiptap/extension-table-row.
|
||||
1
frontend/static/js/vendor/tiptap-extension-table.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-table.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-table.umd.min.js in @tiptap/extension-table.
|
||||
1
frontend/static/js/vendor/tiptap-extension-task-item.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-task-item.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-task-item.umd.min.js in @tiptap/extension-task-item.
|
||||
1
frontend/static/js/vendor/tiptap-extension-task-list.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-task-list.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-task-list.umd.min.js in @tiptap/extension-task-list.
|
||||
1
frontend/static/js/vendor/tiptap-extension-text-style.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-extension-text-style.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-extension-text-style.umd.min.js in @tiptap/extension-text-style.
|
||||
1
frontend/static/js/vendor/tiptap-starter-kit.umd.min.js
vendored
Normal file
1
frontend/static/js/vendor/tiptap-starter-kit.umd.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Couldn't find the requested file /dist/tiptap-starter-kit.umd.min.js in @tiptap/starter-kit.
|
||||
249
frontend/static/js/websites.js
Normal file
249
frontend/static/js/websites.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 网站管理页面交互逻辑
|
||||
*/
|
||||
var currentSiteId = null;
|
||||
var parsedImportData = null;
|
||||
|
||||
// ===== 网站选择 =====
|
||||
function selectWebsite(siteId, el) {
|
||||
currentSiteId = siteId;
|
||||
document.querySelector("#currentSiteId").value = siteId;
|
||||
document.querySelectorAll(".site-item").forEach(function(s) { s.classList.remove("active"); });
|
||||
if (el) el.classList.add("active");
|
||||
fetch("/api/websites/" + siteId).then(function(r) { return r.json(); }).then(renderSiteDetail);
|
||||
}
|
||||
|
||||
function renderSiteDetail(site) {
|
||||
var fName = getFolderName(site.folder_id);
|
||||
document.querySelector("#siteHeaderInfo").innerHTML =
|
||||
'<span style="font-size:11px;color:var(--text-tertiary);">' + fName + ' / </span>' +
|
||||
'<span style="font-size:15px;font-weight:600;">' + site.name + '</span>' +
|
||||
'<a href="' + site.url + '" target="_blank" style="font-size:11px;color:var(--brand);margin-left:8px;text-decoration:none;">' + site.url + '</a>';
|
||||
document.querySelector("#editSiteBtn").classList.remove("hidden");
|
||||
document.querySelector("#addAccountBtn").classList.remove("hidden");
|
||||
|
||||
var cards = document.querySelector("#accountCards");
|
||||
var accounts = site.accounts || [];
|
||||
if (!accounts.length) {
|
||||
cards.innerHTML = '<div style="text-align:center;padding:80px 20px;color:var(--text-placeholder);"><p>暂无账号</p></div>';
|
||||
return;
|
||||
}
|
||||
cards.innerHTML = accounts.map(function(a) {
|
||||
return '<div class="card" style="margin-bottom:12px;">' +
|
||||
'<div class="card-header">' +
|
||||
'<span class="card-title">👤 ' + (a.remark || '账号') + '</span>' +
|
||||
'<div class="card-actions">' +
|
||||
'<button class="card-btn edit" onclick="editAccount(' + a.id + ')">✏️ 编辑</button>' +
|
||||
'<button class="card-btn danger" onclick="deleteAccount(' + a.id + ')">🗑 删除</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body">' +
|
||||
'<span class="label">账号</span><span class="value">' + esc(a.username) + '</span>' +
|
||||
'<button class="card-btn" onclick="copyText(\'' + esc(a.username) + '\')">复制</button>' +
|
||||
'<span class="label">密码</span><span class="value pw-hidden" id="pw-' + a.id + '" data-pw="' + esc(a.password) + '">••••••••</span>' +
|
||||
'<div style="display:flex;gap:4px;">' +
|
||||
'<button class="card-btn" onclick="togglePassword(' + a.id + ')">显示</button>' +
|
||||
'<button class="card-btn" onclick="copyText(\'' + esc(a.password) + '\')">复制</button>' +
|
||||
'</div>' +
|
||||
'<span class="label">说明</span><span style="color:var(--text-secondary);font-size:12px;">' + (a.remark || '-') + '</span><span></span>' +
|
||||
'</div></div>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function esc(s) { return (s||"").replace(/'/g,"\\'"); }
|
||||
|
||||
function togglePassword(id) {
|
||||
var el = document.querySelector("#pw-" + id);
|
||||
if (!el) return;
|
||||
el.textContent = el.textContent === "••••••••" ? el.dataset.pw : "••••••••";
|
||||
}
|
||||
|
||||
function copyText(t) {
|
||||
navigator.clipboard.writeText(t).then(function() {
|
||||
var toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.textContent = "已复制";
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function() { toast.remove(); }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function getFolderName(fid) {
|
||||
if (!fid) return "未分类";
|
||||
var el = document.querySelector('.folder-group[data-folder-id="' + fid + '"] .folder-item span:nth-child(2)');
|
||||
return el ? el.textContent.replace(/📁\s*/, "").trim() : "未分类";
|
||||
}
|
||||
|
||||
// ===== 文件夹 =====
|
||||
function toggleFolder(el) {
|
||||
var ch = el.parentElement.querySelector(".folder-children");
|
||||
var arrow = el.querySelector(".folder-arrow");
|
||||
if (!ch) return;
|
||||
if (ch.style.display === "none") { ch.style.display = "block"; arrow.textContent = "▼"; }
|
||||
else { ch.style.display = "none"; arrow.textContent = "▶"; }
|
||||
}
|
||||
|
||||
function toggleFolderMenu(btn) {
|
||||
document.querySelectorAll(".folder-menu").forEach(function(m) { if (m !== btn.nextElementSibling) m.classList.add("hidden"); });
|
||||
var menu = btn.nextElementSibling;
|
||||
if (menu) menu.classList.toggle("hidden");
|
||||
setTimeout(function() {
|
||||
document.addEventListener("click", function c() { if (menu) menu.classList.add("hidden"); document.removeEventListener("click", c); }, { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function renameFolder(id) { var n = prompt("新名称:"); if (n) fetch("/api/folders/" + id, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: n }) }).then(function() { window.location.reload(); }); }
|
||||
function deleteFolder(id) { if (confirm("确定删除?内容将移至「未分类」")) fetch("/api/folders/" + id, { method: "DELETE" }).then(function() { window.location.reload(); }); }
|
||||
function showAddFolder() { var n = prompt("文件夹名:"); if (n) fetch("/api/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: n, type: "website" }) }).then(function() { window.location.reload(); }); }
|
||||
|
||||
// ===== 智能导入 =====
|
||||
function showImportModal() {
|
||||
document.querySelector("#importModal").classList.remove("hidden");
|
||||
document.querySelector("#parseResult").classList.add("hidden");
|
||||
document.querySelector("#importText").value = "";
|
||||
document.querySelector("#importNewFolder").value = "";
|
||||
}
|
||||
function hideImportModal() { document.querySelector("#importModal").classList.add("hidden"); }
|
||||
|
||||
function parseImport() {
|
||||
var text = document.querySelector("#importText").value.trim();
|
||||
if (!text) return alert("请先粘贴文本");
|
||||
fetch("/api/import/parse", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: text }) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok) return alert(d.error);
|
||||
parsedImportData = d;
|
||||
document.querySelector("#parseResult").classList.remove("hidden");
|
||||
document.querySelector("#parseGrid").innerHTML =
|
||||
'<span style="color:var(--text-secondary);">网址</span><span>' + d.url + '</span>' +
|
||||
'<span style="color:var(--text-secondary);">网站名</span><span>' + d.site_name + '</span>' +
|
||||
'<span style="color:var(--text-secondary);">账号</span><span style="font-family:monospace;">' + d.username + '</span>' +
|
||||
'<span style="color:var(--text-secondary);">密码</span><span style="font-family:monospace;">' + d.password + '</span>' +
|
||||
'<span style="color:var(--text-secondary);">备注</span><span>' + (d.remark || '(无)') + '</span>';
|
||||
var alert = document.querySelector("#duplicateAlert");
|
||||
if (d.action === "append") {
|
||||
alert.classList.remove("hidden");
|
||||
alert.innerHTML = '⚠️ <strong>' + d.matched_site.name + '</strong> 已存在(' + d.matched_site.account_count + '个账号),将追加进去';
|
||||
if (d.matched_site.folder_id) document.querySelector("#importFolderSelect").value = d.matched_site.folder_id;
|
||||
} else { alert.classList.add("hidden"); }
|
||||
});
|
||||
}
|
||||
|
||||
function confirmImport() {
|
||||
if (!parsedImportData) return;
|
||||
var d = parsedImportData;
|
||||
var fid = document.querySelector("#importFolderSelect").value || null;
|
||||
var nf = document.querySelector("#importNewFolder").value.trim();
|
||||
if (nf) {
|
||||
fetch("/api/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: nf, type: "website" }) })
|
||||
.then(function(r) { return r.json(); }).then(function(f) { doImport(d, f.id); });
|
||||
} else { doImport(d, fid); }
|
||||
}
|
||||
|
||||
function doImport(d, fid) {
|
||||
fetch("/api/websites").then(function(r) { return r.json(); }).then(function(sites) {
|
||||
var s = sites.find(function(x) { return x.url === d.url; });
|
||||
if (s) { addAccountToSite(s.id, d); }
|
||||
else {
|
||||
fetch("/api/websites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: d.site_name, url: d.url, folder_id: fid }) })
|
||||
.then(function(r) { return r.json(); }).then(function(ns) { addAccountToSite(ns.id, d); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addAccountToSite(sid, d) {
|
||||
fetch("/api/websites/" + sid + "/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: d.username, password: d.password, remark: d.remark }) })
|
||||
.then(function() { hideImportModal(); window.location.reload(); });
|
||||
}
|
||||
|
||||
// ===== 网站弹窗 =====
|
||||
function showAddWebsite() {
|
||||
document.querySelector("#websiteModalTitle").textContent = "添加网站";
|
||||
document.querySelector("#websiteName").value = "";
|
||||
document.querySelector("#websiteUrl").value = "";
|
||||
document.querySelector("#websiteFolder").value = "";
|
||||
document.querySelector("#editWebsiteId").value = "";
|
||||
document.querySelector("#websiteModal").classList.remove("hidden");
|
||||
}
|
||||
function hideWebsiteModal() { document.querySelector("#websiteModal").classList.add("hidden"); }
|
||||
|
||||
function editWebsite() {
|
||||
if (!currentSiteId) return;
|
||||
document.querySelector("#websiteModalTitle").textContent = "编辑网站";
|
||||
document.querySelector("#editWebsiteId").value = currentSiteId;
|
||||
fetch("/api/websites/" + currentSiteId).then(function(r) { return r.json(); }).then(function(s) {
|
||||
document.querySelector("#websiteName").value = s.name;
|
||||
document.querySelector("#websiteUrl").value = s.url;
|
||||
document.querySelector("#websiteFolder").value = s.folder_id || "";
|
||||
document.querySelector("#websiteModal").classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
function saveWebsite() {
|
||||
var id = document.querySelector("#editWebsiteId").value;
|
||||
var data = { name: document.querySelector("#websiteName").value, url: document.querySelector("#websiteUrl").value, folder_id: document.querySelector("#websiteFolder").value || null };
|
||||
fetch(id ? "/api/websites/" + id : "/api/websites", { method: id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
|
||||
.then(function() { hideWebsiteModal(); window.location.reload(); });
|
||||
}
|
||||
|
||||
// ===== 账号弹窗 =====
|
||||
function showAddAccount() {
|
||||
if (!currentSiteId) return alert("请先选择网站");
|
||||
document.querySelector("#accountModalTitle").textContent = "添加账号";
|
||||
document.querySelector("#accountUsername").value = "";
|
||||
document.querySelector("#accountPassword").value = "";
|
||||
document.querySelector("#accountRemark").value = "";
|
||||
document.querySelector("#editAccountId").value = "";
|
||||
document.querySelector("#accountPassword").type = "password";
|
||||
document.querySelector("#accountModal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideAccountModal() { document.querySelector("#accountModal").classList.add("hidden"); }
|
||||
|
||||
function editAccount(accId) {
|
||||
document.querySelector("#accountModalTitle").textContent = "编辑账号";
|
||||
document.querySelector("#editAccountId").value = accId;
|
||||
document.querySelector("#accountPassword").type = "password";
|
||||
fetch("/api/accounts/" + accId).then(function(r) { return r.json(); }).then(function(a) {
|
||||
document.querySelector("#accountUsername").value = a.username || "";
|
||||
document.querySelector("#accountPassword").value = a.password || "";
|
||||
document.querySelector("#accountRemark").value = a.remark || "";
|
||||
document.querySelector("#accountModal").classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
function togglePwVisibility() {
|
||||
var pw = document.querySelector("#accountPassword");
|
||||
var btn = pw.parentElement.querySelector("button");
|
||||
if (pw.type === "password") { pw.type = "text"; btn.textContent = "隐藏"; }
|
||||
else { pw.type = "password"; btn.textContent = "显示"; }
|
||||
}
|
||||
|
||||
function saveAccount() {
|
||||
var sid = document.querySelector("#currentSiteId").value;
|
||||
var aid = document.querySelector("#editAccountId").value;
|
||||
var data = { username: document.querySelector("#accountUsername").value, password: document.querySelector("#accountPassword").value, remark: document.querySelector("#accountRemark").value };
|
||||
if (aid) {
|
||||
fetch("/api/accounts/" + aid, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
|
||||
.then(function() { hideAccountModal(); selectWebsite(sid); });
|
||||
} else {
|
||||
fetch("/api/websites/" + sid + "/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
|
||||
.then(function() { hideAccountModal(); selectWebsite(sid); });
|
||||
}
|
||||
}
|
||||
|
||||
function deleteAccount(accId) {
|
||||
if (!confirm("确定删除?")) return;
|
||||
fetch("/api/accounts/" + accId, { method: "DELETE" }).then(function() { selectWebsite(currentSiteId); });
|
||||
}
|
||||
|
||||
// ===== 搜索 =====
|
||||
function filterSites() {
|
||||
var q = document.querySelector("#siteSearch").value.toLowerCase().trim();
|
||||
document.querySelectorAll(".site-item").forEach(function(item) {
|
||||
if (!q) { item.style.display = "flex"; return; }
|
||||
var name = (item.querySelector(".site-name")?.textContent || "").toLowerCase();
|
||||
var url = (item.dataset.siteUrl || "").toLowerCase();
|
||||
item.style.display = (name.indexOf(q) !== -1 || url.indexOf(q) !== -1) ? "flex" : "none";
|
||||
});
|
||||
}
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
root: '.',
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: 'static/dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'index.html'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user