KJ 9 参赛说明 TRAE AI 创造力大赛初赛

KJ 9 —— 浏览器中的桌面操作系统

把完整的桌面 OS 体验搬进浏览器新标签页

1 项目信息

项目说明
项目名称KJ 9 操作系统
作者传说当中的帅锅
当前版本9.1.8
最后更新2026年7月8日

2 核心定位

我一直在做一件事:让浏览器新起始页/主页不只是快捷链接,而是一个真正自定义的、完全开放的系统

KJ 前身为 PPT 操作系统,在B站和抖音有很多人实现自己的自制系统梦,使用 Office 和 PS 等工具进行制作,通过代码和触发器达到真实系统的效果。而帅锅是最早在2015年提出此概念,同时也是创始人, 9 是这个系列的最新一代,采用现代化设计语言,包含:

桌面图标 任务栏 开始菜单 窗口管理 多应用协同 主题定制 数据持久化 自定义应用

如果你喜欢本项目,请给帅锅在抖音视频❤️点赞、收藏,如果发条评论就更好了,谢谢大伙的支持!链接:https://www.douyin.com/video/7657382542038879538

手机端扫码查看 ☝️

3 内置应用

应用说明图标颜色
文件资源管理器管理虚拟文件系统蓝色
记事本文本编辑器 + Markdown 渲染蓝色
计算器计算工具绿色
终端命令行界面 v9.1.8黑色
照片图片浏览管理蓝色
日历日期与农历 + 年份选择器蓝色
音乐音乐播放(模仿 KJ 8.1.0)粉色
设置系统个性化 + 强调色灰色
任务管理器进程 + JS 堆内存监控绿色
关于系统系统信息 + 版本历史蓝色
KJ展览馆系统演进历程(10 个版本)蓝色
参赛说明本页面内嵌窗口渐变红

真实场景:我想解决什么问题?

不是空泛的"做个系统",而是开发中遇到的具体痛点

1 桌面图标自由拖动

问题:原生拖拽在多列布局下会"跳到末尾"。用户想把图标放到计算器下面,却总是跑到列表最后。

期望:支持多列自由拖放,拖动时显示横杠插入标识符。

2 高斯模糊的精细控制

问题:全局模糊会"误伤"照片应用的预览窗口主题,导致预览窗口被切换为浅色主题。

期望:提供"仅标题栏模糊"的分级控制,不影响应用内容区域。

3 网站快捷方式的完整生命周期

问题:创建后无法再次编辑、图标不可自定义、颜色固定单一。

期望

  • 支持从 FontAwesome 可视化选择图标
  • 图标带中文译名,方便查找
  • 支持选择图标颜色
  • 右键可编辑已创建的快捷方式

4 系统可定制性

问题:用户想直接写 CSS 改系统 UI,但又要保证安全可恢复。

期望:在开发人员选项中提供 CSS 自定义板块,支持实时预览和持久化保存。

5 全局强调色单一

问题:系统所有交互元素使用固定 #187aff 蓝色,用户无法自定义。

期望:提供预设色板 + 自定义 HSV 取色器,一键改变全系统配色。

6 Bing 壁纸每次刷新都重新加载

问题:每次打开新标签页都重新请求壁纸 API,加载慢且浪费流量。

期望:按日缓存到本地,加载失败显示灰色背景,加载完成渐变显现。

7 日历交互不够友好

问题:切换月份无动画反馈,跳转到远处年份需要逐月翻页。

期望:月份切换横滑动画 + 点击标题进入年份选择器。

项目数据

代码规模与迭代历程

449KB
app.js 核心逻辑
178KB
style.css 样式表
64KB
index.html 结构
9.1.9
当前版本号
14
内置应用数

1 版本迭代历程

2015
KJ 4 —— 最远古版本
2018
KJ 5 —— 立体棱角设计
2018
KJ 6 —— 通透玻璃设计
2019
KJ 7 —— 液态玻璃设计
2023
KJ 8 —— 整合历代优秀设计
2026
KJ 9 —— 现代化设计语言(当前版本)

任务1:桌面图标多列拖拽

支持多列自由拖放 + 插入标识符

1 任务拆解

  1. 分析现有拖拽逻辑:只支持单列,appendChild 直接追加到末尾
  2. 计算拖拽位置:需要根据鼠标坐标计算"目标索引"
  3. 可视化反馈:拖动时显示横杠标识符,指示插入位置
  4. 多列支持:处理 elementFromPoint 在多列布局下的命中问题

2 关键 Prompt

Prompt > 修复桌面图标无法自由拖动的问题,比如我把图标拖动到计算器的下面, 但是图标会跳到图标的末尾,而不是在计算器的下面,此外加入插入标识符, 当拖动到某个图标的下面的时候,会有一个横杠

3 踩过的坑

第一版:横杠贯穿整个屏幕

CSS 用了 width: 100% 而非相对父容器,导致横杠从屏幕左边延伸到右边。

第二版:只能拖到第一列

elementFromPoint 在多列布局下命中了错误的容器,第二列之后的图标无法作为放置目标。

最终方案

遍历所有图标计算相对位置 + 限定横杠宽度为图标容器宽度,完美解决多列拖放问题。

4 衍生问题

框选图标失效:鼠标左键长按框选图标时,松手后无法选中图标,而是被取消选中。通过修复 mouseup 事件处理逻辑解决。

任务2:高斯模糊分级控制

仅标题栏模糊 + 主题隔离

1 任务拆解

  1. 分析现有模糊:全局 backdrop-filter,影响所有窗口内容
  2. 新增模式:只对 .window-titlebar 应用模糊,窗口内容保持清晰
  3. 主题隔离:照片应用预览窗口被"误判"为浅色主题,需锁定深色
  4. 透明度调节:标题栏透明度太低太透,需调整 rgba 值

2 关键 Prompt 链

Prompt 1 > 在KJ桌面模式下,设置-系统-辅助功能下增加一个高斯模糊选项:仅标题栏高斯模糊
Prompt 2 > 修复bug,仅标题栏高斯模糊效果打开之后,没有任何高斯模糊显示,标题栏应当有高斯模糊
Prompt 3 > 修复高斯模糊当我在设置-系统-辅助功能切换为仅标题栏高斯模糊的时候, 照片应用的预览图片窗口被切换为浅色主题,应当为深色主题
Prompt 4 > 修复问题,标题栏透明度太低了太透了

3 踩过的坑

开关打开后无模糊显示

原因:CSS 选择器优先级被覆盖。通过提升选择器特异性解决。

照片预览窗口主题被改变

原因:模糊样式作用域过大,影响了照片应用的预览窗口。通过为预览窗口单独锁定深色主题解决。

标题栏太透

原因:rgba 透明度值设置过低。调整 alpha 通道值解决,而非调整模糊半径。

任务3:网站快捷方式完整编辑能力

最复杂的一环:图标选择 + 颜色 + 编辑回填

1 子任务拆解

子任务难点解决方式
图标可视化选择 FontAwesome 图标数百个,如何浏览 分类(网站/社交/媒体/技术)+ 搜索 + 网格预览
图标中文译名 用户不认识英文类名 每个图标显示"中文(fa-xxx)"格式
图标颜色选择 固定颜色不够灵活 8 色预设色板 + data-color 持久化
编辑时回填数据 对话框打开后字段为空 调整初始化顺序:先 showDialog() 再设值
保存时更新而非新建 点击"创建"变成新增 引入 editingAppId 状态判断

2 关键 Prompt 链

Prompt 1 > 编辑网站快捷方式可以修改图标,用户可以从FontAwesome中可视化选择图标
Prompt 2 - 报错 > TypeError: Cannot read properties of undefined (reading 'add')
Prompt 3 > 给这些图标加一个中文译名,注意英文名不要删除,此外, 图标的文字备注后面加一个括号(FontAwesome类名)
Prompt 4 > 修复bug,当我右键一个已经创建的网站应用,编辑输入框里无法填充已经创建的网站链接, 点击创建之后又重新创建了新的网站应用
Prompt 5 > 添加快捷方式也可以选择图标颜色

3 踩过的坑(重点)

函数名冲突

selectIcon 同时被桌面图标选中和图标选择器使用,导致 classList.add 报 undefined。

TRAE 帮我定位到冲突点,将图标选择器的函数重命名为 selectShortcutIcon 解决。

对话框初始化顺序

showCreatorDialog() 内部会清空字段,如果先设值再显示,值会被清空。

解决方案:必须先显示再设值,调整代码顺序后解决。

编辑模式判断

原代码无状态标识,保存时无法区分"新建"还是"更新"。

通过 editingAppId 全局变量区分,保存时检查该变量是否存在。

标签页切换问题

编辑代码应用时,对话框默认打开网站标签页,需手动切换。

修改 showCreatorDialog() 接受 initialTab 参数,支持直接指定标签页。

任务4:开发人员选项 - CSS 自定义板块

让用户直接写 CSS 改系统 UI

1 任务拆解

  1. 用户需求:可直接写 CSS 覆盖系统默认样式
  2. 编辑器:集成 CodeMirror(已有依赖),支持语法高亮
  3. 三态操作:应用 / 重置 / 保存
  4. 持久化:保存到 localStorage,刷新不丢失

2 关键 Prompt 链

Prompt 1 > 设置-系统-开发人员选项,添加一个板块,可以通过代码来修改KJ的CSS样式表以及系统UI
Prompt 2 > 不是这样的,是直接可以通过代码修改当前默认的代码
Prompt 3 > 无法应用修改,这是为什么
Prompt 4 > css样式依然无法保存,f12没有报错,不能直接修改源代码吗

3 踩过的坑

无法应用修改

原因:动态 <style> 标签的 ID 冲突,新样式未正确注入。

用户误解:能否直接改源代码

用户问"不能直接修改源代码吗"。TRAE 帮我解释了浏览器环境下只能通过注入 <style> 覆盖,并实现了正确的注入逻辑。

任务5:全局强调色系统

7 预设色 + HSV 自定义取色器 + CSS 变量全局联动

1 任务拆解

  1. CSS 变量定义:在 :root 中定义 --accent-color--accent-hover
  2. 全量替换:将 CSS 中 93+ 处硬编码 #187aff 替换为 var(--accent-color)
  3. 透明度变体:使用 color-mix(in srgb, var(--accent-color) X%, transparent) 替代 rgba()
  4. 预设色板:7 个预设色 + 自定义选项
  5. HSV 取色器:纯 JS 实现 SV 面板 + Hue 色相条 + HEX 输入
  6. 早期加载:在 <head> 内联脚本中读取 localStorage,防止默认色闪烁

2 关键 Prompt 链

Prompt 1 > 新增功能,可以修改KJ系统的全局强调色,目前强调色是#187aff
Prompt 2 - 矢量壁纸被改色 > 修复bug,当强调色切换之后,内置的几个矢量背景皮肤也随之变化了,应当保持蓝色
Prompt 3 - 自定义取色器 > 点击自定义颜色请弹出自定义的取色器,因为浏览器默认这个无法定位到自定义的下面
Prompt 4 - 拟物图标未跟随 > 修复bug当我选择拟物图标的时候,任务栏打开的应用图标无法根据强调色变化

3 技术实现

CSS 变量 + color-mix() 联动

CSS /* :root 定义 */ --accent-color: #187aff; --accent-hover: #0066dd; /* 透明度变体 - 无需预计算多色值 */ background: color-mix(in srgb, var(--accent-color) 15%, transparent); box-shadow: 0 2px 8px color-mix(in srgb, var(--accent-color) 30%, transparent);

HSV 自定义取色器(纯 JS)

JavaScript // SV 面板拖拽 → 实时 HSV → HEX 转换 _accentPickerState = { h: 210, s: 100, v: 100, dragging: null }; function hsvToHex(h, s, v) { // HSV → RGB → HEX 标准转换算法 const c = v * s; const x = c * (1 - Math.abs((h / 60) % 2 - 1)); const m = v - c; // ... 计算 R/G/B 分量 }

早期加载防闪烁

index.html <head> <!-- 在所有 CSS 加载前内联执行 --> <script> const accentColor = localStorage.getItem('kj_accent_color'); if (accentColor) { document.documentElement.style.setProperty('--accent-color', accentColor); // 自动计算 hover 色(加深 18%) var hover = darkenHexColor(accentColor, 18); document.documentElement.style.setProperty('--accent-hover', hover); } </script>

4 踩过的坑

矢量壁纸预览被改色

7 种矢量壁纸(渐变/菱形/网格/波浪等)的预览图也使用了 #187aff,替换后预览图随强调色变化。用户明确要求"应当保持蓝色"。

将 7 个壁纸预览类 + .wallpaper-default 预览恢复为硬编码 #187aff

拟物图标未跟随强调色

拟物风格任务栏图标使用了硬编码灰蓝色值,未使用 var(--accent-color)

将默认/hover/active 三态的 linear-gradient 全部替换为 color-mix() 变体。

取色器按钮溢出 + 无高斯模糊

自定义取色器面板的"取消/应用"按钮超出面板边界,遮罩层没有 backdrop-filter。

添加 overflow:hidden; min-width:0,遮罩加 backdrop-filter:blur(8px)

选中窗口强调色太深

纯强调色作为 active 窗口背景太刺眼。

调整为 60% accent + 40% white45% accent + 55% light-gray 的渐变。

任务6:Bing 每日壁纸缓存策略

每日缓存 + CORS 代理 + 灰色占位 + 渐变显现

1 任务拆解

  1. API 选择:使用 Bing 官方 HPImageArchive.aspx API 获取当日壁纸 URL
  2. CORS 绕过:通过 api.allorigins.win 代理
  3. 每日缓存:按日期缓存 URL 到 localStorage,同一天不重复请求
  4. 灰色占位:加载期间显示 #555 灰色背景
  5. 渐变显现:图片加载完成后 opacity 0→1 的 0.8s 渐变
  6. 缓存击穿:代理 URL 加 _t=Date.now() 时间戳避免缓存旧数据

2 关键 Prompt 链

Prompt 1 > 设置-系统-个性化中的自然风景改为bing每日壁纸
Prompt 2 - 预览图不显示 > bing每日壁纸的在设置的图片无法显示,请把上个版本的自然风景图片加回来以填充进去, 但是每日壁纸设置保持不变
Prompt 3 - 缓存+渐变 > 每日壁纸每次打开KJ新标签页都会刷新,如果可以,清缓存至用户的电脑里, 如果图片一直未加载,请使用灰色背景,加载完毕之后以渐变的形式展现
Prompt 4 - 跨浏览器不一致 > 我在两台浏览器测试了,bing每日壁纸,两张壁纸不一样,第一台浏览器里显示的还是昨天的壁纸

3 技术实现

三层缓存策略

JavaScript function loadBingDailyWallpaper() { const today = new Date().toISOString().split('T')[0]; const cachedDate = localStorage.getItem('kj_bing_wallpaper_date'); const cachedUrl = localStorage.getItem('kj_bing_wallpaper_url'); if (cachedDate === today && cachedUrl) { applyBingWallpaper(cachedUrl); // 1. 命中当日缓存 } else { // 2. 请求 Bing API(带时间戳防代理缓存) const proxyUrl = 'https://api.allorigins.win/raw?url=' + encodeURIComponent(bingApiUrl) + '&_t=' + Date.now(); fetch(proxyUrl).then(...).then(data => { localStorage.setItem('kj_bing_wallpaper_date', today); localStorage.setItem('kj_bing_wallpaper_url', imageUrl); applyBingWallpaper(imageUrl); }); // 3. 失败则保持灰色背景 } }

渐变显现

CSS + JS /* CSS: opacity 0 → 0.8s 过渡 */ .bing-wallpaper-layer { opacity: 0; transition: opacity 0.8s ease; } .bing-wallpaper-layer.loaded { opacity: 1; } /* JS: Image 预加载完成后触发 */ const img = new Image(); img.onload = function() { layer.style.backgroundImage = `url('${url}')`; requestAnimationFrame(() => layer.classList.add('loaded')); }; img.src = url;

4 踩过的坑

跨浏览器壁纸不一致

原方案使用 api.dujin.org/bing/1920.php 重定向服务,不同 IP/浏览器返回不同缓存结果。

移除所有 dujin.org fallback,统一使用 Bing 官方 API + allorigins 代理,加时间戳防缓存。

设置预览图无法显示

Bing API 返回的 URL 无法直接作为 CSS background-image 在设置预览中使用(CORS 限制)。

设置预览图恢复为 Unsplash 静态图,实际桌面壁纸使用 Bing API 动态加载。

任务7:极简模式完整体系

隐藏全部系统 UI + 搜索 + 问候语 + 光效 + 自定义引擎

1 功能清单

子功能存储键说明
极简模式开关kj_minimal_mode隐藏任务栏/桌面图标/窗口,显示搜索界面
自定义壁纸kj_minimal_wallpaper独立于桌面壁纸
搜索框尺寸kj_minimal_search_size小/中/大三档
红蓝光效kj_minimal_glow径向渐变叠加层
搜索引擎kj_minimal_engineGoogle/Bing/百度/自定义
固定应用kj_minimal_pinned快捷启动栏
问候语根据时间动态显示"早上好/下午好/晚上好"

2 关键 Prompt 链

Prompt 1 - 退出按钮不可见 > 修复极简模式下返回到KJ系统这个按钮是透明底色,导致悬浮后看不清按钮文本
Prompt 2 - 光效点击无反应 > 修复极简模式下红蓝渐变光效按钮点击没反应的问题,另外极简模式的搜索引擎也加入自定义选项
Prompt 3 - 光效层级问题 > 极简模式下红蓝特效还是没有生效,是不是层级有问题

3 踩过的坑

退出按钮悬浮不可见

background: rgba(255,59,48,0.15) 几乎透明,悬浮后文字看不清。

改为 background: #fff 实色背景。

光效按钮点击无反应

#minimal-mode-container 基础 pointer-events: none,但 .visible 状态未恢复。

添加 #minimal-mode-container.visible { pointer-events: auto }

光效 z-index 被遮挡

body.minimal-glow::beforez-index: 0#minimal-mode-containerz-index: 99)完全遮挡。

改为在容器内部插入 <div id="minimal-glow-overlay"> 作为第一个子元素,position: absolute; z-index: 0,自然位于所有内容之下。

任务8:日历年份选择器 + 月份横滑动画

点击标题切换年份视图 + 月份切换左右横滑

1 任务拆解

  1. 月份横滑动画:切换月份时当前内容滑出,新内容从另一侧滑入
  2. 年份选择器:点击标题进入 10 年网格选择器(3 列布局)
  3. 年代翻页:左右箭头切换年代(每次 10 年)
  4. 双日历同步:日历 APP 和任务栏小日历独立实现,逻辑对称
  5. 标题高亮反馈:进入年份模式时标题变为强调色背景

2 关键 Prompt 链

Prompt 1 > 给日历APP以及任务栏右下角的日历在切换月份的时候具备左右横移动画,此外,点击标题可以转到年份切换
Prompt 2 - 滚动条溢出 > 有bug:1.日历APP切换动画时在日历底端会显示横向滚动条 2.任务栏右下角的日历在切换动画的时候,日历里的数字日期会超出日历小窗的边界
Prompt 3 - 标题高亮 > 日历APP以及任务栏右下角小型日历当我点击顶部的切换年份的时候, 应该高亮显示正在切换,以强调色视觉反馈

3 技术实现

月份横滑动画

JavaScript function changeMonth(delta) { const wrap = document.querySelector('.calendar-slide-wrap'); // 1. 当前内容滑出 wrap.style.transform = `translateX(${delta > 0 ? -100 : 100}%)`; wrap.style.opacity = '0'; setTimeout(() => { // 2. 更新日期数据 calendarCurrentDate.setMonth(calendarCurrentDate.getMonth() + delta); renderCalendar(); // 3. 新内容从另一侧滑入 wrap.style.transform = `translateX(${delta > 0 ? 100 : -100}%)`; requestAnimationFrame(() => { wrap.style.transform = 'translateX(0)'; wrap.style.opacity = '1'; }); }, 250); }

年份选择器(10 年网格)

JavaScript function renderCalendarYearPicker() { // 显示 10 年范围:base ~ base+9 for (let y = calendarYearPickerBase; y < calendarYearPickerBase + 10; y++) { const isActive = y === currentYear; html += `<div class="calendar-year-cell${isActive ? ' active' : ''}">${y}</div>`; } } function changeCalendarYearPage(delta) { calendarYearPickerBase += delta * 10; // 每次翻 10 年 }

4 踩过的坑

横向滚动条溢出

translateX(100%) 动画期间内容超出容器宽度,出现横向滚动条。

容器添加 overflow: hidden

任务栏日历数字溢出

小日历弹窗没有 overflow: hidden,滑动动画时日期数字超出边界。

#widget-calendar 添加 overflow: hidden

任务9:飞书审核集成

把应用社区审核搬到飞书,卡片按钮一键通过/拒绝

背景:应用社区原本人工审核需要进 emlog 后台,流程繁琐。本任务把审核入口搬进飞书机器人, 管理员在飞书消息卡片上点「通过审核」/「拒绝」即可完成审核,状态实时同步回 emlog 后台。 涉及飞书开放平台 API、emlog 插件开发、PHP 后端、消息卡片交互等多个技术栈。

1 任务拆解

子任务难点解决方式
飞书开放平台配置 机器人、事件订阅、卡片回调分散在三处 分别创建应用、添加机器人、配置 card.action.trigger 事件、设置回调地址
配置管理 Option::updateOption 有 SQL 注入风险且未处理重复键 重写 saveConfig,手动 INSERT/UPDATE + 错误日志
消息卡片构建 待审核卡片有按钮,已审核卡片无按钮 buildPendingCard + buildReviewedCard 两个静态方法
Webhook 处理 需区分 URL 验证、事件订阅、卡片按钮回调 handleFeishuWebhook 统一入口 + 事件类型分发
审核流程串联 提交审核 → 推送卡片 → 飞书审核 → 更新状态 四个环节环环相扣,任一环节失败都要可追溯
卡片状态同步 审核后卡片要立即更新为「已通过/已拒绝」 通过飞书回调响应返回新卡片 + API 更新双保险

2 关键 Prompt 链

Prompt 1 - 需求提出 > 我的后端应用社区项目可不可以接入飞书,而且应用审核也可以在飞书审核, 这样就不用跑到emlog去审核了
Prompt 2 - 后台打不开 > 这些文件替换和加入之后emlog后台直接打不开了,无法访问,前端显示请求失败:200
Prompt 3 - 配置保存失效 > 我在飞书集成配置里勾选了启用飞书审核通知并且填对了地址,保存配置之后页面刷新了, 刷新之后这些选项又变成空白的了
Prompt 4 - 增加报错日志 > 没有保存成功,请增加报错原因日志,看看为什么报错,由于是空白的,点击发送测试也没用
Prompt 5 - 提交报错但实际成功 > 现在飞书可以正常审核了,但是还有问题: 1.明明已经提交成功了,都可以正常进到emlog和飞书后台了,但是提示还是报错, 而且不会退出这个界面提示"正在审核"导致用户提交了多次一样的应用 2.飞书审核通过后不会显示已通过或者已拒绝的字样,而还是显示两个按钮, 多次点击之后还会提示操作过于频繁,实际上emlog后台已经审核通过了

3 后端全流程详解

以下按请求时序完整梳理后端从「入口路由」到「卡片更新」的每一个环节,涉及 6 个文件、15+ 个函数。

流程①:API 入口与路由分发

所有请求统一从 /?plugin=kj_appstore&action=xxx 进入 kj_appstore_show.php。 emlog dispatcher 加载本文件时,init.php 已执行完毕,ISLOGIN / UID / ROLE / Database 等全局变量可直接使用。

PHP - kj_appstore_show.php // 1. 实例化数据模型 $Model = new KjAppstore_Model(); // 2. 统一 JSON 响应头 + CORS 凭证(init.php 已处理动态 origin) header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Credentials: true'); // 3. 按 action 分发到对应 handler switch ($action) { case 'review_submit': handleReviewSubmit($Model); // 用户提交审核 case 'review_approve': handleReviewApprove($Model); // 后台通过 case 'review_reject': handleReviewReject($Model); // 后台拒绝 case 'feishu_webhook': handleFeishuWebhook($Model); // 飞书回调 case 'feishu_test': handleFeishuTest($Model); // 发送测试 // ... 还有 categories/apps/upload/download 等应用 CRUD 端点 } // 4. 全局 try-catch 兜底,任何异常都返回 JSON 500 try { ... } catch (Exception $e) { sendJSON(500, array('error' => '服务器内部错误: ' . $e->getMessage())); }
关键设计sendJSON() 内部调用 ob_clean() 清空之前的所有输出, 再 http_response_code() + echo json_encode() + exit。 这保证了即使前面有 warning 输出,响应体仍然是干净的 JSON。

流程②:用户提交审核(handleReviewSubmit)

前端 POST review_submit,body 含 type(name_change/app_submission/app_update)、data(申请详情)、authorIdauthorName

PHP function handleReviewSubmit($Model) { $body = readBody(); // file_get_contents('php://input') + json_decode $type = $body['type']; $data = $body['data']; $authorId = $body['authorId']; // 1. 参数校验 if (!$type || !$data || !$authorId) sendJSON(400, ...); if (!in_array($type, array('name_change', 'app_submission', 'app_update'))) sendJSON(400, '无效的审核类型'); // 2. 生成审核单号(rv + 年月日时分秒 + 6位随机) $reviewId = 'rv' . date('YmdHis') . substr(md5(uniqid('', true)), 0, 6); // 3. 写入 emlog_kj_reviews 表,status='pending' $Model->insertReview(array( 'id' => $reviewId, 'type' => $type, 'data' => json_encode($data, JSON_UNESCAPED_UNICODE), 'author_id' => $authorId, 'author_name' => $authorName, 'status' => 'pending', 'created_at' => date('Y-m-d H:i:s'), )); // 4. 组装响应数据(注意:此时还没 sendJSON) $reviewResponse = array('success' => true, 'review' => array(...)); // 5. 飞书推送(放在 sendJSON 之前,包在 try-catch 中) if (function_exists('kj_appstore_push_review_to_feishu')) { try { kj_appstore_push_review_to_feishu($reviewResponse['review'], $Model); } catch (Exception $e) { error_log('[kj_appstore] 飞书推送异常: ' . $e->getMessage()); // 推送失败不影响主流程,继续返回成功 } } // 6. 最后才返回 JSON 给前端 sendJSON(200, $reviewResponse); }
关键教训:飞书推送必须放在 sendJSON 之前。 之前放在之后,推送抛异常时程序已输出 JSON 头部,再输出错误导致响应格式混乱,前端解析失败却误报"提交失败", 用户重复点击提交多次相同应用。

流程③:飞书推送(kj_appstore_push_review_to_feishu)

提交审核成功后,异步把审核申请推送到飞书群聊,生成一张带「通过/拒绝」按钮的交互式卡片。

PHP function kj_appstore_push_review_to_feishu($review, $Model) { // 1. 按需加载飞书模块(避免影响未启用飞书的用户) if (!kj_appstore_load_feishu()) return false; // 2. 检查是否启用(enabled + app_id + app_secret + chat_id 都要有) if (!KjAppstore_FeishuConfig::isEnabled()) return false; // 3. 获取飞书客户端 + 群聊 ID $client = KjAppstore_FeishuConfig::getClient(); $chatId = KjAppstore_FeishuConfig::getChatId(); // 4. 构建「待审核」卡片(含按钮,value 携带 review_id + action) $card = KjAppstore_FeishuCard::buildReviewCard($formatted, $appUrl); // 5. 调用飞书 API 发送交互式消息 $result = $client->sendInteractiveCard($chatId, $card); // 6. 保存飞书消息 ID(feishu_msg_id),供后续更新卡片用 if (!empty($result['message_id'])) { $Model->setReviewFeishuMsgId($reviewId, $result['message_id']); } return $result; }

流程④:Webhook 入口(handleFeishuWebhook)

飞书开放平台把所有事件(URL 验证、事件订阅、卡片按钮点击)都推送到同一个 webhook 地址。本函数是统一入口,按 payload 类型分发。

PHP function handleFeishuWebhook($Model) { // 1. 加载飞书模块 if (!kj_appstore_load_feishu()) { echo 'error'; exit; } // 2. 读取原始请求体并解析 JSON $rawInput = file_get_contents('php://input'); $payload = json_decode($rawInput, true); // 3. 场景A:URL 验证(飞书配置回调地址时发送 challenge) if (isset($payload['type']) && $payload['type'] === 'url_verification') { echo json_encode(array('challenge' => $payload['challenge'])); exit; } // 4. 从 header.event_id 取事件类型 $eventType = $payload['header']['event_type']; // 5. 场景B:卡片按钮点击 → 交给 handleFeishuCardAction 处理 if ($eventType === 'card.action.trigger') { handleFeishuCardAction($Model, $payload); exit; } // 6. 场景C:其他事件 → 返回 ok echo json_encode(array('code' => 0, 'msg' => 'ok')); }
飞书事件分发要点:飞书 v2.0 事件结构是 {header:{event_type:...}, event:{...}}, 卡片按钮的 message_id 藏在 event.context.open_message_id 里, 而不是 event.tokenevent.message.message_id(这两个是早期版本的字段)。

流程⑤:卡片按钮回调(handleFeishuCardAction)

管理员在飞书卡片上点「通过审核」或「拒绝」后,飞书推送 card.action.trigger 事件到此函数。这是飞书审核的核心入口。

PHP function handleFeishuCardAction($Model, $payload) { $event = $payload['event']; $value = $event['action']['value']; // 按钮携带的 value $operatorId = $event['operator']['open_id']; // 点击者 open_id // 1. 多路径取 message_id(兼容不同飞书版本) $messageId = $event['context']['open_message_id']; if (!$messageId && isset($event['token'])) $messageId = $event['token']; if (!$messageId && isset($event['message']['message_id'])) $messageId = $event['message']['message_id']; // 2. 从 value 取 review_id 和 action(approve/reject) $reviewId = $value['review_id']; $actionType = $value['action']; // 3. 幂等校验:已审核的不能再操作(防止重复点击) $review = $Model->getReviewById($reviewId); if ($review['status'] !== 'pending') { feishuCardResponse('操作失败', '该申请已被审核,请勿重复操作'); return; } // 4. 执行审核(返回数组,不直接 sendJSON/exit) $reviewerName = '飞书用户(' . $operatorId . ')'; $result = $actionType === 'approve' ? approveReview($Model, $reviewId, $reviewerName, '') : rejectReview($Model, $reviewId, $reviewerName, '飞书端操作拒绝'); // 5. 保存 message_id(供后台审核时同步飞书卡片用) $Model->setReviewFeishuMsgId($reviewId, $messageId); // 6. 构建「已审核」卡片并通过 API 更新 $finalCard = KjAppstore_FeishuCard::buildReviewedCard($formatted, $reviewerName, $note); $client = KjAppstore_FeishuConfig::getClient(); if ($client && $messageId) { try { $client->updateCard($messageId, $finalCard); } catch (Exception $e) { error_log('更新卡片失败: ' . $e->getMessage()); } } // 7. 响应只返回 toast(不返回 card,避免与 API 更新冲突) feishuCardResponse('操作成功', '审核已通过', null); }

流程⑥:审核执行(approveReview)

approveReview 是审核的核心业务函数,同时被「飞书审核」和「后台审核」调用。 它根据审核类型执行不同的副作用:应用提交/更新要写应用文件和 apps 表;昵称修改要同步 emlog user 表。

PHP function approveReview($Model, $reviewId, $reviewer, $note) { // 1. 查审核记录 + 状态校验 $review = $Model->getReviewById($reviewId); if (!$review) return array('error'=>true, 'code'=>404, 'msg'=>'记录不存在'); if ($review['status'] !== 'pending') return array('error'=>true, ...); // 2. 更新审核状态为 approved $Model->setReviewStatus($reviewId, 'approved', $reviewer, $note); // 3. 按类型执行副作用 $appData = json_decode($review['data'], true); if ($review['type'] === 'app_submission' || $review['type'] === 'app_update') { // 3a. 应用类:写文件到 content/kj_appstore_files/{id}/ + 写 apps 表 $Model->writeAppFiles($id, $appType, $config); // 落盘 index.html/style.css/script.js appExists($id); if ($existing) $Model->updateApp($id, $data); // 更新(保留 downloads/created_at) else $Model->insertApp($data); // 新增 } elseif ($review['type'] === 'name_change') { // 3b. 昵称类:更新 apps 表的 author 字段 + 同步 emlog user 表 $Model->updateAuthorName($review['author_id'], $newName); if (ctype_digit((string)$review['author_id'])) { $User_Model = new User_Model(); // emlog 内置用户模型 $User_Model->updateUser(array('nickname' => $newName), (int)$review['author_id']); } } // 4. 返回结果数组(不直接输出 JSON,由调用方决定格式) return array('success' => true, 'review' => formatReview($updated)); }
关键教训approveReview 必须返回数组而不是调用 sendJSON+exit。 之前函数内直接 sendJSON,从飞书 webhook 调用时程序直接退出,无法返回飞书期望的响应,也无法更新卡片。 改为返回数组后,调用方(飞书 webhook / 后台接口)可以各自决定输出格式。

流程⑦:拒绝审核(rejectReview)

拒绝逻辑简单:只更新状态,不执行任何副作用(不写文件、不改 user 表)。

PHP function rejectReview($Model, $reviewId, $reviewer, $note) { $review = $Model->getReviewById($reviewId); if (!$review) return array('error'=>true, 'code'=>404); if ($review['status'] !== 'pending') return array('error'=>true, 'code'=>400); $Model->setReviewStatus($reviewId, 'rejected', $reviewer, $note); return array('success' => true, 'review' => formatReview($updated)); }

流程⑧:emlog 后台审核(handleReviewApprove / handleReviewReject)

管理员也可在 emlog 后台审核。与飞书审核的区别:需要 requireAdmin() 鉴权,审核后要调用 kj_appstore_sync_feishu_card 同步更新飞书卡片。

PHP function handleReviewApprove($Model) { requireAdmin(); // kj_appstore_is_admin() 检查,否则 sendJSON(401) $id = Input::getStrVar('id'); // 1. 取当前登录用户作为审核人 $currentUser = kj_appstore_current_user(); $reviewer = kj_appstore_is_admin() ? $currentUser['username'] : 'admin'; // 2. 调用核心审核函数(与飞书审核共用同一个 approveReview) $result = approveReview($Model, $id, $reviewer, $reviewNote); // 3. 同步更新飞书卡片(用之前保存的 feishu_msg_id) if (function_exists('kj_appstore_sync_feishu_card')) { try { $review = $Model->getReviewById($id); kj_appstore_sync_feishu_card($Model, $review, $reviewer, $reviewNote, 'approved'); } catch (Exception $e) { error_log('后台审核同步飞书卡片失败: ' . $e->getMessage()); } } sendJSON(200, $result); }
职责分离approveReview 只管数据库操作,不碰飞书。 飞书卡片同步由调用方负责——飞书审核走「API 更新 + toast 响应」,后台审核走「kj_appstore_sync_feishu_card」。 这样避免了之前三处同时更新卡片的冲突问题。

流程⑨:飞书卡片同步(kj_appstore_sync_feishu_card)

从 emlog 后台审核时,需要把飞书卡片从「待审核」更新为「已审核」。此函数用之前保存的 feishu_msg_id 调用飞书 API。

PHP function kj_appstore_sync_feishu_card($Model, $review, $reviewer, $note, $status) { if (!kj_appstore_load_feishu()) return false; if (!KjAppstore_FeishuConfig::isEnabled()) return false; // 没有飞书消息 ID 就无法更新(可能是推送失败的老记录) if (empty($review['feishu_msg_id'])) return false; $client = KjAppstore_FeishuConfig::getClient(); $formatted = formatReview($review); $newCard = KjAppstore_FeishuCard::buildReviewedCard($formatted, $reviewer, $note); $client->updateCard($review['feishu_msg_id'], $newCard); // PATCH /im/v1/messages/:id return true; }

流程⑩:配置管理(KjAppstore_FeishuConfig)

飞书配置(app_id / app_secret / chat_id / enabled)以 JSON 存储在 emlog options 表,key 为 kj_appstore_feishu_config

PHP - feishu_config.php class KjAppstore_FeishuConfig { const OPTION_KEY = 'kj_appstore_feishu_config'; // 读取:从 Option::get 取 JSON 字符串再 decode public static function getConfig() { $config = Option::get(self::OPTION_KEY); return json_decode($config, true) ?: // 默认空配置 array('enabled'=>false, 'app_id'=>'', ...); } // 保存:手动 INSERT/UPDATE(不用 Option::updateOption) public static function saveConfig($config) { $merged = array_merge(self::getConfig(), $config); // 合并而非覆盖 $jsonValue = json_encode($merged, JSON_UNESCAPED_UNICODE); $DB = Database::getInstance(); $name = $DB->escape_string(self::OPTION_KEY); $value = $DB->escape_string($jsonValue); // 先查存在性,再 INSERT 或 UPDATE $exists = $DB->once_fetch_array("SELECT ... WHERE option_name='{$name}'"); if ($exists) $DB->query("UPDATE ... SET option_value='{$value}'"); else $DB->query("INSERT ... VALUES('{$name}','{$value}')"); // 关键:刷新 emlog 选项缓存 Cache::getInstance()->updateCache('options'); return true; } // 启用判断:4 个字段都非空才算启用 public static function isEnabled() { $c = self::getConfig(); return !empty($c['enabled']) && !empty($c['app_id']) && !empty($c['app_secret']) && !empty($c['chat_id']); } // 创建客户端实例 public static function getClient() { $c = self::getConfig(); return new KjAppstore_FeishuClient($c['app_id'], $c['app_secret']); } }
为什么不用 Option::updateOption:emlog 原生方法用 REPLACE INTO, 会先 DELETE 再 INSERT,丢失自增 ID;且未更新缓存,刷新后读到旧值(配置"变空白"的元凶)。 手动 SELECT + INSERT/UPDATE + updateCache('options') 彻底解决。

流程⑪:飞书 API 客户端(KjAppstore_FeishuClient)

封装飞书开放平台三个核心 API:获取 token、发送交互式卡片、更新卡片。所有请求走 cURL,带超时和 SSL 跳过。

PHP - feishu_client.php class KjAppstore_FeishuClient { const BASE_URL = 'https://open.feishu.cn/open-apis'; // ① 获取 tenant_access_token(带缓存,提前 60s 过期) public function getTenantAccessToken() { if ($this->token && time() < $this->tokenExpire - 60) return $this->token; // POST /auth/v3/tenant_access_token/internal $resp = $this->doRequest($url, json_encode(array( 'app_id' => $this->appId, 'app_secret' => $this->appSecret, ))); // 缓存 token + 过期时间(默认 7200s) $this->token = $data['tenant_access_token']; $this->tokenExpire = time() + $data['expire']; } // ② 发送交互式卡片到群聊 public function sendInteractiveCard($chatId, $card) { // POST /im/v1/messages?receive_id_type=chat_id $payload = array( 'receive_id' => $chatId, 'msg_type' => 'interactive', 'content' => json_encode($card, JSON_UNESCAPED_UNICODE), ); // 返回 data.message_id(后续更新卡片用) } // ③ 更新已发送的卡片内容 public function updateCard($messageId, $card) { // PATCH /im/v1/messages/{message_id} // 用新卡片 JSON 替换原卡片,按钮会消失 } // ④ cURL 统一封装 private function doRequest($url, $body, $withAuth, $token, $method) { $ch = curl_init(); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过 SSL(开发环境) curl_setopt($ch, CURLOPT_TIMEOUT, 15); // 15s 超时 // POST 用 CURLOPT_POST,PATCH 用 CURLOPT_CUSTOMREQUEST // 带 Authorization: Bearer {token} 头 } }

流程⑫:飞书卡片构建(KjAppstore_FeishuCard)

两个静态方法分别构建「待审核」和「已审核」两种卡片。卡片结构遵循飞书消息卡片 v1 协议:config + header + elements[]

方法header templateelements按钮
buildReviewCard 按类型着色(绿/橙/蓝) 申请类型 + 提交者 + 应用详情 ✅ 通过审核 + ❌ 拒绝(value 带 review_id + action)
buildReviewedCard approved=绿 / rejected=红 状态 + 审核人 + 审核时间 + 备注 无按钮(已审核不可再操作)
PHP - 按钮的 value 结构 // 待审核卡片按钮的 value,飞书回调时原样传回 'value' => array( 'action' => 'approve', // 或 'reject' 'review_id' => $review['id'], // 审核单号 )

流程⑬:数据模型层(KjAppstore_Model)

操作 4 张表:emlog_kj_apps(应用)、emlog_kj_reviews(审核)、emlog_kj_categories(分类)、emlog_kj_download_logs(下载日志)。

PHP - kj_appstore_model.php // 审核相关方法 getReviewById($id) // SELECT * FROM kj_reviews WHERE id=? getReviews($status, $type) // 后台列表,按 status/type 过滤 getMyReviews($authorId) // 用户查自己的审核记录 insertReview($data) // INSERT 新审核记录(status=pending) setReviewStatus($id, $s, $r, $n)// UPDATE status/reviewed_at/reviewer/review_note // 飞书消息 ID 存储(含字段自动补全) setReviewFeishuMsgId($id, $msgId) { // 先 SHOW COLUMNS 检查字段是否存在 if (字段不存在) { ALTER TABLE kj_reviews ADD COLUMN feishu_msg_id varchar(100) DEFAULT NULL AFTER review_note; } UPDATE kj_reviews SET feishu_msg_id=? WHERE id=?; } // 应用文件写入(审核通过后落盘) writeAppFiles($appId, $type, $config) { // 按 type 决定文件名(code→html/css/js,python→main.py) // 写入 content/kj_appstore_files/{appId}/ 目录 }
字段自动补全设计:插件升级时不能强制要求用户手动执行 SQL。 setReviewFeishuMsgId 在使用时自动 SHOW COLUMNS 检测,不存在则 ALTER TABLE 补字段, 实现平滑升级,老用户无感知。

流程⑭:设置页面与表单保存(kj_appstore_setting.php)

emlog 后台「插件设置」页面,含三个 Tab:审核管理、应用管理、飞书集成。飞书配置表单提交到 plugin_setting() 处理。

PHP function plugin_setting() { $op = Input::postStrVar('op'); if ($op === 'feishu_save') { // 1. 收集表单字段 $config = array( 'enabled' => (bool)Input::postIntVar('feishu_enabled', 0), 'app_id' => trim(Input::postStrVar('feishu_app_id')), 'app_secret' => trim(Input::postStrVar('feishu_app_secret')), 'chat_id' => trim(Input::postStrVar('feishu_chat_id')), ); // 2. 调用 FeishuConfig::saveConfig(手动 INSERT/UPDATE + 刷缓存) KjAppstore_FeishuConfig::saveConfig($config); return true; } if ($op === 'add_cat') { ... } // 分类增删 if ($op === 'del_cat') { ... } }

页面还展示「事件回调地址」(只读 + 复制按钮),方便用户填到飞书开放平台:

回调地址 // 自动拼接,用户复制后粘贴到飞书开放平台「事件订阅」 {BLOG_URL}?plugin=kj_appstore&action=feishu_webhook

流程⑮:发送测试消息(handleFeishuTest)

配置保存后,管理员可点「发送测试消息」验证飞书集成是否正常。此函数逐步校验配置,发送一张测试卡片到群聊。

PHP function handleFeishuTest($Model) { // 1. 鉴权:必须管理员 if (!kj_appstore_is_admin()) sendJSON(403, ...); // 2. 逐项校验配置 if (empty($config['enabled'])) sendJSON(400, '飞书功能未启用'); if (empty($config['app_id'])) sendJSON(400, '请先配置 App ID'); if (empty($config['app_secret'])) sendJSON(400, '请先配置 App Secret'); if (empty($config['chat_id'])) sendJSON(400, '请先配置群聊 Chat ID'); // 3. 尝试获取 token(验证 app_id/secret 是否正确) $token = $client->getTenantAccessToken(); // 4. 发送测试卡片(绿色 header + 成功文案) $testCard = array( 'header' => array('template' => 'green', 'title' => '🔔 飞书集成测试'), 'elements' => array('恭喜!飞书集成已配置成功!'), ); $result = $client->sendInteractiveCard($config['chat_id'], $testCard); sendJSON(200, array('success' => true, 'message_id' => $result['message_id'])); }

全流程时序图

用户提交审核
↓ POST review_submit
handleReviewSubmit
校验 + insertReview(status=pending)
↓ sendJSON 之前
kj_appstore_push_review_to_feishu
buildReviewCard → sendInteractiveCard
↓ 保存 feishu_msg_id
飞书群聊收到卡片
含「通过」「拒绝」按钮
↓ 管理员点击按钮
飞书推送 card.action.trigger
handleFeishuWebhook
URL验证? / 卡片回调?
handleFeishuCardAction
取 message_id + review_id + action
status === pending?
↓ 是
approveReview / rejectReview
setReviewStatus + 副作用
buildReviewedCard + updateCard
API 更新飞书卡片
feishuCardResponse
只返回 toast,不返回 card
卡片变为「已通过/已拒绝」

4 踩过的坑(重点)

PHP 语法兼容性导致后台打不开

现象:文件替换后 emlog 后台直接打不开,前端显示「请求失败:200」。

原因:飞书推送逻辑中使用了 PHP 7+ 的 ?? 运算符和短数组语法 [], 而服务器 PHP 版本较低,致命错误输出 HTML 错误页面,前端收到 200 状态码但内容是 HTML。

解决:把所有 ?? 替换为 isset() ? : , 短数组 [] 替换为 array(),兼容 PHP 5.x。

配置保存后刷新变空白

现象:保存飞书配置后页面刷新,所有选项又变成空白。

原因Option::updateOption 方法用 REPLACE INTO, 会先 DELETE 再 INSERT,丢失自增 ID;且未更新缓存,刷新后读到的还是旧值。

解决:重写 saveConfig,手动 SELECT 判断存在性后 INSERTUPDATE,最后调用 $CACHE->updateCache('options') 刷新缓存。

提交审核提示报错但实际成功

现象:明明提交成功了,emlog 和飞书后台都能看到,但前端提示报错,且不退出界面, 导致用户重复提交多次相同应用。

原因:飞书推送逻辑在 sendJSON 之后执行,一旦推送抛异常, 程序已经输出了 JSON 头部,再输出错误信息导致响应格式混乱,前端解析失败。

解决:把飞书推送逻辑移到 sendJSON 之前,并包在 try-catch 中, 推送失败不影响主流程返回成功响应。

审核后卡片不更新

现象:飞书点击「通过审核」后,emlog 后台状态正确,但飞书卡片还是显示两个按钮。

原因:三个问题叠加:

  • feishu_msg_id 字段未自动创建,保存失败
  • approveReview 函数内调用了 sendJSON + exit,从 webhook 调用时程序直接退出,无法返回飞书响应
  • 飞书 v2.0 事件的 message_idevent.context.open_message_id,而不是 event.token
解决:① setReviewFeishuMsgId 自动检测并 ALTER TABLE 补字段; ② 把 approveReview/rejectReview 改为返回数组,由调用方决定输出格式; ③ 从 event.context.open_message_id 正确获取 message_id。

卡片显示通过后又变回待审核

现象:点击按钮后卡片先显示「已通过」,下一秒刷新又变回待审核状态。

原因:三处同时在更新同一张卡片,导致冲突:

  • approveReview 内的 kj_appstore_sync_feishu_card(API 更新①)
  • handleFeishuCardAction 内的 $client->updateCard(API 更新②)
  • feishuCardResponse 响应里的 card 字段(飞书直接替换③)

三次更新时序不一致,飞书最终用响应里的简单卡片替换了完整审核卡片,视觉上表现为「变回去了」。

解决:职责分离——飞书审核走「API 更新卡片 + 响应只返回 toast」; emlog 后台审核走「API 更新卡片」。把 kj_appstore_sync_feishu_cardapproveReview/rejectReview 核心函数移出,只在后台审核接口调用。

事件订阅找不到 card.action.trigger

现象:在飞书开放平台「事件订阅」里找不到卡片按钮点击事件。

原因:用户在错误的位置查找——卡片按钮回调事件不在「事件订阅」里配置, 而是在「消息卡片配置」的回调地址里。

解决:指导用户在飞书开放平台正确配置: ① 创建应用并添加机器人能力;② 在「事件订阅」配置 URL 验证地址; ③ 在「消息卡片配置」配置回调地址(指向同一个 webhook)。

5 涉及文件清单

文件作用
feishu_config.php飞书配置管理(读写、客户端初始化、启用判断)
feishu_client.php飞书 API 客户端(token 获取、发消息、更新卡片)
feishu_card.php消息卡片构建(buildPendingCard / buildReviewedCard)
kj_appstore_show.php插件 API 入口,路由分发 + webhook 处理 + 审核逻辑
kj_appstore_model.php数据模型,含 feishu_msg_id 字段自动补全
kj_appstore_setting.php插件设置页面,飞书配置表单

任务10:应用社区前端

独立模块化应用商店,浏览/上传/安装一站式体验

背景:任务9 实现了飞书审核的后端。本任务是配套的前端—— 一个完整的应用社区 Web 应用,让用户浏览社区应用、上传自己的作品、管理已安装应用。 采用 IIFE 独立模块设计,不污染 app.js 主文件,通过 window.initAppStore 挂载点与主系统解耦。

1 任务拆解

子任务难点解决方式
模块独立化 不能让应用商店代码污染 449KB 的 app.js IIFE 封装 + app-init.js 检测 window.initAppStore 挂载点
API 通信层 跨域请求 OA 后端(oa.sgguo.com) 封装 apiGet/apiPost/apiDelete/fetchText 四个方法,credentials: include
用户身份 无登录态,需要唯一标识用户 genKJUserId() 本地生成 UUID 存 localStorage,首次打开自动创建
应用列表 分类筛选 + 关键词搜索 + 卡片网格 renderSidebar + renderGrid,支持本地 JSON 兜底
应用上传 表单弹窗 + 提交审核 + 等待审核状态 showUploadForm 全屏 overlay + uploadToCommunity 调 review_submit API
用户中心 我的应用 + 昵称修改 + 统计数据 renderMyPage + handleEditName + updateMyStats

2 关键 Prompt 链

Prompt 1 - 模块化设计 > 应用社区的前端代码不要写到 app.js 里,做成独立模块,app.js 已经够大了
Prompt 2 - 跨域 API > 前端在 kj.sgguo.com,后端在 oa.sgguo.com,怎么跨域调用 API?
Prompt 3 - 用户身份 > 用户没有登录,怎么知道是谁在上传应用?给他生成一个本地 ID 吧

3 技术实现

模块独立化设计

JS - appstore-app.js // IIFE 封装,完全不污染全局作用域 (function () { // 内部所有函数和变量都是私有的 function getInstalledApps() { ... } function renderAppStore(root) { ... } async function loadApps() { ... } // 唯一对外暴露的挂载点 window.initAppStore = function () { const root = document.getElementById('appstore-root'); if (root) renderAppStore(root); }; })(); // app-init.js 中检测挂载点 appConfigRegistry.register('appstore', { title: '应用社区', content: '<div id="appstore-root"></div>' }); // 注册后触发独立模块初始化 if (typeof window.initAppStore === 'function') { window.initAppStore(); }
设计优势:应用社区有 4 个独立文件(appstore-app.js / appstore-upload.js / appstore-settings.js / appstore.css),共 1500+ 行代码,完全不增加 app.js 体积。 主系统只需提供 #appstore-root 容器和 initAppStore 挂载点。

API 通信层封装

JS // 统一拼接 OA 后端 URL function getOaBaseUrl() { return 'https://oa.sgguo.com'; } function pluginUrl(action, params) { let url = getOaBaseUrl() + '/?plugin=kj_appstore&action=' + action; if (params) { Object.keys(params).forEach(k => url += '&' + k + '=' + encodeURIComponent(params[k])); } return url; } // 四个封装方法,统一 credentials: include(跨域携带 cookie) async function apiGet(action, params) { const res = await fetch(pluginUrl(action, params), { credentials: 'include' }); return res.json(); } async function apiPost(action, body, params) { const res = await fetch(pluginUrl(action, params), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return res.json(); } async function apiDelete(action, body, params) { ... } async function fetchText(action, params) { ... } // 获取应用文件内容

本地用户身份生成

JS function genKJUserId() { // 生成形如 kj_xxxxxxxxxxxxxxxx 的唯一 ID const id = 'kj_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 8); localStorage.setItem('kj_user_id', id); return id; } function getUserId() { let id = localStorage.getItem('kj_user_id'); if (!id) id = genKJUserId(); // 首次访问自动创建 return id; }
设计思路:用户无需注册登录,首次打开应用社区时自动生成本地 UUID。 上传应用、修改昵称、查看我的应用都基于这个 ID。简单但有效。

应用上传流程

JS - appstore-upload.js // 1. 显示上传表单(全屏 overlay) function showUploadForm(prefillData, onSubmit) { const overlay = document.createElement('div'); overlay.className = 'appstore-upload-overlay'; overlay.innerHTML = `<div class="appstore-upload-modal"> <input id="up-name" placeholder="应用名称"> <input id="up-desc" placeholder="应用描述"> <select id="up-category">...</select> <input id="up-version" placeholder="版本号"> <button id="up-submit">提交审核</button> </div>`; document.body.appendChild(overlay); } // 2. 提交到后端 review_submit API(触发飞书推送) async function uploadToCommunity(payload) { const res = await fetch(pluginUrl('review_submit'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); return res.json(); } // 3. 按钮交互(禁用 + loading 动画) submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...'; // 完成后恢复 submitBtn.disabled = false; submitBtn.innerHTML = '<i class="fas fa-paper-plane"></i> 提交审核';
与 task9 的衔接:前端调用 review_submit 后, 后端写入审核记录 + 推送飞书卡片(task9 流程②③)。 用户可在「我的应用」页面查看审核状态(pending/approved/rejected)。

4 涉及文件清单

文件作用
js/appstore-app.js应用社区主逻辑(IIFE):列表渲染、用户中心、API 封装
js/appstore-upload.js应用上传模块:表单弹窗 + review_submit 提交
js/appstore-settings.js应用社区设置面板
css/appstore.css应用社区独立样式(不污染主样式表)
data/appstore-apps.json本地应用数据兜底(后端不可用时降级)
data/appstore-reviews.json本地审核数据兜底

任务11:PWA 离线缓存与版本更新

Service Worker 白名单缓存 + 版本检测 + 系统通知推送

背景:KJ 9 作为浏览器中的桌面 OS,需要支持离线访问和版本自动检测。 本任务实现三层机制:Service Worker 离线缓存、版本更新检测(version.txt 对比)、 系统通知推送(notification.json 配置)。

1 任务拆解

子任务难点解决方式
Service Worker 缓存 不能缓存所有文件(API 数据需实时) 白名单预缓存 + 缓存优先策略 + 版本号管理
缓存版本管理 更新代码后旧缓存不失效 CACHE_VERSION + activate 时删除非当前版本缓存
版本更新检测 用户不知道系统已更新 fetch version.txt + 版本对比 + 清缓存重载
系统通知推送 每次刷新都弹通知很烦 localStorage 记录 last_shown + 按 validFrom 时间判断
跨域请求处理 API 请求不能走缓存 非同源请求 + /api/ 路径直接走网络,返回 503 兜底

2 技术实现

Service Worker 白名单缓存策略

JS - service-worker.js const CACHE_VERSION = 'v30'; const CACHE_NAME = `kj-offline-${CACHE_VERSION}`; // 1. 预缓存白名单(仅核心资源,不含 API 数据) const RESOURCES_TO_CACHE = [ './app.js', './style.css', './css/all.min.css', './css/appstore.css', './css/codemirror.css', './css/dracula.css', './css/widgets/widget-base.css', './css/widgets/todo.css', // ... widget 样式 ]; // 2. install:预缓存 + skipWaiting 立即生效 self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then(cache => cache.addAll(RESOURCES_TO_CACHE)) .then(() => self.skipWaiting()) ); }); // 3. activate:删除旧版本缓存 + clients.claim self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then(names => Promise.all( names.map(name => name !== CACHE_NAME ? caches.delete(name) : null) )).then(() => self.clients.claim()) ); });

fetch 拦截:三分流策略

JS self.addEventListener('fetch', (event) => { // 跳过非 GET 请求(POST/HEAD 不可缓存) if (event.request.method !== 'GET') return; // ① 非同源请求(如 oa.sgguo.com API)→ 直接走网络 if (!isSameOrigin) { event.respondWith(fetch(event.request).catch(() => offline503)); return; } // ② 管理后台和 API 路径 → 直接走网络(安全 + 数据实时性) if (urlPath === '/admin-review.html' || urlPath.startsWith('/api/')) { event.respondWith(fetch(event.request).catch(() => offline503)); return; } // ③ 非白名单资源 → 直接走网络,不写入缓存 if (!isCacheableResource(requestUrl)) { event.respondWith(fetch(event.request).catch(() => offline503)); return; } // ④ 白名单资源 → 缓存优先,未命中则网络获取并写入缓存 event.respondWith( caches.match(event.request).then(response => { if (response) return response; // 缓存命中 return fetch(event.request).then(resp => { if (resp.status === 200 && resp.type === 'basic') { caches.open(CACHE_NAME).then(cache => cache.put(event.request, resp.clone())); } return resp; }); }) ); });
三分流设计:① 跨域 API 不缓存 ② 管理后台不缓存(安全)③ 非白名单不缓存。 只有白名单内的核心资源走「缓存优先」,既保证离线可用,又确保 API 数据实时性。

版本更新检测机制

JS - app.js async function checkForUpdates() { // 1. 加时间戳请求 version.txt,防止浏览器缓存 const response = await fetch('version.txt?t=' + Date.now()); const serverVersion = (await response.text()).trim(); // 2. 与本地 CURRENT_VERSION 对比 if (serverVersion !== CURRENT_VERSION) { showUpdateNotification(); // 显示更新提示横幅 reloadOfflineResources(); // 清旧缓存 + 重新预载 } else { showToast('已是最新版本', 'success'); } } // 3. 清除旧缓存 + 重新预载 async function reloadOfflineResources() { // 删除所有 kj-offline 开头的缓存 await caches.keys().then(names => Promise.all( names.filter(n => n.startsWith('kj-offline')).map(n => caches.delete(n)) )); preloadOfflineResources(); // 重新预载白名单资源 }

系统通知推送机制

JSON - notification.json { "enabled": true, "title": "KJ标签页通知", "body": "<p>欢迎使用KJ操作系统...</p>", "validFrom": "2026-05-29T15:00:00Z", "actionUrl": "app://about" }
JS - app.js async function checkServerNotification() { const response = await fetch('notification.json', { cache: 'no-cache' }); const data = await response.json(); if (data && data.enabled) { // localStorage 记录上次显示时间,避免重复弹窗 const lastShown = localStorage.getItem('kj_notification_last_shown'); const shouldShow = !lastShown || new Date(lastShown) < new Date(data.validFrom || Date.now()); if (shouldShow) { showNotificationPopup(data); // 显示弹窗 } } } function closeNotificationPopup() { // 关闭时记录当前时间,下次不再显示同一条通知 localStorage.setItem('kj_notification_last_shown', new Date().toISOString()); }
去重设计:用 validFrom 时间戳 + localStorage 记录上次显示时间, 只有当通知更新(validFrom 变化)时才再次弹出,避免每次刷新都打扰用户。

3 踩过的坑

缓存版本不更新导致代码不生效

现象:更新代码后用户刷新页面看到的还是旧版本。

原因:Service Worker 缓存了旧资源,且 CACHE_VERSION 没有递增。

解决:每次发版递增 CACHE_VERSION(当前 v30), activate 事件中删除所有非当前版本的缓存。

POST 请求被缓存导致数据不更新

原因:fetch 拦截没有过滤非 GET 请求,cache.put 对 POST 抛异常。

解决:① fetch 拦截开头过滤 method !== 'GET' 直接 return; ② cache.put 前二次确认 method === 'GET'

version.txt 被浏览器缓存

原因fetch('version.txt') 命中浏览器 HTTP 缓存,返回旧版本号。

解决:URL 加时间戳 ?t=Date.now(),强制走网络。

任务12:系统功能增强合集

图标风格 / 云端备份 / 音量滑块 / 待办托盘 / 全屏 / 窗口警告 / Widget边界 / 搜索引擎

背景:这些是迭代过程中逐步添加的中小型功能增强,单独不够撑一个完整任务模块, 但组合起来显著提升了用户体验。此处统一记录实现要点。

1 图标风格切换系统

支持 4 种桌面图标风格实时切换,通过 body class 控制,CSS 负责渲染差异。

风格class视觉效果
默认icon-style-default圆角方块 + 阴影
无界icon-style-borderless无边框,图标悬浮
液态玻璃icon-style-liquid毛玻璃背景 + 模糊
拟物icon-style-skeuomorphic立体阴影 + 渐变
JS - app.js function setIconStyle(style) { localStorage.setItem('iconStyle', style); // 移除所有风格 class,添加当前风格 document.body.classList.remove( 'icon-style-default', 'icon-style-borderless', 'icon-style-liquid', 'icon-style-skeuomorphic' ); document.body.classList.add('icon-style-' + style); // 更新设置面板选中状态 document.querySelectorAll('.icon-style-option').forEach(opt => opt.classList.remove('active')); document.querySelector(`[onclick="setIconStyle('${style}')"]`)?.classList.add('active'); } function loadIconStyle() { const style = localStorage.getItem('iconStyle') || 'default'; document.body.classList.add('icon-style-' + style); }

2 系统云端备份还原

通过 emlog user_expand 插件实现云端同步,把本地配置(IndexedDB + localStorage)序列化为 JSON 上传到服务器。

JS - app.js // 1. 收集本地数据(IndexedDB 配置 + localStorage) const data = await collectKJData(); const kjData = JSON.stringify(data); // 2. 上传到服务器(FormData 方式更可靠) const formData = new FormData(); formData.append('kj_data', kjData); const response = await fetch(`${getApiBaseUrl()}/?plugin=user_expand&action=save_kj_data`, { method: 'POST', credentials: 'include', body: formData }); // 3. 从服务器恢复(先清空本地旧数据,再写入) async function restoreKJData(dataObj) { if (!confirm('确定要同步吗?这将覆盖当前设置。')) return; // 先清除旧 IndexedDB,防止旧数据覆盖还原值 await configDB.clearAllConfigs(); // 再写入新数据... }
关键细节:还原时必须先 clearAllConfigs() 清空 IndexedDB, 否则旧数据会覆盖新还原的值(IndexedDB 的 merge 行为不是完全替换)。

3 音量滑块强调色填充

音量滑块的填充条跟随全局强调色,并根据音量切换三种图标(静音/低音量/高音量)。

JS // 滑块背景用线性渐变填充已选区域 slider.style.background = `linear-gradient(to right, var(--accent-color) ${percent}%, var(--slider-bg) ${percent}%)`; // 三态图标切换 if (volume === 0) icon.className = 'fa-volume-mute'; else if (volume < 50) icon.className = 'fa-volume-down'; else icon.className = 'fa-volume-up';

4 待办事项托盘

从任务栏托盘位置展开的待办列表,带数量徽章和展开/收缩动画。

  • 数据持久化:IndexedDB 存储,跨设备不同步
  • 数量徽章:托盘图标右上角显示未完成数量
  • 展开动画max-height 过渡 + opacity 渐显
  • 复选交互:点击勾选,划线 + 置灰

5 全屏切换 + ESC 同步

开始菜单的全屏按钮 + 浏览器 F11 全屏状态同步。

JS // 监听浏览器全屏变化,同步 UI 状态 document.addEventListener('fullscreenchange', () => { const isFullscreen = !!document.fullscreenElement; fullscreenBtn.classList.toggle('active', isFullscreen); }); // ESC 退出全屏时同步按钮状态 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.fullscreenElement) { document.exitFullscreen(); } });

6 窗口关闭警告

用户关闭浏览器时,如果有未关闭的应用窗口,弹出确认提示。

JS window.addEventListener('beforeunload', (e) => { const openWindows = document.querySelectorAll('.window-element:not(.minimized)'); if (openWindows.length > 0) { e.preventDefault(); e.returnValue = ''; // 触发浏览器原生确认弹窗 } });

7 Widget 拖拽边界限制

小组件拖拽时限制在浏览器可视区域内,防止拖出屏幕。

JS // 拖拽时 clamp 坐标到可视区域 const maxX = window.innerWidth - widget.offsetWidth; const maxY = window.innerHeight - widget.offsetHeight; x = Math.max(0, Math.min(x, maxX)); y = Math.max(0, Math.min(y, maxY)); widget.style.left = x + 'px'; widget.style.top = y + 'px';

8 自定义搜索引擎

支持自定义搜索引擎,用 {query} 占位符替换搜索词,自动识别常见引擎。

JS function setSearchEngine(engine) { localStorage.setItem('searchEngine', engine); } // 搜索时替换占位符 function doSearch(query) { const engine = localStorage.getItem('searchEngine') || 'google'; const url = engine.replace('{query}', encodeURIComponent(query)); window.open(url, '_blank'); } // 预设引擎(自动识别 {query} 占位符) // Google: https://www.google.com/search?q={query} // Bing: https://www.bing.com/search?q={query} // 百度: https://www.baidu.com/s?wd={query}

9 应用管理中心

设置 → 系统 → 应用管理,集中管理所有桌面图标,支持删除和一键恢复。

分类说明操作
自建应用用户通过 KJ API 自定义的应用删除
快捷方式用户添加的网站快捷方式删除
默认图标系统预置的 16 个图标(含应用社区)删除 / 恢复
JS - app.js // 默认图标配置表(删除后可恢复的全部在这) const allDefaultIcons = [ { app: 'photos', name: '照片', color: '#0078d4', icon: 'fas fa-images' }, { app: 'calendar', name: '日历', color: '#e91e63', icon: 'fas fa-calendar' }, { app: 'music', name: '音乐', color: '#9c27b0', icon: 'fas fa-music' }, { app: 'notepad', name: '记事本', color: '#0078d4', icon: 'fas fa-file-lines' }, { app: 'calculator', name: '计算器', color: '#107c10', icon: 'fas fa-calculator' }, { app: 'terminal', name: '终端', color: '#1a1a1a', icon: 'fas fa-terminal' }, { app: 'about', name: '关于系统', color: '#187aff', icon: 'fas fa-info-circle' }, { app: 'appstore', name: '应用社区', color: '#187aff', icon: 'fas fa-store' }, { app: 'google', name: 'Google', color: '#4285f4', icon: 'fab fa-google' }, // ... 共 16 个默认图标 ]; // 删除图标(通用,对所有类型有效) async function deleteDesktopIcon(iconId) { if (!confirm('确定要删除这个图标吗?')) return; const icon = document.querySelector(`#desktop-icons [data-app="${iconId}"]`); if (icon) { icon.remove(); await saveDesktopIconsConfig(); showToast('图标已删除', 'success'); openApp('settings', 'system_apps'); // 刷新页面 } } // 恢复默认图标(带 websiteUrls 特殊处理) function restoreDefaultIcon(appId, name, color, iconClass) { const iconEl = document.createElement('div'); iconEl.className = 'desktop-icon'; iconEl.dataset.app = appId; // 网站类图标额外加 url 和 type 属性 const websiteUrls = { google: '...', youtube: '...', /* ... */ }; if (websiteUrls[appId]) { iconEl.dataset.url = websiteUrls[appId]; iconEl.dataset.type = 'website'; } iconEl.innerHTML = `<div class="icon-img" style="background: ${color};">...`; desktopIcons.appendChild(iconEl); saveDesktopIconsConfig(); }
设计思路:默认图标列表集中配置在 allDefaultIcons 数组里, 删除只是从 DOM 移除并保存配置,恢复时从配置表重新生成。 设置和文件管理器图标被排除(appId !== 'settings' && appId !== 'explorer'),防止用户误删后无法恢复。

10 书签栏

任务栏左侧(开始按钮旁)的书签栏入口,支持导入浏览器收藏夹,默认关闭,可在设置中开启。

功能说明
默认关闭需在设置 → 任务栏 → 「显示书签栏」手动开启
导入书签支持导入 Chrome/Edge/Firefox 导出的 HTML 书签文件
文件夹解析自动解析书签 HTML 中的 <H3> 文件夹结构,可折叠展开
手动添加支持手动输入名称和 URL 添加单条书签
去重导入按 URL 判断重复,已存在的书签不重复导入
数据存储localStorage 持久化,刷新不丢失
点击外部关闭点击书签栏外区域自动收起
JS - 核心函数 // 开关控制 function toggleBookmarksBar() { ... } function toggleBookmarksBarSetting(enabled) { ... } // 设置里的开关 // 数据层 getBookmarks() // localStorage 读取 saveBookmarks() // localStorage 写入 // 渲染层 renderBookmarksList() // 根列表渲染 renderBookmarkItems() // 单组书签项渲染 toggleBookmarkFolder() // 文件夹折叠/展开 // 导入解析(核心算法 - DOMParser 递归解析 DL/DT/H3/A) function parseBookmarkHtml(html) { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); function parseDl(dlElement, folderName) { for (child of dlElement.children) { if (h3) { // <H3> 是文件夹 parseDl(nextDl, folderTitle); // 递归 } else if (a) { // <A> 是书签 bookmarks.push({ name, url, icon }); } } } } // 去重导入(Set 存已有 URL,逐条比对) const existingUrls = new Set(bookmarks.map(b => b.url)); bookmarks.forEach(b => { if (!existingUrls.has(b.url)) { data.bookmarks.push(b); addedCount++; } });
安全设计:因为浏览器安全限制,网页无法直接读取用户的浏览器收藏夹, 所以采用「导出 HTML 文件 → 导入到 KJ」的方案。 DOMParser 在浏览器端本地解析,书签数据不上传服务器,完全在本地处理。

文件架构

KJ 9 项目目录结构与各文件作用说明

1 目录结构总览

📁 KJ/ # 项目根目录
📄 index.html # 主入口页面,桌面 UI 结构
📄 app.js # 核心逻辑(449KB),窗口/桌面/应用管理
📄 style.css # 全局样式表(178KB),含 --accent-color 变量系统
📄 config-db.js # IndexedDB 封装,配置持久化
📄 service-worker.js # 离线缓存 Service Worker
📄 server.js # Node.js 静态文件服务器
📄 version.txt # 版本号(9.1.0)
📄 notification.json # 系统通知配置
📄 trae-competition.html # 本参赛说明页面
📁 configs/ # 应用配置目录
📄 app-config-base.js # AppConfig 基类与注册表
📄 app-init.js # 所有内置应用的配置注册
📁 css/ # 第三方样式
📄 all.min.css # FontAwesome 图标库
📄 codemirror.css # CodeMirror 编辑器样式
📄 dracula.css # Dracula 主题
📁 js/ # 第三方脚本
📄 codemirror.js # CodeMirror 核心代码编辑器
📄 xml.js / css.js / htmlmixed.js / javascript.js # 语法高亮模式
📁 img/ # 图片资源
🖼️ KJ.png / KJwhite.png # 系统 Logo
🖼️ jiazai.gif # 加载动画
🎵 music.m4a # 音乐示例文件
📁 webfonts/ # FontAwesome 字体文件
📁 docs/ # VitePress 文档站点源码
📁 .trae/ # TRAE 项目配置

2 核心文件详解

</>
index.html
64 KB
主入口页面,定义桌面 UI 结构
包含桌面图标容器 #desktop-icons、任务栏 #taskbar、开始菜单、窗口容器 #windows-container、搜索框、小组件等。通过 <script> 标签按顺序加载 config-db.jsapp.jsconfigs/app-init.js,并在末尾调用 initAppConfigs() 注册所有应用配置。
JS
app.js
449 KB
核心逻辑文件,整个桌面系统的"大脑"
包含:
openApp() - 打开应用窗口
makeDraggable() - 窗口拖拽(requestAnimationFrame 优化)
setupResize() - 窗口缩放(8 方向)
setAccentColor() / loadAccentColor() - 强调色系统
loadBingDailyWallpaper() - Bing 壁纸缓存策略
toggleMinimalMode() - 极简模式
toggleCalendarYearPicker() - 日历年份选择器
updateVolumeSliderBackground() - 音量滑块强调色填充
addShortcut() / editWebsiteShortcut() - 快捷方式管理
initIconSelector() - FontAwesome 图标选择器
solarToLunar() - 农历转换(1900-2100)
• 主题切换、壁纸、模糊、极简模式等所有系统功能
CSS
style.css
178 KB
全局样式表,定义所有视觉表现
包含 CSS 变量系统(--accent-color--accent-hover--title-bar-height--text-color--window-bg 等)、74+ 处 color-mix() 透明度变体、三种图标风格(无界/液态/拟物)、窗口动画系统(打开/关闭/拖拽/splash)、Bing 壁纸层、音量滑块、日历年份选择器、待办 widget、极简模式、高斯模糊分级等。
DB
config-db.js
IndexedDB 封装,配置持久化层
定义 ConfigDB 类,数据库名 KJOSDatabase,存储对象 userConfig。提供 init()ensureDB() 等异步 API,用于保存自定义应用、快捷方式、主题等配置,确保用户清除浏览器缓存后数据仍不丢失。
configs/app-config-base.js
应用配置基类与注册表
定义 AppConfig 类(校验 title/icon/width/height/content)和 AppConfigRegistry 注册表。全局暴露 appConfigRegistry 实例和 getAppConfig() 函数,供 openApp() 获取应用配置。
configs/app-init.js
所有内置应用的配置注册
initAppConfigs() 函数内通过 appConfigRegistry.register() 注册所有应用:计算器、终端、任务管理器、设置、关于、照片、日历、音乐、记事本、文件管理器、展览馆、参赛说明等。每个应用配置包含 title、icon、iconBg、width、height、content(HTML 内容)。
SW
service-worker.js
离线缓存 Service Worker
缓存版本 v3,缓存名 kj-offline-v3。预缓存所有核心资源(index.html、app.js、style.css、configs/、css/、js/、img/),实现离线访问。在 index.html 末尾通过 navigator.serviceWorker.register() 注册。
SV
server.js
Node.js 静态文件服务器(开发用)
使用 Node.js 原生 http 模块,端口 8080。根据文件扩展名设置 MIME 类型,支持 html/css/js/json/png/jpg/gif/svg/ico/txt/m4a。用于本地开发调试。
📋
version.txt
版本号文件,用于更新检测
当前内容 9.1.0。系统启动时通过 HTTP 请求此文件,与本地版本对比,判断是否有新版本可用。

3 文件加载顺序

KJ 9 的文件加载有严格的依赖顺序:

index.html <!-- 1. 样式表 --> <link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="css/all.min.css"> <link rel="stylesheet" href="css/codemirror.css"> <link rel="stylesheet" href="css/dracula.css"> <!-- 2. 第三方脚本 --> <script src="js/codemirror.js"></script> <script src="js/xml.js"></script> <script src="js/css.js"></script> <script src="js/htmlmixed.js"></script> <script src="js/javascript.js"></script> <!-- 3. 核心逻辑(按依赖顺序)--> <script src="config-db.js"></script> <!-- IndexedDB 封装 --> <script src="app.js"></script> <!-- 核心逻辑,依赖 configDB --> <script src="configs/app-init.js"></script> <!-- 应用配置,依赖 appConfigRegistry --> <!-- 4. 初始化 --> <script> initAppConfigs(); // 注册所有应用配置 navigator.serviceWorker.register('./service-worker.js'); // 注册 SW </script>
依赖关系config-db.jsapp.js(使用 configDB)→ app-config-base.js(定义 appConfigRegistry)→ app-init.js(注册具体应用)→ initAppConfigs() 调用。

流程图

KJ 9 核心流程的可视化展示

1 系统启动流程

用户打开新标签页
浏览器加载 index.html
加载 CSS 样式表
style.css / FontAwesome / CodeMirror
加载 JS 脚本
config-db.js → app.js → app-init.js
调用 initAppConfigs()
注册所有应用配置
注册 Service Worker?
渲染桌面图标
渲染任务栏
初始化小组件
桌面就绪,等待用户操作

2 打开应用窗口流程

用户双击桌面图标
调用 openApp(appName)
窗口已打开?
↓ 是
focusWindow() 聚焦已有窗口
↓ 否
getAppConfig(appName)
从 appConfigRegistry 获取配置
创建 windowEl 元素
设置宽高、位置、zIndex
渲染标题栏 + 内容区
显示 splash 加载动画
makeDraggable() + setupResize()
绑定拖拽和缩放事件
addToTaskbar()
添加到任务栏
1200ms 后隐藏 splash
显示真实应用内容
应用窗口就绪

3 网站快捷方式创建流程

右键桌面 → 添加快捷方式
showAddShortcutDialog()
显示对话框 + 初始化图标选择器
用户输入名称、URL
选择图标(FontAwesome 可视化)
选择图标颜色(8 色色板)
点击"添加" → addShortcut()
名称和 URL 有效?
↓ 是
创建 shortcut 对象
id / name / url / icon / color
saveDesktopIconsConfig()
持久化到 IndexedDB
创建桌面图标 DOM 元素
绑定单击/双击打开事件
快捷方式创建完成

4 网站快捷方式编辑流程

右键已创建的快捷方式 → 编辑
editWebsiteShortcut(shortcutId)
读取图标 DOM 的 dataset
name / url / icon / color
显示对话框(先 show 再设值)
避免字段被清空
填充表单字段
名称 / URL / 图标 / 颜色
修改按钮文本为"保存"
绑定 saveShortcutEdit()
用户修改后点击"保存"
saveShortcutEdit(shortcutId)
更新 DOM dataset
icon-img 背景 / 图标类名
saveDesktopIconsConfig()
持久化到 IndexedDB
编辑完成,桌面图标更新

5 数据持久化架构

用户操作(创建/编辑/设置)
数据类型?
主题/壁纸/模糊
简单配置
localStorage
同步读写,快速
自定义应用/快捷方式
复杂数据
IndexedDB
config-db.js
静态资源
HTML/CSS/JS/图片
Service Worker
离线缓存
数据持久化完成
三层存储策略
localStorage - 存储简单配置项(主题、壁纸、模糊模式、强调色等),同步读写,性能高
IndexedDB - 存储复杂数据(自定义应用、快捷方式),通过 config-db.js 封装,异步读写,容量大
Service Worker Cache - 缓存静态资源,实现离线访问

6 强调色系统联动流程

用户选择强调色
预设色 or 自定义?
↓ 自定义
打开 HSV 取色器
SV 面板 + Hue 色相条
实时 HSV → HEX 转换
setAccentColor(color)
设置 CSS 变量 + 存 localStorage
--accent-color 更新
按钮/边框
var(--accent-color)
透明度变体
color-mix() 74+处
拟物图标
渐变三态
全系统配色实时更新
防闪烁机制:在 <head> 内联脚本中提前读取 localStorage 并设置 CSS 变量, 确保页面渲染时已是用户选择的强调色,避免默认蓝色闪烁。

使用的 TRAE 能力

在 KJ 开发过程中用到的 TRAE 核心能力

1 自然语言代码搜索

用自然语言描述需求,TRAE 自动定位相关代码。例如定位 editWebsiteShortcutloadBingDailyWallpapersetAccentColor 等函数实现。

2 多文件协同编辑

同时修改 app.js(逻辑)、index.html(结构)、style.css(样式)、config-db.js(数据层)、configs/app-init.js(应用配置),保持五者一致性。

3 上下文感知

理解 KJ 项目的对话框初始化模式,避免破坏现有逻辑。例如知道 showCreatorDialog() 会清空字段,建议调整调用顺序。理解 offsetLeft vs getBoundingClientRect() 在滚动场景下的差异。

4 错误诊断

定位 TypeError: Cannot read properties of undefined (reading 'add') 的根因(函数名冲突),并给出重命名建议。定位 SyntaxError: Identifier 'initNetworkStatus' has already been declared 的重复声明问题。

5 增量修改

只改必要的代码段,不重写整个函数。保持代码库的稳定性和可维护性。例如替换 93+ 处 #187aff 时,逐个确认上下文是否适合替换。

6 会话压缩与长上下文

TRAE 的会话压缩让我能在长对话中保持上下文,不用重复解释项目结构。跨多轮对话完成复杂功能开发(如强调色系统涉及 CSS 变量定义、全量替换、取色器、防闪烁等多个步骤)。

7 深度代码探索

使用 SearchCodebase 语义搜索,快速找到"音量滑块填充色在哪里设置"、"Bing 壁纸缓存逻辑在哪"等跨文件问题。

8 技术方案建议

TRAE 主动建议使用 color-mix() 替代 rgba() 实现强调色透明度变体,建议使用双 requestAnimationFrame 解决 display:none 过渡问题。

关键 Prompt 汇总

开发过程中使用的核心 Prompt 集合

1 功能开发类

Prompt // 桌面图标拖拽 > 修复桌面图标无法自由拖动的问题,比如我把图标拖动到计算器的下面, 但是图标会跳到图标的末尾,而不是在计算器的下面,此外加入插入标识符 // 高斯模糊分级 > 在KJ桌面模式下,设置-系统-辅助功能下增加一个高斯模糊选项:仅标题栏高斯模糊 // 图标选择 > 编辑网站快捷方式可以修改图标,用户可以从FontAwesome中可视化选择图标 // 图标颜色 > 添加快捷方式也可以选择图标颜色 // 全局强调色 > 新增功能,可以修改KJ系统的全局强调色,目前强调色是#187aff // Bing 每日壁纸 > 设置-系统-个性化中的自然风景改为bing每日壁纸 // 壁纸缓存+渐变 > 每日壁纸每次打开KJ新标签页都会刷新,如果可以,清缓存至用户的电脑里, 如果图片一直未加载,请使用灰色背景,加载完毕之后以渐变的形式展现 // 日历年份选择器 > 给日历APP以及任务栏右下角的日历在切换月份的时候具备左右横移动画, 此外,点击标题可以转到年份切换 // 待办事项托盘 > 在任务栏右下角像音量、网络图标一样加入待办事项的托盘图标, 用户可以显示或者隐藏待办事项 // 全屏按钮 > 在开始按钮的底栏关机的左侧增加一个功能:全屏 // 窗口关闭警告 > 在KJ中如果用户打开了窗口,那么关闭KJ新标签页的时候提醒

2 Bug 修复类

Prompt // 报错直接贴 > TypeError: Cannot read properties of undefined (reading 'add') // 具体描述现象 > 修复bug,当我右键一个已经创建的网站应用,编辑输入框里无法填充已经创建的网站链接, 点击创建之后又重新创建了新的网站应用 // 视觉问题 > 有bug,横线应该显示在图标下方,而不是贯穿到整个屏幕,此外图标只能拖放到第一列, 其他列无法拖动 // 强调色误伤矢量壁纸 > 修复bug,当强调色切换之后,内置的几个矢量背景皮肤也随之变化了,应当保持蓝色 // 极简模式光效不生效 > 极简模式下红蓝特效还是没有生效,是不是层级有问题 // 跨浏览器壁纸不一致 > 我在两台浏览器测试了,bing每日壁纸,两张壁纸不一样 // 桌面图标跨列拖拽 > 检查问题,当我以默认桌面的形式进入的时候,桌面仅有两列图标, 第二列拖动图标的时候拖不到第二行列任意位置 // Widget 超出屏幕 > 修复当用户调整浏览器比例的时候,待办事项和搜索栏都会超出屏幕外

3 Prompt 技巧总结

  1. 具体描述现象:不说"修复拖动",而说"拖到计算器下面却跳到末尾"
  2. 报错信息直接贴TypeError: ... 比"有 bug"有效得多
  3. 分步验证:复杂功能拆成多步,每步验证
  4. 保留上下文:利用 TRAE 的长上下文能力,避免重复解释

踩坑总结

开发过程中遇到的关键问题与解决方案

1 函数名冲突

现象TypeError: Cannot read properties of undefined (reading 'add')

原因selectIcon 同时被桌面图标选中和图标选择器使用

解决:重命名为 selectShortcutIcon,消除命名冲突

2 对话框初始化顺序

现象:编辑时输入框无法填充已有数据

原因showCreatorDialog() 内部会清空字段,先设值再显示会导致值被清空

解决:调整顺序,先 showDialog() 再设值

3 编辑模式无状态标识

现象:点击"创建"变成新增,而非更新

原因:原代码无状态标识,无法区分"新建"还是"更新"

解决:引入 editingAppId 全局变量

4 CSS 横杠贯穿屏幕

现象:插入标识符横杠从屏幕左边延伸到右边

原因width: 100% 相对于视口而非父容器

解决:限定宽度为图标容器宽度

5 多列拖放失效

现象:只能拖到第一列,其他列无法拖动

原因elementFromPoint 在多列布局下命中错误容器

解决:遍历所有图标计算相对位置

6 高斯模糊误伤

现象:照片应用预览窗口被切换为浅色主题

原因:模糊样式作用域过大

解决:为预览窗口单独锁定深色主题

7 网络状态图标误报

现象:右下角网络图标显示"服务器不可达",但设置中可检测更新

原因:网络检查逻辑与更新检查逻辑不一致

解决:统一网络状态判断逻辑

8 桌面图标跨列拖拽失效

现象:第二列图标无法拖到第二行的任意位置

原因Math.floor((x-rect.left)/94) 硬编码列宽 + getBoundingClientRect() 在滚动时 Y 坐标偏移

解决:改用 offsetLeft 分组识别列 + offsetTop + scrollTop 补偿比较 Y 位置

9 极简模式光效 z-index 遮挡

现象:红蓝光效开关打开后看不到任何效果

原因body::before 伪元素 z-index: 0#minimal-mode-containerz-index: 99)完全遮挡

解决:改为容器内部插入 <div> 元素作为第一个子元素

10 pointer-events 吞噬点击

现象:极简模式下光效开关点击无反应

原因:容器基础 pointer-events: none.visible 状态未恢复 auto

解决#minimal-mode-container.visible { pointer-events: auto }

11 Bing 壁纸跨浏览器不一致

现象:两台浏览器显示不同的 Bing 壁纸

原因api.dujin.org 重定向服务按 IP 返回不同缓存

解决:移除 dujin fallback,统一用 Bing 官方 API + allorigins 代理 + 时间戳防缓存

12 display:none → transition 失效

现象:待办 widget 从隐藏切换到显示时无过渡动画

原因display:none → display:flex 不触发 CSS transition

解决:使用双 requestAnimationFrame 技巧 — 先设 display,下一帧再设 transform/opacity

13 重复函数声明导致 SyntaxError

现象SyntaxError: Identifier 'initNetworkStatus' has already been declared

原因:同一函数在 app.js 中存在两份定义(简版 + 完整版),JS 不允许重复声明

解决:删除简版,保留完整版。注意级联错误(AppConfigRegistry not found)会随语法错误修复而消失

版本更新日志

KJ 9 完整版本迭代记录(9.0.0 → 9.1.1)

1 v9.1.1 — 2026年6月21日

新增 开发人员菜单查看 CSS
新增 自定义链接及网站应用图标
修复 桌面图标拖动问题,添加动态放置反馈
修复 桌面图标框选问题
优化 低配机可选高斯模糊配置

2 v9.1.0 — 2026年5月29日

新增 极简模式
新增 音乐 APP(模仿 KJ 8.1.0 PPT 系统)
新增 照片 APP
新增 "创建你的应用"功能(KJ API:消息通知、音量控制、窗口控制、高斯模糊等)
新增 任务栏音量控制
新增 网络状态栏图标
新增 设置-更新-预载功能(离线秒启动)
新增 待办事项(数量显示+收缩模式)
新增 高斯模糊透明 div 效果
优化 日历 APP 显示农历

3 v9.0.6 — 2026年5月23日

优化 检测更新
修复 框选错位

4 v9.0.5 — 2026年5月21日

新增 任务管理器(JS 内存占用查看)
新增 开发人员名单彩蛋(关机滑动触发)
优化 窗口不跑出浏览器外

5 v9.0.4 — 2026年5月15日

重构 应用实现方式
新增 应用管理

6 v9.0.3 — 2026年5月12日

新增 确认关机界面
新增 文件管理

7 v9.0.2 — 2026年5月7日

新增 备份还原
优化 窗口拖动性能

8 v9.0.1 — 2026年5月2日

新增 "创建你的应用"
新增 在线代码编辑器
安全 修复 XSS 漏洞

9 v9.0.0 — 2026年4月28日

里程碑 全新 KJ 9 操作系统发布
新增 现代化"锅UI 2"设计语言
新增 自定义搜索引擎
新增 任务栏样式切换
新增 桌面图标排列

成果展示

使用 TRAE 完成的功能与改进

40+
完成功能/修复
12
核心任务模块
18+
关键 Bug 修复

1 功能成果

  • 桌面图标多列拖拽:支持自由拖放 + 插入标识符 + offsetLeft 列识别
  • 高斯模糊分级控制:仅标题栏模糊 + 主题隔离
  • 网站快捷方式完整编辑:图标选择 + 颜色 + 编辑回填
  • CSS 自定义板块:开发人员选项中直接写 CSS 改 UI
  • 全局强调色系统:7 预设 + HSV 取色器 + 100+ 处 CSS 变量联动
  • Bing 每日壁纸:每日缓存 + CORS 代理 + 灰色占位 + 渐变显现
  • 极简模式:隐藏全部系统 UI + 搜索 + 问候语 + 红蓝光效
  • 日历年份选择器:10 年网格 + 月份横滑动画 + 标题高亮反馈
  • 音量滑块填充:强调色渐变填充 + 三态图标切换
  • 待办事项托盘:从托盘位置展开/收缩动画 + 数量徽章
  • 全屏切换:开始菜单全屏按钮 + ESC 同步
  • 窗口关闭警告:beforeunload 检测未关闭窗口
  • Widget 拖拽边界:限制在浏览器可视区域内
  • 自定义搜索引擎:支持 {query} 占位符 + 自动识别
  • 系统备份/还原:修复无法还原的问题
  • 应用社区前端:IIFE 独立模块 + 跨域 API 封装 + 本地 UUID 身份
  • Service Worker 离线缓存:白名单预缓存 + 三分流 fetch 策略 + 版本管理
  • 版本更新检测:version.txt 对比 + 自动清缓存重载
  • 系统通知推送:notification.json 配置 + localStorage 去重
  • 图标风格切换:4 种风格(默认/无界/液态/拟物)实时切换
  • 云端备份还原:IndexedDB + localStorage 序列化上传 emlog 服务器

2 技术亮点

  • CSS 变量 + color-mix() 联动:74+ 处使用 color-mix(in srgb, var(--accent-color) X%, transparent),无需预计算多色值
  • HSV 自定义取色器:纯 JS 实现 SV 面板 + Hue 色相条 + HEX 输入
  • offsetLeft/offsetTop 坐标系:解决 flex-wrap 多列布局下 getBoundingClientRect 滚动偏移问题
  • 双 requestAnimationFrame 技巧:解决 display:none → transition 失效问题
  • 三层缓存策略:localStorage(配置)+ IndexedDB(复杂数据)+ Service Worker(离线资源)
  • Bing API + CORS 代理:allorigins.win + 时间戳防缓存
  • FontAwesome 可视化选择器:分类 + 搜索 + 中文译名 + 网格预览
  • 编辑模式状态管理editingAppId 区分新建/更新
  • 主题隔离机制:为特定窗口锁定主题,避免全局样式影响
  • 三种图标风格:无界 / 液态玻璃 / 拟物,可实时切换
  • 农历转换:1900-2100 年完整农历数据表
  • IIFE 模块独立化:应用社区 1500+ 行代码完全不污染 app.js,通过 window 挂载点解耦
  • Service Worker 三分流策略:跨域/API/非白名单走网络,白名单走缓存优先
  • 本地 UUID 身份系统:无登录态下用 localStorage 生成唯一用户标识
  • 通知去重机制:validFrom 时间戳 + localStorage 记录,避免重复弹窗

3 项目最终状态

KJ 9.1.9 实现了完整的桌面 OS 体验:

  • 14 个内置应用 + 自定义应用创建(KJ API 开放)
  • 主题/壁纸/模糊分级控制 + 全局强调色系统
  • 桌面图标支持多列自由拖拽带插入标识
  • 网站快捷方式支持图标+颜色+编辑全生命周期
  • 极简模式:隐藏全部 UI,专注搜索体验
  • Bing 每日壁纸:每日缓存 + 渐变显现
  • 日历 APP:年份选择器 + 月份横滑动画 + 农历
  • 开发者可通过 CSS 自定义板块深度定制系统 UI
  • Service Worker 离线缓存 + IndexedDB 配置持久化
  • 四种图标风格 + 拟物/液态/无界/默认实时切换
  • 应用社区:独立模块化前端 + 飞书审核后端 + 云端备份
  • PWA:离线缓存 + 版本检测 + 系统通知推送
总结:通过 TRAE,我能够用自然语言描述需求,快速定位和修复 Bug,实现复杂功能。 TRAE 的上下文感知和错误诊断能力,让我在 11 年的项目迭代中,依然能高效地完成新功能开发和问题修复。 从 CSS 变量联动到 HSV 取色器,从 Bing API 缓存到 offsetLeft 坐标系,TRAE 都能准确理解技术上下文并给出正确实现。