feat(sync): 添加同步状态面板和历史功能

- 创建同步状态面板视图,显示已发布文章列表和快速操作按钮
- 添加同步历史弹窗,记录和展示同步操作记录
- 在侧边栏添加同步图标,支持快速打开面板
- 更新国际化文件,添加中英文同步相关文案
- 编写详细的使用指南文档,说明所有功能使用方法
- 更新插件主程序,注册新命令和视图
This commit is contained in:
2026-04-26 18:34:29 +08:00
parent b72f36926a
commit 9b4530555f
17 changed files with 1358 additions and 67 deletions
+26 -1
View File
@@ -1,6 +1,7 @@
{
"ribbon_icon": {
"publish": "Publish current document to Halo"
"publish": "Publish current document to Halo",
"sync_status": "Sync Status Panel"
},
"command": {
"publish": {
@@ -44,6 +45,9 @@
"name": "Export as Markdown",
"error_not_published": "This document is not published to Halo"
},
"open_sync_panel": {
"name": "Open Sync Status Panel"
},
"export_json": {
"name": "Export as JSON",
"error_not_published": "This document is not published to Halo"
@@ -249,6 +253,27 @@
"error_export_failed": "Export failed",
"notice_export_success": "Exported to file: {fileName}"
},
"sync_panel": {
"title": "Sync Status",
"button_refresh": "Refresh",
"button_history": "History",
"button_clear_history": "Clear History",
"button_update": "Update",
"button_pull": "Pull",
"empty": "No published posts yet",
"total_posts": "Total {count} posts"
},
"sync_history": {
"title": "Sync History",
"empty": "No sync records",
"stats": "Total {total} records, {success} successful",
"action_publish": "Publish",
"action_update": "Update",
"action_pull": "Pull",
"action_delete": "Delete",
"confirm_clear": "Are you sure you want to clear all sync history?",
"notice_cleared": "History cleared"
},
"common": {
"error_connection_failed": "Connection failed",
"button_close": "Close",
+26 -1
View File
@@ -1,6 +1,7 @@
{
"ribbon_icon": {
"publish": "发布当前文档到 Halo"
"publish": "发布当前文档到 Halo",
"sync_status": "同步状态面板"
},
"command": {
"publish": {
@@ -47,6 +48,9 @@
"export_json": {
"name": "导出为 JSON",
"error_not_published": "此文档还未发布到 Halo"
},
"open_sync_panel": {
"name": "打开同步状态面板"
}
},
"settings": {
@@ -249,6 +253,27 @@
"error_export_failed": "导出失败",
"notice_export_success": "已导出到文件: {fileName}"
},
"sync_panel": {
"title": "同步状态",
"button_refresh": "刷新",
"button_history": "历史",
"button_clear_history": "清除历史",
"button_update": "更新",
"button_pull": "拉取",
"empty": "暂无已发布的文章",
"total_posts": "共 {count} 篇文章"
},
"sync_history": {
"title": "同步历史",
"empty": "暂无同步记录",
"stats": "共 {total} 条记录,成功 {success} 条",
"action_publish": "发布",
"action_update": "更新",
"action_pull": "拉取",
"action_delete": "删除",
"confirm_clear": "确定要清除所有同步历史吗?",
"notice_cleared": "历史已清除"
},
"common": {
"error_connection_failed": "连接失败",
"button_close": "关闭",
+26 -1
View File
@@ -1,6 +1,7 @@
{
"ribbon_icon": {
"publish": "發佈當前文件到 Halo"
"publish": "發佈當前文件到 Halo",
"sync_status": "同步狀態面板"
},
"command": {
"publish": {
@@ -44,6 +45,9 @@
"name": "導出為 Markdown",
"error_not_published": "此文件還未發佈到 Halo"
},
"open_sync_panel": {
"name": "打開同步狀態面板"
},
"export_json": {
"name": "導出為 JSON",
"error_not_published": "此文件還未發佈到 Halo"
@@ -249,6 +253,27 @@
"error_export_failed": "導出失敗",
"notice_export_success": "已導出到文件: {fileName}"
},
"sync_panel": {
"title": "同步狀態",
"button_refresh": "刷新",
"button_history": "歷史",
"button_clear_history": "清除歷史",
"button_update": "更新",
"button_pull": "拉取",
"empty": "暫無已發佈的文章",
"total_posts": "共 {count} 篇文章"
},
"sync_history": {
"title": "同步歷史",
"empty": "暫無同步記錄",
"stats": "共 {total} 條記錄,成功 {success} 條",
"action_publish": "發布",
"action_update": "更新",
"action_pull": "拉取",
"action_delete": "刪除",
"confirm_clear": "確定要清除所有同步歷史嗎?",
"notice_cleared": "歷史已清除"
},
"common": {
"error_connection_failed": "連接失敗",
"button_close": "關閉",
+22
View File
@@ -8,6 +8,7 @@ import { deletePost } from "./commands/delete-post";
import { manageTags, manageCategories } from "./commands/manage-taxonomy";
import { exportPostAsMarkdown, exportPostAsJson } from "./commands/export-post";
import { searchPosts } from "./commands/search-posts";
import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from "./views/sync-status-view";
import { DEFAULT_SETTINGS, type HaloSetting, HaloSettingTab, type HaloSite } from "./settings";
import HaloService from "./service";
import { openSiteSelectionModal } from "./site-selection-modal";
@@ -33,6 +34,10 @@ export default class HaloPlugin extends Plugin {
await this.publishCommand();
});
this.addRibbonIcon("sync-icon", i18next.t("ribbon_icon.sync_status"), async (evt: MouseEvent) => {
await this.openSyncStatusPanel();
});
this.addCommand({
id: "publish",
name: i18next.t("command.publish.name"),
@@ -181,7 +186,24 @@ export default class HaloPlugin extends Plugin {
},
});
this.addCommand({
id: "open-sync-status-panel",
name: i18next.t("command.open_sync_panel.name"),
callback: async () => {
await this.openSyncStatusPanel();
},
});
this.addSettingTab(new HaloSettingTab(this));
this.registerView(SYNC_STATUS_VIEW_TYPE, (leaf) => new SyncStatusView(leaf, this));
}
async openSyncStatusPanel() {
const leaf = this.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({ type: SYNC_STATUS_VIEW_TYPE });
}
}
onunload() {}
@@ -0,0 +1,91 @@
import i18next from "i18next";
import { Modal, Notice } from "obsidian";
export interface SyncHistoryItem {
id: string;
action: "publish" | "update" | "pull" | "delete";
title: string;
timestamp: number;
success: boolean;
}
export function openSyncHistoryModal(history: SyncHistoryItem[]): void {
const modal = new SyncHistoryModal(history);
modal.open();
}
class SyncHistoryModal extends Modal {
constructor(private readonly history: SyncHistoryItem[]) {
super(app);
}
async onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", {
text: i18next.t("sync_history.title"),
});
const stats = contentEl.createDiv("history-stats");
const successCount = this.history.filter(h => h.success).length;
stats.createEl("span", {
text: i18next.t("sync_history.stats", { total: this.history.length, success: successCount })
});
if (this.history.length === 0) {
contentEl.createEl("p", {
text: i18next.t("sync_history.empty"),
cls: "history-empty"
});
} else {
const list = contentEl.createDiv("history-list");
for (const item of [...this.history].reverse()) {
const itemEl = list.createDiv("history-item");
const headerEl = itemEl.createDiv("history-header");
const icon = item.success ? "✅" : "❌";
const actionText = this.getActionText(item.action);
headerEl.createEl("span", {
text: `${icon} ${actionText}`,
cls: "history-action"
});
headerEl.createEl("span", {
text: new Date(item.timestamp).toLocaleString(),
cls: "history-time"
});
const contentEl2 = itemEl.createDiv("history-content");
contentEl2.createEl("span", {
text: item.title,
cls: "history-title"
});
}
}
const actions = contentEl.createDiv("history-actions");
actions.createEl("button", {
text: i18next.t("common.button_close"),
cls: "mod-warning"
}).addEventListener("click", () => this.close());
}
private getActionText(action: string): string {
const actionMap: Record<string, string> = {
publish: i18next.t("sync_history.action_publish"),
update: i18next.t("sync_history.action_update"),
pull: i18next.t("sync_history.action_pull"),
delete: i18next.t("sync_history.action_delete"),
};
return actionMap[action] || action;
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
+240
View File
@@ -0,0 +1,240 @@
import i18next from "i18next";
import { ItemView, Notice, WorkspaceLeaf, TFile } from "obsidian";
import type HaloPlugin from "../main";
export const SYNC_STATUS_VIEW_TYPE = "halo-sync-status";
interface SyncHistoryItem {
id: string;
action: "publish" | "update" | "pull" | "delete";
title: string;
timestamp: number;
success: boolean;
}
export class SyncStatusView extends ItemView {
private plugin: HaloPlugin;
private history: SyncHistoryItem[] = [];
constructor(leaf: WorkspaceLeaf, plugin: HaloPlugin) {
super(leaf);
this.plugin = plugin;
this.loadHistory();
}
getViewType(): string {
return SYNC_STATUS_VIEW_TYPE;
}
getDisplayText(): string {
return i18next.t("sync_panel.title");
}
async onOpen() {
this.render();
}
private loadHistory() {
const stored = this.plugin.loadData();
this.history = stored?.syncHistory || [];
}
private saveHistory() {
const data = this.plugin.loadData() || {};
data.syncHistory = this.history.slice(-50);
this.plugin.saveData(data);
}
addToHistory(action: "publish" | "update" | "pull" | "delete", title: string, success: boolean) {
this.history.push({
id: Date.now().toString(),
action,
title,
timestamp: Date.now(),
success,
});
this.saveHistory();
}
private getPublishedPosts() {
const publishedPosts: Array<{
file: TFile;
title: string;
slug: string;
haloName: string;
haloSite: string;
publishStatus: boolean;
}> = [];
for (const file of this.plugin.app.vault.getFiles()) {
if (file.extension !== "md") continue;
const cache = this.plugin.app.metadataCache.getFileCache(file);
if (!cache?.frontmatter?.halo?.name) continue;
publishedPosts.push({
file,
title: cache.frontmatter.title || file.basename,
slug: cache.frontmatter.slug || "",
haloName: cache.frontmatter.halo.name,
haloSite: cache.frontmatter.halo.site || "",
publishStatus: cache.frontmatter.halo.publish ?? false,
});
}
return publishedPosts;
}
private async render() {
const container = this.containerEl;
container.empty();
const header = container.createDiv("sync-header");
header.createEl("h2", { text: i18next.t("sync_panel.title") });
const actions = header.createDiv("sync-actions");
actions.createEl("button", {
text: i18next.t("sync_panel.button_refresh"),
cls: "sync-action-btn"
}).addEventListener("click", () => this.render());
actions.createEl("button", {
text: i18next.t("sync_panel.button_history"),
cls: "sync-action-btn"
}).addEventListener("click", () => this.showHistory());
actions.createEl("button", {
text: i18next.t("sync_panel.button_clear_history"),
cls: "sync-action-btn danger"
}).addEventListener("click", () => this.clearHistory());
const posts = this.getPublishedPosts();
if (posts.length === 0) {
container.createEl("p", {
text: i18next.t("sync_panel.empty"),
cls: "sync-empty"
});
return;
}
const list = container.createDiv("sync-list");
for (const post of posts) {
const item = list.createDiv("sync-item");
const statusIcon = item.createSpan({
text: post.publishStatus ? "✅" : "📝",
cls: "sync-status-icon"
});
const info = item.createDiv("sync-info");
info.createEl("span", { text: post.title, cls: "sync-title" });
info.createEl("span", { text: `Slug: ${post.slug}`, cls: "sync-slug" });
const itemActions = item.createDiv("sync-item-actions");
itemActions.createEl("button", {
text: i18next.t("sync_panel.button_update"),
cls: "sync-item-btn"
}).addEventListener("click", async () => {
const site = this.plugin.settings.sites.find(s => s.url === post.haloSite);
if (!site) {
new Notice(i18next.t("service.error_site_not_match"));
return;
}
const { default: HaloService } = await import("../service");
const service = new HaloService(this.plugin.app, this.plugin.settings, site);
await service.updatePost();
this.addToHistory("update", post.title, true);
new Notice(i18next.t("command.update_post.success"));
this.render();
});
itemActions.createEl("button", {
text: i18next.t("sync_panel.button_pull"),
cls: "sync-item-btn"
}).addEventListener("click", async () => {
const site = this.plugin.settings.sites.find(s => s.url === post.haloSite);
if (!site) {
new Notice(i18next.t("service.error_site_not_match"));
return;
}
const { default: HaloService } = await import("../service");
const service = new HaloService(this.plugin.app, this.plugin.settings, site);
await service.pullPost(post.haloName);
this.addToHistory("pull", post.title, true);
this.render();
});
}
const stats = container.createDiv("sync-stats");
stats.createEl("span", { text: i18next.t("sync_panel.total_posts", { count: posts.length }) });
}
private showHistory() {
const modal = document.createElement("div");
modal.className = "sync-history-modal";
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
`;
const content = modal.createDiv("sync-history-content");
content.style.cssText = `
background: var(--background-primary);
padding: 20px;
border-radius: 8px;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
`;
content.createEl("h3", { text: i18next.t("sync_history.title") });
const closeBtn = content.createEl("button", { text: i18next.t("common.button_close") });
closeBtn.style.cssText = "margin-bottom: 15px;";
closeBtn.addEventListener("click", () => modal.remove());
if (this.history.length === 0) {
content.createEl("p", { text: i18next.t("sync_history.empty") });
} else {
const list = content.createDiv("sync-history-list");
for (const item of [...this.history].reverse()) {
const historyItem = list.createDiv("history-item");
const time = new Date(item.timestamp).toLocaleString();
const actionText = i18next.t(`sync_history.action_${item.action}`);
const icon = item.success ? "✅" : "❌";
historyItem.createEl("span", { text: `${icon} ${actionText}: ${item.title}` });
historyItem.createEl("span", { text: time, cls: "history-time" });
}
}
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.remove();
});
document.body.appendChild(modal);
}
private clearHistory() {
if (confirm(i18next.t("sync_history.confirm_clear"))) {
this.history = [];
this.saveHistory();
this.render();
new Notice(i18next.t("sync_history.notice_cleared"));
}
}
}