commit 30db7100936e10db219f9d3026af1d62c3b9a128 Author: yangy Date: Tue Mar 31 18:52:37 2026 +0800 Initial commit: 提交控制器版本2核心代码 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d21f814 --- /dev/null +++ b/.gitignore @@ -0,0 +1,109 @@ +# Dependencies +**/node_modules +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Webdev artifacts (checkpoint zips, migrations, etc.) +.webdev/ diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..27a587d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +.git +*.min.js +*.min.css diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..67c0bc8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "proseWrap": "preserve" +} diff --git a/UI_LAYOUT_GUIDE.md b/UI_LAYOUT_GUIDE.md new file mode 100644 index 0000000..1a8093f --- /dev/null +++ b/UI_LAYOUT_GUIDE.md @@ -0,0 +1,380 @@ +# Luxsin X9 UI 功能排表与布局指南 + +## 全局组件 + +### 1. 顶部状态栏(所有页面) +| 位置 | 组件 | 功能 | 尺寸 | +|------|------|------|------| +| 最顶部 | StatusBar | 显示时间、信号、WiFi、电量 | 44px 高度 | +| 左侧 | 时间显示 | 实时更新系统时间(HH:MM) | 13px 字体 | +| 右侧 | 状态图标 | 信号强度、WiFi、蓝牙、电量指示 | 3.5-4px 图标 | + +### 2. 底部导航栏(手机端 < 1024px) +| 位置 | Tab 名称 | 路由 | 图标 | +|------|---------|------|------| +| 左1 | 社区 | /community | 消息气泡 | +| 左2 | HP-EQ | /eq | 波形图 | +| 中央 | 主页 | / | 房子 | +| 右1 | Effect | /effects | 效果器 | +| 右2 | 我的AI | /ai | AI 图标 | + +--- + +## 首页(Home / Dashboard) + +### 区块 1:顶部导航栏 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 设备名称 + 下拉箭头 | 显示 "Luxsin X9" | 可切换设备(预留功能) | +| 右侧 | 设置按钮 | 跳转到 /system | 齿轮图标,圆形玻璃按钮 | + +### 区块 2:设备示意图 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 中央 | 设备 SVG 图 | 展示 Luxsin X9 外观 | 可替换为真实产品照片 | +| 下方 | 音频格式显示 | 显示当前采样率(如 PCM 44.1kHz) | 文本信息 | + +### 区块 3:音量控制 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 中央 | 音量数值 | 显示当前音量(如 -22dB) | 青色文字,大字体 | +| 下方 | 音量滑块 | 拖拽调整音量 | 青色填充 + 霓虹发光 | +| 左右 | 减/加按钮 | 快速调整音量 ±1dB | 喇叭图标 | + +### 区块 4:快捷控制矩阵(2×2 网格) +| 位置 | 按钮 | 功能 | 说明 | +|------|------|------|------| +| 左上 | 电源 | 开关设备 | 电源图标,圆形玻璃按钮 | +| 右上 | EQ | 跳转 /eq(HP-EQ 页面) | 波形图标 | +| 左下 | HP-EQ | 跳转 /eq | 耳机图标 | +| 右下 | Bypass | 切换 DSP 处理 | 绕过图标 | + +### 区块 5:快速菜单列表 +| 位置 | 菜单项 | 路由 | 说明 | +|------|--------|------|------| +| 1 | 输入源 | /io | 选择输入端口(USB/光纤/蓝牙等) | +| 2 | 输出端口 | /io | 选择输出端口(XLR/RCA/耳机等) | +| 3 | Effect | /effects | 效果器开关 + 当前效果器名称 | +| 4 | 音频设置 | /audio | 音频参数调整 | +| 5 | VU 表 | /vu | VU 表库选择 | + +--- + +## HP-EQ 页面(/eq) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回首页 | 左箭头图标 | +| 中央 | 标题 | 显示 "HP-EQ" | 17px 字体,加粗 | +| 右侧 | 开关 | 启用/禁用 HP-EQ | iOS 风格 Toggle,缩小 10% | + +### 区块 2:操作卡片(2 列) +| 位置 | 卡片 | 功能 | 尺寸 | +|------|------|------|------| +| 左 | 批量编辑 | 打开批量编辑界面 | 玻璃卡片,py-3(高度缩小 10px) | +| 右 | 添加耳机型号 | 添加新的耳机型号 | 玻璃卡片,py-3 | + +### 区块 3:耳机型号选择 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 耳机名称 + 下拉箭头 | 显示当前选择的耳机型号 | 可点击跳转到 SelectPage | +| 右侧 | −/+ 按钮 | 快速切换耳机型号 | 两个圆形按钮 | + +### 区块 4:频率响应图 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 顶部 | 图例 | 显示 Equalizer / Raw / Equalized | 三种曲线类型 | +| 中央 | A/B 对比按钮 | 切换 A/B 曲线对比 | 青色高亮激活状态 | +| 中央 | 复制到 B 按钮 | 将 A 曲线复制到 B | 灰色按钮 | +| 中央 | DIFF 按钮 | 显示差异曲线 | 黄色边框按钮 | +| 下方 | SVG 曲线图 | 绘制频率响应曲线 | 带网格线,频率标签(20Hz-20kHz) | + +### 区块 5:波段按钮网格(2 行 × 5 列) +| 位置 | 按钮 | 频率 | 说明 | +|------|------|------|------| +| 1 | 9.5K | LSHELF | 低频架 | +| 2 | 9.2K | PEAK | 峰值 | +| 3 | 220 | PEAK | 中低频 | +| 4 | 500 | PEAK | 中频(当前选中,青色) | +| 5 | 1.2K | PEAK | 中高频 | +| 6 | 13.8K | NOTCH | 凹陷 | +| 7 | 11.0K | PEAK | 峰值 | +| 8 | 7.4K | PEAK | 高频 | +| 9 | 8.2K | PEAK | 高频 | +| 10 | 10K | HSHELF | 高频架 | + +### 区块 6:滤波器参数卡 +| 位置 | 参数 | 范围 | 说明 | +|------|------|------|------| +| 顶部 | 滤波器类型 | PEAK / LSHELF / HSHELF / NOTCH 等 | 点击跳转 SelectPage 选择 | +| 中部 | FREQ(频率) | 20Hz - 20kHz | 青色滑块,显示当前值 | +| 中部 | GAIN(增益) | -15dB - +15dB | 青色滑块,显示当前值 | +| 下部 | Q 值 | 0.1 - 10 | 青色滑块,显示当前值 | + +### 区块 7:总增益卡 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 总增益标签 | 显示 "总增益" | 文本标签 | +| 中央 | 总增益数值 | 显示当前总增益(如 -3.0 dB) | 青色文字 | +| 右侧 | AUTO 开关 | 自动调整总增益 | iOS Toggle,缩小 10% | +| 下方 | 总增益滑块 | 手动调整总增益 | 青色滑块 | + +--- + +## 音频设置页面(/audio) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回上一页 | 左箭头 | +| 中央 | 标题 | 显示 "音频设置" | 17px 字体 | + +### 区块 2:iOS 列表行(可滚动) +| 序号 | 菜单项 | 右侧显示 | 功能 | +|------|--------|---------|------| +| 1 | 左右平衡 | 数值 + 滑块 | 调整 L/R 平衡 | +| 2 | VU 表灵敏度 | 数值 + 滑块 | 调整 VU 表灵敏度 | +| 3 | 输出特性 | 下拉选择 | 跳转 SelectPage 选择 | +| 4 | 滤波特性 | 下拉选择 | 跳转 SelectPage 选择 | +| 5 | 耳机增益 | 下拉选择 | 跳转 SelectPage 选择 | +| 6 | 音量幅度 | 下拉选择 | 跳转 SelectPage 选择 | +| 7 | 开机音量 | 下拉选择 | 跳转 SelectPage 选择 | +| 8 | XLR 端口极性 | 下拉选择 | 跳转 SelectPage 选择 | +| 9 | IIS 静音电平 | 下拉选择 | 跳转 SelectPage 选择 | +| 10 | IIS 模式 | 下拉选择 | 跳转 SelectPage 选择 | + +--- + +## 效果器页面(/effects) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回首页 | 左箭头 | +| 中央 | 标题 | 显示 "Effect" | 17px 字体 | +| 右侧 | 开关 | 启用/禁用效果器 | iOS Toggle,缩小 10% | + +### 区块 2:效果器卡片(可滚动列表) +| 序号 | 效果器名称 | 右侧显示 | 功能 | +|------|-----------|---------|------| +| 1 | Style(风格) | 下拉 + Toggle | 选择风格 / 启用开关 | +| 2 | 声场宽度 | 数值 + 滑块 + Toggle | 调整宽度 / 启用开关 | +| 3 | Crossfeed(交叉反馈) | 下拉 + Toggle | 选择预设 / 启用开关 | +| 4 | 音调 | Toggle | 启用/禁用 | +| 5 | 响度补偿 | Toggle | 启用/禁用 | +| 6 | 低音炮 | Toggle | 启用/禁用 | + +--- + +## 输入/输出页面(/io) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回上一页 | 左箭头 | +| 中央 | 标题 | 显示 "输入输出" | 17px 字体 | + +### 区块 2:Input 圆形图标选择器 +| 图标 | 标签 | 功能 | 说明 | +|------|------|------|------| +| USB-B | USB-B | 选择 USB-B 输入 | 圆形按钮,激活时青色 | +| USB-C | USB-C | 选择 USB-C 输入 | 圆形按钮,激活时青色 | +| 同轴 | 同轴 | 选择同轴输入 | 圆形按钮 | +| 光纤 | 光纤 | 选择光纤输入 | 圆形按钮 | +| 蓝牙 | 蓝牙 | 选择蓝牙输入 | 圆形按钮 | +| IIS | IIS | 选择 IIS 输入 | 圆形按钮 | + +### 区块 3:Output 圆形图标选择器 +| 图标 | 标签 | 功能 | 说明 | +|------|------|------|------| +| XLR | XLR | 选择 XLR 输出 | 圆形按钮 | +| RCA | RCA | 选择 RCA 输出 | 圆形按钮 | +| XLR/RCA | XLR/RCA | 选择 XLR/RCA 输出 | 圆形按钮 | +| 耳机 | 耳机 | 选择耳机输出 | 圆形按钮,激活时青色 | + +--- + +## 显示与灯光页面(/display) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回上一页 | 左箭头 | +| 中央 | 标题 | 显示 "显示与灯光" | 17px 字体 | + +### 区块 2:iOS 列表行 +| 序号 | 菜单项 | 右侧显示 | 功能 | +|------|--------|---------|------| +| 1 | 屏幕亮度 | 下拉选择 | 跳转 SelectPage 选择 | +| 2 | 自动锁屏 | 下拉选择 | 跳转 SelectPage 选择 | +| 3 | 旋钮亮度 | 下拉选择 | 跳转 SelectPage 选择 | +| 4 | 旋钮呼吸灯 | Toggle | 启用/禁用呼吸灯效果 | +| 5 | 按键灯 | Toggle | 启用/禁用按键灯 | + +--- + +## DSP & DAC 页面(/dsp) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回上一页 | 左箭头 | +| 中央 | 标题 | 显示 "DSP & DAC" | 17px 字体 | + +### 区块 2:iOS 列表行 +| 序号 | 菜单项 | 右侧显示 | 功能 | +|------|--------|---------|------| +| 1 | DSP 引擎 | 下拉选择 | 跳转 SelectPage 选择 | +| 2 | DAC 模式 | 下拉选择 | 跳转 SelectPage 选择 | +| 3 | 模拟增益 | 下拉选择 | 跳转 SelectPage 选择 | +| 4 | XLR 端口阻抗 | 下拉选择 | 跳转 SelectPage 选择 | +| 5 | ARC 模式 | 下拉选择 | 跳转 SelectPage 选择 | + +--- + +## 蓝牙页面(/bluetooth) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回上一页 | 左箭头 | +| 中央 | 标题 | 显示 "蓝牙" | 17px 字体 | + +### 区块 2:蓝牙状态卡 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 顶部 | 蓝牙状态 | 显示已连接/未连接 | 文本信息 | +| 中部 | 设备名称 | 显示配对的蓝牙设备名 | 文本信息 | +| 下部 | 信号强度 | 显示蓝牙信号强度 | 信号强度条 | + +### 区块 3:蓝牙播放控制 +| 位置 | 按钮 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 上一曲 | 播放上一首 | 跳过后退图标 | +| 中央 | 播放/暂停 | 控制播放 | 播放/暂停图标 | +| 右侧 | 下一曲 | 播放下一首 | 跳过前进图标 | + +--- + +## 系统设置页面(/system) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回首页 | 左箭头 | +| 中央 | 标题 | 显示 "系统设置" | 17px 字体 | + +### 区块 2:iOS 列表行 +| 序号 | 菜单项 | 右侧显示 | 功能 | +|------|--------|---------|------| +| 1 | 连接状态 | 已连接/断开 | 显示当前连接状态 | +| 2 | 设备 IP | IP 地址 | 显示设备 IP 地址 | +| 3 | 语言 | 下拉选择 | 跳转 SelectPage 选择语言 | +| 4 | 定时器 | 下拉选择 | 跳转 SelectPage 选择定时器 | +| 5 | 设备信息 | 箭头 | 查看设备详细信息 | +| 6 | 断开连接 | 按钮 | 断开当前连接 | +| 7 | Demo 模式 | Toggle | 启用/禁用演示模式 | + +--- + +## VU 表页面(/vu) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回上一页 | 左箭头 | +| 中央 | 标题 | 显示 "VU 表" | 17px 字体 | + +### 区块 2:VU 表库选择(网格或列表) +| 库名 | 类型 | 功能 | 说明 | +|------|------|------|------| +| VU 表 1 | 指针表 | 选择指针式 VU 表 | 圆形或列表项 | +| VU 表 2 | 条形表 | 选择条形 VU 表 | 圆形或列表项 | +| VU 表 3 | 数字表 | 选择数字 VU 表 | 圆形或列表项 | + +--- + +## 通用选择页面(/select) + +### 区块 1:页面头部 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 左侧 | 返回按钮 | 返回上一页 | 左箭头 | +| 中央 | 标题 | 动态显示选择项标题 | 从 URL 参数读取 | + +### 区块 2:选择项列表 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 列表 | 选择项 | 点击选择 | 激活时青色高亮 | +| 右侧 | 复选标记 | 显示当前选中项 | 青色勾选图标 | + +--- + +## 连接页面(/connect) + +### 区块 1:页面背景 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 全屏 | 背景图 | 展示品牌背景 | 深色渐变 + 光效 | + +### 区块 2:中央卡片 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 顶部 | 品牌 Logo | 显示 Luxsin 标志 | 圆形图标 | +| 中部 | 标题 | 显示 "Luxsin X9" | 大字体 | +| 中部 | 副标题 | 显示 "Pre-Amplifier Controller" | 灰色小字 | + +### 区块 3:连接输入框 +| 位置 | 组件 | 功能 | 说明 | +|------|------|------|------| +| 输入框 | IP 地址输入 | 输入设备 IP 地址 | 玻璃卡片风格 | +| 下方 | 提示文本 | 显示输入提示 | 灰色小字 | + +### 区块 4:连接按钮 +| 位置 | 按钮 | 功能 | 说明 | +|------|------|------|------| +| 主按钮 | 连接设备 | 连接到输入的 IP | 青色按钮,全宽 | +| 副按钮 | 使用演示模式 | 进入 Demo 模式 | 青色文字链接 | + +--- + +## 样式参考 + +### 颜色系统 +| 用途 | 颜色值 | 说明 | +|------|--------|------| +| 主色调 | #00FFF6 | 青色,用于按钮、Toggle、滑块、高亮 | +| 背景 | #000000 | 纯黑 | +| 卡片背景 | rgba(28,28,30,0.92) | 深灰玻璃 | +| 文字主色 | #FFFFFF | 白色 | +| 文字辅色 | rgba(255,255,255,0.6) | 半透明白色 | +| 边框 | rgba(255,255,255,0.1) | 浅色边框 | + +### 圆角规范 +| 元素 | 圆角值 | 说明 | +|------|--------|------| +| 按钮 | 10-12px | 中等圆角 | +| 卡片 | 12-16px | 较大圆角 | +| 输入框 | 10px | 中等圆角 | +| Toggle | 完全圆形 | 药丸形 | + +### 尺寸规范 +| 元素 | 尺寸 | 说明 | +|------|------|------| +| Toggle 开关 | 46×28px | 缩小 10% 后的尺寸 | +| 圆形按钮 | 40×40px | 快捷控制、输入输出选择 | +| 列表行高 | 56px | iOS 标准列表行高 | +| 状态栏高度 | 44px | iOS 标准状态栏高度 | + +--- + +## 使用说明 + +当你需要修改某个组件时,可以这样指出: +- **"首页区块 4 的电源按钮改成..."** — 指首页快捷控制矩阵的左上按钮 +- **"HP-EQ 区块 5 的波段按钮改成..."** — 指 HP-EQ 页面的频率波段网格 +- **"音频设置第 3 行改成..."** — 指音频设置页面的第 3 个菜单项 +- **"所有 Toggle 开关改成..."** — 指全局所有 iOS Toggle 组件 + +这样我就能准确定位你要改的位置了! diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..32f124a --- /dev/null +++ b/client/index.html @@ -0,0 +1,19 @@ + + + + + + Luxsin X9 Controller + + + + + +
+ + + + diff --git a/client/public/.gitkeep b/client/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/client/public/__manus__/debug-collector.js b/client/public/__manus__/debug-collector.js new file mode 100644 index 0000000..0504555 --- /dev/null +++ b/client/public/__manus__/debug-collector.js @@ -0,0 +1,821 @@ +/** + * Manus Debug Collector (agent-friendly) + * + * Captures: + * 1) Console logs + * 2) Network requests (fetch + XHR) + * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.) + * + * Data is periodically sent to /__manus__/logs + * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log + */ +(function () { + "use strict"; + + // Prevent double initialization + if (window.__MANUS_DEBUG_COLLECTOR__) return; + + // ========================================================================== + // Configuration + // ========================================================================== + const CONFIG = { + reportEndpoint: "/__manus__/logs", + bufferSize: { + console: 500, + network: 200, + // semantic, agent-friendly UI events + ui: 500, + }, + reportInterval: 2000, + sensitiveFields: [ + "password", + "token", + "secret", + "key", + "authorization", + "cookie", + "session", + ], + maxBodyLength: 10240, + // UI event logging privacy policy: + // - inputs matching sensitiveFields or type=password are masked by default + // - non-sensitive inputs log up to 200 chars + uiInputMaxLen: 200, + uiTextMaxLen: 80, + // Scroll throttling: minimum ms between scroll events + scrollThrottleMs: 500, + }; + + // ========================================================================== + // Storage + // ========================================================================== + const store = { + consoleLogs: [], + networkRequests: [], + uiEvents: [], + lastReportTime: Date.now(), + lastScrollTime: 0, + }; + + // ========================================================================== + // Utility Functions + // ========================================================================== + + function sanitizeValue(value, depth) { + if (depth === void 0) depth = 0; + if (depth > 5) return "[Max Depth]"; + if (value === null) return null; + if (value === undefined) return undefined; + + if (typeof value === "string") { + return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value; + } + + if (typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.slice(0, 100).map(function (v) { + return sanitizeValue(v, depth + 1); + }); + } + + var sanitized = {}; + for (var k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + var isSensitive = CONFIG.sensitiveFields.some(function (f) { + return k.toLowerCase().indexOf(f) !== -1; + }); + if (isSensitive) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeValue(value[k], depth + 1); + } + } + } + return sanitized; + } + + function formatArg(arg) { + try { + if (arg instanceof Error) { + return { type: "Error", message: arg.message, stack: arg.stack }; + } + if (typeof arg === "object") return sanitizeValue(arg); + return String(arg); + } catch (e) { + return "[Unserializable]"; + } + } + + function formatArgs(args) { + var result = []; + for (var i = 0; i < args.length; i++) result.push(formatArg(args[i])); + return result; + } + + function pruneBuffer(buffer, maxSize) { + if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize); + } + + function tryParseJson(str) { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch (e) { + return str; + } + } + + // ========================================================================== + // Semantic UI Event Logging (agent-friendly) + // ========================================================================== + + function shouldIgnoreTarget(target) { + try { + if (!target || !(target instanceof Element)) return false; + return !!target.closest(".manus-no-record"); + } catch (e) { + return false; + } + } + + function compactText(s, maxLen) { + try { + var t = (s || "").trim().replace(/\s+/g, " "); + if (!t) return ""; + return t.length > maxLen ? t.slice(0, maxLen) + "…" : t; + } catch (e) { + return ""; + } + } + + function elText(el) { + try { + var t = el.innerText || el.textContent || ""; + return compactText(t, CONFIG.uiTextMaxLen); + } catch (e) { + return ""; + } + } + + function describeElement(el) { + if (!el || !(el instanceof Element)) return null; + + var getAttr = function (name) { + return el.getAttribute(name); + }; + + var tag = el.tagName ? el.tagName.toLowerCase() : null; + var id = el.id || null; + var name = getAttr("name") || null; + var role = getAttr("role") || null; + var ariaLabel = getAttr("aria-label") || null; + + var dataLoc = getAttr("data-loc") || null; + var testId = + getAttr("data-testid") || + getAttr("data-test-id") || + getAttr("data-test") || + null; + + var type = tag === "input" ? (getAttr("type") || "text") : null; + var href = tag === "a" ? getAttr("href") || null : null; + + // a small, stable hint for agents (avoid building full CSS paths) + var selectorHint = null; + if (testId) selectorHint = '[data-testid="' + testId + '"]'; + else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]'; + else if (id) selectorHint = "#" + id; + else selectorHint = tag || "unknown"; + + return { + tag: tag, + id: id, + name: name, + type: type, + role: role, + ariaLabel: ariaLabel, + testId: testId, + dataLoc: dataLoc, + href: href, + text: elText(el), + selectorHint: selectorHint, + }; + } + + function isSensitiveField(el) { + if (!el || !(el instanceof Element)) return false; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea") return false; + + var type = (el.getAttribute("type") || "").toLowerCase(); + if (type === "password") return true; + + var name = (el.getAttribute("name") || "").toLowerCase(); + var id = (el.id || "").toLowerCase(); + + return CONFIG.sensitiveFields.some(function (f) { + return name.indexOf(f) !== -1 || id.indexOf(f) !== -1; + }); + } + + function getInputValueSafe(el) { + if (!el || !(el instanceof Element)) return null; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea" && tag !== "select") return null; + + var v = ""; + try { + v = el.value != null ? String(el.value) : ""; + } catch (e) { + v = ""; + } + + if (isSensitiveField(el)) return { masked: true, length: v.length }; + + if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…"; + return v; + } + + function logUiEvent(kind, payload) { + var entry = { + timestamp: Date.now(), + kind: kind, + url: location.href, + viewport: { width: window.innerWidth, height: window.innerHeight }, + payload: sanitizeValue(payload), + }; + store.uiEvents.push(entry); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + } + + function installUiEventListeners() { + // Clicks + document.addEventListener( + "click", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("click", { + target: describeElement(t), + x: e.clientX, + y: e.clientY, + }); + }, + true + ); + + // Typing "commit" events + document.addEventListener( + "change", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("change", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + document.addEventListener( + "focusin", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusin", { target: describeElement(t) }); + }, + true + ); + + document.addEventListener( + "focusout", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusout", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + // Enter/Escape are useful for form flows & modals + document.addEventListener( + "keydown", + function (e) { + if (e.key !== "Enter" && e.key !== "Escape") return; + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("keydown", { key: e.key, target: describeElement(t) }); + }, + true + ); + + // Form submissions + document.addEventListener( + "submit", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("submit", { target: describeElement(t) }); + }, + true + ); + + // Throttled scroll events + window.addEventListener( + "scroll", + function () { + var now = Date.now(); + if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return; + store.lastScrollTime = now; + + logUiEvent("scroll", { + scrollX: window.scrollX, + scrollY: window.scrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + }, + { passive: true } + ); + + // Navigation tracking for SPAs + function nav(reason) { + logUiEvent("navigate", { reason: reason }); + } + + var origPush = history.pushState; + history.pushState = function () { + origPush.apply(this, arguments); + nav("pushState"); + }; + + var origReplace = history.replaceState; + history.replaceState = function () { + origReplace.apply(this, arguments); + nav("replaceState"); + }; + + window.addEventListener("popstate", function () { + nav("popstate"); + }); + window.addEventListener("hashchange", function () { + nav("hashchange"); + }); + } + + // ========================================================================== + // Console Interception + // ========================================================================== + + var originalConsole = { + log: console.log.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + ["log", "debug", "info", "warn", "error"].forEach(function (method) { + console[method] = function () { + var args = Array.prototype.slice.call(arguments); + + var entry = { + timestamp: Date.now(), + level: method.toUpperCase(), + args: formatArgs(args), + stack: method === "error" ? new Error().stack : null, + }; + + store.consoleLogs.push(entry); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", function (event) { + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UncaughtError", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }, + ], + stack: event.error ? event.error.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + // Mark an error moment in UI event stream for agents + logUiEvent("error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + window.addEventListener("unhandledrejection", function (event) { + var reason = event.reason; + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UnhandledRejection", + reason: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : null, + }, + ], + stack: reason && reason.stack ? reason.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + logUiEvent("unhandledrejection", { + reason: reason && reason.message ? reason.message : String(reason), + }); + }); + + // ========================================================================== + // Fetch Interception + // ========================================================================== + + var originalFetch = window.fetch.bind(window); + + window.fetch = function (input, init) { + init = init || {}; + var startTime = Date.now(); + // Handle string, Request object, or URL object + var url = typeof input === "string" + ? input + : (input && (input.url || input.href || String(input))) || ""; + var method = init.method || (input && input.method) || "GET"; + + // Don't intercept internal requests + if (url.indexOf("/__manus__/") === 0) { + return originalFetch(input, init); + } + + // Safely parse headers (avoid breaking if headers format is invalid) + var requestHeaders = {}; + try { + if (init.headers) { + requestHeaders = Object.fromEntries(new Headers(init.headers).entries()); + } + } catch (e) { + requestHeaders = { _parseError: true }; + } + + var entry = { + timestamp: startTime, + type: "fetch", + method: method.toUpperCase(), + url: url, + request: { + headers: requestHeaders, + body: init.body ? sanitizeValue(tryParseJson(init.body)) : null, + }, + response: null, + duration: null, + error: null, + }; + + return originalFetch(input, init) + .then(function (response) { + entry.duration = Date.now() - startTime; + + var contentType = (response.headers.get("content-type") || "").toLowerCase(); + var contentLength = response.headers.get("content-length"); + + entry.response = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: null, + }; + + // Semantic network hint for agents on failures (sync, no need to wait for body) + if (response.status >= 400) { + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + status: response.status, + statusText: response.statusText, + }); + } + + // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + if (isStreaming) { + entry.response.body = "[Streaming response - not captured]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for large responses to avoid memory issues + if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) { + entry.response.body = "[Response too large: " + contentLength + " bytes]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + if (isBinary) { + entry.response.body = "[Binary content: " + contentType + "]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // For text responses, clone and read body in background + var clonedResponse = response.clone(); + + // Async: read body in background, don't block the response + clonedResponse + .text() + .then(function (text) { + if (text.length <= CONFIG.maxBodyLength) { + entry.response.body = sanitizeValue(tryParseJson(text)); + } else { + entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } + }) + .catch(function () { + entry.response.body = "[Unable to read body]"; + }) + .finally(function () { + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + }); + + // Return response immediately, don't wait for body reading + return response; + }) + .catch(function (error) { + entry.duration = Date.now() - startTime; + entry.error = { message: error.message, stack: error.stack }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + message: error.message, + }); + + throw error; + }); + }; + + // ========================================================================== + // XHR Interception + // ========================================================================== + + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._manusData = { + method: (method || "GET").toUpperCase(), + url: url, + startTime: null, + }; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + + if ( + xhr._manusData && + xhr._manusData.url && + xhr._manusData.url.indexOf("/__manus__/") !== 0 + ) { + xhr._manusData.startTime = Date.now(); + xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null; + + xhr.addEventListener("load", function () { + var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase(); + var responseBody = null; + + // Skip body capture for streaming responses + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + + if (isStreaming) { + responseBody = "[Streaming response - not captured]"; + } else if (isBinary) { + responseBody = "[Binary content: " + contentType + "]"; + } else { + // Safe to read responseText for text responses + try { + var text = xhr.responseText || ""; + if (text.length > CONFIG.maxBodyLength) { + responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } else { + responseBody = sanitizeValue(tryParseJson(text)); + } + } catch (e) { + // responseText may throw for non-text responses + responseBody = "[Unable to read response: " + e.message + "]"; + } + } + + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: { + status: xhr.status, + statusText: xhr.statusText, + body: responseBody, + }, + duration: Date.now() - xhr._manusData.startTime, + error: null, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + if (entry.response && entry.response.status >= 400) { + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + status: entry.response.status, + statusText: entry.response.statusText, + }); + } + }); + + xhr.addEventListener("error", function () { + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: null, + duration: Date.now() - xhr._manusData.startTime, + error: { message: "Network error" }, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + message: "Network error", + }); + }); + } + + return originalXHRSend.apply(this, arguments); + }; + + // ========================================================================== + // Data Reporting + // ========================================================================== + + function reportLogs() { + var consoleLogs = store.consoleLogs.splice(0); + var networkRequests = store.networkRequests.splice(0); + var uiEvents = store.uiEvents.splice(0); + + // Skip if no new data + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return Promise.resolve(); + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + // agent-friendly semantic events + uiEvents: uiEvents, + }; + + return originalFetch(CONFIG.reportEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(function () { + // Put data back on failure (but respect limits) + store.consoleLogs = consoleLogs.concat(store.consoleLogs); + store.networkRequests = networkRequests.concat(store.networkRequests); + store.uiEvents = uiEvents.concat(store.uiEvents); + + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + }); + } + + // Periodic reporting + setInterval(reportLogs, CONFIG.reportInterval); + + // Report on page unload + window.addEventListener("beforeunload", function () { + var consoleLogs = store.consoleLogs; + var networkRequests = store.networkRequests; + var uiEvents = store.uiEvents; + + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return; + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + uiEvents: uiEvents, + }; + + if (navigator.sendBeacon) { + var payloadStr = JSON.stringify(payload); + // sendBeacon has ~64KB limit, truncate if too large + var MAX_BEACON_SIZE = 60000; // Leave some margin + if (payloadStr.length > MAX_BEACON_SIZE) { + // Prioritize: keep recent events, drop older logs + var truncatedPayload = { + timestamp: Date.now(), + consoleLogs: consoleLogs.slice(-50), + networkRequests: networkRequests.slice(-20), + sessionEvents: uiEvents.slice(-100), + uiEvents: uiEvents.slice(-100), + _truncated: true, + }; + payloadStr = JSON.stringify(truncatedPayload); + } + navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr); + } + }); + + // ========================================================================== + // Initialization + // ========================================================================== + + // Install semantic UI listeners ASAP + try { + installUiEventListeners(); + } catch (e) { + console.warn("[Manus] Failed to install UI listeners:", e); + } + + // Mark as initialized + window.__MANUS_DEBUG_COLLECTOR__ = { + version: "2.0-no-rrweb", + store: store, + forceReport: reportLogs, + }; + + console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)"); +})(); diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..4502b6a --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,57 @@ +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Route, Switch } from "wouter"; +import ErrorBoundary from "./components/ErrorBoundary"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import { DeviceProvider } from "./contexts/DeviceContext"; +import Home from "./pages/Home"; +import NotFound from "./pages/NotFound"; +import Dashboard from "./pages/Dashboard"; +import AudioPage from "./pages/AudioPage"; +import EQPage from "./pages/EQPage"; +import EffectsPage from "./pages/EffectsPage"; +import BluetoothPage from "./pages/BluetoothPage"; +import DspPage from "./pages/DspPage"; +import DisplayPage from "./pages/DisplayPage"; +import SystemPage from "./pages/SystemPage"; +import IOPage from "./pages/IOPage"; +import VUPage from "./pages/VUPage"; +import SelectPage from "./pages/SelectPage"; + +function Router() { + return ( + + + + + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + + + ); +} + +export default App; diff --git a/client/src/components/BottomNav.tsx b/client/src/components/BottomNav.tsx new file mode 100644 index 0000000..0823adf --- /dev/null +++ b/client/src/components/BottomNav.tsx @@ -0,0 +1,109 @@ +/* ============================================================ + BOTTOM NAV — Luxsin X9 Controller (Mobile) + Design: iOS/Android App-style bottom tab bar + 5 tabs: 社区 / HP-EQ / 主页 / Effect / 我的AI + - Active tab: cyan icon + top indicator bar + - Inactive: muted icon + label + ============================================================ */ +import { cn } from "@/lib/utils"; +import { + Activity, + Bot, + Home, + MessageSquare, + Sliders, +} from "lucide-react"; +import { Link, useLocation } from "wouter"; + +interface NavItem { + path: string; + icon: React.ComponentType<{ size?: number; className?: string }>; + label: string; + placeholder?: boolean; +} + +const NAV_ITEMS: NavItem[] = [ + { path: "/community", icon: MessageSquare, label: "社区", placeholder: true }, + { path: "/eq", icon: Sliders, label: "HP-EQ" }, + { path: "/", icon: Home, label: "主页" }, + { path: "/effects", icon: Activity, label: "Effect" }, + { path: "/ai", icon: Bot, label: "我的AI", placeholder: true }, +]; + +export default function BottomNav() { + const [location] = useLocation(); + + return ( + + ); +} diff --git a/client/src/components/ConnectScreen.tsx b/client/src/components/ConnectScreen.tsx new file mode 100644 index 0000000..ba39759 --- /dev/null +++ b/client/src/components/ConnectScreen.tsx @@ -0,0 +1,117 @@ +/* ============================================================ + CONNECT SCREEN — Luxsin X9 Controller + Design: Pure black iOS App style, glass card, #00FFF6 accent + ============================================================ */ +import { useDevice } from "@/contexts/DeviceContext"; +import { Loader2, Wifi } from "lucide-react"; +import { useState } from "react"; + +export default function ConnectScreen() { + const { ip, setIp, connect, isConnecting, error, setDemoMode, isDemoMode } = useDevice(); + const [inputIp, setInputIp] = useState(ip); + + const handleConnect = async () => { + setIp(inputIp); + setDemoMode(false); + await connect(); + }; + + const handleDemo = async () => { + setDemoMode(true); + await connect(true); + }; + + return ( +
+ {/* Logo / Brand */} +
+
+ + + + + + + + +
+

Luxsin X9

+

Pre-Amplifier Controller

+
+ + {/* Connection card */} +
+
+ +
+ + setInputIp(e.target.value)} + placeholder="192.168.2.30" + className="w-full pl-9 pr-4 py-3.5 rounded-[12px] text-[15px] text-white outline-none transition-all" + style={{ + background: "rgba(44,44,46,0.8)", + border: "1px solid rgba(255,255,255,0.08)", + fontFamily: "'JetBrains Mono', monospace", + }} + onFocus={(e) => { e.target.style.borderColor = "rgba(0,255,246,0.4)"; e.target.style.boxShadow = "0 0 0 3px rgba(0,255,246,0.06)"; }} + onBlur={(e) => { e.target.style.borderColor = "rgba(255,255,255,0.08)"; e.target.style.boxShadow = "none"; }} + onKeyDown={(e) => e.key === "Enter" && handleConnect()} + /> +
+

请确保与设备处于同一局域网

+
+ + {error && ( +
+ {error} +
+ )} + + +
+ + {/* Demo mode */} + + +

+ 演示模式使用模拟数据,无需实际设备 +

+
+ ); +} diff --git a/client/src/components/ErrorBoundary.tsx b/client/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..1422986 --- /dev/null +++ b/client/src/components/ErrorBoundary.tsx @@ -0,0 +1,62 @@ +import { cn } from "@/lib/utils"; +import { AlertTriangle, RotateCcw } from "lucide-react"; +import { Component, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+
+ + +

An unexpected error occurred.

+ +
+
+                {this.state.error?.stack}
+              
+
+ + +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/client/src/components/GlassToggle.tsx b/client/src/components/GlassToggle.tsx new file mode 100644 index 0000000..5f4fdc1 --- /dev/null +++ b/client/src/components/GlassToggle.tsx @@ -0,0 +1,53 @@ +/* ============================================================ + GLASS TOGGLE — Apple-style glass toggle switch + ============================================================ */ +import { cn } from "@/lib/utils"; + +interface GlassToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + size?: "sm" | "md"; +} + +export default function GlassToggle({ checked, onChange, disabled, size = "md" }: GlassToggleProps) { + const isSmall = size === "sm"; + + return ( + + ); +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..52c7bb1 --- /dev/null +++ b/client/src/components/Layout.tsx @@ -0,0 +1,46 @@ +/* ============================================================ + LAYOUT — Luxsin X9 Controller + Design: Desktop → Sidebar left + TopBar + Content + Mobile → TopBar + Content + BottomNav (App style) + ============================================================ */ +import BottomNav from "./BottomNav"; +import Sidebar from "./Sidebar"; +import TopBar from "./TopBar"; +import StatusBar from "./StatusBar"; + +interface LayoutProps { + children: React.ReactNode; + title?: string; + subtitle?: string; +} + +export default function Layout({ children, title, subtitle }: LayoutProps) { + return ( +
+ {/* Global Status Bar */} + + +
+ {/* Desktop sidebar — hidden on mobile */} +
+ +
+ + {/* Main content area */} +
+ +
+ {children} +
+
+
+ + {/* Mobile bottom navigation — hidden on desktop */} + +
+ ); +} diff --git a/client/src/components/ManusDialog.tsx b/client/src/components/ManusDialog.tsx new file mode 100644 index 0000000..0aeff4b --- /dev/null +++ b/client/src/components/ManusDialog.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ManusDialogProps { + title?: string; + logo?: string; + open?: boolean; + onLogin: () => void; + onOpenChange?: (open: boolean) => void; + onClose?: () => void; +} + +export function ManusDialog({ + title, + logo, + open = false, + onLogin, + onOpenChange, + onClose, +}: ManusDialogProps) { + const [internalOpen, setInternalOpen] = useState(open); + + useEffect(() => { + if (!onOpenChange) { + setInternalOpen(open); + } + }, [open, onOpenChange]); + + const handleOpenChange = (nextOpen: boolean) => { + if (onOpenChange) { + onOpenChange(nextOpen); + } else { + setInternalOpen(nextOpen); + } + + if (!nextOpen) { + onClose?.(); + } + }; + + return ( + + +
+ {logo ? ( +
+ Dialog graphic +
+ ) : null} + + {/* Title and subtitle */} + {title ? ( + + {title} + + ) : null} + + Please login with Manus to continue + +
+ + + {/* Login button */} + + +
+
+ ); +} diff --git a/client/src/components/Map.tsx b/client/src/components/Map.tsx new file mode 100644 index 0000000..4849e05 --- /dev/null +++ b/client/src/components/Map.tsx @@ -0,0 +1,155 @@ +/** + * GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE + * + * USAGE FROM PARENT COMPONENT: + * ====== + * + * const mapRef = useRef(null); + * + * { + * mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state. + * + * + * ====== + * Available Libraries and Core Features: + * ------------------------------- + * 📍 MARKER (from `marker` library) + * - Attaches to map using { map, position } + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: { lat: 37.7749, lng: -122.4194 }, + * title: "San Francisco", + * }); + * + * ------------------------------- + * 🏢 PLACES (from `places` library) + * - Does not attach directly to map; use data with your map manually. + * const place = new google.maps.places.Place({ id: PLACE_ID }); + * await place.fetchFields({ fields: ["displayName", "location"] }); + * map.setCenter(place.location); + * new google.maps.marker.AdvancedMarkerElement({ map, position: place.location }); + * + * ------------------------------- + * 🧭 GEOCODER (from `geocoding` library) + * - Standalone service; manually apply results to map. + * const geocoder = new google.maps.Geocoder(); + * geocoder.geocode({ address: "New York" }, (results, status) => { + * if (status === "OK" && results[0]) { + * map.setCenter(results[0].geometry.location); + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: results[0].geometry.location, + * }); + * } + * }); + * + * ------------------------------- + * 📐 GEOMETRY (from `geometry` library) + * - Pure utility functions; not attached to map. + * const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2); + * + * ------------------------------- + * 🛣️ ROUTES (from `routes` library) + * - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached) + * const directionsService = new google.maps.DirectionsService(); + * const directionsRenderer = new google.maps.DirectionsRenderer({ map }); + * directionsService.route( + * { origin, destination, travelMode: "DRIVING" }, + * (res, status) => status === "OK" && directionsRenderer.setDirections(res) + * ); + * + * ------------------------------- + * 🌦️ MAP LAYERS (attach directly to map) + * - new google.maps.TrafficLayer().setMap(map); + * - new google.maps.TransitLayer().setMap(map); + * - new google.maps.BicyclingLayer().setMap(map); + * + * ------------------------------- + * ✅ SUMMARY + * - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers. + * - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService. + * - “data-only” → Place, Geometry utilities. + */ + +/// + +import { useEffect, useRef } from "react"; +import { usePersistFn } from "@/hooks/usePersistFn"; +import { cn } from "@/lib/utils"; + +declare global { + interface Window { + google?: typeof google; + } +} + +const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY; +const FORGE_BASE_URL = + import.meta.env.VITE_FRONTEND_FORGE_API_URL || + "https://forge.butterfly-effect.dev"; +const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`; + +function loadMapScript() { + return new Promise(resolve => { + const script = document.createElement("script"); + script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`; + script.async = true; + script.crossOrigin = "anonymous"; + script.onload = () => { + resolve(null); + script.remove(); // Clean up immediately + }; + script.onerror = () => { + console.error("Failed to load Google Maps script"); + }; + document.head.appendChild(script); + }); +} + +interface MapViewProps { + className?: string; + initialCenter?: google.maps.LatLngLiteral; + initialZoom?: number; + onMapReady?: (map: google.maps.Map) => void; +} + +export function MapView({ + className, + initialCenter = { lat: 37.7749, lng: -122.4194 }, + initialZoom = 12, + onMapReady, +}: MapViewProps) { + const mapContainer = useRef(null); + const map = useRef(null); + + const init = usePersistFn(async () => { + await loadMapScript(); + if (!mapContainer.current) { + console.error("Map container not found"); + return; + } + map.current = new window.google.maps.Map(mapContainer.current, { + zoom: initialZoom, + center: initialCenter, + mapTypeControl: true, + fullscreenControl: true, + zoomControl: true, + streetViewControl: true, + mapId: "DEMO_MAP_ID", + }); + if (onMapReady) { + onMapReady(map.current); + } + }); + + useEffect(() => { + init(); + }, [init]); + + return ( +
+ ); +} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx new file mode 100644 index 0000000..9c07ee6 --- /dev/null +++ b/client/src/components/Sidebar.tsx @@ -0,0 +1,139 @@ +/* ============================================================ + SIDEBAR — Luxsin X9 Controller + Design: Dark glass sidebar with cyan active states + ============================================================ */ +import { useDevice } from "@/contexts/DeviceContext"; +import { cn } from "@/lib/utils"; +import { + Activity, + Bluetooth, + ChevronRight, + Cpu, + Gauge, + Layers, + LayoutDashboard, + Settings, + Sliders, + Volume2, +} from "lucide-react"; +import { Link, useLocation } from "wouter"; + +interface NavItem { + path: string; + icon: React.ComponentType<{ size?: number; className?: string }>; + label: string; +} + +const NAV_ITEMS: NavItem[] = [ + { path: "/", icon: LayoutDashboard, label: "Dashboard" }, + { path: "/audio", icon: Volume2, label: "Audio" }, + { path: "/eq", icon: Sliders, label: "Parametric EQ" }, + { path: "/effects", icon: Layers, label: "Effects" }, + { path: "/bluetooth", icon: Bluetooth, label: "Bluetooth" }, + { path: "/dsp", icon: Cpu, label: "DSP & DAC" }, + { path: "/display", icon: Activity, label: "Display & Lights" }, + { path: "/system", icon: Settings, label: "System" }, +]; + +export default function Sidebar() { + const [location] = useLocation(); + const { deviceState, isConnected, isDemoMode } = useDevice(); + + return ( + + ); +} diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx new file mode 100644 index 0000000..dfd1350 --- /dev/null +++ b/client/src/components/StatusBar.tsx @@ -0,0 +1,60 @@ +import { useState, useEffect } from "react"; + +export default function StatusBar() { + const [time, setTime] = useState("14:35"); + + useEffect(() => { + const updateTime = () => { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + setTime(`${hours}:${minutes}`); + }; + updateTime(); + const interval = setInterval(updateTime, 60000); + return () => clearInterval(interval); + }, []); + + return ( +
+ {time} +
+ {/* 信号强度 */} + + + + {/* WiFi */} + + + + {/* 蓝牙 */} + + + + {/* 电量 */} +
+
+
+
+
+ ); +} diff --git a/client/src/components/TopBar.tsx b/client/src/components/TopBar.tsx new file mode 100644 index 0000000..66ae078 --- /dev/null +++ b/client/src/components/TopBar.tsx @@ -0,0 +1,101 @@ +/* ============================================================ + TOPBAR — Luxsin X9 Controller + Design: Glass top bar with connection status and device info + ============================================================ */ +import { useDevice } from "@/contexts/DeviceContext"; +import { cn } from "@/lib/utils"; +import { AudioLines, RefreshCw, Wifi, WifiOff } from "lucide-react"; +import { useState } from "react"; + +interface TopBarProps { + title?: string; + subtitle?: string; +} + +export default function TopBar({ title, subtitle }: TopBarProps) { + const { isConnected, isDemoMode, deviceState, lastUpdated, refresh, isConnecting } = useDevice(); + const [refreshing, setRefreshing] = useState(false); + + const handleRefresh = async () => { + setRefreshing(true); + await refresh(); + setTimeout(() => setRefreshing(false), 600); + }; + + const formatTime = (date: Date | null) => { + if (!date) return "--:--"; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + }; + + return ( +
+ {/* Page title */} +
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ + {/* Audio format badge */} + {deviceState?.audioFormat && ( +
+ + + {deviceState.audioFormat} + +
+ )} + + {/* Last updated */} + {lastUpdated && ( +
+ {formatTime(lastUpdated)} +
+ )} + + {/* Refresh */} + + + {/* Connection status */} +
+ {isConnected ? ( + <> +
+ + {isDemoMode ? "Demo" : "Live"} + + + ) : ( + <> + + Offline + + )} +
+
+ ); +} diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx new file mode 100644 index 0000000..62705e3 --- /dev/null +++ b/client/src/components/ui/accordion.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Accordion({ + ...props +}: React.ComponentProps) { + return ; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..6949979 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx new file mode 100644 index 0000000..5b1a0b5 --- /dev/null +++ b/client/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..01d045d --- /dev/null +++ b/client/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,9 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return ; +} + +export { AspectRatio }; diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx new file mode 100644 index 0000000..02305fd --- /dev/null +++ b/client/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx new file mode 100644 index 0000000..83750ed --- /dev/null +++ b/client/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..9d88a37 --- /dev/null +++ b/client/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return