feat: add native forward target picker

This commit is contained in:
kris
2026-03-28 07:22:48 +08:00
parent 9613c3c154
commit c90dea4b7c
6 changed files with 523 additions and 90 deletions

View File

@@ -31,6 +31,7 @@
<activity android:name=".ProjectGoalsActivity" android:exported="false" />
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
<activity android:name=".ProjectForwardActivity" android:exported="false" />
<activity android:name=".ForwardTargetActivity" android:exported="false" />
<activity android:name=".ThreadDetailActivity" android:exported="false" />
<activity android:name=".ConversationInfoActivity" android:exported="false" />
<activity android:name=".GroupInfoActivity" android:exported="false" />

View File

@@ -94,11 +94,10 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
}
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("targetProjectId", targetProjectId);
payload.put("note", note);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", payload);
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
JSONObject requestPayload = payload == null ? new JSONObject() : payload;
requestPayload.put("targetProjectId", targetProjectId);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestPayload);
}
public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException {

View File

@@ -0,0 +1,318 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public class ForwardTargetActivity extends BossScreenActivity {
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
public static final String EXTRA_FORWARD_MODE = "forward_mode";
public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id";
public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids";
private String sourceProjectId;
private String forwardMode;
@Nullable
private String sourceMessageId;
private final ArrayList<String> sourceMessageIds = new ArrayList<>();
@Override
protected int getLayoutResId() {
return R.layout.activity_forward_target;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
sourceProjectId = intent.getStringExtra(EXTRA_SOURCE_PROJECT_ID);
forwardMode = intent.getStringExtra(EXTRA_FORWARD_MODE);
sourceMessageId = intent.getStringExtra(EXTRA_SOURCE_MESSAGE_ID);
String[] messageIds = intent.getStringArrayExtra(EXTRA_SOURCE_MESSAGE_IDS);
if (messageIds != null) {
for (String messageId : messageIds) {
if (!TextUtils.isEmpty(messageId)) {
sourceMessageIds.add(messageId);
}
}
}
configureScreen("选择转发目标", buildSourceMeta());
reload();
}
@Override
protected void reload() {
if (isEmpty(sourceProjectId)) {
showMessage("缺少源会话");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getConversations();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONArray conversations = response.json.optJSONArray("conversations");
List<JSONObject> targets = collectSelectableTargets(conversations, sourceProjectId);
runOnUiThread(() -> renderTargets(targets));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
});
}
});
}
public static List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {
ArrayList<JSONObject> result = new ArrayList<>();
if (conversations == null) {
return result;
}
for (int i = 0; i < conversations.length(); i++) {
JSONObject item = conversations.optJSONObject(i);
if (item == null) {
continue;
}
if (!isEmpty(sourceProjectId) && sourceProjectId.equals(item.optString("projectId", ""))) {
continue;
}
result.add(item);
}
return result;
}
public static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, List<String> sourceMessageIds)
throws JSONException {
MutableJsonObject payload = new MutableJsonObject();
String normalizedMode = isEmpty(mode) ? "single" : mode;
payload.put("mode", normalizedMode);
if (normalizedMode.startsWith("single")) {
String resolvedSourceMessageId = sourceMessageId;
if (isEmpty(resolvedSourceMessageId) && sourceMessageIds != null && sourceMessageIds.size() == 1) {
resolvedSourceMessageId = sourceMessageIds.get(0);
}
if (isEmpty(resolvedSourceMessageId)) {
throw new JSONException("sourceMessageId required");
}
payload.put("sourceMessageId", resolvedSourceMessageId);
return payload;
}
MutableJsonArray orderedIds = new MutableJsonArray();
if (sourceMessageIds != null) {
for (String messageId : sourceMessageIds) {
if (!isEmpty(messageId)) {
orderedIds.put(messageId);
}
}
}
if (orderedIds.length() == 0) {
throw new JSONException("sourceMessageIds required");
}
payload.put("sourceMessageIds", orderedIds);
return payload;
}
private void renderTargets(List<JSONObject> targets) {
replaceContent(
BossUi.buildCard(
this,
"正在选择转发目标",
buildSourceBody(),
buildSourceMeta()
)
);
if (targets.isEmpty()) {
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标会话。"));
setRefreshing(false);
return;
}
for (JSONObject target : targets) {
appendContent(BossUi.buildConversationRow(
this,
WechatSurfaceMapper.toConversationRow(target),
v -> forwardToTarget(target)
));
}
setRefreshing(false);
}
private String buildSourceBody() {
StringBuilder builder = new StringBuilder();
builder.append("源会话:").append(isEmpty(sourceProjectId) ? "-" : sourceProjectId);
builder.append("\n转发模式").append(isEmpty(forwardMode) ? "single" : forwardMode);
return builder.toString();
}
private String buildSourceMeta() {
int messageCount = sourceMessageIds.size();
if (!isEmpty(sourceMessageId)) {
return "source_message_id 已就绪";
}
if (messageCount > 0) {
return "source_message_ids " + messageCount + "";
}
return "等待聊天页入口补充消息选择";
}
private void forwardToTarget(JSONObject target) {
if (target == null) {
showMessage("目标会话无效");
return;
}
String targetProjectId = target.optString("projectId", "");
if (isEmpty(targetProjectId)) {
showMessage("目标会话无效");
return;
}
try {
JSONObject payload = buildForwardPayload(
forwardMode,
sourceMessageId,
sourceMessageIds
);
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(sourceProjectId, targetProjectId, payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
boolean approvalRequired = response.json.optBoolean("approvalRequired", false);
runOnUiThread(() -> {
setRefreshing(false);
if (approvalRequired) {
showMessage("已提交主 Agent 审批");
} else {
showMessage("转发成功");
}
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("转发失败:" + error.getMessage());
});
}
});
} catch (JSONException error) {
showMessage("缺少源消息,暂无法转发");
}
}
private static boolean isEmpty(@Nullable String value) {
return value == null || value.length() == 0;
}
private static final class MutableJsonObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.LinkedHashMap<>();
@Override
public JSONObject put(String key, boolean value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, int value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, long value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, Object value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
String value = optString(key);
return value.isEmpty() ? fallback : value;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
@Override
public boolean optBoolean(String key, boolean fallback) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : fallback;
}
}
private static final class MutableJsonArray extends JSONArray {
private final ArrayList<Object> values = new ArrayList<>();
@Override
public JSONArray put(boolean value) {
values.add(value);
return this;
}
@Override
public JSONArray put(int value) {
values.add(value);
return this;
}
@Override
public JSONArray put(long value) {
values.add(value);
return this;
}
@Override
public JSONArray put(Object value) {
values.add(value);
return this;
}
@Override
public int length() {
return values.size();
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= values.size()) {
return null;
}
Object value = values.get(index);
return value instanceof JSONObject ? (JSONObject) value : null;
}
}
}

View File

@@ -1,106 +1,30 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
public class ProjectForwardActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private String projectId;
private String projectName;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen("消息转发", projectName == null ? "选择目标项目并写备注" : "源项目:" + projectName);
reload();
configureScreen("消息转发", "正在切换到微信式转发");
Intent intent = new Intent(this, ForwardTargetActivity.class);
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy");
startActivity(intent);
finish();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getConversations();
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> renderTargets(response.json.optJSONArray("conversations")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
});
}
});
}
private void renderTargets(@Nullable JSONArray conversations) {
replaceContent(BossUi.buildCard(
this,
"原生转发入口",
"选择一个目标项目,填写备注后会走现有 `/api/v1/projects/{projectId}/forwards`。",
"源项目:" + (projectName == null ? projectId : projectName)
));
if (conversations == null || conversations.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标项目。"));
setRefreshing(false);
return;
}
for (int i = 0; i < conversations.length(); i++) {
JSONObject item = conversations.optJSONObject(i);
if (item == null) continue;
String targetProjectId = item.optString("projectId");
if (projectId.equals(targetProjectId)) continue;
appendContent(BossUi.buildCard(
this,
item.optString("projectTitle", "未命名项目"),
item.optString("preview", ""),
item.optString("latestReplyLabel", "最近更新"),
v -> openForwardDialog(targetProjectId, item.optString("projectTitle", targetProjectId))
));
}
setRefreshing(false);
}
private void openForwardDialog(String targetProjectId, String targetTitle) {
final android.widget.EditText input = BossUi.buildInput(this, "请输入要附带的转发说明", true);
input.setText("请同步关注 " + targetTitle + " 的当前进展。");
new AlertDialog.Builder(this)
.setTitle("转发到 " + targetTitle)
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("转发", (dialog, which) -> forwardMessage(targetProjectId, input.getText().toString().trim()))
.show();
}
private void forwardMessage(String targetProjectId, String note) {
if (note.isEmpty()) {
showMessage("请先填写转发说明");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(projectId, targetProjectId, note);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
setRefreshing(false);
showMessage("转发成功");
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("转发失败:" + error.getMessage());
});
}
});
// 兼容页只负责跳转,不再承载旧的备注转发链路。
}
}

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="14dp">
<Button
android:id="@+id/screen_back_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="标题"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="副标题"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<Button
android:id="@+id/screen_header_action"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="操作"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:visibility="gone" />
<Button
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -0,0 +1,82 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import java.util.List;
public class ForwardTargetActivityTest {
@Test
public void filtersOutSourceConversationFromTargets() {
JSONArray conversations = new StubJSONArray(
new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话"),
new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话")
);
List<JSONObject> result = ForwardTargetActivity.collectSelectableTargets(conversations, "source");
assertEquals(1, result.size());
assertEquals("target", result.get(0).optString("projectId"));
}
@Test
public void singleModeRequiresOneMessageId() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("single", "m1", java.util.List.of());
assertEquals("single", payload.optString("mode"));
assertEquals("m1", payload.optString("sourceMessageId"));
}
@Test
public void bundleModeUsesOrderedMessageIds() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("bundle", null, java.util.List.of("m1", "m2"));
assertEquals("bundle", payload.optString("mode"));
assertEquals(2, payload.optJSONArray("sourceMessageIds").length());
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
Object value = values.get(key);
return value instanceof String ? (String) value : fallback;
}
}
private static final class StubJSONArray extends JSONArray {
private final JSONObject[] values;
StubJSONArray(JSONObject... values) {
this.values = values == null ? new JSONObject[0] : values;
}
@Override
public int length() {
return values.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= values.length) {
return null;
}
return values[index];
}
}
}