diff --git a/README.md b/README.md index 2c98edb..b7bd1e5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - `src/boss_control`:空占位目录,不参与当前运行 - `src/boss_device_agent`:空占位目录,不参与当前运行 -## 当前运行状态(2026-03-27) +## 当前运行状态(2026-03-28) 本地: @@ -90,17 +90,20 @@ 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.2.1`(`versionCode=10`) +- 当前最新 release 构建版本:`2.3.0`(`versionCode=11`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` -- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 -- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 -- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维 / 审计 / 修复` 不再出现在一级 `我的`,而是下沉到 `关于 > 高级与调试` +- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 +- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 +- 当前聊天列表已切到“线程 = 会话窗口”的结构:主标题显示线程名,副标题显示所属文件夹名,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会显示成多个独立聊天窗口 +- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名 +- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页 - 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView - `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退 - `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通 - `2.1.1` 已补上原生 OTA 下载链路:关于页会直接请求受保护的 `/api/v1/user/ota/package`,下载完成后可拉起系统安装器 - `2.2.0` 已把原生 UI 从控制台风格回退到微信式简单列表和聊天优先视图,并复核了设备页 / 我的页 / 深层高级入口 - `2.2.1` 已继续补齐原生交互细节:聊天页发送后会先出现本地“发送中”气泡,关于页会展示 OTA 下载进度 / 重试 / 安装授权提示,根 tab 会记住用户上次停留位置并改成“再按一次返回进入后台” +- `2.3.0` 已把会话模型切到“线程 = 聊天窗口”,补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化 ## 本地启动 diff --git a/android/app/build.gradle b/android/app/build.gradle index 20763b7..07f9d4b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,8 +33,8 @@ android { applicationId "com.hyzq.boss" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 10 - versionName "2.2.1" + versionCode 11 + versionName "2.3.0" buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 31d2b9b..960a370 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,9 @@ + + + diff --git a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java index 3727bf8..887a4f0 100644 --- a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java @@ -25,6 +25,7 @@ public class AboutActivity extends BossScreenActivity { private static final String KEY_ACTIVE_DOWNLOAD_ID = "ota_active_download_id"; private static final String KEY_COMPLETED_DOWNLOAD_ID = "ota_completed_download_id"; private static final String KEY_LAST_DOWNLOAD_FILE_NAME = "ota_last_download_file_name"; + private static final String KEY_LAST_DOWNLOAD_VERSION = "ota_last_download_version"; private static final String KEY_LAST_DOWNLOAD_STATUS = "ota_last_download_status"; private long activeDownloadId = -1L; @@ -33,6 +34,7 @@ public class AboutActivity extends BossScreenActivity { private @Nullable LinearLayout otaDownloadStateSection; private @Nullable Uri downloadedApkUri; private @Nullable String lastDownloadFileName; + private @Nullable String lastDownloadVersion; private int lastDownloadStatus = -1; private long lastDownloadedBytes = 0L; private long lastTotalBytes = -1L; @@ -64,7 +66,7 @@ public class AboutActivity extends BossScreenActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - configureScreen("关于", "版本与更新"); + configureScreen("关于", "版本与 OTA 更新"); restoreDownloadUiState(); IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -93,14 +95,12 @@ public class AboutActivity extends BossScreenActivity { try { BossApiClient.ApiResponse settings = apiClient.getSettings(); BossApiClient.ApiResponse ota = apiClient.getOtaStatus(); - BossApiClient.ApiResponse session = apiClient.getSession(); - if (!settings.ok() || !ota.ok() || !session.ok()) { + if (!settings.ok() || !ota.ok()) { throw new IllegalStateException("PROFILE_OR_OTA_LOAD_FAILED"); } runOnUiThread(() -> renderAbout( settings.json.optJSONObject("user"), - ota.json, - session.json.optJSONObject("session") + ota.json )); } catch (Exception error) { runOnUiThread(() -> { @@ -111,80 +111,164 @@ public class AboutActivity extends BossScreenActivity { }); } - private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) { + private void renderAbout(@Nullable JSONObject user, JSONObject ota) { replaceContent(); otaPayload = ota; - if (user != null) { - appendContent(BossUi.buildListRow( - this, - "当前版本", - user.optString("version", "-") - + " · 账号 " + user.optString("account", "-"), - "绑定 Codex " + user.optString("boundCodexNodeLabel", "未绑定") - + (session == null ? "" : " · 会话到期 " + session.optString("expiresAt", "-")), - null, - null - )); - } - - JSONObject availableRelease = ota.optJSONObject("availableRelease"); - String otaSubtitle = availableRelease == null - ? "当前已经是最新版本。" - : "可用版本 " + availableRelease.optString("version", "未知版本"); - String otaMeta = availableRelease == null - ? "当前版本 " + ota.optString("currentVersion", "-") - : availableRelease.optString("summary", "暂无摘要") - + " · 文件 " + availableRelease.optString("packageFileName", "-"); - appendContent(BossUi.buildListRow( + invalidateStaleDownloadedApk(ota.optJSONObject("availableRelease")); + appendContent(BossUi.buildWechatMenuRow( this, - "OTA 状态", - otaSubtitle, - otaMeta, - availableRelease == null ? null : "NEW", + "当前版本", + user == null ? ota.optString("currentVersion", "-") : user.optString("version", ota.optString("currentVersion", "-")), + "已安装版本", + null, null )); - appendContent(BossUi.buildMenuRow(this, "检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check"))); - appendContent(BossUi.buildMenuRow(this, "登记应用 OTA", "把当前已应用版本写回服务端", null, v -> performOtaAction("apply"))); - appendContent(BossUi.buildMenuRow(this, "应用内下载 APK", "下载最新安装包并拉起系统安装器", null, v -> downloadLatestApk())); + JSONObject availableRelease = ota.optJSONObject("availableRelease"); + appendContent(BossUi.buildWechatMenuRow( + this, + "OTA 状态", + buildOtaStatusSubtitle(ota), + buildOtaStatusMeta(ota), + availableRelease == null ? null : "OTA", + null + )); + + appendContent(BossUi.buildSoftPanel( + this, + "OTA 更新内容", + buildOtaContentBody(ota), + availableRelease == null ? "没有可下载的新版本时,可直接点按钮检查更新。" : "下载完成后会自动拉起系统安装器。" + )); + + android.widget.Button otaButton = BossUi.buildPrimaryButton(this, resolvePrimaryOtaActionLabel(availableRelease)); + otaButton.setEnabled(activeDownloadId <= 0); + otaButton.setOnClickListener(v -> performPrimaryOtaAction(availableRelease)); + appendContent(otaButton); + + appendContent(BossUi.buildMenuRow(this, "重新检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check"))); + if (downloadedApkUri != null || completedDownloadId > 0) { + appendContent(BossUi.buildMenuRow( + this, + "同步已应用状态", + "安装完成后点这里,把服务端 OTA 状态更新为已应用", + null, + v -> performOtaAction("apply") + )); + } otaDownloadStateSection = new LinearLayout(this); otaDownloadStateSection.setOrientation(LinearLayout.VERTICAL); appendContent(otaDownloadStateSection); refreshDownloadStateSection(); - appendContent(BossUi.buildMenuRow( - this, - WechatSurfaceMapper.advancedEntryTitle(), - "进入运维对话、审计与修复入口", - null, - v -> startActivity(new Intent(this, OpsCenterActivity.class)) - )); + setRefreshing(false); + } - 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.buildListRow( - this, - log.optString("version", "OTA"), - log.optString("summary", ""), - log.optString("status", "-") + " · " + log.optString("createdAt", "-"), - null, - null - )); + private static String buildOtaStatusSubtitle(JSONObject ota) { + JSONObject availableRelease = ota.optJSONObject("availableRelease"); + if (availableRelease == null) { + return "当前已经是最新版本。"; + } + return "发现新版本 " + availableRelease.optString("version", "未知版本"); + } + + private static String buildOtaStatusMeta(JSONObject ota) { + JSONObject availableRelease = ota.optJSONObject("availableRelease"); + if (availableRelease == null) { + return "当前版本 " + ota.optString("currentVersion", "-"); + } + String summaryLine = firstSummaryLine(availableRelease.optJSONArray("summary")); + return availableRelease.optString("packageFileName", "boss-android-latest.apk") + + (summaryLine.isEmpty() ? "" : " · " + summaryLine); + } + + private static String buildOtaContentBody(JSONObject ota) { + JSONObject availableRelease = ota.optJSONObject("availableRelease"); + if (availableRelease != null) { + JSONArray lines = availableRelease.optJSONArray("summary"); + if (lines != null && lines.length() > 0) { + StringBuilder builder = new StringBuilder("版本 ").append(availableRelease.optString("version", "-")); + for (int i = 0; i < lines.length(); i++) { + String line = lines.optString(i); + if (line == null || line.isEmpty()) { + continue; + } + builder.append("\n").append(i + 1).append(". ").append(line); + } + return builder.toString(); + } + String note = availableRelease.optString("note", ""); + if (!note.isEmpty()) { + return "版本 " + availableRelease.optString("version", "-") + "\n" + note; } } - setRefreshing(false); + + JSONArray logs = ota.optJSONArray("logs"); + if (logs != null && logs.length() > 0) { + JSONObject latest = logs.optJSONObject(0); + if (latest != null) { + String note = latest.optString("note", ""); + if (!note.isEmpty()) { + return latest.optString("version", "当前版本") + "\n" + note; + } + } + } + return "当前没有待更新内容,点击下方按钮可重新检查更新。"; + } + + private static String firstSummaryLine(@Nullable JSONArray lines) { + if (lines == null || lines.length() == 0) { + return ""; + } + for (int i = 0; i < lines.length(); i++) { + String line = lines.optString(i); + if (line != null && !line.isEmpty()) { + return line; + } + } + return ""; + } + + private String resolvePrimaryOtaActionLabel(@Nullable JSONObject availableRelease) { + if (activeDownloadId > 0) { + return "下载中…"; + } + if (downloadedApkUri != null) { + return "安装更新"; + } + if (availableRelease != null) { + return "立即 OTA"; + } + return "检查更新"; + } + + private void performPrimaryOtaAction(@Nullable JSONObject availableRelease) { + if (activeDownloadId > 0) { + return; + } + if (downloadedApkUri != null) { + installDownloadedApk(); + return; + } + if (availableRelease != null) { + downloadLatestApk(); + return; + } + performOtaAction("check"); } private void performOtaAction(String action) { setRefreshing(true); executor.execute(() -> { try { - BossApiClient.ApiResponse response = "check".equals(action) ? apiClient.checkOta() : apiClient.applyOta(); + BossApiClient.ApiResponse response = "apply".equals(action) + ? apiClient.applyOta() + : apiClient.checkOta(); if (!response.ok()) throw new IllegalStateException(response.message()); runOnUiThread(() -> { - showMessage("check".equals(action) ? "已完成版本检查" : "已登记 OTA 应用"); + if ("apply".equals(action)) { + clearLocalOtaDownloadState(); + } + showMessage("apply".equals(action) ? "已同步 OTA 应用状态" : "已完成版本检查"); reload(); }); } catch (Exception error) { @@ -196,6 +280,19 @@ public class AboutActivity extends BossScreenActivity { }); } + private void clearLocalOtaDownloadState() { + activeDownloadId = -1L; + completedDownloadId = -1L; + downloadedApkUri = null; + lastDownloadFileName = null; + lastDownloadVersion = null; + lastDownloadStatus = -1; + lastDownloadedBytes = 0L; + lastTotalBytes = -1L; + otaProgressHandler.removeCallbacks(otaProgressPoller); + persistDownloadUiState(); + } + private void downloadLatestApk() { executor.execute(() -> { try { @@ -224,6 +321,9 @@ public class AboutActivity extends BossScreenActivity { String fileName = availableRelease == null ? "boss-android-latest.apk" : availableRelease.optString("packageFileName", "boss-android-latest.apk"); + String releaseVersion = availableRelease == null + ? null + : availableRelease.optString("version", null); DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apiClient.getProtectedOtaPackageUrl())); request.setTitle(fileName); @@ -237,6 +337,7 @@ public class AboutActivity extends BossScreenActivity { downloadedApkUri = null; lastDownloadFileName = fileName; + lastDownloadVersion = releaseVersion; lastDownloadStatus = DownloadManager.STATUS_PENDING; lastDownloadedBytes = 0L; lastTotalBytes = -1L; @@ -345,6 +446,8 @@ public class AboutActivity extends BossScreenActivity { @Nullable private OtaDownloadStateMapper.UiState resolveDownloadUiState() { + JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease"); + invalidateStaleDownloadedApk(availableRelease); String fileName = resolveDownloadFileName(); if (activeDownloadId > 0) { DownloadProgressSnapshot snapshot = queryDownloadProgress(activeDownloadId); @@ -435,6 +538,7 @@ public class AboutActivity extends BossScreenActivity { activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L); completedDownloadId = prefs.getLong(KEY_COMPLETED_DOWNLOAD_ID, -1L); lastDownloadFileName = prefs.getString(KEY_LAST_DOWNLOAD_FILE_NAME, null); + lastDownloadVersion = prefs.getString(KEY_LAST_DOWNLOAD_VERSION, null); lastDownloadStatus = prefs.getInt(KEY_LAST_DOWNLOAD_STATUS, -1); if (completedDownloadId > 0) { DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE); @@ -454,10 +558,91 @@ public class AboutActivity extends BossScreenActivity { .putLong(KEY_ACTIVE_DOWNLOAD_ID, activeDownloadId) .putLong(KEY_COMPLETED_DOWNLOAD_ID, completedDownloadId) .putString(KEY_LAST_DOWNLOAD_FILE_NAME, lastDownloadFileName) + .putString(KEY_LAST_DOWNLOAD_VERSION, lastDownloadVersion) .putInt(KEY_LAST_DOWNLOAD_STATUS, lastDownloadStatus) .apply(); } + private void invalidateStaleDownloadedApk(@Nullable JSONObject availableRelease) { + long[] downloadIds = collectStaleDownloadIdsForRemoval( + availableRelease, + lastDownloadFileName, + lastDownloadVersion, + downloadedApkUri != null || completedDownloadId > 0 || activeDownloadId > 0, + activeDownloadId, + completedDownloadId + ); + if (downloadIds.length == 0) { + return; + } + removeStaleDownloadTasks(downloadIds); + clearLocalOtaDownloadState(); + } + + private void removeStaleDownloadTasks(long[] downloadIds) { + if (downloadIds.length == 0) { + return; + } + DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE); + if (manager == null) { + return; + } + try { + manager.remove(downloadIds); + } catch (RuntimeException ignored) { + // Keep UI state recoverable even if DownloadManager cleanup fails. + } + } + + static long[] collectStaleDownloadIdsForRemoval( + @Nullable JSONObject availableRelease, + @Nullable String downloadedFileName, + @Nullable String downloadedVersion, + boolean hasLocalDownload, + long activeId, + long completedId + ) { + if (!hasLocalDownload) { + return new long[0]; + } + if (isDownloadedReleaseCurrent(availableRelease, downloadedFileName, downloadedVersion)) { + return new long[0]; + } + return collectDownloadIdsForRemoval(activeId, completedId); + } + + private static long[] collectDownloadIdsForRemoval(long activeId, long completedId) { + if (activeId > 0 && completedId > 0) { + if (activeId == completedId) { + return new long[]{activeId}; + } + return new long[]{activeId, completedId}; + } + if (activeId > 0) { + return new long[]{activeId}; + } + if (completedId > 0) { + return new long[]{completedId}; + } + return new long[0]; + } + + private static boolean isDownloadedReleaseCurrent( + @Nullable JSONObject availableRelease, + @Nullable String downloadedFileName, + @Nullable String downloadedVersion + ) { + if (availableRelease == null) { + return false; + } + String releaseFileName = availableRelease.optString("packageFileName", ""); + String releaseVersion = availableRelease.optString("version", ""); + if (releaseFileName.isEmpty() || releaseVersion.isEmpty()) { + return false; + } + return releaseFileName.equals(downloadedFileName) && releaseVersion.equals(downloadedVersion); + } + @Nullable private DownloadProgressSnapshot queryDownloadProgress(long downloadId) { DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE); diff --git a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java index 2d13063..6401f5e 100644 --- a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java @@ -49,13 +49,11 @@ public class AiAccountsActivity extends BossScreenActivity { JSONArray accounts = payload.optJSONArray("accounts"); JSONObject activeIdentity = payload.optJSONObject("activeIdentity"); replaceContent(); - appendContent(BossUi.buildListRow( + appendContent(BossUi.buildSoftPanel( this, - "账号管理", - "管理主 GPT、备用 GPT 与 API 容灾。", - "支持新增、编辑、激活、校验和删除。", - null, - null + "AI 账号", + "这里统一管理主 GPT、备用 GPT 与 API 容灾账号。", + "轻点条目可编辑,按钮可切换、校验或删除。" )); appendContent(buildActiveIdentityCard(activeIdentity)); appendContent(buildAccountsSection(accounts)); @@ -64,25 +62,29 @@ public class AiAccountsActivity extends BossScreenActivity { private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) { if (activeIdentity == null) { - return BossUi.buildListRow(this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。", null, null); + return BossUi.buildSoftPanel(this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。"); } - String subtitle = activeIdentity.optString("label", "AI 账号") - + " · " + activeIdentity.optString("displayName", "-"); - String meta = activeIdentity.optString("roleLabel", "-") - + " · " + activeIdentity.optString("providerLabel", "-") - + " · " + activeIdentity.optString("statusLabel", "-"); - return BossUi.buildListRow(this, "当前主控身份", subtitle, meta, "当前", null); + String body = activeIdentity.optString("label", "AI 账号") + + " · " + activeIdentity.optString("displayName", "-") + + "\n" + activeIdentity.optString("roleLabel", "-") + + " · " + activeIdentity.optString("providerLabel", "-"); + return BossUi.buildSoftPanel( + this, + "当前主控身份", + body, + activeIdentity.optString("statusLabel", "-") + ); } private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) { LinearLayout section = new LinearLayout(this); section.setOrientation(LinearLayout.VERTICAL); - section.addView(BossUi.buildListRow( + section.addView(BossUi.buildWechatMenuRow( this, "账号列表", accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点开可编辑,按钮可激活、校验或删除。", - "当前 API:/api/v1/accounts", + null, null, null )); @@ -116,7 +118,7 @@ public class AiAccountsActivity extends BossScreenActivity { LinearLayout card = new LinearLayout(this); card.setOrientation(LinearLayout.VERTICAL); - card.addView(BossUi.buildListRow( + card.addView(BossUi.buildWechatMenuRow( this, account.optString("label", "未命名账号"), subtitle.toString(), @@ -125,22 +127,16 @@ public class AiAccountsActivity extends BossScreenActivity { v -> openAccountEditor(account, null) )); - Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控"); + Button activate = BossUi.buildMiniActionButton(this, account.optBoolean("isActive") ? "当前主控" : "设为当前", !account.optBoolean("isActive")); activate.setEnabled(!account.optBoolean("isActive")); activate.setOnClickListener(v -> activateAccount(account)); - card.addView(activate); - Button validate = BossUi.buildSecondaryButton(this, "校验连接"); + Button validate = BossUi.buildMiniActionButton(this, "校验连接", false); 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, "删除账号"); + Button delete = BossUi.buildMiniActionButton(this, "删除账号", false); delete.setOnClickListener(v -> confirmDeleteAccount(account)); - card.addView(delete); + card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete)); return card; } @@ -182,18 +178,18 @@ public class AiAccountsActivity extends BossScreenActivity { 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); + form.addView(BossUi.buildFormCell(this, "标签", "例如 主 GPT", labelInput)); + form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput)); + form.addView(BossUi.buildFormCell(this, "账号标识", "邮箱、登录名或备注信息", accountIdentifierInput)); + form.addView(BossUi.buildFormCell(this, "节点 ID", "Master Codex Node 的唯一标识", nodeIdInput)); + form.addView(BossUi.buildFormCell(this, "节点名称", "用于快速识别节点", nodeLabelInput)); + form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput)); + form.addView(BossUi.buildFormCell(this, "API Key", "仅 OpenAI API 模式需要", apiKeyInput)); + form.addView(BossUi.buildFormCell(this, "登录状态备注", "可记录 Plus、有无风控等状态", loginStatusInput)); + form.addView(BossUi.buildFormCell(this, "账号角色", null, roleSpinner)); + form.addView(BossUi.buildFormCell(this, "提供方", null, providerSpinner)); + form.addView(BossUi.buildFormCell(this, "启用状态", null, enabledSwitch)); + form.addView(BossUi.buildFormCell(this, "保存后动作", null, setActiveSwitch)); new AlertDialog.Builder(this) .setTitle(existing == null ? "新增 AI 账号" : "编辑 AI 账号") diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index fec7ca9..087ebf0 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -72,6 +72,21 @@ public class BossApiClient { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null); } + public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("name", name); + payload.put("mode", group ? "group" : "thread"); + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/rename", payload); + } + + public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/group-chat", payload == null ? new JSONObject() : payload); + } + + public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null); + } + public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put("body", body); diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index f115cc7..4f2e1f4 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -1,14 +1,20 @@ package com.hyzq.boss; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.graphics.Color; import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; import android.text.TextUtils; +import android.view.animation.AccelerateDecelerateInterpolator; import android.view.Gravity; import android.view.View; import android.widget.Button; import android.widget.EditText; +import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; @@ -16,6 +22,22 @@ import android.widget.Toast; import androidx.annotation.Nullable; public final class BossUi { + private static final int[] AVATAR_BG_COLORS = { + Color.parseColor("#1EC76F"), + Color.parseColor("#DFF5EC"), + Color.parseColor("#DCEEF6"), + Color.parseColor("#FCE5BF") + }; + private static final int[] ACTIVITY_ICON_COLORS = { + Color.parseColor("#C4CCC8"), + Color.parseColor("#B5C0BA"), + Color.parseColor("#A7B3AD"), + Color.parseColor("#98A6A0") + }; + private static final int DEVICE_STATUS_ONLINE = Color.parseColor("#18B85A"); + private static final int DEVICE_STATUS_ABNORMAL = Color.parseColor("#FF5A5A"); + private static final int DEVICE_STATUS_OFFLINE = Color.parseColor("#A7AFB7"); + private BossUi() {} public static LinearLayout buildListRow( @@ -167,10 +189,482 @@ public final class BossUi { return buildListRow(context, title, description, null, badge, listener); } + public static LinearLayout buildWechatMenuRow( + Context context, + String title, + @Nullable String subtitle, + @Nullable String meta, + @Nullable String badge, + @Nullable View.OnClickListener listener + ) { + return buildListRow(context, title, subtitle, meta, badge, listener); + } + + public static LinearLayout buildSimpleProfileHeader( + Context context, + String name, + String subtitle, + @Nullable String detail + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.HORIZONTAL); + card.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 12); + card.setLayoutParams(params); + card.setPadding(dp(context, 16), dp(context, 16), dp(context, 16), dp(context, 16)); + card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18))); + card.setElevation(dp(context, 1)); + + TextView avatar = new TextView(context); + LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, 64), dp(context, 64)); + avatar.setLayoutParams(avatarParams); + avatar.setGravity(Gravity.CENTER); + avatar.setText(firstLetter(name)); + avatar.setTextSize(28); + avatar.setTypeface(Typeface.DEFAULT_BOLD); + avatar.setTextColor(context.getColor(R.color.boss_green)); + avatar.setBackground(createRoundedBackground(Color.parseColor("#DFF3E8"), dp(context, 18))); + card.addView(avatar); + + LinearLayout textWrap = new LinearLayout(context); + textWrap.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + textParams.leftMargin = dp(context, 14); + textWrap.setLayoutParams(textParams); + + TextView titleView = new TextView(context); + titleView.setText(TextUtils.isEmpty(name) ? "我的" : name); + titleView.setTextSize(17); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + textWrap.addView(titleView); + + TextView subtitleView = new TextView(context); + subtitleView.setText(subtitle); + subtitleView.setTextSize(13); + subtitleView.setTextColor(context.getColor(R.color.boss_text_muted)); + subtitleView.setPadding(0, dp(context, 4), 0, 0); + textWrap.addView(subtitleView); + + if (!TextUtils.isEmpty(detail)) { + TextView detailView = new TextView(context); + detailView.setText(detail); + detailView.setTextSize(12); + detailView.setTextColor(context.getColor(R.color.boss_text_soft)); + detailView.setPadding(0, dp(context, 6), 0, 0); + textWrap.addView(detailView); + } + + card.addView(textWrap); + return card; + } + + public static LinearLayout buildFormCell( + Context context, + String label, + @Nullable String helper, + View field + ) { + LinearLayout cell = new LinearLayout(context); + cell.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 10); + cell.setLayoutParams(params); + cell.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14)); + cell.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18))); + + TextView labelView = new TextView(context); + labelView.setText(label); + labelView.setTextSize(14); + labelView.setTypeface(Typeface.DEFAULT_BOLD); + labelView.setTextColor(context.getColor(R.color.boss_text_primary)); + cell.addView(labelView); + + if (!TextUtils.isEmpty(helper)) { + TextView helperView = new TextView(context); + helperView.setText(helper); + helperView.setTextSize(12); + helperView.setTextColor(context.getColor(R.color.boss_text_soft)); + helperView.setPadding(0, dp(context, 4), 0, dp(context, 10)); + cell.addView(helperView); + } + + field.setPadding(field.getPaddingLeft(), field.getPaddingTop(), field.getPaddingRight(), field.getPaddingBottom()); + cell.addView(field); + return cell; + } + + public static LinearLayout buildDeviceCard( + Context context, + WechatSurfaceMapper.DeviceRow row, + @Nullable View.OnClickListener listener, + @Nullable View.OnLongClickListener longClickListener + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.HORIZONTAL); + card.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 12); + card.setLayoutParams(params); + card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14)); + card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18))); + card.setElevation(dp(context, 1)); + if (listener != null) { + card.setClickable(true); + card.setFocusable(true); + card.setOnClickListener(listener); + } + if (longClickListener != null) { + card.setOnLongClickListener(longClickListener); + } + + FrameLayout avatarWrap = new FrameLayout(context); + LinearLayout.LayoutParams avatarWrapParams = new LinearLayout.LayoutParams(dp(context, 56), dp(context, 56)); + avatarWrap.setLayoutParams(avatarWrapParams); + + TextView avatar = new TextView(context); + FrameLayout.LayoutParams avatarParams = new FrameLayout.LayoutParams(dp(context, 52), dp(context, 52)); + avatar.setLayoutParams(avatarParams); + avatar.setGravity(Gravity.CENTER); + avatar.setText(firstLetter(firstNonEmpty(row.avatarLabel, row.title, "设"))); + avatar.setTextSize(22); + avatar.setTypeface(Typeface.DEFAULT_BOLD); + avatar.setTextColor(resolveDeviceAccentColor(row.statusKey)); + avatar.setBackground(createRoundedBackground(resolveDeviceAvatarBackground(row.statusKey), dp(context, 18))); + avatarWrap.addView(avatar); + + View statusDot = new View(context); + FrameLayout.LayoutParams dotParams = new FrameLayout.LayoutParams(dp(context, 10), dp(context, 10), Gravity.END | Gravity.CENTER_VERTICAL); + dotParams.rightMargin = dp(context, 1); + statusDot.setLayoutParams(dotParams); + statusDot.setBackground(createRoundedBackground(resolveDeviceAccentColor(row.statusKey), dp(context, 5))); + avatarWrap.addView(statusDot); + + card.addView(avatarWrap); + + LinearLayout textWrap = new LinearLayout(context); + textWrap.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + textParams.leftMargin = dp(context, 12); + textWrap.setLayoutParams(textParams); + + TextView titleView = new TextView(context); + titleView.setText(TextUtils.isEmpty(row.title) ? "设备" : row.title); + titleView.setTextSize(17); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + titleView.setMaxLines(1); + titleView.setEllipsize(TextUtils.TruncateAt.END); + textWrap.addView(titleView); + + TextView subtitleView = new TextView(context); + subtitleView.setText(row.subtitle); + subtitleView.setTextSize(13); + subtitleView.setTextColor(context.getColor(R.color.boss_text_muted)); + subtitleView.setPadding(0, dp(context, 5), 0, 0); + subtitleView.setMaxLines(2); + subtitleView.setEllipsize(TextUtils.TruncateAt.END); + textWrap.addView(subtitleView); + + TextView metaView = new TextView(context); + metaView.setText(row.meta); + metaView.setTextSize(12); + metaView.setTextColor(resolveDeviceMetaColor(context, row.statusKey)); + metaView.setPadding(0, dp(context, 6), 0, 0); + metaView.setMaxLines(2); + metaView.setEllipsize(TextUtils.TruncateAt.END); + textWrap.addView(metaView); + + card.addView(textWrap); + + if (listener != null) { + TextView arrowView = new TextView(context); + arrowView.setText("›"); + arrowView.setTextSize(18); + arrowView.setTextColor(context.getColor(R.color.boss_text_soft)); + arrowView.setPadding(dp(context, 8), 0, 0, 0); + card.addView(arrowView); + } + return card; + } + + public static LinearLayout buildSoftPanel( + Context context, + String title, + String body, + @Nullable String meta + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 12); + card.setLayoutParams(params); + card.setPadding(dp(context, 16), dp(context, 16), dp(context, 16), dp(context, 16)); + card.setBackground(createRoundedBackground(Color.parseColor("#E7F5ED"), dp(context, 18))); + + TextView titleView = new TextView(context); + titleView.setText(title); + titleView.setTextSize(16); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + card.addView(titleView); + + TextView bodyView = new TextView(context); + bodyView.setText(body); + bodyView.setTextSize(14); + bodyView.setLineSpacing(0f, 1.2f); + bodyView.setTextColor(context.getColor(R.color.boss_text_primary)); + bodyView.setPadding(0, dp(context, 8), 0, 0); + card.addView(bodyView); + + if (!TextUtils.isEmpty(meta)) { + 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(metaView); + } + return card; + } + + public static LinearLayout buildInlineActionRow(Context context, Button... buttons) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + rowParams.leftMargin = dp(context, 12); + rowParams.rightMargin = dp(context, 12); + rowParams.bottomMargin = dp(context, 12); + row.setLayoutParams(rowParams); + + for (int i = 0; i < buttons.length; i++) { + Button button = buttons[i]; + LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + if (i > 0) { + buttonParams.leftMargin = dp(context, 8); + } + button.setLayoutParams(buttonParams); + row.addView(button); + } + return row; + } + + public static Button buildMiniActionButton(Context context, String label, boolean primary) { + Button button = new Button(context); + button.setText(label); + button.setAllCaps(false); + button.setTextSize(13); + button.setPadding(dp(context, 10), dp(context, 10), dp(context, 10), dp(context, 10)); + button.setTextColor(context.getColor(primary ? R.color.boss_surface : R.color.boss_green)); + button.setBackgroundResource(primary ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button); + return button; + } + + public static LinearLayout buildConversationRow( + Context context, + WechatSurfaceMapper.ConversationRow row, + @Nullable View.OnClickListener listener + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.HORIZONTAL); + card.setGravity(Gravity.TOP); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 12); + card.setLayoutParams(params); + card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14)); + card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18))); + card.setElevation(dp(context, 1)); + if (listener != null) { + card.setClickable(true); + card.setFocusable(true); + card.setOnClickListener(listener); + } + + card.addView(buildConversationAvatar(context, row)); + + LinearLayout centerColumn = new LinearLayout(context); + centerColumn.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams centerParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + centerParams.leftMargin = dp(context, 12); + centerParams.rightMargin = dp(context, 8); + centerColumn.setLayoutParams(centerParams); + + TextView titleView = new TextView(context); + titleView.setText(TextUtils.isEmpty(row.threadTitle) ? "未命名会话" : row.threadTitle); + titleView.setTextSize(18); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + titleView.setMaxLines(1); + titleView.setEllipsize(TextUtils.TruncateAt.END); + centerColumn.addView(titleView); + + if (!TextUtils.isEmpty(row.folderLabel)) { + TextView folderView = new TextView(context); + folderView.setText(row.folderLabel); + folderView.setTextSize(13); + folderView.setTextColor(context.getColor(R.color.boss_text_muted)); + folderView.setPadding(0, dp(context, 4), 0, 0); + folderView.setMaxLines(1); + folderView.setEllipsize(TextUtils.TruncateAt.END); + centerColumn.addView(folderView); + } + + TextView previewView = new TextView(context); + previewView.setText(TextUtils.isEmpty(row.lastMessagePreview) ? "暂无消息" : row.lastMessagePreview); + previewView.setTextSize(14); + previewView.setTextColor(context.getColor(R.color.boss_text_soft)); + previewView.setPadding(0, dp(context, 5), 0, 0); + previewView.setMaxLines(2); + previewView.setEllipsize(TextUtils.TruncateAt.END); + centerColumn.addView(previewView); + + card.addView(centerColumn); + + LinearLayout trailingColumn = new LinearLayout(context); + trailingColumn.setOrientation(LinearLayout.VERTICAL); + trailingColumn.setGravity(Gravity.END); + trailingColumn.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT + )); + + if (!TextUtils.isEmpty(row.topPinnedLabel)) { + TextView pinnedView = new TextView(context); + pinnedView.setText(row.topPinnedLabel); + pinnedView.setTextSize(11); + pinnedView.setTypeface(Typeface.DEFAULT_BOLD); + pinnedView.setTextColor(context.getColor(R.color.boss_green)); + pinnedView.setBackground(createRoundedBackground(Color.parseColor("#E7F7EE"), dp(context, 9))); + pinnedView.setPadding(dp(context, 7), dp(context, 3), dp(context, 7), dp(context, 3)); + trailingColumn.addView(pinnedView); + } + + TextView timeView = new TextView(context); + timeView.setText(TextUtils.isEmpty(row.timeLabel) ? "--:--" : row.timeLabel); + timeView.setTextSize(12); + timeView.setTextColor(context.getColor(R.color.boss_text_muted)); + timeView.setPadding(0, dp(context, TextUtils.isEmpty(row.topPinnedLabel) ? 2 : 8), 0, 0); + trailingColumn.addView(timeView); + + if (row.unreadCount > 0) { + TextView unreadView = new TextView(context); + unreadView.setText(row.unreadCount > 99 ? "99+" : String.valueOf(row.unreadCount)); + unreadView.setTextSize(11); + unreadView.setTypeface(Typeface.DEFAULT_BOLD); + unreadView.setTextColor(Color.WHITE); + unreadView.setGravity(Gravity.CENTER); + unreadView.setMinWidth(dp(context, 20)); + unreadView.setBackground(createRoundedBackground(Color.parseColor("#FF5A5A"), dp(context, 10))); + unreadView.setPadding(dp(context, 6), dp(context, 2), dp(context, 6), dp(context, 2)); + LinearLayout.LayoutParams unreadParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + unreadParams.topMargin = dp(context, 8); + unreadView.setLayoutParams(unreadParams); + trailingColumn.addView(unreadView); + } + + View spacer = new View(context); + spacer.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + 0, + 1f + )); + trailingColumn.addView(spacer); + + LinearLayout activityWrap = new LinearLayout(context); + activityWrap.setOrientation(LinearLayout.HORIZONTAL); + activityWrap.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams activityParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + activityParams.topMargin = dp(context, 10); + activityWrap.setLayoutParams(activityParams); + int activityCount = Math.max(0, Math.min(row.activityIconCount, WechatSurfaceMapper.maxConversationActivityIcons())); + for (int i = 0; i < activityCount; i++) { + View dot = buildAnimatedActivityDot(context, i); + if (i > 0) { + LinearLayout.LayoutParams dotParams = (LinearLayout.LayoutParams) dot.getLayoutParams(); + dotParams.leftMargin = dp(context, 4); + dot.setLayoutParams(dotParams); + } + activityWrap.addView(dot); + } + trailingColumn.addView(activityWrap); + + card.addView(trailingColumn); + return card; + } + public static LinearLayout buildEmptyCard(Context context, String text) { return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。"); } + public static TextView buildHintPill(Context context, String text) { + TextView pill = new TextView(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 12); + pill.setLayoutParams(params); + pill.setText(text); + pill.setTextSize(12); + pill.setTextColor(context.getColor(R.color.boss_text_muted)); + pill.setPadding(dp(context, 12), dp(context, 7), dp(context, 12), dp(context, 7)); + pill.setBackground(createRoundedBackground(Color.parseColor("#F4F5F2"), dp(context, 14))); + return pill; + } + public static LinearLayout buildMessageBubble( Context context, String senderLabel, @@ -312,4 +806,202 @@ public final class BossUi { public static int dp(Context context, int value) { return Math.round(value * context.getResources().getDisplayMetrics().density); } + + private static View buildConversationAvatar(Context context, WechatSurfaceMapper.ConversationRow row) { + if (!row.isGroup) { + return buildAvatarCircle( + context, + firstNonEmpty(row.avatarPrimary, row.threadTitle, "会"), + AVATAR_BG_COLORS[0], + Color.WHITE, + 52 + ); + } + + FrameLayout groupWrap = new FrameLayout(context); + LinearLayout.LayoutParams wrapParams = new LinearLayout.LayoutParams(dp(context, 52), dp(context, 52)); + groupWrap.setLayoutParams(wrapParams); + + GradientDrawable bg = createRoundedBackground(Color.parseColor("#F2F6F3"), dp(context, 18)); + bg.setStroke(dp(context, 1), Color.parseColor("#E5ECE7")); + groupWrap.setBackground(bg); + + int visibleCount = Math.min(row.groupAvatarMembers.length, 4); + if (visibleCount == 0) { + groupWrap.addView(buildCenteredAvatarTile(context, "群", AVATAR_BG_COLORS[1], 32, 10, 10)); + return groupWrap; + } + + int[][] offsets = { + {4, 4}, + {24, 4}, + {4, 24}, + {24, 24} + }; + for (int i = 0; i < visibleCount; i++) { + WechatSurfaceMapper.GroupAvatarMember member = row.groupAvatarMembers[i]; + groupWrap.addView(buildCenteredAvatarTile( + context, + firstNonEmpty(member.avatarLabel, member.title, "群"), + AVATAR_BG_COLORS[(i + 1) % AVATAR_BG_COLORS.length], + 22, + offsets[i][0], + offsets[i][1] + )); + } + return groupWrap; + } + + private static TextView buildAvatarCircle( + Context context, + String labelSource, + int backgroundColor, + int textColor, + int sizeDp + ) { + TextView avatarView = new TextView(context); + LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, sizeDp), dp(context, sizeDp)); + avatarView.setLayoutParams(avatarParams); + avatarView.setGravity(Gravity.CENTER); + avatarView.setText(firstLetter(labelSource)); + avatarView.setTextSize(sizeDp >= 48 ? 24 : 12); + avatarView.setTypeface(Typeface.DEFAULT_BOLD); + avatarView.setTextColor(textColor); + avatarView.setBackground(createRoundedBackground(backgroundColor, dp(context, sizeDp / 2))); + return avatarView; + } + + private static View buildCenteredAvatarTile( + Context context, + String labelSource, + int backgroundColor, + int sizeDp, + int leftDp, + int topDp + ) { + TextView tile = buildAvatarCircle(context, labelSource, backgroundColor, context.getColor(R.color.boss_text_primary), sizeDp); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(dp(context, sizeDp), dp(context, sizeDp)); + params.leftMargin = dp(context, leftDp); + params.topMargin = dp(context, topDp); + tile.setLayoutParams(params); + return tile; + } + + private static GradientDrawable createRoundedBackground(int color, int radiusPx) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(color); + drawable.setCornerRadius(radiusPx); + return drawable; + } + + private static int resolveDeviceAccentColor(String statusKey) { + if ("online".equals(statusKey)) { + return DEVICE_STATUS_ONLINE; + } + if ("abnormal".equals(statusKey)) { + return DEVICE_STATUS_ABNORMAL; + } + return DEVICE_STATUS_OFFLINE; + } + + private static int resolveDeviceAvatarBackground(String statusKey) { + if ("online".equals(statusKey)) { + return Color.parseColor("#E5F6EC"); + } + if ("abnormal".equals(statusKey)) { + return Color.parseColor("#FCE9E7"); + } + return Color.parseColor("#F2F4F7"); + } + + private static int resolveDeviceMetaColor(Context context, String statusKey) { + if ("online".equals(statusKey)) { + return context.getColor(R.color.boss_text_muted); + } + return resolveDeviceAccentColor(statusKey); + } + + private static View buildAnimatedActivityDot(Context context, int index) { + View dot = new View(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(context, 7), dp(context, 7)); + dot.setLayoutParams(params); + + GradientDrawable drawable = createRoundedBackground( + ACTIVITY_ICON_COLORS[index % ACTIVITY_ICON_COLORS.length], + dp(context, 4) + ); + dot.setBackground(drawable); + dot.setAlpha(0.35f); + dot.setScaleX(0.82f); + dot.setScaleY(0.82f); + + final AnimatorSet[] animatorRef = new AnimatorSet[1]; + dot.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + cancelConversationActivityAnimator(animatorRef); + animatorRef[0] = createConversationActivityAnimator(v, index); + animatorRef[0].start(); + } + + @Override + public void onViewDetachedFromWindow(View v) { + cancelConversationActivityAnimator(animatorRef); + v.animate().cancel(); + v.setAlpha(0.35f); + v.setScaleX(0.82f); + v.setScaleY(0.82f); + } + }); + return dot; + } + + private static AnimatorSet createConversationActivityAnimator(View dot, int index) { + long durationMs = 850L; + long startDelayMs = index * 120L; + ObjectAnimator alpha = ObjectAnimator.ofFloat(dot, View.ALPHA, 0.35f, 1f, 0.35f); + alpha.setDuration(durationMs); + alpha.setStartDelay(startDelayMs); + alpha.setRepeatCount(ObjectAnimator.INFINITE); + + ObjectAnimator scaleX = ObjectAnimator.ofFloat(dot, View.SCALE_X, 0.82f, 1.1f, 0.82f); + scaleX.setDuration(durationMs); + scaleX.setStartDelay(startDelayMs); + scaleX.setRepeatCount(ObjectAnimator.INFINITE); + + ObjectAnimator scaleY = ObjectAnimator.ofFloat(dot, View.SCALE_Y, 0.82f, 1.1f, 0.82f); + scaleY.setDuration(durationMs); + scaleY.setStartDelay(startDelayMs); + scaleY.setRepeatCount(ObjectAnimator.INFINITE); + + AnimatorSet pulse = new AnimatorSet(); + pulse.setInterpolator(new AccelerateDecelerateInterpolator()); + pulse.playTogether(alpha, scaleX, scaleY); + return pulse; + } + + private static void cancelConversationActivityAnimator(AnimatorSet[] animatorRef) { + if (animatorRef[0] != null) { + animatorRef[0].cancel(); + animatorRef[0] = null; + } + } + + private static String firstLetter(String value) { + String text = value == null ? "" : value.trim(); + if (text.isEmpty()) { + return "会"; + } + return text.substring(0, 1); + } + + private static String firstNonEmpty(String first, String second, String fallback) { + if (!TextUtils.isEmpty(first)) { + return first; + } + if (!TextUtils.isEmpty(second)) { + return second; + } + return fallback; + } } diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java new file mode 100644 index 0000000..e162c40 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -0,0 +1,230 @@ +package com.hyzq.boss; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.EditText; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class ConversationInfoActivity 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; + private String projectFolderName; + private int participantCount; + + @Override + protected int getLayoutResId() { + return R.layout.activity_conversation_info; + } + + @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 -> openRenameDialog()); + reload(); + } + + @Override + protected void reload() { + if (projectId == null || projectId.isEmpty()) { + showMessage("缺少 projectId"); + finish(); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId); + if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message()); + BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); + if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); + runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "会话信息加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderConversation(JSONObject detail, JSONObject participantsPayload) { + replaceContent(); + JSONObject project = detail.optJSONObject("project"); + JSONArray participants = participantsPayload.optJSONArray("participants"); + + if (project == null) { + appendContent(BossUi.buildEmptyCard(this, "会话不存在。")); + setRefreshing(false); + return; + } + + projectName = project.optString("name", projectName == null ? "会话信息" : projectName); + JSONObject threadMeta = project.optJSONObject("threadMeta"); + projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + participantCount = participants == null ? 0 : participants.length(); + configureScreen("会话信息", buildSubtitle(threadMeta, participantCount)); + + appendContent(BossUi.buildCard( + this, + projectName, + buildDetailBody(project, threadMeta), + buildDetailMeta(projectId, projectFolderName, participantCount) + )); + + appendContent(BossUi.buildMenuRow( + this, + "发起群聊", + "从当前会话选择其他线程,创建新的独立群聊", + null, + v -> openGroupCreate() + )); + + appendContent(BossUi.buildCard( + this, + "参与设备 / 线程", + "以下线程参与当前会话,点击可查看对应项目详情。", + participantCount == 0 ? "当前没有可展示的参与线程。" : "共 " + participantCount + " 个参与线程" + )); + + if (participants == null || participants.length() == 0) { + appendContent(BossUi.buildEmptyCard(this, "当前没有参与线程信息。")); + } else { + for (int i = 0; i < participants.length(); i++) { + JSONObject participant = participants.optJSONObject(i); + if (participant == null) continue; + appendContent(buildParticipantRow(participant)); + } + } + + setRefreshing(false); + } + + private LinearLayout buildParticipantRow(JSONObject participant) { + boolean sourceProject = participant.optBoolean("isSourceProject", false); + String participantProjectId = participant.optString("projectId", ""); + String title = participant.optString("threadDisplayName", "未命名线程"); + String subtitle = participant.optString("folderName", ""); + String meta = participant.optString("deviceId", ""); + if (!participant.optString("threadId", "").isEmpty()) { + meta = meta.isEmpty() ? participant.optString("threadId", "") : meta + " · " + participant.optString("threadId", ""); + } + return BossUi.buildListRow( + this, + title, + subtitle, + meta, + sourceProject ? "来源" : null, + v -> openProject(participantProjectId, title) + ); + } + + private void openProject(String targetProjectId, String targetProjectName) { + if (targetProjectId == null || targetProjectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + Intent intent = new Intent(this, ProjectDetailActivity.class); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName); + startActivity(intent); + } + + private void openGroupCreate() { + if (projectId == null || projectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + Intent intent = new Intent(this, GroupCreateActivity.class); + intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, projectId); + intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, projectName); + startActivity(intent); + } + + private void openRenameDialog() { + final EditText input = BossUi.buildInput(this, "线程名", false); + input.setText(projectName == null ? "" : projectName); + new AlertDialog.Builder(this) + .setTitle("重命名会话") + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveConversationName(input.getText().toString().trim())) + .show(); + } + + private void saveConversationName(String name) { + if (name.isEmpty()) { + showMessage("线程名不能为空"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, false); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + Intent result = new Intent(); + result.putExtra(EXTRA_PROJECT_NAME, name); + setResult(RESULT_OK, result); + showMessage("线程名已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + private String buildSubtitle(@Nullable JSONObject threadMeta, int count) { + String folder = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + String suffix = count <= 0 ? "暂无参与线程" : count + " 个参与线程"; + if (folder.isEmpty()) { + return suffix; + } + return folder + " · " + suffix; + } + + private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) { + String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", ""); + String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + String deviceCount = project.optJSONArray("deviceIds") == null ? "0" : String.valueOf(project.optJSONArray("deviceIds").length()); + StringBuilder builder = new StringBuilder(); + builder.append("线程 ID:").append(threadId.isEmpty() ? project.optString("id", "-") : threadId); + builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName); + builder.append("\n绑定设备:").append(deviceCount); + builder.append("\n群聊状态:").append(project.optBoolean("isGroup", false) ? "群聊" : "单线程"); + return builder.toString(); + } + + private String buildDetailMeta(String projectId, String folderName, int count) { + StringBuilder builder = new StringBuilder(); + if (!projectId.isEmpty()) { + builder.append("project ").append(projectId); + } + if (!folderName.isEmpty()) { + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append(folderName); + } + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append(count <= 0 ? "暂无参与线程" : "参与线程 " + count); + return builder.toString(); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java index a622e56..2c0bb91 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java @@ -22,8 +22,8 @@ public class DeviceDetailActivity extends BossScreenActivity { super.onCreate(savedInstanceState); deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID); deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME); - configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情"); - hideHeaderAction(); + configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态与绑定项目"); + setHeaderAction("编辑", v -> openEditDialog()); reload(); } @@ -56,19 +56,25 @@ public class DeviceDetailActivity extends BossScreenActivity { } deviceName = device.optString("name", deviceId); - configureScreen(deviceName, device.optString("endpoint", "设备详情")); + configureScreen(deviceName, "设备状态与绑定项目"); WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device); - appendContent(BossUi.buildListRow( + appendContent(BossUi.buildDeviceCard( this, - summary.title.isEmpty() ? "设备" : summary.title, - summary.subtitle, - summary.meta, + WechatSurfaceMapper.toDeviceRow(device), null, null )); - + if (summary.meta != null && !summary.meta.isEmpty()) { + appendContent(BossUi.buildWechatMenuRow( + this, + "设备说明", + summary.meta, + null, + null, + null + )); + } appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills())); - appendContent(BossUi.buildMenuRow(this, "编辑", "修改设备名称、备注和项目列表", null, v -> openEditDialog())); setRefreshing(false); } diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java index 3613655..a1ae953 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java @@ -19,7 +19,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - configureScreen("添加设备", "通过 pairing code 或 token 把新设备接入"); + configureScreen("添加设备", "填写设备信息后生成配对草稿"); hideHeaderAction(); buildForm(); } @@ -38,23 +38,24 @@ public class DeviceEnrollmentActivity extends BossScreenActivity { noteInput = BossUi.buildInput(this, "备注", true); projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true); + android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "生成绑定草稿"); + submitButton.setOnClickListener(v -> submitEnrollment()); + replaceContent( - BossUi.buildCard( + BossUi.buildSoftPanel( this, - "绑定新设备", - "支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入。", - "当前原生页会直接调用 /api/v1/devices/enrollments" + "接入新设备", + "支持通过 pairing code 或 token 接入 Mac、Windows、云端节点。", + "生成后把配对码交给设备端即可完成绑定。" ), - nameInput, - avatarInput, - accountInput, - endpointInput, - noteInput, - projectsInput, - BossUi.buildPrimaryButton(this, "生成绑定草稿") + BossUi.buildFormCell(this, "设备名称", "例如 Mac Studio 或 Windows GPU", nameInput), + BossUi.buildFormCell(this, "头像字符", "会显示在设备卡片左侧", avatarInput), + BossUi.buildFormCell(this, "所属账号", "默认使用当前登录账号", accountInput), + BossUi.buildFormCell(this, "设备地址", "例如 mac://kris.local", endpointInput), + BossUi.buildFormCell(this, "设备备注", "可填写位置、用途或节点说明", noteInput), + BossUi.buildFormCell(this, "项目列表", "多个项目用逗号分隔", projectsInput), + submitButton ); - ((android.widget.Button) contentLayout.getChildAt(contentLayout.getChildCount() - 1)) - .setOnClickListener(v -> submitEnrollment()); } private void submitEnrollment() { @@ -80,7 +81,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity { JSONObject enrollment = response.json.optJSONObject("enrollment"); JSONObject device = response.json.optJSONObject("device"); replaceContent( - BossUi.buildCard( + BossUi.buildSoftPanel( this, "绑定草稿已生成", "设备 " + (device == null ? "-" : device.optString("name", "-")) diff --git a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java new file mode 100644 index 0000000..f83a39b --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java @@ -0,0 +1,356 @@ +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 org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public class GroupCreateActivity extends BossScreenActivity { + public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id"; + public static final String EXTRA_SOURCE_PROJECT_NAME = "source_project_name"; + + private final List candidates = new ArrayList<>(); + private final Set selectedProjectIds = new LinkedHashSet<>(); + private final Set lastCandidateProjectIds = new LinkedHashSet<>(); + + private String sourceProjectId; + private String sourceProjectName; + private String sourceFolderName; + private LinearLayout candidateListLayout; + private Button createButton; + private boolean creatingGroupChat; + private JSONObject cachedParticipantsPayload; + private JSONObject cachedConversationsPayload; + + @Override + protected int getLayoutResId() { + return R.layout.activity_group_create; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + sourceProjectId = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_ID); + sourceProjectName = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_NAME); + configureScreen("发起群聊", sourceProjectName == null ? "从当前会话出发" : sourceProjectName); + reload(); + } + + @Override + protected void reload() { + if (sourceProjectId == null || sourceProjectId.isEmpty()) { + showMessage("缺少 projectId"); + finish(); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId); + if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); + BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations(); + if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message()); + runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "群聊创建页加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderCreatePage(JSONObject participantsPayload, JSONObject conversationsPayload, boolean rebuildCandidates) { + cachedParticipantsPayload = participantsPayload; + cachedConversationsPayload = conversationsPayload; + replaceContent(); + + JSONObject threadMeta = participantsPayload.optJSONObject("threadMeta"); + JSONArray participants = participantsPayload.optJSONArray("participants"); + sourceFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + sourceProjectName = threadMeta == null + ? sourceProjectName + : threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName); + + appendContent(BossUi.buildCard( + this, + "新建独立群聊", + "群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。", + buildSourceMeta(threadMeta, participants) + )); + + appendContent(BossUi.buildCard( + this, + sourceProjectName, + buildSourceBody(threadMeta, participants), + sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName) + )); + + if (rebuildCandidates) { + List selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId); + List nextCandidates = new ArrayList<>(selectableConversations.size()); + Set nextCandidateProjectIds = new LinkedHashSet<>(); + for (JSONObject item : selectableConversations) { + CandidateConversation candidate = new CandidateConversation( + item.optString("projectId", ""), + item.optString("projectTitle", item.optString("threadTitle", "未命名会话")), + item.optString("folderLabel", ""), + item.optString("lastMessagePreview", item.optString("preview", "")), + item.optString("latestReplyLabel", ""), + false + ); + nextCandidates.add(candidate); + nextCandidateProjectIds.add(candidate.projectId); + } + Set currentSelectedProjectIds = new LinkedHashSet<>(selectedProjectIds); + candidates.clear(); + candidates.addAll(nextCandidates); + selectedProjectIds.clear(); + selectedProjectIds.addAll(reconcileSelectedProjectIds( + currentSelectedProjectIds, + lastCandidateProjectIds, + nextCandidateProjectIds + )); + lastCandidateProjectIds.clear(); + lastCandidateProjectIds.addAll(nextCandidateProjectIds); + } + + appendContent(BossUi.buildCard( + this, + "选择其他线程", + candidates.isEmpty() + ? "当前没有可加入的其他线程。" + : selectedProjectIds.isEmpty() + ? "你已取消全部勾选,可继续手动选择。" + : "已保留你当前的勾选状态。", + "已选 " + selectedProjectIds.size() + " 个线程" + )); + + candidateListLayout = new LinearLayout(this); + candidateListLayout.setOrientation(LinearLayout.VERTICAL); + for (CandidateConversation candidate : candidates) { + candidateListLayout.addView(buildCandidateRow(candidate)); + } + if (candidates.isEmpty()) { + candidateListLayout.addView(BossUi.buildEmptyCard(this, "当前没有可选择的其他线程。")); + } + appendContent(candidateListLayout); + + createButton = BossUi.buildPrimaryButton(this, "创建群聊"); + createButton.setOnClickListener(v -> createGroupChat()); + appendContent(createButton); + + Button cancelButton = BossUi.buildSecondaryButton(this, "取消"); + cancelButton.setOnClickListener(v -> finish()); + appendContent(cancelButton); + + setRefreshing(false); + updateCreateButtonState(); + } + + static List collectSelectableConversationItems(@Nullable JSONObject conversationsPayload, String sourceProjectId) { + List result = new ArrayList<>(); + JSONArray conversations = conversationsPayload == null ? null : conversationsPayload.optJSONArray("conversations"); + if (conversations == null) { + return result; + } + for (int i = 0; i < conversations.length(); i++) { + JSONObject item = conversations.optJSONObject(i); + if (item == null) continue; + String projectId = item.optString("projectId", ""); + if (projectId.isEmpty() || sourceProjectId.equals(projectId) || item.optBoolean("isGroup", false)) { + continue; + } + result.add(item); + } + return result; + } + + private LinearLayout buildCandidateRow(CandidateConversation candidate) { + boolean selected = selectedProjectIds.contains(candidate.projectId); + String badge = selected ? "已选" : "未选"; + String subtitle = candidate.folderLabel.isEmpty() ? candidate.latestReplyLabel : candidate.folderLabel; + String meta = candidate.preview; + if (!candidate.latestReplyLabel.isEmpty() && !candidate.latestReplyLabel.equals(candidate.preview)) { + meta = candidate.latestReplyLabel + (meta.isEmpty() ? "" : " · " + meta); + } + return BossUi.buildListRow( + this, + candidate.title, + subtitle, + meta, + badge, + v -> toggleSelection(candidate.projectId) + ); + } + + private void toggleSelection(String projectId) { + if (selectedProjectIds.contains(projectId)) { + selectedProjectIds.remove(projectId); + } else { + selectedProjectIds.add(projectId); + } + refreshCandidateRows(); + updateCreateButtonState(); + } + + private void refreshCandidateRows() { + if (cachedParticipantsPayload == null || cachedConversationsPayload == null) { + return; + } + renderCreatePage(cachedParticipantsPayload, cachedConversationsPayload, false); + } + + private void updateCreateButtonState() { + if (createButton != null) { + boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing(); + createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds)); + createButton.setText(creatingGroupChat ? "创建中..." : "创建群聊"); + } + } + + private void createGroupChat() { + boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing(); + if (refreshing || creatingGroupChat) { + return; + } + if (selectedProjectIds.isEmpty()) { + showMessage("请至少选择一个其他线程"); + return; + } + List memberProjectIdsSnapshot = new ArrayList<>(selectedProjectIds); + creatingGroupChat = true; + setRefreshing(true); + updateCreateButtonState(); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + JSONArray memberProjectIds = new JSONArray(); + for (String projectId : memberProjectIdsSnapshot) { + memberProjectIds.put(projectId); + } + payload.put("memberProjectIds", memberProjectIds); + BossApiClient.ApiResponse response = apiClient.createGroupChat(sourceProjectId, payload); + if (!response.ok()) throw new IllegalStateException(response.message()); + JSONObject project = response.json.optJSONObject("project"); + if (project == null) throw new IllegalStateException("GROUP_CHAT_PROJECT_MISSING"); + String createdProjectId = project.optString("id", ""); + if (createdProjectId.isEmpty()) { + throw new IllegalStateException("GROUP_CHAT_PROJECT_ID_MISSING"); + } + String createdProjectName = project.optString("name", sourceProjectName == null ? "群聊" : sourceProjectName); + runOnUiThread(() -> { + setRefreshing(false); + creatingGroupChat = false; + updateCreateButtonState(); + showMessage("群聊已创建"); + Intent intent = new Intent(this, ProjectDetailActivity.class); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, createdProjectId); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, createdProjectName); + startActivity(intent); + finish(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + creatingGroupChat = false; + setRefreshing(false); + showMessage("创建失败:" + error.getMessage()); + updateCreateButtonState(); + }); + } + }); + } + + static boolean canCreateGroupChat( + boolean refreshing, + boolean creatingGroupChat, + @Nullable Set selectedProjectIds + ) { + return !refreshing + && !creatingGroupChat + && selectedProjectIds != null + && !selectedProjectIds.isEmpty(); + } + + static Set reconcileSelectedProjectIds( + @Nullable Set currentSelectedProjectIds, + @Nullable Set previousCandidateProjectIds, + @Nullable Set nextCandidateProjectIds + ) { + Set reconciled = new LinkedHashSet<>(); + if (nextCandidateProjectIds == null || nextCandidateProjectIds.isEmpty()) { + return reconciled; + } + if (previousCandidateProjectIds == null + || previousCandidateProjectIds.isEmpty() + || !previousCandidateProjectIds.equals(nextCandidateProjectIds)) { + reconciled.addAll(nextCandidateProjectIds); + return reconciled; + } + if (currentSelectedProjectIds == null || currentSelectedProjectIds.isEmpty()) { + return reconciled; + } + for (String projectId : currentSelectedProjectIds) { + if (nextCandidateProjectIds.contains(projectId)) { + reconciled.add(projectId); + } + } + return reconciled; + } + + private String buildSourceMeta(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) { + String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + int count = participants == null ? 0 : participants.length(); + String memberLabel = count <= 0 ? "暂无参与线程" : count + " 个参与线程"; + if (folderName.isEmpty()) { + return memberLabel; + } + return folderName + " · " + memberLabel; + } + + private String buildSourceBody(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) { + String threadId = threadMeta == null ? sourceProjectId : threadMeta.optString("threadId", sourceProjectId); + String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + StringBuilder builder = new StringBuilder(); + builder.append("来源线程:").append(threadId); + builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName); + builder.append("\n参与线程:").append(participants == null ? 0 : participants.length()); + builder.append("\n默认规则:会自动勾选当前会话之外的其他线程"); + return builder.toString(); + } + + private static final class CandidateConversation { + private final String projectId; + private final String title; + private final String folderLabel; + private final String preview; + private final String latestReplyLabel; + private final boolean isGroup; + + private CandidateConversation( + String projectId, + String title, + String folderLabel, + String preview, + String latestReplyLabel, + boolean isGroup + ) { + this.projectId = projectId; + this.title = title; + this.folderLabel = folderLabel; + this.preview = preview; + this.latestReplyLabel = latestReplyLabel; + this.isGroup = isGroup; + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java new file mode 100644 index 0000000..b4d0ad4 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java @@ -0,0 +1,208 @@ +package com.hyzq.boss; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.EditText; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class GroupInfoActivity 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 int getLayoutResId() { + return R.layout.activity_group_info; + } + + @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 -> openRenameDialog()); + reload(); + } + + @Override + protected void reload() { + if (projectId == null || projectId.isEmpty()) { + showMessage("缺少 projectId"); + finish(); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId); + if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message()); + BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); + if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); + runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "群资料加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderGroup(JSONObject detail, JSONObject participantsPayload) { + replaceContent(); + JSONObject project = detail.optJSONObject("project"); + JSONArray participants = participantsPayload.optJSONArray("participants"); + + if (project == null) { + appendContent(BossUi.buildEmptyCard(this, "群聊不存在。")); + setRefreshing(false); + return; + } + + projectName = project.optString("name", projectName == null ? "群聊" : projectName); + JSONObject threadMeta = project.optJSONObject("threadMeta"); + String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + int participantCount = participants == null ? 0 : participants.length(); + configureScreen("群资料", buildSubtitle(folderName, participantCount)); + + appendContent(BossUi.buildCard( + this, + projectName, + buildDetailBody(project, threadMeta), + buildDetailMeta(projectId, folderName, participantCount) + )); + + appendContent(BossUi.buildCard( + this, + "成员线程", + "群聊成员可点击查看对应项目详情。", + participantCount == 0 ? "当前没有成员线程。" : "共 " + participantCount + " 个成员" + )); + + if (participants == null || participants.length() == 0) { + appendContent(BossUi.buildEmptyCard(this, "当前没有群成员信息。")); + } else { + for (int i = 0; i < participants.length(); i++) { + JSONObject participant = participants.optJSONObject(i); + if (participant == null) continue; + appendContent(buildMemberRow(participant)); + } + } + + setRefreshing(false); + } + + private LinearLayout buildMemberRow(JSONObject participant) { + boolean sourceProject = participant.optBoolean("isSourceProject", false); + String participantProjectId = participant.optString("projectId", ""); + String title = participant.optString("threadDisplayName", "未命名线程"); + String subtitle = participant.optString("folderName", ""); + String meta = participant.optString("deviceId", ""); + String threadId = participant.optString("threadId", ""); + if (!threadId.isEmpty()) { + meta = meta.isEmpty() ? threadId : meta + " · " + threadId; + } + return BossUi.buildListRow( + this, + title, + subtitle, + meta, + sourceProject ? "当前" : null, + v -> openProject(participantProjectId, title) + ); + } + + private void openProject(String targetProjectId, String targetProjectName) { + if (targetProjectId == null || targetProjectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + Intent intent = new Intent(this, ProjectDetailActivity.class); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName); + startActivity(intent); + } + + private void openRenameDialog() { + final EditText input = BossUi.buildInput(this, "群名", false); + input.setText(projectName == null ? "" : projectName); + new AlertDialog.Builder(this) + .setTitle("重命名群聊") + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveGroupName(input.getText().toString().trim())) + .show(); + } + + private void saveGroupName(String name) { + if (name.isEmpty()) { + showMessage("群名不能为空"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, true); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + Intent result = new Intent(); + result.putExtra(EXTRA_PROJECT_NAME, name); + setResult(RESULT_OK, result); + showMessage("群名已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + private String buildSubtitle(String folderName, int count) { + String memberLabel = count <= 0 ? "暂无成员" : count + " 个成员"; + if (folderName.isEmpty()) { + return memberLabel; + } + return folderName + " · " + memberLabel; + } + + private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) { + String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", ""); + String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + StringBuilder builder = new StringBuilder(); + builder.append("群聊线程:").append(threadId.isEmpty() ? project.optString("id", "-") : threadId); + builder.append("\n群聊名称:").append(project.optString("name", "群聊")); + builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName); + builder.append("\n协作模式:").append(project.optString("collaborationMode", "development")); + return builder.toString(); + } + + private String buildDetailMeta(String projectId, String folderName, int count) { + StringBuilder builder = new StringBuilder(); + if (!projectId.isEmpty()) { + builder.append("project ").append(projectId); + } + if (!folderName.isEmpty()) { + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append(folderName); + } + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append(count <= 0 ? "暂无成员" : "成员 " + count); + return builder.toString(); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index a0ecd4e..48e34a8 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -32,6 +32,7 @@ public class MainActivity extends AppCompatActivity { private View loginPanel; private View contentPanel; + private TextView loginTitle; private TextView loginHint; private Button loginButton; private ProgressBar loginProgress; @@ -55,6 +56,8 @@ public class MainActivity extends AppCompatActivity { private @Nullable JSONObject otaData; private @Nullable JSONArray conversationsData; private @Nullable JSONArray devicesData; + private @Nullable String boundDeviceId; + private @Nullable String boundDeviceName; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -107,6 +110,7 @@ public class MainActivity extends AppCompatActivity { private void bindViews() { loginPanel = findViewById(R.id.login_panel); contentPanel = findViewById(R.id.content_panel); + loginTitle = findViewById(R.id.login_title); loginHint = findViewById(R.id.login_hint); loginButton = findViewById(R.id.login_button); loginProgress = findViewById(R.id.login_progress); @@ -124,12 +128,15 @@ public class MainActivity extends AppCompatActivity { tabConversations.setText(rootTabs[0]); tabDevices.setText(rootTabs[1]); tabMe.setText(rootTabs[2]); + loginTitle.setText(WechatSurfaceMapper.loginTitle()); + loginHint.setText(WechatSurfaceMapper.loginHintText()); + loginButton.setText(WechatSurfaceMapper.loginButtonLabel()); } private void bindActions() { loginButton.setOnClickListener(v -> performAutoLogin()); backButton.setVisibility(View.GONE); - refreshButton.setOnClickListener(v -> refreshCurrentTab()); + refreshButton.setOnClickListener(v -> handleTopAction()); tabConversations.setOnClickListener(v -> setActiveTab("conversations", true)); tabDevices.setOnClickListener(v -> setActiveTab("devices", true)); tabMe.setOnClickListener(v -> setActiveTab("me", true)); @@ -146,7 +153,7 @@ public class MainActivity extends AppCompatActivity { } private void bootstrapSession() { - showLogin("原生 Android 客户端已启用。点击下方按钮直接进入系统。"); + showLogin(WechatSurfaceMapper.loginHintText()); if (!apiClient.hasSessionHints()) { return; } @@ -169,7 +176,7 @@ public class MainActivity extends AppCompatActivity { } catch (Exception ignored) { // Fall back to login panel. } - runOnUiThread(() -> setLoginLoading(false, "点击登录后会直接进入系统。")); + runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText())); }); } @@ -199,10 +206,12 @@ public class MainActivity extends AppCompatActivity { private void refreshAllData(@Nullable JSONObject initialSession) { startRefreshing(true); - topSubtitle.setText("正在同步最新数据..."); + topSubtitle.setText(""); + topSubtitle.setVisibility(View.GONE); executor.execute(() -> { + JSONObject session; try { - JSONObject session = initialSession; + session = initialSession; if (session == null) { BossApiClient.ApiResponse sessionResponse = apiClient.getSession(); if (!sessionResponse.ok()) { @@ -213,29 +222,6 @@ public class MainActivity extends AppCompatActivity { } session = sessionResponse.json.optJSONObject("session"); } - - BossApiClient.ApiResponse conversations = apiClient.getConversations(); - BossApiClient.ApiResponse devices = apiClient.getDevices(); - BossApiClient.ApiResponse ota = apiClient.getOtaStatus(); - BossApiClient.ApiResponse settings = apiClient.getSettings(); - if (!conversations.ok() || !devices.ok() || !ota.ok() || !settings.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; - JSONObject settingsPayload = settings.json.optJSONObject("settings"); - if (settingsPayload != null) { - preferredEntryTab = settingsPayload.optString("preferredEntryPoint", "conversations"); - } - maybeApplyPreferredEntry(); - renderCurrentTab(); - startRefreshing(false); - }); } catch (Exception error) { runOnUiThread(() -> { startRefreshing(false); @@ -245,6 +231,94 @@ public class MainActivity extends AppCompatActivity { otaData = null; showLogin("当前登录已失效或同步失败,请重新点击登录。"); }); + return; + } + + try { + BossApiClient.ApiResponse conversations = null; + BossApiClient.ApiResponse devices = null; + BossApiClient.ApiResponse ota = null; + BossApiClient.ApiResponse settings = null; + boolean conversationsOk = false; + boolean devicesOk = false; + boolean otaOk = false; + boolean settingsOk = false; + + try { + conversations = apiClient.getConversations(); + conversationsOk = conversations.ok(); + } catch (Exception ignored) { + conversationsOk = false; + } + try { + devices = apiClient.getDevices(); + devicesOk = devices.ok(); + } catch (Exception ignored) { + devicesOk = false; + } + try { + ota = apiClient.getOtaStatus(); + otaOk = ota.ok(); + } catch (Exception ignored) { + otaOk = false; + } + try { + settings = apiClient.getSettings(); + settingsOk = settings.ok(); + } catch (Exception ignored) { + settingsOk = false; + } + + JSONObject finalSession = session; + BossApiClient.ApiResponse finalConversations = conversations; + BossApiClient.ApiResponse finalDevices = devices; + BossApiClient.ApiResponse finalOta = ota; + BossApiClient.ApiResponse finalSettings = settings; + final boolean finalConversationsOk = conversationsOk; + final boolean finalDevicesOk = devicesOk; + final boolean finalOtaOk = otaOk; + final boolean finalSettingsOk = settingsOk; + runOnUiThread(() -> { + sessionData = finalSession; + conversationsData = WechatSurfaceMapper.resolveRefreshValue( + conversationsData, + finalConversations == null ? null : finalConversations.json.optJSONArray("conversations"), + finalConversationsOk + ); + devicesData = WechatSurfaceMapper.resolveRefreshValue( + devicesData, + finalDevices == null ? null : finalDevices.json.optJSONArray("devices"), + finalDevicesOk + ); + otaData = WechatSurfaceMapper.resolveRefreshValue( + otaData, + finalOta == null ? null : finalOta.json, + finalOtaOk + ); + JSONObject settingsPayload = finalSettings == null ? null : finalSettings.json.optJSONObject("settings"); + JSONObject userPayload = finalSettings == null ? null : finalSettings.json.optJSONObject("user"); + if (finalSettingsOk && settingsPayload != null) { + preferredEntryTab = settingsPayload.optString("preferredEntryPoint", "conversations"); + } + if (finalSettingsOk) { + updateBoundDeviceState(userPayload); + } + maybeApplyPreferredEntry(); + renderCurrentTab(); + startRefreshing(false); + if (!finalConversationsOk || !finalDevicesOk || !finalOtaOk || !finalSettingsOk) { + showMessage("刷新失败,请稍后重试"); + } + }); + } catch (Exception error) { + JSONObject finalSession = session; + runOnUiThread(() -> { + sessionData = finalSession == null ? sessionData : finalSession; + maybeApplyPreferredEntry(); + renderCurrentTab(); + startRefreshing(false); + showMessage("刷新失败,请稍后重试"); + }); } }); } @@ -264,7 +338,7 @@ public class MainActivity extends AppCompatActivity { private void setLoginLoading(boolean loading, String hint) { loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE); loginButton.setEnabled(!loading); - loginButton.setText(loading ? "处理中..." : "登录"); + loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel()); loginHint.setText(hint); } @@ -293,16 +367,19 @@ public class MainActivity extends AppCompatActivity { switch (activeTab) { case "devices": - updateHeader("设备", "生产设备列表"); + updateHeader("设备", "这里管理已接入设备与账号状态。"); + configureTopAction("+添加", true); renderDevicesRoot(); break; case "me": - updateHeader("我的", "账号与应用设置"); + updateHeader("我的", ""); + configureTopAction("刷新", false); renderMeRoot(); break; case "conversations": default: - updateHeader("会话", "最近消息"); + updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle()); + configureTopAction("刷新", false); renderConversationsRoot(); break; } @@ -311,6 +388,7 @@ public class MainActivity extends AppCompatActivity { private void updateHeader(String title, String subtitle) { topTitle.setText(title); topSubtitle.setText(subtitle); + topSubtitle.setVisibility(subtitle == null || subtitle.isEmpty() ? View.GONE : View.VISIBLE); } private void updateTabStyles() { @@ -324,8 +402,33 @@ public class MainActivity extends AppCompatActivity { button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted)); } + private void configureTopAction(String label, boolean primaryStyle) { + refreshButton.setText(label); + refreshButton.setBackgroundResource(primaryStyle ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button); + refreshButton.setTextColor(getColor(primaryStyle ? R.color.boss_surface : R.color.boss_green)); + } + + private void syncTopActionVisualState(boolean refreshing) { + if ("devices".equals(activeTab)) { + configureTopAction("+添加", true); + refreshButton.setEnabled(true); + return; + } + configureTopAction(refreshing ? "同步中" : "刷新", false); + refreshButton.setEnabled(!refreshing); + } + + private void handleTopAction() { + if ("devices".equals(activeTab)) { + startActivity(new Intent(this, DeviceEnrollmentActivity.class)); + return; + } + refreshCurrentTab(); + } + private void renderConversationsRoot() { screenContent.removeAllViews(); + screenContent.addView(BossUi.buildHintPill(this, WechatSurfaceMapper.conversationsHintPillText())); if (conversationsData == null || conversationsData.length() == 0) { screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。")); return; @@ -336,33 +439,22 @@ public class MainActivity extends AppCompatActivity { if (item == null) continue; String projectId = item.optString("projectId", ""); WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); - String badge = row.unreadCount > 0 ? String.valueOf(row.unreadCount) : null; - screenContent.addView(BossUi.buildListRow( + screenContent.addView(BossUi.buildConversationRow( this, - row.title.isEmpty() ? "未命名会话" : row.title, - row.preview.isEmpty() ? "暂无预览" : row.preview, - row.timeLabel.isEmpty() ? null : row.timeLabel, - badge, + row, v -> { if (projectId.isEmpty()) { showMessage("缺少 projectId"); return; } - openProject(projectId, row.title.isEmpty() ? "未命名会话" : row.title); + String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle; + openProject(projectId, projectName); })); } } private void renderDevicesRoot() { screenContent.removeAllViews(); - screenContent.addView(BossUi.buildMenuRow( - this, - "添加设备", - "通过配对码接入新的生产设备", - null, - v -> startActivity(new Intent(this, DeviceEnrollmentActivity.class)) - )); - if (devicesData == null || devicesData.length() == 0) { screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。")); return; @@ -373,13 +465,18 @@ public class MainActivity extends AppCompatActivity { if (item == null) continue; String deviceId = item.optString("id", ""); WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item); - screenContent.addView(BossUi.buildListRow(this, row.title, row.subtitle, null, null, v -> { - if (deviceId.isEmpty()) { - showMessage("缺少 deviceId"); - return; - } - openDevice(deviceId, row.title); - })); + screenContent.addView(BossUi.buildDeviceCard( + this, + row, + v -> { + if (deviceId.isEmpty()) { + showMessage("缺少 deviceId"); + return; + } + openDevice(deviceId, row.title); + }, + null + )); } } @@ -391,28 +488,23 @@ public class MainActivity extends AppCompatActivity { String account = sessionData == null ? apiClient.getAccountLabel() : sessionData.optString("account", apiClient.getAccountLabel()); - screenContent.addView(BossUi.buildListRow( + screenContent.addView(BossUi.buildSimpleProfileHeader( this, displayName, - "账号 " + account, - null, - null, - null + "ChatGPT Plus · 主账号", + "主控账号已启用安全保护 · " + account )); - for (String title : WechatSurfaceMapper.rootMeMenuTitles()) { - screenContent.addView(BossUi.buildMenuRow( + for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) { + screenContent.addView(BossUi.buildWechatMenuRow( this, - title, - meDescriptionFor(title), - meBadgeFor(title), - v -> openMeEntry(title) + item.title, + item.description, + null, + meBadgeFor(item.key), + v -> openMeEntry(item.key) )); } - - Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录"); - logoutButton.setOnClickListener(v -> logout()); - screenContent.addView(logoutButton); } private void logout() { @@ -450,58 +542,126 @@ public class MainActivity extends AppCompatActivity { private void startRefreshing(boolean refreshing) { screenRefresh.setRefreshing(refreshing); - refreshButton.setEnabled(!refreshing); - refreshButton.setText(refreshing ? "同步中" : "刷新"); + syncTopActionVisualState(refreshing); } private void showMessage(String text) { Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); } - private void openMeEntry(String title) { + private void openMeEntry(String key) { Intent intent; - switch (title) { - case "账号与安全": + switch (key) { + case "security": intent = new Intent(this, SecurityActivity.class); break; - case "AI 账号": + case "ai_accounts": intent = new Intent(this, AiAccountsActivity.class); break; - case "设置": + case "settings": intent = new Intent(this, SettingsActivity.class); break; - case "技能": - intent = new Intent(this, SkillInventoryActivity.class); + case "ops": + intent = new Intent(this, OpsCenterActivity.class); break; - case "关于": + case "skills": + openSkillInventoryFromMe(); + return; + case "about": intent = new Intent(this, AboutActivity.class); break; default: - showMessage("暂未接入:" + title); + showMessage("暂未接入:" + key); return; } startActivity(intent); } - private String meDescriptionFor(String title) { - switch (title) { - case "账号与安全": - return "查看当前会话与登录安全"; - case "AI 账号": - return "管理主 GPT、备用 GPT 与 API 容灾"; - case "设置": - return "调整默认首页和提醒偏好"; - case "技能": - return "按设备查看 Skill 清单"; - case "关于": - return "查看版本、更新与高级入口"; - default: - return ""; + private void openSkillInventoryFromMe() { + String targetDeviceId = resolveSkillTargetDeviceId(); + if (targetDeviceId == null || targetDeviceId.isEmpty()) { + showMessage("当前没有可确定的绑定设备,请先到设备页确认。"); + return; } + Intent intent = new Intent(this, SkillInventoryActivity.class); + intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, targetDeviceId); + String targetDeviceName = resolveDeviceName(targetDeviceId); + if (targetDeviceName != null && !targetDeviceName.isEmpty()) { + intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, targetDeviceName); + } + startActivity(intent); } - private @Nullable String meBadgeFor(String title) { - if ("关于".equals(title) && otaData != null && otaData.optBoolean("hasOta", false)) { + private void updateBoundDeviceState(@Nullable JSONObject userPayload) { + String nextBoundDeviceId = userPayload == null ? "" : userPayload.optString("boundDeviceId", ""); + boundDeviceId = nextBoundDeviceId.isEmpty() ? null : nextBoundDeviceId; + boundDeviceName = resolveDeviceName(boundDeviceId); + } + + private @Nullable String resolveSkillTargetDeviceId() { + if (devicesData == null || devicesData.length() == 0) { + return null; + } + + if (boundDeviceId != null && !boundDeviceId.isEmpty()) { + String validatedBoundDeviceId = findDeviceId(devicesData, boundDeviceId); + if (validatedBoundDeviceId != null) { + return validatedBoundDeviceId; + } + } + + String account = sessionData == null ? apiClient.getAccountLabel() : sessionData.optString("account", apiClient.getAccountLabel()); + String matchedByAccount = null; + for (int i = 0; i < devicesData.length(); i++) { + JSONObject device = devicesData.optJSONObject(i); + if (device == null) continue; + String deviceAccount = device.optString("account", ""); + if (!account.isEmpty() && account.equals(deviceAccount)) { + matchedByAccount = device.optString("id", ""); + break; + } + } + if (matchedByAccount != null && !matchedByAccount.isEmpty()) { + return matchedByAccount; + } + if (devicesData.length() == 1) { + JSONObject onlyDevice = devicesData.optJSONObject(0); + return onlyDevice == null ? null : onlyDevice.optString("id", ""); + } + JSONObject fallback = devicesData.optJSONObject(0); + return fallback == null ? null : fallback.optString("id", ""); + } + + private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) { + if (candidateDeviceId == null || candidateDeviceId.isEmpty()) { + return null; + } + for (int i = 0; i < devices.length(); i++) { + JSONObject device = devices.optJSONObject(i); + if (device == null) continue; + if (candidateDeviceId.equals(device.optString("id", ""))) { + return candidateDeviceId; + } + } + return null; + } + + private @Nullable String resolveDeviceName(@Nullable String deviceId) { + if (deviceId == null || deviceId.isEmpty() || devicesData == null) { + return null; + } + for (int i = 0; i < devicesData.length(); i++) { + JSONObject device = devicesData.optJSONObject(i); + if (device == null) continue; + if (deviceId.equals(device.optString("id", ""))) { + return device.optString("name", deviceId); + } + } + return boundDeviceName; + } + + private @Nullable String meBadgeFor(String key) { + if ("about".equals(key) && otaData != null && otaData.optBoolean("hasOta", false)) { return "OTA"; } return null; diff --git a/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java index 0491f3d..6a3c3a2 100644 --- a/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java @@ -10,18 +10,12 @@ 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("运维中心", "运维对话 / 审计对话"); + configureScreen("运维与修复", "运维会话、修复回放与 standby 切换"); setHeaderAction("刷新", v -> reload()); contentRoot = new LinearLayout(this); contentRoot.setOrientation(LinearLayout.VERTICAL); @@ -35,62 +29,34 @@ public class OpsCenterActivity extends BossScreenActivity { 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"); + if (!ops.ok()) { + throw new IllegalStateException("OPS_LOAD_FAILED"); } - runOnUiThread(() -> render(ops.json, audit.json)); + runOnUiThread(() -> render(ops.json)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); - replaceContent(BossUi.buildEmptyCard(this, "运维中心加载失败:" + error.getMessage())); + replaceContent(BossUi.buildEmptyCard(this, "运维与修复加载失败:" + error.getMessage())); }); } }); } - private void render(JSONObject ops, JSONObject audit) { + private void render(JSONObject ops) { + replaceContent(contentRoot); contentRoot.removeAllViews(); - contentRoot.addView(buildTabBar()); - if (activeTab == Tab.OPS) { - renderOpsTab(ops); - } else { - renderAuditTab(audit); - } + renderOpsTab(ops); 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( + contentRoot.addView(BossUi.buildSoftPanel( this, - "当前巡检模式", + "巡检状态", ops.optString("mode", "idle").equals("active") ? "active:当前存在风险线程或未关闭运维工单。" : "idle:当前没有高风险工单,保持低频巡检。", - "来源:/api/v1/ops/summary" + "这里只保留修复与验证的轻量入口。" )); JSONArray faults = ops.optJSONArray("faults"); @@ -106,7 +72,9 @@ public class OpsCenterActivity extends BossScreenActivity { } private LinearLayout buildFaultCard(JSONObject fault, @Nullable JSONArray tickets) { - LinearLayout card = BossUi.buildCard( + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + card.addView(BossUi.buildWechatMenuRow( this, fault.optString("faultKey", "故障"), fault.optString("summary", "暂无摘要"), @@ -114,13 +82,9 @@ public class OpsCenterActivity extends BossScreenActivity { + " · " + fault.optString("status", "-") + " · " + fault.optString("nodeId", "-") + " · " + fault.optString("serviceName", "-") - ); - - card.addView(BossUi.buildCard( - this, - "建议动作", - fault.optString("suggestedNextAction", "暂无"), - "trace " + fault.optString("traceId", "-") + + " · 建议 " + fault.optString("suggestedNextAction", "暂无"), + null, + null )); if (tickets != null) { @@ -135,122 +99,38 @@ public class OpsCenterActivity extends BossScreenActivity { } private LinearLayout buildTicketCard(JSONObject ticket) { - LinearLayout card = BossUi.buildCard( + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + card.addView(BossUi.buildWechatMenuRow( this, ticket.optString("title", "修复工单"), ticket.optString("actionSummary", "暂无动作摘要"), ticket.optString("approvalStatus", "-") + " · " + ticket.optString("executionStatus", "-") + " · " + ticket.optString("targetNodeId", "-") - ); + + " · " + ticket.optString("updatedAt", "-"), + null, + null + )); if (ticket.optJSONObject("verification") != null) { JSONObject verification = ticket.optJSONObject("verification"); - card.addView(BossUi.buildCard( + card.addView(BossUi.buildWechatMenuRow( this, "验证结果", verification.optString("summary", "暂无"), verification.optString("status", "-") - + " · " + verification.optString("verifiedAt", "-") + + " · " + verification.optString("verifiedAt", "-"), + null, + null )); } - Button approve = BossUi.buildPrimaryButton(this, "批准修复"); + Button approve = BossUi.buildMiniActionButton(this, "批准修复", true); approve.setOnClickListener(v -> approveTicket(ticket.optString("ticketId"))); - card.addView(approve); - - Button verify = BossUi.buildSecondaryButton(this, "验证修复"); + Button verify = BossUi.buildMiniActionButton(this, "验证修复", false); 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")) - )); + card.addView(BossUi.buildInlineActionRow(this, approve, verify)); return card; } @@ -311,16 +191,4 @@ public class OpsCenterActivity extends BossScreenActivity { } 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(); - } } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 51a85fe..06f80b0 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -11,6 +11,8 @@ import android.widget.EditText; import android.widget.LinearLayout; import android.widget.ScrollView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; import org.json.JSONArray; @@ -22,6 +24,8 @@ public class ProjectDetailActivity extends BossScreenActivity { private String projectId; private String initialProjectName; + private boolean projectIsGroup; + private String projectFolderName; private LinearLayout quickActionsLayout; private EditText composerInput; private Button composerSendButton; @@ -30,6 +34,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private boolean composerSending; private boolean renderNearBottom; private boolean renderForcedScrollToBottom; + private ActivityResultLauncher conversationInfoLauncher; @Override protected int getLayoutResId() { @@ -45,9 +50,25 @@ public class ProjectDetailActivity extends BossScreenActivity { composerInput = findViewById(R.id.project_chat_input); composerSendButton = findViewById(R.id.project_chat_send); chatScrollView = findViewById(R.id.project_chat_scroll); + conversationInfoLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() != RESULT_OK) { + return; + } + Intent data = result.getData(); + if (data != null) { + String updatedTitle = data.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME); + if (!TextUtils.isEmpty(updatedTitle)) { + initialProjectName = updatedTitle; + configureScreen(updatedTitle, "正在同步项目详情..."); + } + } + reload(); + } + ); configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); - hideHeaderAction(); composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer()); composerInput.addTextChangedListener(new TextWatcher() { @Override @@ -113,10 +134,14 @@ public class ProjectDetailActivity extends BossScreenActivity { private void renderProject(JSONObject payload) { JSONObject project = payload.optJSONObject("project"); JSONArray devices = payload.optJSONArray("devices"); + JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta"); String title = project != null ? project.optString("name", "项目详情") : "项目详情"; initialProjectName = title; - configureScreen(title, "设备:" + joinDeviceNames(devices)); + projectIsGroup = project != null && project.optBoolean("isGroup", false); + projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + configureScreen(title, buildProjectSubtitle(projectFolderName, devices)); + setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo()); renderQuickActions(); replaceContent(); @@ -242,6 +267,17 @@ public class ProjectDetailActivity extends BossScreenActivity { startActivity(intent); } + private void openConversationInfo() { + if (projectId == null || projectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + Intent intent = new Intent(this, WechatSurfaceMapper.resolveConversationInfoTargetClass(projectIsGroup)); + intent.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, projectId); + intent.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, initialProjectName); + conversationInfoLauncher.launch(intent); + } + private String joinDeviceNames(@Nullable JSONArray devices) { if (devices == null || devices.length() == 0) { return "未绑定设备"; @@ -260,6 +296,14 @@ public class ProjectDetailActivity extends BossScreenActivity { return builder.length() == 0 ? "未绑定设备" : builder.toString(); } + private String buildProjectSubtitle(String folderName, @Nullable JSONArray devices) { + String deviceLabel = joinDeviceNames(devices); + if (TextUtils.isEmpty(folderName)) { + return "设备:" + deviceLabel; + } + return folderName + " · 设备:" + deviceLabel; + } + private void scrollChatToBottom() { if (chatScrollView == null) { return; diff --git a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java index a155f1c..463a824 100644 --- a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java @@ -11,7 +11,7 @@ public class SecurityActivity extends BossScreenActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - configureScreen("账号与安全", "原生会话与设备安全"); + configureScreen("账号与安全", "登录会话与设备保护"); reload(); } @@ -34,16 +34,14 @@ public class SecurityActivity extends BossScreenActivity { private void renderSecurity(@Nullable JSONObject session) { replaceContent(); - appendContent(BossUi.buildListRow( + appendContent(BossUi.buildSoftPanel( this, "当前登录模式", "当前客户端仍使用快速进入模式。", - "需要更严格认证时,再切回账号密码或验证码登录。", - null, - null + "需要更严格认证时,再切回账号密码或验证码登录。" )); if (session != null) { - appendContent(BossUi.buildListRow( + appendContent(BossUi.buildWechatMenuRow( this, "当前会话", "账号 " + session.optString("account", "-") diff --git a/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java b/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java index de98f85..4b211fe 100644 --- a/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java @@ -15,13 +15,15 @@ public class SettingsActivity extends BossScreenActivity { private SwitchCompat riskBadgesSwitch; private SwitchCompat confirmActionsSwitch; private Spinner preferredEntrySpinner; + private boolean settingsLoaded = false; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - configureScreen("设置", "原生偏好配置"); + configureScreen("设置", "默认首页与提醒偏好"); setHeaderAction("保存", v -> saveSettings()); - buildForm(); + buildFormContent(); + updateSaveAvailability(); reload(); } @@ -36,48 +38,52 @@ public class SettingsActivity extends BossScreenActivity { } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); + settingsLoaded = false; + updateSaveAvailability(); replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage())); }); } }); } - private void buildForm() { - replaceContent(BossUi.buildListRow( + private void buildFormContent() { + if (liveUpdatesSwitch == null) { + liveUpdatesSwitch = new SwitchCompat(this); + liveUpdatesSwitch.setText("启用实时刷新"); + } + if (riskBadgesSwitch == null) { + riskBadgesSwitch = new SwitchCompat(this); + riskBadgesSwitch.setText("显示风险徽标"); + } + if (confirmActionsSwitch == null) { + confirmActionsSwitch = new SwitchCompat(this); + confirmActionsSwitch.setText("危险操作前确认"); + } + if (preferredEntrySpinner == null) { + preferredEntrySpinner = new Spinner(this); + ArrayAdapter adapter = new ArrayAdapter<>( + this, + android.R.layout.simple_spinner_dropdown_item, + new String[]{"conversations", "devices", "me"} + ); + preferredEntrySpinner.setAdapter(adapter); + } + + replaceContent(BossUi.buildSoftPanel( this, "偏好设置", "调整默认首页和提醒行为。", - "保存后会直接写入 /api/v1/settings。", - null, - null + "保存后会直接写入 /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 adapter = new ArrayAdapter<>( - this, - android.R.layout.simple_spinner_dropdown_item, - new String[]{"conversations", "devices", "me"} - ); - preferredEntrySpinner.setAdapter(adapter); - - LinearLayout form = BossUi.buildCard(this, "交互偏好", "切换默认首页与提醒开关。", "保存后立即生效"); - form.addView(liveUpdatesSwitch); - form.addView(riskBadgesSwitch); - form.addView(confirmActionsSwitch); - form.addView(preferredEntrySpinner); - appendContent(form); + appendContent(BossUi.buildFormCell(this, "实时刷新", "会话、设备和 OTA 状态变化时自动更新", liveUpdatesSwitch)); + appendContent(BossUi.buildFormCell(this, "风险徽标", "在列表中显示风险状态提示", riskBadgesSwitch)); + appendContent(BossUi.buildFormCell(this, "危险操作确认", "执行修复或切换前再次确认", confirmActionsSwitch)); + appendContent(BossUi.buildFormCell(this, "默认首页", "下次打开 App 优先进入这里", preferredEntrySpinner)); } private void populate(@Nullable JSONObject settings) { + buildFormContent(); if (settings != null) { liveUpdatesSwitch.setChecked(settings.optBoolean("liveUpdates", true)); riskBadgesSwitch.setChecked(settings.optBoolean("showRiskBadges", true)); @@ -91,10 +97,16 @@ public class SettingsActivity extends BossScreenActivity { preferredEntrySpinner.setSelection(0); } } + settingsLoaded = settings != null; + updateSaveAvailability(); setRefreshing(false); } private void saveSettings() { + if (!settingsLoaded) { + showMessage("设置尚未加载完成,请先刷新成功后再保存。"); + return; + } setRefreshing(true); executor.execute(() -> { try { @@ -117,4 +129,11 @@ public class SettingsActivity extends BossScreenActivity { } }); } + + private void updateSaveAvailability() { + if (headerActionButton != null) { + headerActionButton.setEnabled(settingsLoaded); + headerActionButton.setAlpha(settingsLoaded ? 1f : 0.45f); + } + } } diff --git a/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java b/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java index 978ddcb..518c62d 100644 --- a/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java @@ -45,8 +45,15 @@ public class SkillInventoryActivity extends BossScreenActivity { } private String resolveTargetDeviceId() throws Exception { - if (deviceId != null && !deviceId.isEmpty()) { - return deviceId; + String explicitDeviceId = deviceId; + String boundDeviceId = null; + BossApiClient.ApiResponse settingsResponse = apiClient.getSettings(); + if (settingsResponse.ok()) { + JSONObject user = settingsResponse.json.optJSONObject("user"); + if (user != null) { + String candidate = user.optString("boundDeviceId", ""); + boundDeviceId = candidate.isEmpty() ? null : candidate; + } } BossApiClient.ApiResponse response = apiClient.getDevices(); if (!response.ok()) throw new IllegalStateException(response.message()); @@ -54,7 +61,54 @@ public class SkillInventoryActivity extends BossScreenActivity { if (devices == null || devices.length() == 0) { throw new IllegalStateException("NO_DEVICE"); } - return devices.optJSONObject(0).optString("id"); + return chooseTargetDeviceId(explicitDeviceId, boundDeviceId, apiClient.getAccountLabel(), devices); + } + + private static String chooseTargetDeviceId( + @Nullable String explicitDeviceId, + @Nullable String boundDeviceId, + String account, + JSONArray devices + ) { + String explicitMatch = findDeviceId(devices, explicitDeviceId); + if (explicitMatch != null) { + return explicitMatch; + } + + String boundMatch = findDeviceId(devices, boundDeviceId); + if (boundMatch != null) { + return boundMatch; + } + + for (int i = 0; i < devices.length(); i++) { + JSONObject device = devices.optJSONObject(i); + if (device == null) continue; + if (account.equals(device.optString("account", ""))) { + return device.optString("id", ""); + } + } + if (devices.length() == 1) { + JSONObject onlyDevice = devices.optJSONObject(0); + if (onlyDevice != null) { + return onlyDevice.optString("id", ""); + } + } + JSONObject fallback = devices.optJSONObject(0); + return fallback == null ? "" : fallback.optString("id", ""); + } + + private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) { + if (candidateDeviceId == null || candidateDeviceId.isEmpty()) { + return null; + } + for (int i = 0; i < devices.length(); i++) { + JSONObject device = devices.optJSONObject(i); + if (device == null) continue; + if (candidateDeviceId.equals(device.optString("id", ""))) { + return candidateDeviceId; + } + } + return null; } private void renderSkills(JSONObject payload) { @@ -65,13 +119,11 @@ public class SkillInventoryActivity extends BossScreenActivity { if (device != null) { deviceName = device.optString("name", deviceId); configureScreen("技能", deviceName); - appendContent(BossUi.buildListRow( + appendContent(BossUi.buildSoftPanel( this, deviceName, "当前页按设备查看 Skill 清单。", - "Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。", - null, - null + "Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。" )); } @@ -85,7 +137,7 @@ public class SkillInventoryActivity extends BossScreenActivity { if (skill == null) continue; LinearLayout card = new LinearLayout(this); card.setOrientation(LinearLayout.VERTICAL); - card.addView(BossUi.buildListRow( + card.addView(BossUi.buildWechatMenuRow( this, skill.optString("name", "未命名 Skill"), skill.optString("description", "未提供说明"), @@ -94,12 +146,11 @@ public class SkillInventoryActivity extends BossScreenActivity { null, null )); - Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句"); + Button copyInvocation = BossUi.buildMiniActionButton(this, "复制调用", true); copyInvocation.setOnClickListener(v -> BossUi.copyText(this, "Skill 调用", skill.optString("invocation", ""))); - card.addView(copyInvocation); - Button copyPath = BossUi.buildSecondaryButton(this, "复制路径"); + Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false); copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", ""))); - card.addView(copyPath); + card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath)); appendContent(card); } setRefreshing(false); diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java index 0d20044..b44a611 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -3,6 +3,7 @@ package com.hyzq.boss; import org.json.JSONObject; import org.json.JSONArray; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -13,12 +14,13 @@ public final class WechatSurfaceMapper { "我的" ); - private static final List ROOT_ME_MENU_TITLES = Arrays.asList( - "账号与安全", - "AI 账号", - "设置", - "技能", - "关于" + private static final List ROOT_ME_MENU_ITEMS = Arrays.asList( + new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"), + new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"), + new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"), + new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"), + new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"), + new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容") ); private static final List PROJECT_QUICK_ACTIONS = Arrays.asList( @@ -37,11 +39,33 @@ public final class WechatSurfaceMapper { public static ConversationRow toConversationRow(JSONObject item) { JSONObject source = item == null ? new JSONObject() : item; + JSONArray members = source.optJSONArray("groupMembers"); + List groupAvatarMembers = new ArrayList<>(); + if (members != null) { + for (int i = 0; i < members.length(); i++) { + JSONObject member = members.optJSONObject(i); + if (member == null) continue; + groupAvatarMembers.add(new GroupAvatarMember( + member.optString("threadId", ""), + member.optString("avatar", ""), + member.optString("title", "") + )); + } + } + JSONObject avatar = source.optJSONObject("avatar"); + boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1); return new ConversationRow( - source.optString("title", source.optString("projectTitle", "")), - source.optString("preview", ""), + source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))), + source.optString("folderLabel", ""), + source.optString("lastMessagePreview", source.optString("preview", "")), source.optString("timeLabel", source.optString("latestReplyLabel", "")), - source.optInt("unreadCount", 0) + source.optInt("unreadCount", 0), + source.optString("topPinnedLabel", ""), + source.optInt("activityIconCount", 0), + isGroup, + isGroup ? "" : avatar == null ? "" : avatar.optString("primary", ""), + isGroup ? "" : avatar == null ? "" : avatar.optString("secondary", ""), + groupAvatarMembers.toArray(new GroupAvatarMember[0]) ); } @@ -49,16 +73,20 @@ public final class WechatSurfaceMapper { JSONObject source = item == null ? new JSONObject() : item; return new DeviceRow( source.optString("title", source.optString("name", "")), - buildSubtitle(source) + buildDeviceAccountProjectLine(source), + buildDeviceQuotaStatusLine(source), + source.optString("avatar", ""), + resolveDeviceStatusKey(source) ); } public static DeviceDetailSummary toDeviceDetailSummary(JSONObject item) { JSONObject source = item == null ? new JSONObject() : item; + DeviceRow row = toDeviceRow(source); return new DeviceDetailSummary( - source.optString("title", source.optString("name", "")), - buildSubtitle(source), - buildDetailMeta(source) + row.title, + row.subtitle, + mergeDeviceMeta(row.meta, buildDetailMeta(source)) ); } @@ -67,31 +95,83 @@ public final class WechatSurfaceMapper { } public static String[] rootMeMenuTitles() { - return ROOT_ME_MENU_TITLES.toArray(new String[0]); + String[] titles = new String[ROOT_ME_MENU_ITEMS.size()]; + for (int i = 0; i < ROOT_ME_MENU_ITEMS.size(); i++) { + titles[i] = ROOT_ME_MENU_ITEMS.get(i).title; + } + return titles; } - public static String advancedEntryTitle() { - return "高级与调试"; + public static MeMenuItem[] rootMeMenuItems() { + return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]); + } + + public static MeMenuItem findMeMenuItem(String key) { + for (MeMenuItem item : ROOT_ME_MENU_ITEMS) { + if (item.key.equals(key)) { + return item; + } + } + return null; } public static String[] projectQuickActions() { return PROJECT_QUICK_ACTIONS.toArray(new String[0]); } + public static Class resolveConversationInfoTargetClass(boolean isGroup) { + return isGroup ? GroupInfoActivity.class : ConversationInfoActivity.class; + } + public static String[] projectPrimarySections() { return PROJECT_PRIMARY_SECTIONS.toArray(new String[0]); } - private static String buildSubtitle(JSONObject source) { - String statusValue = source.optString("status", ""); - String status; - if (source.optBoolean("online", false) || "online".equals(statusValue)) { - status = "在线"; - } else if ("abnormal".equals(statusValue)) { - status = "异常"; - } else { - status = "离线"; + public static String conversationInfoActionLabel() { + return "信息"; + } + + public static String loginTitle() { + return "会话"; + } + + public static String loginHintText() { + return "轻量会话首页已恢复,直接进入最近线程。"; + } + + public static String loginButtonLabel() { + return "进入会话"; + } + + public static String conversationsHintPillText() { + return "项目自动对应设备 GUI 项目文件夹"; + } + + public static String conversationsHeaderSubtitle() { + return ""; + } + + public static String conversationActivityIconMode() { + return "animated_dots"; + } + + public static int maxConversationActivityIcons() { + return 4; + } + + public static String conversationActivityAnimationCleanup() { + return "cancel_on_detach"; + } + + public static T resolveRefreshValue(T cachedValue, T freshValue, boolean requestSucceeded) { + if (requestSucceeded) { + return freshValue; } + return cachedValue; + } + + private static String buildSubtitle(JSONObject source) { + String status = localizeDeviceStatus(resolveDeviceStatusKey(source)); String account = source.optString("account", ""); if (account.isEmpty()) { return status; @@ -99,6 +179,103 @@ public final class WechatSurfaceMapper { return status + " · " + account; } + private static String buildDeviceAccountProjectLine(JSONObject source) { + StringBuilder builder = new StringBuilder(); + String account = source.optString("account", ""); + if (!account.isEmpty()) { + builder.append("账号: ").append(account); + } + String projects = joinProjects(source.optJSONArray("projects")); + if (!projects.isEmpty()) { + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append("项目: ").append(projects); + } + if (builder.length() == 0) { + return buildSubtitle(source); + } + return builder.toString(); + } + + private static String buildDeviceQuotaStatusLine(JSONObject source) { + StringBuilder builder = new StringBuilder("额度: "); + int quota5h = source.optInt("quota5h", -1); + int quota7d = source.optInt("quota7d", -1); + boolean hasQuota = false; + if (quota5h >= 0) { + builder.append("5h ").append(quota5h).append("%"); + hasQuota = true; + } + if (quota7d >= 0) { + if (hasQuota) { + builder.append(" · "); + } + builder.append("7d ").append(quota7d).append("%"); + hasQuota = true; + } + if (!hasQuota) { + builder.append("暂无"); + } + + String statusKey = resolveDeviceStatusKey(source); + if ("abnormal".equals(statusKey)) { + builder.append(" · 状态异常"); + } else if ("offline".equals(statusKey)) { + builder.append(" · 当前离线"); + } + return builder.toString(); + } + + private static String mergeDeviceMeta(String primary, String secondary) { + if (primary == null || primary.isEmpty()) { + return secondary; + } + if (secondary == null || secondary.isEmpty()) { + return primary; + } + return primary + " · " + secondary; + } + + private static String joinProjects(JSONArray projects) { + if (projects == null || projects.length() == 0) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < projects.length(); i++) { + String project = projects.optString(i); + if (project == null || project.isEmpty()) { + continue; + } + if (builder.length() > 0) { + builder.append(" / "); + } + builder.append(project); + } + return builder.toString(); + } + + private static String resolveDeviceStatusKey(JSONObject source) { + String statusValue = source.optString("status", ""); + if (source.optBoolean("online", false) || "online".equals(statusValue)) { + return "online"; + } + if ("abnormal".equals(statusValue)) { + return "abnormal"; + } + return "offline"; + } + + private static String localizeDeviceStatus(String statusKey) { + if ("online".equals(statusKey)) { + return "在线"; + } + if ("abnormal".equals(statusKey)) { + return "异常"; + } + return "离线"; + } + private static String buildDetailMeta(JSONObject source) { StringBuilder builder = new StringBuilder(); appendSegment(builder, source.optString("note", "")); @@ -132,26 +309,70 @@ public final class WechatSurfaceMapper { } public static final class ConversationRow { - public final String title; - public final String preview; + public final String threadTitle; + public final String folderLabel; + public final String lastMessagePreview; public final String timeLabel; public final int unreadCount; + public final String topPinnedLabel; + public final int activityIconCount; + public final boolean isGroup; + public final String avatarPrimary; + public final String avatarSecondary; + public final GroupAvatarMember[] groupAvatarMembers; - public ConversationRow(String title, String preview, String timeLabel, int unreadCount) { - this.title = title; - this.preview = preview; + public ConversationRow( + String threadTitle, + String folderLabel, + String lastMessagePreview, + String timeLabel, + int unreadCount, + String topPinnedLabel, + int activityIconCount, + boolean isGroup, + String avatarPrimary, + String avatarSecondary, + GroupAvatarMember[] groupAvatarMembers + ) { + this.threadTitle = threadTitle; + this.folderLabel = folderLabel; + this.lastMessagePreview = lastMessagePreview; this.timeLabel = timeLabel; this.unreadCount = unreadCount; + this.topPinnedLabel = topPinnedLabel; + this.activityIconCount = activityIconCount; + this.isGroup = isGroup; + this.avatarPrimary = avatarPrimary; + this.avatarSecondary = avatarSecondary; + this.groupAvatarMembers = groupAvatarMembers == null ? new GroupAvatarMember[0] : groupAvatarMembers; + } + } + + public static final class GroupAvatarMember { + public final String threadId; + public final String avatarLabel; + public final String title; + + public GroupAvatarMember(String threadId, String avatarLabel, String title) { + this.threadId = threadId; + this.avatarLabel = avatarLabel; + this.title = title; } } public static final class DeviceRow { public final String title; public final String subtitle; + public final String meta; + public final String avatarLabel; + public final String statusKey; - public DeviceRow(String title, String subtitle) { + public DeviceRow(String title, String subtitle, String meta, String avatarLabel, String statusKey) { this.title = title; this.subtitle = subtitle; + this.meta = meta; + this.avatarLabel = avatarLabel; + this.statusKey = statusKey; } } @@ -166,4 +387,16 @@ public final class WechatSurfaceMapper { this.meta = meta; } } + + public static final class MeMenuItem { + public final String key; + public final String title; + public final String description; + + public MeMenuItem(String key, String title, String description) { + this.key = key; + this.title = title; + this.description = description; + } + } } diff --git a/android/app/src/main/res/layout/activity_conversation_info.xml b/android/app/src/main/res/layout/activity_conversation_info.xml new file mode 100644 index 0000000..3f52d82 --- /dev/null +++ b/android/app/src/main/res/layout/activity_conversation_info.xml @@ -0,0 +1,109 @@ + + + + + +