fix: restore safe top actions and home group chat entry

This commit is contained in:
kris
2026-03-29 18:44:53 +08:00
parent e9ab62e94d
commit c6e8d19ee5
22 changed files with 467 additions and 127 deletions

View File

@@ -94,8 +94,10 @@ Android APK
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
- 当前聊天列表已切到“线程 = 会话窗口”的结构:主标题显示线程名,副标题显示所属文件夹名,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会显示成多个独立聊天窗口
- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名
- 原生顶部安全区当前已补齐状态栏 inset 处理,并把首页 / 会话信息 / 群资料 / 发起群聊 / 转发目标等页面的顶部操作区域收回到可点击安全区内
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
- 当前 `设备``我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的``审计对话` 作为置顶会话保留在会话首页

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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
}
});
}
}

View File

@@ -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<JSONObject> 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<String> selectedProjectIds
@Nullable Set<String> selectedProjectIds,
boolean hasSourceProject
) {
return !refreshing
&& !creatingGroupChat
&& selectedProjectIds != null
&& !selectedProjectIds.isEmpty();
&& selectedProjectIds.size() >= (hasSourceProject ? 1 : 2);
}
static Set<String> reconcileSelectedProjectIds(
@Nullable Set<String> currentSelectedProjectIds,
@Nullable Set<String> previousCandidateProjectIds,
@Nullable Set<String> nextCandidateProjectIds
) {
return reconcileSelectedProjectIds(
currentSelectedProjectIds,
previousCandidateProjectIds,
nextCandidateProjectIds,
true
);
}
static Set<String> reconcileSelectedProjectIds(
@Nullable Set<String> currentSelectedProjectIds,
@Nullable Set<String> previousCandidateProjectIds,
@Nullable Set<String> nextCandidateProjectIds,
boolean defaultSelectAll
) {
Set<String> 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();

View File

@@ -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();
}

View File

@@ -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> 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", "");

View File

@@ -6,6 +6,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -13,7 +14,7 @@
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingRight="28dp"
android:paddingBottom="14dp">
<Button
@@ -75,6 +76,7 @@
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"

View File

@@ -6,6 +6,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -13,7 +14,7 @@
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingRight="28dp"
android:paddingBottom="14dp">
<Button
@@ -75,6 +76,7 @@
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"

View File

@@ -6,6 +6,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -13,7 +14,7 @@
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingRight="28dp"
android:paddingBottom="14dp">
<Button
@@ -75,6 +76,7 @@
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"

View File

@@ -6,6 +6,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -13,7 +14,7 @@
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingRight="28dp"
android:paddingBottom="14dp">
<Button
@@ -75,6 +76,7 @@
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"

View File

@@ -11,6 +11,7 @@
android:fillViewport="true">
<LinearLayout
android:id="@+id/login_shell"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
@@ -82,6 +83,7 @@
android:visibility="gone">
<LinearLayout
android:id="@+id/main_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -89,7 +91,7 @@
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingRight="32dp"
android:paddingBottom="12dp">
<Button
@@ -138,6 +140,7 @@
android:id="@+id/refresh_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="12dp"

View File

@@ -6,6 +6,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -13,7 +14,7 @@
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="14dp"
android:paddingRight="16dp"
android:paddingRight="28dp"
android:paddingBottom="12dp">
<Button
@@ -75,6 +76,7 @@
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"

View File

@@ -6,6 +6,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -13,7 +14,7 @@
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingRight="28dp"
android:paddingBottom="14dp">
<Button
@@ -75,6 +76,7 @@
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"

View File

@@ -0,0 +1,38 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.view.View;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.RuntimeEnvironment;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossWindowInsetsTest {
@Test
public void applyStatusBarInset_addsInsetOnTopOfInitialPadding() {
View view = new View(RuntimeEnvironment.getApplication());
view.setPadding(12, 16, 18, 20);
BossWindowInsets.applyStatusBarInset(view);
WindowInsetsCompat insets = new WindowInsetsCompat.Builder()
.setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, 30, 0, 0))
.build();
WindowInsetsCompat applied = androidx.core.view.ViewCompat.dispatchApplyWindowInsets(view, insets);
assertEquals(12, view.getPaddingLeft());
assertEquals(46, view.getPaddingTop());
assertEquals(18, view.getPaddingRight());
assertEquals(20, view.getPaddingBottom());
assertEquals(insets, applied);
}
}

View File

@@ -35,6 +35,30 @@ public class GroupCreateActivityTest {
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
}
@Test
public void collectSelectableConversationItems_keepsAllThreadsWhenSourceConversationIsMissing() {
JSONObject threadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "线程一")
.withBoolean("isGroup", false);
JSONObject secondThreadConversation = new StubJSONObject()
.withString("projectId", "thread-2")
.withString("projectTitle", "线程二")
.withBoolean("isGroup", false);
JSONObject groupConversation = new StubJSONObject()
.withString("projectId", "group-1")
.withString("projectTitle", "已有群聊")
.withBoolean("isGroup", true);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", threadConversation, secondThreadConversation, groupConversation);
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
assertEquals(2, filtered.size());
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
assertEquals("thread-2", filtered.get(1).optString("projectId", ""));
}
@Test
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
@@ -57,10 +81,16 @@ public class GroupCreateActivityTest {
public void canCreateGroupChat_blocksWhileRefreshingOrCreating() {
Set<String> selectedProjectIds = linkedSet("thread-1");
assertFalse(GroupCreateActivity.canCreateGroupChat(true, false, selectedProjectIds));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, true, selectedProjectIds));
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, selectedProjectIds));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet()));
assertFalse(GroupCreateActivity.canCreateGroupChat(true, false, selectedProjectIds, true));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, true, selectedProjectIds, true));
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, selectedProjectIds, true));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet(), true));
}
@Test
public void canCreateGroupChat_requiresTwoSelectionsWhenCreatedFromConversationList() {
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet("thread-1"), false));
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet("thread-1", "thread-2"), false));
}
private static Set<String> linkedSet(String... values) {

View File

@@ -0,0 +1,36 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class WechatSurfaceMapperTopActionTest {
@Test
public void rootTopAction_usesPlusForConversations() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false);
assertEquals("+", action.label);
assertTrue(action.primaryStyle);
assertEquals("create_group_chat", action.actionKey);
}
@Test
public void rootTopAction_keepsAddDeviceOnDevicesTab() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false);
assertEquals("+添加", action.label);
assertTrue(action.primaryStyle);
assertEquals("add_device", action.actionKey);
}
@Test
public void rootTopAction_keepsRefreshOnMeTab() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);
assertEquals("同步中", action.label);
assertFalse(action.primaryStyle);
assertEquals("refresh", action.actionKey);
}
}

View File

@@ -56,6 +56,7 @@
- `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`:原生独立群聊创建页
- `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`:原生微信式会话选择页,承接单条转发与多选合并转发
- `android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java`:原生附件发送确认规则与待上传附件模型
- `android/app/src/main/java/com/hyzq/boss/BossWindowInsets.java`:原生顶部安全区处理,负责把状态栏 / 刘海区让出来
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情与技能入口
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
@@ -84,6 +85,7 @@
- `POST /api/v1/storage/config/validate` 正常,已验证可校验并保存阿里 OSS 私有桶配置
- `POST /api/v1/projects/[projectId]/attachments` 正常,已支持图片 / 视频 / 文件上传与附件消息写入
- `POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze` 正常,已支持手动触发主 Agent 附件分析
- `POST /api/v1/group-chats` 正常,已支持从会话首页直接发起独立群聊
- `GET /api/v1/attachments/[attachmentId]/download` 正常,已支持会话鉴权下载和 task token 下载
- `POST /api/auth/login` 正常,会写入 `boss_session`
- `boss_session` 当前默认保持 30 天

View File

@@ -63,6 +63,7 @@
- 第二行显示所属文件夹名
- 右下角显示后台活跃数量动态图标
- `主 Agent / 审计对话` 已作为普通置顶会话固定在顶部
- 会话首页右上角当前为微信式 `+` 入口,可直接从会话列表发起独立群聊
- 当前 `关于` 页:
- 保留版本与 OTA 操作
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
@@ -337,6 +338,17 @@
- 新群聊默认自动命名
- 新群聊默认由主 Agent 发起,并以开发任务协作为默认模式
#### `POST /api/v1/group-chats`
- 用途:从会话首页直接发起独立群聊,不依赖某个来源单线程会话
- 输入:
- `memberProjectIds[]`
- 当前行为:
- 至少要求 2 个线程
- 新群聊会作为独立会话插入列表
- 群名会按成员线程自动生成
- 创建完成后仍可在群资料页改名
#### `GET /api/v1/accounts`
- 用途:返回 AI 账号列表、当前主控身份和切换历史

View File

@@ -90,8 +90,10 @@ cd /Users/kris/code/boss
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
- UI 外壳已收口为真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部一级导航固定在视口底部,背景图按手机 viewport 全屏 coverWebView 不再显示外层圆角矩形预览壳
- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab一级面维持微信式简单列表和聊天优先`主 Agent / 审计对话` 以普通置顶会话样式固定在会话首页顶部
- 会话首页右上角当前已改成微信式 `+` 入口:直接从会话列表发起独立群聊;设备页右上角仍保留 `+添加`
- 会话列表当前已切到“线程 = 聊天窗口”:主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会渲染成多个独立聊天窗口
- 项目详情页右上角当前会进入微信式会话信息页:单线程会话支持改名和发起群聊,群聊会进入群资料页并支持改群名
- 原生顶部安全区当前已统一补上状态栏 inset首页、项目详情、会话信息、群资料、发起群聊和转发目标页的顶部按钮都已退回真机可点击区域
- 项目详情页当前已补齐微信式消息转发:长按消息会弹出 `转发 / 多选 / 复制 / 删除 / 取消`;单条消息直接进入统一会话选择页,多选消息会进入合并转发链路
- 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent``审计对话`
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
@@ -127,6 +129,7 @@ cd /Users/kris/code/boss
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 已改成底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本会自动排队给主 Agent 分析,视频 / Office / 大文件改成手动触发
- `2.5.0` 已上线 `我的 > 附件与存储`:默认使用服务器文件存储,用户可切到阿里 OSS 私有桶并填写最小配置;下载链会使用附件上传时固化的 OSS 快照,避免后续改配置后旧附件失效
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
## 2. 服务器状态

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { createIndependentGroupChat } from "@/lib/boss-data";
export async function POST(request: NextRequest) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const body = (await request.json()) as {
memberProjectIds?: string[];
};
try {
const project = await createIndependentGroupChat({
memberProjectIds: Array.isArray(body.memberProjectIds)
? body.memberProjectIds.filter((memberProjectId) => typeof memberProjectId === "string")
: [],
createdBy: session.account,
});
return NextResponse.json({ ok: true, project });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -4411,84 +4411,129 @@ export async function createProjectGroupChat(input: {
const project = await mutateState((state) => {
const source = state.projects.find((item) => item.id === input.sourceProjectId);
if (!source) throw new Error("GROUP_CHAT_SOURCE_NOT_FOUND");
const requestedProjectIds = [input.sourceProjectId, ...input.memberProjectIds];
const memberProjects: Project[] = [];
const seenProjectIds = new Set<string>();
for (const projectId of requestedProjectIds) {
if (seenProjectIds.has(projectId)) {
continue;
}
seenProjectIds.add(projectId);
const memberProject = state.projects.find((item) => item.id === projectId);
if (!memberProject) {
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
}
memberProjects.push(memberProject);
}
if (memberProjects.length < 2) {
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
}
const now = nowIso();
const projectId = randomToken("project");
const threadId = randomToken("thread");
const threadDisplayName = source.threadMeta.threadDisplayName ?? source.name;
const folderName = source.threadMeta.folderName ?? (source.isGroup ? "群聊" : source.name);
const groupMembers = memberProjects.map((memberProject) => ({
projectId: memberProject.id,
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
threadId: memberProject.threadMeta.threadId,
threadDisplayName: memberProject.threadMeta.threadDisplayName,
folderName: memberProject.threadMeta.folderName,
}));
const nextProject = normalizeProject({
id: projectId,
name: threadDisplayName,
pinned: false,
systemPinned: false,
deviceIds: dedupeStrings(groupMembers.map((member) => member.deviceId)),
preview: `已创建群聊《${threadDisplayName}`,
updatedAt: now,
lastMessageAt: now,
isGroup: true,
unreadCount: 0,
riskLevel: source.riskLevel,
threadMeta: {
projectId,
threadId,
threadDisplayName,
folderName,
activityIconCount: Math.max(1, memberProjects.length),
updatedAt: now,
},
groupMembers,
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
messages: [
{
id: randomToken("msg"),
sender: "master",
senderLabel: input.createdBy || "群聊创建",
body: `已由 ${input.createdBy || "系统"} 创建群聊《${threadDisplayName}》。`,
sentAt: now,
kind: "text",
},
],
goals: [],
versions: [],
return createGroupChatFromProjectIds(state, {
requestedProjectIds: [input.sourceProjectId, ...input.memberProjectIds],
createdBy: input.createdBy,
defaultRiskLevel: source.riskLevel,
});
state.projects.unshift(nextProject);
return nextProject;
});
publishBossEvent("project.messages.updated", { projectId: project.id });
publishBossEvent("conversation.updated", { projectId: project.id });
return project;
}
export async function createIndependentGroupChat(input: {
memberProjectIds: string[];
createdBy: string;
}) {
const project = await mutateState((state) =>
createGroupChatFromProjectIds(state, {
requestedProjectIds: input.memberProjectIds,
createdBy: input.createdBy,
}),
);
publishBossEvent("project.messages.updated", { projectId: project.id });
publishBossEvent("conversation.updated", { projectId: project.id });
return project;
}
function createGroupChatFromProjectIds(
state: BossState,
input: {
requestedProjectIds: string[];
createdBy: string;
defaultRiskLevel?: Project["riskLevel"];
},
) {
const memberProjects: Project[] = [];
const seenProjectIds = new Set<string>();
for (const projectId of input.requestedProjectIds) {
if (!projectId || seenProjectIds.has(projectId)) {
continue;
}
seenProjectIds.add(projectId);
const memberProject = state.projects.find((item) => item.id === projectId);
if (!memberProject) {
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
}
memberProjects.push(memberProject);
}
if (memberProjects.length < 2) {
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
}
const now = nowIso();
const projectId = randomToken("project");
const threadId = randomToken("thread");
const threadDisplayName = buildAutoGroupChatName(memberProjects);
const folderName = "群聊";
const groupMembers = memberProjects.map((memberProject) => ({
projectId: memberProject.id,
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
threadId: memberProject.threadMeta.threadId,
threadDisplayName: memberProject.threadMeta.threadDisplayName,
folderName: memberProject.threadMeta.folderName,
}));
const seedProject = memberProjects[0];
const nextProject = normalizeProject({
id: projectId,
name: threadDisplayName,
pinned: false,
systemPinned: false,
deviceIds: dedupeStrings(groupMembers.map((member) => member.deviceId)),
preview: `已创建群聊《${threadDisplayName}`,
updatedAt: now,
lastMessageAt: now,
isGroup: true,
unreadCount: 0,
riskLevel: input.defaultRiskLevel ?? seedProject?.riskLevel ?? "normal",
threadMeta: {
projectId,
threadId,
threadDisplayName,
folderName,
activityIconCount: Math.max(1, memberProjects.length),
updatedAt: now,
},
groupMembers,
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
messages: [
{
id: randomToken("msg"),
sender: "master",
senderLabel: input.createdBy || "群聊创建",
body: `已由 ${input.createdBy || "系统"} 创建群聊《${threadDisplayName}》。`,
sentAt: now,
kind: "text",
},
],
goals: [],
versions: [],
});
state.projects.unshift(nextProject);
return nextProject;
}
function buildAutoGroupChatName(memberProjects: Project[]) {
const titles = memberProjects
.map((project) => project.threadMeta.threadDisplayName || project.name)
.filter((title) => typeof title === "string" && title.trim().length > 0);
if (titles.length === 0) {
return "新群聊";
}
if (titles.length === 1) {
return titles[0];
}
if (titles.length === 2) {
return `${titles[0]}${titles[1]}`;
}
return `${titles[0]}${titles[1]}${titles.length}个线程`;
}
export async function appendProjectMessage(payload: {
projectId: string;
sender?: MessageSender;