初始化简单写作项目
This commit is contained in:
+1000
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||
<rect width="192" height="192" rx="40" fill="#f4efe6"/>
|
||||
<rect x="40" y="36" width="112" height="120" rx="10" fill="#b9925a"/>
|
||||
<rect x="56" y="60" width="80" height="8" rx="4" fill="#fbf8f1"/>
|
||||
<rect x="56" y="80" width="80" height="8" rx="4" fill="#fbf8f1"/>
|
||||
<rect x="56" y="100" width="56" height="8" rx="4" fill="#fbf8f1"/>
|
||||
<path d="M118 132 l24 24 -8 0 -20 -20 z" fill="#8a645a"/>
|
||||
<path d="M134 156 l4 8 -10 0 4 -10 z" fill="#8a645a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 547 B |
+267
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f4efe6" />
|
||||
<meta name="description" content="简单写作 - 极简专注的跨平台写作工具" />
|
||||
<title>简单写作</title>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="icon.svg" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- 侧边栏:文章列表 -->
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="brand">简单写作</span>
|
||||
<button id="newDocBtn" class="icon-btn" title="新建文章" aria-label="新建文章">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="docList" class="doc-list"></div>
|
||||
<div class="sidebar-footer">
|
||||
<button id="themeBtn" class="text-btn" title="切换主题">🌙 主题</button>
|
||||
<button id="installBtn" class="text-btn" hidden>⬇ 安装</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 遮罩(移动端侧边栏) -->
|
||||
<div id="overlay" class="overlay"></div>
|
||||
|
||||
<!-- 主写作区 -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button id="menuBtn" class="icon-btn" title="文章列表" aria-label="文章列表">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
|
||||
</button>
|
||||
<input id="titleInput" class="title-input" placeholder="无标题" />
|
||||
<button id="renameBtn" class="icon-btn rename-btn" title="修改文章名" aria-label="修改文章名">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<div class="topbar-actions">
|
||||
<button id="focusBtn" class="icon-btn" title="阅读模式" aria-label="阅读模式">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M21 5c-1.11-.35-2.33-.5-3.5-.5-1.95 0-4.05.4-5.5 1.5-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5 1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5V6c-.6-.45-1.25-.75-2-1zm0 13.5c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5V8c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5v11.5z"/></svg>
|
||||
</button>
|
||||
<button id="typeBtn" class="icon-btn" title="打字机滚动(当前行居中)" aria-label="打字机滚动">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M4 7h16v2H4zm2 4h2v6H6zm10 0h2v6h-2zM4 17h16v2H4z"/></svg>
|
||||
</button>
|
||||
<button id="syncBtn" class="icon-btn" title="多端同步" aria-label="多端同步">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19 18H6a4 4 0 1 1 .9-7.9A5.5 5.5 0 0 1 18 9.5a4 4 0 0 1 1 8.5zM6 16h13a2 2 0 1 0 0-4 2 2 0 0 0-1.5.7l-.4.4-.9-.9.5-.5A3.5 3.5 0 0 1 18 11a3 3 0 0 0-5.8-1l-.7.7-.7-.7a4.5 4.5 0 0 0-8.6 1.9A3 3 0 0 0 6 18z"/></svg>
|
||||
<span id="syncBadge" class="sync-badge" hidden></span>
|
||||
</button>
|
||||
<button id="exportBtn" class="icon-btn" title="导出" aria-label="导出">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 3v10.55l3.3-3.3 1.4 1.42L12 17.4l-4.7-4.73 1.4-1.42 3.3 3.3V3zM5 19h14v2H5z"/></svg>
|
||||
</button>
|
||||
<button id="historyBtn" class="icon-btn" title="历史记录" aria-label="历史记录">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l4 4 4-4H6a7 7 0 1 1 2.05 4.95l-1.42 1.42A9 9 0 1 0 13 3zm-1 5v5l4.25 2.52.75-1.23-3.5-2.08V8z"/></svg>
|
||||
</button>
|
||||
<button id="settingsBtn" class="icon-btn" title="设置" aria-label="设置">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19.14 12.94a7.49 7.49 0 0 0 .05-.94 7.49 7.49 0 0 0-.05-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.61-.22l-2.39.96a7 7 0 0 0-1.62-.94l-.36-2.54a.5.5 0 0 0-.5-.42h-3.84a.5.5 0 0 0-.5.42l-.36 2.54a7 7 0 0 0-1.62.94l-2.39-.96a.5.5 0 0 0-.61.22l-1.92 3.32a.5.5 0 0 0 .12.64l2.03 1.58c-.03.31-.05.62-.05.94s.02.63.05.94l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32a.5.5 0 0 0 .61.22l2.39-.96c.5.38 1.04.7 1.62.94l.36 2.54a.5.5 0 0 0 .5.42h3.84a.5.5 0 0 0 .5-.42l.36-2.54c.58-.24 1.12-.56 1.62-.94l2.39.96a.5.5 0 0 0 .61-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 导出菜单 -->
|
||||
<div id="exportMenu" class="export-menu" hidden>
|
||||
<button data-type="txt">导出为 TXT</button>
|
||||
<button data-type="md">导出为 Markdown</button>
|
||||
<button data-type="copy">复制全文</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-wrap">
|
||||
<div id="editor" class="editor" contenteditable="true" spellcheck="false"
|
||||
role="textbox" aria-multiline="true" data-placeholder="开始写作吧……"></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 设置面板 -->
|
||||
<div id="settingsMask" class="settings-mask" hidden>
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<span>设置</span>
|
||||
<button id="settingsClose" class="icon-btn" title="关闭" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<label class="set-row">
|
||||
<span>主题</span>
|
||||
<select id="setTheme">
|
||||
<option value="system">跟随系统</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark">深色</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>正文字号</span>
|
||||
<select id="setFont">
|
||||
<option value="16">小</option>
|
||||
<option value="19">中</option>
|
||||
<option value="22">大</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>正文字体</span>
|
||||
<select id="setFontFamily">
|
||||
<option value='"Georgia", "Songti SC", "SimSun", serif'>宋体 / 衬线</option>
|
||||
<option value='-apple-system, "Segoe UI", "Microsoft YaHei", sans-serif'>黑体 / 无衬线</option>
|
||||
<option value='"Kaiti SC", "KaiTi", "STKaiti", serif'>楷体</option>
|
||||
<option value='"LXGW WenKai", "Kaiti SC", "KaiTi", "STKaiti", serif'>霞鹜文楷</option>
|
||||
<option value='"YouYuan", "圆体-简", sans-serif'>圆体</option>
|
||||
<option value='"Consolas", "Courier New", "SFMono-Regular", monospace'>等宽</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>编辑区宽度</span>
|
||||
<select id="setWidth">
|
||||
<option value="720">窄</option>
|
||||
<option value="920">适中</option>
|
||||
<option value="1100">宽</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>段首缩进(字符)</span>
|
||||
<select id="setIndent">
|
||||
<option value="0">0(不缩进)</option>
|
||||
<option value="2">2(默认)</option>
|
||||
<option value="4">4</option>
|
||||
<option value="6">6</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>段间距</span>
|
||||
<select id="setParaGap">
|
||||
<option value="0">0(无额外间距)</option>
|
||||
<option value="0.6" selected>0.6 行高(默认)</option>
|
||||
<option value="0.7">0.7 行高</option>
|
||||
<option value="0.8">0.8 行高</option>
|
||||
<option value="0.9">0.9 行高</option>
|
||||
<option value="1">1 行高</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>行间距</span>
|
||||
<select id="setLine">
|
||||
<option value="1.2">紧凑 1.2</option>
|
||||
<option value="1.5">适中 1.5</option>
|
||||
<option value="1.8">舒适 1.8</option>
|
||||
<option value="2">宽松 2.0</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="set-row">
|
||||
<span>排版(写入正文)</span>
|
||||
<button id="applyTypoBtn" class="text-btn primary" type="button">应用排版</button>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<span>输入区颜色</span>
|
||||
<div class="color-picks" id="paperPicks">
|
||||
<button type="button" class="color-swatch" data-color="" title="跟随主题(默认)" style="background:linear-gradient(135deg,#fbf8f1 50%,#232220 50%)"></button>
|
||||
<button type="button" class="color-swatch" data-color="#ffffff" title="纯白" style="background:#ffffff"></button>
|
||||
<button type="button" class="color-swatch" data-color="#f7f3e8" title="米黄(护眼)" style="background:#f7f3e8"></button>
|
||||
<button type="button" class="color-swatch" data-color="#e8f0e4" title="豆绿(护眼)" style="background:#e8f0e4"></button>
|
||||
<button type="button" class="color-swatch" data-color="#eef1f6" title="淡蓝" style="background:#eef1f6"></button>
|
||||
<button type="button" class="color-swatch" data-color="#1b1a18" title="纯黑" style="background:#1b1a18"></button>
|
||||
<input type="color" id="setPaperCustom" title="自定义颜色" value="#fbf8f1" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="set-row">
|
||||
<span>打字机滚动</span>
|
||||
<input type="checkbox" id="setType" />
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>每日目标字数</span>
|
||||
<input type="number" id="setGoal" min="0" step="100" placeholder="0 = 不限制" />
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>阅读模式显示字数</span>
|
||||
<input type="checkbox" id="setFocusCount" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录面板 -->
|
||||
<div id="historyMask" class="settings-mask" hidden>
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<span>历史记录</span>
|
||||
<button id="historyClose" class="icon-btn" title="关闭" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<p class="history-tip">自动保存的版本快照。点「预览」查看内容,点「恢复」覆盖当前正文(原内容会再存一个版本,可随时找回)。</p>
|
||||
<div id="historyList" class="history-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重命名弹窗 -->
|
||||
<div id="renameMask" class="rename-mask" hidden>
|
||||
<div class="rename-dialog">
|
||||
<div class="rename-dialog-header">修改文章名</div>
|
||||
<input id="renameInput" class="rename-input" placeholder="输入文章名" />
|
||||
<div class="rename-dialog-btns">
|
||||
<button id="renameCancel" class="text-btn">取消</button>
|
||||
<button id="renameConfirm" class="text-btn primary">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="syncPanel" class="sync-panel" hidden>
|
||||
<div class="sync-panel-header">
|
||||
<span>多端同步</span>
|
||||
<button id="syncClose" class="icon-btn" title="关闭" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="sync-panel-body">
|
||||
<div id="syncLogin">
|
||||
<div id="modeWebdav">
|
||||
<label>WebDAV 服务器地址</label>
|
||||
<input id="wdServer" placeholder="https://dav.jianguoyun.com/dav/" />
|
||||
<label>存储路径(文件名)</label>
|
||||
<input id="wdPath" placeholder="jiandan-sync.json" />
|
||||
<label>WebDAV 用户名</label>
|
||||
<input id="wdUser" placeholder="WebDAV 账号" />
|
||||
<label>WebDAV 密码</label>
|
||||
<input id="wdPass" type="password" placeholder="登录 WebDAV 的密码" />
|
||||
<label>加密密码(端到端,与 WebDAV 密码可不同,请牢记)</label>
|
||||
<input id="wdCrypto" type="password" placeholder="用于加密内容,服务器无法读取" />
|
||||
</div>
|
||||
|
||||
<label class="switch-row"><input type="checkbox" id="syncAutoLogin" checked /> 自动上传(编辑后自动同步到 WebDAV)</label>
|
||||
|
||||
<div class="sync-btns">
|
||||
<button id="syncLoginBtn" class="text-btn primary">登录并同步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="syncReady" hidden>
|
||||
<p class="sync-user">已登录:<b id="syncUserLabel"></b></p>
|
||||
<label class="switch-row"><input type="checkbox" id="syncAuto" checked /> 自动同步(编辑后)</label>
|
||||
<div class="sync-btns">
|
||||
<button id="syncNow" class="text-btn primary">立即同步</button>
|
||||
<button id="syncLogout" class="text-btn">退出</button>
|
||||
</div>
|
||||
|
||||
<div class="backup-box">
|
||||
<div class="backup-title">手动备份存档 <span class="backup-hint">(独立快照,防自动同步误覆盖)</span></div>
|
||||
<div class="sync-btns backup-btns">
|
||||
<button id="backupNow" class="text-btn">+ 生成存档</button>
|
||||
<button id="backupRefresh" class="text-btn" title="刷新存档列表">刷新列表</button>
|
||||
</div>
|
||||
<label>历史存档</label>
|
||||
<select id="backupSelect"><option value="">(暂无存档)</option></select>
|
||||
<div class="sync-btns">
|
||||
<button id="backupRestore" class="text-btn primary">恢复所选存档</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="syncStatus" class="sync-status"></p>
|
||||
<p class="sync-tip">内容以你的密码端到端加密后上传,服务器仅存储密文,无法读取你的文字。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="sync.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "简单写作",
|
||||
"short_name": "简单写作",
|
||||
"description": "极简专注的跨平台写作工具",
|
||||
"start_url": "./index.html",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#f4efe6",
|
||||
"theme_color": "#f4efe6",
|
||||
"lang": "zh-CN",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/* 简单写作 · 一站式启动器
|
||||
* 提供:静态写作页面 + WebDAV 同步代理(同源 /api/webdav/,绕过浏览器 CORS)
|
||||
* 用法: node start.js (默认端口 8080,可用 PORT 覆盖)
|
||||
*/
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
const ROOT = __dirname;
|
||||
|
||||
// ===================== WebDAV 代理(绕过浏览器 CORS,仅转发密文) =====================
|
||||
function wdReq(targetBase, p, user, pass, method, body, extraHeaders) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const base = targetBase.replace(/\/$/, "");
|
||||
const full = p ? base + "/" + String(p).replace(/^\//, "") : base;
|
||||
let target;
|
||||
try { target = new URL(full); } catch (e) { return reject(new Error("WebDAV 地址无效")); }
|
||||
const auth = "Basic " + Buffer.from(user + ":" + pass, "utf8").toString("base64");
|
||||
const mod = target.protocol === "https:" ? https : http;
|
||||
const opt = {
|
||||
method,
|
||||
hostname: target.hostname,
|
||||
port: target.port || (target.protocol === "https:" ? 443 : 80),
|
||||
path: (target.pathname || "/") + (target.search || ""),
|
||||
headers: Object.assign({ Authorization: auth }, extraHeaders || {}),
|
||||
};
|
||||
const req = mod.request(opt, (resp) => {
|
||||
let data = "";
|
||||
resp.on("data", (c) => (data += c));
|
||||
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: data }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
if (body != null) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function wdMkcolRecursive(base, dir, user, pass) {
|
||||
const parts = String(dir).split("/").filter(Boolean);
|
||||
let cur = base.replace(/\/$/, "");
|
||||
for (const part of parts) {
|
||||
cur = cur + "/" + part;
|
||||
try { await wdReq(cur, "", user, pass, "MKCOL"); } catch (e) { /* 已存在则忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
function wdParseProp(xml, tag) {
|
||||
const m = xml.match(new RegExp(tag + "\\b[^>]*>([^<]*)<", "i"));
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
// 解析 PROPFIND (Depth:1) 的 multistatus,取出所有 href(忽略命名空间前缀差异)
|
||||
function wdParseHrefs(xml) {
|
||||
const out = [];
|
||||
const re = /<[^>]*:?href[^>]*>([^<]+)<\/[^>]*:?href>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
let h = m[1].trim();
|
||||
try { h = decodeURIComponent(h); } catch (e) { /* 保留原样 */ }
|
||||
out.push(h);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sendWebdavJson(res, code, obj) {
|
||||
res.writeHead(code, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function handleWebdav(req, res, url) {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", async () => {
|
||||
let json = {};
|
||||
try { if (body) json = JSON.parse(body); } catch { return sendWebdavJson(res, 400, { error: "请求格式错误" }); }
|
||||
const { base, path: p, user, pass } = json;
|
||||
if (!base || !user) return sendWebdavJson(res, 400, { error: "缺少 WebDAV 服务器地址或用户名" });
|
||||
const ep = url.pathname.replace("/api/webdav/", "");
|
||||
try {
|
||||
if (ep === "propfind") {
|
||||
const xml = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||
'<D:propfind xmlns:D="DAV:"><D:prop><D:getetag/><D:getlastmodified/></D:prop></D:propfind>';
|
||||
const r = await wdReq(base, p, user, pass, "PROPFIND", xml, { Depth: "0", "Content-Type": "application/xml" });
|
||||
if (r.status === 404) return sendWebdavJson(res, 404, { status: 404 });
|
||||
return sendWebdavJson(res, 200, {
|
||||
status: r.status,
|
||||
etag: r.headers["etag"] || wdParseProp(r.body, "getetag"),
|
||||
lastmodified: r.headers["last-modified"] || wdParseProp(r.body, "getlastmodified"),
|
||||
});
|
||||
}
|
||||
if (ep === "get") {
|
||||
const r = await wdReq(base, p, user, pass, "GET");
|
||||
return sendWebdavJson(res, r.status, { status: r.status, etag: r.headers["etag"], content: r.body });
|
||||
}
|
||||
if (ep === "list") {
|
||||
// 列目录(Depth:1),返回目录下所有 href,供前端筛选备份存档
|
||||
const xml = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||
'<D:propfind xmlns:D="DAV:"><D:prop><D:getlastmodified/></D:prop></D:propfind>';
|
||||
const r = await wdReq(base, p, user, pass, "PROPFIND", xml, { Depth: "1", "Content-Type": "application/xml" });
|
||||
if (r.status === 404) return sendWebdavJson(res, 200, { status: 404, hrefs: [] });
|
||||
return sendWebdavJson(res, 200, { status: r.status, hrefs: wdParseHrefs(r.body) });
|
||||
}
|
||||
if (ep === "put") {
|
||||
const dir = String(p || "").includes("/") ? p.substring(0, p.lastIndexOf("/")) : "";
|
||||
if (dir) await wdMkcolRecursive(base, dir, user, pass);
|
||||
const r = await wdReq(base, p, user, pass, "PUT", json.content || "", { "Content-Type": "application/json" });
|
||||
return sendWebdavJson(res, 200, { status: r.status, etag: r.headers["etag"] });
|
||||
}
|
||||
return sendWebdavJson(res, 404, { error: "未知端点" });
|
||||
} catch (e) {
|
||||
return sendWebdavJson(res, 502, { error: String((e && e.message) || e) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== 静态文件 =====================
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".webmanifest": "application/manifest+json",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function serveStatic(req, res, url) {
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
if (pathname === "/" || pathname === "") pathname = "/index.html";
|
||||
const filePath = path.normalize(path.join(ROOT, pathname));
|
||||
if (!filePath.startsWith(ROOT)) { res.writeHead(403); return res.end("Forbidden"); }
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
return res.end("404 Not Found");
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Cache-Control": "no-cache" });
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
|
||||
// ===================== 路由 =====================
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (url.pathname.startsWith("/api/webdav/")) { handleWebdav(req, res, url); return; }
|
||||
serveStatic(req, res, url);
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[简单写作] 已启动 → http://localhost:${PORT}`);
|
||||
console.log(`[简单写作] WebDAV 代理 → http://localhost:${PORT}/api/webdav/`);
|
||||
});
|
||||
+705
@@ -0,0 +1,705 @@
|
||||
:root {
|
||||
--bg: #f4efe6;
|
||||
--paper: #fbf8f1;
|
||||
--text: #2f2a24;
|
||||
--text-soft: #8a8175;
|
||||
--line: #e5ddcf;
|
||||
--accent: #b9925a;
|
||||
--sidebar-bg: #efe8db;
|
||||
--hover: #e9e1d2;
|
||||
--shadow: 0 2px 12px rgba(80, 60, 30, 0.06);
|
||||
--font-ui: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
--font-write: "Georgia", "Songti SC", "SimSun", serif;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #1b1a18;
|
||||
--paper: #232220;
|
||||
--text: #e6e1d6;
|
||||
--text-soft: #8f887b;
|
||||
--line: #34322e;
|
||||
--accent: #c9a15f;
|
||||
--sidebar-bg: #201f1d;
|
||||
--hover: #2c2a27;
|
||||
--shadow: 0 2px 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* hidden 属性必须始终生效,避免被 display 规则覆盖(如设置面板的 flex) */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* ---------- 侧边栏 ---------- */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.28s ease, width 0.28s ease;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 16px 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.doc-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.doc-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.doc-item:hover { background: var(--hover); }
|
||||
.doc-item.active { background: var(--hover); }
|
||||
.doc-item.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 20%;
|
||||
height: 60%;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.doc-item .d-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-item .d-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-soft);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.doc-item .del-btn {
|
||||
position: absolute;
|
||||
right: 8px; top: 8px;
|
||||
border: none; background: transparent;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.doc-item:hover .del-btn { opacity: 1; }
|
||||
.doc-item .del-btn:hover { background: var(--line); color: #c0392b; }
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---------- 主区 ---------- */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.menu-btn-wrap { display: none; }
|
||||
|
||||
.title-input {
|
||||
flex: 1;
|
||||
visibility: hidden;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-write);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-actions { display: flex; gap: 4px; }
|
||||
|
||||
/* 重命名按钮默认隐藏,仅移动端显示 */
|
||||
.rename-btn { display: none; }
|
||||
|
||||
.icon-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
padding: 7px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--hover); color: var(--text); }
|
||||
.icon-btn.active { color: var(--accent); background: var(--hover); }
|
||||
|
||||
/* 同步按钮右下角的轻量绿色圆点(上传成功后显示) */
|
||||
#syncBtn { position: relative; }
|
||||
.sync-badge {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #2ecc71;
|
||||
box-shadow: 0 0 0 2px var(--paper);
|
||||
pointer-events: none;
|
||||
}
|
||||
.sync-badge[hidden] { display: none !important; }
|
||||
|
||||
.text-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.text-btn:hover { background: var(--hover); color: var(--text); }
|
||||
|
||||
/* ---------- 编辑区 ---------- */
|
||||
.editor-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 编辑区 */
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
max-width: 920px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: var(--paper);
|
||||
color: var(--text);
|
||||
font-family: var(--font-write);
|
||||
font-size: 19px;
|
||||
line-height: 2;
|
||||
padding: 48px 16px 40vh;
|
||||
letter-spacing: 0.3px;
|
||||
scroll-behavior: smooth;
|
||||
/* contenteditable:保留空白与换行、长词换行、竖排书写方向常规 */
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
position: relative;
|
||||
-webkit-user-modify: read-write-plaintext-only; /* 尽量按纯文本编辑,减少富文本粘贴 */
|
||||
}
|
||||
/* 段落块:段间距 = 段间距档位 × 行高(--para-gap,纯排版、不写入正文) */
|
||||
/* 段首缩进也通过 CSS text-indent 实现,不写入正文 */
|
||||
.editor .para { margin: 0; text-indent: var(--para-indent, 0); }
|
||||
.editor .para + .para { margin-top: var(--para-gap, 0); }
|
||||
/* 占位符(内容为空时显示) */
|
||||
.editor.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-soft);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 编辑区滚动条:细、不预留空间、不用时透明,悬停/聚焦/拖动时悬浮显示 */
|
||||
.editor { scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
||||
.editor::-webkit-scrollbar { width: 8px; }
|
||||
.editor::-webkit-scrollbar-track { background: transparent; }
|
||||
.editor::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.editor:hover::-webkit-scrollbar-thumb,
|
||||
.editor:focus::-webkit-scrollbar-thumb,
|
||||
.editor:active::-webkit-scrollbar-thumb {
|
||||
background-color: var(--line);
|
||||
}
|
||||
.editor::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--text-soft);
|
||||
}
|
||||
/* 深色主题下略微调亮,保持可见但依旧克制 */
|
||||
[data-theme="dark"] .editor:hover::-webkit-scrollbar-thumb,
|
||||
[data-theme="dark"] .editor:focus::-webkit-scrollbar-thumb,
|
||||
[data-theme="dark"] .editor:active::-webkit-scrollbar-thumb { background-color: #4a463f; }
|
||||
[data-theme="dark"] .editor::-webkit-scrollbar-thumb:hover { background-color: var(--text-soft); }
|
||||
|
||||
/* ---------- 导出菜单 ---------- */
|
||||
.export-menu {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 52px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 40;
|
||||
overflow: hidden;
|
||||
min-width: 160px;
|
||||
}
|
||||
.export-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 11px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.export-menu button:hover { background: var(--hover); }
|
||||
|
||||
/* ---------- 遮罩 ---------- */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
z-index: 25;
|
||||
}
|
||||
.overlay.show { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* ---------- 阅读模式 ---------- */
|
||||
body.reading .sidebar,
|
||||
body.reading .topbar {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
body.reading .sidebar { transform: translateX(-100%); }
|
||||
body.reading .topbar {
|
||||
height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
body.reading .editor {
|
||||
padding-top: 12vh;
|
||||
cursor: default;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
body.reading .topbar,
|
||||
body.reading .sidebar { transition: all 0.3s; }
|
||||
|
||||
/* 阅读模式下右上角悬浮退出提示 */
|
||||
.reading-exit {
|
||||
position: fixed;
|
||||
top: 14px; right: 18px;
|
||||
z-index: 50;
|
||||
font-size: 12px;
|
||||
color: var(--text-soft);
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 阅读模式下默认隐藏,鼠标/手指移到顶部才浮现 */
|
||||
body.reading .reading-exit { opacity: 0; pointer-events: none; }
|
||||
body.reading.top-shown .reading-exit { opacity: 0.75; pointer-events: auto; }
|
||||
.reading-exit:hover { opacity: 1 !important; }
|
||||
|
||||
/* ---------- 移动端 ---------- */
|
||||
@media (max-width: 720px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
transform: translateX(-100%);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.editor {
|
||||
font-size: 17px;
|
||||
padding: 42px 0 40vh 16px; /* top 42px 为固定顶栏留空 */
|
||||
line-height: 1.9;
|
||||
}
|
||||
/* 顶栏始终显示,不隐藏 */
|
||||
.topbar {
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
/* 移动端隐藏标题框,仅弹窗方式修改 */
|
||||
.title-input { display: none; }
|
||||
/* 移动端显示重命名按钮 */
|
||||
.rename-btn { display: inline-flex; }
|
||||
}
|
||||
|
||||
@media (min-width: 721px) {
|
||||
#menuBtn { display: none; }
|
||||
}
|
||||
/* 大屏显示器:进一步放宽输入宽度 */
|
||||
@media (min-width: 1400px) {
|
||||
.editor { max-width: 1100px; }
|
||||
}
|
||||
@media (min-width: 2000px) {
|
||||
.editor { max-width: 1280px; }
|
||||
}
|
||||
|
||||
/* ---------- 重命名弹窗 ---------- */
|
||||
.rename-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s;
|
||||
}
|
||||
.rename-dialog {
|
||||
background: var(--paper);
|
||||
border-radius: 12px;
|
||||
padding: 24px 20px 16px;
|
||||
width: min(360px, 90vw);
|
||||
box-shadow: var(--shadow), 0 8px 30px rgba(0,0,0,0.12);
|
||||
animation: slideUp 0.25s ease;
|
||||
}
|
||||
.rename-dialog-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
.rename-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-family: var(--font-write);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.rename-input:focus { border-color: var(--accent); }
|
||||
.rename-dialog-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* ---------- 同步面板 ---------- */
|
||||
.sync-panel {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 52px;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 36px);
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 40;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sync-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sync-panel-body { padding: 14px; }
|
||||
.sync-panel label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-soft);
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
.sync-panel input {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.sync-panel input:focus { border-color: var(--accent); }
|
||||
.sync-panel select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.sync-panel select:focus { border-color: var(--accent); }
|
||||
.sync-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.text-btn.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.text-btn.primary:hover { filter: brightness(1.05); color: #fff; background: var(--accent); }
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.switch-row input { width: auto; }
|
||||
.sync-user { font-size: 13px; margin-bottom: 4px; }
|
||||
.sync-status {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
margin-top: 12px;
|
||||
min-height: 16px;
|
||||
}
|
||||
.sync-tip {
|
||||
font-size: 11px;
|
||||
color: var(--text-soft);
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- 手动备份存档 ---------- */
|
||||
.backup-box {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.backup-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.backup-hint {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.backup-btns { margin-top: 10px; justify-content: flex-start; }
|
||||
|
||||
/* ---------- 设置面板 ---------- */
|
||||
.settings-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 60;
|
||||
}
|
||||
.settings-panel {
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 86vh;
|
||||
overflow-y: auto;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 600;
|
||||
}
|
||||
.settings-body { padding: 4px 16px 14px; }
|
||||
.set-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
}
|
||||
.set-row:last-child { border-bottom: none; }
|
||||
.set-row > span { color: var(--text); }
|
||||
.set-row select,
|
||||
.set-row input[type="number"] {
|
||||
min-width: 120px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.set-row select:focus,
|
||||
.set-row input:focus { border-color: var(--accent); }
|
||||
.set-row input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- 历史记录面板 ---------- */
|
||||
.history-tip {
|
||||
margin: 10px 2px 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.history-list { padding: 6px 0 4px; }
|
||||
.history-empty {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-soft);
|
||||
padding: 18px 4px;
|
||||
}
|
||||
.history-item {
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
.h-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.h-time { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||
.h-words { font-size: 12px; color: var(--text-soft); flex-shrink: 0; }
|
||||
.h-snippet {
|
||||
margin: 5px 0 8px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-soft);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.h-actions { display: flex; gap: 6px; }
|
||||
.h-actions .text-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12.5px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.h-full {
|
||||
margin: 9px 0 0;
|
||||
padding: 10px 12px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-write);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
#goalCount {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 输入区颜色选择 */
|
||||
.color-picks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.color-swatch:hover { transform: scale(1.12); }
|
||||
.color-swatch.active {
|
||||
box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px var(--accent);
|
||||
}
|
||||
#setPaperCustom {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* 简单写作 · Service Worker(离线缓存,network-first) */
|
||||
const CACHE = "jiandan-v1";
|
||||
const ASSETS = [
|
||||
"./",
|
||||
"./index.html",
|
||||
"./styles.css",
|
||||
"./app.js",
|
||||
"./manifest.json",
|
||||
"./icon.svg"
|
||||
];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
if (e.request.method !== "GET") return;
|
||||
// 同步 API 不走缓存,直接请求
|
||||
if (e.request.url.includes("/api/")) {
|
||||
e.respondWith(fetch(e.request));
|
||||
return;
|
||||
}
|
||||
// 其余资源:先请求网络(保证更新即时),失败再回退缓存(离线可用)
|
||||
e.respondWith(
|
||||
fetch(e.request)
|
||||
.then((res) => {
|
||||
if (res && res.status === 200 && res.type === "basic") {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(e.request, clone));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(e.request))
|
||||
);
|
||||
});
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
/* ==================== 简单写作 · WebDAV 同步(前端) ==================== */
|
||||
/* 内容在浏览器端用 AES-GCM 端到端加密,经本机后端 /api/webdav/ 代理转发到 WebDAV(代理不接触明文)。
|
||||
* 浏览器直连第三方 WebDAV 会被 CORS 拦截,因此统一经同源代理。
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
const CC = () => window.JianDan;
|
||||
const SYNC_KEY = "jiandan.syncCfg";
|
||||
|
||||
let cfg = loadCfg(); // { webdav:{base,path,user,pass,crypto}, lastSync, auto }
|
||||
if (cfg.auto === undefined) cfg.auto = true; // 默认开启自动上传
|
||||
let state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null };
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const panel = $("syncPanel");
|
||||
const syncBtn = $("syncBtn");
|
||||
const badge = $("syncBadge");
|
||||
|
||||
// 上传成功后在同步图标右下角显示绿色✅;同步中/失败/登出时隐藏
|
||||
function showBadge(on) {
|
||||
if (badge) badge.hidden = !on;
|
||||
}
|
||||
|
||||
// 旧版配置若残留「自建服务器」模式,统一迁移为 WebDAV
|
||||
if (cfg.mode === "server") { delete cfg.mode; saveCfg(); }
|
||||
|
||||
// ---------- 配置持久化 ----------
|
||||
function loadCfg() {
|
||||
try { return JSON.parse(localStorage.getItem(SYNC_KEY)) || {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
function saveCfg() { localStorage.setItem(SYNC_KEY, JSON.stringify(cfg)); }
|
||||
|
||||
// ---------- 加解密(Web Crypto,端到端) ----------
|
||||
function b64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
function b64ToBytes(b) {
|
||||
const s = atob(b);
|
||||
const a = new Uint8Array(s.length);
|
||||
for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
async function deriveKey(password, username) {
|
||||
const enc = new TextEncoder();
|
||||
const salt = enc.encode("jiandan-sync-v1::" + username);
|
||||
const base = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
|
||||
base,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
async function enc(key, text) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text || ""));
|
||||
return b64(iv) + ":" + b64(ct);
|
||||
}
|
||||
async function dec(key, blob) {
|
||||
if (!blob) return "";
|
||||
const [ivb, ctb] = String(blob).split(":");
|
||||
if (!ivb || !ctb) return "";
|
||||
const iv = b64ToBytes(ivb);
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64ToBytes(ctb));
|
||||
return new TextDecoder().decode(pt);
|
||||
}
|
||||
|
||||
// ---------- 工具 ----------
|
||||
async function encryptDoc(key, d) {
|
||||
return { id: d.id, title: await enc(key, d.title), content: await enc(key, d.content), updated: d.updated };
|
||||
}
|
||||
async function decryptDoc(key, e) {
|
||||
try {
|
||||
return { id: e.id, title: await dec(key, e.title), content: await dec(key, e.content), updated: e.updated };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function mergeIntoLocal(remoteDocs) {
|
||||
const apiObj = CC();
|
||||
const map = {};
|
||||
for (const d of apiObj.getDocs()) map[d.id] = d;
|
||||
for (const r of remoteDocs) {
|
||||
const ex = map[r.id];
|
||||
if (!ex || (r.updated || 0) > (ex.updated || 0)) map[r.id] = r;
|
||||
}
|
||||
apiObj.setDocs(Object.values(map));
|
||||
}
|
||||
|
||||
// ---------- 路径工具(备份存档用) ----------
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
function dirOf(p) { const i = String(p || "").lastIndexOf("/"); return i >= 0 ? p.slice(0, i) : ""; }
|
||||
function joinPath(dir, name) { return dir ? dir.replace(/\/$/, "") + "/" + name : name; }
|
||||
function backupName() {
|
||||
const d = new Date();
|
||||
return `jiandan-backup-${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}.json`;
|
||||
}
|
||||
function fmtBackupLabel(name) {
|
||||
const m = name.match(/jiandan-backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.json/i);
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : name;
|
||||
}
|
||||
|
||||
// ---------- WebDAV 代理 API(同源,绕过 CORS) ----------
|
||||
async function wdApi(ep, o) {
|
||||
try {
|
||||
const res = await fetch("/api/webdav/" + ep, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(o),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) { return { status: 0, error: e.message }; }
|
||||
}
|
||||
|
||||
// ---------- 同步核心:WebDAV ----------
|
||||
let wdSyncing = false;
|
||||
let wdDirty = false;
|
||||
let pollTimer = null;
|
||||
|
||||
async function wdSyncNow() {
|
||||
if (!state.loggedIn) return;
|
||||
if (wdSyncing) return;
|
||||
wdSyncing = true;
|
||||
showBadge(false);
|
||||
setStatus("同步中…");
|
||||
try {
|
||||
const wd = state.wd;
|
||||
const key = state.key;
|
||||
// 1. 检查远程是否有变化
|
||||
const pr = await wdApi("propfind", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass });
|
||||
if (pr.status === 401 || pr.status === 403) { setStatus("WebDAV 认证失败,请检查账号密码"); stopPoll(); return; }
|
||||
if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) {
|
||||
throw new Error("WebDAV " + (pr.error || pr.status));
|
||||
}
|
||||
let remoteDocs = [];
|
||||
if (pr.status === 200 && pr.etag && pr.etag === state.lastEtag && !wdDirty) {
|
||||
// 远程无变化且本地无改动
|
||||
setStatus("已是最新 · " + CC().fmtDate(cfg.lastSync || Date.now()));
|
||||
return;
|
||||
}
|
||||
if (pr.status === 200) {
|
||||
const gr = await wdApi("get", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass });
|
||||
if (gr.status === 200 && gr.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(gr.content);
|
||||
for (const e of (parsed.docs || [])) {
|
||||
const d = await decryptDoc(key, e);
|
||||
if (d) remoteDocs.push(d);
|
||||
}
|
||||
} catch (e) { /* 忽略坏数据 */ }
|
||||
}
|
||||
}
|
||||
// 2. 合并远程到本地
|
||||
mergeIntoLocal(remoteDocs);
|
||||
// 3. 把本地最新整文件推回 WebDAV
|
||||
const apiObj = CC();
|
||||
const toPush = [];
|
||||
for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d));
|
||||
const payload = JSON.stringify({ docs: toPush, v: 1 });
|
||||
const ur = await wdApi("put", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass, content: payload });
|
||||
if (ur.status !== 200 && ur.status !== 201 && ur.status !== 204 && ur.status !== 207) {
|
||||
throw new Error("上传失败 " + (ur.error || ur.status));
|
||||
}
|
||||
wdDirty = false;
|
||||
state.lastEtag = ur.etag || pr.etag || null;
|
||||
apiObj.persist();
|
||||
apiObj.renderList();
|
||||
apiObj.renderEditor();
|
||||
cfg.lastSync = Date.now();
|
||||
saveCfg();
|
||||
showBadge(true);
|
||||
setStatus("已同步 · " + apiObj.fmtDate(cfg.lastSync));
|
||||
} catch (e) {
|
||||
showBadge(false);
|
||||
setStatus("同步失败:" + e.message);
|
||||
} finally {
|
||||
wdSyncing = false;
|
||||
schedulePoll();
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
if (!state.loggedIn || cfg.auto === false) return;
|
||||
pollTimer = setTimeout(() => wdSyncNow(), 8000);
|
||||
}
|
||||
function stopPoll() { if (pollTimer) clearTimeout(pollTimer); }
|
||||
|
||||
// ---------- 手动备份存档(独立快照,不覆盖主同步文件) ----------
|
||||
async function manualBackup() {
|
||||
if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; }
|
||||
const wd = state.wd, key = state.key;
|
||||
setStatus("生成存档中…");
|
||||
try {
|
||||
const apiObj = CC();
|
||||
const toPush = [];
|
||||
for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d));
|
||||
const payload = JSON.stringify({ docs: toPush, v: 1, backupAt: Date.now() });
|
||||
const name = backupName();
|
||||
const p = joinPath(dirOf(wd.path), name);
|
||||
const r = await wdApi("put", { base: wd.base, path: p, user: wd.user, pass: wd.pass, content: payload });
|
||||
if (r.status !== 200 && r.status !== 201 && r.status !== 204 && r.status !== 207) {
|
||||
throw new Error("存档失败 " + (r.error || r.status));
|
||||
}
|
||||
setStatus("已生成存档 · " + fmtBackupLabel(name));
|
||||
loadBackupList();
|
||||
} catch (e) { setStatus("存档失败:" + e.message); }
|
||||
}
|
||||
|
||||
async function loadBackupList() {
|
||||
if (!state.loggedIn) return;
|
||||
const wd = state.wd;
|
||||
const sel = $("backupSelect");
|
||||
if (!sel) return;
|
||||
try {
|
||||
const r = await wdApi("list", { base: wd.base, path: dirOf(wd.path), user: wd.user, pass: wd.pass });
|
||||
const names = (r.hrefs || [])
|
||||
.map((h) => h.split("/").filter(Boolean).pop() || "")
|
||||
.filter((n) => /^jiandan-backup-.*\.json$/i.test(n));
|
||||
// 去重 + 按时间倒序(文件名含时间戳,字典序即时间序)
|
||||
const uniq = Array.from(new Set(names)).sort().reverse();
|
||||
sel.innerHTML = "";
|
||||
if (!uniq.length) {
|
||||
const o = document.createElement("option");
|
||||
o.value = ""; o.textContent = "(暂无存档)";
|
||||
sel.appendChild(o);
|
||||
return;
|
||||
}
|
||||
for (const n of uniq) {
|
||||
const o = document.createElement("option");
|
||||
o.value = n; o.textContent = fmtBackupLabel(n);
|
||||
sel.appendChild(o);
|
||||
}
|
||||
} catch (e) { /* 忽略列表加载失败 */ }
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; }
|
||||
const sel = $("backupSelect");
|
||||
const name = sel ? sel.value : "";
|
||||
if (!name) { setStatus("请先选择一个存档"); return; }
|
||||
if (!window.confirm(`确定用存档「${fmtBackupLabel(name)}」恢复吗?\n将与当前内容合并(同一篇以更新时间较新者为准),不会删除本地文章。`)) return;
|
||||
const wd = state.wd, key = state.key;
|
||||
setStatus("恢复中…");
|
||||
try {
|
||||
const p = joinPath(dirOf(wd.path), name);
|
||||
const gr = await wdApi("get", { base: wd.base, path: p, user: wd.user, pass: wd.pass });
|
||||
if (gr.status !== 200 || !gr.content) throw new Error("读取存档失败 " + (gr.error || gr.status));
|
||||
const parsed = JSON.parse(gr.content);
|
||||
const remoteDocs = [];
|
||||
for (const e of (parsed.docs || [])) { const d = await decryptDoc(key, e); if (d) remoteDocs.push(d); }
|
||||
if (!remoteDocs.length) throw new Error("存档为空或解密失败(检查加密密码)");
|
||||
mergeIntoLocal(remoteDocs);
|
||||
const apiObj = CC();
|
||||
apiObj.persist(); apiObj.renderList(); apiObj.renderEditor();
|
||||
setStatus("已从存档恢复 · " + fmtBackupLabel(name));
|
||||
wdDirty = true;
|
||||
wdSyncNow(); // 把恢复合并后的结果推回主同步文件
|
||||
} catch (e) { setStatus("恢复失败:" + e.message); }
|
||||
}
|
||||
|
||||
// ---------- 统一入口 ----------
|
||||
function syncNow() { return wdSyncNow(); }
|
||||
let wdTimer = null;
|
||||
function scheduleSync() {
|
||||
if (!state.loggedIn) return;
|
||||
wdDirty = true;
|
||||
if (cfg.auto === false) return;
|
||||
if (wdTimer) clearTimeout(wdTimer);
|
||||
wdTimer = setTimeout(() => wdSyncNow(), 1200);
|
||||
}
|
||||
window.JianDanSync = { scheduleSync };
|
||||
|
||||
// ---------- 登录:WebDAV ----------
|
||||
async function doWdLogin() {
|
||||
const base = $("wdServer").value.trim();
|
||||
const path = $("wdPath").value.trim() || "jiandan-sync.json";
|
||||
const user = $("wdUser").value.trim();
|
||||
const pass = $("wdPass").value;
|
||||
const cryptoPass = $("wdCrypto").value;
|
||||
if (!base || !user || !pass || !cryptoPass) { setStatus("请填写完整(WebDAV 账号/密码 + 加密密码)"); return; }
|
||||
if (!crypto.subtle) { setStatus("需 HTTPS 才能同步"); return; }
|
||||
const autoOn = $("syncAutoLogin") ? $("syncAutoLogin").checked : true;
|
||||
setStatus("连接 WebDAV 中…");
|
||||
try {
|
||||
const pr = await wdApi("propfind", { base, path, user, pass });
|
||||
if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) {
|
||||
throw new Error("连接失败(" + (pr.error || pr.status) + "),检查地址/账号");
|
||||
}
|
||||
const key = await deriveKey(cryptoPass, user);
|
||||
state = { token: null, key, loggedIn: true, username: user, wd: { base, path, user, pass }, lastEtag: null };
|
||||
cfg.webdav = { base, path, user, pass, crypto: cryptoPass };
|
||||
cfg.auto = autoOn !== false; saveCfg();
|
||||
showReady();
|
||||
wdSyncNow();
|
||||
} catch (e) { setStatus("登录失败:" + e.message); }
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
stopPoll();
|
||||
showBadge(false);
|
||||
state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null };
|
||||
delete cfg.webdav;
|
||||
saveCfg();
|
||||
showLogin(); setStatus("");
|
||||
}
|
||||
|
||||
// ---------- UI ----------
|
||||
function showReady() {
|
||||
$("syncLogin").hidden = true;
|
||||
$("syncReady").hidden = false;
|
||||
$("syncUserLabel").textContent = state.username + "(WebDAV)";
|
||||
$("syncAuto").checked = cfg.auto !== false;
|
||||
$("syncStatus").textContent = cfg.lastSync ? ("上次同步 " + CC().fmtDate(cfg.lastSync)) : "";
|
||||
loadBackupList();
|
||||
}
|
||||
function showLogin() {
|
||||
$("syncLogin").hidden = false;
|
||||
$("syncReady").hidden = true;
|
||||
const autoEl = $("syncAutoLogin");
|
||||
if (autoEl) autoEl.checked = cfg.auto !== false;
|
||||
if (cfg.webdav) {
|
||||
$("wdServer").value = cfg.webdav.base || "";
|
||||
$("wdPath").value = cfg.webdav.path || "jiandan-sync.json";
|
||||
$("wdUser").value = cfg.webdav.user || "";
|
||||
$("wdPass").value = cfg.webdav.pass || "";
|
||||
$("wdCrypto").value = cfg.webdav.crypto || "";
|
||||
}
|
||||
}
|
||||
function setStatus(t) { const el = $("syncStatus"); if (el) el.textContent = t; }
|
||||
|
||||
function openPanel() {
|
||||
if (!state.loggedIn) showLogin(); else showReady();
|
||||
panel.hidden = false;
|
||||
}
|
||||
function closePanel() { panel.hidden = true; }
|
||||
|
||||
// ---------- 事件 ----------
|
||||
syncBtn.addEventListener("click", (e) => { e.stopPropagation(); panel.hidden = !panel.hidden; if (!panel.hidden) openPanel(); });
|
||||
$("syncClose").addEventListener("click", closePanel);
|
||||
$("syncLoginBtn").addEventListener("click", doWdLogin);
|
||||
$("syncNow").addEventListener("click", syncNow);
|
||||
$("syncLogout").addEventListener("click", doLogout);
|
||||
$("backupNow").addEventListener("click", manualBackup);
|
||||
$("backupRefresh").addEventListener("click", loadBackupList);
|
||||
$("backupRestore").addEventListener("click", restoreBackup);
|
||||
$("syncAuto").addEventListener("change", (e) => { cfg.auto = e.target.checked; saveCfg(); if (e.target.checked) schedulePoll(); });
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!panel.hidden && !panel.contains(e.target) && e.target.id !== "syncBtn") closePanel();
|
||||
});
|
||||
["wdServer", "wdPath", "wdUser", "wdPass", "wdCrypto"].forEach((id) =>
|
||||
$(id).addEventListener("keydown", (e) => { if (e.key === "Enter") doWdLogin(); })
|
||||
);
|
||||
|
||||
// ---------- WebDAV 自动恢复(刷新后免登录继续同步) ----------
|
||||
if (cfg.webdav && cfg.webdav.base && cfg.webdav.crypto) {
|
||||
(async () => {
|
||||
try {
|
||||
const w = cfg.webdav;
|
||||
const key = await deriveKey(w.crypto, w.user);
|
||||
const pr = await wdApi("propfind", { base: w.base, path: w.path, user: w.user, pass: w.pass });
|
||||
state = { token: null, key, loggedIn: true, username: w.user, wd: { base: w.base, path: w.path, user: w.user, pass: w.pass }, lastEtag: null };
|
||||
showReady();
|
||||
wdSyncNow();
|
||||
} catch (e) { /* 忽略,等待用户手动登录 */ }
|
||||
})();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user