Files
note2any/src/batch-publish-modal.ts
2025-10-08 19:45:28 +08:00

748 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 文件batch-publish-modal.ts
* 功能:批量发布模态窗口;支持文件夹 / 多文件选择 + 多平台勾选。
* - 文件列表与过滤
* - 平台选择(公众号 / 小红书)
* - 批量触发发布逻辑
*/
import { App, Modal, Setting, TFile, Notice, ButtonComponent } from 'obsidian';
import { BatchArticleFilter, BatchFilterConfig } from './batch-filter';
import NoteToMpPlugin from './main';
// 小红书功能模块
import { XiaohongshuContentAdapter } from './xiaohongshu/adapter';
import { XiaohongshuAPIManager } from './xiaohongshu/api';
/**
* BatchPublishModal
*
* 说明(中文注释):
* 该模块负责提供一个在 Obsidian 中的模态窗口,用于:
* - 根据多个条件筛选笔记标签、文件名、文件夹、frontmatter 等)
* - 在筛选结果中支持单选/多选/框选(鼠标拖拽)操作
* - 将选中的文章逐条发送到公众号(通过 NotePreview 的 renderMarkdown/postArticle 流程)
*
* 设计要点:
* - 使用 `BatchArticleFilter` 复用筛选逻辑,返回已排序的 `TFile[]` 列表
* - 用 `Set<TFile>` 保存选中状态,避免重复项并方便计数
* - 鼠标拖拽选择支持两种模式:常规拖拽为“添加选择”,按 Ctrl/Cmd 拖拽为“取消选择”
* - 发布流程为顺序异步执行,发送间隔有短延迟以降低请求频率
*
* 注意事项:
* - 该文件以用户交互为主,敏感的网络/身份验证交互在 `note-preview` 模块内处理
* - 对 DOM 的位置计算要考虑容器滚动偏移scrollLeft/scrollTop已在代码中处理
*/
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 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: 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';
// 发布平台选择(新增)
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<void> {
// 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<void> {
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;
}
}