update at 2025-09-25 22:35:01

This commit is contained in:
douboer
2025-09-25 22:35:01 +08:00
parent a60cdd593c
commit c9ce811ce9
11 changed files with 1192 additions and 21 deletions

600
src/batch-publish-modal.ts Normal file
View File

@@ -0,0 +1,600 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { App, Modal, Setting, TFile, Notice, ButtonComponent } from 'obsidian';
import { BatchArticleFilter, BatchFilterConfig } from './batch-filter';
import NoteToMpPlugin from './main';
export class BatchPublishModal extends Modal {
plugin: NoteToMpPlugin;
filter: BatchArticleFilter;
filteredFiles: TFile[] = [];
selectedFiles: Set<TFile> = new Set();
// UI 元素
private filterContainer: HTMLElement;
private resultsContainer: HTMLElement;
private publishButton: ButtonComponent;
// 鼠标框选相关
private isSelecting = false;
private selectionStart: { x: number; y: number } | null = null;
private selectionBox: HTMLElement | null = null;
private isCtrlPressed = false; // 跟踪 Ctrl 键状态
// 筛选配置
private filterConfig: BatchFilterConfig = {
conditions: [
{
type: 'folder',
operator: 'contains',
value: 'content/post'
}
],
logic: 'and',
orderBy: 'name',
orderDirection: 'asc'
};
constructor(app: App, plugin: NoteToMpPlugin) {
super(app);
this.plugin = plugin;
this.filter = new BatchArticleFilter(app);
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('batch-publish-modal');
// 设置模态框的整体布局
contentEl.style.display = 'flex';
contentEl.style.flexDirection = 'column';
contentEl.style.height = '80vh';
contentEl.style.maxHeight = '600px';
// 标题
contentEl.createEl('h2', { text: '批量发布到公众号' });
// 筛选条件区域
this.filterContainer = contentEl.createDiv('filter-container');
this.createFilterUI();
// 结果展示区域(可滚动)
this.resultsContainer = contentEl.createDiv('results-container');
this.resultsContainer.style.flex = '1';
this.resultsContainer.style.overflow = 'hidden';
this.resultsContainer.style.display = 'flex';
this.resultsContainer.style.flexDirection = 'column';
// 操作按钮(固定在底部)
const buttonContainer = contentEl.createDiv('button-container');
buttonContainer.style.marginTop = '20px';
buttonContainer.style.textAlign = 'center';
buttonContainer.style.paddingTop = '15px';
buttonContainer.style.borderTop = '1px solid var(--background-modifier-border)';
buttonContainer.style.flexShrink = '0';
new ButtonComponent(buttonContainer)
.setButtonText('应用筛选')
.setCta()
.onClick(() => this.applyFilter());
this.publishButton = new ButtonComponent(buttonContainer)
.setButtonText('发布选中文章 (0)')
.setDisabled(true)
.onClick(() => this.publishSelected());
new ButtonComponent(buttonContainer)
.setButtonText('取消')
.onClick(() => this.close());
// 初始加载所有文章
this.applyFilter();
}
/**
* 创建筛选条件界面
*/
private createFilterUI() {
this.filterContainer.empty();
// 标签筛选
new Setting(this.filterContainer)
.setName('按标签筛选')
.setDesc('输入要筛选的标签名称')
.addText(text => {
const tagCondition = this.filterConfig.conditions.find(c => c.type === 'tag');
text.setPlaceholder('如: 篆刻')
.setValue(tagCondition?.value || '')
.onChange(value => {
this.updateTagCondition(value);
});
// 添加回车键监听
text.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.applyFilter();
}
});
});
// 文件名筛选
new Setting(this.filterContainer)
.setName('按文件名筛选')
.setDesc('输入文件名关键词')
.addText(text => {
const nameCondition = this.filterConfig.conditions.find(c => c.type === 'filename');
text.setPlaceholder('如: 故事')
.setValue(nameCondition?.value || '')
.onChange(value => {
this.updateFilenameCondition(value);
});
// 添加回车键监听
text.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.applyFilter();
}
});
});
// 文件夹筛选
new Setting(this.filterContainer)
.setName('按文件夹筛选')
.setDesc('输入文件夹路径')
.addText(text => {
const folderCondition = this.filterConfig.conditions.find(c => c.type === 'folder');
text.setPlaceholder('如: content/post')
.setValue(folderCondition?.value || 'content/post')
.onChange(value => {
this.updateFolderCondition(value);
});
// 添加回车键监听
text.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.applyFilter();
}
});
});
// 排序选项
new Setting(this.filterContainer)
.setName('排序方式')
.setDesc('选择文章排序方式')
.addDropdown(dropdown => {
dropdown.addOption('name', '按文件名')
.addOption('created', '按创建时间')
.addOption('modified', '按修改时间')
.setValue(this.filterConfig.orderBy || 'name')
.onChange(value => {
this.filterConfig.orderBy = value as any;
});
})
.addDropdown(dropdown => {
dropdown.addOption('asc', '升序')
.addOption('desc', '降序')
.setValue(this.filterConfig.orderDirection || 'asc')
.onChange(value => {
this.filterConfig.orderDirection = value as any;
});
});
}
/**
* 更新标签条件
*/
private updateTagCondition(value: string) {
// 移除现有的标签条件
this.filterConfig.conditions = this.filterConfig.conditions.filter(c => c.type !== 'tag');
if (value.trim()) {
this.filterConfig.conditions.push({
type: 'tag',
operator: 'contains',
value: value.trim()
});
}
}
/**
* 更新文件名条件
*/
private updateFilenameCondition(value: string) {
this.filterConfig.conditions = this.filterConfig.conditions.filter(c => c.type !== 'filename');
if (value.trim()) {
this.filterConfig.conditions.push({
type: 'filename',
operator: 'contains',
value: value.trim()
});
}
}
/**
* 更新文件夹条件
*/
private updateFolderCondition(value: string) {
this.filterConfig.conditions = this.filterConfig.conditions.filter(c => c.type !== 'folder');
if (value.trim()) {
this.filterConfig.conditions.push({
type: 'folder',
operator: 'contains',
value: value.trim()
});
}
}
/**
* 应用筛选条件
*/
private async applyFilter() {
try {
this.filteredFiles = await this.filter.filterArticles(this.filterConfig);
this.selectedFiles.clear();
this.displayResults();
this.updatePublishButton();
} catch (error) {
new Notice('筛选文章时出错: ' + error.message);
console.error(error);
}
}
/**
* 显示筛选结果
*/
private displayResults() {
this.resultsContainer.empty();
if (this.filteredFiles.length === 0) {
this.resultsContainer.createEl('p', {
text: '未找到匹配的文章',
cls: 'no-results'
});
return;
}
// 统计信息
const statsEl = this.resultsContainer.createDiv('results-stats');
statsEl.textContent = `找到 ${this.filteredFiles.length} 篇文章`;
statsEl.style.flexShrink = '0';
// 全选/取消全选
const selectAllContainer = this.resultsContainer.createDiv('select-all-container');
selectAllContainer.style.flexShrink = '0';
const selectAllCheckbox = selectAllContainer.createEl('input', {
type: 'checkbox'
});
selectAllContainer.createSpan({ text: ' 全选/取消全选' });
selectAllCheckbox.addEventListener('change', () => {
if (selectAllCheckbox.checked) {
this.filteredFiles.forEach(file => this.selectedFiles.add(file));
} else {
this.selectedFiles.clear();
}
this.updateCheckboxes();
this.updatePublishButton();
});
// 文章列表(可滚动区域)
const listContainer = this.resultsContainer.createDiv('articles-list');
listContainer.style.flex = '1';
listContainer.style.overflowY = 'auto';
listContainer.style.border = '1px solid var(--background-modifier-border)';
listContainer.style.borderRadius = '4px';
listContainer.style.padding = '10px';
listContainer.style.position = 'relative';
listContainer.style.userSelect = 'none'; // 禁用文本选择
// 添加鼠标框选功能
this.setupMouseSelection(listContainer);
this.filteredFiles.forEach(file => {
const itemEl = listContainer.createDiv('article-item');
itemEl.style.display = 'flex';
itemEl.style.alignItems = 'center';
itemEl.style.padding = '5px 0';
itemEl.style.borderBottom = '1px solid var(--background-modifier-border-hover)';
itemEl.style.cursor = 'pointer';
itemEl.setAttribute('data-file-path', file.path);
const checkbox = itemEl.createEl('input', {
type: 'checkbox'
});
checkbox.style.marginRight = '10px';
const titleEl = itemEl.createSpan({ text: file.basename });
titleEl.style.flex = '1';
const pathEl = itemEl.createEl('small', { text: file.path });
pathEl.style.color = 'var(--text-muted)';
pathEl.style.marginLeft = '10px';
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
this.selectedFiles.add(file);
} else {
this.selectedFiles.delete(file);
}
this.updatePublishButton();
});
// 点击整行也能选择
itemEl.addEventListener('click', (e) => {
if (e.target !== checkbox) {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
}
});
// 存储引用以便后续更新
(checkbox as any)._file = file;
});
}
/**
* 更新所有复选框状态
*/
private updateCheckboxes() {
const checkboxes = this.resultsContainer.querySelectorAll('.articles-list input[type="checkbox"]');
checkboxes.forEach(checkbox => {
const file = (checkbox as any)._file;
if (file) {
(checkbox as HTMLInputElement).checked = this.selectedFiles.has(file);
}
});
}
/**
* 更新发布按钮
*/
private updatePublishButton() {
const count = this.selectedFiles.size;
this.publishButton.setButtonText(`发布选中文章 (${count})`);
this.publishButton.setDisabled(count === 0);
}
/**
* 发布选中的文章
*/
private async publishSelected() {
if (this.selectedFiles.size === 0) {
new Notice('请选择要发布的文章');
return;
}
const files = Array.from(this.selectedFiles);
const total = files.length;
let completed = 0;
let failed = 0;
// 显示进度
const notice = new Notice(`开始批量发布 ${total} 篇文章...`, 0);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
// 更新进度
notice.setMessage(`正在发布: ${file.basename} (${i + 1}/${total})`);
// 激活预览视图并发布
await this.plugin.activateView();
const preview = this.plugin.getNotePreview();
if (preview) {
await preview.renderMarkdown(file);
await preview.postArticle();
completed++;
} else {
throw new Error('无法获取预览视图');
}
// 避免请求过于频繁
if (i < files.length - 1) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
console.error(`发布文章 ${file.basename} 失败:`, error);
failed++;
}
}
// 显示最终结果
notice.hide();
new Notice(`批量发布完成!成功: ${completed} 篇,失败: ${failed}`);
if (completed > 0) {
this.close();
}
} catch (error) {
notice.hide();
new Notice('批量发布过程中出错: ' + error.message);
console.error(error);
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
// 清理鼠标框选相关的事件监听器
this.cleanupMouseSelection();
}
/**
* 设置鼠标框选功能
*/
private setupMouseSelection(container: HTMLElement) {
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // 只响应左键
if (e.target !== container && !(e.target as HTMLElement).closest('.article-item')) {
return;
}
this.isSelecting = true;
this.isCtrlPressed = e.ctrlKey || e.metaKey; // 检测 Ctrl 键Mac 上是 Cmd 键)
const containerRect = container.getBoundingClientRect();
this.selectionStart = {
x: e.clientX - containerRect.left + container.scrollLeft,
y: e.clientY - containerRect.top + container.scrollTop
};
// 创建选择框
this.selectionBox = document.createElement('div');
this.selectionBox.style.position = 'absolute';
this.selectionBox.style.border = '1px dashed var(--interactive-accent)';
this.selectionBox.style.backgroundColor = 'var(--interactive-accent-hover)';
this.selectionBox.style.opacity = '0.3';
this.selectionBox.style.zIndex = '1000';
this.selectionBox.style.pointerEvents = 'none';
// Ctrl 键时使用不同的视觉样式表示取消选中模式
if (this.isCtrlPressed) {
this.selectionBox.style.border = '1px dashed var(--text-error)';
this.selectionBox.style.backgroundColor = 'var(--background-modifier-error-hover)';
}
container.appendChild(this.selectionBox);
e.preventDefault();
});
container.addEventListener('mousemove', (e) => {
if (!this.isSelecting || !this.selectionStart || !this.selectionBox) return;
const containerRect = container.getBoundingClientRect();
const currentX = e.clientX - containerRect.left + container.scrollLeft;
const currentY = e.clientY - containerRect.top + container.scrollTop;
const left = Math.min(this.selectionStart.x, currentX);
const top = Math.min(this.selectionStart.y, currentY);
const width = Math.abs(currentX - this.selectionStart.x);
const height = Math.abs(currentY - this.selectionStart.y);
this.selectionBox.style.left = left + 'px';
this.selectionBox.style.top = top + 'px';
this.selectionBox.style.width = width + 'px';
this.selectionBox.style.height = height + 'px';
// 检测哪些文件项在选择框内
this.updateSelectionByBox(container, left, top, width, height);
});
container.addEventListener('mouseup', () => {
if (this.isSelecting) {
this.isSelecting = false;
this.selectionStart = null;
this.isCtrlPressed = false;
if (this.selectionBox) {
this.selectionBox.remove();
this.selectionBox = null;
}
this.updatePublishButton();
}
});
// 防止拖拽离开容器时无法结束选择
document.addEventListener('mouseup', () => {
if (this.isSelecting) {
this.isSelecting = false;
this.selectionStart = null;
this.isCtrlPressed = false;
if (this.selectionBox) {
this.selectionBox.remove();
this.selectionBox = null;
}
}
});
}
/**
* 根据选择框更新文件选择状态
*/
private updateSelectionByBox(container: HTMLElement, boxLeft: number, boxTop: number, boxWidth: number, boxHeight: number) {
const items = container.querySelectorAll('.article-item');
items.forEach(item => {
const itemEl = item as HTMLElement;
// 获取元素相对于容器的位置(考虑滚动)
let itemTop = 0;
let currentEl = itemEl;
while (currentEl && currentEl !== container) {
itemTop += currentEl.offsetTop;
currentEl = currentEl.offsetParent as HTMLElement;
}
const itemLeft = itemEl.offsetLeft;
const itemRight = itemLeft + itemEl.offsetWidth;
const itemBottom = itemTop + itemEl.offsetHeight;
const boxRight = boxLeft + boxWidth;
const boxBottom = boxTop + boxHeight;
// 检测是否有重叠
const isIntersecting = !(itemRight < boxLeft ||
itemLeft > boxRight ||
itemBottom < boxTop ||
itemTop > boxBottom);
if (isIntersecting) {
const checkbox = itemEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
const file = (checkbox as any)._file as TFile;
if (checkbox && file) {
if (this.isCtrlPressed) {
// Ctrl+框选:取消选中
checkbox.checked = false;
this.selectedFiles.delete(file);
} else {
// 普通框选:选中
checkbox.checked = true;
this.selectedFiles.add(file);
}
}
}
});
}
/**
* 清理鼠标框选相关资源
*/
private cleanupMouseSelection() {
if (this.selectionBox) {
this.selectionBox.remove();
this.selectionBox = null;
}
this.isSelecting = false;
this.selectionStart = null;
}
}