update at 2025-10-09 12:39:24

This commit is contained in:
douboer
2025-10-09 12:39:24 +08:00
parent a891153be0
commit 6f51916b50
44 changed files with 332 additions and 226 deletions

View File

@@ -53,7 +53,7 @@ export class PlatformChooser {
constructor(container: HTMLElement, options: PlatformChooserOptions = {}) {
this.container = container;
this.currentPlatform = options.defaultPlatform || 'wechat';
this.currentPlatform = options.defaultPlatform || 'xiaohongshu';
if (options.onPlatformChange) {
this.onChange = (platform) => {
options.onPlatformChange!(platform);
@@ -72,15 +72,16 @@ export class PlatformChooser {
* 渲染平台选择器 UI
*/
render(): void {
// 创建平台选择行
const lineDiv = this.container.createDiv({ cls: 'toolbar-line platform-selector-line' });
// 将容器作为单层 Grid 行使用
this.container.addClass('platform-selector-line');
this.container.addClass('platform-chooser-grid');
// 创建标签
const platformLabel = lineDiv.createDiv({ cls: 'style-label' });
const platformLabel = this.container.createDiv({ cls: 'style-label' });
platformLabel.innerText = '发布平台';
// 创建选择器
const platformSelect = lineDiv.createEl('select', { cls: 'platform-select' });
const platformSelect = this.container.createEl('select', { cls: 'platform-select' });
this.selectElement = platformSelect;
// 添加平台选项

View File

@@ -37,7 +37,7 @@ export class PreviewManager {
private xhsContainer: HTMLDivElement | null = null;
// 状态
private currentPlatform: PlatformType = 'wechat';
private currentPlatform: PlatformType = 'xiaohongshu';
private currentFile: TFile | null = null;
constructor(container: HTMLElement, app: App, render: ArticleRender) {
@@ -68,8 +68,8 @@ export class PreviewManager {
// 3. 创建并构建小红书预览
this.createXiaohongshuPreview();
// 4. 初始显示微信平台
await this.switchPlatform('wechat');
// 4. 初始显示小红书平台
await this.switchPlatform('xiaohongshu');
console.log('[PreviewManager] 界面构建完成');
}

View File

@@ -51,6 +51,7 @@ export class NMPSettings {
sliceImageSavePath: string; // 切图保存路径
sliceImageWidth: number; // 切图宽度(像素)
sliceImageAspectRatio: string; // 横竖比例,格式 "3:4"
xhsPreviewWidth: number; // 小红书预览宽度(像素)
private static instance: NMPSettings;
@@ -100,6 +101,7 @@ export class NMPSettings {
this.sliceImageSavePath = '/Users/gavin/note2mp/images/xhs';
this.sliceImageWidth = 1080;
this.sliceImageAspectRatio = '3:4';
this.xhsPreviewWidth = 540;
}
resetStyelAndHighlight() {
@@ -136,7 +138,8 @@ export class NMPSettings {
batchPublishPresets = [],
sliceImageSavePath,
sliceImageWidth,
sliceImageAspectRatio
sliceImageAspectRatio,
xhsPreviewWidth
} = data;
const settings = NMPSettings.getInstance();
@@ -166,6 +169,9 @@ export class NMPSettings {
if (sliceImageSavePath) settings.sliceImageSavePath = sliceImageSavePath;
if (sliceImageWidth !== undefined && Number.isFinite(sliceImageWidth)) settings.sliceImageWidth = Math.max(100, parseInt(sliceImageWidth));
if (sliceImageAspectRatio) settings.sliceImageAspectRatio = sliceImageAspectRatio;
if (xhsPreviewWidth !== undefined && Number.isFinite(xhsPreviewWidth)) {
settings.xhsPreviewWidth = Math.max(100, parseInt(xhsPreviewWidth));
}
settings.getExpiredDate();
settings.isLoaded = true;
@@ -200,6 +206,7 @@ export class NMPSettings {
'sliceImageSavePath': settings.sliceImageSavePath,
'sliceImageWidth': settings.sliceImageWidth,
'sliceImageAspectRatio': settings.sliceImageAspectRatio,
'xhsPreviewWidth': settings.xhsPreviewWidth,
}
}
@@ -221,4 +228,4 @@ export class NMPSettings {
if (this.expireat == null) return false;
return this.expireat > new Date();
}
}
}

View File

@@ -93,25 +93,23 @@ export class WechatPreview {
* 构建工具栏
*/
private buildToolbar(parent: HTMLDivElement): void {
let lineDiv;
// 单层 Grid所有控件直接挂到 parent.preview-toolbar
// 公众号选择
if (this.settings.wxInfo.length > 1 || Platform.isDesktop) {
lineDiv = parent.createDiv({ cls: 'toolbar-line' });
const wxLabel = lineDiv.createDiv({ cls: 'style-label' });
const wxLabel = parent.createDiv({ cls: 'style-label' });
wxLabel.innerText = '公众号';
const wxSelect = lineDiv.createEl('select', { cls: 'wechat-select' });
const wxSelect = parent.createEl('select', { cls: 'wechat-select' });
wxSelect.onchange = async () => {
this.currentAppId = wxSelect.value;
this.onAppIdChanged();
};
const defaultOp = wxSelect.createEl('option');
defaultOp.value = '';
defaultOp.text = '请在设置里配置公众号';
for (let i = 0; i < this.settings.wxInfo.length; i++) {
const op = wxSelect.createEl('option');
const wx = this.settings.wxInfo[i];
@@ -125,8 +123,8 @@ export class WechatPreview {
this.wechatSelect = wxSelect;
if (Platform.isDesktop) {
const separator = lineDiv.createDiv({ cls: 'toolbar-separator' });
const openBtn = lineDiv.createEl('button', { text: '🌐 去公众号后台', cls: 'toolbar-button purple-gradient' });
//parent.createDiv({ cls: 'toolbar-separator' });
const openBtn = parent.createEl('button', { text: '🌐 去公众号后台', cls: 'toolbar-button purple-gradient' });
openBtn.onclick = async () => {
const { shell } = require('electron');
shell.openExternal('https://mp.weixin.qq.com');
@@ -135,21 +133,15 @@ export class WechatPreview {
}
}
// 操作按钮
lineDiv = parent.createDiv({ cls: 'toolbar-line flex-wrap' });
const refreshBtn = lineDiv.createEl('button', { text: '🔄 刷新', cls: 'toolbar-button purple-gradient' });
refreshBtn.onclick = async () => {
if (this.onRefreshCallback) {
await this.onRefreshCallback();
}
};
// 操作按钮(直接平铺在 Grid 中)
const refreshBtn = parent.createEl('button', { text: '🔄 刷新', cls: 'toolbar-button purple-gradient' });
refreshBtn.onclick = async () => { if (this.onRefreshCallback) await this.onRefreshCallback(); };
const postBtn = lineDiv.createEl('button', { text: '📝 发布', cls: 'toolbar-button' });
const postBtn = parent.createEl('button', { text: '📝 发布', cls: 'toolbar-button' });
postBtn.onclick = async () => await this.postArticle();
if (Platform.isDesktop && this.settings.isAuthKeyVaild()) {
const htmlBtn = lineDiv.createEl('button', { text: '💾 导出HTML', cls: 'toolbar-button' });
const htmlBtn = parent.createEl('button', { text: '💾 导出HTML', cls: 'toolbar-button' });
htmlBtn.onclick = async () => await this.exportHTML();
}
@@ -166,11 +158,10 @@ export class WechatPreview {
* 构建封面选择器
*/
private buildCoverSelector(parent: HTMLDivElement): void {
const lineDiv = parent.createDiv({ cls: 'toolbar-line' });
const coverTitle = lineDiv.createDiv({ cls: 'style-label' });
const coverTitle = parent.createDiv({ cls: 'style-label' });
coverTitle.innerText = '封面';
this.useDefaultCover = lineDiv.createEl('input', { cls: 'input-style' });
this.useDefaultCover = parent.createEl('input', { cls: 'input-style' });
this.useDefaultCover.setAttr('type', 'radio');
this.useDefaultCover.setAttr('name', 'cover');
this.useDefaultCover.setAttr('value', 'default');
@@ -182,11 +173,11 @@ export class WechatPreview {
}
};
const defaultLabel = lineDiv.createEl('label');
const defaultLabel = parent.createEl('label');
defaultLabel.innerText = '默认';
defaultLabel.setAttr('for', 'default-cover');
this.useLocalCover = lineDiv.createEl('input', { cls: 'input-style' });
this.useLocalCover = parent.createEl('input', { cls: 'input-style' });
this.useLocalCover.setAttr('type', 'radio');
this.useLocalCover.setAttr('name', 'cover');
this.useLocalCover.setAttr('value', 'local');
@@ -198,11 +189,11 @@ export class WechatPreview {
}
};
const localLabel = lineDiv.createEl('label');
const localLabel = parent.createEl('label');
localLabel.setAttr('for', 'local-cover');
localLabel.innerText = '上传';
this.coverEl = lineDiv.createEl('input', { cls: 'upload-input' });
this.coverEl = parent.createEl('input', { cls: 'upload-input' });
this.coverEl.setAttr('type', 'file');
this.coverEl.setAttr('placeholder', '封面图片');
this.coverEl.setAttr('accept', '.png, .jpg, .jpeg');
@@ -214,12 +205,10 @@ export class WechatPreview {
* 构建样式选择器
*/
private buildStyleSelector(parent: HTMLDivElement): void {
const lineDiv = parent.createDiv({ cls: 'toolbar-line flex-wrap' });
const cssStyle = lineDiv.createDiv({ cls: 'style-label' });
const cssStyle = parent.createDiv({ cls: 'style-label' });
cssStyle.innerText = '样式';
const selectBtn = lineDiv.createEl('select', { cls: 'style-select' });
const selectBtn = parent.createEl('select', { cls: 'style-select' });
selectBtn.onchange = async () => {
this.currentTheme = selectBtn.value;
this.render.updateStyle(selectBtn.value);
@@ -233,12 +222,12 @@ export class WechatPreview {
}
this.themeSelect = selectBtn;
const separator = lineDiv.createDiv({ cls: 'toolbar-separator' });
parent.createDiv({ cls: 'toolbar-separator' });
const highlightStyle = lineDiv.createDiv({ cls: 'style-label' });
const highlightStyle = parent.createDiv({ cls: 'style-label' });
highlightStyle.innerText = '代码高亮';
const highlightStyleBtn = lineDiv.createEl('select', { cls: 'style-select' });
const highlightStyleBtn = parent.createEl('select', { cls: 'style-select' });
highlightStyleBtn.onchange = async () => {
this.currentHighlight = highlightStyleBtn.value;
this.render.updateHighLight(highlightStyleBtn.value);

View File

@@ -1,72 +0,0 @@
# 小红书自动化发布机制说明
## 1. 结构化 CSS 选择器
集中存放于 `selectors.ts`,按功能分类:
- ENTRY入口区域视频/图文选择)
- PUBLISH_TAB主发布 Tab视频 or 图片)
- VIDEO视频发布流程元素
- IMAGE图文发布流程元素
修改页面结构时,仅需维护该文件。
## 2. 发布流程自动化方法api.ts
| 方法 | 作用 |
|------|------|
| openPublishEntry | 打开发布入口页面 |
| selectPublishTab | 切换到视频 or 图文 Tab |
| triggerMediaUpload | 触发上传入口(不处理系统文件对话框)|
| fillTitleAndContent | 并行填写标题与正文(不阻塞上传)|
| choosePublishMode | 选择立即发布或定时(暂实现立即)|
| waitForUploadSuccess | 轮询等待“上传成功”文案出现 |
| clickPublishButton | 点击发布按钮 |
| publishViaAutomation | 高层封装:一键执行完整流程 |
| saveCookies | 将 document.cookie 简单保存到 localStorage |
| restoreCookies | 从 localStorage 写回 cookie仅适合简单会话|
| ensureSession | 恢复并检测是否仍已登录 |
## 3. 异步上传策略
- 上传触发后立即并行执行:填写标题 + 填写正文 + 设置发布模式
- 独立等待“上传成功”文案出现(最大 180s
- 提供扩展点:可替换为 MutationObserver
## 4. Cookies 会话保持策略
当前采用简化方案:
1. 登录后或发布点击后调用 `saveCookies()``document.cookie` 原始串写入 localStorage。
2. 下次调用 `ensureSession()` 时:
- 打开发布页
- `restoreCookies()` 将简单 key=value 还原
- 检查是否仍已登录(调用 `checkLoginStatus()`
局限:
- 无法还原 HttpOnly / 过期属性 / 域等
- 真实长期稳定需使用:
- Electron session APIs如 webContents.session.cookies.get/set
- 或在本地插件存储中序列化 cookie 条目
## 5. 待优化建议
- 增加前端 Hook上传完成事件触发后立即发布
- 增加失败重试,比如发布按钮未出现时二次尝试选择 Tab
- 图文上传成功 DOM 精细化判断
- 支持定时发布scheduleTime 入参)
- 支持话题 / 地址选择自动化
## 6. 示例调用
```ts
await api.publishViaAutomation({
type: 'video',
title: '测试标题',
content: '正文内容...',
immediate: true
});
```
## 7. 风险提示
| 风险 | 描述 | 处理建议 |
|------|------|----------|
| DOM 变动 | 页面结构变化导致选择器失效 | 增加多选择器冗余 + 容错 |
| 登录失效 | Cookies 方式失效 | 使用 Electron cookies API |
| 上传超时 | 网络抖动导致等待失败 | 暴露重试机制 |
| 发布失败未捕获 | 发布后提示弹窗变化 | 增加结果轮询与提示解析 |
---
更新时间2025-09-27

View File

@@ -1,107 +0,0 @@
# 小红书发布功能完成总结
## 📋 功能概述
**已完成**: 为 Note2MP 插件成功添加了完整的小红书发布功能。
## 🚀 新增功能
### 1. 右键菜单集成
- ✅ 在文件右键菜单中添加了"发布到小红书"选项
- ✅ 仅对 Markdown 文件显示该选项
- ✅ 使用心形图标lucide-heart作为菜单图标
### 2. 登录系统
- ✅ 智能登录检查:首次使用时自动检测登录状态
- ✅ 登录弹窗:未登录时自动弹出登录对话框
- ✅ 手机验证码登录:默认手机号 13357108011
- ✅ 验证码发送功能60秒倒计时防重复发送
- ✅ 登录状态管理:记录用户登录状态
### 3. 内容适配系统
- ✅ Markdown 转小红书格式
- ✅ 标题自动生成和长度控制20字符以内
- ✅ 内容长度限制1000字符以内
- ✅ 小红书风格样式添加(表情符号等)
- ✅ 标签自动提取和格式化
### 4. 图片处理
- ✅ 自动图片格式转换统一转为PNG
- ✅ EXIF 信息处理和图片方向校正
- ✅ 图片尺寸优化(适应平台要求)
### 5. Web 自动化发布
- ✅ 基于 Electron webview 的网页操作
- ✅ 自动填写发布表单
- ✅ 模拟用户操作发布流程
- ✅ 发布状态检查和结果反馈
## 📁 文件结构
```
src/xiaohongshu/
├── types.ts # 类型定义和常量
├── api.ts # Web API 和自动化逻辑
├── adapter.ts # 内容格式转换
├── image.ts # 图片处理工具
└── login-modal.ts # 登录界面组件
```
## 🔧 技术特点
### 架构设计
- **模块化设计**: 独立的小红书模块,不影响现有微信公众号功能
- **单例模式**: API 管理器使用单例模式,确保资源有效利用
- **类型安全**: 完整的 TypeScript 类型定义
### 用户体验
- **一键发布**: 右键选择文件即可发布
- **智能检查**: 自动检测登录状态和文件类型
- **实时反馈**: 详细的状态提示和错误信息
- **无缝集成**: 与现有预览界面完美集成
### 错误处理
- **完善的异常捕获**: 各层级都有相应的错误处理
- **用户友好提示**: 清晰的错误信息和解决建议
- **日志记录**: 调试模式下的详细操作日志
## 📱 使用流程
1. **选择文件**: 在文件资源管理器中右键选择 Markdown 文件
2. **点击发布**: 选择"发布到小红书"菜单项
3. **登录验证**: 首次使用时输入手机号和验证码登录
4. **内容处理**: 系统自动转换内容格式并优化
5. **发布完成**: 获得发布结果反馈
## ✨ 用户需求满足度
**核心需求**: "新增小红书发布功能" - 完全实现
**技术方案**: "模拟网页操作类似Playwright自动化" - 通过 Electron webview 实现
**UI集成**: "文章右键增加'发布小红书'" - 已完成
**登录流程**: "如果没有登陆弹出登陆对话框。默认用户名13357108011。点击发送验证码。填入验证码验证登陆" - 完全按要求实现
## 🎯 完成状态
- [x] 架构设计和技术方案
- [x] 核心模块开发4个模块
- [x] 内容适配和图片处理
- [x] 登录界面和验证流程
- [x] 右键菜单集成
- [x] 完整功能测试和构建验证
**总计**: 1800+ 行代码,功能完整,可以投入使用!
## 🔮 后续扩展
该架构为后续功能扩展预留了空间:
- 批量发布小红书内容
- 发布状态追踪和管理
- 更多平台支持
- 高级内容编辑功能
---
*Created: 2024-12-31*
*Status: ✅ 完成*
*Code Lines: ~1800*
*Files Modified: 5 files created, 1 file modified*

View File

@@ -1,112 +0,0 @@
# 小红书发布功能使用指南
## 📋 问题修复情况
### ✅ 问题1: 右键菜单无法弹出登录窗口
**原因**: 登录状态检查方法在主线程调用时可能失败
**修复**:
- 添加了详细的调试日志
- 临时设置为总是显示登录对话框(便于测试)
- 在 main.ts 中添加了状态提示
### ✅ 问题2: 验证码发送后手机收不到
**原因**: 当前为开发模式,使用模拟验证码服务
**修复**:
- 明确标注为开发模式
- 提供测试验证码:`123456`
- 在界面中显示测试提示
## 🚀 测试步骤
### 1. 基本测试流程
1. **右键发布**:
- 在文件资源管理器中选择任意 `.md` 文件
- 右键选择"发布到小红书"
- 应该看到提示:"开始发布到小红书..."
2. **登录对话框**:
- 会自动弹出登录对话框
- 默认手机号:`13357108011`
- 标题显示为:"登录小红书"
3. **验证码测试**:
- 点击"发送验证码"按钮
- 看到提示:"验证码已发送 [开发模式: 请使用 123456]"
- 在验证码输入框中输入:`123456`
- 点击"登录"按钮
4. **登录成功**:
- 显示"登录成功!"
- 1.5秒后自动关闭对话框
- 继续发布流程
### 2. 开发者控制台日志
打开开发者控制台F12可以看到详细日志
```
开始发布到小红书... filename.md
检查登录状态...
登录状态: false
用户未登录,显示登录对话框...
打开登录模态窗口...
[模拟] 向 13357108011 发送验证码
[开发模式] 请使用测试验证码: 123456
[模拟] 使用手机号 13357108011 和验证码 123456 登录
登录成功回调被调用
登录窗口关闭
登录结果: true
```
## 🔧 调试信息
### 当前模拟状态
- **登录检查**: 总是返回未登录状态(便于测试登录流程)
- **验证码发送**: 模拟发送,不会真正发送短信
- **验证码验证**: 接受测试验证码 `123456`, `000000`, `888888`
- **内容发布**: 会执行内容转换,但实际发布为模拟状态
### 预期的用户交互
1. ✅ 右键菜单显示"发布到小红书"
2. ✅ 点击后显示加载提示
3. ✅ 自动弹出登录对话框
4. ✅ 默认手机号已填写
5. ✅ 发送验证码功能正常
6. ✅ 使用测试验证码可以成功登录
7. ✅ 登录成功后会关闭对话框
## 🐛 故障排除
### 如果登录对话框没有弹出
1. 检查开发者控制台是否有错误信息
2. 确认是否安装了最新版本的插件
3. 检查是否选择的是 `.md` 文件
### 如果验证码验证失败
1. 确认输入的是测试验证码:`123456`
2. 检查是否先点击了"发送验证码"
3. 确认倒计时已开始60秒
### 如果发布流程中断
1. 查看开发者控制台的详细错误信息
2. 确认文件格式为有效的 Markdown
3. 检查插件是否正确加载了所有小红书模块
## 💡 下一步工作
### 生产环境集成
1. **真实验证码服务**: 集成小红书官方验证码API
2. **登录状态持久化**: 保存登录状态,避免重复登录
3. **实际发布接口**: 连接小红书创作者平台API
4. **错误处理优化**: 添加更详细的错误提示和恢复机制
### 功能增强
1. **批量发布**: 支持选择多个文件批量发布
2. **发布历史**: 记录发布历史和状态
3. **内容预览**: 发布前预览小红书格式效果
4. **高级设置**: 允许用户自定义发布参数
---
**开发状态**: ✅ 功能调试完成可以进行UI测试
**测试验证码**: `123456`
**当前版本**: v1.3.0-dev
**最后更新**: 2024-12-31

View File

@@ -15,6 +15,9 @@ import AssetsManager from '../assets';
import { paginateArticle, renderPage, PageInfo } from './paginator';
import { sliceCurrentPage, sliceAllPages } from './slice';
const XHS_PREVIEW_DEFAULT_WIDTH = 540;
const XHS_PREVIEW_WIDTH_OPTIONS = [1080, 720, 540, 360];
/**
* 小红书预览视图类
*/
@@ -29,6 +32,7 @@ export class XiaohongshuPreview {
topToolbar!: HTMLDivElement;
templateSelect!: HTMLSelectElement;
fontSizeInput!: HTMLInputElement;
previewWidthSelect!: HTMLSelectElement;
pageContainer!: HTMLDivElement;
bottomToolbar!: HTMLDivElement;
@@ -110,6 +114,30 @@ export class XiaohongshuPreview {
option.text = name;
});
const previewWidthLabel = this.topToolbar.createDiv({ cls: 'toolbar-label' });
previewWidthLabel.innerText = '预览宽度';
this.previewWidthSelect = this.topToolbar.createEl('select', { cls: 'xhs-select' });
const currentPreviewWidth = this.settings.xhsPreviewWidth || XHS_PREVIEW_DEFAULT_WIDTH;
XHS_PREVIEW_WIDTH_OPTIONS.forEach(value => {
const option = this.previewWidthSelect.createEl('option');
option.value = String(value);
option.text = `${value}px`;
});
if (!XHS_PREVIEW_WIDTH_OPTIONS.includes(currentPreviewWidth)) {
const customOption = this.previewWidthSelect.createEl('option');
customOption.value = String(currentPreviewWidth);
customOption.text = `${currentPreviewWidth}px`;
}
this.previewWidthSelect.value = String(currentPreviewWidth);
this.previewWidthSelect.onchange = async () => {
const value = parseInt(this.previewWidthSelect.value, 10);
if (Number.isFinite(value) && value > 0) {
await this.onPreviewWidthChanged(value);
} else {
this.previewWidthSelect.value = String(this.settings.xhsPreviewWidth || XHS_PREVIEW_DEFAULT_WIDTH);
}
};
// 字号控制(可直接编辑)
const fontSizeLabel = this.topToolbar.createDiv({ cls: 'toolbar-label' });
fontSizeLabel.innerText = '字号';
@@ -205,6 +233,7 @@ export class XiaohongshuPreview {
if (this.currentThemeClass) classes.push('note-to-mp');
const pageElement = wrapper.createDiv({ cls: classes.join(' ') });
renderPage(pageElement, page.content, this.settings);
this.applyPreviewSizing(wrapper, pageElement);
// 应用字体设置
this.applyFontSettings(pageElement);
@@ -213,6 +242,49 @@ export class XiaohongshuPreview {
this.pageNumberDisplay.innerText = `${this.currentPageIndex + 1}/${this.pages.length}`;
}
/**
* 根据设置的宽度和横竖比应用预览尺寸与缩放
*/
private applyPreviewSizing(wrapper: HTMLElement, pageElement: HTMLElement): void {
const configuredWidth = this.settings.sliceImageWidth || 1080;
const actualWidth = Math.max(1, configuredWidth);
const ratio = this.parseAspectRatio(this.settings.sliceImageAspectRatio);
const actualHeight = Math.round((actualWidth * ratio.height) / ratio.width);
const previewWidthSetting = this.settings.xhsPreviewWidth || XHS_PREVIEW_DEFAULT_WIDTH;
const previewWidth = Math.max(1, previewWidthSetting);
const scale = Math.max(previewWidth / actualWidth, 0.01);
const previewHeight = Math.max(1, Math.round(actualHeight * scale));
wrapper.style.width = `${previewWidth}px`;
wrapper.style.height = `${previewHeight}px`;
pageElement.style.width = `${actualWidth}px`;
pageElement.style.height = `${actualHeight}px`;
pageElement.style.transform = `scale(${scale})`;
pageElement.style.position = 'absolute';
pageElement.style.top = '0';
pageElement.style.left = '0';
}
private async onPreviewWidthChanged(newWidth: number): Promise<void> {
if (newWidth <= 0) return;
if (this.settings.xhsPreviewWidth === newWidth) return;
this.settings.xhsPreviewWidth = newWidth;
await this.persistSettings();
this.renderCurrentPage();
}
/**
* 解析横竖比例字符串
*/
private parseAspectRatio(ratio: string | undefined): { width: number; height: number } {
const parts = (ratio ?? '').split(':').map(part => parseFloat(part.trim()));
if (parts.length === 2 && isFinite(parts[0]) && isFinite(parts[1]) && parts[0] > 0 && parts[1] > 0) {
return { width: parts[0], height: parts[1] };
}
return { width: 3, height: 4 };
}
/**
* 应用字体设置(仅字号,字体从主题读取)
*/
@@ -341,6 +413,17 @@ export class XiaohongshuPreview {
}
}
private async persistSettings(): Promise<void> {
try {
const plugin = (this.app as any)?.plugins?.getPlugin?.('note-to-mp');
if (plugin?.saveSettings) {
await plugin.saveSettings();
}
} catch (error) {
console.warn('[XiaohongshuPreview] 保存设置失败', error);
}
}
/**
* 显示小红书预览视图
*/
@@ -365,6 +448,7 @@ export class XiaohongshuPreview {
destroy(): void {
this.topToolbar = null as any;
this.templateSelect = null as any;
this.previewWidthSelect = null as any;
this.fontSizeInput = null as any;
this.pageContainer = null as any;
this.bottomToolbar = null as any;