feat: ship native boss android console
8
.gitignore
vendored
@@ -23,6 +23,14 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.playwright-cli/
|
||||
data/*.json
|
||||
data/*.json.bak
|
||||
android/.gradle/
|
||||
android/**/build/
|
||||
android/local.properties
|
||||
android/keystores/
|
||||
android/signing/*.properties
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
35
AGENTS.md
@@ -3,3 +3,38 @@
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
# Boss 项目接手规则
|
||||
|
||||
先读:
|
||||
|
||||
1. `README.md`
|
||||
2. `docs/architecture/ai_handoff_index_cn.md`
|
||||
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
4. `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
当前有效实现:
|
||||
|
||||
- Web MVP:`src/app`、`src/components`、`src/lib`
|
||||
- 本地设备端:`local-agent`
|
||||
- 部署资产:`deployment`、`scripts`
|
||||
|
||||
当前不是有效运行时:
|
||||
|
||||
- `docs/source-material`
|
||||
- `deploy`
|
||||
- `src/boss_control`
|
||||
- `src/boss_device_agent`
|
||||
|
||||
运行与部署约束:
|
||||
|
||||
- Web 当前使用文件存储:`data/boss-state.json`
|
||||
- 本地 agent 当前通过 `launchd` 常驻:`com.hyzq.boss.local-agent`
|
||||
- 服务器固定是 `106.53.170.158`
|
||||
- 优先使用 skill:`$HOME/.codex/skills/boss-server-debug/SKILL.md`
|
||||
|
||||
如果你要继续开发:
|
||||
|
||||
- 先验证 `npm run build` 和 `npm run lint`
|
||||
- 先看当前路由和 API,再改文档
|
||||
- 对服务器、域名和 HTTPS 的判断以 `docs/architecture/current_runtime_and_deploy_status_cn.md` 为准,不要沿用旧上下文
|
||||
|
||||
269
README.md
@@ -1,36 +1,265 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Boss 控制台 MVP
|
||||
|
||||
## Getting Started
|
||||
这个仓库当前已经收口成“下一条 AI 线程不需要重新摸索结构和部署方式”的状态。真实实现是一套基于 `Next.js App Router` 的移动控制台,加一个本地 `device-agent`,持久化仍然是 `data/boss-state.json`,部署链路是 `systemd + Caddy + launchd`。
|
||||
|
||||
First, run the development server:
|
||||
## 先读哪里
|
||||
|
||||
按这个顺序看,交接成本最低:
|
||||
|
||||
1. `docs/architecture/ai_handoff_index_cn.md`
|
||||
2. `docs/architecture/repo_map_cn.md`
|
||||
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
4. `docs/architecture/api_and_service_inventory_cn.md`
|
||||
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
|
||||
## 当前有效目录
|
||||
|
||||
- `src/app`:当前 Web 页面和 API 路由
|
||||
- `src/components`:共享 UI 和交互组件
|
||||
- `src/lib`:文件型状态模型和聚合投影视图
|
||||
- `local-agent`:本地设备端心跳 + thread-context 上报服务
|
||||
- `deployment`:`Caddy`、`systemd`、`launchd` 配置
|
||||
- `scripts`:本地启动、安装、远端部署脚本
|
||||
- `design`:Pencil 原稿和导出图
|
||||
- `android`:原生 Android 客户端工程和 APK 构建目录
|
||||
- `docs/architecture`:当前权威中文文档
|
||||
- `prompts`:交给其他 Codex 线程的提示词
|
||||
|
||||
## 当前仅作参考或占位的目录
|
||||
|
||||
- `docs/source-material`:历史材料,不是运行时真相
|
||||
- `deploy`:空占位目录,不参与当前部署
|
||||
- `src/boss_control`:空占位目录,不参与当前运行
|
||||
- `src/boss_device_agent`:空占位目录,不参与当前运行
|
||||
|
||||
## 当前运行状态(2026-03-26)
|
||||
|
||||
本地:
|
||||
|
||||
- `npm run lint` 已通过
|
||||
- `npm run build` 已通过
|
||||
- `GET http://127.0.0.1:3000/api/health` 正常
|
||||
- `GET http://127.0.0.1:3000/api/v1/conversations` 正常
|
||||
- `GET http://127.0.0.1:3000/api/v1/projects/master-agent` 正常,主 Agent 项目页已能显示最近 APP 日志
|
||||
- `GET http://127.0.0.1:3000/api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾账号摘要
|
||||
- `GET http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` 正常,已返回本机同步 Skill 列表
|
||||
- `POST http://127.0.0.1:3000/api/auth/login` 正常,会写入 `boss_session` Cookie
|
||||
- `GET http://127.0.0.1:3000/api/auth/session` 正常
|
||||
- `POST http://127.0.0.1:3000/api/auth/restore` 正常,已验证可用原生 restore token 恢复登录态
|
||||
- `POST http://127.0.0.1:3000/api/v1/projects/master-agent/messages` 正常,已验证可通过 `Mac Studio local-agent -> 本机 Master Codex Node -> 回写项目账本` 返回真实主 Agent 回复
|
||||
- `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401`
|
||||
- `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包
|
||||
- `GET http://127.0.0.1:4317/health` 正常
|
||||
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
|
||||
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报
|
||||
- `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
|
||||
|
||||
服务器:
|
||||
|
||||
- 服务器地址:`106.53.170.158`
|
||||
- 代码路径:`/opt/boss`
|
||||
- `boss-web.service` 正常
|
||||
- `caddy.service` 正常
|
||||
- `postfix.service` 正常
|
||||
- `dovecot.service` 正常
|
||||
- `GET http://127.0.0.1:3000/api/health` 正常
|
||||
- `GET http://127.0.0.1:3000/api/v1/conversations` 正常
|
||||
|
||||
域名和 HTTPS:
|
||||
|
||||
- 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158`
|
||||
- 服务器本机 `curl --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net -I` 返回 `307` 并跳转到 `/auth/login`
|
||||
- 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188`
|
||||
- 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308`
|
||||
- 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307` 并跳转到 `/auth/login`
|
||||
- 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,...}`
|
||||
- 当前本机网络 `curl https://boss.hyzq.net/api/v1/conversations` 已返回真实聚合数据
|
||||
- 当前本机网络 `nc -vz boss.hyzq.net 25 587 993` 全部成功
|
||||
|
||||
当前结论更新为:
|
||||
|
||||
- 当前网络下 `boss.hyzq.net` 的 HTTP/HTTPS 已可达
|
||||
- `Caddy/TLS` 和外部 `443` 路径都已经能实际返回页面跳转
|
||||
- 公网域名下的 Web API 也已经能实际返回健康探针和业务数据
|
||||
- 服务器上的 `Postfix + Dovecot` 已部署完成,公网 `25 / 587 / 993` 当前也已经可达
|
||||
- 但当前网络下 `dig` 仍显示 `198.18.1.188`,说明这里可能存在代理层、分裂 DNS 或中间入口,不要再把这个解析值自动当成错误配置
|
||||
|
||||
Android APK:
|
||||
|
||||
- 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.1.0`(`versionCode=7`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
|
||||
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView
|
||||
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退
|
||||
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
|
||||
|
||||
## 本地启动
|
||||
|
||||
开发态:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm install
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
构建态:
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
说明:
|
||||
|
||||
## Learn More
|
||||
- `npm run build` 前会自动清理 `.next`,避免旧目录残留导致 `ENOTEMPTY`
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
默认入口:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- 登录页:[http://127.0.0.1:3000/auth/login](http://127.0.0.1:3000/auth/login)
|
||||
- 会话页:[http://127.0.0.1:3000/conversations](http://127.0.0.1:3000/conversations)
|
||||
- 设备页:[http://127.0.0.1:3000/devices](http://127.0.0.1:3000/devices)
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## 设备端本地服务
|
||||
|
||||
## Deploy on Vercel
|
||||
手动启动:
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
./scripts/start-local-agent.sh ./local-agent/config.example.json
|
||||
```
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
安装常驻 `launchd`:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
./scripts/install-local-launchagent.sh
|
||||
```
|
||||
|
||||
如需把常驻 agent 指回本地开发控制面:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
./scripts/install-local-launchagent.sh /Users/kris/code/boss/local-agent/config.example.json
|
||||
```
|
||||
|
||||
device-agent 当前职责:
|
||||
|
||||
- 上报设备状态、账号、5h/7d 额度和项目列表
|
||||
- 向云端 `/api/device-heartbeat` 发心跳
|
||||
- 向云端 `/api/v1/workers/{workerId}/thread-context` 发线程预算更新
|
||||
- 递归扫描本机 `~/.codex/skills`,并同步到云端 `/api/v1/devices/[deviceId]/skills`
|
||||
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
|
||||
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
|
||||
当前常驻默认值:
|
||||
|
||||
- `launchd` 默认加载 `local-agent/config.cloud.json`,控制面指向 `https://boss.hyzq.net`
|
||||
- `local-agent/config.example.json` 保留给本地 `127.0.0.1:3000` 回环开发
|
||||
|
||||
## 部署入口
|
||||
|
||||
- 服务器连接与部署:`docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
- 服务器调试 skill:`$HOME/.codex/skills/boss-server-debug/SKILL.md`
|
||||
- 远端部署脚本:`scripts/deploy-server.sh`
|
||||
- 远端邮件部署脚本:`scripts/install-server-mail.sh`
|
||||
- 远端初始化脚本:`scripts/bootstrap-server.sh`
|
||||
- APK 发布脚本:`scripts/publish-apk-to-public.sh`
|
||||
- `systemd` 配置:`deployment/systemd/boss-web.service`
|
||||
- `Caddy` 配置:`deployment/Caddyfile`
|
||||
- 邮件配置:`deployment/mail/`
|
||||
- Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Android API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Android 原生会话页:`android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Android 原生设备页:`android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Android 原生我的页:`android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`、`android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`、`android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- 服务器环境示例:`.env.server.example`
|
||||
|
||||
当前 `scripts/deploy-server.sh`:
|
||||
|
||||
- 优先从 macOS Keychain 读取 `ubuntu@106.53.170.158` 的密码
|
||||
- 如果 Keychain 不可用,再回退读取 `BOSS_SERVER_PASS`
|
||||
- 当前已明确排除 `data/` 目录,部署不会再覆盖服务器上的 `boss-state.json`
|
||||
- 部署脚本当前会先在本机执行 `npm run build`,再把本机已经验证通过的 `.next` 构建产物同步到服务器
|
||||
- 同步前会先在服务器上删除旧 `.next` 并修正 `/opt/boss` 所有权,避免 rsync 被历史 root 产物卡住
|
||||
- 服务器当前不再现编 Next standalone,而是直接重启使用本机同步过去的构建产物,避免服务器端 tracing / 权限差异导致部署失败
|
||||
- 远端重启服务后会自动执行一次 `curl -fsS http://127.0.0.1:3000/api/health`
|
||||
|
||||
APK 构建:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run apk:debug
|
||||
```
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run apk:release
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `npm run apk:debug` 现在会在 Gradle 构建完成后自动执行 `scripts/publish-apk-to-public.sh`
|
||||
- `npm run apk:release` 会先准备本机 release keystore,再构建 signed release APK,并发布到 `public/downloads`
|
||||
- 最新 APK 会同步到 `public/downloads/boss-android-latest.apk`
|
||||
- 同时也会额外保留一份带版本号的 APK:`public/downloads/boss-android-v{versionName}-{flavor}.apk`
|
||||
- OTA 下载入口固定走受保护的 `GET /api/v1/user/ota/package`
|
||||
- release 签名文件当前放在本机:
|
||||
- `android/keystores/boss-release.keystore`
|
||||
- `android/signing/release-signing.properties`
|
||||
- 以上文件已加入 `.gitignore`,不会进仓库
|
||||
|
||||
## 关键实现说明
|
||||
|
||||
- 当前持久化是真正生效的文件存储:`data/boss-state.json`
|
||||
- Web 生产启动和服务器 `systemd` 都显式设置了 `BOSS_STATE_FILE`,避免 Next standalone 误把状态写进 `.next/standalone/data/`
|
||||
- Web 生产启动、服务器 `systemd` 和部署构建当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误把整个仓库根目录带进 tracing
|
||||
- `next.config.ts` 已显式排除 `deployment / docs / design / local-agent / prompts / scripts / android` 等非运行时目录,避免服务器端 standalone tracing 卷入运维资产导致构建失败
|
||||
- 文件写入已经改成串行事务队列 + 原子写入 + `data/boss-state.json.bak` 备份恢复,`heartbeat` 和 APP 日志并发写不会再互相覆盖
|
||||
- 当前文件存储里已经包含:
|
||||
- `projects / messages / goals / versions`
|
||||
- `authAccounts / otaUpdates / otaUpdateLogs`
|
||||
- `threadContextSnapshots / threadHandoffPackages / threadContextAlerts`
|
||||
- `deviceEnrollments`
|
||||
- `deviceSkills / appLogs`
|
||||
- `opsFaults / opsRepairTickets / opsRepairVerifications`
|
||||
- `auditRequests / auditResults / capabilities`
|
||||
- 根布局会挂载 `AppLogBridge`,前端路由切换、运行时异常、发送消息、OTA 操作都会通过 `/api/v1/app-logs` 实时同步到服务器
|
||||
- Web 端根布局当前仍保留 `NativeAppBridge`,用于浏览器态与历史桥接兼容;当前正式 APK 已改为原生 Activity + 原生 API 客户端,不再依赖 WebView
|
||||
- APP 日志桥已经改成会话感知:只会按当前登录账号解析绑定设备,不再在未登录页默认按全局管理员设备写日志
|
||||
- APP 外壳已经从“桌面预览卡片”切回真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部 `会话 / 设备 / 我的` 导航固定在视口底部,背景改为全屏 cover,不再出现圆角矩形外壳
|
||||
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
|
||||
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
|
||||
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句
|
||||
- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- API 容灾当前不走服务器预置 Key,而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
|
||||
- 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除
|
||||
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
|
||||
- 新增 `GET /api/auth/session`、`POST /api/auth/logout` 与 `POST /api/auth/restore`
|
||||
- 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话
|
||||
- 验证码新增防刷与防重放:60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟
|
||||
- `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册
|
||||
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
|
||||
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
|
||||
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- 当前默认最高管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
|
||||
- 主 Agent 对话当前真实执行链路是:`Boss Web -> master-agent task queue -> local-agent -> codex exec -> complete task -> project ledger`
|
||||
- 主 Agent 同步等待窗口已调到 55 秒;如果本机 Codex 节点执行较慢,项目页也会通过 SSE 在任务完成后自动刷新出真实回复
|
||||
- 服务器已经部署 `Postfix + Dovecot`,邮箱别名 `verify@boss.hyzq.net` / `no-reply@boss.hyzq.net` 当前会投递到本机 `bossmail` 邮箱
|
||||
- 应用内 `POST /api/auth/send-code` 已经支持 email 模式,并可通过 `/opt/boss/.env.server` 切换;本轮已临时切到 email 模式验证成功,随后恢复默认 fixed
|
||||
- 应用内 `GET /api/v1/user/ota` / `POST /api/v1/user/ota` / `GET /api/v1/user/ota/package` 现在已经支持 OTA 状态、检查更新、执行升级和 APK 包下载
|
||||
- `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页
|
||||
- 设备写接口 `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话
|
||||
- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护
|
||||
- 当前图片 / 视频入口会写入消息账本,但真实文件上传还没有接对象存储
|
||||
- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件
|
||||
- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话
|
||||
|
||||
101
android/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
70
android/app/build.gradle
Normal file
@@ -0,0 +1,70 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
def releaseSigningProps = new Properties()
|
||||
def releaseSigningPropsFile = rootProject.file('signing/release-signing.properties')
|
||||
def hasReleaseSigning = releaseSigningPropsFile.exists()
|
||||
if (hasReleaseSigning) {
|
||||
releaseSigningProps.load(new FileInputStream(releaseSigningPropsFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.hyzq.boss"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
release {
|
||||
storeFile file(releaseSigningProps['storeFile'])
|
||||
storePassword releaseSigningProps['storePassword']
|
||||
keyAlias releaseSigningProps['keyAlias']
|
||||
keyPassword releaseSigningProps['keyPassword']
|
||||
enableV1Signing true
|
||||
enableV2Signing true
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId "com.hyzq.boss"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 7
|
||||
versionName "2.1.0"
|
||||
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
}
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
21
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
55
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<activity android:name=".ProjectDetailActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectGoalsActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectForwardActivity" android:exported="false" />
|
||||
<activity android:name=".ThreadDetailActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceDetailActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" />
|
||||
<activity android:name=".SecurityActivity" android:exported="false" />
|
||||
<activity android:name=".SettingsActivity" android:exported="false" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" />
|
||||
<activity android:name=".OpsCenterActivity" android:exported="false" />
|
||||
<activity android:name=".AboutActivity" android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
122
android/app/src/main/java/com/hyzq/boss/AboutActivity.java
Normal file
@@ -0,0 +1,122 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class AboutActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("关于 / OTA", "原生版本中心");
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse settings = apiClient.getSettings();
|
||||
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
|
||||
BossApiClient.ApiResponse session = apiClient.getSession();
|
||||
if (!settings.ok() || !ota.ok() || !session.ok()) {
|
||||
throw new IllegalStateException("PROFILE_OR_OTA_LOAD_FAILED");
|
||||
}
|
||||
runOnUiThread(() -> renderAbout(
|
||||
settings.json.optJSONObject("user"),
|
||||
ota.json,
|
||||
session.json.optJSONObject("session")
|
||||
));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "关于页加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) {
|
||||
replaceContent();
|
||||
if (user != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"当前版本",
|
||||
user.optString("version", "-")
|
||||
+ "\n当前账号:" + user.optString("account", "-")
|
||||
+ "\n绑定 Codex:" + user.optString("boundCodexNodeLabel", "未绑定"),
|
||||
session == null ? "-" : "会话到期 " + session.optString("expiresAt", "-")
|
||||
));
|
||||
}
|
||||
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
String otaBody = availableRelease == null
|
||||
? "当前已经是最新版本。"
|
||||
: availableRelease.optString("version", "未知版本")
|
||||
+ "\n" + availableRelease.optString("summary", "暂无摘要")
|
||||
+ "\n文件:" + availableRelease.optString("packageFileName", "-");
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"OTA 状态",
|
||||
otaBody,
|
||||
"当前版本 " + ota.optString("currentVersion", "-")
|
||||
));
|
||||
|
||||
LinearLayout actionCard = BossUi.buildCard(this, "OTA 操作", "可在原生页直接检查更新、登记 OTA 并下载 APK。", "当前接口:/api/v1/user/ota");
|
||||
Button check = BossUi.buildPrimaryButton(this, "检查更新");
|
||||
check.setOnClickListener(v -> performOtaAction("check"));
|
||||
actionCard.addView(check);
|
||||
Button apply = BossUi.buildSecondaryButton(this, "登记应用 OTA");
|
||||
apply.setOnClickListener(v -> performOtaAction("apply"));
|
||||
actionCard.addView(apply);
|
||||
Button download = BossUi.buildSecondaryButton(this, "下载最新 APK");
|
||||
download.setOnClickListener(v -> {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apiClient.getProtectedOtaPackageUrl()));
|
||||
startActivity(intent);
|
||||
});
|
||||
actionCard.addView(download);
|
||||
appendContent(actionCard);
|
||||
|
||||
JSONArray logs = ota.optJSONArray("logs");
|
||||
if (logs != null) {
|
||||
for (int i = 0; i < logs.length(); i++) {
|
||||
JSONObject log = logs.optJSONObject(i);
|
||||
if (log == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
log.optString("version", "OTA"),
|
||||
log.optString("summary", ""),
|
||||
log.optString("status", "-") + " · " + log.optString("createdAt", "-")
|
||||
));
|
||||
}
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void performOtaAction(String action) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = "check".equals(action) ? apiClient.checkOta() : apiClient.applyOta();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("check".equals(action) ? "已完成版本检查" : "已登记 OTA 应用");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("OTA 操作失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
382
android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java
Normal file
@@ -0,0 +1,382 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class AiAccountsActivity extends BossScreenActivity {
|
||||
private static final String[] ROLE_VALUES = {"primary", "backup", "api_fallback"};
|
||||
private static final String[] ROLE_LABELS = {"主 GPT", "备用 GPT", "API 容灾"};
|
||||
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"};
|
||||
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"};
|
||||
|
||||
private LinearLayout accountList;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾");
|
||||
setHeaderAction("新增", v -> openAccountEditor(null, null));
|
||||
replaceContent(buildIntroCard(), buildAccountListShell());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getAccounts();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderAccounts(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "AI 账号加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private LinearLayout buildIntroCard() {
|
||||
return BossUi.buildCard(
|
||||
this,
|
||||
"账号说明",
|
||||
"当前页面管理 Boss 的主控 AI 账号。主链路优先使用已绑定电脑上的 Master Codex Node,API 容灾在同页可补充配置。",
|
||||
"支持新增、编辑、激活、校验和删除"
|
||||
);
|
||||
}
|
||||
|
||||
private LinearLayout buildAccountListShell() {
|
||||
LinearLayout wrapper = new LinearLayout(this);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
accountList = new LinearLayout(this);
|
||||
accountList.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.addView(accountList);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private void renderAccounts(JSONObject payload) {
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONObject activeIdentity = payload.optJSONObject("activeIdentity");
|
||||
JSONArray switchHistory = payload.optJSONArray("switchHistory");
|
||||
|
||||
accountList.removeAllViews();
|
||||
replaceContent(buildIntroCard(), buildActiveIdentityCard(activeIdentity), buildAccountsSection(accounts), buildSwitchHistoryCard(switchHistory));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) {
|
||||
String body = activeIdentity == null
|
||||
? "当前没有可用的主控身份。"
|
||||
: activeIdentity.optString("label", "AI 账号")
|
||||
+ "\n" + activeIdentity.optString("displayName", "-")
|
||||
+ "\n" + activeIdentity.optString("providerLabel", "-")
|
||||
+ (activeIdentity.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + activeIdentity.optString("nodeLabel"));
|
||||
String meta = activeIdentity == null
|
||||
? "请先配置一个可用账号"
|
||||
: activeIdentity.optString("roleLabel", "-") + " · " + activeIdentity.optString("statusLabel", "-");
|
||||
return BossUi.buildCard(this, "当前主控身份", body, meta);
|
||||
}
|
||||
|
||||
private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) {
|
||||
LinearLayout section = new LinearLayout(this);
|
||||
section.setOrientation(LinearLayout.VERTICAL);
|
||||
|
||||
section.addView(BossUi.buildCard(
|
||||
this,
|
||||
"账号列表",
|
||||
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点击卡片可编辑,按钮可激活 / 校验 / 删除。",
|
||||
"当前 API:/api/v1/accounts"
|
||||
));
|
||||
|
||||
if (accounts == null || accounts.length() == 0) {
|
||||
section.addView(BossUi.buildEmptyCard(this, "尚未配置任何 AI 账号。"));
|
||||
return section;
|
||||
}
|
||||
|
||||
for (int i = 0; i < accounts.length(); i++) {
|
||||
JSONObject account = accounts.optJSONObject(i);
|
||||
if (account == null) continue;
|
||||
section.addView(buildAccountCard(account));
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
private LinearLayout buildAccountCard(JSONObject account) {
|
||||
String statusLabel = account.optString("statusLabel", account.optString("status", "-"));
|
||||
String meta = account.optString("roleLabel", "-")
|
||||
+ " · " + account.optString("providerLabel", "-")
|
||||
+ " · " + statusLabel
|
||||
+ (account.optBoolean("isActive") ? " · 当前主控" : "")
|
||||
+ (account.optBoolean("apiKeyConfigured") ? " · 已配置 Key" : "");
|
||||
String body = account.optString("displayName", "-")
|
||||
+ "\n账号:" + account.optString("accountIdentifier", "-")
|
||||
+ (account.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + account.optString("nodeLabel"))
|
||||
+ (account.optString("loginStatusNote").isEmpty() ? "" : "\n" + account.optString("loginStatusNote"));
|
||||
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
account.optString("label", "未命名账号"),
|
||||
body,
|
||||
meta,
|
||||
v -> openAccountEditor(account, null)
|
||||
);
|
||||
|
||||
Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控");
|
||||
activate.setEnabled(!account.optBoolean("isActive"));
|
||||
activate.setOnClickListener(v -> activateAccount(account));
|
||||
card.addView(activate);
|
||||
|
||||
Button validate = BossUi.buildSecondaryButton(this, "校验连接");
|
||||
validate.setOnClickListener(v -> validateAccount(account));
|
||||
card.addView(validate);
|
||||
|
||||
Button edit = BossUi.buildSecondaryButton(this, "编辑账号");
|
||||
edit.setOnClickListener(v -> openAccountEditor(account, null));
|
||||
card.addView(edit);
|
||||
|
||||
Button delete = BossUi.buildSecondaryButton(this, "删除账号");
|
||||
delete.setOnClickListener(v -> confirmDeleteAccount(account));
|
||||
card.addView(delete);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private LinearLayout buildSwitchHistoryCard(@Nullable JSONArray switchHistory) {
|
||||
LinearLayout section = new LinearLayout(this);
|
||||
section.setOrientation(LinearLayout.VERTICAL);
|
||||
section.addView(BossUi.buildCard(
|
||||
this,
|
||||
"切换历史",
|
||||
switchHistory == null || switchHistory.length() == 0 ? "当前没有切换记录。" : "最近切换记录会保留 40 条。",
|
||||
"用于追踪主控身份变化"
|
||||
));
|
||||
|
||||
if (switchHistory == null || switchHistory.length() == 0) {
|
||||
section.addView(BossUi.buildEmptyCard(this, "当前没有 AI 账号切换历史。"));
|
||||
return section;
|
||||
}
|
||||
|
||||
for (int i = 0; i < switchHistory.length(); i++) {
|
||||
JSONObject record = switchHistory.optJSONObject(i);
|
||||
if (record == null) continue;
|
||||
String body = "从 " + record.optString("fromLabel", "无")
|
||||
+ "\n到 " + record.optString("toLabel", "-")
|
||||
+ "\n原因:" + record.optString("reason", "-");
|
||||
String meta = record.optString("role", "-") + " · " + record.optString("switchedAt", "-");
|
||||
section.addView(BossUi.buildCard(this, "切换记录", body, meta));
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) {
|
||||
final android.widget.EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
|
||||
final android.widget.EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
|
||||
final android.widget.EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 邮箱 / 登录名", false);
|
||||
final android.widget.EditText nodeIdInput = BossUi.buildInput(this, "节点 ID", false);
|
||||
final android.widget.EditText nodeLabelInput = BossUi.buildInput(this, "节点名称", false);
|
||||
final android.widget.EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
|
||||
final android.widget.EditText apiKeyInput = BossUi.buildInput(this, "API Key", false);
|
||||
final android.widget.EditText loginStatusInput = BossUi.buildInput(this, "登录状态备注", true);
|
||||
final Spinner roleSpinner = new Spinner(this);
|
||||
roleSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ROLE_LABELS));
|
||||
final Spinner providerSpinner = new Spinner(this);
|
||||
providerSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, PROVIDER_LABELS));
|
||||
final SwitchCompat enabledSwitch = new SwitchCompat(this);
|
||||
enabledSwitch.setText("启用");
|
||||
enabledSwitch.setChecked(existing == null || existing.optBoolean("enabled", true));
|
||||
final SwitchCompat setActiveSwitch = new SwitchCompat(this);
|
||||
setActiveSwitch.setText("保存后设为当前主控");
|
||||
setActiveSwitch.setChecked(existing != null ? existing.optBoolean("isActive") : false);
|
||||
|
||||
if (existing != null) {
|
||||
labelInput.setText(existing.optString("label", ""));
|
||||
displayNameInput.setText(existing.optString("displayName", ""));
|
||||
accountIdentifierInput.setText(existing.optString("accountIdentifier", ""));
|
||||
nodeIdInput.setText(existing.optString("nodeId", ""));
|
||||
nodeLabelInput.setText(existing.optString("nodeLabel", ""));
|
||||
modelInput.setText(existing.optString("model", ""));
|
||||
loginStatusInput.setText(existing.optString("loginStatusNote", ""));
|
||||
roleSpinner.setSelection(indexOf(ROLE_VALUES, existing.optString("role", "primary")));
|
||||
providerSpinner.setSelection(indexOf(PROVIDER_VALUES, existing.optString("provider", "master_codex_node")));
|
||||
}
|
||||
if (apiKeyHint != null && !apiKeyHint.isEmpty()) {
|
||||
apiKeyInput.setText(apiKeyHint);
|
||||
}
|
||||
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
form.addView(labelInput);
|
||||
form.addView(displayNameInput);
|
||||
form.addView(accountIdentifierInput);
|
||||
form.addView(nodeIdInput);
|
||||
form.addView(nodeLabelInput);
|
||||
form.addView(modelInput);
|
||||
form.addView(apiKeyInput);
|
||||
form.addView(loginStatusInput);
|
||||
form.addView(roleSpinner);
|
||||
form.addView(providerSpinner);
|
||||
form.addView(enabledSwitch);
|
||||
form.addView(setActiveSwitch);
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(existing == null ? "新增 AI 账号" : "编辑 AI 账号")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveAccount(
|
||||
existing,
|
||||
labelInput.getText().toString().trim(),
|
||||
displayNameInput.getText().toString().trim(),
|
||||
accountIdentifierInput.getText().toString().trim(),
|
||||
nodeIdInput.getText().toString().trim(),
|
||||
nodeLabelInput.getText().toString().trim(),
|
||||
modelInput.getText().toString().trim(),
|
||||
apiKeyInput.getText().toString().trim(),
|
||||
loginStatusInput.getText().toString().trim(),
|
||||
enabledSwitch.isChecked(),
|
||||
setActiveSwitch.isChecked(),
|
||||
ROLE_VALUES[roleSpinner.getSelectedItemPosition()],
|
||||
PROVIDER_VALUES[providerSpinner.getSelectedItemPosition()]
|
||||
))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveAccount(
|
||||
@Nullable JSONObject existing,
|
||||
String label,
|
||||
String displayName,
|
||||
String accountIdentifier,
|
||||
String nodeId,
|
||||
String nodeLabel,
|
||||
String model,
|
||||
String apiKey,
|
||||
String loginStatusNote,
|
||||
boolean enabled,
|
||||
boolean setActive,
|
||||
String role,
|
||||
String provider
|
||||
) {
|
||||
if (label.isEmpty() || displayName.isEmpty()) {
|
||||
showMessage("标签和显示名称不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("label", label);
|
||||
payload.put("displayName", displayName);
|
||||
payload.put("accountIdentifier", accountIdentifier);
|
||||
payload.put("nodeId", nodeId);
|
||||
payload.put("nodeLabel", nodeLabel);
|
||||
payload.put("model", model);
|
||||
payload.put("apiKey", apiKey);
|
||||
payload.put("loginStatusNote", loginStatusNote);
|
||||
payload.put("enabled", enabled);
|
||||
payload.put("setActive", setActive);
|
||||
payload.put("role", role);
|
||||
payload.put("provider", provider);
|
||||
|
||||
BossApiClient.ApiResponse response = existing == null
|
||||
? apiClient.createAccount(payload)
|
||||
: apiClient.updateAccount(existing.optString("accountId"), payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage(existing == null ? "AI 账号已新增" : "AI 账号已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int indexOf(String[] values, String target) {
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
if (values[i].equals(target)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void activateAccount(JSONObject account) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.activateAccount(account.optString("accountId"), "原生页面手动切换");
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已切换当前主控");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("切换失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void validateAccount(JSONObject account) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.validateAccount(account.optString("accountId"));
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("账号校验成功");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("校验失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void confirmDeleteAccount(JSONObject account) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("删除 AI 账号")
|
||||
.setMessage("确认删除 " + account.optString("label", "该账号") + " 吗?")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("删除", (dialog, which) -> deleteAccount(account))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void deleteAccount(JSONObject account) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.deleteAccount(account.optString("accountId"));
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("AI 账号已删除");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("删除失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
375
android/app/src/main/java/com/hyzq/boss/BossApiClient.java
Normal file
@@ -0,0 +1,375 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class BossApiClient {
|
||||
private static final String PREFS_NAME = "boss_native_client";
|
||||
private static final String KEY_SESSION_COOKIE = "session_cookie";
|
||||
private static final String KEY_RESTORE_TOKEN = "restore_token";
|
||||
private static final String KEY_ACCOUNT = "account";
|
||||
private static final String KEY_DISPLAY_NAME = "display_name";
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
private final String baseUrl;
|
||||
|
||||
public BossApiClient(Context context) {
|
||||
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
this.baseUrl = BuildConfig.BOSS_API_BASE_URL;
|
||||
}
|
||||
|
||||
public boolean hasSessionHints() {
|
||||
return !getSessionCookie().isEmpty() || !getRestoreToken().isEmpty();
|
||||
}
|
||||
|
||||
public ApiResponse autoLogin() throws IOException, JSONException {
|
||||
ApiResponse response = request("POST", "/api/auth/login", new JSONObject(), false);
|
||||
if (response.ok()) {
|
||||
rememberIdentity(response.json);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse restoreSession() throws IOException, JSONException {
|
||||
if (getRestoreToken().isEmpty()) {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
|
||||
}
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("restoreToken", getRestoreToken());
|
||||
ApiResponse response = request("POST", "/api/auth/restore", body, false);
|
||||
if (response.ok()) {
|
||||
rememberIdentity(response.json);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse getSession() throws IOException, JSONException {
|
||||
return request("GET", "/api/auth/session", null, true);
|
||||
}
|
||||
|
||||
public ApiResponse getConversations() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/conversations", null);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
}
|
||||
|
||||
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("body", body);
|
||||
payload.put("kind", kind);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
|
||||
}
|
||||
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("targetProjectId", targetProjectId);
|
||||
payload.put("note", note);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/threads/" + encode(threadId) + "/context-budget", null);
|
||||
}
|
||||
|
||||
public ApiResponse toggleGoal(String projectId, String goalId) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/" + encode(goalId) + "/toggle", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse createGoal(String projectId, String text) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "create");
|
||||
payload.put("text", text);
|
||||
return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/update", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateGoal(String projectId, String goalId, String text) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "update");
|
||||
payload.put("goalId", goalId);
|
||||
payload.put("text", text);
|
||||
return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/update", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getDevices() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/devices", null);
|
||||
}
|
||||
|
||||
public ApiResponse getDeviceDetail(String deviceId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/devices?device=" + encode(deviceId), null);
|
||||
}
|
||||
|
||||
public ApiResponse updateDevice(String deviceId, JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("PATCH", "/api/v1/devices/" + encode(deviceId), payload);
|
||||
}
|
||||
|
||||
public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
|
||||
}
|
||||
|
||||
public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
|
||||
}
|
||||
|
||||
public ApiResponse createDeviceEnrollment(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/devices/enrollments", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getAccounts() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/accounts", null);
|
||||
}
|
||||
|
||||
public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/accounts", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateAccount(String accountId, JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("PATCH", "/api/v1/accounts/" + encode(accountId), payload);
|
||||
}
|
||||
|
||||
public ApiResponse deleteAccount(String accountId) throws IOException, JSONException {
|
||||
return requestWithRestore("DELETE", "/api/v1/accounts/" + encode(accountId), null);
|
||||
}
|
||||
|
||||
public ApiResponse activateAccount(String accountId, String reason) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("reason", reason);
|
||||
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/activate", payload);
|
||||
}
|
||||
|
||||
public ApiResponse validateAccount(String accountId) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse getOpsSummary() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/ops/summary", null);
|
||||
}
|
||||
|
||||
public ApiResponse approveRepairTicket(String ticketId) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/ops/repair-tickets/" + encode(ticketId) + "/approve", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse verifyRepairTicket(String ticketId) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/ops/repair-tickets/" + encode(ticketId) + "/verify", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse getAuditSummary() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/audits/summary", null);
|
||||
}
|
||||
|
||||
public ApiResponse getSettings() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/settings", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateSettings(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/settings", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getOtaStatus() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/user/ota", null);
|
||||
}
|
||||
|
||||
public ApiResponse checkOta() throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "check");
|
||||
return requestWithRestore("POST", "/api/v1/user/ota", payload);
|
||||
}
|
||||
|
||||
public ApiResponse applyOta() throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "apply");
|
||||
return requestWithRestore("POST", "/api/v1/user/ota", payload);
|
||||
}
|
||||
|
||||
public ApiResponse logout() throws IOException, JSONException {
|
||||
ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false);
|
||||
clearSession();
|
||||
return response;
|
||||
}
|
||||
|
||||
public String getAccountLabel() {
|
||||
return prefs.getString(KEY_ACCOUNT, "17600003315");
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return prefs.getString(KEY_DISPLAY_NAME, "Boss 超级管理员");
|
||||
}
|
||||
|
||||
public String getRestoreToken() {
|
||||
return prefs.getString(KEY_RESTORE_TOKEN, "");
|
||||
}
|
||||
|
||||
public String getSessionCookie() {
|
||||
return prefs.getString(KEY_SESSION_COOKIE, "");
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public String getProtectedOtaPackageUrl() {
|
||||
return baseUrl + "/api/v1/user/ota/package";
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
|
||||
ApiResponse response = request(method, path, body, true);
|
||||
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return request(method, path, body, true);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoInput(true);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setRequestProperty("x-boss-native-app", "1");
|
||||
|
||||
String cookie = getSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
}
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body.toString());
|
||||
}
|
||||
}
|
||||
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
|
||||
if (statusCode == 401 && !expectProtected) {
|
||||
clearSession();
|
||||
}
|
||||
if (json != null) {
|
||||
rememberIdentity(json);
|
||||
}
|
||||
|
||||
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
if (stream == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
builder.append(line);
|
||||
}
|
||||
}
|
||||
String raw = builder.toString().trim();
|
||||
if (raw.isEmpty()) {
|
||||
return new JSONObject();
|
||||
}
|
||||
return new JSONObject(raw);
|
||||
}
|
||||
|
||||
private void captureSessionCookie(Map<String, List<String>> headers) {
|
||||
if (headers == null) return;
|
||||
List<String> setCookieHeaders = headers.get("Set-Cookie");
|
||||
if (setCookieHeaders == null) {
|
||||
setCookieHeaders = headers.get("set-cookie");
|
||||
}
|
||||
if (setCookieHeaders == null) return;
|
||||
|
||||
for (String header : setCookieHeaders) {
|
||||
if (header == null || !header.startsWith("boss_session=")) continue;
|
||||
String cookiePair = header.split(";", 2)[0];
|
||||
if (header.contains("Max-Age=0")) {
|
||||
clearSession();
|
||||
return;
|
||||
}
|
||||
prefs.edit().putString(KEY_SESSION_COOKIE, cookiePair).apply();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberIdentity(JSONObject json) {
|
||||
if (json == null) return;
|
||||
JSONObject session = json.optJSONObject("session");
|
||||
JSONObject source = session != null ? session : json;
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
|
||||
String restoreToken = source.optString("restoreToken", "");
|
||||
if (!restoreToken.isEmpty()) {
|
||||
editor.putString(KEY_RESTORE_TOKEN, restoreToken);
|
||||
}
|
||||
|
||||
String account = source.optString("account", "");
|
||||
if (!account.isEmpty()) {
|
||||
editor.putString(KEY_ACCOUNT, account);
|
||||
}
|
||||
|
||||
String displayName = source.optString("displayName", "");
|
||||
if (!displayName.isEmpty()) {
|
||||
editor.putString(KEY_DISPLAY_NAME, displayName);
|
||||
}
|
||||
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
private void clearSession() {
|
||||
prefs.edit()
|
||||
.remove(KEY_SESSION_COOKIE)
|
||||
.remove(KEY_RESTORE_TOKEN)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
return Uri.encode(value);
|
||||
}
|
||||
|
||||
public static class ApiResponse {
|
||||
public final int statusCode;
|
||||
public final JSONObject json;
|
||||
|
||||
public ApiResponse(int statusCode, JSONObject json) {
|
||||
this.statusCode = statusCode;
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
public boolean ok() {
|
||||
return statusCode >= 200 && statusCode < 300 && json.optBoolean("ok", false);
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return json.optString("message", "UNKNOWN");
|
||||
}
|
||||
|
||||
public static ApiResponse error(int statusCode, JSONObject json) {
|
||||
return new ApiResponse(statusCode, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
protected final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
protected BossApiClient apiClient;
|
||||
protected Button backButton;
|
||||
protected Button refreshButton;
|
||||
protected Button headerActionButton;
|
||||
protected TextView titleView;
|
||||
protected TextView subtitleView;
|
||||
protected SwipeRefreshLayout refreshLayout;
|
||||
protected LinearLayout contentLayout;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_screen);
|
||||
apiClient = new BossApiClient(this);
|
||||
|
||||
backButton = findViewById(R.id.screen_back_button);
|
||||
refreshButton = findViewById(R.id.screen_refresh_button);
|
||||
headerActionButton = findViewById(R.id.screen_header_action);
|
||||
titleView = findViewById(R.id.screen_title);
|
||||
subtitleView = findViewById(R.id.screen_subtitle);
|
||||
refreshLayout = findViewById(R.id.screen_refresh_layout);
|
||||
contentLayout = findViewById(R.id.screen_content);
|
||||
|
||||
backButton.setOnClickListener(v -> finish());
|
||||
refreshButton.setOnClickListener(v -> reload());
|
||||
refreshLayout.setOnRefreshListener(this::reload);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
protected void configureScreen(String title, String subtitle) {
|
||||
titleView.setText(title);
|
||||
subtitleView.setText(subtitle == null || subtitle.isEmpty() ? "原生页面" : subtitle);
|
||||
}
|
||||
|
||||
protected void setHeaderAction(String label, android.view.View.OnClickListener listener) {
|
||||
headerActionButton.setVisibility(android.view.View.VISIBLE);
|
||||
headerActionButton.setText(label);
|
||||
headerActionButton.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
protected void hideHeaderAction() {
|
||||
headerActionButton.setVisibility(android.view.View.GONE);
|
||||
headerActionButton.setOnClickListener(null);
|
||||
}
|
||||
|
||||
protected void setRefreshing(boolean refreshing) {
|
||||
refreshLayout.setRefreshing(refreshing);
|
||||
refreshButton.setEnabled(!refreshing);
|
||||
refreshButton.setText(refreshing ? "同步中" : "刷新");
|
||||
}
|
||||
|
||||
protected void replaceContent(android.view.View... views) {
|
||||
contentLayout.removeAllViews();
|
||||
for (android.view.View view : views) {
|
||||
contentLayout.addView(view);
|
||||
}
|
||||
}
|
||||
|
||||
protected void appendContent(android.view.View view) {
|
||||
contentLayout.addView(view);
|
||||
}
|
||||
|
||||
protected void showMessage(String text) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
protected abstract void reload();
|
||||
}
|
||||
177
android/app/src/main/java/com/hyzq/boss/BossUi.java
Normal file
@@ -0,0 +1,177 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.graphics.Typeface;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class BossUi {
|
||||
private BossUi() {}
|
||||
|
||||
public static LinearLayout buildCard(Context context, String title, String body, String meta) {
|
||||
return buildCard(context, title, body, meta, null);
|
||||
}
|
||||
|
||||
public static LinearLayout buildCard(
|
||||
Context context,
|
||||
String title,
|
||||
String body,
|
||||
String meta,
|
||||
@Nullable View.OnClickListener listener
|
||||
) {
|
||||
LinearLayout card = new LinearLayout(context);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.bottomMargin = dp(context, 12);
|
||||
card.setLayoutParams(params);
|
||||
card.setPadding(dp(context, 18), dp(context, 18), dp(context, 18), dp(context, 18));
|
||||
card.setBackgroundResource(R.drawable.bg_card);
|
||||
if (listener != null) {
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
card.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
TextView titleView = new TextView(context);
|
||||
titleView.setText(title);
|
||||
titleView.setTextSize(18);
|
||||
titleView.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
|
||||
TextView bodyView = new TextView(context);
|
||||
bodyView.setText(body);
|
||||
bodyView.setTextSize(14);
|
||||
bodyView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
bodyView.setPadding(0, dp(context, 8), 0, 0);
|
||||
|
||||
TextView metaView = new TextView(context);
|
||||
metaView.setText(meta);
|
||||
metaView.setTextSize(12);
|
||||
metaView.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
metaView.setPadding(0, dp(context, 10), 0, 0);
|
||||
|
||||
card.addView(titleView);
|
||||
card.addView(bodyView);
|
||||
card.addView(metaView);
|
||||
return card;
|
||||
}
|
||||
|
||||
public static LinearLayout buildMenuRow(
|
||||
Context context,
|
||||
String title,
|
||||
String description,
|
||||
@Nullable String badge,
|
||||
View.OnClickListener listener
|
||||
) {
|
||||
LinearLayout row = buildCard(context, title, description, badge == null ? "点击进入" : badge, listener);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.removeAllViews();
|
||||
|
||||
LinearLayout textWrap = new LinearLayout(context);
|
||||
textWrap.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
);
|
||||
textWrap.setLayoutParams(textParams);
|
||||
|
||||
TextView titleView = new TextView(context);
|
||||
titleView.setText(title);
|
||||
titleView.setTextSize(17);
|
||||
titleView.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
|
||||
TextView descView = new TextView(context);
|
||||
descView.setText(description);
|
||||
descView.setTextSize(13);
|
||||
descView.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
descView.setPadding(0, dp(context, 6), 0, 0);
|
||||
|
||||
textWrap.addView(titleView);
|
||||
textWrap.addView(descView);
|
||||
|
||||
TextView accessory = new TextView(context);
|
||||
accessory.setText(badge == null ? "›" : badge + " ›");
|
||||
accessory.setTextSize(13);
|
||||
accessory.setTextColor(context.getColor(R.color.boss_green));
|
||||
|
||||
row.addView(textWrap);
|
||||
row.addView(accessory);
|
||||
return row;
|
||||
}
|
||||
|
||||
public static LinearLayout buildEmptyCard(Context context, String text) {
|
||||
return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。");
|
||||
}
|
||||
|
||||
public static Button buildPrimaryButton(Context context, String label) {
|
||||
Button button = new Button(context);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.topMargin = dp(context, 12);
|
||||
button.setLayoutParams(params);
|
||||
button.setText(label);
|
||||
button.setTextColor(context.getColor(R.color.boss_surface));
|
||||
button.setBackgroundResource(R.drawable.bg_primary_button);
|
||||
button.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
|
||||
button.setAllCaps(false);
|
||||
return button;
|
||||
}
|
||||
|
||||
public static Button buildSecondaryButton(Context context, String label) {
|
||||
Button button = new Button(context);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.topMargin = dp(context, 10);
|
||||
button.setLayoutParams(params);
|
||||
button.setText(label);
|
||||
button.setTextColor(context.getColor(R.color.boss_green));
|
||||
button.setBackgroundResource(R.drawable.bg_secondary_button);
|
||||
button.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
|
||||
button.setAllCaps(false);
|
||||
return button;
|
||||
}
|
||||
|
||||
public static EditText buildInput(Context context, String hint, boolean multiline) {
|
||||
EditText input = new EditText(context);
|
||||
input.setHint(hint);
|
||||
input.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
input.setHintTextColor(context.getColor(R.color.boss_text_muted));
|
||||
input.setBackgroundResource(R.drawable.bg_secondary_button);
|
||||
input.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
|
||||
input.setSingleLine(!multiline);
|
||||
if (multiline) {
|
||||
input.setMinLines(3);
|
||||
input.setMaxLines(6);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
public static void copyText(Context context, String label, String text) {
|
||||
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard != null) {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, text));
|
||||
Toast.makeText(context, label + " 已复制", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
public static int dp(Context context, int value) {
|
||||
return Math.round(value * context.getResources().getDisplayMetrics().density);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class DeviceDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_DEVICE_ID = "device_id";
|
||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||
|
||||
private String deviceId;
|
||||
private String deviceName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
|
||||
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
|
||||
configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情");
|
||||
setHeaderAction("编辑", v -> openEditDialog());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getDeviceDetail(deviceId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderDevice(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "设备详情加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderDevice(JSONObject payload) {
|
||||
JSONObject workspace = payload.optJSONObject("workspace");
|
||||
JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice");
|
||||
JSONArray relatedThreads = workspace == null ? null : workspace.optJSONArray("relatedThreads");
|
||||
JSONObject enrollment = workspace == null ? null : workspace.optJSONObject("activeEnrollment");
|
||||
|
||||
replaceContent();
|
||||
if (device == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "设备不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
deviceName = device.optString("name", deviceId);
|
||||
configureScreen(deviceName, device.optString("endpoint", "设备详情"));
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
device.optString("name", "设备"),
|
||||
device.optString("note", "暂无备注"),
|
||||
"状态 " + device.optString("status", "unknown")
|
||||
+ " · 账号 " + device.optString("account", "-")
|
||||
+ " · 5h " + device.optInt("quota5h", 0)
|
||||
+ " · 7d " + device.optInt("quota7d", 0)
|
||||
));
|
||||
|
||||
Button skillsButton = BossUi.buildPrimaryButton(this, "查看技能");
|
||||
skillsButton.setOnClickListener(v -> openSkills());
|
||||
appendContent(skillsButton);
|
||||
|
||||
if (relatedThreads != null && relatedThreads.length() > 0) {
|
||||
for (int i = 0; i < relatedThreads.length(); i++) {
|
||||
JSONObject thread = relatedThreads.optJSONObject(i);
|
||||
if (thread == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
thread.optString("title", "线程"),
|
||||
thread.optString("summary", ""),
|
||||
thread.optString("workerId", "-")
|
||||
+ " · " + thread.optInt("contextBudgetRemainingPct", 0) + "%"
|
||||
+ " · " + thread.optString("contextBudgetLevel", "safe"),
|
||||
v -> openThread(thread.optString("threadId"))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (enrollment != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"当前绑定草稿",
|
||||
"pairingCode " + enrollment.optString("pairingCode", "-")
|
||||
+ "\ntoken " + enrollment.optString("token", "-"),
|
||||
enrollment.optString("status", "ready")
|
||||
+ " · 到期 " + enrollment.optString("expiresAt", "-")
|
||||
));
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void openThread(String threadId) {
|
||||
Intent intent = new Intent(this, ThreadDetailActivity.class);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openSkills() {
|
||||
Intent intent = new Intent(this, SkillInventoryActivity.class);
|
||||
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, deviceId);
|
||||
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, deviceName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openEditDialog() {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getDeviceDetail(deviceId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
JSONObject workspace = response.json.optJSONObject("workspace");
|
||||
JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice");
|
||||
if (device == null) throw new IllegalStateException("DEVICE_NOT_FOUND");
|
||||
runOnUiThread(() -> showEditForm(device));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> showMessage("读取设备失败:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showEditForm(JSONObject device) {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
form.setPadding(0, 0, 0, 0);
|
||||
|
||||
final android.widget.EditText nameInput = BossUi.buildInput(this, "设备名", false);
|
||||
nameInput.setText(device.optString("name", ""));
|
||||
final android.widget.EditText avatarInput = BossUi.buildInput(this, "头像字符", false);
|
||||
avatarInput.setText(device.optString("avatar", ""));
|
||||
final android.widget.EditText endpointInput = BossUi.buildInput(this, "endpoint", false);
|
||||
endpointInput.setText(device.optString("endpoint", ""));
|
||||
final android.widget.EditText noteInput = BossUi.buildInput(this, "备注", true);
|
||||
noteInput.setText(device.optString("note", ""));
|
||||
final android.widget.EditText projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true);
|
||||
projectsInput.setText(joinArray(device.optJSONArray("projects")));
|
||||
|
||||
form.addView(nameInput);
|
||||
form.addView(avatarInput);
|
||||
form.addView(endpointInput);
|
||||
form.addView(noteInput);
|
||||
form.addView(projectsInput);
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("编辑设备")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveDevice(
|
||||
nameInput.getText().toString().trim(),
|
||||
avatarInput.getText().toString().trim(),
|
||||
endpointInput.getText().toString().trim(),
|
||||
noteInput.getText().toString().trim(),
|
||||
projectsInput.getText().toString().trim()
|
||||
))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveDevice(String name, String avatar, String endpoint, String note, String projectsText) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("name", name);
|
||||
payload.put("avatar", avatar);
|
||||
payload.put("endpoint", endpoint);
|
||||
payload.put("note", note);
|
||||
JSONArray projects = new JSONArray();
|
||||
for (String item : projectsText.split(",")) {
|
||||
String trimmed = item.trim();
|
||||
if (!trimmed.isEmpty()) projects.put(trimmed);
|
||||
}
|
||||
payload.put("projects", projects);
|
||||
BossApiClient.ApiResponse response = apiClient.updateDevice(deviceId, payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("设备已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String joinArray(@Nullable JSONArray values) {
|
||||
if (values == null || values.length() == 0) return "";
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i);
|
||||
if (value == null || value.isEmpty()) continue;
|
||||
if (builder.length() > 0) builder.append(", ");
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
private EditText nameInput;
|
||||
private EditText avatarInput;
|
||||
private EditText accountInput;
|
||||
private EditText endpointInput;
|
||||
private EditText noteInput;
|
||||
private EditText projectsInput;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("添加设备", "通过 pairing code 或 token 把新设备接入");
|
||||
hideHeaderAction();
|
||||
buildForm();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
// static form
|
||||
}
|
||||
|
||||
private void buildForm() {
|
||||
nameInput = BossUi.buildInput(this, "设备名,例如 Mac Studio", false);
|
||||
avatarInput = BossUi.buildInput(this, "头像字符,例如 M", false);
|
||||
accountInput = BossUi.buildInput(this, "账号", false);
|
||||
accountInput.setText(apiClient.getAccountLabel());
|
||||
endpointInput = BossUi.buildInput(this, "endpoint,例如 mac://kris.local", false);
|
||||
noteInput = BossUi.buildInput(this, "备注", true);
|
||||
projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true);
|
||||
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"绑定新设备",
|
||||
"支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入。",
|
||||
"当前原生页会直接调用 /api/v1/devices/enrollments"
|
||||
),
|
||||
nameInput,
|
||||
avatarInput,
|
||||
accountInput,
|
||||
endpointInput,
|
||||
noteInput,
|
||||
projectsInput,
|
||||
BossUi.buildPrimaryButton(this, "生成绑定草稿")
|
||||
);
|
||||
((android.widget.Button) contentLayout.getChildAt(contentLayout.getChildCount() - 1))
|
||||
.setOnClickListener(v -> submitEnrollment());
|
||||
}
|
||||
|
||||
private void submitEnrollment() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("name", nameInput.getText().toString().trim());
|
||||
payload.put("avatar", avatarInput.getText().toString().trim());
|
||||
payload.put("account", accountInput.getText().toString().trim());
|
||||
payload.put("endpoint", endpointInput.getText().toString().trim());
|
||||
payload.put("note", noteInput.getText().toString().trim());
|
||||
JSONArray projects = new JSONArray();
|
||||
for (String item : projectsInput.getText().toString().split(",")) {
|
||||
String trimmed = item.trim();
|
||||
if (!trimmed.isEmpty()) projects.put(trimmed);
|
||||
}
|
||||
payload.put("projects", projects);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.createDeviceEnrollment(payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
JSONObject enrollment = response.json.optJSONObject("enrollment");
|
||||
JSONObject device = response.json.optJSONObject("device");
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"绑定草稿已生成",
|
||||
"设备 " + (device == null ? "-" : device.optString("name", "-"))
|
||||
+ "\npairingCode " + (enrollment == null ? "-" : enrollment.optString("pairingCode", "-"))
|
||||
+ "\ntoken " + (enrollment == null ? "-" : enrollment.optString("token", "-")),
|
||||
enrollment == null ? "ready" : enrollment.optString("status", "ready")
|
||||
+ " · 到期 " + enrollment.optString("expiresAt", "-")
|
||||
)
|
||||
);
|
||||
setRefreshing(false);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("创建失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
468
android/app/src/main/java/com/hyzq/boss/MainActivity.java
Normal file
@@ -0,0 +1,468 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
public static final String EXTRA_INITIAL_TAB = "initial_tab";
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
private BossApiClient apiClient;
|
||||
|
||||
private View loginPanel;
|
||||
private View contentPanel;
|
||||
private TextView loginHint;
|
||||
private Button loginButton;
|
||||
private ProgressBar loginProgress;
|
||||
|
||||
private Button backButton;
|
||||
private TextView topTitle;
|
||||
private TextView topSubtitle;
|
||||
private Button refreshButton;
|
||||
private Button tabConversations;
|
||||
private Button tabDevices;
|
||||
private Button tabMe;
|
||||
private SwipeRefreshLayout screenRefresh;
|
||||
private LinearLayout screenContent;
|
||||
|
||||
private String activeTab = "conversations";
|
||||
private @Nullable JSONObject sessionData;
|
||||
private @Nullable JSONObject otaData;
|
||||
private @Nullable JSONArray conversationsData;
|
||||
private @Nullable JSONArray devicesData;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
apiClient = new BossApiClient(this);
|
||||
bindViews();
|
||||
bindActions();
|
||||
applyInitialTab(getIntent());
|
||||
bootstrapSession();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
applyInitialTab(intent);
|
||||
if (contentPanel.getVisibility() == View.VISIBLE) {
|
||||
renderCurrentTab();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void bindViews() {
|
||||
loginPanel = findViewById(R.id.login_panel);
|
||||
contentPanel = findViewById(R.id.content_panel);
|
||||
loginHint = findViewById(R.id.login_hint);
|
||||
loginButton = findViewById(R.id.login_button);
|
||||
loginProgress = findViewById(R.id.login_progress);
|
||||
backButton = findViewById(R.id.back_button);
|
||||
topTitle = findViewById(R.id.top_title);
|
||||
topSubtitle = findViewById(R.id.top_subtitle);
|
||||
refreshButton = findViewById(R.id.refresh_button);
|
||||
tabConversations = findViewById(R.id.tab_conversations);
|
||||
tabDevices = findViewById(R.id.tab_devices);
|
||||
tabMe = findViewById(R.id.tab_me);
|
||||
screenRefresh = findViewById(R.id.screen_refresh);
|
||||
screenContent = findViewById(R.id.screen_content);
|
||||
}
|
||||
|
||||
private void bindActions() {
|
||||
loginButton.setOnClickListener(v -> performAutoLogin());
|
||||
backButton.setVisibility(View.GONE);
|
||||
refreshButton.setOnClickListener(v -> refreshCurrentTab());
|
||||
tabConversations.setOnClickListener(v -> switchTab("conversations"));
|
||||
tabDevices.setOnClickListener(v -> switchTab("devices"));
|
||||
tabMe.setOnClickListener(v -> switchTab("me"));
|
||||
screenRefresh.setOnRefreshListener(this::refreshCurrentTab);
|
||||
}
|
||||
|
||||
private void applyInitialTab(@Nullable Intent intent) {
|
||||
String requested = intent == null ? null : intent.getStringExtra(EXTRA_INITIAL_TAB);
|
||||
if ("devices".equals(requested) || "me".equals(requested) || "conversations".equals(requested)) {
|
||||
activeTab = requested;
|
||||
}
|
||||
}
|
||||
|
||||
private void bootstrapSession() {
|
||||
showLogin("原生 Android 客户端已启用。点击下方按钮直接进入系统。");
|
||||
if (!apiClient.hasSessionHints()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoginLoading(true, "正在恢复上次登录状态...");
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
|
||||
if (!sessionResponse.ok()) {
|
||||
sessionResponse = apiClient.restoreSession();
|
||||
}
|
||||
if (sessionResponse.ok()) {
|
||||
JSONObject session = sessionResponse.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
showContent();
|
||||
refreshAllData(session);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Fall back to login panel.
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "点击登录后会直接进入系统。"));
|
||||
});
|
||||
}
|
||||
|
||||
private void performAutoLogin() {
|
||||
setLoginLoading(true, "正在创建会话...");
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.autoLogin();
|
||||
if (response.ok()) {
|
||||
JSONObject session = response.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
showContent();
|
||||
refreshAllData(session);
|
||||
});
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "登录失败:" + response.message()));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "登录链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshCurrentTab() {
|
||||
refreshAllData(sessionData);
|
||||
}
|
||||
|
||||
private void refreshAllData(@Nullable JSONObject initialSession) {
|
||||
startRefreshing(true);
|
||||
topSubtitle.setText("正在同步最新数据...");
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject session = initialSession;
|
||||
if (session == null) {
|
||||
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
|
||||
if (!sessionResponse.ok()) {
|
||||
sessionResponse = apiClient.restoreSession();
|
||||
}
|
||||
if (!sessionResponse.ok()) {
|
||||
throw new IOException("SESSION_UNAVAILABLE");
|
||||
}
|
||||
session = sessionResponse.json.optJSONObject("session");
|
||||
}
|
||||
|
||||
BossApiClient.ApiResponse conversations = apiClient.getConversations();
|
||||
BossApiClient.ApiResponse devices = apiClient.getDevices();
|
||||
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
|
||||
if (!conversations.ok() || !devices.ok() || !ota.ok()) {
|
||||
throw new IOException("API_REFRESH_FAILED");
|
||||
}
|
||||
|
||||
JSONObject finalSession = session;
|
||||
runOnUiThread(() -> {
|
||||
sessionData = finalSession;
|
||||
conversationsData = conversations.json.optJSONArray("conversations");
|
||||
devicesData = devices.json.optJSONArray("devices");
|
||||
otaData = ota.json;
|
||||
renderCurrentTab();
|
||||
startRefreshing(false);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
startRefreshing(false);
|
||||
sessionData = null;
|
||||
conversationsData = null;
|
||||
devicesData = null;
|
||||
otaData = null;
|
||||
showLogin("当前登录已失效或同步失败,请重新点击登录。");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showLogin(String hint) {
|
||||
loginPanel.setVisibility(View.VISIBLE);
|
||||
contentPanel.setVisibility(View.GONE);
|
||||
setLoginLoading(false, hint);
|
||||
}
|
||||
|
||||
private void showContent() {
|
||||
loginPanel.setVisibility(View.GONE);
|
||||
contentPanel.setVisibility(View.VISIBLE);
|
||||
switchTab(activeTab);
|
||||
}
|
||||
|
||||
private void setLoginLoading(boolean loading, String hint) {
|
||||
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
|
||||
loginButton.setEnabled(!loading);
|
||||
loginButton.setText(loading ? "处理中..." : "登录");
|
||||
loginHint.setText(hint);
|
||||
}
|
||||
|
||||
private void switchTab(String tab) {
|
||||
activeTab = tab;
|
||||
updateTabStyles();
|
||||
renderCurrentTab();
|
||||
}
|
||||
|
||||
private void renderCurrentTab() {
|
||||
if (contentPanel.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (activeTab) {
|
||||
case "devices":
|
||||
updateHeader("设备", "只展示当前正式接入生产链路的设备。");
|
||||
renderDevicesRoot();
|
||||
break;
|
||||
case "me":
|
||||
updateHeader("我的", "账号、安全、技能、运维、OTA 都从这里进入。");
|
||||
renderMeRoot();
|
||||
break;
|
||||
case "conversations":
|
||||
default:
|
||||
updateHeader("会话", "原生会话列表直接消费 /api/v1/conversations。");
|
||||
renderConversationsRoot();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateHeader(String title, String subtitle) {
|
||||
topTitle.setText(title);
|
||||
topSubtitle.setText(subtitle);
|
||||
}
|
||||
|
||||
private void updateTabStyles() {
|
||||
styleTab(tabConversations, "conversations".equals(activeTab));
|
||||
styleTab(tabDevices, "devices".equals(activeTab));
|
||||
styleTab(tabMe, "me".equals(activeTab));
|
||||
}
|
||||
|
||||
private void styleTab(Button button, boolean active) {
|
||||
button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green));
|
||||
}
|
||||
|
||||
private void renderConversationsRoot() {
|
||||
screenContent.removeAllViews();
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
this,
|
||||
"会话首页",
|
||||
"当前原生首页会直接进入项目详情、目标、版本、转发与线程预算详情。",
|
||||
conversationsData == null ? "正在等待数据" : "会话数 " + conversationsData.length()
|
||||
));
|
||||
|
||||
if (conversationsData == null || conversationsData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < conversationsData.length(); i++) {
|
||||
JSONObject item = conversationsData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String projectId = item.optString("projectId", "");
|
||||
String title = item.optString("projectTitle", "未命名会话");
|
||||
StringBuilder body = new StringBuilder(item.optString("preview", "暂无预览"));
|
||||
if (item.optInt("activeDeviceCount", 0) > 0) {
|
||||
body.append("\n设备 ").append(item.optString("deviceNamesPreview", "未标注"));
|
||||
}
|
||||
JSONObject budget = item.optJSONObject("contextBudgetIndicator");
|
||||
String meta = "风险 " + item.optString("riskLevel", "unknown")
|
||||
+ " · 未读 " + item.optInt("unreadCount", 0)
|
||||
+ " · " + item.optString("latestReplyLabel", "-");
|
||||
if (budget != null && budget.optBoolean("visible", false)) {
|
||||
meta = meta + " · 预算 " + budget.optInt("percent", 0) + "%";
|
||||
}
|
||||
screenContent.addView(BossUi.buildCard(this, title, body.toString(), meta, v -> {
|
||||
if (projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
openProject(projectId, title);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void renderDevicesRoot() {
|
||||
screenContent.removeAllViews();
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
this,
|
||||
"设备首页",
|
||||
"设备详情、技能清单和配对草稿都改为原生页。",
|
||||
devicesData == null ? "正在等待数据" : "设备数 " + devicesData.length()
|
||||
));
|
||||
|
||||
Button addDeviceButton = BossUi.buildPrimaryButton(this, "添加设备");
|
||||
addDeviceButton.setOnClickListener(v -> startActivity(new Intent(this, DeviceEnrollmentActivity.class)));
|
||||
screenContent.addView(addDeviceButton);
|
||||
|
||||
if (devicesData == null || devicesData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < devicesData.length(); i++) {
|
||||
JSONObject item = devicesData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String deviceId = item.optString("id", "");
|
||||
String title = item.optString("name", "未命名设备");
|
||||
String body = item.optString("note", item.optString("endpoint", "暂无设备说明"));
|
||||
String meta = "状态 " + item.optString("status", "unknown")
|
||||
+ " · 账号 " + item.optString("account", "-")
|
||||
+ " · 5h " + item.optInt("quota5h", 0)
|
||||
+ " · 7d " + item.optInt("quota7d", 0);
|
||||
screenContent.addView(BossUi.buildCard(this, title, body, meta, v -> {
|
||||
if (deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
openDevice(deviceId, title);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void renderMeRoot() {
|
||||
screenContent.removeAllViews();
|
||||
String displayName = sessionData == null
|
||||
? apiClient.getDisplayName()
|
||||
: sessionData.optString("displayName", apiClient.getDisplayName());
|
||||
String account = sessionData == null
|
||||
? apiClient.getAccountLabel()
|
||||
: sessionData.optString("account", apiClient.getAccountLabel());
|
||||
String expiresAt = sessionData == null ? "-" : sessionData.optString("expiresAt", "-");
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
this,
|
||||
displayName,
|
||||
"账号 " + account + "\n当前原生客户端已覆盖会话 / 设备 / 我的一级导航。",
|
||||
"会话到期 " + expiresAt
|
||||
));
|
||||
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"账号与安全",
|
||||
"查看当前会话、登录模式和退出登录。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SecurityActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"设置",
|
||||
"实时刷新、风险徽标和默认首页。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SettingsActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"运维与修复",
|
||||
"查看故障、repair ticket、审计请求和能力注册表。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, OpsCenterActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"AI 账号",
|
||||
"管理主 GPT、备用 GPT、Master Codex Node 与 API 容灾。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, AiAccountsActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"技能",
|
||||
"按绑定设备查看 Skill,并一键复制调用语句。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SkillInventoryActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"关于",
|
||||
"查看版本、OTA 状态和当前绑定节点。",
|
||||
otaData == null ? null : otaData.optBoolean("hasOta", false) ? "OTA" : null,
|
||||
v -> startActivity(new Intent(this, AboutActivity.class))
|
||||
));
|
||||
|
||||
if (otaData != null) {
|
||||
JSONObject availableRelease = otaData.optJSONObject("availableRelease");
|
||||
String body = "当前版本 " + otaData.optString("currentVersion", "-");
|
||||
String meta = availableRelease == null
|
||||
? "当前没有待安装版本"
|
||||
: "可用版本 " + availableRelease.optString("version", "-")
|
||||
+ " · 文件 " + availableRelease.optString("packageFileName", "-");
|
||||
screenContent.addView(BossUi.buildCard(this, "OTA 状态", body, meta));
|
||||
}
|
||||
|
||||
Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
|
||||
logoutButton.setOnClickListener(v -> logout());
|
||||
screenContent.addView(logoutButton);
|
||||
}
|
||||
|
||||
private void logout() {
|
||||
startRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
apiClient.logout();
|
||||
} catch (Exception ignored) {
|
||||
// Ignore transport errors and still clear UI.
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
startRefreshing(false);
|
||||
sessionData = null;
|
||||
conversationsData = null;
|
||||
devicesData = null;
|
||||
otaData = null;
|
||||
showLogin("已退出登录。点击登录可重新进入系统。");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void openProject(String projectId, String projectName) {
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, projectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openDevice(String deviceId, String deviceName) {
|
||||
Intent intent = new Intent(this, DeviceDetailActivity.class);
|
||||
intent.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, deviceId);
|
||||
intent.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, deviceName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void startRefreshing(boolean refreshing) {
|
||||
screenRefresh.setRefreshing(refreshing);
|
||||
refreshButton.setEnabled(!refreshing);
|
||||
refreshButton.setText(refreshing ? "同步中" : "刷新");
|
||||
}
|
||||
|
||||
private void showMessage(String text) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
326
android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java
Normal file
@@ -0,0 +1,326 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class OpsCenterActivity extends BossScreenActivity {
|
||||
private enum Tab {
|
||||
OPS,
|
||||
AUDIT
|
||||
}
|
||||
|
||||
private Tab activeTab = Tab.OPS;
|
||||
private LinearLayout contentRoot;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("运维中心", "运维对话 / 审计对话");
|
||||
setHeaderAction("刷新", v -> reload());
|
||||
contentRoot = new LinearLayout(this);
|
||||
contentRoot.setOrientation(LinearLayout.VERTICAL);
|
||||
replaceContent(contentRoot);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse ops = apiClient.getOpsSummary();
|
||||
BossApiClient.ApiResponse audit = apiClient.getAuditSummary();
|
||||
if (!ops.ok() || !audit.ok()) {
|
||||
throw new IllegalStateException("OPS_OR_AUDIT_LOAD_FAILED");
|
||||
}
|
||||
runOnUiThread(() -> render(ops.json, audit.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "运维中心加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void render(JSONObject ops, JSONObject audit) {
|
||||
contentRoot.removeAllViews();
|
||||
contentRoot.addView(buildTabBar());
|
||||
if (activeTab == Tab.OPS) {
|
||||
renderOpsTab(ops);
|
||||
} else {
|
||||
renderAuditTab(audit);
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildTabBar() {
|
||||
LinearLayout bar = new LinearLayout(this);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.addView(buildTabButton("运维对话", activeTab == Tab.OPS, v -> {
|
||||
activeTab = Tab.OPS;
|
||||
reload();
|
||||
}));
|
||||
bar.addView(buildTabButton("审计对话", activeTab == Tab.AUDIT, v -> {
|
||||
activeTab = Tab.AUDIT;
|
||||
reload();
|
||||
}));
|
||||
return bar;
|
||||
}
|
||||
|
||||
private Button buildTabButton(String label, boolean active, android.view.View.OnClickListener listener) {
|
||||
Button button = BossUi.buildPrimaryButton(this, label);
|
||||
button.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
|
||||
button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green));
|
||||
button.setOnClickListener(listener);
|
||||
return button;
|
||||
}
|
||||
|
||||
private void renderOpsTab(JSONObject ops) {
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
this,
|
||||
"当前巡检模式",
|
||||
ops.optString("mode", "idle").equals("active")
|
||||
? "active:当前存在风险线程或未关闭运维工单。"
|
||||
: "idle:当前没有高风险工单,保持低频巡检。",
|
||||
"来源:/api/v1/ops/summary"
|
||||
));
|
||||
|
||||
JSONArray faults = ops.optJSONArray("faults");
|
||||
if (faults == null || faults.length() == 0) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有运维故障。"));
|
||||
} else {
|
||||
for (int i = 0; i < faults.length(); i++) {
|
||||
JSONObject fault = faults.optJSONObject(i);
|
||||
if (fault == null) continue;
|
||||
contentRoot.addView(buildFaultCard(fault, ops.optJSONArray("tickets")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildFaultCard(JSONObject fault, @Nullable JSONArray tickets) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
fault.optString("faultKey", "故障"),
|
||||
fault.optString("summary", "暂无摘要"),
|
||||
fault.optString("severity", "-")
|
||||
+ " · " + fault.optString("status", "-")
|
||||
+ " · " + fault.optString("nodeId", "-")
|
||||
+ " · " + fault.optString("serviceName", "-")
|
||||
);
|
||||
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"建议动作",
|
||||
fault.optString("suggestedNextAction", "暂无"),
|
||||
"trace " + fault.optString("traceId", "-")
|
||||
));
|
||||
|
||||
if (tickets != null) {
|
||||
for (int i = 0; i < tickets.length(); i++) {
|
||||
JSONObject ticket = tickets.optJSONObject(i);
|
||||
if (ticket == null) continue;
|
||||
if (!fault.optString("faultId").equals(ticket.optString("faultId"))) continue;
|
||||
card.addView(buildTicketCard(ticket));
|
||||
}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private LinearLayout buildTicketCard(JSONObject ticket) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
ticket.optString("title", "修复工单"),
|
||||
ticket.optString("actionSummary", "暂无动作摘要"),
|
||||
ticket.optString("approvalStatus", "-")
|
||||
+ " · " + ticket.optString("executionStatus", "-")
|
||||
+ " · " + ticket.optString("targetNodeId", "-")
|
||||
);
|
||||
|
||||
if (ticket.optJSONObject("verification") != null) {
|
||||
JSONObject verification = ticket.optJSONObject("verification");
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"验证结果",
|
||||
verification.optString("summary", "暂无"),
|
||||
verification.optString("status", "-")
|
||||
+ " · " + verification.optString("verifiedAt", "-")
|
||||
));
|
||||
}
|
||||
|
||||
Button approve = BossUi.buildPrimaryButton(this, "批准修复");
|
||||
approve.setOnClickListener(v -> approveTicket(ticket.optString("ticketId")));
|
||||
card.addView(approve);
|
||||
|
||||
Button verify = BossUi.buildSecondaryButton(this, "验证修复");
|
||||
verify.setOnClickListener(v -> verifyTicket(ticket.optString("ticketId")));
|
||||
card.addView(verify);
|
||||
return card;
|
||||
}
|
||||
|
||||
private void renderAuditTab(JSONObject audit) {
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计概要",
|
||||
"待处理请求 " + (audit.optJSONArray("pendingRequests") == null ? 0 : audit.optJSONArray("pendingRequests").length())
|
||||
+ "\n最新结果 " + (audit.optJSONArray("latestResults") == null ? 0 : audit.optJSONArray("latestResults").length()),
|
||||
"来源:/api/v1/audits/summary"
|
||||
));
|
||||
|
||||
JSONArray pendingRequests = audit.optJSONArray("pendingRequests");
|
||||
if (pendingRequests == null || pendingRequests.length() == 0) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待处理的审计请求。"));
|
||||
} else {
|
||||
for (int i = 0; i < pendingRequests.length(); i++) {
|
||||
JSONObject request = pendingRequests.optJSONObject(i);
|
||||
if (request == null) continue;
|
||||
contentRoot.addView(buildAuditRequestCard(request));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray latestResults = audit.optJSONArray("latestResults");
|
||||
if (latestResults != null && latestResults.length() > 0) {
|
||||
contentRoot.addView(BossUi.buildCard(this, "审计结果", "最近完成的审计会展示在这里。", "可回看 decision / findings"));
|
||||
for (int i = 0; i < latestResults.length(); i++) {
|
||||
JSONObject result = latestResults.optJSONObject(i);
|
||||
if (result == null) continue;
|
||||
contentRoot.addView(buildAuditResultCard(result));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray capabilities = audit.optJSONArray("capabilities");
|
||||
if (capabilities != null && capabilities.length() > 0) {
|
||||
contentRoot.addView(BossUi.buildCard(this, "能力注册表", "展示当前设备上的可用能力。", "与审计请求的 capabilityRequirements 对应"));
|
||||
for (int i = 0; i < capabilities.length(); i++) {
|
||||
JSONObject capability = capabilities.optJSONObject(i);
|
||||
if (capability == null) continue;
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
this,
|
||||
capability.optString("displayName", "能力"),
|
||||
capability.optString("capabilityType", "-")
|
||||
+ "\n提供者:" + capability.optString("providerId", "-")
|
||||
+ "\n模式:" + capability.optString("leaseMode", "-")
|
||||
+ "\n动作:" + joinArray(capability.optJSONArray("supportedActions")),
|
||||
capability.optString("status", "-")
|
||||
+ " · " + capability.optString("healthStatus", "-")
|
||||
+ " · " + capability.optString("nodeId", "-")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildAuditRequestCard(JSONObject request) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
request.optString("projectName", "审计请求"),
|
||||
request.optString("objective", "暂无目标"),
|
||||
request.optString("auditType", "-")
|
||||
+ " · priority " + request.optInt("priority", 0)
|
||||
+ " · " + request.optString("trigger", "-")
|
||||
);
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计条件",
|
||||
"要求:" + joinStringArray(request.optJSONArray("acceptanceCriteria"))
|
||||
+ "\n风险:" + joinStringArray(request.optJSONArray("riskFocus"))
|
||||
+ "\n证据:" + joinStringArray(request.optJSONArray("evidenceRefs")),
|
||||
"时限 " + request.optInt("timeBudgetSeconds", 0) + " 秒"
|
||||
));
|
||||
return card;
|
||||
}
|
||||
|
||||
private LinearLayout buildAuditResultCard(JSONObject result) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
result.optString("decision", "result"),
|
||||
result.optString("summary", "暂无摘要"),
|
||||
result.optString("status", "-")
|
||||
+ " · confidence " + result.optDouble("confidence", 0.0)
|
||||
+ " · " + result.optString("completedAt", "-")
|
||||
);
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计发现",
|
||||
joinStringArray(result.optJSONArray("findings")),
|
||||
"需要动作:" + joinStringArray(result.optJSONArray("requiredActions"))
|
||||
));
|
||||
return card;
|
||||
}
|
||||
|
||||
private void approveTicket(String ticketId) {
|
||||
if (ticketId == null || ticketId.isEmpty()) {
|
||||
showMessage("缺少 ticketId");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.approveRepairTicket(ticketId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("修复工单已批准");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("批准失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void verifyTicket(String ticketId) {
|
||||
if (ticketId == null || ticketId.isEmpty()) {
|
||||
showMessage("缺少 ticketId");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.verifyRepairTicket(ticketId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("修复结果已验证");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("验证失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String joinArray(@Nullable JSONArray values) {
|
||||
if (values == null || values.length() == 0) return "-";
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i);
|
||||
if (value == null || value.isEmpty()) continue;
|
||||
if (builder.length() > 0) builder.append(" · ");
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.length() == 0 ? "-" : builder.toString();
|
||||
}
|
||||
|
||||
private String joinStringArray(@Nullable JSONArray values) {
|
||||
if (values == null || values.length() == 0) return "-";
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i);
|
||||
if (value == null || value.isEmpty()) continue;
|
||||
if (builder.length() > 0) builder.append(";");
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.length() == 0 ? "-" : builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ProjectDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String initialProjectName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
initialProjectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
|
||||
setHeaderAction("发消息", v -> chooseMessageKindAndSend());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderProject(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderProject(JSONObject payload) {
|
||||
JSONObject project = payload.optJSONObject("project");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
JSONArray threadContexts = payload.optJSONArray("activeThreadContexts");
|
||||
JSONArray recentLogs = payload.optJSONArray("recentAppLogs");
|
||||
|
||||
String title = project != null ? project.optString("name", "项目详情") : "项目详情";
|
||||
String subtitle = "设备:" + joinDeviceNames(devices);
|
||||
configureScreen(title, subtitle);
|
||||
|
||||
replaceContent();
|
||||
appendContent(buildActionGrid());
|
||||
|
||||
JSONObject masterIdentity = payload.optJSONObject("masterIdentity");
|
||||
if (masterIdentity != null) {
|
||||
String body = masterIdentity.optString("roleLabel", "主控")
|
||||
+ " · " + masterIdentity.optString("displayName", "-")
|
||||
+ (masterIdentity.optString("nodeLabel").isEmpty() ? "" : " · " + masterIdentity.optString("nodeLabel"))
|
||||
+ (masterIdentity.optString("model").isEmpty() ? "" : "\n模型 " + masterIdentity.optString("model"));
|
||||
String meta = masterIdentity.optString("statusLabel", "")
|
||||
+ (masterIdentity.optString("lastSwitchedAt").isEmpty() ? "" : " · 最近切换 " + masterIdentity.optString("lastSwitchedAt"));
|
||||
appendContent(BossUi.buildCard(this, "当前主控身份", body, meta));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"主 Agent 调度结论",
|
||||
payload.optString("masterContextStrategySummary", "暂无调度摘要。"),
|
||||
"原生项目详情已接入 /api/v1/projects/{projectId}"
|
||||
));
|
||||
|
||||
if (threadContexts != null && threadContexts.length() > 0) {
|
||||
for (int i = 0; i < threadContexts.length(); i++) {
|
||||
JSONObject thread = threadContexts.optJSONObject(i);
|
||||
if (thread == null) continue;
|
||||
JSONObject snapshot = thread.optJSONObject("snapshot");
|
||||
if (snapshot == null) continue;
|
||||
String threadId = snapshot.optString("threadId");
|
||||
String body = snapshot.optString("summary", "暂无摘要");
|
||||
String meta = snapshot.optString("workerId", "-")
|
||||
+ " · " + snapshot.optString("nodeId", "-")
|
||||
+ " · " + snapshot.optInt("contextBudgetRemainingPct", 0) + "%"
|
||||
+ " · " + snapshot.optString("contextBudgetLevel", "safe");
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
snapshot.optString("title", "线程详情"),
|
||||
body,
|
||||
meta,
|
||||
v -> openThread(threadId)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有线程预算数据。"));
|
||||
}
|
||||
|
||||
if (recentLogs != null && recentLogs.length() > 0) {
|
||||
for (int i = 0; i < recentLogs.length(); i++) {
|
||||
JSONObject log = recentLogs.optJSONObject(i);
|
||||
if (log == null) continue;
|
||||
String body = log.optString("message", "无消息体");
|
||||
if (!log.optString("detail").isEmpty()) {
|
||||
body = body + "\n" + log.optString("detail");
|
||||
}
|
||||
String meta = log.optString("deviceId", "-")
|
||||
+ " · " + log.optString("category", "-")
|
||||
+ " · " + log.optString("createdAt", "-");
|
||||
appendContent(BossUi.buildCard(this, "实时 APP 日志", body, meta));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
if (messages != null && messages.length() > 0) {
|
||||
for (int i = 0; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message == null) continue;
|
||||
String meta = message.optString("sentAt", "-")
|
||||
+ (message.optString("kind").isEmpty() ? "" : " · " + message.optString("kind"));
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
message.optString("senderLabel", "消息"),
|
||||
message.optString("body", ""),
|
||||
meta
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"媒体与转发说明",
|
||||
"语音、图片、视频与转发现在都通过原生入口触发,并写回现有 Boss 消息账本。",
|
||||
"对象存储与真实媒体文件仍保持 MVP 占位。"
|
||||
));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildActionGrid() {
|
||||
LinearLayout wrapper = new LinearLayout(this);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.setLayoutParams(new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
));
|
||||
|
||||
wrapper.addView(buildActionRow(
|
||||
buildActionButton("发送消息", v -> chooseMessageKindAndSend()),
|
||||
buildActionButton("项目目标", v -> openGoals())
|
||||
));
|
||||
wrapper.addView(buildActionRow(
|
||||
buildActionButton("版本记录", v -> openVersions()),
|
||||
buildActionButton("消息转发", v -> openForward())
|
||||
));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private LinearLayout buildActionRow(Button left, Button right) {
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.bottomMargin = BossUi.dp(this, 12);
|
||||
row.setLayoutParams(params);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
|
||||
LinearLayout.LayoutParams childParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||
childParams.rightMargin = BossUi.dp(this, 6);
|
||||
left.setLayoutParams(childParams);
|
||||
|
||||
LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||
rightParams.leftMargin = BossUi.dp(this, 6);
|
||||
right.setLayoutParams(rightParams);
|
||||
|
||||
row.addView(left);
|
||||
row.addView(right);
|
||||
return row;
|
||||
}
|
||||
|
||||
private Button buildActionButton(String label, android.view.View.OnClickListener listener) {
|
||||
Button button = BossUi.buildPrimaryButton(this, label);
|
||||
button.setOnClickListener(listener);
|
||||
return button;
|
||||
}
|
||||
|
||||
private void chooseMessageKindAndSend() {
|
||||
final String[] labels = {"文本消息", "语音意图", "图片意图", "视频意图"};
|
||||
final String[] kinds = {"text", "voice_intent", "image_intent", "video_intent"};
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("选择消息类型")
|
||||
.setItems(labels, (dialog, which) -> showSendDialog(kinds[which], labels[which]))
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showSendDialog(String kind, String label) {
|
||||
final android.widget.EditText input = BossUi.buildInput(this, "请输入要发送给项目的内容", true);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("发送" + label)
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("发送", (dialog, which) -> sendProjectMessage(kind, input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void sendProjectMessage(String kind, String body) {
|
||||
if (body.isEmpty()) {
|
||||
showMessage("请输入消息内容");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("消息已发送");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("发送失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openGoals() {
|
||||
Intent intent = new Intent(this, ProjectGoalsActivity.class);
|
||||
intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, initialProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openVersions() {
|
||||
Intent intent = new Intent(this, ProjectVersionsActivity.class);
|
||||
intent.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, initialProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openForward() {
|
||||
Intent intent = new Intent(this, ProjectForwardActivity.class);
|
||||
intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_NAME, initialProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openThread(String threadId) {
|
||||
Intent intent = new Intent(this, ThreadDetailActivity.class);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_PROJECT_ID, projectId);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private String joinDeviceNames(@Nullable JSONArray devices) {
|
||||
if (devices == null || devices.length() == 0) {
|
||||
return "未绑定设备";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < devices.length(); i++) {
|
||||
JSONObject device = devices.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (builder.length() > 0) builder.append(" / ");
|
||||
builder.append(device.optString("name", device.optString("id", "设备")));
|
||||
}
|
||||
return builder.length() == 0 ? "未绑定设备" : builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ProjectForwardActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("消息转发", projectName == null ? "选择目标项目并写备注" : "源项目:" + projectName);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getConversations();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderTargets(response.json.optJSONArray("conversations")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderTargets(@Nullable JSONArray conversations) {
|
||||
replaceContent(BossUi.buildCard(
|
||||
this,
|
||||
"原生转发入口",
|
||||
"选择一个目标项目,填写备注后会走现有 `/api/v1/projects/{projectId}/forwards`。",
|
||||
"源项目:" + (projectName == null ? projectId : projectName)
|
||||
));
|
||||
if (conversations == null || conversations.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标项目。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String targetProjectId = item.optString("projectId");
|
||||
if (projectId.equals(targetProjectId)) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
item.optString("projectTitle", "未命名项目"),
|
||||
item.optString("preview", ""),
|
||||
item.optString("latestReplyLabel", "最近更新"),
|
||||
v -> openForwardDialog(targetProjectId, item.optString("projectTitle", targetProjectId))
|
||||
));
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void openForwardDialog(String targetProjectId, String targetTitle) {
|
||||
final android.widget.EditText input = BossUi.buildInput(this, "请输入要附带的转发说明", true);
|
||||
input.setText("请同步关注 " + targetTitle + " 的当前进展。");
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("转发到 " + targetTitle)
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("转发", (dialog, which) -> forwardMessage(targetProjectId, input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void forwardMessage(String targetProjectId, String note) {
|
||||
if (note.isEmpty()) {
|
||||
showMessage("请先填写转发说明");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(projectId, targetProjectId, note);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发成功");
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("项目目标", projectName == null ? "原生目标清单" : projectName);
|
||||
setHeaderAction("新增", v -> openGoalEditor(null, ""));
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderGoals(response.json.optJSONObject("project")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "目标清单加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderGoals(@Nullable JSONObject project) {
|
||||
replaceContent();
|
||||
if (project == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "项目不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
JSONArray goals = project.optJSONArray("goals");
|
||||
int completedCount = 0;
|
||||
if (goals != null) {
|
||||
for (int i = 0; i < goals.length(); i++) {
|
||||
JSONObject goal = goals.optJSONObject(i);
|
||||
if (goal != null && "completed".equals(goal.optString("state"))) {
|
||||
completedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"主 Agent 已整理项目目标",
|
||||
"已完成 " + completedCount + "/" + (goals == null ? 0 : goals.length()),
|
||||
"用户可编辑,点按钮即可标记完成或修改正文。"
|
||||
));
|
||||
|
||||
if (goals == null || goals.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。"));
|
||||
} else {
|
||||
for (int i = 0; i < goals.length(); i++) {
|
||||
JSONObject goal = goals.optJSONObject(i);
|
||||
if (goal == null) continue;
|
||||
appendContent(buildGoalCard(goal));
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"当前约束",
|
||||
"• 只能使用已绑定设备\n• 审计证据必须可回放\n• 版本记录仅主 Agent 可发布",
|
||||
"原生目标页已覆盖 Web 目标清单"
|
||||
));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildGoalCard(JSONObject goal) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
goal.optString("text", "未命名目标"),
|
||||
goal.optString("note", "暂无备注"),
|
||||
"状态 " + goal.optString("state", "pending")
|
||||
);
|
||||
|
||||
Button toggle = BossUi.buildPrimaryButton(
|
||||
this,
|
||||
"completed".equals(goal.optString("state")) ? "标记未完成" : "标记完成"
|
||||
);
|
||||
toggle.setOnClickListener(v -> toggleGoal(goal.optString("id")));
|
||||
card.addView(toggle);
|
||||
|
||||
Button edit = BossUi.buildSecondaryButton(this, "编辑目标");
|
||||
edit.setOnClickListener(v -> openGoalEditor(goal.optString("id"), goal.optString("text")));
|
||||
card.addView(edit);
|
||||
return card;
|
||||
}
|
||||
|
||||
private void toggleGoal(String goalId) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.toggleGoal(projectId, goalId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("目标状态已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("更新失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openGoalEditor(@Nullable String goalId, String currentText) {
|
||||
final android.widget.EditText input = BossUi.buildInput(this, "请输入目标正文", true);
|
||||
input.setText(currentText);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(goalId == null ? "新增目标" : "编辑目标")
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveGoal(goalId, input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveGoal(@Nullable String goalId, String text) {
|
||||
if (text.isEmpty()) {
|
||||
showMessage("目标正文不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = goalId == null
|
||||
? apiClient.createGoal(projectId, text)
|
||||
: apiClient.updateGoal(projectId, goalId, text);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage(goalId == null ? "目标已新增" : "目标已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderVersions(response.json.optJSONObject("project")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "版本记录加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderVersions(@Nullable JSONObject project) {
|
||||
replaceContent(BossUi.buildCard(
|
||||
this,
|
||||
"版本记录只读",
|
||||
"版本记录由主 Agent 监督各线程提交,并在复核后自动发布。",
|
||||
"原生版本页仅展示,不允许手工篡改正文。"
|
||||
));
|
||||
if (project == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "项目不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
JSONArray versions = project.optJSONArray("versions");
|
||||
if (versions == null || versions.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有版本记录。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < versions.length(); i++) {
|
||||
JSONObject item = versions.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
item.optString("version", "未命名版本"),
|
||||
item.optString("summary", ""),
|
||||
item.optString("createdAt", "-")
|
||||
));
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class SecurityActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("账号与安全", "原生会话与设备安全");
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getSession();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "安全信息加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderSecurity(@Nullable JSONObject session) {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"当前登录模式",
|
||||
"当前登录页已临时切到免验证模式,点击登录会直接创建最高管理员会话。",
|
||||
"后续如收口认证,再切回账号密码 / 验证码登录。"
|
||||
)
|
||||
);
|
||||
if (session != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"当前会话",
|
||||
"账号 " + session.optString("account", "-")
|
||||
+ "\n角色 " + session.optString("role", "-")
|
||||
+ "\n登录方式 " + session.optString("loginMethod", "-"),
|
||||
"到期 " + session.optString("expiresAt", "-")
|
||||
));
|
||||
}
|
||||
|
||||
android.widget.Button devicesButton = BossUi.buildPrimaryButton(this, "打开设备页");
|
||||
devicesButton.setOnClickListener(v -> {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
startActivity(intent);
|
||||
});
|
||||
appendContent(devicesButton);
|
||||
|
||||
android.widget.Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
|
||||
logoutButton.setOnClickListener(v -> logout());
|
||||
appendContent(logoutButton);
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void logout() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
apiClient.logout();
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
120
android/app/src/main/java/com/hyzq/boss/SettingsActivity.java
Normal file
@@ -0,0 +1,120 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class SettingsActivity extends BossScreenActivity {
|
||||
private SwitchCompat liveUpdatesSwitch;
|
||||
private SwitchCompat riskBadgesSwitch;
|
||||
private SwitchCompat confirmActionsSwitch;
|
||||
private Spinner preferredEntrySpinner;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("设置", "原生偏好配置");
|
||||
setHeaderAction("保存", v -> saveSettings());
|
||||
buildForm();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getSettings();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> populate(response.json.optJSONObject("settings")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildForm() {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"设置说明",
|
||||
"当前设置会持久化到 data/boss-state.json,下一线程接手不会丢失。",
|
||||
"原生设置页直接走 /api/v1/settings"
|
||||
)
|
||||
);
|
||||
|
||||
liveUpdatesSwitch = new SwitchCompat(this);
|
||||
liveUpdatesSwitch.setText("启用实时刷新");
|
||||
|
||||
riskBadgesSwitch = new SwitchCompat(this);
|
||||
riskBadgesSwitch.setText("显示风险徽标");
|
||||
|
||||
confirmActionsSwitch = new SwitchCompat(this);
|
||||
confirmActionsSwitch.setText("危险操作前确认");
|
||||
|
||||
preferredEntrySpinner = new Spinner(this);
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"conversations", "devices", "me"}
|
||||
);
|
||||
preferredEntrySpinner.setAdapter(adapter);
|
||||
|
||||
LinearLayout card = BossUi.buildCard(this, "交互偏好", "可切换默认首页与提醒行为。", "保存后立即生效");
|
||||
card.addView(liveUpdatesSwitch);
|
||||
card.addView(riskBadgesSwitch);
|
||||
card.addView(confirmActionsSwitch);
|
||||
card.addView(preferredEntrySpinner);
|
||||
appendContent(card);
|
||||
}
|
||||
|
||||
private void populate(@Nullable JSONObject settings) {
|
||||
if (settings != null) {
|
||||
liveUpdatesSwitch.setChecked(settings.optBoolean("liveUpdates", true));
|
||||
riskBadgesSwitch.setChecked(settings.optBoolean("showRiskBadges", true));
|
||||
confirmActionsSwitch.setChecked(settings.optBoolean("confirmDangerousActions", true));
|
||||
String preferredEntry = settings.optString("preferredEntryPoint", "conversations");
|
||||
if ("devices".equals(preferredEntry)) {
|
||||
preferredEntrySpinner.setSelection(1);
|
||||
} else if ("me".equals(preferredEntry)) {
|
||||
preferredEntrySpinner.setSelection(2);
|
||||
} else {
|
||||
preferredEntrySpinner.setSelection(0);
|
||||
}
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void saveSettings() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("liveUpdates", liveUpdatesSwitch.isChecked());
|
||||
payload.put("showRiskBadges", riskBadgesSwitch.isChecked());
|
||||
payload.put("confirmDangerousActions", confirmActionsSwitch.isChecked());
|
||||
payload.put("preferredEntryPoint", preferredEntrySpinner.getSelectedItem().toString());
|
||||
BossApiClient.ApiResponse response = apiClient.updateSettings(payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("设置已保存");
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("设置保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class SkillInventoryActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_DEVICE_ID = "device_id";
|
||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||
|
||||
private String deviceId;
|
||||
private String deviceName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
|
||||
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
|
||||
configureScreen("技能", deviceName == null ? "当前设备 Skill 清单" : deviceName);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String targetDeviceId = resolveTargetDeviceId();
|
||||
BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
deviceId = targetDeviceId;
|
||||
runOnUiThread(() -> renderSkills(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "技能列表加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String resolveTargetDeviceId() throws Exception {
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
return deviceId;
|
||||
}
|
||||
BossApiClient.ApiResponse response = apiClient.getDevices();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
JSONArray devices = response.json.optJSONArray("devices");
|
||||
if (devices == null || devices.length() == 0) {
|
||||
throw new IllegalStateException("NO_DEVICE");
|
||||
}
|
||||
return devices.optJSONObject(0).optString("id");
|
||||
}
|
||||
|
||||
private void renderSkills(JSONObject payload) {
|
||||
replaceContent();
|
||||
JSONObject device = payload.optJSONObject("device");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
|
||||
if (device != null) {
|
||||
deviceName = device.optString("name", deviceId);
|
||||
configureScreen("技能", deviceName);
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
deviceName,
|
||||
"当前页按设备查看 Skill 清单。",
|
||||
"Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。"
|
||||
));
|
||||
}
|
||||
|
||||
if (skills == null || skills.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < skills.length(); i++) {
|
||||
JSONObject skill = skills.optJSONObject(i);
|
||||
if (skill == null) continue;
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
skill.optString("name", "未命名 Skill"),
|
||||
skill.optString("description", "未提供说明"),
|
||||
skill.optString("category", "-")
|
||||
+ " · " + skill.optString("updatedAt", "-")
|
||||
);
|
||||
Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句");
|
||||
copyInvocation.setOnClickListener(v -> BossUi.copyText(this, "Skill 调用", skill.optString("invocation", "")));
|
||||
card.addView(copyInvocation);
|
||||
Button copyPath = BossUi.buildSecondaryButton(this, "复制路径");
|
||||
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
|
||||
card.addView(copyPath);
|
||||
appendContent(card);
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ThreadDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_THREAD_ID = "thread_id";
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
|
||||
private String threadId;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
threadId = getIntent().getStringExtra(EXTRA_THREAD_ID);
|
||||
configureScreen("线程详情", threadId == null ? "原生线程预算视图" : threadId);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getThreadDetail(threadId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderThread(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "线程详情加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderThread(JSONObject payload) {
|
||||
replaceContent();
|
||||
JSONObject snapshot = payload.optJSONObject("snapshot");
|
||||
if (snapshot == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "线程不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
configureScreen("线程详情", snapshot.optString("title", threadId));
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
snapshot.optString("title", "线程"),
|
||||
snapshot.optString("summary", "暂无摘要"),
|
||||
snapshot.optString("workerId", "-")
|
||||
+ " · " + snapshot.optString("nodeId", "-")
|
||||
+ " · " + snapshot.optInt("contextBudgetRemainingPct", 0) + "%"
|
||||
+ " · " + snapshot.optString("contextBudgetLevel", "safe")
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"压缩前收尾清单",
|
||||
joinBulletLines(payload.optJSONArray("currentChecklist")),
|
||||
"这些步骤需要在上下文压缩前固化。"
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"主 Agent 动作",
|
||||
joinBulletLines(payload.optJSONArray("masterActions")),
|
||||
"若为空,说明当前无需额外动作。"
|
||||
));
|
||||
|
||||
JSONObject handoffPackage = payload.optJSONObject("handoffPackage");
|
||||
if (handoffPackage != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"handoff package",
|
||||
handoffPackage.optString("summaryText", "暂无摘要"),
|
||||
handoffPackage.optString("packageStatus", "draft")
|
||||
));
|
||||
}
|
||||
|
||||
JSONArray alerts = payload.optJSONArray("alerts");
|
||||
if (alerts != null) {
|
||||
for (int i = 0; i < alerts.length(); i++) {
|
||||
JSONObject alert = alerts.optJSONObject(i);
|
||||
if (alert == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"上下文告警",
|
||||
alert.optString("summary", "无摘要"),
|
||||
alert.optString("status", "opened")
|
||||
));
|
||||
}
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private String joinBulletLines(@Nullable JSONArray items) {
|
||||
if (items == null || items.length() == 0) {
|
||||
return "当前没有内容。";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
String value = items.optString(i);
|
||||
if (value == null || value.isEmpty()) continue;
|
||||
if (builder.length() > 0) builder.append('\n');
|
||||
builder.append("• ").append(value);
|
||||
}
|
||||
return builder.length() == 0 ? "当前没有内容。" : builder.toString();
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
7
android/app/src/main/res/drawable/bg_app_gradient.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:endColor="@color/boss_bg_end"
|
||||
android:startColor="@color/boss_bg_start" />
|
||||
</shape>
|
||||
8
android/app/src/main/res/drawable/bg_card.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners android:radius="24dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_card_stroke" />
|
||||
</shape>
|
||||
5
android/app/src/main/res/drawable/bg_primary_button.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_green" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners android:radius="18dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_card_stroke" />
|
||||
</shape>
|
||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
253
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,253 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/login_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingTop="72dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingBottom="32dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:gravity="center"
|
||||
android:text="B"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Boss 原生控制台"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/login_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="原生 Android 客户端已启用。点击下方按钮直接进入系统。"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:background="@drawable/bg_card"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="当前临时模式"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="1. 这是原生 Android Activity,不再打开 WebView。\n2. 登录暂时不做验证,点击按钮会直接进入最高管理员会话。\n3. 会话 / 设备 / 我的三栏都直接调用现有 Boss API。"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/login_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/login_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:text="登录"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/content_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="18dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginRight="12dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/top_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="会话"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/top_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="原生 Android 客户端,直接消费 Boss API。"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/screen_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="88dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="76dp"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="10dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_conversations"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="会话"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_devices"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:text="设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_me"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:text="我的"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
109
android/app/src/main/res/layout/activity_screen.xml
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="标题"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text="副标题"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="操作"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="18dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingRight="18dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
14
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="boss_green">#07C160</color>
|
||||
<color name="boss_green_dark">#04984B</color>
|
||||
<color name="boss_surface">#FFFFFFFF</color>
|
||||
<color name="boss_bg_start">#FFF1F6EE</color>
|
||||
<color name="boss_bg_end">#FFE3F0E3</color>
|
||||
<color name="boss_card_stroke">#1A0F1B12</color>
|
||||
<color name="boss_text_primary">#FF111111</color>
|
||||
<color name="boss_text_muted">#FF5F6B63</color>
|
||||
<color name="colorPrimary">@color/boss_green</color>
|
||||
<color name="colorPrimaryDark">@color/boss_green_dark</color>
|
||||
<color name="colorAccent">@color/boss_green</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Boss</string>
|
||||
<string name="title_activity_main">Boss</string>
|
||||
<string name="package_name">com.hyzq.boss</string>
|
||||
<string name="custom_url_scheme">com.hyzq.boss</string>
|
||||
</resources>
|
||||
22
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
29
android/build.gradle
Normal file
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
22
android/gradle.properties
Normal file
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
1
android/settings.gradle
Normal file
@@ -0,0 +1 @@
|
||||
include ':app'
|
||||
4
android/signing/release-signing.properties.example
Normal file
@@ -0,0 +1,4 @@
|
||||
storeFile=../keystores/boss-release.keystore
|
||||
storePassword=replace-with-store-password
|
||||
keyAlias=bossrelease
|
||||
keyPassword=replace-with-key-password
|
||||
16
android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
9
deployment/Caddyfile
Normal file
@@ -0,0 +1,9 @@
|
||||
boss.hyzq.net {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
http://106.53.170.158 {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
22
deployment/launchd/com.hyzq.boss.local-agent.plist
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hyzq.boss.local-agent</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/zsh</string>
|
||||
<string>-lc</string>
|
||||
<string>cd /Users/kris/code/boss && ./scripts/start-local-agent.sh __BOSS_AGENT_CONFIG__</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/boss-local-agent.out</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/boss-local-agent.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
142
deployment/mail/install-postfix-dovecot.sh
Executable file
@@ -0,0 +1,142 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Please run with sudo."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
MAIL_DOMAIN="${BOSS_MAIL_DOMAIN:-boss.hyzq.net}"
|
||||
MAILBOX_USER="${BOSS_MAILBOX_USER:-bossmail}"
|
||||
MAILBOX_HOME="/home/${MAILBOX_USER}"
|
||||
STATE_DIR="/etc/boss-mail"
|
||||
TLS_DIR="${STATE_DIR}/tls"
|
||||
TLS_CERT_TARGET="${TLS_DIR}/fullchain.pem"
|
||||
TLS_KEY_TARGET="${TLS_DIR}/privkey.pem"
|
||||
MAILBOX_ENV_FILE="${STATE_DIR}/mailbox.env"
|
||||
|
||||
echo "postfix postfix/mailname string ${MAIL_DOMAIN}" | debconf-set-selections
|
||||
echo "postfix postfix/main_mailer_type string 'Internet Site'" | debconf-set-selections
|
||||
|
||||
apt-get update
|
||||
apt-get install -y postfix dovecot-core dovecot-imapd mailutils swaks
|
||||
|
||||
install -d -m 700 "${STATE_DIR}"
|
||||
install -d -m 700 "${TLS_DIR}"
|
||||
|
||||
if ! id "${MAILBOX_USER}" >/dev/null 2>&1; then
|
||||
useradd -m -s /usr/sbin/nologin "${MAILBOX_USER}"
|
||||
fi
|
||||
|
||||
MAILBOX_PASSWORD="${BOSS_MAILBOX_PASSWORD:-}"
|
||||
if [[ -z "${MAILBOX_PASSWORD}" && -f "${MAILBOX_ENV_FILE}" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "${MAILBOX_ENV_FILE}"
|
||||
MAILBOX_PASSWORD="${BOSS_MAILBOX_PASSWORD:-${MAILBOX_PASSWORD:-}}"
|
||||
fi
|
||||
|
||||
if [[ -z "${MAILBOX_PASSWORD}" ]]; then
|
||||
MAILBOX_PASSWORD="$(openssl rand -base64 24 | tr -d '\n=' | cut -c1-20)"
|
||||
fi
|
||||
|
||||
cat >"${MAILBOX_ENV_FILE}" <<EOF
|
||||
BOSS_MAIL_DOMAIN=${MAIL_DOMAIN}
|
||||
BOSS_MAILBOX_USER=${MAILBOX_USER}
|
||||
BOSS_MAILBOX_PASSWORD=${MAILBOX_PASSWORD}
|
||||
EOF
|
||||
chmod 600 "${MAILBOX_ENV_FILE}"
|
||||
|
||||
echo "${MAILBOX_USER}:${MAILBOX_PASSWORD}" | chpasswd
|
||||
|
||||
install -m 755 "${SCRIPT_DIR}/sync-caddy-mail-cert.sh" /usr/local/bin/boss-mail-cert-sync.sh
|
||||
cp "${SCRIPT_DIR}/systemd/boss-mail-cert-sync.service" /etc/systemd/system/boss-mail-cert-sync.service
|
||||
cp "${SCRIPT_DIR}/systemd/boss-mail-cert-sync.timer" /etc/systemd/system/boss-mail-cert-sync.timer
|
||||
|
||||
cat > /etc/dovecot/conf.d/99-boss-mail.conf <<EOF
|
||||
protocols = imap
|
||||
mail_location = maildir:~/Maildir
|
||||
disable_plaintext_auth = yes
|
||||
auth_mechanisms = plain login
|
||||
ssl = required
|
||||
ssl_cert = <${TLS_CERT_TARGET}
|
||||
ssl_key = <${TLS_KEY_TARGET}
|
||||
|
||||
service auth {
|
||||
unix_listener /var/spool/postfix/private/auth {
|
||||
mode = 0660
|
||||
user = postfix
|
||||
group = postfix
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
postconf -e "myhostname = ${MAIL_DOMAIN}"
|
||||
postconf -e "myorigin = /etc/mailname"
|
||||
postconf -e "mydestination = \$myhostname, localhost.\$mydomain, localhost, ${MAIL_DOMAIN}"
|
||||
postconf -e "inet_interfaces = all"
|
||||
postconf -e "inet_protocols = all"
|
||||
postconf -e "home_mailbox = Maildir/"
|
||||
postconf -e "mailbox_size_limit = 0"
|
||||
postconf -e "recipient_delimiter = +"
|
||||
postconf -e "alias_maps = hash:/etc/aliases"
|
||||
postconf -e "alias_database = hash:/etc/aliases"
|
||||
postconf -e "smtpd_tls_cert_file = ${TLS_CERT_TARGET}"
|
||||
postconf -e "smtpd_tls_key_file = ${TLS_KEY_TARGET}"
|
||||
postconf -e "smtpd_tls_security_level = may"
|
||||
postconf -e "smtp_tls_security_level = may"
|
||||
postconf -e "smtpd_sasl_auth_enable = yes"
|
||||
postconf -e "smtpd_sasl_type = dovecot"
|
||||
postconf -e "smtpd_sasl_path = private/auth"
|
||||
postconf -e "broken_sasl_auth_clients = yes"
|
||||
postconf -e "smtpd_recipient_restrictions = permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination"
|
||||
|
||||
touch /etc/aliases
|
||||
ALIASES_TMP="$(mktemp)"
|
||||
grep -Ev '^(verify|no-reply|noreply|root|postmaster):|^# BOSS MAIL ALIASES (START|END)$' /etc/aliases > "${ALIASES_TMP}" || true
|
||||
echo "postmaster: root" >> "${ALIASES_TMP}"
|
||||
cat >> "${ALIASES_TMP}" <<EOF
|
||||
root: ${MAILBOX_USER}
|
||||
# BOSS MAIL ALIASES START
|
||||
verify: ${MAILBOX_USER}
|
||||
no-reply: ${MAILBOX_USER}
|
||||
noreply: ${MAILBOX_USER}
|
||||
# BOSS MAIL ALIASES END
|
||||
EOF
|
||||
install -m 644 "${ALIASES_TMP}" /etc/aliases
|
||||
rm -f "${ALIASES_TMP}"
|
||||
newaliases
|
||||
|
||||
if ! grep -q "^submission inet" /etc/postfix/master.cf; then
|
||||
cat >> /etc/postfix/master.cf <<'EOF'
|
||||
submission inet n - y - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
||||
smtps inet n - y - - smtpd
|
||||
-o syslog_name=postfix/smtps
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
||||
EOF
|
||||
fi
|
||||
|
||||
touch "${MAILBOX_HOME}/.hushlogin"
|
||||
install -d -m 700 -o "${MAILBOX_USER}" -g "${MAILBOX_USER}" "${MAILBOX_HOME}/Maildir"
|
||||
install -d -m 700 -o "${MAILBOX_USER}" -g "${MAILBOX_USER}" "${MAILBOX_HOME}/Maildir/cur"
|
||||
install -d -m 700 -o "${MAILBOX_USER}" -g "${MAILBOX_USER}" "${MAILBOX_HOME}/Maildir/new"
|
||||
install -d -m 700 -o "${MAILBOX_USER}" -g "${MAILBOX_USER}" "${MAILBOX_HOME}/Maildir/tmp"
|
||||
|
||||
/usr/local/bin/boss-mail-cert-sync.sh
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable postfix dovecot boss-mail-cert-sync.timer
|
||||
systemctl restart postfix dovecot
|
||||
systemctl restart boss-mail-cert-sync.timer
|
||||
|
||||
printf 'Boss mail stack installed for %s\n' "${MAIL_DOMAIN}"
|
||||
printf 'Mailbox user: %s\n' "${MAILBOX_USER}"
|
||||
printf 'Mailbox password file: %s\n' "${MAILBOX_ENV_FILE}"
|
||||
34
deployment/mail/sync-caddy-mail-cert.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
MAIL_DOMAIN="${BOSS_MAIL_DOMAIN:-boss.hyzq.net}"
|
||||
SOURCE_DIR="${BOSS_MAIL_TLS_SOURCE_DIR:-/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/${MAIL_DOMAIN}}"
|
||||
SOURCE_CERT="${BOSS_MAIL_TLS_CERT_SOURCE:-${SOURCE_DIR}/${MAIL_DOMAIN}.crt}"
|
||||
SOURCE_KEY="${BOSS_MAIL_TLS_KEY_SOURCE:-${SOURCE_DIR}/${MAIL_DOMAIN}.key}"
|
||||
TARGET_DIR="${BOSS_MAIL_TLS_TARGET_DIR:-/etc/boss-mail/tls}"
|
||||
TARGET_CERT="${TARGET_DIR}/fullchain.pem"
|
||||
TARGET_KEY="${TARGET_DIR}/privkey.pem"
|
||||
|
||||
if [[ ! -f "${SOURCE_CERT}" || ! -f "${SOURCE_KEY}" ]]; then
|
||||
echo "Missing Caddy TLS assets for ${MAIL_DOMAIN} under ${SOURCE_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d -m 700 "${TARGET_DIR}"
|
||||
|
||||
changed=0
|
||||
if [[ ! -f "${TARGET_CERT}" ]] || ! cmp -s "${SOURCE_CERT}" "${TARGET_CERT}"; then
|
||||
install -m 644 "${SOURCE_CERT}" "${TARGET_CERT}"
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${TARGET_KEY}" ]] || ! cmp -s "${SOURCE_KEY}" "${TARGET_KEY}"; then
|
||||
install -m 600 "${SOURCE_KEY}" "${TARGET_KEY}"
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [[ "${changed}" -eq 1 ]]; then
|
||||
systemctl restart postfix dovecot
|
||||
fi
|
||||
|
||||
echo "boss-mail-cert-sync completed"
|
||||
8
deployment/mail/systemd/boss-mail-cert-sync.service
Normal file
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Sync Boss mail TLS certificate from Caddy
|
||||
After=network-online.target caddy.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/boss-mail-cert-sync.sh
|
||||
10
deployment/mail/systemd/boss-mail-cert-sync.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Periodic Boss mail TLS certificate sync
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2m
|
||||
OnUnitActiveSec=1h
|
||||
Unit=boss-mail-cert-sync.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
21
deployment/systemd/boss-web.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Boss Web
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/boss
|
||||
Environment=PORT=3000
|
||||
Environment=BOSS_AUTH_VERIFICATION_MODE=fixed
|
||||
Environment=BOSS_AUTH_FIXED_CODE=000000
|
||||
Environment=BOSS_RUNTIME_ROOT=/opt/boss
|
||||
Environment=BOSS_STATE_FILE=/opt/boss/data/boss-state.json
|
||||
EnvironmentFile=-/opt/boss/.env.server
|
||||
ExecStart=/usr/bin/npm start
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
BIN
design/exports/ui-codex-ops-mobile-v13/5iGU7.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/CwqbE.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/EIH5F.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/KEs3m.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/LQOJ0.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/OlK6f.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/XGb3o.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/aTIvM.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/d5gpt.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/g8Qpr.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/grcep.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/i7IZ1.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/jsR54.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/ksDUL.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/mEGZZ.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/mNaad.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/o3OKu.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
design/exports/ui-codex-ops-mobile-v13/qVDXn.png
Normal file
|
After Width: | Height: | Size: 34 KiB |