commit 140f40c168e99a61e2306f0b90f6c3870ddff2a7 Author: ApplePine Date: Sun Jul 20 11:41:29 2025 +0800 initial: 初始版本 - 在系统课程列表界面读取发送请求,获取课程信息 - 在课程列表界面为课程卡片补充提示信息(包含是否已经学习过、学时、学分、课程类型等) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9fe4f60 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +WXT_PUBLIC_UCLASS_HOSTNAME="xxx.xxxx.cn" +WXT_PUBLIC_INTRANET_HOSTNAME="192.168.1.7" +WXT_PUBLIC_INTRANET_PORT="1234" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1e2d61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.output +stats.html +stats-*.json +.wxt +web-ext.config.ts + +# Editor directories and files +.vscode/ +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# lock file +bun.lock + +# env +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a218c58 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# 金蝶课程系统助手 + +一个专为企业金蝶课程系统设计的浏览器扩展插件,能够为课程视频卡片添加智能提示信息,帮助用户更好地了解课程学习状态。 + +## ✨ 功能特性 + +- 🎯 **智能课程识别**:自动识别页面中的课程列表 +- 📊 **学习状态显示**:显示课程的学习状态(已完成/未完成) +- ⏱️ **学时学分信息**:显示课程的学分和学时信息 +- 🎬 **内容类型识别**:智能判断课程是视频还是文档类型 +- 🎨 **视觉提示**:未完成课程以红色标识,已完成课程保持原色 +- 🔄 **实时更新**:页面刷新后自动重新应用样式和信息 + +## 🚀 安装方法 + +### 方法一:从源码构建(推荐) + +1. 克隆项目到本地: +```bash +git clone https://github.com/App1ePine/kingdeeCourseBot.git +cd kingdeeCourseBot +``` + +2. 安装依赖: +```bash +npm install +``` + +3. 配置环境变量: + - 将 `.env.example` 文件复制并重命名为 `.env` + - 根据您的实际环境修改 `.env` 文件中的配置值 + +4. 构建扩展: +```bash +npm run build +``` + +5. 在浏览器中加载扩展: + - **Chrome/Edge**: 打开 `chrome://extensions/`,开启"开发者模式",点击"加载已解压的扩展程序",选择 `dist` 文件夹 + - **Firefox**: 打开 `about:debugging#/runtime/this-firefox`,点击"加载临时附加组件",选择 `dist` 文件夹中的 `manifest.json` + +### 方法二:开发模式 + +```bash +npm run dev +``` + +## 📖 使用说明 + +1. 安装扩展后,访问金蝶课程系统 +2. 插件会自动检测课程列表页面 +3. 每个课程卡片前会显示格式为 `(学分-学时)-内容类型` 的提示信息 +4. 未完成的课程标题会以红色显示 +5. 已完成的课程保持原色 + +## 🛠️ 技术栈 + +- **框架**: [WXT](https://wxt.dev/) - 现代化的浏览器扩展开发框架 +- **前端**: Vue 3 + JavaScript +- **构建工具**: Vite +- **语言**: JavaScript + +## 🔧 开发 + +### 环境要求 + +- Node.js 16+ +- npm 或 yarn + +### 开发命令 + +```bash +# 开发模式(Chrome) +npm run dev + +# 开发模式(Firefox) +npm run dev:firefox + +# 构建生产版本 +npm run build + +# 构建 Firefox 版本 +npm run build:firefox + +# 打包扩展 +npm run zip + +# 类型检查 +npm run compile +``` + +### 项目结构 + +``` +kingdeeCourseBot/ +├── entrypoints/ +│ ├── background.ts # 后台脚本 +│ ├── content.ts # 内容脚本 +│ └── popup/ # 弹出窗口 +├── components/ # Vue 组件 +├── assets/ # 静态资源 +├── public/ # 公共资源 +└── wxt.config.ts # WXT 配置文件 +``` + +## ⚙️ 配置 + +插件通过环境变量配置目标网站。项目根目录包含 `.env.example` 文件,您需要: + +1. 将 `.env.example` 文件复制并重命名为 `.env` +2. 在 `.env` 文件中配置以下环境变量: + +```bash +# 金蝶课程系统主机名 +WXT_PUBLIC_UCLASS_HOSTNAME=your-uclass-hostname.com + +# 内网主机名 +WXT_PUBLIC_INTRANET_HOSTNAME=your-intranet-hostname.com + +# 内网端口 +WXT_PUBLIC_INTRANET_PORT=8080 +``` + +根据实际情况使用公网域名 或内网IP,请根据您的实际环境修改这些配置值。 diff --git a/assets/vue.svg b/assets/vue.svg new file mode 100644 index 0000000..ca8129c --- /dev/null +++ b/assets/vue.svg @@ -0,0 +1 @@ + diff --git a/entrypoints/background.ts b/entrypoints/background.ts new file mode 100644 index 0000000..45e9a33 --- /dev/null +++ b/entrypoints/background.ts @@ -0,0 +1,3 @@ +export default defineBackground(() => { + console.log('kingdeeCourseAutoPlayer 已启动', { id: browser.runtime.id }); +}) diff --git a/entrypoints/content.ts b/entrypoints/content.ts new file mode 100644 index 0000000..7293db5 --- /dev/null +++ b/entrypoints/content.ts @@ -0,0 +1,144 @@ +export default defineContentScript({ + matches: [ + `*://${import.meta.env.WXT_PUBLIC_UCLASS_HOSTNAME}/*`, + `*://${import.meta.env.WXT_PUBLIC_INTRANET_HOSTNAME}/*` + ], + + world: 'MAIN', + + runAt: 'document_start', + + main() { + const uclassHostname = import.meta.env.WXT_PUBLIC_UCLASS_HOSTNAME; + const intranetHostname = import.meta.env.WXT_PUBLIC_INTRANET_HOSTNAME; + const intranetPort = import.meta.env.WXT_PUBLIC_INTRANET_PORT; + + const isTargetPage = + window.location.hostname === uclassHostname || + (window.location.hostname === intranetHostname && window.location.port === intranetPort) + + if (!isTargetPage) { + return + } + + console.log('[XHR Interceptor] 脚本已通过 WXT 成功注入。') + + if (typeof XMLHttpRequest === 'undefined') { + return + } + + const original_open = XMLHttpRequest.prototype.open + const original_send = XMLHttpRequest.prototype.send + + interface PatchedXHR extends XMLHttpRequest { + _url?: string | URL + } + + XMLHttpRequest.prototype.open = function ( + this: PatchedXHR, + method: string, + url: string | URL, + ...args: any[] + ) { + this._url = url + return original_open.apply(this, arguments as any) + } + + XMLHttpRequest.prototype.send = function (this: PatchedXHR, body) { + this.addEventListener('load', () => { + if ( + this._url && + typeof this._url === 'string' && + this._url.includes('/lms/api//user/course/registList') + ) { + try { + console.log('========== [WXT Main World] 检测到目标XHR请求 ==========') + console.log('请求URL:', this._url) + const responseData = JSON.parse(this.responseText) + console.log('响应JSON数据:', responseData) + + // 检查响应数据是否是我们期望的格式 + if (responseData && responseData.page && Array.isArray(responseData.page.content)) { + // 为课程对象定义一个更详细的类型 + type Course = { + name: string + status: string + score: number | string + hour: number | string + cumulativeStudyCount: number + studyCount: number + [key: string]: any + } + + const courses: Course[] = responseData.page.content + + const cleanupAndApplyStyles = () => { + document.querySelectorAll('.chrome-ext-course-prefix').forEach((el) => el.remove()) + document.querySelectorAll('[data-course-processed="true"]').forEach((el) => { + const htmlEl = el as HTMLElement + const title = htmlEl.querySelector('.title, .course-name, h3, h4') // 尝试一些常见的标题选择器 + if (title) title.style.color = '' + htmlEl.removeAttribute('data-course-processed') + }) + + courses.forEach((course) => { + if (course && typeof course.name === 'string') { + const courseName = course.name.trim() + if (courseName === '') return + + try { + const xpath = `//*[normalize-space(text())="${courseName}"]` + const titleElements: HTMLElement[] = [] + const result = document.evaluate( + xpath, + document, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE, + null, + ) + let node: Node | null + while ((node = result.iterateNext())) { + if (node instanceof HTMLElement) { + titleElements.push(node) + } + } + + titleElements.forEach((titleElement) => { + const parent = titleElement.parentElement + if (!parent) return + + parent.dataset.courseProcessed = 'true' + + const score = course.score ?? 'N/A' + const hour = course.hour ?? 'N/A' + const cumulativeStudyCount = course.cumulativeStudyCount ?? 'N/A' + const studyCount = course.studyCount ?? 'N/A' + const isVideo = cumulativeStudyCount / studyCount > 5 ? '大概率是文档' : '大概率是视频' + const prefixSpan = document.createElement('span') + prefixSpan.className = 'chrome-ext-course-prefix' + prefixSpan.textContent = `(${score}分-${hour}学时)-${isVideo}` + parent.insertBefore(prefixSpan, titleElement) + + titleElement.style.color = course.status !== 'FINISH' ? 'red' : '' + }) + } catch (e) { + console.error(`为课程 "${courseName}" 应用样式时出错:`, e) + } + } + }) + console.log('样式和前缀已更新。') + } + setTimeout(cleanupAndApplyStyles, 1000) + } else { + console.log('[WXT Main World] 响应数据中未找到有效的课程列表 (response.page.content)') + } + console.log('======================================================') + } catch (e) { + console.error('[WXT Main World] 解析XHR响应失败:', e, '原始响应:', this.responseText) + } + } + }) + return original_send.apply(this, arguments as any) + } + }, +}) diff --git a/entrypoints/popup/App.vue b/entrypoints/popup/App.vue new file mode 100644 index 0000000..d6b279d --- /dev/null +++ b/entrypoints/popup/App.vue @@ -0,0 +1,10 @@ + + + + + diff --git a/entrypoints/popup/index.html b/entrypoints/popup/index.html new file mode 100644 index 0000000..5a2184e --- /dev/null +++ b/entrypoints/popup/index.html @@ -0,0 +1,13 @@ + + + + + + Default Popup Title + + + +
+ + + diff --git a/entrypoints/popup/main.ts b/entrypoints/popup/main.ts new file mode 100644 index 0000000..f8c23e4 --- /dev/null +++ b/entrypoints/popup/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import './style.css'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/entrypoints/popup/style.css b/entrypoints/popup/style.css new file mode 100644 index 0000000..7294765 --- /dev/null +++ b/entrypoints/popup/style.css @@ -0,0 +1,80 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c22c68 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "kingdee-course-bot", + "description": "kingdee course bot", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "wxt", + "dev:firefox": "wxt -b firefox", + "build": "wxt build", + "build:firefox": "wxt build -b firefox", + "zip": "wxt zip", + "zip:firefox": "wxt zip -b firefox", + "compile": "vue-tsc --noEmit", + "postinstall": "wxt prepare" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@wxt-dev/module-vue": "^1.0.2", + "typescript": "5.6.3", + "vue-tsc": "^2.2.10", + "wxt": "^0.20.6" + } +} diff --git a/public/icon/128.png b/public/icon/128.png new file mode 100644 index 0000000..9e35d13 Binary files /dev/null and b/public/icon/128.png differ diff --git a/public/icon/16.png b/public/icon/16.png new file mode 100644 index 0000000..cd09f8c Binary files /dev/null and b/public/icon/16.png differ diff --git a/public/icon/32.png b/public/icon/32.png new file mode 100644 index 0000000..f51ce1b Binary files /dev/null and b/public/icon/32.png differ diff --git a/public/icon/48.png b/public/icon/48.png new file mode 100644 index 0000000..cb7a449 Binary files /dev/null and b/public/icon/48.png differ diff --git a/public/icon/96.png b/public/icon/96.png new file mode 100644 index 0000000..c28ad52 Binary files /dev/null and b/public/icon/96.png differ diff --git a/public/wxt.svg b/public/wxt.svg new file mode 100644 index 0000000..0e76320 --- /dev/null +++ b/public/wxt.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..008bc3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.wxt/tsconfig.json" +} diff --git a/wxt.config.ts b/wxt.config.ts new file mode 100644 index 0000000..55fbc4a --- /dev/null +++ b/wxt.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'wxt'; + +// See https://wxt.dev/api/config.html +export default defineConfig({ + modules: ['@wxt-dev/module-vue'], +});