/** * 文件:batch-publish-modal.ts * 功能:批量发布模态窗口;支持文件夹 / 多文件选择 + 多平台勾选。 * - 文件列表与过滤 * - 平台选择(公众号 / 小红书) * - 批量触发发布逻辑 */ import { App, Modal, Setting, TFile, Notice, ButtonComponent } from 'obsidian'; import { BatchArticleFilter, BatchFilterConfig } from './batch-filter'; import Note2AnyPlugin from './main'; // 小红书功能模块 import { XiaohongshuContentAdapter } from './xiaohongshu/adapter'; import { XiaohongshuAPIManager } from './xiaohongshu/api'; /** * BatchPublishModal * * 说明(中文注释): * 该模块负责提供一个在 Obsidian 中的模态窗口,用于: * - 根据多个条件筛选笔记(标签、文件名、文件夹、frontmatter 等) * - 在筛选结果中支持单选/多选/框选(鼠标拖拽)操作 * - 将选中的文章逐条发送到公众号(通过 NotePreview 的 renderMarkdown/postArticle 流程) * * 设计要点: * - 使用 `BatchArticleFilter` 复用筛选逻辑,返回已排序的 `TFile[]` 列表 * - 用 `Set` 保存选中状态,避免重复项并方便计数 * - 鼠标拖拽选择支持两种模式:常规拖拽为“添加选择”,按 Ctrl/Cmd 拖拽为“取消选择” * - 发布流程为顺序异步执行,发送间隔有短延迟以降低请求频率 * * 注意事项: * - 该文件以用户交互为主,敏感的网络/身份验证交互在 `note-preview` 模块内处理 * - 对 DOM 的位置计算要考虑容器滚动偏移(scrollLeft/scrollTop),已在代码中处理 */ export class BatchPublishModal extends Modal { plugin: Note2AnyPlugin; filter: BatchArticleFilter; filteredFiles: TFile[] = []; selectedFiles: Set = new Set(); // UI 元素 private filterContainer: HTMLElement; private resultsContainer: HTMLElement; private publishButton: ButtonComponent; // 平台选择相关(新增) private wechatCheckbox: HTMLInputElement; private xiaohongshuCheckbox: HTMLInputElement; private allPlatformsCheckbox: HTMLInputElement; // 鼠标框选相关 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: Note2AnyPlugin) { 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'; // 发布平台选择(新增) const platformContainer = buttonContainer.createDiv('platform-select-container'); platformContainer.style.marginBottom = '15px'; platformContainer.style.display = 'flex'; platformContainer.style.alignItems = 'center'; platformContainer.style.justifyContent = 'center'; platformContainer.style.gap = '10px'; const platformLabel = platformContainer.createSpan(); platformLabel.innerText = '发布到: '; const wechatCheckbox = platformContainer.createEl('input', { type: 'checkbox' }); wechatCheckbox.id = 'publish-wechat'; wechatCheckbox.checked = true; this.wechatCheckbox = wechatCheckbox; const wechatLabel = platformContainer.createEl('label'); wechatLabel.setAttribute('for', 'publish-wechat'); wechatLabel.innerText = '微信公众号'; wechatLabel.style.marginRight = '15px'; const xiaohongshuCheckbox = platformContainer.createEl('input', { type: 'checkbox' }); xiaohongshuCheckbox.id = 'publish-xiaohongshu'; this.xiaohongshuCheckbox = xiaohongshuCheckbox; const xiaohongshuLabel = platformContainer.createEl('label'); xiaohongshuLabel.setAttribute('for', 'publish-xiaohongshu'); xiaohongshuLabel.innerText = '小红书'; xiaohongshuLabel.style.marginRight = '15px'; const allPlatformsCheckbox = platformContainer.createEl('input', { type: 'checkbox' }); allPlatformsCheckbox.id = 'publish-all'; this.allPlatformsCheckbox = allPlatformsCheckbox; const allPlatformsLabel = platformContainer.createEl('label'); allPlatformsLabel.setAttribute('for', 'publish-all'); allPlatformsLabel.innerText = '全部平台'; // 全部平台checkbox的联动逻辑 allPlatformsCheckbox.addEventListener('change', () => { if (allPlatformsCheckbox.checked) { wechatCheckbox.checked = true; xiaohongshuCheckbox.checked = true; } }); // 单个平台checkbox的联动逻辑 const updateAllPlatforms = () => { if (wechatCheckbox.checked && xiaohongshuCheckbox.checked) { allPlatformsCheckbox.checked = true; } else { allPlatformsCheckbox.checked = false; } }; wechatCheckbox.addEventListener('change', updateAllPlatforms); xiaohongshuCheckbox.addEventListener('change', updateAllPlatforms); 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 platforms = this.getSelectedPlatforms(); if (platforms.length === 0) { new Notice('请选择至少一个发布平台'); return; } const files = Array.from(this.selectedFiles); const totalTasks = files.length * platforms.length; let completed = 0; let failed = 0; // 显示进度 const notice = new Notice(`开始批量发布 ${files.length} 篇文章到 ${platforms.join('、')}...`, 0); try { for (let i = 0; i < files.length; i++) { const file = files[i]; for (const platform of platforms) { try { // 更新进度 const taskIndex = i * platforms.length + platforms.indexOf(platform) + 1; notice.setMessage(`正在发布: ${file.basename} 到 ${platform} (${taskIndex}/${totalTasks})`); if (platform === '微信公众号') { await this.publishToWechat(file); } else if (platform === '小红书') { await this.publishToXiaohongshu(file); } completed++; } catch (error) { console.error(`发布文章 ${file.basename} 到 ${platform} 失败:`, error); failed++; } // 避免请求过于频繁 const taskIndex = i * platforms.length + platforms.indexOf(platform) + 1; if (taskIndex < totalTasks) { await new Promise(resolve => setTimeout(resolve, 2000)); } } } // 显示最终结果 notice.hide(); new Notice(`批量发布完成!成功: ${completed} 个任务,失败: ${failed} 个任务`); } catch (error) { notice.hide(); new Notice('批量发布过程中发生错误: ' + error.message); console.error('批量发布错误:', error); } } /** * 获取选择的发布平台 */ private getSelectedPlatforms(): string[] { const platforms: string[] = []; if (this.wechatCheckbox.checked) { platforms.push('微信公众号'); } if (this.xiaohongshuCheckbox.checked) { platforms.push('小红书'); } return platforms; } /** * 发布到微信公众号 */ private async publishToWechat(file: TFile): Promise { // TODO: 重构后需要重新实现批量发布到微信 // 激活预览视图并发布 await this.plugin.activateView(); const preview = this.plugin.getNotePreview(); if (preview) { // 临时方案:直接打开文件让用户手动发布 await preview.setFile(file); throw new Error('批量发布功能正在重构中,请在预览视图中手动发布'); } else { throw new Error('无法获取预览视图'); } } /** * 发布到小红书 */ private async publishToXiaohongshu(file: TFile): Promise { try { // 读取文件内容 const fileContent = await this.app.vault.read(file); // 使用小红书适配器转换内容 const adapter = new XiaohongshuContentAdapter(); const xiaohongshuPost = adapter.adaptMarkdownToXiaohongshu(fileContent, { addStyle: true, generateTitle: true }); // 验证内容 const validation = adapter.validatePost(xiaohongshuPost); if (!validation.valid) { throw new Error('内容验证失败: ' + validation.errors.join('; ')); } // 获取小红书API实例 const api = XiaohongshuAPIManager.getInstance(false); // 检查登录状态 const isLoggedIn = await api.checkLoginStatus(); if (!isLoggedIn) { throw new Error('小红书未登录,请在预览界面登录后再试'); } // 发布内容 const result = await api.createPost(xiaohongshuPost); if (!result.success) { throw new Error(result.message); } } catch (error) { throw new Error(`发布到小红书失败: ${error.message}`); } } 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; } }