feat: add group repair and dispatch rejection flows
This commit is contained in:
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.hyzq.boss"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 17
|
||||
versionName "2.5.4"
|
||||
versionCode 18
|
||||
versionName "2.5.5"
|
||||
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -112,6 +112,16 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse rejectDispatchPlan(String projectId, String planId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/reject",
|
||||
new JSONObject().toString(),
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CHAT_FLOW_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("name", name);
|
||||
@@ -131,6 +141,12 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
|
||||
}
|
||||
|
||||
public ApiResponse replaceConversationParticipants(String projectId, JSONArray memberProjectIds) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("memberProjectIds", memberProjectIds == null ? new JSONArray() : memberProjectIds);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/participants", payload);
|
||||
}
|
||||
|
||||
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("body", body);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
@@ -72,6 +73,10 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
JSONObject threadMeta = project.optJSONObject("threadMeta");
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
int participantCount = participants == null ? 0 : participants.length();
|
||||
boolean repairRequired = participantsPayload.optBoolean("repairRequired", false);
|
||||
String repairReason = participantsPayload.optString("repairReason", "");
|
||||
int validParticipantCount = participantsPayload.optInt("validParticipantCount", participantCount);
|
||||
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
||||
configureScreen("群资料", buildSubtitle(folderName, participantCount));
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
@@ -90,6 +95,20 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
|
||||
if (repairRequired) {
|
||||
String meta = invalidParticipantCount > 0
|
||||
? "存在 " + invalidParticipantCount + " 个失效成员"
|
||||
: "当前仅有 " + validParticipantCount + " 个真实线程成员";
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"修复群成员",
|
||||
TextUtils.isEmpty(repairReason) ? "重新选择要加入群聊的真实线程" : repairReason,
|
||||
meta,
|
||||
"推荐",
|
||||
v -> openRepairMembersDialog(participantsPayload)
|
||||
));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"群成员",
|
||||
@@ -121,6 +140,9 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
|
||||
private LinearLayout buildMemberRow(JSONObject participant) {
|
||||
boolean sourceProject = participant.optBoolean("isSourceProject", false);
|
||||
boolean canOpenProject = participant.optBoolean("canOpenProject", true);
|
||||
String status = participant.optString("status", "active");
|
||||
String statusLabel = participant.optString("statusLabel", "");
|
||||
String participantProjectId = participant.optString("projectId", "");
|
||||
String title = participant.optString("threadDisplayName", "未命名线程");
|
||||
String subtitle = participant.optString("folderName", "");
|
||||
@@ -132,16 +154,120 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
if (sourceProject) {
|
||||
subtitle = subtitle.isEmpty() ? "当前群聊" : "当前群聊 · " + subtitle;
|
||||
}
|
||||
if (!statusLabel.isEmpty() && !"active".equals(status)) {
|
||||
subtitle = subtitle.isEmpty() ? statusLabel : subtitle + " · " + statusLabel;
|
||||
}
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
sourceProject ? "当前" : null,
|
||||
v -> openProject(participantProjectId, title)
|
||||
sourceProject ? "当前" : (!"active".equals(status) ? "失效" : null),
|
||||
canOpenProject ? v -> openProject(participantProjectId, title) : null
|
||||
);
|
||||
}
|
||||
|
||||
private void openRepairMembersDialog(JSONObject participantsPayload) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations();
|
||||
if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message());
|
||||
runOnUiThread(() -> showRepairMembersPicker(participantsPayload, conversationsResponse.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("加载可选线程失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showRepairMembersPicker(JSONObject participantsPayload, JSONObject conversationsPayload) {
|
||||
JSONArray conversations = conversationsPayload.optJSONArray("conversations");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
java.util.Set<String> selectedProjectIds = new java.util.LinkedHashSet<>();
|
||||
if (participants != null) {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
if (!"active".equals(participant.optString("status", "active"))) continue;
|
||||
String participantProjectId = participant.optString("projectId", "").trim();
|
||||
if (!participantProjectId.isEmpty()) {
|
||||
selectedProjectIds.add(participantProjectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java.util.ArrayList<String> labels = new java.util.ArrayList<>();
|
||||
java.util.ArrayList<String> projectIds = new java.util.ArrayList<>();
|
||||
java.util.ArrayList<Boolean> checkedValues = new java.util.ArrayList<>();
|
||||
if (conversations != null) {
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject conversation = conversations.optJSONObject(i);
|
||||
if (conversation == null) continue;
|
||||
if (!"single_device".equals(conversation.optString("conversationType", ""))) continue;
|
||||
String candidateProjectId = conversation.optString("projectId", "").trim();
|
||||
if (candidateProjectId.isEmpty() || candidateProjectId.equals(projectId)) continue;
|
||||
String title = conversation.optString("threadTitle", conversation.optString("projectTitle", "未命名线程"));
|
||||
String folderLabel = conversation.optString("folderLabel", "");
|
||||
labels.add(TextUtils.isEmpty(folderLabel) ? title : title + " · " + folderLabel);
|
||||
projectIds.add(candidateProjectId);
|
||||
checkedValues.add(selectedProjectIds.contains(candidateProjectId));
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.isEmpty()) {
|
||||
setRefreshing(false);
|
||||
showMessage("当前没有可加入群聊的真实线程");
|
||||
return;
|
||||
}
|
||||
|
||||
CharSequence[] items = labels.toArray(new CharSequence[0]);
|
||||
boolean[] checked = new boolean[checkedValues.size()];
|
||||
for (int i = 0; i < checked.length; i++) {
|
||||
checked[i] = checkedValues.get(i);
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("修复群成员")
|
||||
.setMessage("请选择要加入这个群聊的真实线程。")
|
||||
.setMultiChoiceItems(items, checked, (dialog, which, isChecked) -> checked[which] = isChecked)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("应用", (dialog, which) -> applyGroupRepair(projectIds, checked))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void applyGroupRepair(java.util.List<String> candidateProjectIds, boolean[] checked) {
|
||||
JSONArray memberProjectIds = new JSONArray();
|
||||
for (int i = 0; i < checked.length && i < candidateProjectIds.size(); i++) {
|
||||
if (checked[i]) {
|
||||
memberProjectIds.put(candidateProjectIds.get(i));
|
||||
}
|
||||
}
|
||||
if (memberProjectIds.length() < 2) {
|
||||
showMessage("群聊至少需要 2 个真实线程成员");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("群成员已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("修复失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openProject(String targetProjectId, String targetProjectName) {
|
||||
if (targetProjectId == null || targetProjectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
|
||||
@@ -598,7 +598,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setNegativeButton("稍后", null)
|
||||
.setNegativeButton("拒绝", (dialog, which) -> rejectDispatchPlan(dispatchPlan))
|
||||
.setNeutralButton("稍后", null)
|
||||
.setPositiveButton("确认下发", (dialog, which) -> confirmDispatchPlan(dispatchPlan))
|
||||
.show();
|
||||
}
|
||||
@@ -651,6 +652,36 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void rejectDispatchPlan(JSONObject dispatchPlan) {
|
||||
String planId = dispatchPlan.optString("planId", "").trim();
|
||||
if (planId.isEmpty()) {
|
||||
showMessage("缺少调度方案 ID");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.rejectDispatchPlan(projectId, planId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
currentPendingDispatchPlan = null;
|
||||
composerSending = false;
|
||||
projectApprovalState = "rejected";
|
||||
updateComposerSendButtonState();
|
||||
showMessage("已拒绝主 Agent 推荐");
|
||||
reload(true);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("拒绝失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private View buildMessageView(JSONObject message) {
|
||||
String messageId = message.optString("id", "");
|
||||
String senderLabel = message.optString("senderLabel", "消息");
|
||||
|
||||
@@ -54,6 +54,21 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectDispatchPlanUsesProjectScopedRejectEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/reject"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.rejectDispatchPlan("p1", "plan-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/reject", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(65000, connection.readTimeoutValue);
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendProjectMessageUsesExtendedReadTimeoutForMasterAgent() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages"));
|
||||
|
||||
@@ -92,6 +92,29 @@ public class GroupInfoActivityTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderGroupShowsRepairEntryForDirtyMembers() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
|
||||
TestGroupInfoActivity activity = Robolectric
|
||||
.buildActivity(TestGroupInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderGroup",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildRepairParticipantsPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "修复群成员"));
|
||||
assertTrue(viewTreeContainsText(content, "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"));
|
||||
assertTrue(viewTreeContainsText(content, "失效"));
|
||||
}
|
||||
|
||||
private static JSONObject buildDetailPayload() throws Exception {
|
||||
JSONObject threadMeta = new JSONObject()
|
||||
.put("threadId", "group-thread-3")
|
||||
@@ -123,6 +146,25 @@ public class GroupInfoActivityTest {
|
||||
return new JSONObject().put("participants", participants);
|
||||
}
|
||||
|
||||
private static JSONObject buildRepairParticipantsPayload() throws Exception {
|
||||
JSONArray participants = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("threadDisplayName", "主 Agent 汇总")
|
||||
.put("folderName", "主控线程")
|
||||
.put("deviceId", "Mac Studio")
|
||||
.put("threadId", "master-agent-thread")
|
||||
.put("status", "invalid_target")
|
||||
.put("statusLabel", "不是可下发线程")
|
||||
.put("canOpenProject", true));
|
||||
return new JSONObject()
|
||||
.put("participants", participants)
|
||||
.put("repairRequired", true)
|
||||
.put("repairReason", "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。")
|
||||
.put("validParticipantCount", 0)
|
||||
.put("invalidParticipantCount", 1);
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
|
||||
Reference in New Issue
Block a user