Compare commits

..

17 Commits

Author SHA1 Message Date
李岩岩
a83d5d7bb9 fix(M3): 使用正则表达式替代 DOMParser,修复 Service Worker 环境限制 2026-02-11 15:01:25 +08:00
李岩岩
64290b9dd1 fix(M3): 词典直接使用 fetch 替代 messaging,修复 'Receiving end does not exist' 错误 2026-02-11 14:56:31 +08:00
李岩岩
83dc377efd feat(M3.11): 词典图标标识 (v0.2.11) 2026-02-11 14:23:52 +08:00
李岩岩
737902388a feat(M3.10): 结果折叠/展开 (v0.2.10) 2026-02-11 14:23:22 +08:00
李岩岩
971a0c9dd9 feat(M3.9): 有道词典实现 (v0.2.9) 2026-02-11 14:22:42 +08:00
李岩岩
ff6c884744 feat(M3.8): 加载状态显示 (v0.2.8) 2026-02-11 14:21:43 +08:00
李岩岩
e279da0c8b feat(M3.7): 必应词典真实API (v0.2.7) 2026-02-11 14:21:08 +08:00
李岩岩
7bb557be01 feat(M3.6): 点击图标查词(Mock数据) (v0.2.6) 2026-02-11 14:20:28 +08:00
李岩岩
70a76ac438 feat(M3.5): 结果展示组件(静态) (v0.2.5) 2026-02-11 14:19:59 +08:00
李岩岩
fde62ba8fb feat(M3.4): 后台查询接口 (v0.2.4) 2026-02-11 14:19:30 +08:00
李岩岩
09f0978025 feat(M3.3): 必应词典实现(Mock版) (v0.2.3) 2026-02-11 14:19:10 +08:00
李岩岩
e677daa0c0 feat(M3.2): 词典管理器 (v0.2.2) 2026-02-11 14:18:44 +08:00
李岩岩
90ba3284d2 feat(M3.1): 词典接口基类设计 (v0.2.1) 2026-02-11 14:18:22 +08:00
李岩岩
725bf8fe71 feat(M2.9): 图标显示开关 (v0.1.9)
- Popup 中添加'启用划词' Toggle 开关
- 开关状态保存到 chrome.storage.local
- 关闭开关后网页划词不再显示图标
- 配置管理统一封装到 src/shared/config.js
- ConfigManager 提供 get/set/onChange API
2026-02-11 14:08:29 +08:00
李岩岩
3c363a14b0 feat(M2.8): 图标-面板联动 (v0.1.8)
- 点击图标显示面板,图标保持可见
- 再次点击图标可切换面板显示/隐藏
- 点击面板外部区域关闭面板
- 按 ESC 键关闭面板
- 优化事件监听:show时绑定,hide时解绑
2026-02-11 11:27:42 +08:00
李岩岩
ee531f4987 feat(M2.7): 面板位置计算 (v0.1.7)
- 面板智能定位,不超出视口
- 右侧/下方超出时自动显示在左侧/上方
- 面板边界与视口保留 10px 边距
- 支持拖拽面板头部更改位置
2026-02-11 10:35:34 +08:00
李岩岩
20dd127fa6 feat(M2.6): 基础面板组件 (v0.1.6)
- 创建 src/content/components/DictPanel.js
- 面板尺寸 400x300px,白色背景,带阴影
- 使用 Shadow DOM 封装样式
- 点击图标显示面板,图标消失
- 点击页面空白处面板消失
2026-02-11 10:06:15 +08:00
18 changed files with 1833 additions and 95 deletions

View File

@ -69,12 +69,12 @@
- [ ] 如果右侧超出视口,显示在左侧 - [ ] 如果右侧超出视口,显示在左侧
- [ ] 如果下方超出视口,显示在上方 - [ ] 如果下方超出视口,显示在上方
- [ ] 面板边界与视口保留 10px 边距 - [ ] 面板边界与视口保留 10px 边距
- [ ] 可以通过拖拽面板头部更改面板位置
### M2.8 图标-面板联动 [目标版本: 0.1.8] ### M2.8 图标-面板联动 [目标版本: 0.1.8]
**任务**: 点击图标显示面板 **任务**: 点击图标显示面板
**验收标准**: **验收标准**:
- [ ] 点击图标,面板显示在图标附近 - [ ] 点击图标,面板显示在图标附近
- [ ] 面板显示时,图标保持可见
- [ ] 点击面板外部区域,面板关闭 - [ ] 点击面板外部区域,面板关闭
- [ ] 按 ESC 键,面板关闭 - [ ] 按 ESC 键,面板关闭

View File

@ -7,7 +7,7 @@
## 版本速查 ## 版本速查
### 当前版本 ### 当前版本
`0.1.5` → 下一目标 `0.1.6` ([M2.6](./M2.md)) `0.2.11` → 下一目标 `0.3.1` ([M4.1](./M4.md))
### 模块版本范围 ### 模块版本范围

View File

@ -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

View File

@ -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%

View File

@ -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",

View File

@ -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",

View File

@ -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');

View 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;
}

View File

@ -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);

View File

@ -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');
} }

View File

@ -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>

View File

@ -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
View 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);
}

View 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 || ''
};
}

View 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();

View 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';

View 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 };

View 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();