/ Update
8 min
No categories available
No tags available
基于 WedDAV 的轻量说说/微语管理工具
Without further ado
val views
|
comments
一个基于 WebDAV 协议的极简微博客管理工具(Whispers Manager)。通过一个网页界面,在支持 WebDAV 的远程存储上发布、编辑、删除并管理自己的短篇文字内容(whispers)。
通过 Alist 等网盘挂载工具,挂载 S3 服务,实现此网页工具读写微语数据,S3 服务搭配 CDN,实现博客等应用数据获取。
这个 HTML 是一个 无后端、纯前端的微博客客户端,它将 WebDAV 作为存储层,实现了完整的短内容发布与管理功能,尤其适合部署在支持 WebDAV 的个人云盘(如 NextCloud、OwnCloud、坚果云 WebDAV 等)或任何可 PUT/GET JSON 的 HTTP 服务上。切分文件功能让它具备一定程度的文章分页或备份归档能力。
同时,可以修改源码,将完整的 JSON 存储到 Alist 本地挂载的文件夹中。仅将分页公开。
其性能可能会有瓶颈,并非一劳永逸的完美解决方案,但我想它可以满足大部分轻量级微博客需求。

代码
代码是 AI 写的,用了上篇文章提到的 Water.css 。和 AI 自己写的 CSS 有些冲突。问题不大。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whispers · 微博客管理</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
:root {
--accent: #7dd3fc;
--accent2: #a78bfa;
--danger: #f87171;
--success: #34d399;
--muted: #64748b;
--border: #1e293b;
--card-bg: #0f172a;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Noto+Serif+SC:wght@400;600&display=swap');
* { box-sizing: border-box; }
body {
font-family: 'Noto Serif SC', serif;
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem;
background: #020817;
min-height: 100vh;
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(120,119,198,0.12), transparent),
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(125,211,252,0.06), transparent);
}
h1 {
font-family: var(--font-mono);
font-size: 1.6rem;
letter-spacing: -0.02em;
color: var(--accent);
margin-bottom: 0.2rem;
text-shadow: 0 0 30px rgba(125,211,252,0.3);
}
.subtitle {
color: var(--muted);
font-size: 0.82rem;
font-family: var(--font-mono);
margin-bottom: 2rem;
}
/* Config Panel */
.config-panel {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.2rem 1.4rem;
margin-bottom: 1.5rem;
}
.config-panel summary {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--muted);
cursor: pointer;
user-select: none;
letter-spacing: 0.05em;
}
.config-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.6rem;
margin-top: 1rem;
align-items: end;
}
.config-row label { font-size: 0.75rem; color: var(--muted); }
/* Status bar */
.status-bar {
display: flex;
align-items: center;
gap: 0.6rem;
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.5rem 0.8rem;
border-radius: 5px;
margin-bottom: 1.2rem;
transition: all 0.3s;
}
.status-bar.info { background: rgba(125,211,252,0.08); color: var(--accent); border: 1px solid rgba(125,211,252,0.2); }
.status-bar.success { background: rgba(52,211,153,0.08); color: var(--success); border: 1px solid rgba(52,211,153,0.2); }
.status-bar.error { background: rgba(248,113,113,0.08); color: var(--danger); border: 1px solid rgba(248,113,113,0.2); }
.status-bar.loading { background: rgba(167,139,250,0.08); color: var(--accent2); border: 1px solid rgba(167,139,250,0.2); }
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
.dot.pulse { animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* Compose */
.compose-box {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.2rem 1.4rem;
margin-bottom: 1.5rem;
}
.compose-box h3 {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--muted);
letter-spacing: 0.08em;
margin: 0 0 0.8rem;
text-transform: uppercase;
}
textarea {
width: 100%;
min-height: 100px;
resize: vertical;
font-family: var(--font-mono);
font-size: 0.88rem;
background: #060e1c;
border: 1px solid var(--border);
border-radius: 6px;
color: #e2e8f0;
padding: 0.7rem 0.9rem;
transition: border-color 0.2s;
line-height: 1.6;
}
textarea:focus { border-color: var(--accent); outline: none; }
.preview-box {
background: #060e1c;
border: 1px dashed rgba(125,211,252,0.2);
border-radius: 6px;
padding: 0.7rem 0.9rem;
font-size: 0.88rem;
line-height: 1.7;
color: #cbd5e1;
min-height: 40px;
margin-top: 0.6rem;
}
.preview-label {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
margin-bottom: 0.3rem;
}
.compose-actions {
display: flex;
gap: 0.6rem;
margin-top: 0.8rem;
flex-wrap: wrap;
}
/* Buttons */
button {
font-family: var(--font-mono);
font-size: 0.78rem;
padding: 0.45rem 1rem;
border-radius: 5px;
border: none;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.03em;
}
.btn-primary {
background: rgba(125,211,252,0.15);
color: var(--accent);
border: 1px solid rgba(125,211,252,0.3);
}
.btn-primary:hover { background: rgba(125,211,252,0.25); }
.btn-danger {
background: rgba(248,113,113,0.1);
color: var(--danger);
border: 1px solid rgba(248,113,113,0.25);
}
.btn-danger:hover { background: rgba(248,113,113,0.2); }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover { color: #94a3b8; border-color: #334155; }
.btn-split {
background: rgba(167,139,250,0.1);
color: var(--accent2);
border: 1px solid rgba(167,139,250,0.25);
}
.btn-split:hover { background: rgba(167,139,250,0.2); }
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.count-badge {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--muted);
background: var(--card-bg);
border: 1px solid var(--border);
padding: 0.25rem 0.7rem;
border-radius: 99px;
}
/* Whisper cards */
.whisper-list { display: flex; flex-direction: column; gap: 0.8rem; }
.whisper-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.2rem;
transition: border-color 0.2s, transform 0.1s;
animation: slideIn 0.25s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.whisper-card:hover { border-color: #334155; }
.whisper-card.editing { border-color: var(--accent2); }
.whisper-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.whisper-time {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
flex: 1;
}
.whisper-actions { display: flex; gap: 0.4rem; }
.btn-icon {
background: transparent;
border: none;
color: var(--muted);
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
font-family: var(--font-mono);
}
.btn-icon:hover { color: #e2e8f0; background: rgba(255,255,255,0.05); }
.btn-icon.edit:hover { color: var(--accent); }
.btn-icon.del:hover { color: var(--danger); }
.btn-icon.save:hover { color: var(--success); }
.whisper-content {
font-size: 0.9rem;
line-height: 1.75;
color: #cbd5e1;
}
.whisper-content p { margin: 0 0 0.5em; }
.whisper-content p:last-child { margin-bottom: 0; }
.whisper-content code {
font-family: var(--font-mono);
font-size: 0.82em;
background: rgba(125,211,252,0.08);
color: var(--accent);
padding: 0.1em 0.4em;
border-radius: 3px;
}
.whisper-content pre {
background: #060e1c;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.7rem 0.9rem;
overflow-x: auto;
}
.whisper-content pre code { background: none; padding: 0; }
.whisper-content strong { color: #e2e8f0; }
.whisper-content em { color: #94a3b8; }
.whisper-content a { color: var(--accent); }
.whisper-content blockquote {
border-left: 3px solid rgba(125,211,252,0.3);
margin: 0.5em 0;
padding: 0.3em 0 0.3em 0.8em;
color: var(--muted);
}
.edit-textarea {
width: 100%;
min-height: 80px;
resize: vertical;
font-family: var(--font-mono);
font-size: 0.82rem;
background: #060e1c;
border: 1px solid var(--accent2);
border-radius: 5px;
color: #e2e8f0;
padding: 0.6rem 0.8rem;
line-height: 1.6;
}
/* Split dialog */
.split-dialog {
background: var(--card-bg);
border: 1px solid rgba(167,139,250,0.3);
border-radius: 8px;
padding: 1.2rem 1.4rem;
margin-bottom: 1.5rem;
}
.split-dialog h3 {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--accent2);
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0 0 0.8rem;
}
.split-config {
display: flex;
align-items: end;
gap: 0.8rem;
flex-wrap: wrap;
}
.split-files-preview {
margin-top: 0.8rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-chip {
font-family: var(--font-mono);
font-size: 0.72rem;
padding: 0.2rem 0.7rem;
border-radius: 99px;
background: rgba(167,139,250,0.1);
color: var(--accent2);
border: 1px solid rgba(167,139,250,0.2);
}
.empty-state {
text-align: center;
color: var(--muted);
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 3rem 1rem;
border: 1px dashed var(--border);
border-radius: 8px;
}
input[type="number"], input[type="text"], input[type="url"], input[type="password"] {
font-family: var(--font-mono);
font-size: 0.82rem;
}
details > summary { list-style: none; }
details > summary::-webkit-details-marker { display: none; }
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 1.5rem 0;
}
</style>
</head>
<body x-data="whispersApp()" x-init="init()">
<h1>// whispers</h1>
<p class="subtitle">microblog · WebDAV manager</p>
<!-- Config -->
<details class="config-panel">
<summary>⚙ 连接配置</summary>
<div class="config-row">
<div>
<label>WebDAV 地址(或 API 基础 URL)</label>
<input type="url" x-model="config.baseUrl" placeholder="https://dav.example.com" style="width:100%">
</div>
<div>
<label>用户名</label>
<input type="text" x-model="config.username" placeholder="username" style="width:100%">
</div>
<div>
<label>密码 / Token</label>
<input type="password" x-model="config.password" placeholder="••••••" style="width:100%">
</div>
</div>
<div style="margin-top:0.8rem; display:flex; gap:0.6rem; align-items:center;">
<div style="flex:1">
<label style="font-size:0.75rem;color:var(--muted)">文件路径</label>
<input type="text" x-model="config.filePath" style="width:100%">
</div>
<div style="padding-top:1.2rem">
<button class="btn-primary" @click="loadWhispers()">连接并加载</button>
</div>
</div>
<p style="font-size:0.72rem;color:var(--muted);margin-top:0.6rem;font-family:var(--font-mono)">
▸ 未填写凭据则以匿名方式请求 · 切分文件会将新文件写入同一目录
</p>
</details>
<!-- Status -->
<div class="status-bar" :class="status.type" x-show="status.msg">
<span class="dot" :class="status.type === 'loading' ? 'pulse' : ''"></span>
<span x-text="status.msg"></span>
</div>
<!-- Compose -->
<div class="compose-box">
<h3>✦ 写一条 Whisper</h3>
<textarea
x-model="draft"
@input="previewHtml = renderMarkdown(draft)"
placeholder="支持 Markdown:**粗体** *斜体* `代码` > 引用 # 标题 [链接](url) ..."
></textarea>
<div x-show="draft.trim()">
<p class="preview-label">预览</p>
<div class="preview-box whisper-content" x-html="previewHtml"></div>
</div>
<div class="compose-actions">
<button class="btn-primary" @click="addWhisper()" :disabled="!draft.trim()">+ 发布</button>
<button class="btn-ghost" @click="draft=''; previewHtml=''">清空</button>
<button class="btn-primary" @click="saveWhispers()" :disabled="whispers.length === 0" style="margin-left:auto">
↑ 保存到 WebDAV
</button>
<button class="btn-primary" @click="saveWhispers();splitWhispers();" style="margin-left:auto">
↑ 保存主文件并切分
</button>
</div>
</div>
<!-- Split tool -->
<details>
<summary style="font-family:var(--font-mono);font-size:0.75rem;color:var(--muted);cursor:pointer;margin-bottom:0.8rem;letter-spacing:0.05em">
✂ 切分文件工具
</summary>
<div class="split-dialog">
<h3>切分 whispers.json</h3>
<div class="split-config">
<div>
<label style="font-size:0.75rem;color:var(--muted)">每个文件最多条数</label>
<input type="number" x-model.number="splitSize" min="1" style="width:100px">
</div>
<button class="btn-split" @click="splitWhispers()">执行切分</button>
</div>
<div class="split-files-preview" x-show="splitPreview.length > 0">
<template x-for="f in splitPreview" :key="f">
<span class="file-chip" x-text="f"></span>
</template>
</div>
<p style="font-size:0.72rem;color:var(--muted);margin-top:0.6rem;font-family:var(--font-mono)" x-show="splitResult">
<span x-text="splitResult"></span>
</p>
</div>
</details>
<hr class="divider">
<!-- List toolbar -->
<div class="toolbar">
<button class="btn-ghost" @click="loadWhispers()">↻ 刷新</button>
<input type="text" x-model="searchQ" placeholder="搜索…" style="flex:1;min-width:140px;max-width:260px;font-size:0.82rem">
<span class="count-badge" x-text="filtered.length + ' / ' + whispers.length + ' 条'"></span>
</div>
<!-- List -->
<div class="whisper-list">
<template x-if="filtered.length === 0">
<div class="empty-state">
<div style="font-size:1.8rem;margin-bottom:0.5rem">◌</div>
<span x-text="whispers.length === 0 ? '暂无内容 · 请先连接并加载' : '没有匹配的内容'"></span>
</div>
</template>
<template x-for="(item, idx) in filtered" :key="item.created_at + idx">
<div class="whisper-card" :class="item._editing ? 'editing' : ''">
<div class="whisper-header">
<span class="whisper-time" x-text="formatTime(item.created_at)"></span>
<div class="whisper-actions">
<template x-if="!item._editing">
<button class="btn-icon edit" @click="startEdit(item)" title="编辑">✎</button>
</template>
<template x-if="item._editing">
<button class="btn-icon save" @click="saveEdit(item)" title="保存">✓</button>
</template>
<button class="btn-icon del" @click="deleteWhisper(item)" title="删除">✕</button>
</div>
</div>
<template x-if="!item._editing">
<div class="whisper-content" x-html="renderMarkdown(item.content)"></div>
</template>
<template x-if="item._editing">
<div>
<textarea class="edit-textarea" x-model="item._draft"></textarea>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem">
<button class="btn-primary" style="font-size:0.75rem" @click="saveEdit(item)">保存</button>
<button class="btn-ghost" style="font-size:0.75rem" @click="cancelEdit(item)">取消</button>
</div>
</div>
</template>
</div>
</template>
</div>
<script>
function whispersApp() {
return {
config: {
baseUrl: '',
username: '',
password: '',
filePath: 'microblog/whispers.json',
},
whispers: [],
draft: '',
previewHtml: '',
searchQ: '',
status: { msg: '', type: 'info' },
splitSize: 20,
splitPreview: [],
splitResult: '',
get filtered() {
if (!this.searchQ.trim()) return [...this.whispers].reverse();
const q = this.searchQ.toLowerCase();
return [...this.whispers].reverse().filter(w =>
w.content.toLowerCase().includes(q)
);
},
init() {
const saved = localStorage.getItem('whispers_config');
if (saved) {
try { Object.assign(this.config, JSON.parse(saved)); } catch(e) {}
}
this.loadWhispers();
},
saveConfig() {
localStorage.setItem('whispers_config', JSON.stringify(this.config));
},
setStatus(msg, type = 'info') {
this.status = { msg, type };
},
buildHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (this.config.username || this.config.password) {
const cred = btoa(`${this.config.username}:${this.config.password}`);
headers['Authorization'] = `Basic ${cred}`;
}
return headers;
},
fileUrl(path) {
const base = this.config.baseUrl.replace(/\/$/, '');
return `${base}/${path}`;
},
async loadWhispers() {
if (!this.config.baseUrl) {
this.setStatus('请先填写 WebDAV 地址', 'error');
return;
}
this.saveConfig();
this.setStatus('正在加载…', 'loading');
try {
const res = await fetch(this.fileUrl(this.config.filePath), {
method: 'GET',
headers: this.buildHeaders(),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
this.whispers = Array.isArray(data) ? data : (data.whispers || []);
this.setStatus(`已加载 ${this.whispers.length} 条记录`, 'success');
} catch(e) {
this.setStatus(`加载失败: ${e.message}`, 'error');
}
},
async saveWhispers(path, data) {
const filePath = path || this.config.filePath;
const payload = data || this.whispers;
if (!this.config.baseUrl) {
this.setStatus('请先填写 WebDAV 地址', 'error');
return false;
}
this.setStatus('正在保存…', 'loading');
try {
const res = await fetch(this.fileUrl(filePath), {
method: 'PUT',
headers: this.buildHeaders(),
body: JSON.stringify(payload),
// body: JSON.stringify(payload, null, 2),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
this.setStatus('已保存到 WebDAV ✓', 'success');
return true;
} catch(e) {
this.setStatus(`保存失败: ${e.message}`, 'error');
return false;
}
},
addWhisper() {
if (!this.draft.trim()) return;
const item = {
content: this.draft.trim(),
created_at: new Date().toISOString(),
};
this.whispers.push(item);
this.draft = '';
this.previewHtml = '';
this.setStatus('已添加,记得保存到 WebDAV', 'success');
},
startEdit(item) {
item._draft = item.content;
item._editing = true;
},
saveEdit(item) {
if (item._draft !== undefined) {
item.content = item._draft;
}
item._editing = false;
this.setStatus('已修改,记得保存到 WebDAV', 'info');
},
cancelEdit(item) {
item._editing = false;
delete item._draft;
},
deleteWhisper(item) {
if (!confirm('确认删除这条 Whisper?')) return;
this.whispers = this.whispers.filter(w => w !== item);
this.setStatus('已删除,记得保存到 WebDAV', 'info');
},
async splitWhispers() {
if (this.whispers.length === 0) {
this.setStatus('没有数据可切分', 'error');
return;
}
const size = Math.max(1, this.splitSize);
const chunks = [];
const revWhispers = this.whispers.slice().reverse();
console.log('Reversed whispers for splitting:', revWhispers);
for (let i = 0; i < revWhispers.length; i += size) {
// chunks.push(revWhispers.slice(i, i + size));
const chunk = revWhispers.slice(i, i + size).map(item => ({
content: item.content,
created_at: item.created_at
}));
chunks.push(chunk);
}
// Build preview filenames
const dir = this.config.filePath.replace(/[^/]+$/, '');
const totalPages = Math.ceil(this.whispers.length / size);
const files = chunks.map((_, i) => `${dir}whispers${i + 1}.json`);
// const files = chunks.map((_, i) => `${dir}whispers${chunks.length - i}.json`);
this.splitPreview = files;
if (!this.config.baseUrl) {
this.splitResult = `预览:将生成 ${files.join(', ')}(请先填写 WebDAV 地址以执行保存)`;
return;
}
this.setStatus('正在切分并保存…', 'loading');
let ok = 0;
for (let i = 0; i < chunks.length; i++) {
const success = await this.saveWhispers(files[i], { whispers: chunks[i] , total: this.whispers.length, page: i + 1, per_page: size, total_pages: totalPages });
if (success) ok++;
}
this.splitResult = `已写入 ${ok}/${chunks.length} 个文件`;
this.setStatus(`切分完成,写入 ${ok} 个文件`, 'success');
},
formatTime(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
} catch(e) { return iso; }
},
renderMarkdown(text) {
if (!text) return '';
let html = text
// Code blocks
.replace(/```([\s\S]*?)```/g, (_, code) =>
`<pre><code>${escHtml(code.trim())}</code></pre>`)
// Inline code
.replace(/`([^`]+)`/g, (_, c) => `<code>${escHtml(c)}</code>`)
// Headings
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold & italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Strikethrough
.replace(/~~(.+?)~~/g, '<del>$1</del>')
// Blockquote
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
// Horizontal rule
.replace(/^---+$/gm, '<hr>')
// Unordered list
.replace(/^\s*[-*+] (.+)$/gm, '<li>$1</li>')
// Ordered list
.replace(/^\s*\d+\. (.+)$/gm, '<li>$1</li>')
// Line breaks → paragraphs
.split(/\n{2,}/)
.map(p => {
p = p.trim();
if (!p) return '';
if (/^<(h[1-6]|pre|blockquote|hr|ul|ol|li)/.test(p)) return p;
if (p.startsWith('<li>')) return `<ul>${p}</ul>`;
return `<p>${p.replace(/\n/g, '<br>')}</p>`;
})
.join('\n');
return html;
},
};
}
function escHtml(str) {
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
</script>
</body>
</html>html