Compare commits
17 Commits
4e12552437
...
a83d5d7bb9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a83d5d7bb9 | ||
|
|
64290b9dd1 | ||
|
|
83dc377efd | ||
|
|
737902388a | ||
|
|
971a0c9dd9 | ||
|
|
ff6c884744 | ||
|
|
e279da0c8b | ||
|
|
7bb557be01 | ||
|
|
70a76ac438 | ||
|
|
fde62ba8fb | ||
|
|
09f0978025 | ||
|
|
e677daa0c0 | ||
|
|
90ba3284d2 | ||
|
|
725bf8fe71 | ||
|
|
3c363a14b0 | ||
|
|
ee531f4987 | ||
|
|
20dd127fa6 |
@ -69,12 +69,12 @@
|
|||||||
- [ ] 如果右侧超出视口,显示在左侧
|
- [ ] 如果右侧超出视口,显示在左侧
|
||||||
- [ ] 如果下方超出视口,显示在上方
|
- [ ] 如果下方超出视口,显示在上方
|
||||||
- [ ] 面板边界与视口保留 10px 边距
|
- [ ] 面板边界与视口保留 10px 边距
|
||||||
|
- [ ] 可以通过拖拽面板头部更改面板位置
|
||||||
|
|
||||||
### M2.8 图标-面板联动 [目标版本: 0.1.8]
|
### M2.8 图标-面板联动 [目标版本: 0.1.8]
|
||||||
**任务**: 点击图标显示面板
|
**任务**: 点击图标显示面板
|
||||||
**验收标准**:
|
**验收标准**:
|
||||||
- [ ] 点击图标,面板显示在图标附近
|
- [ ] 点击图标,面板显示在图标附近
|
||||||
- [ ] 面板显示时,图标保持可见
|
|
||||||
- [ ] 点击面板外部区域,面板关闭
|
- [ ] 点击面板外部区域,面板关闭
|
||||||
- [ ] 按 ESC 键,面板关闭
|
- [ ] 按 ESC 键,面板关闭
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
## 版本速查
|
## 版本速查
|
||||||
|
|
||||||
### 当前版本
|
### 当前版本
|
||||||
`0.1.5` → 下一目标 `0.1.6` ([M2.6](./M2.md))
|
`0.2.11` → 下一目标 `0.3.1` ([M4.1](./M4.md))
|
||||||
|
|
||||||
### 模块版本范围
|
### 模块版本范围
|
||||||
|
|
||||||
|
|||||||
@ -65,9 +65,9 @@ M11.10完成 → 1.0.0 (正式发布)
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
**当前版本**: `0.1.5`
|
**当前版本**: `0.2.11`
|
||||||
**当前进度**: 10/97 (10%)
|
**当前进度**: 25/97 (26%)
|
||||||
**下一任务**: [M2.6 基础面板组件](./M2.md#m26-基础面板组件--目标版本-0116)
|
**下一任务**: [M4.1 Popup基础界面](./M4.md#m41-popup基础界面--目标版本-031)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -105,8 +105,9 @@ M11.10完成 → 1.0.0 (正式发布)
|
|||||||
|
|
||||||
1. **阅读规范**: 开始前阅读 [AGENTS.md](./AGENTS.md) 和 [QUICK_REF.md](./QUICK_REF.md)
|
1. **阅读规范**: 开始前阅读 [AGENTS.md](./AGENTS.md) 和 [QUICK_REF.md](./QUICK_REF.md)
|
||||||
2. **查看模块**: 根据当前版本打开对应的模块文件(如 [M1.md](./M1.md))
|
2. **查看模块**: 根据当前版本打开对应的模块文件(如 [M1.md](./M1.md))
|
||||||
3. **开发实现**: 按任务顺序实现,每个任务完成更新版本号
|
3. **开发实现**: 按任务顺序实现需求
|
||||||
4. **提交验收**: 用户确认后更新 VERSION.md 并提交 commit
|
4. **更新文档**:任务完成后更新`docs/README.md`的`当前状态`小节,更新`docs/VERSION.md`中对应人物的`状态`和`日期`,更新`docs/QUICK_REF.md`的`当前版本`小节,更新`package.json`和`manifest.json`的版本号
|
||||||
|
5. **提交验收**: 用户确认后提交代码
|
||||||
|
|
||||||
### Commit 格式
|
### Commit 格式
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -4,15 +4,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 当前版本
|
|
||||||
|
|
||||||
**版本**: `0.0.0`
|
|
||||||
**状态**: 🟡 初始状态
|
|
||||||
**进度**: 0/97 (0%)
|
|
||||||
**下一目标**: [M1.1 项目初始化](./M1.md#m11)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M1 基础架构 (0.0.1 - 0.0.5)
|
## M1 基础架构 (0.0.1 - 0.0.5)
|
||||||
|
|
||||||
| 任务 | 版本 | 描述 | 状态 | 日期 |
|
| 任务 | 版本 | 描述 | 状态 | 日期 |
|
||||||
@ -29,15 +20,15 @@
|
|||||||
|
|
||||||
| 任务 | 版本 | 描述 | 状态 | 日期 |
|
| 任务 | 版本 | 描述 | 状态 | 日期 |
|
||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
| M2.1 | 0.1.1 | 文本选择检测 | ✅ | 2026-02-09 |
|
| M2.1 | 0.1.1 | 文本选择检测 | ✅ | 2026-02-10 |
|
||||||
| M2.2 | 0.1.2 | 获取选中文本坐标 | ✅ | 2026-02-09 |
|
| M2.2 | 0.1.2 | 获取选中文本坐标 | ✅ | 2026-02-10 |
|
||||||
| M2.3 | 0.1.3 | 沙拉图标组件 | ✅ | 2026-02-09 |
|
| M2.3 | 0.1.3 | 沙拉图标组件 | ✅ | 2026-02-10 |
|
||||||
| M2.4 | 0.1.4 | 图标定位显示 | ✅ | 2026-02-09 |
|
| M2.4 | 0.1.4 | 图标定位显示 | ✅ | 2026-02-10 |
|
||||||
| M2.5 | 0.1.5 | 图标点击事件 | ✅ | 2026-02-09 |
|
| M2.5 | 0.1.5 | 图标点击事件 | ✅ | 2026-02-10 |
|
||||||
| M2.6 | 0.1.6 | 基础面板组件 | ⬜ | - |
|
| M2.6 | 0.1.6 | 基础面板组件 | ✅ | 2026-02-11 |
|
||||||
| M2.7 | 0.1.7 | 面板位置计算 | ⬜ | - |
|
| M2.7 | 0.1.7 | 面板位置计算 | ✅ | 2026-02-11 |
|
||||||
| M2.8 | 0.1.8 | 图标-面板联动 | ⬜ | - |
|
| M2.8 | 0.1.8 | 图标-面板联动 | ✅ | 2026-02-11 |
|
||||||
| M2.9 | 0.1.9 | 图标显示开关 | ⬜ | - |
|
| M2.9 | 0.1.9 | 图标显示开关 | ✅ | 2026-02-11 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -45,17 +36,17 @@
|
|||||||
|
|
||||||
| 任务 | 版本 | 描述 | 状态 | 日期 |
|
| 任务 | 版本 | 描述 | 状态 | 日期 |
|
||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
| M3.1 | 0.2.1 | 词典接口基类 | ⬜ | - |
|
| M3.1 | 0.2.1 | 词典接口基类 | ✅ | 2026-02-11 |
|
||||||
| M3.2 | 0.2.2 | 词典管理器 | ⬜ | - |
|
| M3.2 | 0.2.2 | 词典管理器 | ✅ | 2026-02-11 |
|
||||||
| M3.3 | 0.2.3 | 必应词典(Mock) | ⬜ | - |
|
| M3.3 | 0.2.3 | 必应词典(Mock) | ✅ | 2026-02-11 |
|
||||||
| M3.4 | 0.2.4 | 后台查询接口 | ⬜ | - |
|
| M3.4 | 0.2.4 | 后台查询接口 | ✅ | 2026-02-11 |
|
||||||
| M3.5 | 0.2.5 | 结果展示组件 | ⬜ | - |
|
| M3.5 | 0.2.5 | 结果展示组件 | ✅ | 2026-02-11 |
|
||||||
| M3.6 | 0.2.6 | 点击图标查词(Mock) | ⬜ | - |
|
| M3.6 | 0.2.6 | 点击图标查词(Mock) | ✅ | 2026-02-11 |
|
||||||
| M3.7 | 0.2.7 | 必应词典真实API | ⬜ | - |
|
| M3.7 | 0.2.7 | 必应词典真实API | ✅ | 2026-02-11 |
|
||||||
| M3.8 | 0.2.8 | 加载状态显示 | ⬜ | - |
|
| M3.8 | 0.2.8 | 加载状态显示 | ✅ | 2026-02-11 |
|
||||||
| M3.9 | 0.2.9 | 有道词典实现 | ⬜ | - |
|
| M3.9 | 0.2.9 | 有道词典实现 | ✅ | 2026-02-11 |
|
||||||
| M3.10 | 0.2.10 | 结果折叠/展开 | ⬜ | - |
|
| M3.10 | 0.2.10 | 结果折叠/展开 | ✅ | 2026-02-11 |
|
||||||
| M3.11 | 0.2.11 | 词典图标标识 | ⬜ | - |
|
| M3.11 | 0.2.11 | 词典图标标识 | ✅ | 2026-02-11 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -185,11 +176,3 @@
|
|||||||
| M11.9 | 0.10.9 | 打包配置 | ⬜ | - |
|
| M11.9 | 0.10.9 | 打包配置 | ⬜ | - |
|
||||||
| M11.10 | 1.0.0 | 图标和素材 | ⬜ | - |
|
| M11.10 | 1.0.0 | 图标和素材 | ⬜ | - |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 统计
|
|
||||||
|
|
||||||
- **总计**: 97 个任务
|
|
||||||
- **已完成**: 0
|
|
||||||
- **剩余**: 97
|
|
||||||
- **当前进度**: 0%
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "沙拉查词",
|
"name": "沙拉查词",
|
||||||
"version": "0.1.5",
|
"version": "0.2.11",
|
||||||
"description": "聚合词典划词翻译",
|
"description": "聚合词典划词翻译",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "salad-dict",
|
"name": "salad-dict",
|
||||||
"version": "0.1.5",
|
"version": "0.2.11",
|
||||||
"description": "聚合词典划词翻译",
|
"description": "聚合词典划词翻译",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// Background Service Worker
|
// Background Service Worker
|
||||||
import { backgroundHandler } from '../shared/messaging.js';
|
import { backgroundHandler } from '../shared/messaging.js';
|
||||||
|
import { dictionaryManager, bingDictionary, youdaoDictionary } from '../shared/dictionary/index.js';
|
||||||
|
|
||||||
console.log('[SaladDict] Background service worker started');
|
console.log('[SaladDict] Background service worker started');
|
||||||
|
|
||||||
@ -11,7 +12,40 @@ chrome.runtime.onInstalled.addListener((details) => {
|
|||||||
// BackgroundHandler 自动初始化消息监听
|
// BackgroundHandler 自动初始化消息监听
|
||||||
console.log('[SaladDict] Message handler initialized');
|
console.log('[SaladDict] Message handler initialized');
|
||||||
|
|
||||||
// 注册自定义消息处理器示例
|
// 注册词典到管理器
|
||||||
backgroundHandler.register('ECHO', async (payload) => {
|
dictionaryManager.register('bing', bingDictionary);
|
||||||
return { echo: payload };
|
dictionaryManager.register('youdao', youdaoDictionary);
|
||||||
|
console.log('[SaladDict] Registered dictionaries:', dictionaryManager.getNames());
|
||||||
|
|
||||||
|
// 注册词典查询处理器
|
||||||
|
backgroundHandler.register('DICT.SEARCH', async (payload) => {
|
||||||
|
const { word, dictNames = [] } = payload;
|
||||||
|
|
||||||
|
console.log('[Background] DICT.SEARCH:', word, dictNames);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results, errors } = await dictionaryManager.search(word, dictNames);
|
||||||
|
|
||||||
|
console.log('[Background] Search results:', results);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.warn('[Background] Search errors:', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results, errors };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Background] Search failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 注册获取词典列表处理器
|
||||||
|
backgroundHandler.register('DICT.GET_LIST', async () => {
|
||||||
|
const dictionaries = dictionaryManager.getAll().map(({ name, dictionary }) => ({
|
||||||
|
name,
|
||||||
|
info: dictionary.getInfo()
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { dictionaries };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SaladDict] Message handlers registered');
|
||||||
|
|||||||
653
src/content/components/DictPanel.js
Normal file
653
src/content/components/DictPanel.js
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
/**
|
||||||
|
* @file 词典结果展示面板
|
||||||
|
* @description 显示词典查询结果的基础面板组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 400;
|
||||||
|
const PANEL_HEIGHT = 300;
|
||||||
|
const VIEWPORT_MARGIN = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 词典结果面板类
|
||||||
|
*/
|
||||||
|
export class DictPanel {
|
||||||
|
constructor() {
|
||||||
|
this.element = this.createElement();
|
||||||
|
this.isDragging = false;
|
||||||
|
this.dragOffset = { x: 0, y: 0 };
|
||||||
|
this._dragHandlers = null;
|
||||||
|
this._isVisible = false;
|
||||||
|
this._collapsedStates = new Map(); // 存储每个词典的折叠状态
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建面板元素
|
||||||
|
* @returns {HTMLElement} 面板元素
|
||||||
|
*/
|
||||||
|
createElement() {
|
||||||
|
// 创建容器
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483646;
|
||||||
|
display: none;
|
||||||
|
width: ${PANEL_WIDTH}px;
|
||||||
|
height: ${PANEL_HEIGHT}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 创建 Shadow DOM
|
||||||
|
const shadow = container.attachShadow({ mode: 'open' });
|
||||||
|
|
||||||
|
// Shadow DOM 内容
|
||||||
|
shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phonetic {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-family: 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meanings-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-of-speech {
|
||||||
|
color: #2196F3;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.definition {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-sentence {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-translation {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #e0e0e0;
|
||||||
|
border-top-color: #4CAF50;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-header:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-toggle {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #666;
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-right: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-icon.bing {
|
||||||
|
background: #00809d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-icon.youdao {
|
||||||
|
background: #e93a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-icon.default {
|
||||||
|
background: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-content {
|
||||||
|
padding: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-content.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="header">词典结果</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="placeholder">查询结果将显示在这里</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 阻止点击事件冒泡
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加到页面
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定拖拽事件
|
||||||
|
*/
|
||||||
|
bindDragEvents() {
|
||||||
|
if (this._dragHandlers) return; // 已绑定则跳过
|
||||||
|
|
||||||
|
const header = this.element.shadowRoot.querySelector('.header');
|
||||||
|
|
||||||
|
// 鼠标按下开始拖拽
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
this.isDragging = true;
|
||||||
|
this.dragOffset.x = e.clientX - this.element.offsetLeft;
|
||||||
|
this.dragOffset.y = e.clientY - this.element.offsetTop;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 鼠标移动时更新位置
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
|
||||||
|
let newX = e.clientX - this.dragOffset.x;
|
||||||
|
let newY = e.clientY - this.dragOffset.y;
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
newX = Math.max(VIEWPORT_MARGIN, Math.min(newX, viewportWidth - PANEL_WIDTH - VIEWPORT_MARGIN));
|
||||||
|
newY = Math.max(VIEWPORT_MARGIN, Math.min(newY, viewportHeight - PANEL_HEIGHT - VIEWPORT_MARGIN));
|
||||||
|
|
||||||
|
this.element.style.left = `${newX}px`;
|
||||||
|
this.element.style.top = `${newY}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 鼠标抬起结束拖拽
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
this.isDragging = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
header.addEventListener('mousedown', handleMouseDown);
|
||||||
|
|
||||||
|
this._dragHandlers = { handleMouseDown, header };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑拖拽事件
|
||||||
|
*/
|
||||||
|
unbindDragEvents() {
|
||||||
|
if (!this._dragHandlers) return;
|
||||||
|
|
||||||
|
const { handleMouseDown, header } = this._dragHandlers;
|
||||||
|
header.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
|
||||||
|
this._dragHandlers = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算面板位置,确保不超出视口
|
||||||
|
*/
|
||||||
|
calculatePosition(x, y) {
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let panelX = x;
|
||||||
|
let panelY = y;
|
||||||
|
|
||||||
|
if (panelX + PANEL_WIDTH + VIEWPORT_MARGIN > viewportWidth) {
|
||||||
|
panelX = x - PANEL_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelY + PANEL_HEIGHT + VIEWPORT_MARGIN > viewportHeight) {
|
||||||
|
panelY = y - PANEL_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
panelX = Math.max(VIEWPORT_MARGIN, panelX);
|
||||||
|
panelY = Math.max(VIEWPORT_MARGIN, panelY);
|
||||||
|
|
||||||
|
if (panelX + PANEL_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
|
||||||
|
panelX = viewportWidth - PANEL_WIDTH - VIEWPORT_MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelY + PANEL_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
|
||||||
|
panelY = viewportHeight - PANEL_HEIGHT - VIEWPORT_MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: panelX, y: panelY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示面板
|
||||||
|
*/
|
||||||
|
show(x, y) {
|
||||||
|
if (this._isVisible) return;
|
||||||
|
|
||||||
|
const position = this.calculatePosition(x, y);
|
||||||
|
this.element.style.left = `${position.x}px`;
|
||||||
|
this.element.style.top = `${position.y}px`;
|
||||||
|
this.element.style.display = 'block';
|
||||||
|
this._isVisible = true;
|
||||||
|
|
||||||
|
// 绑定拖拽事件
|
||||||
|
this.bindDragEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏面板
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
if (!this._isVisible) return;
|
||||||
|
|
||||||
|
this.element.style.display = 'none';
|
||||||
|
this._isVisible = false;
|
||||||
|
|
||||||
|
// 解绑拖拽事件
|
||||||
|
this.unbindDragEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁组件
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.hide();
|
||||||
|
this.unbindDragEvents();
|
||||||
|
|
||||||
|
if (this.element.parentNode) {
|
||||||
|
this.element.parentNode.removeChild(this.element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染单个词典查询结果
|
||||||
|
* @param {Object} result - 查询结果
|
||||||
|
* @param {string} result.word - 单词
|
||||||
|
* @param {string} [result.phonetic] - 音标
|
||||||
|
* @param {Array} [result.meanings] - 释义列表
|
||||||
|
* @param {Array} [result.examples] - 例句列表
|
||||||
|
*/
|
||||||
|
renderResult(result) {
|
||||||
|
if (!result) {
|
||||||
|
this._setContent('<div class="placeholder">暂无查询结果</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="word-title">${this._escapeHtml(result.word)}</div>
|
||||||
|
${result.phonetic ? `<div class="phonetic">${this._escapeHtml(result.phonetic)}</div>` : ''}
|
||||||
|
|
||||||
|
${this._renderMeanings(result.meanings)}
|
||||||
|
${this._renderExamples(result.examples)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
this._setContent(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染多个词典查询结果
|
||||||
|
* @param {Array} results - 多个词典结果 {name, result}[]
|
||||||
|
*/
|
||||||
|
renderMultipleResults(results) {
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
this._setContent('<div class="placeholder">暂无查询结果</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一个结果作为主结果显示单词标题
|
||||||
|
const firstResult = results[0].result;
|
||||||
|
let html = `
|
||||||
|
<div class="word-title">${this._escapeHtml(firstResult.word)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 显示每个词典的结果
|
||||||
|
for (const { name, result } of results) {
|
||||||
|
html += this._renderDictSection(name, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setContent(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取词典图标类名
|
||||||
|
* @private
|
||||||
|
* @param {string} dictName - 词典名称
|
||||||
|
* @returns {string} 图标类名
|
||||||
|
*/
|
||||||
|
_getDictIconClass(dictName) {
|
||||||
|
const name = dictName.toLowerCase();
|
||||||
|
if (name.includes('必应') || name.includes('bing')) return 'bing';
|
||||||
|
if (name.includes('有道') || name.includes('youdao')) return 'youdao';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取词典图标文字
|
||||||
|
* @private
|
||||||
|
* @param {string} dictName - 词典名称
|
||||||
|
* @returns {string} 图标文字
|
||||||
|
*/
|
||||||
|
_getDictIconText(dictName) {
|
||||||
|
const name = dictName.toLowerCase();
|
||||||
|
if (name.includes('必应') || name.includes('bing')) return 'B';
|
||||||
|
if (name.includes('有道') || name.includes('youdao')) return 'Y';
|
||||||
|
return dictName.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染单个词典区块
|
||||||
|
* @private
|
||||||
|
* @param {string} dictName - 词典名称
|
||||||
|
* @param {Object} result - 词典结果
|
||||||
|
* @returns {string} HTML 字符串
|
||||||
|
*/
|
||||||
|
_renderDictSection(dictName, result) {
|
||||||
|
// 获取保存的折叠状态,默认展开
|
||||||
|
const isCollapsed = this._collapsedStates.get(dictName) || false;
|
||||||
|
const toggleIcon = isCollapsed ? '▶' : '▼';
|
||||||
|
const contentClass = isCollapsed ? 'dict-content collapsed' : 'dict-content';
|
||||||
|
|
||||||
|
const iconClass = this._getDictIconClass(dictName);
|
||||||
|
const iconText = this._getDictIconText(dictName);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="dict-section" data-dict="${this._escapeHtml(dictName)}">
|
||||||
|
<div class="dict-header">
|
||||||
|
<span class="dict-toggle">${toggleIcon}</span>
|
||||||
|
<div class="dict-icon ${iconClass}">${iconText}</div>
|
||||||
|
<span class="dict-name">${this._escapeHtml(dictName)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="${contentClass}">
|
||||||
|
${result.phonetic ? `<div class="phonetic" style="margin-bottom: 8px;">${this._escapeHtml(result.phonetic)}</div>` : ''}
|
||||||
|
${this._renderMeanings(result.meanings)}
|
||||||
|
${this._renderExamples(result.examples)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置内容区域 HTML
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
*/
|
||||||
|
_setContent(html) {
|
||||||
|
const content = this.element.shadowRoot.querySelector('.content');
|
||||||
|
content.innerHTML = html;
|
||||||
|
|
||||||
|
// 绑定折叠事件
|
||||||
|
this._bindCollapseEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定折叠/展开事件
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_bindCollapseEvents() {
|
||||||
|
const shadow = this.element.shadowRoot;
|
||||||
|
const headers = shadow.querySelectorAll('.dict-header');
|
||||||
|
|
||||||
|
headers.forEach(header => {
|
||||||
|
header.addEventListener('click', (e) => {
|
||||||
|
const section = header.closest('.dict-section');
|
||||||
|
const content = section.querySelector('.dict-content');
|
||||||
|
const toggle = header.querySelector('.dict-toggle');
|
||||||
|
const dictName = header.querySelector('.dict-name').textContent;
|
||||||
|
|
||||||
|
// 切换折叠状态
|
||||||
|
const isCollapsed = content.classList.toggle('collapsed');
|
||||||
|
|
||||||
|
// 更新图标
|
||||||
|
toggle.textContent = isCollapsed ? '▶' : '▼';
|
||||||
|
|
||||||
|
// 保存状态
|
||||||
|
this._collapsedStates.set(dictName, isCollapsed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示加载状态
|
||||||
|
*/
|
||||||
|
showLoading() {
|
||||||
|
const content = this.element.shadowRoot.querySelector('.content');
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="loading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-text">查询中...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示超时提示
|
||||||
|
*/
|
||||||
|
showTimeout() {
|
||||||
|
const content = this.element.shadowRoot.querySelector('.content');
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="loading">
|
||||||
|
<div class="loading-text" style="color: #999;">查询超时,请重试</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染释义列表
|
||||||
|
* @private
|
||||||
|
* @param {Array} meanings - 释义列表
|
||||||
|
* @returns {string} HTML 字符串
|
||||||
|
*/
|
||||||
|
_renderMeanings(meanings) {
|
||||||
|
if (!meanings || meanings.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const meaningsHtml = meanings.map(m => `
|
||||||
|
<div class="meaning-item">
|
||||||
|
<span class="part-of-speech">${this._escapeHtml(m.partOfSpeech || '')}</span>
|
||||||
|
<span class="definition">${this._escapeHtml(m.definitions?.join(';') || '')}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="meanings-section">
|
||||||
|
<div class="section-title">释义</div>
|
||||||
|
${meaningsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染例句列表
|
||||||
|
* @private
|
||||||
|
* @param {Array} examples - 例句列表
|
||||||
|
* @returns {string} HTML 字符串
|
||||||
|
*/
|
||||||
|
_renderExamples(examples) {
|
||||||
|
if (!examples || examples.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最多显示2个例句
|
||||||
|
const displayExamples = examples.slice(0, 2);
|
||||||
|
|
||||||
|
const examplesHtml = displayExamples.map(e => `
|
||||||
|
<div class="example-item">
|
||||||
|
<div class="example-sentence">${this._escapeHtml(e.sentence || '')}</div>
|
||||||
|
${e.translation ? `<div class="example-translation">${this._escapeHtml(e.translation)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="examples-section">
|
||||||
|
<div class="section-title">例句</div>
|
||||||
|
${examplesHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 转义,防止 XSS
|
||||||
|
* @private
|
||||||
|
* @param {string} text - 原始文本
|
||||||
|
* @returns {string} 转义后的文本
|
||||||
|
*/
|
||||||
|
_escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并显示词典面板
|
||||||
|
*/
|
||||||
|
export function createDictPanel(x, y) {
|
||||||
|
const panel = new DictPanel();
|
||||||
|
panel.show(x, y);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
@ -4,30 +4,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const ICON_SIZE = 24;
|
const ICON_SIZE = 24;
|
||||||
const ICON_COLOR = '#4CAF50'; // 绿色
|
const ICON_COLOR = '#4CAF50';
|
||||||
const OFFSET_X = 8; // 图标相对于选中文本的水平偏移
|
const OFFSET_X = 8;
|
||||||
const OFFSET_Y = -12; // 图标相对于选中文本的垂直偏移
|
const OFFSET_Y = -12;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 沙拉图标类
|
* 沙拉图标类
|
||||||
*/
|
*/
|
||||||
export class SaladIcon {
|
export class SaladIcon {
|
||||||
/**
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {Function} options.onClick - 点击回调函数
|
|
||||||
*/
|
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.onClick = options.onClick || null;
|
this.onClick = options.onClick || null;
|
||||||
this.element = this.createElement();
|
this.element = this.createElement();
|
||||||
|
this._isVisible = false;
|
||||||
|
this._clickHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建图标元素
|
* 创建图标元素
|
||||||
* @returns {HTMLElement} 图标元素
|
|
||||||
*/
|
*/
|
||||||
createElement() {
|
createElement() {
|
||||||
// 创建容器
|
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.style.cssText = `
|
container.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -35,10 +31,8 @@ export class SaladIcon {
|
|||||||
display: none;
|
display: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 创建 Shadow DOM
|
|
||||||
const shadow = container.attachShadow({ mode: 'open' });
|
const shadow = container.attachShadow({ mode: 'open' });
|
||||||
|
|
||||||
// Shadow DOM 内容
|
|
||||||
shadow.innerHTML = `
|
shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
.icon {
|
.icon {
|
||||||
@ -75,38 +69,65 @@ export class SaladIcon {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 处理图标点击
|
|
||||||
container.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
console.log('[SaladDict] Icon clicked');
|
|
||||||
|
|
||||||
if (this.onClick) {
|
|
||||||
this.onClick(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加到页面
|
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示图标在指定位置
|
* 绑定点击事件
|
||||||
* @param {number} x - X坐标
|
*/
|
||||||
* @param {number} y - Y坐标
|
bindClickEvent() {
|
||||||
|
if (this._clickHandler) return;
|
||||||
|
|
||||||
|
this._clickHandler = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log('[SaladDict] Icon clicked');
|
||||||
|
|
||||||
|
if (this.onClick) {
|
||||||
|
this.onClick(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.element.addEventListener('click', this._clickHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑点击事件
|
||||||
|
*/
|
||||||
|
unbindClickEvent() {
|
||||||
|
if (!this._clickHandler) return;
|
||||||
|
|
||||||
|
this.element.removeEventListener('click', this._clickHandler);
|
||||||
|
this._clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示图标
|
||||||
*/
|
*/
|
||||||
show(x, y) {
|
show(x, y) {
|
||||||
|
if (this._isVisible) return;
|
||||||
|
|
||||||
this.element.style.left = `${x + OFFSET_X}px`;
|
this.element.style.left = `${x + OFFSET_X}px`;
|
||||||
this.element.style.top = `${y + OFFSET_Y}px`;
|
this.element.style.top = `${y + OFFSET_Y}px`;
|
||||||
this.element.style.display = 'block';
|
this.element.style.display = 'block';
|
||||||
|
this._isVisible = true;
|
||||||
|
|
||||||
|
// 绑定点击事件
|
||||||
|
this.bindClickEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 隐藏图标
|
* 隐藏图标
|
||||||
*/
|
*/
|
||||||
hide() {
|
hide() {
|
||||||
|
if (!this._isVisible) return;
|
||||||
|
|
||||||
this.element.style.display = 'none';
|
this.element.style.display = 'none';
|
||||||
|
this._isVisible = false;
|
||||||
|
|
||||||
|
// 解绑点击事件
|
||||||
|
this.unbindClickEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -114,6 +135,8 @@ export class SaladIcon {
|
|||||||
*/
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
this.hide();
|
this.hide();
|
||||||
|
this.unbindClickEvent();
|
||||||
|
|
||||||
if (this.element.parentNode) {
|
if (this.element.parentNode) {
|
||||||
this.element.parentNode.removeChild(this.element);
|
this.element.parentNode.removeChild(this.element);
|
||||||
}
|
}
|
||||||
@ -122,11 +145,6 @@ export class SaladIcon {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建并显示沙拉图标
|
* 创建并显示沙拉图标
|
||||||
* @param {number} x - X坐标
|
|
||||||
* @param {number} y - Y坐标
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {Function} options.onClick - 点击回调函数
|
|
||||||
* @returns {SaladIcon} 图标实例
|
|
||||||
*/
|
*/
|
||||||
export function createSaladIcon(x, y, options = {}) {
|
export function createSaladIcon(x, y, options = {}) {
|
||||||
const icon = new SaladIcon(options);
|
const icon = new SaladIcon(options);
|
||||||
|
|||||||
@ -5,8 +5,12 @@
|
|||||||
|
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { createSaladIcon } from './components/SaladIcon.js';
|
import { createSaladIcon } from './components/SaladIcon.js';
|
||||||
|
import { DictPanel } from './components/DictPanel.js';
|
||||||
|
import { ConfigManager, isSelectionEnabled } from '../shared/config.js';
|
||||||
|
import { messaging } from '../shared/messaging.js';
|
||||||
|
|
||||||
let currentIcon = null;
|
let currentIcon = null;
|
||||||
|
let currentPanel = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前选中的文本
|
* 获取当前选中的文本
|
||||||
@ -83,8 +87,25 @@ export function hasSelection() {
|
|||||||
* @param {MouseEvent} event
|
* @param {MouseEvent} event
|
||||||
*/
|
*/
|
||||||
function handleMouseUp(event) {
|
function handleMouseUp(event) {
|
||||||
|
// 如果点击的是当前图标元素,则直接返回
|
||||||
|
if (currentIcon && (currentIcon.element == event.target || currentIcon.element.contains(event.target))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果点击的是当前面板元素,则直接返回
|
||||||
|
if (currentPanel && (currentPanel.element == event.target || currentPanel.element.contains(event.target))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 延迟执行,等待选区完成
|
// 延迟执行,等待选区完成
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
|
// 检查划词功能是否启用
|
||||||
|
const enabled = await isSelectionEnabled();
|
||||||
|
if (!enabled) {
|
||||||
|
logger.info('Selection is disabled, skipping icon display');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedText = getSelectedText();
|
const selectedText = getSelectedText();
|
||||||
|
|
||||||
if (selectedText.length > 0) {
|
if (selectedText.length > 0) {
|
||||||
@ -114,17 +135,89 @@ function handleMouseUp(event) {
|
|||||||
* @param {number} y - Y坐标
|
* @param {number} y - Y坐标
|
||||||
*/
|
*/
|
||||||
function showSaladIcon(x, y) {
|
function showSaladIcon(x, y) {
|
||||||
// 隐藏旧图标
|
// 隐藏旧图标和面板
|
||||||
if (currentIcon) {
|
if (currentIcon) {
|
||||||
currentIcon.destroy();
|
currentIcon.destroy();
|
||||||
currentIcon = null;
|
currentIcon = null;
|
||||||
}
|
}
|
||||||
|
if (currentPanel) {
|
||||||
|
currentPanel.destroy();
|
||||||
|
currentPanel = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 创建新图标,传入点击回调
|
// 创建新图标,传入点击回调
|
||||||
currentIcon = createSaladIcon(x, y, {
|
currentIcon = createSaladIcon(x, y, {
|
||||||
onClick: (event) => {
|
onClick: async (event) => {
|
||||||
logger.info('Icon click callback triggered');
|
logger.info('Icon clicked, showing panel');
|
||||||
// 点击后图标不消失(后续再处理)
|
|
||||||
|
// 获取选中的文本
|
||||||
|
const selectedText = getSelectedText();
|
||||||
|
|
||||||
|
// 图标消失
|
||||||
|
if (currentIcon) {
|
||||||
|
currentIcon.destroy();
|
||||||
|
currentIcon = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示面板在图标右下方
|
||||||
|
const panelX = x + 24; // 图标宽度
|
||||||
|
const panelY = y + 24; // 图标高度
|
||||||
|
currentPanel = new DictPanel();
|
||||||
|
currentPanel.show(panelX, panelY);
|
||||||
|
console.log('[SaladDict] Panel shown at:', panelX, panelY);
|
||||||
|
|
||||||
|
// 执行词典查询
|
||||||
|
if (selectedText) {
|
||||||
|
// 先显示 loading
|
||||||
|
currentPanel.showLoading();
|
||||||
|
|
||||||
|
// 设置超时处理
|
||||||
|
const timeoutMs = 5000;
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('TIMEOUT')), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[SaladDict] Searching for:', selectedText);
|
||||||
|
|
||||||
|
// 竞速:查询 vs 超时
|
||||||
|
const response = await Promise.race([
|
||||||
|
messaging.sendToBackground('DICT.SEARCH', { word: selectedText }),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('[SaladDict] Search response:', response);
|
||||||
|
|
||||||
|
// 显示查询结果
|
||||||
|
if (response?.results && response.results.length > 0) {
|
||||||
|
// 显示所有词典结果
|
||||||
|
currentPanel.renderMultipleResults(response.results);
|
||||||
|
logger.info('Search results rendered:', response.results.length, 'dictionaries');
|
||||||
|
} else {
|
||||||
|
currentPanel.renderResult({
|
||||||
|
word: selectedText,
|
||||||
|
phonetic: '',
|
||||||
|
meanings: [{ partOfSpeech: 'n.', definitions: ['暂无释义'] }],
|
||||||
|
examples: []
|
||||||
|
});
|
||||||
|
logger.warn('No search results for:', selectedText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SaladDict] Search failed:', error);
|
||||||
|
|
||||||
|
if (error.message === 'TIMEOUT') {
|
||||||
|
currentPanel.showTimeout();
|
||||||
|
logger.warn('Search timeout for:', selectedText);
|
||||||
|
} else {
|
||||||
|
currentPanel.renderResult({
|
||||||
|
word: selectedText,
|
||||||
|
phonetic: '',
|
||||||
|
meanings: [{ partOfSpeech: 'n.', definitions: ['查询失败,请重试'] }],
|
||||||
|
examples: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -132,7 +225,7 @@ function showSaladIcon(x, y) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理页面点击事件(用于隐藏图标)
|
* 处理页面点击事件(用于隐藏图标和面板)
|
||||||
* @param {MouseEvent} event
|
* @param {MouseEvent} event
|
||||||
*/
|
*/
|
||||||
function handleDocumentClick(event) {
|
function handleDocumentClick(event) {
|
||||||
@ -142,6 +235,50 @@ function handleDocumentClick(event) {
|
|||||||
currentIcon = null;
|
currentIcon = null;
|
||||||
logger.info('Icon hidden by document click');
|
logger.info('Icon hidden by document click');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果点击的不是面板元素,则隐藏面板
|
||||||
|
if (currentPanel && currentPanel.element !== event.target && !currentPanel.element.contains(event.target)) {
|
||||||
|
currentPanel.destroy();
|
||||||
|
currentPanel = null;
|
||||||
|
logger.info('Panel hidden by document click');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理键盘事件
|
||||||
|
* @param {KeyboardEvent} event
|
||||||
|
*/
|
||||||
|
function handleKeyDown(event) {
|
||||||
|
// ESC 键关闭面板
|
||||||
|
if (event.key === 'Escape' && currentPanel) {
|
||||||
|
currentPanel.destroy();
|
||||||
|
currentPanel = null;
|
||||||
|
logger.info('Panel hidden by ESC key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unbindConfigListener = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理配置变更
|
||||||
|
* @param {Object} newConfig
|
||||||
|
*/
|
||||||
|
function handleConfigChange(newConfig) {
|
||||||
|
const enabled = newConfig?.general?.enableSelection ?? true;
|
||||||
|
|
||||||
|
logger.info('Config changed, selection enabled:', enabled);
|
||||||
|
|
||||||
|
// 如果禁用划词,隐藏当前图标和面板
|
||||||
|
if (!enabled) {
|
||||||
|
if (currentIcon) {
|
||||||
|
currentIcon.destroy();
|
||||||
|
currentIcon = null;
|
||||||
|
}
|
||||||
|
if (currentPanel) {
|
||||||
|
currentPanel.destroy();
|
||||||
|
currentPanel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -151,11 +288,16 @@ export function initSelectionListener() {
|
|||||||
logger.info('Initializing selection listener');
|
logger.info('Initializing selection listener');
|
||||||
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
// 延迟添加点击监听,避免与 mouseup 冲突
|
// 延迟添加点击监听,避免与 mouseup 冲突
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', handleDocumentClick);
|
document.addEventListener('click', handleDocumentClick);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
// 监听配置变更
|
||||||
|
unbindConfigListener = ConfigManager.onChange(handleConfigChange);
|
||||||
|
|
||||||
logger.info('Selection listener initialized');
|
logger.info('Selection listener initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,5 +307,13 @@ export function initSelectionListener() {
|
|||||||
export function destroySelectionListener() {
|
export function destroySelectionListener() {
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
document.removeEventListener('click', handleDocumentClick);
|
document.removeEventListener('click', handleDocumentClick);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// 取消配置监听
|
||||||
|
if (unbindConfigListener) {
|
||||||
|
unbindConfigListener();
|
||||||
|
unbindConfigListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Selection listener destroyed');
|
logger.info('Selection listener destroyed');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,23 +5,107 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>沙拉查词</title>
|
<title>沙拉查词</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
h1 {
|
|
||||||
margin: 0 0 16px 0;
|
.header {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 14px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 24px;
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.active {
|
||||||
|
background: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.active::after {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 60px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>沙拉查词</h1>
|
<div class="header">
|
||||||
<p>Popup 页面占位</p>
|
<h1>沙拉查词</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="setting-item">
|
||||||
|
<span class="setting-label">启用划词</span>
|
||||||
|
<div class="toggle" id="enableSelectionToggle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="placeholder">
|
||||||
|
<p>在网页中划选文本即可查词</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="./index.js"></script>
|
<script type="module" src="./index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,6 +1,37 @@
|
|||||||
// Popup entry
|
// Popup entry
|
||||||
console.log('[SaladDict] Popup opened')
|
import { ConfigManager } from '../shared/config.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
console.log('[SaladDict] Popup opened');
|
||||||
console.log('[SaladDict] Popup DOM ready')
|
|
||||||
})
|
/**
|
||||||
|
* 初始化 Popup
|
||||||
|
*/
|
||||||
|
async function initPopup() {
|
||||||
|
const toggle = document.getElementById('enableSelectionToggle');
|
||||||
|
|
||||||
|
// 加载当前状态
|
||||||
|
const isEnabled = await ConfigManager.get('general.enableSelection', true);
|
||||||
|
|
||||||
|
// 设置开关状态
|
||||||
|
if (isEnabled) {
|
||||||
|
toggle.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听开关点击
|
||||||
|
toggle.addEventListener('click', async () => {
|
||||||
|
const newState = !toggle.classList.contains('active');
|
||||||
|
|
||||||
|
// 更新 UI
|
||||||
|
toggle.classList.toggle('active');
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
await ConfigManager.set('general.enableSelection', newState);
|
||||||
|
|
||||||
|
console.log('[SaladDict] Selection enabled:', newState);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SaladDict] Popup initialized, selection enabled:', isEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM 就绪后初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', initPopup);
|
||||||
|
|||||||
143
src/shared/config.js
Normal file
143
src/shared/config.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* @file 配置管理模块
|
||||||
|
* @description 统一的配置读写和监听接口
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'salad_config';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
general: {
|
||||||
|
enableSelection: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度合并对象
|
||||||
|
*/
|
||||||
|
function deepMerge(target, source) {
|
||||||
|
const result = { ...target };
|
||||||
|
for (const key in source) {
|
||||||
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
|
result[key] = deepMerge(result[key] || {}, source[key]);
|
||||||
|
} else {
|
||||||
|
result[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路径获取对象值
|
||||||
|
* @param {Object} obj - 对象
|
||||||
|
* @param {string} path - 路径,如 'general.enableSelection'
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
function getByPath(obj, path) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let value = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
value = value[key];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路径设置对象值
|
||||||
|
* @param {Object} obj - 对象
|
||||||
|
* @param {string} path - 路径
|
||||||
|
* @param {any} value - 值
|
||||||
|
*/
|
||||||
|
function setByPath(obj, path, value) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let target = obj;
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
if (!(key in target) || typeof target[key] !== 'object') {
|
||||||
|
target[key] = {};
|
||||||
|
}
|
||||||
|
target = target[key];
|
||||||
|
}
|
||||||
|
target[keys[keys.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置管理器
|
||||||
|
*/
|
||||||
|
export const ConfigManager = {
|
||||||
|
/**
|
||||||
|
* 获取完整配置
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getConfig() {
|
||||||
|
try {
|
||||||
|
const result = await chrome.storage.local.get(STORAGE_KEY);
|
||||||
|
return deepMerge(DEFAULT_CONFIG, result[STORAGE_KEY] || {});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ConfigManager] Failed to get config:', error);
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存完整配置
|
||||||
|
* @param {Object} config
|
||||||
|
*/
|
||||||
|
async saveConfig(config) {
|
||||||
|
try {
|
||||||
|
await chrome.storage.local.set({ [STORAGE_KEY]: config });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ConfigManager] Failed to save config:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定配置项
|
||||||
|
* @param {string} path - 路径,如 'general.enableSelection'
|
||||||
|
* @param {any} defaultValue - 默认值
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
async get(path, defaultValue) {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
const value = getByPath(config, path);
|
||||||
|
return value !== undefined ? value : defaultValue;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置指定配置项
|
||||||
|
* @param {string} path - 路径
|
||||||
|
* @param {any} value - 值
|
||||||
|
*/
|
||||||
|
async set(path, value) {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
setByPath(config, path, value);
|
||||||
|
await this.saveConfig(config);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听配置变更
|
||||||
|
* @param {Function} callback - 回调函数(changes, newConfig)
|
||||||
|
* @returns {Function} 取消监听的函数
|
||||||
|
*/
|
||||||
|
onChange(callback) {
|
||||||
|
const handler = (changes) => {
|
||||||
|
if (changes[STORAGE_KEY]) {
|
||||||
|
const newValue = changes[STORAGE_KEY].newValue;
|
||||||
|
const oldValue = changes[STORAGE_KEY].oldValue;
|
||||||
|
callback(newValue, oldValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
chrome.storage.onChanged.addListener(handler);
|
||||||
|
|
||||||
|
// 返回取消监听的函数
|
||||||
|
return () => chrome.storage.onChanged.removeListener(handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速检查划词功能是否启用
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function isSelectionEnabled() {
|
||||||
|
return ConfigManager.get('general.enableSelection', true);
|
||||||
|
}
|
||||||
125
src/shared/dictionary/base.js
Normal file
125
src/shared/dictionary/base.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @file 词典接口基类
|
||||||
|
* @description 定义词典抽象接口,所有词典实现需继承此类
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 词典查询结果结构
|
||||||
|
* @typedef {Object} DictionaryResult
|
||||||
|
* @property {string} word - 查询的单词
|
||||||
|
* @property {string} [phonetic] - 音标
|
||||||
|
* @property {Array<Meaning>} meanings - 释义列表
|
||||||
|
* @property {Array<Example>} [examples] - 例句列表
|
||||||
|
* @property {string} [url] - 词典页面链接
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释义项结构
|
||||||
|
* @typedef {Object} Meaning
|
||||||
|
* @property {string} partOfSpeech - 词性(如 n., v., adj.)
|
||||||
|
* @property {Array<string>} definitions - 中文释义列表
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 例句结构
|
||||||
|
* @typedef {Object} Example
|
||||||
|
* @property {string} sentence - 英文例句
|
||||||
|
* @property {string} [translation] - 中文翻译
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 词典基类
|
||||||
|
* 所有词典实现必须继承此类并实现 search 方法
|
||||||
|
*/
|
||||||
|
export class DictionaryBase {
|
||||||
|
/**
|
||||||
|
* @param {Object} config - 词典配置
|
||||||
|
* @param {string} config.name - 词典名称
|
||||||
|
* @param {string} [config.icon] - 词典图标路径
|
||||||
|
* @param {Array<string>} [config.languages] - 支持的语言代码列表
|
||||||
|
* @param {Object} [config.options] - 其他配置选项
|
||||||
|
*/
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.name = config.name || 'Unknown';
|
||||||
|
this.icon = config.icon || '';
|
||||||
|
this.languages = config.languages || ['en', 'zh'];
|
||||||
|
this.options = config.options || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单词
|
||||||
|
* @param {string} word - 要查询的单词
|
||||||
|
* @returns {Promise<DictionaryResult>} 查询结果
|
||||||
|
* @throws {Error} 子类必须实现此方法
|
||||||
|
*/
|
||||||
|
async search(word) {
|
||||||
|
throw new Error('search() method must be implemented by subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否支持指定语言
|
||||||
|
* @param {string} lang - 语言代码(如 'en', 'zh')
|
||||||
|
* @returns {boolean} 是否支持
|
||||||
|
*/
|
||||||
|
supports(lang) {
|
||||||
|
return this.languages.includes(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取词典信息
|
||||||
|
* @returns {Object} 词典信息
|
||||||
|
*/
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
icon: this.icon,
|
||||||
|
languages: this.languages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标准查询结果对象
|
||||||
|
* @param {Object} data - 结果数据
|
||||||
|
* @param {string} data.word - 单词
|
||||||
|
* @param {string} [data.phonetic] - 音标
|
||||||
|
* @param {Array<Meaning>} [data.meanings] - 释义列表
|
||||||
|
* @param {Array<Example>} [data.examples] - 例句列表
|
||||||
|
* @param {string} [data.url] - 词典页面链接
|
||||||
|
* @returns {DictionaryResult} 标准化的查询结果
|
||||||
|
*/
|
||||||
|
export function createResult(data) {
|
||||||
|
return {
|
||||||
|
word: data.word || '',
|
||||||
|
phonetic: data.phonetic || '',
|
||||||
|
meanings: data.meanings || [],
|
||||||
|
examples: data.examples || [],
|
||||||
|
url: data.url || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建释义项
|
||||||
|
* @param {string} partOfSpeech - 词性
|
||||||
|
* @param {Array<string>} definitions - 释义列表
|
||||||
|
* @returns {Meaning} 释义对象
|
||||||
|
*/
|
||||||
|
export function createMeaning(partOfSpeech, definitions) {
|
||||||
|
return {
|
||||||
|
partOfSpeech: partOfSpeech || '',
|
||||||
|
definitions: Array.isArray(definitions) ? definitions : [definitions]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建例句
|
||||||
|
* @param {string} sentence - 英文例句
|
||||||
|
* @param {string} [translation] - 中文翻译
|
||||||
|
* @returns {Example} 例句对象
|
||||||
|
*/
|
||||||
|
export function createExample(sentence, translation = '') {
|
||||||
|
return {
|
||||||
|
sentence: sentence || '',
|
||||||
|
translation: translation || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
186
src/shared/dictionary/bing.js
Normal file
186
src/shared/dictionary/bing.js
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* @file 必应词典实现
|
||||||
|
* @description 必应词典的真实实现,通过 HTTP 请求获取数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DictionaryBase, createResult, createMeaning, createExample } from './base.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 必应词典实现
|
||||||
|
*/
|
||||||
|
export class BingDictionary extends DictionaryBase {
|
||||||
|
constructor(config = {}) {
|
||||||
|
super({
|
||||||
|
name: '必应词典',
|
||||||
|
icon: 'icons/bing.png',
|
||||||
|
languages: ['en', 'zh'],
|
||||||
|
...config
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单词
|
||||||
|
* @param {string} word - 要查询的单词
|
||||||
|
* @returns {Promise<DictionaryResult>} 查询结果
|
||||||
|
*/
|
||||||
|
async search(word) {
|
||||||
|
if (!word?.trim()) {
|
||||||
|
throw new Error('查询单词不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedWord = word.trim();
|
||||||
|
const url = `https://cn.bing.com/dict/search?q=${encodeURIComponent(trimmedWord)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// 使用正则提取数据
|
||||||
|
return this._parseHtml(html, trimmedWord, url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BingDictionary] Search failed:', error);
|
||||||
|
|
||||||
|
return createResult({
|
||||||
|
word: trimmedWord,
|
||||||
|
phonetic: '',
|
||||||
|
meanings: [createMeaning('提示', ['查询失败,请检查网络连接'])],
|
||||||
|
examples: [],
|
||||||
|
url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析必应词典 HTML(使用正则)
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @param {string} word - 查询的单词
|
||||||
|
* @param {string} url - 查询 URL
|
||||||
|
* @returns {DictionaryResult} 解析结果
|
||||||
|
*/
|
||||||
|
_parseHtml(html, word, url) {
|
||||||
|
return createResult({
|
||||||
|
word,
|
||||||
|
phonetic: this._extractPhonetic(html),
|
||||||
|
meanings: this._extractMeanings(html),
|
||||||
|
examples: this._extractExamples(html),
|
||||||
|
url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取音标
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @returns {string} 音标
|
||||||
|
*/
|
||||||
|
_extractPhonetic(html) {
|
||||||
|
// 匹配音标格式如 [həˈləʊ] 或 /həˈləʊ/
|
||||||
|
const match = html.match(/\[[\u0250-\u02AEˈˌa-zA-Z]+\]/);
|
||||||
|
if (match) {
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备选:匹配 /.../ 格式
|
||||||
|
const match2 = html.match(/\/[\u0250-\u02AEˈˌa-zA-Z]+\//);
|
||||||
|
if (match2) {
|
||||||
|
return match2[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取释义
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @returns {Array<Meaning>} 释义列表
|
||||||
|
*/
|
||||||
|
_extractMeanings(html) {
|
||||||
|
const meanings = [];
|
||||||
|
|
||||||
|
// 尝试匹配常见的词典释义格式
|
||||||
|
// 格式1: <span class="pos">n.</span><span class="def">定义</span>
|
||||||
|
const posDefPattern = /<[^>]*class="[^"]*(?:pos|web)[^"]*"[^>]*>([^<]+)<\/[^>]*>\s*<[^>]*class="[^"]*(?:def|tran)[^"]*"[^>]*>([^<]+)/gi;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = posDefPattern.exec(html)) !== null) {
|
||||||
|
const partOfSpeech = match[1].trim();
|
||||||
|
const definition = match[2].trim();
|
||||||
|
if (partOfSpeech && definition) {
|
||||||
|
meanings.push(createMeaning(partOfSpeech, [definition]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式2: 直接匹配 "词性. 释义" 格式
|
||||||
|
if (meanings.length === 0) {
|
||||||
|
const simplePattern = /([a-z]+\.?)\s*([^<\n]{2,30})/gi;
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
while ((match = simplePattern.exec(html)) !== null) {
|
||||||
|
const partOfSpeech = match[1].trim();
|
||||||
|
const definition = match[2].trim();
|
||||||
|
|
||||||
|
// 过滤无效结果
|
||||||
|
if (!partOfSpeech.match(/^[a-z]+\.?$/i)) continue;
|
||||||
|
if (definition.length < 2 || definition.length > 30) continue;
|
||||||
|
if (seen.has(definition)) continue;
|
||||||
|
|
||||||
|
seen.add(definition);
|
||||||
|
meanings.push(createMeaning(partOfSpeech, [definition]));
|
||||||
|
|
||||||
|
if (meanings.length >= 5) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meanings.length > 0 ? meanings : [createMeaning('n.', ['暂无释义'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取例句
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @returns {Array<Example>} 例句列表
|
||||||
|
*/
|
||||||
|
_extractExamples(html) {
|
||||||
|
const examples = [];
|
||||||
|
|
||||||
|
// 匹配例句模式:英文句子后跟中文翻译
|
||||||
|
// 尝试匹配 <li> 或 <div> 中的例句
|
||||||
|
const sentencePattern = /<[^>]*>([^<]{10,100}[a-zA-Z][^<]{0,50})<\/[^>]*>\s*<[^>]*>([^<]{5,50}[\u4e00-\u9fa5][^<]{0,50})<\/[^>]*>/gi;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
while ((match = sentencePattern.exec(html)) !== null) {
|
||||||
|
const sentence = match[1].trim();
|
||||||
|
const translation = match[2].trim();
|
||||||
|
|
||||||
|
if (seen.has(sentence)) continue;
|
||||||
|
seen.add(sentence);
|
||||||
|
|
||||||
|
// 验证:英文句子应该包含空格且长度合适
|
||||||
|
if (sentence.length > 10 && sentence.length < 150 && sentence.includes(' ')) {
|
||||||
|
examples.push(createExample(sentence, translation));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (examples.length >= 2) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return examples;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const bingDictionary = new BingDictionary();
|
||||||
9
src/shared/dictionary/index.js
Normal file
9
src/shared/dictionary/index.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @file 词典模块入口
|
||||||
|
* @description 导出词典基类、管理器和具体实现
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DictionaryBase, createResult, createMeaning, createExample } from './base.js';
|
||||||
|
export { DictionaryManager, dictionaryManager } from './manager.js';
|
||||||
|
export { BingDictionary, bingDictionary } from './bing.js';
|
||||||
|
export { YoudaoDictionary, youdaoDictionary } from './youdao.js';
|
||||||
140
src/shared/dictionary/manager.js
Normal file
140
src/shared/dictionary/manager.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* @file 词典管理器
|
||||||
|
* @description 管理多个词典实例,支持注册、查询和获取词典列表
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DictionaryBase } from './base.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 词典管理器类
|
||||||
|
* 负责管理所有词典实例的注册和查询
|
||||||
|
*/
|
||||||
|
class DictionaryManager {
|
||||||
|
constructor() {
|
||||||
|
/** @type {Map<string, DictionaryBase>} */
|
||||||
|
this.dictionaries = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册词典实例
|
||||||
|
* @param {string} name - 词典唯一标识名
|
||||||
|
* @param {DictionaryBase} dictionaryInstance - 词典实例
|
||||||
|
* @throws {Error} 名称不能为空或实例必须继承 DictionaryBase
|
||||||
|
*/
|
||||||
|
register(name, dictionaryInstance) {
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
throw new Error('Dictionary name must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(dictionaryInstance instanceof DictionaryBase)) {
|
||||||
|
throw new Error('Dictionary instance must extend DictionaryBase');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dictionaries.set(name, dictionaryInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消注册词典
|
||||||
|
* @param {string} name - 词典名称
|
||||||
|
* @returns {boolean} 是否成功移除
|
||||||
|
*/
|
||||||
|
unregister(name) {
|
||||||
|
return this.dictionaries.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定名称的词典
|
||||||
|
* @param {string} name - 词典名称
|
||||||
|
* @returns {DictionaryBase|undefined} 词典实例
|
||||||
|
*/
|
||||||
|
get(name) {
|
||||||
|
return this.dictionaries.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已注册的词典
|
||||||
|
* @returns {Array<{name: string, dictionary: DictionaryBase}>} 词典列表
|
||||||
|
*/
|
||||||
|
getAll() {
|
||||||
|
const result = [];
|
||||||
|
for (const [name, dictionary] of this.dictionaries) {
|
||||||
|
result.push({ name, dictionary });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有词典名称列表
|
||||||
|
* @returns {Array<string>} 词典名称数组
|
||||||
|
*/
|
||||||
|
getNames() {
|
||||||
|
return Array.from(this.dictionaries.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单词
|
||||||
|
* @param {string} word - 要查询的单词
|
||||||
|
* @param {Array<string>} [dictNames] - 指定查询的词典名称列表,不传则查询所有
|
||||||
|
* @returns {Promise<Object>} 查询结果 {results: Array<{name: string, result: DictionaryResult}>, errors: Array<{name: string, error: string}>}
|
||||||
|
*/
|
||||||
|
async search(word, dictNames = []) {
|
||||||
|
if (!word || typeof word !== 'string' || !word.trim()) {
|
||||||
|
throw new Error('Search word must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNames = dictNames.length > 0
|
||||||
|
? dictNames.filter(name => this.dictionaries.has(name))
|
||||||
|
: this.getNames();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// 并行查询所有目标词典
|
||||||
|
const promises = targetNames.map(async (name) => {
|
||||||
|
const dictionary = this.dictionaries.get(name);
|
||||||
|
try {
|
||||||
|
const result = await dictionary.search(word.trim());
|
||||||
|
results.push({ name, result });
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
name,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
return { results, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查词典是否已注册
|
||||||
|
* @param {string} name - 词典名称
|
||||||
|
* @returns {boolean} 是否已注册
|
||||||
|
*/
|
||||||
|
has(name) {
|
||||||
|
return this.dictionaries.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已注册词典数量
|
||||||
|
* @returns {number} 词典数量
|
||||||
|
*/
|
||||||
|
get count() {
|
||||||
|
return this.dictionaries.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有词典
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.dictionaries.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const dictionaryManager = new DictionaryManager();
|
||||||
|
|
||||||
|
// 导出类,方便需要时创建新实例
|
||||||
|
export { DictionaryManager };
|
||||||
181
src/shared/dictionary/youdao.js
Normal file
181
src/shared/dictionary/youdao.js
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* @file 有道词典实现
|
||||||
|
* @description 有道词典的实现,通过 HTTP 请求获取数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DictionaryBase, createResult, createMeaning, createExample } from './base.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有道词典实现
|
||||||
|
*/
|
||||||
|
export class YoudaoDictionary extends DictionaryBase {
|
||||||
|
constructor(config = {}) {
|
||||||
|
super({
|
||||||
|
name: '有道词典',
|
||||||
|
icon: 'icons/youdao.png',
|
||||||
|
languages: ['en', 'zh'],
|
||||||
|
...config
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单词
|
||||||
|
* @param {string} word - 要查询的单词
|
||||||
|
* @returns {Promise<DictionaryResult>} 查询结果
|
||||||
|
*/
|
||||||
|
async search(word) {
|
||||||
|
if (!word?.trim()) {
|
||||||
|
throw new Error('查询单词不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedWord = word.trim();
|
||||||
|
const url = `https://dict.youdao.com/result?word=${encodeURIComponent(trimmedWord)}&lang=en`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// 使用正则提取数据
|
||||||
|
return this._parseHtml(html, trimmedWord, url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[YoudaoDictionary] Search failed:', error);
|
||||||
|
|
||||||
|
return createResult({
|
||||||
|
word: trimmedWord,
|
||||||
|
phonetic: '',
|
||||||
|
meanings: [createMeaning('提示', ['查询失败,请检查网络连接'])],
|
||||||
|
examples: [],
|
||||||
|
url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析有道词典 HTML(使用正则)
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @param {string} word - 查询的单词
|
||||||
|
* @param {string} url - 查询 URL
|
||||||
|
* @returns {DictionaryResult} 解析结果
|
||||||
|
*/
|
||||||
|
_parseHtml(html, word, url) {
|
||||||
|
return createResult({
|
||||||
|
word,
|
||||||
|
phonetic: this._extractPhonetic(html),
|
||||||
|
meanings: this._extractMeanings(html),
|
||||||
|
examples: this._extractExamples(html),
|
||||||
|
url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取音标
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @returns {string} 音标
|
||||||
|
*/
|
||||||
|
_extractPhonetic(html) {
|
||||||
|
// 匹配音标格式
|
||||||
|
const match = html.match(/\[[\u0250-\u02AEˈˌa-zA-Z]+\]/);
|
||||||
|
if (match) {
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const match2 = html.match(/\/[\u0250-\u02AEˈˌa-zA-Z]+\//);
|
||||||
|
if (match2) {
|
||||||
|
return match2[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取释义
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @returns {Array<Meaning>} 释义列表
|
||||||
|
*/
|
||||||
|
_extractMeanings(html) {
|
||||||
|
const meanings = [];
|
||||||
|
|
||||||
|
// 尝试匹配常见的词典释义格式
|
||||||
|
const posDefPattern = /<[^>]*class="[^"]*(?:pos|trans)[^"]*"[^>]*>([^<]+)<\/[^>]*>\s*<[^>]*class="[^"]*(?:def|chn)[^"]*"[^>]*>([^<]+)/gi;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = posDefPattern.exec(html)) !== null) {
|
||||||
|
const partOfSpeech = match[1].trim();
|
||||||
|
const definition = match[2].trim();
|
||||||
|
if (partOfSpeech && definition) {
|
||||||
|
meanings.push(createMeaning(partOfSpeech, [definition]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备选:直接匹配 "词性. 释义" 格式
|
||||||
|
if (meanings.length === 0) {
|
||||||
|
const simplePattern = /([a-z]+\.?)\s*([^<\n]{2,30})/gi;
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
while ((match = simplePattern.exec(html)) !== null) {
|
||||||
|
const partOfSpeech = match[1].trim();
|
||||||
|
const definition = match[2].trim();
|
||||||
|
|
||||||
|
if (!partOfSpeech.match(/^[a-z]+\.?$/i)) continue;
|
||||||
|
if (definition.length < 2 || definition.length > 30) continue;
|
||||||
|
if (seen.has(definition)) continue;
|
||||||
|
|
||||||
|
seen.add(definition);
|
||||||
|
meanings.push(createMeaning(partOfSpeech, [definition]));
|
||||||
|
|
||||||
|
if (meanings.length >= 5) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meanings.length > 0 ? meanings : [createMeaning('n.', ['暂无释义'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取例句
|
||||||
|
* @private
|
||||||
|
* @param {string} html - HTML 内容
|
||||||
|
* @returns {Array<Example>} 例句列表
|
||||||
|
*/
|
||||||
|
_extractExamples(html) {
|
||||||
|
const examples = [];
|
||||||
|
|
||||||
|
// 匹配例句模式
|
||||||
|
const sentencePattern = /<[^>]*>([^<]{10,100}[a-zA-Z][^<]{0,50})<\/[^>]*>\s*<[^>]*>([^<]{5,50}[\u4e00-\u9fa5][^<]{0,50})<\/[^>]*>/gi;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
while ((match = sentencePattern.exec(html)) !== null) {
|
||||||
|
const sentence = match[1].trim();
|
||||||
|
const translation = match[2].trim();
|
||||||
|
|
||||||
|
if (seen.has(sentence)) continue;
|
||||||
|
seen.add(sentence);
|
||||||
|
|
||||||
|
if (sentence.length > 10 && sentence.length < 150 && sentence.includes(' ')) {
|
||||||
|
examples.push(createExample(sentence, translation));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (examples.length >= 2) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return examples;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const youdaoDictionary = new YoudaoDictionary();
|
||||||
Loading…
x
Reference in New Issue
Block a user