初始化简单写作项目

This commit is contained in:
2026-07-10 17:09:39 +08:00
commit 31e631027b
74 changed files with 5123 additions and 0 deletions
+1000
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+9
View File
@@ -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
View File
@@ -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>
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+45
View File
@@ -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
View File
@@ -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) { /* 忽略,等待用户手动登录 */ }
})();
}
})();