diff --git a/README.md b/README.md index 98a3587..1941d69 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,10 @@ Android APK: - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 +- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加` - 当前聊天列表已切到“线程 = 会话窗口”的结构:主标题显示线程名,副标题显示所属文件夹名,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会显示成多个独立聊天窗口 - 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名 +- 原生顶部安全区当前已补齐状态栏 inset 处理,并把首页 / 会话信息 / 群资料 / 发起群聊 / 转发目标等页面的顶部操作区域收回到可点击安全区内 - 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页 - 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页 - 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / 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 c0d0069..d23acaa 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -92,6 +92,10 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/group-chat", payload == null ? new JSONObject() : payload); } + public ApiResponse createStandaloneGroupChat(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload); + } + public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null); } diff --git a/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java b/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java index 25f1567..1b10adb 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java @@ -1,6 +1,7 @@ package com.hyzq.boss; import android.os.Bundle; +import android.view.View; import android.widget.Button; import android.widget.LinearLayout; import android.widget.TextView; @@ -20,6 +21,7 @@ public abstract class BossScreenActivity extends AppCompatActivity { protected Button backButton; protected Button refreshButton; protected Button headerActionButton; + protected View topBarView; protected TextView titleView; protected TextView subtitleView; protected SwipeRefreshLayout refreshLayout; @@ -34,11 +36,14 @@ public abstract class BossScreenActivity extends AppCompatActivity { backButton = findViewById(R.id.screen_back_button); refreshButton = findViewById(R.id.screen_refresh_button); headerActionButton = findViewById(R.id.screen_header_action); + topBarView = findViewById(R.id.screen_top_bar); titleView = findViewById(R.id.screen_title); subtitleView = findViewById(R.id.screen_subtitle); refreshLayout = findViewById(R.id.screen_refresh_layout); contentLayout = findViewById(R.id.screen_content); + BossWindowInsets.applyStatusBarInset(topBarView); + backButton.setOnClickListener(v -> finish()); refreshButton.setOnClickListener(v -> reload()); refreshLayout.setOnRefreshListener(this::reload); diff --git a/android/app/src/main/java/com/hyzq/boss/BossWindowInsets.java b/android/app/src/main/java/com/hyzq/boss/BossWindowInsets.java new file mode 100644 index 0000000..8076ef1 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossWindowInsets.java @@ -0,0 +1,52 @@ +package com.hyzq.boss; + +import android.view.View; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +public final class BossWindowInsets { + private BossWindowInsets() {} + + public static void applyStatusBarInset(View view) { + if (view == null) { + return; + } + final int initialLeft = view.getPaddingLeft(); + final int initialTop = view.getPaddingTop(); + final int initialRight = view.getPaddingRight(); + final int initialBottom = view.getPaddingBottom(); + + ViewCompat.setOnApplyWindowInsetsListener(view, (target, insets) -> { + Insets statusInsets = insets.getInsets( + WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.displayCutout() + ); + target.setPadding( + initialLeft, + initialTop + statusInsets.top, + initialRight, + initialBottom + ); + return insets; + }); + + if (ViewCompat.isAttachedToWindow(view)) { + ViewCompat.requestApplyInsets(view); + return; + } + + view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + v.removeOnAttachStateChangeListener(this); + ViewCompat.requestApplyInsets(v); + } + + @Override + public void onViewDetachedFromWindow(View v) { + // no-op + } + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java index f83a39b..3f608a3 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java @@ -42,24 +42,26 @@ public class GroupCreateActivity extends BossScreenActivity { super.onCreate(savedInstanceState); sourceProjectId = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_ID); sourceProjectName = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_NAME); - configureScreen("发起群聊", sourceProjectName == null ? "从当前会话出发" : sourceProjectName); + configureScreen( + "发起群聊", + hasSourceProject() ? (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()); + if (!hasSourceProject()) { + runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true)); + return; + } + BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId); + if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true)); } catch (Exception error) { runOnUiThread(() -> { @@ -78,23 +80,32 @@ public class GroupCreateActivity extends BossScreenActivity { 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); + if (hasSourceProject()) { + sourceProjectName = threadMeta == null + ? sourceProjectName + : threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName); - appendContent(BossUi.buildCard( - this, - "新建独立群聊", - "群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。", - buildSourceMeta(threadMeta, participants) - )); + appendContent(BossUi.buildCard( + this, + "新建独立群聊", + "群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。", + buildSourceMeta(threadMeta, participants) + )); - appendContent(BossUi.buildCard( - this, - sourceProjectName, - buildSourceBody(threadMeta, participants), - sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName) - )); + appendContent(BossUi.buildCard( + this, + sourceProjectName, + buildSourceBody(threadMeta, participants), + sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName) + )); + } else { + appendContent(BossUi.buildCard( + this, + "从会话首页发起群聊", + "你可以直接把任意线程拉进一个新的独立群聊,原来的单线程会话会保留不变。", + "至少选择 2 个线程" + )); + } if (rebuildCandidates) { List selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId); @@ -119,7 +130,8 @@ public class GroupCreateActivity extends BossScreenActivity { selectedProjectIds.addAll(reconcileSelectedProjectIds( currentSelectedProjectIds, lastCandidateProjectIds, - nextCandidateProjectIds + nextCandidateProjectIds, + hasSourceProject() )); lastCandidateProjectIds.clear(); lastCandidateProjectIds.addAll(nextCandidateProjectIds); @@ -164,11 +176,14 @@ public class GroupCreateActivity extends BossScreenActivity { if (conversations == null) { return result; } + boolean hasSourceProject = sourceProjectId != null && !sourceProjectId.isEmpty(); 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)) { + if (projectId.isEmpty() + || (hasSourceProject && sourceProjectId.equals(projectId)) + || item.optBoolean("isGroup", false)) { continue; } result.add(item); @@ -214,7 +229,7 @@ public class GroupCreateActivity extends BossScreenActivity { private void updateCreateButtonState() { if (createButton != null) { boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing(); - createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds)); + createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds, hasSourceProject())); createButton.setText(creatingGroupChat ? "创建中..." : "创建群聊"); } } @@ -240,7 +255,9 @@ public class GroupCreateActivity extends BossScreenActivity { memberProjectIds.put(projectId); } payload.put("memberProjectIds", memberProjectIds); - BossApiClient.ApiResponse response = apiClient.createGroupChat(sourceProjectId, payload); + BossApiClient.ApiResponse response = hasSourceProject() + ? apiClient.createGroupChat(sourceProjectId, payload) + : apiClient.createStandaloneGroupChat(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"); @@ -274,18 +291,33 @@ public class GroupCreateActivity extends BossScreenActivity { static boolean canCreateGroupChat( boolean refreshing, boolean creatingGroupChat, - @Nullable Set selectedProjectIds + @Nullable Set selectedProjectIds, + boolean hasSourceProject ) { return !refreshing && !creatingGroupChat && selectedProjectIds != null - && !selectedProjectIds.isEmpty(); + && selectedProjectIds.size() >= (hasSourceProject ? 1 : 2); } static Set reconcileSelectedProjectIds( @Nullable Set currentSelectedProjectIds, @Nullable Set previousCandidateProjectIds, @Nullable Set nextCandidateProjectIds + ) { + return reconcileSelectedProjectIds( + currentSelectedProjectIds, + previousCandidateProjectIds, + nextCandidateProjectIds, + true + ); + } + + static Set reconcileSelectedProjectIds( + @Nullable Set currentSelectedProjectIds, + @Nullable Set previousCandidateProjectIds, + @Nullable Set nextCandidateProjectIds, + boolean defaultSelectAll ) { Set reconciled = new LinkedHashSet<>(); if (nextCandidateProjectIds == null || nextCandidateProjectIds.isEmpty()) { @@ -294,7 +326,9 @@ public class GroupCreateActivity extends BossScreenActivity { if (previousCandidateProjectIds == null || previousCandidateProjectIds.isEmpty() || !previousCandidateProjectIds.equals(nextCandidateProjectIds)) { - reconciled.addAll(nextCandidateProjectIds); + if (defaultSelectAll) { + reconciled.addAll(nextCandidateProjectIds); + } return reconciled; } if (currentSelectedProjectIds == null || currentSelectedProjectIds.isEmpty()) { @@ -308,6 +342,10 @@ public class GroupCreateActivity extends BossScreenActivity { return reconciled; } + private boolean hasSourceProject() { + return sourceProjectId != null && !sourceProjectId.isEmpty(); + } + private String buildSourceMeta(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) { String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); int count = participants == null ? 0 : participants.length(); 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 48e34a8..879b283 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,8 @@ public class MainActivity extends AppCompatActivity { private View loginPanel; private View contentPanel; + private View loginShell; + private View mainTopBar; private TextView loginTitle; private TextView loginHint; private Button loginButton; @@ -110,6 +112,8 @@ public class MainActivity extends AppCompatActivity { private void bindViews() { loginPanel = findViewById(R.id.login_panel); contentPanel = findViewById(R.id.content_panel); + loginShell = findViewById(R.id.login_shell); + mainTopBar = findViewById(R.id.main_top_bar); loginTitle = findViewById(R.id.login_title); loginHint = findViewById(R.id.login_hint); loginButton = findViewById(R.id.login_button); @@ -131,6 +135,8 @@ public class MainActivity extends AppCompatActivity { loginTitle.setText(WechatSurfaceMapper.loginTitle()); loginHint.setText(WechatSurfaceMapper.loginHintText()); loginButton.setText(WechatSurfaceMapper.loginButtonLabel()); + BossWindowInsets.applyStatusBarInset(loginShell); + BossWindowInsets.applyStatusBarInset(mainTopBar); } private void bindActions() { @@ -368,18 +374,18 @@ public class MainActivity extends AppCompatActivity { switch (activeTab) { case "devices": updateHeader("设备", "这里管理已接入设备与账号状态。"); - configureTopAction("+添加", true); + configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false)); renderDevicesRoot(); break; case "me": updateHeader("我的", ""); - configureTopAction("刷新", false); + configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false)); renderMeRoot(); break; case "conversations": default: updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle()); - configureTopAction("刷新", false); + configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false)); renderConversationsRoot(); break; } @@ -402,27 +408,28 @@ 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 configureTopAction(WechatSurfaceMapper.RootTopAction action) { + refreshButton.setText(action.label); + refreshButton.setBackgroundResource(action.primaryStyle ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button); + refreshButton.setTextColor(getColor(action.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); + WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing); + configureTopAction(action); + refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing); } private void handleTopAction() { - if ("devices".equals(activeTab)) { + String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false).actionKey; + if ("add_device".equals(actionKey)) { startActivity(new Intent(this, DeviceEnrollmentActivity.class)); return; } + if ("create_group_chat".equals(actionKey)) { + startActivity(new Intent(this, GroupCreateActivity.class)); + return; + } refreshCurrentTab(); } 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 b44a611..d997bca 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -163,6 +163,16 @@ public final class WechatSurfaceMapper { return "cancel_on_detach"; } + public static RootTopAction rootTopAction(String activeTab, boolean refreshing) { + if ("devices".equals(activeTab)) { + return new RootTopAction("+添加", true, "add_device"); + } + if ("conversations".equals(activeTab)) { + return new RootTopAction("+", true, "create_group_chat"); + } + return new RootTopAction(refreshing ? "同步中" : "刷新", false, "refresh"); + } + public static T resolveRefreshValue(T cachedValue, T freshValue, boolean requestSucceeded) { if (requestSucceeded) { return freshValue; @@ -170,6 +180,18 @@ public final class WechatSurfaceMapper { return cachedValue; } + public static final class RootTopAction { + public final String label; + public final boolean primaryStyle; + public final String actionKey; + + RootTopAction(String label, boolean primaryStyle, String actionKey) { + this.label = label; + this.primaryStyle = primaryStyle; + this.actionKey = actionKey; + } + } + private static String buildSubtitle(JSONObject source) { String status = localizeDeviceStatus(resolveDeviceStatusKey(source)); String account = source.optString("account", ""); diff --git a/android/app/src/main/res/layout/activity_conversation_info.xml b/android/app/src/main/res/layout/activity_conversation_info.xml index 3f52d82..c4da8c8 100644 --- a/android/app/src/main/res/layout/activity_conversation_info.xml +++ b/android/app/src/main/res/layout/activity_conversation_info.xml @@ -6,6 +6,7 @@ android:orientation="vertical">