Compare commits
33 Commits
codex/nati
...
a5e8ba2b7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e8ba2b7e | ||
|
|
cc08ca28aa | ||
|
|
a3a7f43626 | ||
|
|
64ad401d0c | ||
|
|
d2291af32c | ||
|
|
7109f1d3db | ||
|
|
200fc18210 | ||
|
|
13c67425ab | ||
|
|
0783f4da14 | ||
|
|
42063db78f | ||
|
|
c90dea4b7c | ||
|
|
9613c3c154 | ||
|
|
227d270505 | ||
|
|
b606af66f6 | ||
|
|
a9e8bb9ddd | ||
|
|
f0735b31e5 | ||
|
|
afa7e79ad2 | ||
|
|
e27ea1e071 | ||
|
|
0a3390b132 | ||
|
|
4dbf4ac1de | ||
|
|
6559ad5bce | ||
|
|
ae571a76ff | ||
|
|
63ceef9871 | ||
|
|
8da592bddf | ||
|
|
9e0b5b223f | ||
|
|
ff56617fdb | ||
|
|
05e26afbf1 | ||
|
|
b794ba05fa | ||
|
|
ce8dcad41c | ||
|
|
17300c49ea | ||
|
|
efcefd8a62 | ||
|
|
785db90a7a | ||
|
|
8439428479 |
23
README.md
23
README.md
@@ -33,7 +33,7 @@
|
||||
- `src/boss_control`:空占位目录,不参与当前运行
|
||||
- `src/boss_device_agent`:空占位目录,不参与当前运行
|
||||
|
||||
## 当前运行状态(2026-03-26)
|
||||
## 当前运行状态(2026-03-28)
|
||||
|
||||
本地:
|
||||
|
||||
@@ -90,13 +90,23 @@ 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.1.1`(`versionCode=8`)
|
||||
- 当前最新 release 构建版本:`2.4.0`(`versionCode=12`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、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 / 审计对话` 普通置顶会话化
|
||||
- `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片
|
||||
|
||||
## 本地启动
|
||||
|
||||
@@ -180,6 +190,8 @@ device-agent 当前职责:
|
||||
- Android 原生会话页:`android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Android 原生设备页:`android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Android 原生我的页:`android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`、`android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`、`android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Android 微信式 surface contract:`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Android 聊天页布局:`android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- 服务器环境示例:`.env.server.example`
|
||||
|
||||
当前 `scripts/deploy-server.sh`:
|
||||
@@ -244,6 +256,8 @@ npm run aab:release
|
||||
- Web 端根布局当前仍保留 `NativeAppBridge`,用于浏览器态与历史桥接兼容;当前正式 APK 已改为原生 Activity + 原生 API 客户端,不再依赖 WebView
|
||||
- APP 日志桥已经改成会话感知:只会按当前登录账号解析绑定设备,不再在未登录页默认按全局管理员设备写日志
|
||||
- APP 外壳已经从“桌面预览卡片”切回真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部 `会话 / 设备 / 我的` 导航固定在视口底部,背景改为全屏 cover,不再出现圆角矩形外壳
|
||||
- 原生 Android 当前也和这套产品口径对齐:根页采用微信式简单列表,项目聊天页改成消息流优先,`设备 / 我的` 页不再展示控制台式统计卡片
|
||||
- 原生聊天页当前会即时渲染本地发送中消息,并且只有在用户接近底部或本次发送是主动触发时才自动滚到底
|
||||
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
|
||||
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
|
||||
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句
|
||||
@@ -259,6 +273,9 @@ npm run aab:release
|
||||
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
|
||||
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
|
||||
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
|
||||
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
|
||||
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
|
||||
- 当前默认最高管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
|
||||
|
||||
@@ -13,6 +13,9 @@ android {
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
@@ -33,8 +36,8 @@ android {
|
||||
applicationId "com.hyzq.boss"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 8
|
||||
versionName "2.1.1"
|
||||
versionCode 12
|
||||
versionName "2.4.0"
|
||||
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -56,6 +59,7 @@ dependencies {
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
testImplementation "org.robolectric:robolectric:4.14.1"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@
|
||||
<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" />
|
||||
<activity android:name=".GroupCreateActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceDetailActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" />
|
||||
|
||||
@@ -9,8 +9,9 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -19,8 +20,34 @@ import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class AboutActivity extends BossScreenActivity {
|
||||
private static final long OTA_PROGRESS_POLL_INTERVAL_MS = 1_000L;
|
||||
private static final String OTA_UI_PREFS = "boss_native_client";
|
||||
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;
|
||||
private long completedDownloadId = -1L;
|
||||
private @Nullable JSONObject otaPayload;
|
||||
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;
|
||||
private final Handler otaProgressHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable otaProgressPoller = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
refreshDownloadStateSection();
|
||||
if (activeDownloadId > 0) {
|
||||
otaProgressHandler.postDelayed(this, OTA_PROGRESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final BroadcastReceiver otaDownloadReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
@@ -32,7 +59,6 @@ public class AboutActivity extends BossScreenActivity {
|
||||
if (downloadId <= 0 || downloadId != activeDownloadId) {
|
||||
return;
|
||||
}
|
||||
activeDownloadId = -1L;
|
||||
handleCompletedDownload(downloadId);
|
||||
}
|
||||
};
|
||||
@@ -40,7 +66,8 @@ public class AboutActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("关于 / OTA", "原生版本中心");
|
||||
configureScreen("关于", "版本与 OTA 更新");
|
||||
restoreDownloadUiState();
|
||||
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
@@ -52,6 +79,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
try {
|
||||
unregisterReceiver(otaDownloadReceiver);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
@@ -67,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(() -> {
|
||||
@@ -85,69 +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.buildCard(
|
||||
this,
|
||||
"当前版本",
|
||||
user.optString("version", "-")
|
||||
+ "\n当前账号:" + user.optString("account", "-")
|
||||
+ "\n绑定 Codex:" + user.optString("boundCodexNodeLabel", "未绑定"),
|
||||
session == null ? "-" : "会话到期 " + session.optString("expiresAt", "-")
|
||||
));
|
||||
}
|
||||
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
String otaBody = availableRelease == null
|
||||
? "当前已经是最新版本。"
|
||||
: availableRelease.optString("version", "未知版本")
|
||||
+ "\n" + availableRelease.optString("summary", "暂无摘要")
|
||||
+ "\n文件:" + availableRelease.optString("packageFileName", "-");
|
||||
appendContent(BossUi.buildCard(
|
||||
invalidateStaleDownloadedApk(ota.optJSONObject("availableRelease"));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"OTA 状态",
|
||||
otaBody,
|
||||
"当前版本 " + ota.optString("currentVersion", "-")
|
||||
"当前版本",
|
||||
user == null ? ota.optString("currentVersion", "-") : user.optString("version", ota.optString("currentVersion", "-")),
|
||||
"已安装版本",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
LinearLayout actionCard = BossUi.buildCard(this, "OTA 操作", "可在原生页直接检查更新、登记 OTA 并下载 APK。", "当前接口:/api/v1/user/ota");
|
||||
Button check = BossUi.buildPrimaryButton(this, "检查更新");
|
||||
check.setOnClickListener(v -> performOtaAction("check"));
|
||||
actionCard.addView(check);
|
||||
Button apply = BossUi.buildSecondaryButton(this, "登记应用 OTA");
|
||||
apply.setOnClickListener(v -> performOtaAction("apply"));
|
||||
actionCard.addView(apply);
|
||||
Button download = BossUi.buildSecondaryButton(this, "应用内下载 APK");
|
||||
download.setOnClickListener(v -> downloadLatestApk());
|
||||
actionCard.addView(download);
|
||||
appendContent(actionCard);
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"OTA 状态",
|
||||
buildOtaStatusSubtitle(ota),
|
||||
buildOtaStatusMeta(ota),
|
||||
availableRelease == null ? null : "OTA",
|
||||
null
|
||||
));
|
||||
|
||||
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.buildCard(
|
||||
this,
|
||||
log.optString("version", "OTA"),
|
||||
log.optString("summary", ""),
|
||||
log.optString("status", "-") + " · " + log.optString("createdAt", "-")
|
||||
));
|
||||
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();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -159,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 {
|
||||
@@ -187,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);
|
||||
@@ -198,7 +335,18 @@ public class AboutActivity extends BossScreenActivity {
|
||||
request.addRequestHeader("x-boss-native-app", "1");
|
||||
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
|
||||
|
||||
downloadedApkUri = null;
|
||||
lastDownloadFileName = fileName;
|
||||
lastDownloadVersion = releaseVersion;
|
||||
lastDownloadStatus = DownloadManager.STATUS_PENDING;
|
||||
lastDownloadedBytes = 0L;
|
||||
lastTotalBytes = -1L;
|
||||
completedDownloadId = -1L;
|
||||
activeDownloadId = manager.enqueue(request);
|
||||
persistDownloadUiState();
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
otaProgressHandler.post(otaProgressPoller);
|
||||
refreshDownloadStateSection();
|
||||
showMessage("已开始下载,完成后会自动拉起安装。");
|
||||
}
|
||||
|
||||
@@ -213,10 +361,23 @@ public class AboutActivity extends BossScreenActivity {
|
||||
try (android.database.Cursor cursor = manager.query(query)) {
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
showMessage("下载完成,但无法读取文件信息");
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
lastDownloadStatus = DownloadManager.STATUS_FAILED;
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
return;
|
||||
}
|
||||
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
||||
if (status != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
lastDownloadStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
||||
lastDownloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
|
||||
lastTotalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
|
||||
if (lastDownloadStatus != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
downloadedApkUri = null;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
showMessage("下载未成功完成");
|
||||
return;
|
||||
}
|
||||
@@ -224,10 +385,23 @@ public class AboutActivity extends BossScreenActivity {
|
||||
|
||||
Uri apkUri = manager.getUriForDownloadedFile(downloadId);
|
||||
if (apkUri == null) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
lastDownloadStatus = DownloadManager.STATUS_FAILED;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
showMessage("下载完成,但找不到安装包");
|
||||
return;
|
||||
}
|
||||
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = downloadId;
|
||||
downloadedApkUri = apkUri;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
@@ -241,4 +415,262 @@ public class AboutActivity extends BossScreenActivity {
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(installIntent);
|
||||
}
|
||||
|
||||
private void refreshDownloadStateSection() {
|
||||
if (otaDownloadStateSection == null) {
|
||||
return;
|
||||
}
|
||||
otaDownloadStateSection.removeAllViews();
|
||||
OtaDownloadStateMapper.UiState uiState = resolveDownloadUiState();
|
||||
if (uiState == null) {
|
||||
return;
|
||||
}
|
||||
otaDownloadStateSection.addView(BossUi.buildListRow(
|
||||
this,
|
||||
uiState.title,
|
||||
uiState.subtitle,
|
||||
uiState.meta,
|
||||
uiState.badge,
|
||||
null
|
||||
));
|
||||
if (uiState.actionKind != OtaDownloadStateMapper.ActionKind.NONE) {
|
||||
otaDownloadStateSection.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
uiState.actionLabel,
|
||||
uiState.subtitle,
|
||||
null,
|
||||
v -> performDownloadStateAction(uiState.actionKind)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
if (snapshot != null) {
|
||||
lastDownloadStatus = snapshot.status;
|
||||
lastDownloadedBytes = snapshot.bytesDownloaded;
|
||||
lastTotalBytes = snapshot.totalBytes;
|
||||
boolean hasKnownTotal = snapshot.totalBytes > 0;
|
||||
int percent = hasKnownTotal
|
||||
? (int) Math.round((snapshot.bytesDownloaded * 100.0d) / snapshot.totalBytes)
|
||||
: 0;
|
||||
if (snapshot.status == DownloadManager.STATUS_RUNNING
|
||||
|| snapshot.status == DownloadManager.STATUS_PENDING
|
||||
|| snapshot.status == DownloadManager.STATUS_PAUSED) {
|
||||
return OtaDownloadStateMapper.active(fileName, percent, hasKnownTotal, snapshot.bytesDownloaded, snapshot.totalBytes);
|
||||
}
|
||||
if (snapshot.status == DownloadManager.STATUS_FAILED) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastDownloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
|
||||
}
|
||||
return OtaDownloadStateMapper.readyToInstall(fileName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void performDownloadStateAction(OtaDownloadStateMapper.ActionKind actionKind) {
|
||||
switch (actionKind) {
|
||||
case RETRY_DOWNLOAD:
|
||||
downloadLatestApk();
|
||||
break;
|
||||
case OPEN_INSTALL_PERMISSION:
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
break;
|
||||
case INSTALL_APK:
|
||||
installDownloadedApk();
|
||||
break;
|
||||
case NONE:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void installDownloadedApk() {
|
||||
if (downloadedApkUri == null) {
|
||||
showMessage("当前没有可安装的更新包");
|
||||
return;
|
||||
}
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
showMessage("请先开启安装未知来源应用权限");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
return;
|
||||
}
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
installIntent.setDataAndType(downloadedApkUri, "application/vnd.android.package-archive");
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(installIntent);
|
||||
}
|
||||
|
||||
private String resolveDownloadFileName() {
|
||||
if (lastDownloadFileName != null && !lastDownloadFileName.isEmpty()) {
|
||||
return lastDownloadFileName;
|
||||
}
|
||||
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
|
||||
if (availableRelease != null) {
|
||||
return availableRelease.optString("packageFileName", "boss-android-latest.apk");
|
||||
}
|
||||
return "boss-android-latest.apk";
|
||||
}
|
||||
|
||||
private void restoreDownloadUiState() {
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
|
||||
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);
|
||||
if (manager != null) {
|
||||
downloadedApkUri = manager.getUriForDownloadedFile(completedDownloadId);
|
||||
}
|
||||
}
|
||||
if (activeDownloadId > 0) {
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
otaProgressHandler.post(otaProgressPoller);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDownloadUiState() {
|
||||
getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.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);
|
||||
if (manager == null) {
|
||||
return null;
|
||||
}
|
||||
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
|
||||
try (android.database.Cursor cursor = manager.query(query)) {
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
return null;
|
||||
}
|
||||
return new DownloadProgressSnapshot(
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DownloadProgressSnapshot {
|
||||
private final int status;
|
||||
private final long bytesDownloaded;
|
||||
private final long totalBytes;
|
||||
|
||||
private DownloadProgressSnapshot(int status, long bytesDownloaded, long totalBytes) {
|
||||
this.status = status;
|
||||
this.bytesDownloaded = bytesDownloaded;
|
||||
this.totalBytes = totalBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -20,14 +19,12 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"};
|
||||
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"};
|
||||
|
||||
private LinearLayout accountList;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾");
|
||||
setHeaderAction("新增", v -> openAccountEditor(null, null));
|
||||
replaceContent(buildIntroCard(), buildAccountListShell());
|
||||
replaceContent();
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -48,56 +45,48 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private LinearLayout buildIntroCard() {
|
||||
return BossUi.buildCard(
|
||||
this,
|
||||
"账号说明",
|
||||
"当前页面管理 Boss 的主控 AI 账号。主链路优先使用已绑定电脑上的 Master Codex Node,API 容灾在同页可补充配置。",
|
||||
"支持新增、编辑、激活、校验和删除"
|
||||
);
|
||||
}
|
||||
|
||||
private LinearLayout buildAccountListShell() {
|
||||
LinearLayout wrapper = new LinearLayout(this);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
accountList = new LinearLayout(this);
|
||||
accountList.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.addView(accountList);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private void renderAccounts(JSONObject payload) {
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONObject activeIdentity = payload.optJSONObject("activeIdentity");
|
||||
JSONArray switchHistory = payload.optJSONArray("switchHistory");
|
||||
|
||||
accountList.removeAllViews();
|
||||
replaceContent(buildIntroCard(), buildActiveIdentityCard(activeIdentity), buildAccountsSection(accounts), buildSwitchHistoryCard(switchHistory));
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"AI 账号",
|
||||
"这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
|
||||
"轻点条目可编辑,按钮可切换、校验或删除。"
|
||||
));
|
||||
appendContent(buildActiveIdentityCard(activeIdentity));
|
||||
appendContent(buildAccountsSection(accounts));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) {
|
||||
String body = activeIdentity == null
|
||||
? "当前没有可用的主控身份。"
|
||||
: activeIdentity.optString("label", "AI 账号")
|
||||
+ "\n" + activeIdentity.optString("displayName", "-")
|
||||
+ "\n" + activeIdentity.optString("providerLabel", "-")
|
||||
+ (activeIdentity.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + activeIdentity.optString("nodeLabel"));
|
||||
String meta = activeIdentity == null
|
||||
? "请先配置一个可用账号"
|
||||
: activeIdentity.optString("roleLabel", "-") + " · " + activeIdentity.optString("statusLabel", "-");
|
||||
return BossUi.buildCard(this, "当前主控身份", body, meta);
|
||||
if (activeIdentity == null) {
|
||||
return BossUi.buildSoftPanel(this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。");
|
||||
}
|
||||
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.buildCard(
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"账号列表",
|
||||
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点击卡片可编辑,按钮可激活 / 校验 / 删除。",
|
||||
"当前 API:/api/v1/accounts"
|
||||
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点开可编辑,按钮可激活、校验或删除。",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (accounts == null || accounts.length() == 0) {
|
||||
@@ -118,66 +107,38 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
String meta = account.optString("roleLabel", "-")
|
||||
+ " · " + account.optString("providerLabel", "-")
|
||||
+ " · " + statusLabel
|
||||
+ (account.optBoolean("isActive") ? " · 当前主控" : "")
|
||||
+ (account.optBoolean("apiKeyConfigured") ? " · 已配置 Key" : "");
|
||||
String body = account.optString("displayName", "-")
|
||||
+ "\n账号:" + account.optString("accountIdentifier", "-")
|
||||
+ (account.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + account.optString("nodeLabel"))
|
||||
+ (account.optString("loginStatusNote").isEmpty() ? "" : "\n" + account.optString("loginStatusNote"));
|
||||
StringBuilder subtitle = new StringBuilder(account.optString("displayName", "-"));
|
||||
if (!account.optString("accountIdentifier").isEmpty()) {
|
||||
subtitle.append(" · ").append(account.optString("accountIdentifier", "-"));
|
||||
}
|
||||
if (!account.optString("nodeLabel").isEmpty()) {
|
||||
subtitle.append(" · ").append(account.optString("nodeLabel", "-"));
|
||||
}
|
||||
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
account.optString("label", "未命名账号"),
|
||||
body,
|
||||
subtitle.toString(),
|
||||
meta,
|
||||
account.optBoolean("isActive") ? "当前" : null,
|
||||
v -> openAccountEditor(account, null)
|
||||
);
|
||||
|
||||
Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控");
|
||||
activate.setEnabled(!account.optBoolean("isActive"));
|
||||
activate.setOnClickListener(v -> activateAccount(account));
|
||||
card.addView(activate);
|
||||
|
||||
Button validate = BossUi.buildSecondaryButton(this, "校验连接");
|
||||
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, "删除账号");
|
||||
delete.setOnClickListener(v -> confirmDeleteAccount(account));
|
||||
card.addView(delete);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private LinearLayout buildSwitchHistoryCard(@Nullable JSONArray switchHistory) {
|
||||
LinearLayout section = new LinearLayout(this);
|
||||
section.setOrientation(LinearLayout.VERTICAL);
|
||||
section.addView(BossUi.buildCard(
|
||||
this,
|
||||
"切换历史",
|
||||
switchHistory == null || switchHistory.length() == 0 ? "当前没有切换记录。" : "最近切换记录会保留 40 条。",
|
||||
"用于追踪主控身份变化"
|
||||
));
|
||||
|
||||
if (switchHistory == null || switchHistory.length() == 0) {
|
||||
section.addView(BossUi.buildEmptyCard(this, "当前没有 AI 账号切换历史。"));
|
||||
return section;
|
||||
}
|
||||
Button activate = BossUi.buildMiniActionButton(this, account.optBoolean("isActive") ? "当前主控" : "设为当前", !account.optBoolean("isActive"));
|
||||
activate.setEnabled(!account.optBoolean("isActive"));
|
||||
activate.setOnClickListener(v -> activateAccount(account));
|
||||
|
||||
for (int i = 0; i < switchHistory.length(); i++) {
|
||||
JSONObject record = switchHistory.optJSONObject(i);
|
||||
if (record == null) continue;
|
||||
String body = "从 " + record.optString("fromLabel", "无")
|
||||
+ "\n到 " + record.optString("toLabel", "-")
|
||||
+ "\n原因:" + record.optString("reason", "-");
|
||||
String meta = record.optString("role", "-") + " · " + record.optString("switchedAt", "-");
|
||||
section.addView(BossUi.buildCard(this, "切换记录", body, meta));
|
||||
}
|
||||
return section;
|
||||
Button validate = BossUi.buildMiniActionButton(this, "校验连接", false);
|
||||
validate.setOnClickListener(v -> validateAccount(account));
|
||||
|
||||
Button delete = BossUi.buildMiniActionButton(this, "删除账号", false);
|
||||
delete.setOnClickListener(v -> confirmDeleteAccount(account));
|
||||
card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) {
|
||||
@@ -217,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 账号")
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -31,8 +33,12 @@ public class BossApiClient {
|
||||
private final String baseUrl;
|
||||
|
||||
public BossApiClient(Context context) {
|
||||
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
this.baseUrl = BuildConfig.BOSS_API_BASE_URL;
|
||||
this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), BuildConfig.BOSS_API_BASE_URL);
|
||||
}
|
||||
|
||||
BossApiClient(SharedPreferences prefs, String baseUrl) {
|
||||
this.prefs = prefs;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public boolean hasSessionHints() {
|
||||
@@ -72,6 +78,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);
|
||||
@@ -79,11 +100,9 @@ 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 {
|
||||
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
|
||||
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
|
||||
}
|
||||
|
||||
public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException {
|
||||
@@ -230,18 +249,26 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
|
||||
ApiResponse response = request(method, path, body, true);
|
||||
return requestWithRestoreRaw(method, path, body == null ? null : body.toString());
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException {
|
||||
ApiResponse response = requestRaw(method, path, body, true);
|
||||
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return request(method, path, body, true);
|
||||
return requestRaw(method, path, body, true);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
return requestRaw(method, path, body == null ? null : body.toString(), expectProtected);
|
||||
}
|
||||
|
||||
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection(path);
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
@@ -260,7 +287,7 @@ public class BossApiClient {
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body.toString());
|
||||
writer.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +305,10 @@ public class BossApiClient {
|
||||
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
|
||||
}
|
||||
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
if (stream == null) {
|
||||
return new JSONObject();
|
||||
@@ -316,7 +347,7 @@ public class BossApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberIdentity(JSONObject json) {
|
||||
void rememberIdentity(JSONObject json) {
|
||||
if (json == null) return;
|
||||
JSONObject session = json.optJSONObject("session");
|
||||
JSONObject source = session != null ? session : json;
|
||||
@@ -347,7 +378,7 @@ public class BossApiClient {
|
||||
.apply();
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
String encode(String value) {
|
||||
return Uri.encode(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_screen);
|
||||
setContentView(getLayoutResId());
|
||||
apiClient = new BossApiClient(this);
|
||||
|
||||
backButton = findViewById(R.id.screen_back_button);
|
||||
@@ -44,6 +44,10 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
refreshLayout.setOnRefreshListener(this::reload);
|
||||
}
|
||||
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_screen;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -23,7 +22,7 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
|
||||
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
|
||||
configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情");
|
||||
configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态与绑定项目");
|
||||
setHeaderAction("编辑", v -> openEditDialog());
|
||||
reload();
|
||||
}
|
||||
@@ -48,8 +47,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
private void renderDevice(JSONObject payload) {
|
||||
JSONObject workspace = payload.optJSONObject("workspace");
|
||||
JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice");
|
||||
JSONArray relatedThreads = workspace == null ? null : workspace.optJSONArray("relatedThreads");
|
||||
JSONObject enrollment = workspace == null ? null : workspace.optJSONObject("activeEnrollment");
|
||||
|
||||
replaceContent();
|
||||
if (device == null) {
|
||||
@@ -59,56 +56,28 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
deviceName = device.optString("name", deviceId);
|
||||
configureScreen(deviceName, device.optString("endpoint", "设备详情"));
|
||||
appendContent(BossUi.buildCard(
|
||||
configureScreen(deviceName, "设备状态与绑定项目");
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device);
|
||||
appendContent(BossUi.buildDeviceCard(
|
||||
this,
|
||||
device.optString("name", "设备"),
|
||||
device.optString("note", "暂无备注"),
|
||||
"状态 " + device.optString("status", "unknown")
|
||||
+ " · 账号 " + device.optString("account", "-")
|
||||
+ " · 5h " + device.optInt("quota5h", 0)
|
||||
+ " · 7d " + device.optInt("quota7d", 0)
|
||||
WechatSurfaceMapper.toDeviceRow(device),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
Button skillsButton = BossUi.buildPrimaryButton(this, "查看技能");
|
||||
skillsButton.setOnClickListener(v -> openSkills());
|
||||
appendContent(skillsButton);
|
||||
|
||||
if (relatedThreads != null && relatedThreads.length() > 0) {
|
||||
for (int i = 0; i < relatedThreads.length(); i++) {
|
||||
JSONObject thread = relatedThreads.optJSONObject(i);
|
||||
if (thread == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
thread.optString("title", "线程"),
|
||||
thread.optString("summary", ""),
|
||||
thread.optString("workerId", "-")
|
||||
+ " · " + thread.optInt("contextBudgetRemainingPct", 0) + "%"
|
||||
+ " · " + thread.optString("contextBudgetLevel", "safe"),
|
||||
v -> openThread(thread.optString("threadId"))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (enrollment != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
if (summary.meta != null && !summary.meta.isEmpty()) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前绑定草稿",
|
||||
"pairingCode " + enrollment.optString("pairingCode", "-")
|
||||
+ "\ntoken " + enrollment.optString("token", "-"),
|
||||
enrollment.optString("status", "ready")
|
||||
+ " · 到期 " + enrollment.optString("expiresAt", "-")
|
||||
"设备说明",
|
||||
summary.meta,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void openThread(String threadId) {
|
||||
Intent intent = new Intent(this, ThreadDetailActivity.class);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openSkills() {
|
||||
Intent intent = new Intent(this, SkillInventoryActivity.class);
|
||||
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, deviceId);
|
||||
|
||||
@@ -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", "-"))
|
||||
|
||||
248
android/app/src/main/java/com/hyzq/boss/ForwardPayloads.java
Normal file
248
android/app/src/main/java/com/hyzq/boss/ForwardPayloads.java
Normal file
@@ -0,0 +1,248 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ForwardPayloads {
|
||||
private ForwardPayloads() {}
|
||||
|
||||
public static JSONObject build(
|
||||
String mode,
|
||||
@Nullable String sourceMessageId,
|
||||
@Nullable 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;
|
||||
}
|
||||
|
||||
public static String toRequestBody(String targetProjectId, @Nullable JSONObject payload) throws JSONException {
|
||||
MutableJsonObject requestPayload = new MutableJsonObject();
|
||||
requestPayload.put("targetProjectId", targetProjectId);
|
||||
if (payload == null) {
|
||||
return requestPayload.toString();
|
||||
}
|
||||
|
||||
String mode = payload.optString("mode", "");
|
||||
if (!isEmpty(mode)) {
|
||||
requestPayload.put("mode", mode);
|
||||
}
|
||||
|
||||
String sourceMessageId = payload.optString("sourceMessageId", "");
|
||||
if (!isEmpty(sourceMessageId)) {
|
||||
requestPayload.put("sourceMessageId", sourceMessageId);
|
||||
}
|
||||
|
||||
JSONArray sourceMessageIds = payload.optJSONArray("sourceMessageIds");
|
||||
if (sourceMessageIds != null && sourceMessageIds.length() > 0) {
|
||||
MutableJsonArray orderedIds = new MutableJsonArray();
|
||||
for (int i = 0; i < sourceMessageIds.length(); i++) {
|
||||
String messageId = sourceMessageIds.optString(i);
|
||||
if (!isEmpty(messageId)) {
|
||||
orderedIds.put(messageId);
|
||||
}
|
||||
}
|
||||
if (orderedIds.length() > 0) {
|
||||
requestPayload.put("sourceMessageIds", orderedIds);
|
||||
}
|
||||
}
|
||||
|
||||
return requestPayload.toString();
|
||||
}
|
||||
|
||||
public static boolean isApprovalRequired(@Nullable JSONObject responseJson) {
|
||||
return responseJson != null && responseJson.optBoolean("approvalRequired", false);
|
||||
}
|
||||
|
||||
private static boolean isEmpty(@Nullable String value) {
|
||||
return value == null || value.length() == 0;
|
||||
}
|
||||
|
||||
private static final class MutableJsonObject extends JSONObject {
|
||||
private final Map<String, Object> values = new 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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> entry : values.entrySet()) {
|
||||
if (!first) {
|
||||
builder.append(",");
|
||||
}
|
||||
first = false;
|
||||
builder.append("\"").append(escape(entry.getKey())).append("\":");
|
||||
builder.append(stringify(entry.getValue()));
|
||||
}
|
||||
builder.append("}");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(int index) {
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return "";
|
||||
}
|
||||
Object value = values.get(index);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder("[");
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
if (i > 0) {
|
||||
builder.append(",");
|
||||
}
|
||||
builder.append(stringify(values.get(i)));
|
||||
}
|
||||
builder.append("]");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static String stringify(@Nullable Object value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return "\"" + escape((String) value) + "\"";
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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 {
|
||||
return ForwardPayloads.build(mode, sourceMessageId, sourceMessageIds);
|
||||
}
|
||||
|
||||
static String resolveForwardResultMessage(JSONObject responseJson) {
|
||||
return ForwardPayloads.isApprovalRequired(responseJson) ? "已提交主 Agent 审批" : "转发成功";
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage(resolveForwardResultMessage(response.json));
|
||||
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;
|
||||
}
|
||||
}
|
||||
356
android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
Normal file
356
android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
Normal file
@@ -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<CandidateConversation> candidates = new ArrayList<>();
|
||||
private final Set<String> selectedProjectIds = new LinkedHashSet<>();
|
||||
private final Set<String> 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<JSONObject> selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId);
|
||||
List<CandidateConversation> nextCandidates = new ArrayList<>(selectableConversations.size());
|
||||
Set<String> 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<String> 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<JSONObject> collectSelectableConversationItems(@Nullable JSONObject conversationsPayload, String sourceProjectId) {
|
||||
List<JSONObject> 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<String> 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<String> selectedProjectIds
|
||||
) {
|
||||
return !refreshing
|
||||
&& !creatingGroupChat
|
||||
&& selectedProjectIds != null
|
||||
&& !selectedProjectIds.isEmpty();
|
||||
}
|
||||
|
||||
static Set<String> reconcileSelectedProjectIds(
|
||||
@Nullable Set<String> currentSelectedProjectIds,
|
||||
@Nullable Set<String> previousCandidateProjectIds,
|
||||
@Nullable Set<String> nextCandidateProjectIds
|
||||
) {
|
||||
Set<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
Normal file
208
android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import java.util.concurrent.Executors;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
public static final String EXTRA_INITIAL_TAB = "initial_tab";
|
||||
private static final String UI_PREFS = "boss_native_client";
|
||||
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
|
||||
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
@@ -29,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;
|
||||
@@ -45,12 +49,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private String activeTab = "conversations";
|
||||
private String preferredEntryTab = "conversations";
|
||||
private boolean explicitTabRequest = false;
|
||||
private @Nullable String requestedInitialTab;
|
||||
private boolean userSelectedTab = false;
|
||||
private long lastRootBackPressedAt = 0L;
|
||||
private @Nullable JSONObject sessionData;
|
||||
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) {
|
||||
@@ -78,10 +85,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onBackPressed() {
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
|
||||
setActiveTab("conversations", false);
|
||||
persistLastRootTab("conversations");
|
||||
return;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE) {
|
||||
moveTaskToBack(true);
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
|
||||
moveTaskToBack(true);
|
||||
return;
|
||||
}
|
||||
lastRootBackPressedAt = now;
|
||||
showMessage("再按一次返回,应用进入后台");
|
||||
return;
|
||||
}
|
||||
super.onBackPressed();
|
||||
@@ -96,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);
|
||||
@@ -108,12 +123,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
tabMe = findViewById(R.id.tab_me);
|
||||
screenRefresh = findViewById(R.id.screen_refresh);
|
||||
screenContent = findViewById(R.id.screen_content);
|
||||
|
||||
String[] rootTabs = WechatSurfaceMapper.rootTabLabels();
|
||||
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));
|
||||
@@ -121,16 +144,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void applyInitialTab(@Nullable Intent intent) {
|
||||
explicitTabRequest = false;
|
||||
requestedInitialTab = null;
|
||||
String requested = intent == null ? null : intent.getStringExtra(EXTRA_INITIAL_TAB);
|
||||
if ("devices".equals(requested) || "me".equals(requested) || "conversations".equals(requested)) {
|
||||
activeTab = requested;
|
||||
explicitTabRequest = true;
|
||||
requestedInitialTab = requested;
|
||||
}
|
||||
activeTab = RootTabMemory.resolveInitialTab(requestedInitialTab, readLastRootTab(), preferredEntryTab);
|
||||
}
|
||||
|
||||
private void bootstrapSession() {
|
||||
showLogin("原生 Android 客户端已启用。点击下方按钮直接进入系统。");
|
||||
showLogin(WechatSurfaceMapper.loginHintText());
|
||||
if (!apiClient.hasSessionHints()) {
|
||||
return;
|
||||
}
|
||||
@@ -153,7 +176,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (Exception ignored) {
|
||||
// Fall back to login panel.
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "点击登录后会直接进入系统。"));
|
||||
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,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()) {
|
||||
@@ -197,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);
|
||||
@@ -229,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("刷新失败,请稍后重试");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -248,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);
|
||||
}
|
||||
|
||||
@@ -256,18 +346,18 @@ public class MainActivity extends AppCompatActivity {
|
||||
activeTab = tab;
|
||||
if (fromUser) {
|
||||
userSelectedTab = true;
|
||||
persistLastRootTab(tab);
|
||||
}
|
||||
lastRootBackPressedAt = 0L;
|
||||
updateTabStyles();
|
||||
renderCurrentTab();
|
||||
}
|
||||
|
||||
private void maybeApplyPreferredEntry() {
|
||||
if (explicitTabRequest || userSelectedTab) {
|
||||
if (userSelectedTab) {
|
||||
return;
|
||||
}
|
||||
if ("devices".equals(preferredEntryTab) || "me".equals(preferredEntryTab) || "conversations".equals(preferredEntryTab)) {
|
||||
activeTab = preferredEntryTab;
|
||||
}
|
||||
activeTab = RootTabMemory.resolveInitialTab(requestedInitialTab, readLastRootTab(), preferredEntryTab);
|
||||
}
|
||||
|
||||
private void renderCurrentTab() {
|
||||
@@ -277,16 +367,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
switch (activeTab) {
|
||||
case "devices":
|
||||
updateHeader("设备", "只展示当前正式接入生产链路的设备。");
|
||||
updateHeader("设备", "这里管理已接入设备与账号状态。");
|
||||
configureTopAction("+添加", true);
|
||||
renderDevicesRoot();
|
||||
break;
|
||||
case "me":
|
||||
updateHeader("我的", "账号、安全、技能、运维、OTA 都从这里进入。");
|
||||
updateHeader("我的", "");
|
||||
configureTopAction("刷新", false);
|
||||
renderMeRoot();
|
||||
break;
|
||||
case "conversations":
|
||||
default:
|
||||
updateHeader("会话", "原生会话列表直接消费 /api/v1/conversations。");
|
||||
updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle());
|
||||
configureTopAction("刷新", false);
|
||||
renderConversationsRoot();
|
||||
break;
|
||||
}
|
||||
@@ -295,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() {
|
||||
@@ -304,19 +398,37 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void styleTab(Button button, boolean active) {
|
||||
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.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
|
||||
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.buildCard(
|
||||
this,
|
||||
"会话首页",
|
||||
"当前原生首页会直接进入项目详情、目标、版本、转发与线程预算详情。",
|
||||
conversationsData == null ? "正在等待数据" : "会话数 " + conversationsData.length()
|
||||
));
|
||||
|
||||
screenContent.addView(BossUi.buildHintPill(this, WechatSurfaceMapper.conversationsHintPillText()));
|
||||
if (conversationsData == null || conversationsData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
|
||||
return;
|
||||
@@ -326,41 +438,23 @@ public class MainActivity extends AppCompatActivity {
|
||||
JSONObject item = conversationsData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String projectId = item.optString("projectId", "");
|
||||
String title = item.optString("projectTitle", "未命名会话");
|
||||
StringBuilder body = new StringBuilder(item.optString("preview", "暂无预览"));
|
||||
if (item.optInt("activeDeviceCount", 0) > 0) {
|
||||
body.append("\n设备 ").append(item.optString("deviceNamesPreview", "未标注"));
|
||||
}
|
||||
JSONObject budget = item.optJSONObject("contextBudgetIndicator");
|
||||
String meta = "风险 " + item.optString("riskLevel", "unknown")
|
||||
+ " · 未读 " + item.optInt("unreadCount", 0)
|
||||
+ " · " + item.optString("latestReplyLabel", "-");
|
||||
if (budget != null && budget.optBoolean("visible", false)) {
|
||||
meta = meta + " · 预算 " + budget.optInt("percent", 0) + "%";
|
||||
}
|
||||
screenContent.addView(BossUi.buildCard(this, title, body.toString(), meta, v -> {
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
screenContent.addView(BossUi.buildConversationRow(
|
||||
this,
|
||||
row,
|
||||
v -> {
|
||||
if (projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
openProject(projectId, title);
|
||||
String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
|
||||
openProject(projectId, projectName);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void renderDevicesRoot() {
|
||||
screenContent.removeAllViews();
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
this,
|
||||
"设备首页",
|
||||
"设备详情、技能清单和配对草稿都改为原生页。",
|
||||
devicesData == null ? "正在等待数据" : "设备数 " + devicesData.length()
|
||||
));
|
||||
|
||||
Button addDeviceButton = BossUi.buildPrimaryButton(this, "添加设备");
|
||||
addDeviceButton.setOnClickListener(v -> startActivity(new Intent(this, DeviceEnrollmentActivity.class)));
|
||||
screenContent.addView(addDeviceButton);
|
||||
|
||||
if (devicesData == null || devicesData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。"));
|
||||
return;
|
||||
@@ -370,19 +464,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
JSONObject item = devicesData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String deviceId = item.optString("id", "");
|
||||
String title = item.optString("name", "未命名设备");
|
||||
String body = item.optString("note", item.optString("endpoint", "暂无设备说明"));
|
||||
String meta = "状态 " + item.optString("status", "unknown")
|
||||
+ " · 账号 " + item.optString("account", "-")
|
||||
+ " · 5h " + item.optInt("quota5h", 0)
|
||||
+ " · 7d " + item.optInt("quota7d", 0);
|
||||
screenContent.addView(BossUi.buildCard(this, title, body, meta, v -> {
|
||||
if (deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
openDevice(deviceId, title);
|
||||
}));
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
screenContent.addView(BossUi.buildDeviceCard(
|
||||
this,
|
||||
row,
|
||||
v -> {
|
||||
if (deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
openDevice(deviceId, row.title);
|
||||
},
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,70 +488,23 @@ public class MainActivity extends AppCompatActivity {
|
||||
String account = sessionData == null
|
||||
? apiClient.getAccountLabel()
|
||||
: sessionData.optString("account", apiClient.getAccountLabel());
|
||||
String expiresAt = sessionData == null ? "-" : sessionData.optString("expiresAt", "-");
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
screenContent.addView(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
displayName,
|
||||
"账号 " + account + "\n当前原生客户端已覆盖会话 / 设备 / 我的一级导航。",
|
||||
"会话到期 " + expiresAt
|
||||
"ChatGPT Plus · 主账号",
|
||||
"主控账号已启用安全保护 · " + account
|
||||
));
|
||||
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"账号与安全",
|
||||
"查看当前会话、登录模式和退出登录。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SecurityActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"设置",
|
||||
"实时刷新、风险徽标和默认首页。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SettingsActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"运维与修复",
|
||||
"查看故障、repair ticket、审计请求和能力注册表。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, OpsCenterActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"AI 账号",
|
||||
"管理主 GPT、备用 GPT、Master Codex Node 与 API 容灾。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, AiAccountsActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"技能",
|
||||
"按绑定设备查看 Skill,并一键复制调用语句。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SkillInventoryActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"关于",
|
||||
"查看版本、OTA 状态和当前绑定节点。",
|
||||
otaData == null ? null : otaData.optBoolean("hasOta", false) ? "OTA" : null,
|
||||
v -> startActivity(new Intent(this, AboutActivity.class))
|
||||
));
|
||||
|
||||
if (otaData != null) {
|
||||
JSONObject availableRelease = otaData.optJSONObject("availableRelease");
|
||||
String body = "当前版本 " + otaData.optString("currentVersion", "-");
|
||||
String meta = availableRelease == null
|
||||
? "当前没有待安装版本"
|
||||
: "可用版本 " + availableRelease.optString("version", "-")
|
||||
+ " · 文件 " + availableRelease.optString("packageFileName", "-");
|
||||
screenContent.addView(BossUi.buildCard(this, "OTA 状态", body, meta));
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
|
||||
screenContent.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
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() {
|
||||
@@ -495,11 +542,140 @@ 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 key) {
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
case "security":
|
||||
intent = new Intent(this, SecurityActivity.class);
|
||||
break;
|
||||
case "ai_accounts":
|
||||
intent = new Intent(this, AiAccountsActivity.class);
|
||||
break;
|
||||
case "settings":
|
||||
intent = new Intent(this, SettingsActivity.class);
|
||||
break;
|
||||
case "ops":
|
||||
intent = new Intent(this, OpsCenterActivity.class);
|
||||
break;
|
||||
case "skills":
|
||||
openSkillInventoryFromMe();
|
||||
return;
|
||||
case "about":
|
||||
intent = new Intent(this, AboutActivity.class);
|
||||
break;
|
||||
default:
|
||||
showMessage("暂未接入:" + key);
|
||||
return;
|
||||
}
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
private void persistLastRootTab(String tab) {
|
||||
getSharedPreferences(UI_PREFS, MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_LAST_ROOT_TAB, tab)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private @Nullable String readLastRootTab() {
|
||||
return getSharedPreferences(UI_PREFS, MODE_PRIVATE)
|
||||
.getString(KEY_LAST_ROOT_TAB, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class OtaDownloadStateMapper {
|
||||
public enum ActionKind {
|
||||
NONE,
|
||||
RETRY_DOWNLOAD,
|
||||
OPEN_INSTALL_PERMISSION,
|
||||
INSTALL_APK
|
||||
}
|
||||
|
||||
public static final class UiState {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
public final String meta;
|
||||
public final String badge;
|
||||
public final String actionLabel;
|
||||
public final ActionKind actionKind;
|
||||
|
||||
public UiState(
|
||||
String title,
|
||||
String subtitle,
|
||||
String meta,
|
||||
String badge,
|
||||
String actionLabel,
|
||||
ActionKind actionKind
|
||||
) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.meta = meta;
|
||||
this.badge = badge;
|
||||
this.actionLabel = actionLabel;
|
||||
this.actionKind = actionKind;
|
||||
}
|
||||
}
|
||||
|
||||
private OtaDownloadStateMapper() {}
|
||||
|
||||
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
|
||||
if (!hasKnownTotal) {
|
||||
return "正在准备下载";
|
||||
}
|
||||
int safePercent = Math.max(0, Math.min(100, percent));
|
||||
return "已下载 " + safePercent + "%";
|
||||
}
|
||||
|
||||
public static UiState active(String fileName, int percent, boolean hasKnownTotal, long bytesDownloaded, long totalBytes) {
|
||||
return new UiState(
|
||||
"安装包下载中",
|
||||
toProgressLabel(percent, hasKnownTotal),
|
||||
buildMeta(fileName, bytesDownloaded, totalBytes),
|
||||
"NOW",
|
||||
null,
|
||||
ActionKind.NONE
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState failed(String fileName) {
|
||||
return new UiState(
|
||||
"安装包下载失败",
|
||||
"下载未成功完成,可以直接重试",
|
||||
fileName,
|
||||
"FAIL",
|
||||
"重试下载",
|
||||
ActionKind.RETRY_DOWNLOAD
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState waitingInstallPermission(String fileName) {
|
||||
return new UiState(
|
||||
"等待安装授权",
|
||||
"请先允许 Boss 安装未知来源应用",
|
||||
fileName,
|
||||
"STEP",
|
||||
"前往授权",
|
||||
ActionKind.OPEN_INSTALL_PERMISSION
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState readyToInstall(String fileName) {
|
||||
return new UiState(
|
||||
"安装包已就绪",
|
||||
"下载完成,可继续拉起系统安装",
|
||||
fileName,
|
||||
"DONE",
|
||||
"继续安装",
|
||||
ActionKind.INSTALL_APK
|
||||
);
|
||||
}
|
||||
|
||||
private static String buildMeta(String fileName, long bytesDownloaded, long totalBytes) {
|
||||
if (bytesDownloaded <= 0 && totalBytes <= 0) {
|
||||
return fileName;
|
||||
}
|
||||
StringBuilder builder = new StringBuilder(fileName);
|
||||
builder.append(" · ").append(formatBytes(bytesDownloaded));
|
||||
if (totalBytes > 0) {
|
||||
builder.append(" / ").append(formatBytes(totalBytes));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String formatBytes(long bytes) {
|
||||
if (bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
}
|
||||
if (bytes < 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024.0d);
|
||||
}
|
||||
return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0d * 1024.0d));
|
||||
}
|
||||
}
|
||||
176
android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
Normal file
176
android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
Normal file
@@ -0,0 +1,176 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ProjectChatUiState {
|
||||
private ProjectChatUiState() {}
|
||||
|
||||
public static final class SelectionState {
|
||||
public final boolean multiSelecting;
|
||||
public final Set<String> selectedMessageIds;
|
||||
|
||||
private SelectionState(Set<String> selectedMessageIds) {
|
||||
LinkedHashSet<String> normalizedIds = new LinkedHashSet<>(selectedMessageIds);
|
||||
this.multiSelecting = !normalizedIds.isEmpty();
|
||||
this.selectedMessageIds = Collections.unmodifiableSet(normalizedIds);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ChromeState {
|
||||
public final boolean multiSelecting;
|
||||
public final boolean showComposer;
|
||||
public final boolean showMultiSelectBar;
|
||||
public final boolean showRefresh;
|
||||
public final boolean showHeaderAction;
|
||||
public final boolean forwardEnabled;
|
||||
public final String backLabel;
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
|
||||
private ChromeState(
|
||||
boolean multiSelecting,
|
||||
boolean showComposer,
|
||||
boolean showMultiSelectBar,
|
||||
boolean showRefresh,
|
||||
boolean showHeaderAction,
|
||||
boolean forwardEnabled,
|
||||
String backLabel,
|
||||
String title,
|
||||
String subtitle
|
||||
) {
|
||||
this.multiSelecting = multiSelecting;
|
||||
this.showComposer = showComposer;
|
||||
this.showMultiSelectBar = showMultiSelectBar;
|
||||
this.showRefresh = showRefresh;
|
||||
this.showHeaderAction = showHeaderAction;
|
||||
this.forwardEnabled = forwardEnabled;
|
||||
this.backLabel = backLabel;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean canSend(String text, boolean sending) {
|
||||
return !sending && text != null && !text.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static SelectionState emptySelection() {
|
||||
return new SelectionState(new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
public static SelectionState selectOnly(String messageId) {
|
||||
return toggleSelection(emptySelection(), messageId);
|
||||
}
|
||||
|
||||
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {
|
||||
if (messageId == null || messageId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("messageId must not be blank");
|
||||
}
|
||||
SelectionState state = current == null ? emptySelection() : current;
|
||||
LinkedHashSet<String> selectedMessageIds = new LinkedHashSet<>(state.selectedMessageIds);
|
||||
if (selectedMessageIds.contains(messageId)) {
|
||||
selectedMessageIds.remove(messageId);
|
||||
return new SelectionState(selectedMessageIds);
|
||||
}
|
||||
selectedMessageIds.add(messageId);
|
||||
return new SelectionState(selectedMessageIds);
|
||||
}
|
||||
|
||||
public static boolean canForwardSelection(@Nullable SelectionState state) {
|
||||
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
|
||||
}
|
||||
|
||||
public static SelectionState reconcileSelection(
|
||||
@Nullable SelectionState current,
|
||||
@Nullable List<String> availableMessageIds
|
||||
) {
|
||||
if (current == null || current.selectedMessageIds.isEmpty() || availableMessageIds == null || availableMessageIds.isEmpty()) {
|
||||
return emptySelection();
|
||||
}
|
||||
LinkedHashSet<String> available = new LinkedHashSet<>(availableMessageIds);
|
||||
LinkedHashSet<String> selected = new LinkedHashSet<>();
|
||||
for (String selectedMessageId : current.selectedMessageIds) {
|
||||
if (available.contains(selectedMessageId)) {
|
||||
selected.add(selectedMessageId);
|
||||
}
|
||||
}
|
||||
return new SelectionState(selected);
|
||||
}
|
||||
|
||||
public static ChromeState resolveChromeState(
|
||||
@Nullable SelectionState selectionState,
|
||||
boolean conversationInfoReady,
|
||||
@Nullable String defaultTitle,
|
||||
@Nullable String defaultSubtitle
|
||||
) {
|
||||
boolean multiSelecting = selectionState != null && selectionState.multiSelecting;
|
||||
if (multiSelecting) {
|
||||
int selectedCount = selectionState.selectedMessageIds.size();
|
||||
return new ChromeState(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
canForwardSelection(selectionState),
|
||||
"取消",
|
||||
"已选 " + selectedCount + " 条",
|
||||
"选择要转发的消息"
|
||||
);
|
||||
}
|
||||
return new ChromeState(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
conversationInfoReady,
|
||||
false,
|
||||
"返回",
|
||||
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
|
||||
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
|
||||
);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String labelForForwardKind(@Nullable String kind) {
|
||||
if ("forward_single".equals(kind)) {
|
||||
return "转发";
|
||||
}
|
||||
if ("forward_bundle".equals(kind)) {
|
||||
return "聊天记录";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String summarizeForwardBundle(@Nullable String lastBody, int itemCount) {
|
||||
if (itemCount > 0 && !isBlank(lastBody)) {
|
||||
return itemCount + " 条消息 · 最后一条:" + truncate(lastBody, 28);
|
||||
}
|
||||
if (itemCount > 0) {
|
||||
return itemCount + " 条消息";
|
||||
}
|
||||
return truncate(lastBody, 28);
|
||||
}
|
||||
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private static String truncate(@Nullable String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "…";
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,182 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.ViewGroup;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
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 androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ProjectDetailActivity 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 initialProjectName;
|
||||
private boolean projectIsGroup;
|
||||
private String projectFolderName;
|
||||
private LinearLayout quickActionsLayout;
|
||||
private LinearLayout composerRow;
|
||||
private LinearLayout multiSelectActionsLayout;
|
||||
private EditText composerInput;
|
||||
private Button composerSendButton;
|
||||
private Button multiSelectForwardButton;
|
||||
private ScrollView chatScrollView;
|
||||
private View pendingOutgoingBubble;
|
||||
private boolean composerSending;
|
||||
private boolean renderNearBottom;
|
||||
private boolean renderForcedScrollToBottom;
|
||||
private boolean conversationInfoReady;
|
||||
private String currentScreenTitle;
|
||||
private String currentScreenSubtitle;
|
||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
||||
|
||||
static final class ChromeBindings {
|
||||
final boolean multiSelecting;
|
||||
final boolean showComposer;
|
||||
final boolean showMultiSelectBar;
|
||||
final boolean showRefresh;
|
||||
final boolean showHeaderAction;
|
||||
final boolean enableForwardButton;
|
||||
final boolean enablePullRefresh;
|
||||
final String backLabel;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
ChromeBindings(
|
||||
boolean multiSelecting,
|
||||
boolean showComposer,
|
||||
boolean showMultiSelectBar,
|
||||
boolean showRefresh,
|
||||
boolean showHeaderAction,
|
||||
boolean enableForwardButton,
|
||||
boolean enablePullRefresh,
|
||||
String backLabel,
|
||||
String title,
|
||||
String subtitle
|
||||
) {
|
||||
this.multiSelecting = multiSelecting;
|
||||
this.showComposer = showComposer;
|
||||
this.showMultiSelectBar = showMultiSelectBar;
|
||||
this.showRefresh = showRefresh;
|
||||
this.showHeaderAction = showHeaderAction;
|
||||
this.enableForwardButton = enableForwardButton;
|
||||
this.enablePullRefresh = enablePullRefresh;
|
||||
this.backLabel = backLabel;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_project_chat;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
initialProjectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
|
||||
setHeaderAction("发消息", v -> chooseMessageKindAndSend());
|
||||
reload();
|
||||
quickActionsLayout = findViewById(R.id.project_chat_quick_actions);
|
||||
composerRow = findViewById(R.id.project_chat_composer_row);
|
||||
multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions);
|
||||
composerInput = findViewById(R.id.project_chat_input);
|
||||
composerSendButton = findViewById(R.id.project_chat_send);
|
||||
multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward);
|
||||
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;
|
||||
updateProjectHeader(updatedTitle, "正在同步项目详情...");
|
||||
}
|
||||
}
|
||||
reload();
|
||||
}
|
||||
);
|
||||
forwardTargetLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() != RESULT_OK) {
|
||||
return;
|
||||
}
|
||||
exitMultiSelect();
|
||||
reload(true);
|
||||
}
|
||||
);
|
||||
|
||||
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
|
||||
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
|
||||
multiSelectForwardButton.setOnClickListener(v -> {
|
||||
if (!ProjectChatUiState.canForwardSelection(selectionState)) {
|
||||
showMessage("至少选择两条消息后才能合并转发");
|
||||
return;
|
||||
}
|
||||
openBundleForwardTarget(new ArrayList<>(selectionState.selectedMessageIds));
|
||||
});
|
||||
composerInput.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
updateComposerSendButtonState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {}
|
||||
});
|
||||
updateComposerSendButtonState();
|
||||
updateSelectionUi();
|
||||
if (shouldLoadOnCreate()) {
|
||||
reload(true);
|
||||
}
|
||||
}
|
||||
|
||||
boolean shouldLoadOnCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
reload(false);
|
||||
}
|
||||
|
||||
private void reload(boolean forcedScrollToBottom) {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
renderNearBottom = isChatNearBottom();
|
||||
renderForcedScrollToBottom = forcedScrollToBottom;
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -48,194 +188,140 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
if (pendingOutgoingBubble == null) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
|
||||
} else {
|
||||
showMessage("项目详情刷新失败:" + error.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setRefreshing(boolean refreshing) {
|
||||
super.setRefreshing(refreshing);
|
||||
if (composerInput != null) {
|
||||
composerInput.setEnabled(!refreshing);
|
||||
}
|
||||
updateComposerSendButtonState();
|
||||
updateSelectionUi();
|
||||
}
|
||||
|
||||
private void renderProject(JSONObject payload) {
|
||||
JSONObject project = payload.optJSONObject("project");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
JSONArray threadContexts = payload.optJSONArray("activeThreadContexts");
|
||||
JSONArray recentLogs = payload.optJSONArray("recentAppLogs");
|
||||
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
|
||||
|
||||
String title = project != null ? project.optString("name", "项目详情") : "项目详情";
|
||||
String subtitle = "设备:" + joinDeviceNames(devices);
|
||||
configureScreen(title, subtitle);
|
||||
initialProjectName = title;
|
||||
projectIsGroup = project != null && project.optBoolean("isGroup", false);
|
||||
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
conversationInfoReady = project != null;
|
||||
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
|
||||
|
||||
renderQuickActions();
|
||||
replaceContent();
|
||||
appendContent(buildActionGrid());
|
||||
|
||||
JSONObject masterIdentity = payload.optJSONObject("masterIdentity");
|
||||
if (masterIdentity != null) {
|
||||
String body = masterIdentity.optString("roleLabel", "主控")
|
||||
+ " · " + masterIdentity.optString("displayName", "-")
|
||||
+ (masterIdentity.optString("nodeLabel").isEmpty() ? "" : " · " + masterIdentity.optString("nodeLabel"))
|
||||
+ (masterIdentity.optString("model").isEmpty() ? "" : "\n模型 " + masterIdentity.optString("model"));
|
||||
String meta = masterIdentity.optString("statusLabel", "")
|
||||
+ (masterIdentity.optString("lastSwitchedAt").isEmpty() ? "" : " · 最近切换 " + masterIdentity.optString("lastSwitchedAt"));
|
||||
appendContent(BossUi.buildCard(this, "当前主控身份", body, meta));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"主 Agent 调度结论",
|
||||
payload.optString("masterContextStrategySummary", "暂无调度摘要。"),
|
||||
"原生项目详情已接入 /api/v1/projects/{projectId}"
|
||||
));
|
||||
|
||||
if (threadContexts != null && threadContexts.length() > 0) {
|
||||
for (int i = 0; i < threadContexts.length(); i++) {
|
||||
JSONObject thread = threadContexts.optJSONObject(i);
|
||||
if (thread == null) continue;
|
||||
JSONObject snapshot = thread.optJSONObject("snapshot");
|
||||
if (snapshot == null) continue;
|
||||
String threadId = snapshot.optString("threadId");
|
||||
String body = snapshot.optString("summary", "暂无摘要");
|
||||
String meta = snapshot.optString("workerId", "-")
|
||||
+ " · " + snapshot.optString("nodeId", "-")
|
||||
+ " · " + snapshot.optInt("contextBudgetRemainingPct", 0) + "%"
|
||||
+ " · " + snapshot.optString("contextBudgetLevel", "safe");
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
snapshot.optString("title", "线程详情"),
|
||||
body,
|
||||
meta,
|
||||
v -> openThread(threadId)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有线程预算数据。"));
|
||||
}
|
||||
|
||||
if (recentLogs != null && recentLogs.length() > 0) {
|
||||
for (int i = 0; i < recentLogs.length(); i++) {
|
||||
JSONObject log = recentLogs.optJSONObject(i);
|
||||
if (log == null) continue;
|
||||
String body = log.optString("message", "无消息体");
|
||||
if (!log.optString("detail").isEmpty()) {
|
||||
body = body + "\n" + log.optString("detail");
|
||||
}
|
||||
String meta = log.optString("deviceId", "-")
|
||||
+ " · " + log.optString("category", "-")
|
||||
+ " · " + log.optString("createdAt", "-");
|
||||
appendContent(BossUi.buildCard(this, "实时 APP 日志", body, meta));
|
||||
}
|
||||
}
|
||||
pendingOutgoingBubble = null;
|
||||
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
selectionState = ProjectChatUiState.reconcileSelection(selectionState, collectMessageIds(messages));
|
||||
if (messages != null && messages.length() > 0) {
|
||||
for (int i = 0; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message == null) continue;
|
||||
String meta = message.optString("sentAt", "-")
|
||||
+ (message.optString("kind").isEmpty() ? "" : " · " + message.optString("kind"));
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
message.optString("senderLabel", "消息"),
|
||||
message.optString("body", ""),
|
||||
meta
|
||||
));
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
appendContent(buildMessageView(message));
|
||||
}
|
||||
} else {
|
||||
appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。"));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"媒体与转发说明",
|
||||
"语音、图片、视频与转发现在都通过原生入口触发,并写回现有 Boss 消息账本。",
|
||||
"对象存储与真实媒体文件仍保持 MVP 占位。"
|
||||
));
|
||||
setRefreshing(false);
|
||||
updateSelectionUi();
|
||||
if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) {
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildActionGrid() {
|
||||
LinearLayout wrapper = new LinearLayout(this);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.setLayoutParams(new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
));
|
||||
|
||||
wrapper.addView(buildActionRow(
|
||||
buildActionButton("发送消息", v -> chooseMessageKindAndSend()),
|
||||
buildActionButton("项目目标", v -> openGoals())
|
||||
));
|
||||
wrapper.addView(buildActionRow(
|
||||
buildActionButton("版本记录", v -> openVersions()),
|
||||
buildActionButton("消息转发", v -> openForward())
|
||||
));
|
||||
return wrapper;
|
||||
private void renderQuickActions() {
|
||||
if (quickActionsLayout == null) {
|
||||
return;
|
||||
}
|
||||
quickActionsLayout.removeAllViews();
|
||||
String[] actions = WechatSurfaceMapper.projectQuickActions();
|
||||
for (int i = 0; i < actions.length; i++) {
|
||||
String action = actions[i];
|
||||
Button button = buildQuickActionButton(action, i == 0);
|
||||
if ("项目目标".equals(action)) {
|
||||
button.setOnClickListener(v -> openGoals());
|
||||
} else if ("版本记录".equals(action)) {
|
||||
button.setOnClickListener(v -> openVersions());
|
||||
}
|
||||
quickActionsLayout.addView(button);
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildActionRow(Button left, Button right) {
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.bottomMargin = BossUi.dp(this, 12);
|
||||
row.setLayoutParams(params);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
|
||||
LinearLayout.LayoutParams childParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||
childParams.rightMargin = BossUi.dp(this, 6);
|
||||
left.setLayoutParams(childParams);
|
||||
|
||||
LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||
rightParams.leftMargin = BossUi.dp(this, 6);
|
||||
right.setLayoutParams(rightParams);
|
||||
|
||||
row.addView(left);
|
||||
row.addView(right);
|
||||
return row;
|
||||
}
|
||||
|
||||
private Button buildActionButton(String label, android.view.View.OnClickListener listener) {
|
||||
Button button = BossUi.buildPrimaryButton(this, label);
|
||||
button.setOnClickListener(listener);
|
||||
private Button buildQuickActionButton(String label, boolean highlight) {
|
||||
Button button = new Button(this);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, BossUi.dp(this, 40), 1f);
|
||||
if (quickActionsLayout.getChildCount() > 0) {
|
||||
params.leftMargin = BossUi.dp(this, 8);
|
||||
}
|
||||
button.setLayoutParams(params);
|
||||
button.setMinWidth(0);
|
||||
button.setText(label);
|
||||
button.setTextSize(14);
|
||||
button.setAllCaps(false);
|
||||
button.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), 0);
|
||||
button.setBackgroundResource(highlight ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
button.setTextColor(getColor(highlight ? R.color.boss_surface : R.color.boss_text_primary));
|
||||
return button;
|
||||
}
|
||||
|
||||
private void chooseMessageKindAndSend() {
|
||||
final String[] labels = {"文本消息", "语音意图", "图片意图", "视频意图"};
|
||||
final String[] kinds = {"text", "voice_intent", "image_intent", "video_intent"};
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("选择消息类型")
|
||||
.setItems(labels, (dialog, which) -> showSendDialog(kinds[which], labels[which]))
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showSendDialog(String kind, String label) {
|
||||
final android.widget.EditText input = BossUi.buildInput(this, "请输入要发送给项目的内容", true);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("发送" + label)
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("发送", (dialog, which) -> sendProjectMessage(kind, input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void sendProjectMessage(String kind, String body) {
|
||||
private void sendTextMessageFromComposer() {
|
||||
if (composerInput == null) {
|
||||
return;
|
||||
}
|
||||
String body = composerInput.getText() == null ? "" : composerInput.getText().toString().trim();
|
||||
if (body.isEmpty()) {
|
||||
showMessage("请输入消息内容");
|
||||
return;
|
||||
}
|
||||
if (!ProjectChatUiState.canSend(body, isComposerBusy())) {
|
||||
return;
|
||||
}
|
||||
sendProjectMessage("text", body);
|
||||
}
|
||||
|
||||
private void sendProjectMessage(String kind, String body) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(true);
|
||||
appendPendingOutgoingBubble(body);
|
||||
scrollChatToBottom();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
composerSending = false;
|
||||
composerInput.setText("");
|
||||
showMessage("消息已发送");
|
||||
reload();
|
||||
reload(true);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
removePendingOutgoingBubble();
|
||||
showMessage("发送失败:" + error.getMessage());
|
||||
updateComposerSendButtonState();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -255,18 +341,228 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openForward() {
|
||||
Intent intent = new Intent(this, ProjectForwardActivity.class);
|
||||
intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_NAME, initialProjectName);
|
||||
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 void openThread(String threadId) {
|
||||
Intent intent = new Intent(this, ThreadDetailActivity.class);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_PROJECT_ID, projectId);
|
||||
startActivity(intent);
|
||||
private View buildMessageView(JSONObject message) {
|
||||
String messageId = message.optString("id", "");
|
||||
String senderLabel = message.optString("senderLabel", "消息");
|
||||
String body = message.optString("body", "");
|
||||
String meta = formatMessageTime(message.optString("sentAt", ""));
|
||||
String kind = message.optString("kind", "");
|
||||
boolean outgoing = isOutgoingMessage(senderLabel);
|
||||
|
||||
View messageView;
|
||||
switch (kind) {
|
||||
case "forward_single":
|
||||
messageView = BossUi.buildForwardSingleBubble(
|
||||
this,
|
||||
senderLabel,
|
||||
body,
|
||||
meta,
|
||||
resolveForwardSingleSourceLabel(message),
|
||||
outgoing
|
||||
);
|
||||
break;
|
||||
case "forward_bundle":
|
||||
messageView = BossUi.buildForwardBundleCard(
|
||||
this,
|
||||
senderLabel,
|
||||
resolveForwardBundleTitle(message),
|
||||
resolveForwardBundleSummary(message),
|
||||
meta,
|
||||
outgoing
|
||||
);
|
||||
break;
|
||||
default:
|
||||
messageView = BossUi.buildMessageBubble(
|
||||
this,
|
||||
senderLabel,
|
||||
body,
|
||||
meta,
|
||||
outgoing,
|
||||
labelForMessageKind(kind)
|
||||
);
|
||||
break;
|
||||
}
|
||||
bindMessageInteractions(messageView, messageId, body);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
private void bindMessageInteractions(View messageView, String messageId, String body) {
|
||||
if (messageView == null || TextUtils.isEmpty(messageId)) {
|
||||
return;
|
||||
}
|
||||
messageView.setTag(messageId);
|
||||
messageView.setClickable(true);
|
||||
messageView.setLongClickable(true);
|
||||
messageView.setOnClickListener(v -> {
|
||||
if (!selectionState.multiSelecting) {
|
||||
return;
|
||||
}
|
||||
toggleMultiSelectMessage(messageId);
|
||||
});
|
||||
messageView.setOnLongClickListener(v -> {
|
||||
showMessageActions(messageId, body);
|
||||
return true;
|
||||
});
|
||||
BossUi.applyMessageSelectionState(
|
||||
this,
|
||||
messageView,
|
||||
selectionState.selectedMessageIds.contains(messageId)
|
||||
);
|
||||
}
|
||||
|
||||
private void showMessageActions(String messageId, String body) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("消息操作")
|
||||
.setItems(new CharSequence[]{"转发", "多选", "复制", "删除", "取消"}, (dialog, which) -> {
|
||||
switch (which) {
|
||||
case 0:
|
||||
openSingleForwardTarget(messageId);
|
||||
break;
|
||||
case 1:
|
||||
enterMultiSelectFromMessage(messageId);
|
||||
break;
|
||||
case 2:
|
||||
copyMessageBody(body);
|
||||
break;
|
||||
case 3:
|
||||
showMessage("删除消息能力暂未接通");
|
||||
break;
|
||||
default:
|
||||
dialog.dismiss();
|
||||
break;
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void copyMessageBody(String body) {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
|
||||
if (clipboard == null) {
|
||||
showMessage("当前设备不支持复制");
|
||||
return;
|
||||
}
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("boss-message", TextUtils.isEmpty(body) ? "" : body));
|
||||
showMessage("已复制消息");
|
||||
}
|
||||
|
||||
private void openSingleForwardTarget(String sourceMessageId) {
|
||||
if (TextUtils.isEmpty(sourceMessageId)) {
|
||||
showMessage("缺少消息 ID");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ForwardTargetActivity.class);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single");
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_MESSAGE_ID, sourceMessageId);
|
||||
forwardTargetLauncher.launch(intent);
|
||||
}
|
||||
|
||||
private void openBundleForwardTarget(List<String> sourceMessageIds) {
|
||||
if (sourceMessageIds == null || sourceMessageIds.size() < 2) {
|
||||
showMessage("至少选择两条消息后才能合并转发");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ForwardTargetActivity.class);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "bundle");
|
||||
intent.putExtra(
|
||||
ForwardTargetActivity.EXTRA_SOURCE_MESSAGE_IDS,
|
||||
sourceMessageIds.toArray(new String[0])
|
||||
);
|
||||
forwardTargetLauncher.launch(intent);
|
||||
}
|
||||
|
||||
private void enterMultiSelectFromMessage(String messageId) {
|
||||
selectionState = ProjectChatUiState.selectOnly(messageId);
|
||||
updateSelectionUi();
|
||||
}
|
||||
|
||||
private void exitMultiSelect() {
|
||||
selectionState = ProjectChatUiState.emptySelection();
|
||||
updateSelectionUi();
|
||||
}
|
||||
|
||||
private void toggleMultiSelectMessage(String messageId) {
|
||||
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(selectionState, messageId);
|
||||
if (!next.multiSelecting) {
|
||||
exitMultiSelect();
|
||||
return;
|
||||
}
|
||||
selectionState = next;
|
||||
updateSelectionUi();
|
||||
}
|
||||
|
||||
private void updateSelectionUi() {
|
||||
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
|
||||
selectionState,
|
||||
conversationInfoReady,
|
||||
currentScreenTitle,
|
||||
currentScreenSubtitle
|
||||
);
|
||||
ChromeBindings bindings = buildChromeBindings(chromeState, isComposerBusy());
|
||||
if (composerRow != null) {
|
||||
composerRow.setVisibility(bindings.showComposer ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
if (multiSelectActionsLayout != null) {
|
||||
multiSelectActionsLayout.setVisibility(bindings.showMultiSelectBar ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
if (multiSelectForwardButton != null) {
|
||||
multiSelectForwardButton.setEnabled(bindings.enableForwardButton);
|
||||
}
|
||||
if (refreshLayout != null) {
|
||||
refreshLayout.setEnabled(bindings.enablePullRefresh);
|
||||
}
|
||||
backButton.setText(bindings.backLabel);
|
||||
backButton.setOnClickListener(v -> {
|
||||
if (bindings.multiSelecting) {
|
||||
exitMultiSelect();
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
});
|
||||
refreshButton.setVisibility(bindings.showRefresh ? View.VISIBLE : View.GONE);
|
||||
titleView.setText(bindings.title);
|
||||
subtitleView.setText(bindings.subtitle);
|
||||
if (bindings.showHeaderAction) {
|
||||
setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo());
|
||||
} else {
|
||||
hideHeaderAction();
|
||||
}
|
||||
refreshMessageSelectionViews();
|
||||
}
|
||||
|
||||
private void refreshMessageSelectionViews() {
|
||||
if (contentLayout == null) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < contentLayout.getChildCount(); i++) {
|
||||
View child = contentLayout.getChildAt(i);
|
||||
Object tag = child.getTag();
|
||||
boolean selected = tag instanceof String
|
||||
&& selectionState != null
|
||||
&& selectionState.selectedMessageIds.contains(tag);
|
||||
BossUi.applyMessageSelectionState(this, child, selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateProjectHeader(String title, String subtitle) {
|
||||
currentScreenTitle = title;
|
||||
currentScreenSubtitle = subtitle;
|
||||
if (selectionState != null && selectionState.multiSelecting) {
|
||||
return;
|
||||
}
|
||||
configureScreen(title, subtitle);
|
||||
}
|
||||
|
||||
private String joinDeviceNames(@Nullable JSONArray devices) {
|
||||
@@ -276,10 +572,197 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < devices.length(); i++) {
|
||||
JSONObject device = devices.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (builder.length() > 0) builder.append(" / ");
|
||||
if (device == null) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" / ");
|
||||
}
|
||||
builder.append(device.optString("name", device.optString("id", "设备")));
|
||||
}
|
||||
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;
|
||||
}
|
||||
chatScrollView.post(() -> chatScrollView.fullScroll(View.FOCUS_DOWN));
|
||||
}
|
||||
|
||||
private void appendPendingOutgoingBubble(String body) {
|
||||
if (contentLayout == null) {
|
||||
return;
|
||||
}
|
||||
removePendingOutgoingBubble();
|
||||
if (contentLayout.getChildCount() == 1 && contentLayout.getChildAt(0) instanceof android.widget.TextView) {
|
||||
contentLayout.removeAllViews();
|
||||
}
|
||||
String senderLabel = TextUtils.isEmpty(apiClient.getDisplayName()) ? "你" : apiClient.getDisplayName();
|
||||
pendingOutgoingBubble = BossUi.buildPendingOutgoingMessageBubble(this, senderLabel, body);
|
||||
appendContent(pendingOutgoingBubble);
|
||||
}
|
||||
|
||||
private void removePendingOutgoingBubble() {
|
||||
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
|
||||
contentLayout.removeView(pendingOutgoingBubble);
|
||||
}
|
||||
pendingOutgoingBubble = null;
|
||||
}
|
||||
|
||||
private void updateComposerSendButtonState() {
|
||||
if (composerSendButton == null || composerInput == null) {
|
||||
return;
|
||||
}
|
||||
String body = composerInput.getText() == null ? "" : composerInput.getText().toString();
|
||||
composerSendButton.setEnabled(ProjectChatUiState.canSend(body, isComposerBusy()));
|
||||
}
|
||||
|
||||
private boolean isComposerBusy() {
|
||||
return composerSending || (refreshLayout != null && refreshLayout.isRefreshing());
|
||||
}
|
||||
|
||||
private boolean isChatNearBottom() {
|
||||
if (chatScrollView == null || chatScrollView.getChildCount() == 0 || chatScrollView.getHeight() == 0) {
|
||||
return true;
|
||||
}
|
||||
View child = chatScrollView.getChildAt(0);
|
||||
if (child == null || child.getHeight() == 0) {
|
||||
return true;
|
||||
}
|
||||
int remainingScroll = child.getBottom() - (chatScrollView.getScrollY() + chatScrollView.getHeight());
|
||||
return remainingScroll <= BossUi.dp(this, 96);
|
||||
}
|
||||
|
||||
private boolean isOutgoingMessage(String senderLabel) {
|
||||
if (TextUtils.isEmpty(senderLabel)) {
|
||||
return false;
|
||||
}
|
||||
return "你".equals(senderLabel)
|
||||
|| senderLabel.equals(apiClient.getDisplayName())
|
||||
|| senderLabel.equals(apiClient.getAccountLabel());
|
||||
}
|
||||
|
||||
private String formatMessageTime(String sentAt) {
|
||||
if (TextUtils.isEmpty(sentAt)) {
|
||||
return "";
|
||||
}
|
||||
int timeSeparator = sentAt.indexOf('T');
|
||||
if (timeSeparator >= 0 && sentAt.length() >= timeSeparator + 6) {
|
||||
return sentAt.substring(timeSeparator + 1, timeSeparator + 6);
|
||||
}
|
||||
int blankIndex = sentAt.indexOf(' ');
|
||||
if (blankIndex >= 0 && sentAt.length() >= blankIndex + 6) {
|
||||
return sentAt.substring(blankIndex + 1, blankIndex + 6);
|
||||
}
|
||||
return sentAt;
|
||||
}
|
||||
|
||||
private String resolveForwardSingleSourceLabel(JSONObject message) {
|
||||
JSONObject forwardSource = message.optJSONObject("forwardSource");
|
||||
if (forwardSource == null) {
|
||||
return "";
|
||||
}
|
||||
String threadTitle = forwardSource.optString("sourceThreadTitle", "");
|
||||
if (!TextUtils.isEmpty(threadTitle)) {
|
||||
return threadTitle;
|
||||
}
|
||||
return forwardSource.optString("sourceProjectName", "");
|
||||
}
|
||||
|
||||
private String resolveForwardBundleTitle(JSONObject message) {
|
||||
JSONObject forwardBundle = message.optJSONObject("forwardBundle");
|
||||
if (forwardBundle == null) {
|
||||
return "聊天记录";
|
||||
}
|
||||
String threadTitle = forwardBundle.optString("sourceThreadTitle", "");
|
||||
if (!TextUtils.isEmpty(threadTitle)) {
|
||||
return threadTitle;
|
||||
}
|
||||
String projectName = forwardBundle.optString("sourceProjectName", "");
|
||||
return TextUtils.isEmpty(projectName) ? "聊天记录" : projectName;
|
||||
}
|
||||
|
||||
private String resolveForwardBundleSummary(JSONObject message) {
|
||||
JSONObject forwardBundle = message.optJSONObject("forwardBundle");
|
||||
if (forwardBundle == null) {
|
||||
return message.optString("body", "转发的聊天记录");
|
||||
}
|
||||
int itemCount = forwardBundle.optInt("itemCount", 0);
|
||||
JSONArray items = forwardBundle.optJSONArray("items");
|
||||
JSONObject lastItem = items == null || items.length() == 0 ? null : items.optJSONObject(items.length() - 1);
|
||||
String lastBody = lastItem == null ? "" : lastItem.optString("body", "");
|
||||
String summarized = ProjectChatUiState.summarizeForwardBundle(lastBody, itemCount);
|
||||
if (!TextUtils.isEmpty(summarized)) {
|
||||
return summarized;
|
||||
}
|
||||
return message.optString("body", "转发的聊天记录");
|
||||
}
|
||||
|
||||
static ChromeBindings buildChromeBindings(
|
||||
ProjectChatUiState.ChromeState chromeState,
|
||||
boolean composerBusy
|
||||
) {
|
||||
return new ChromeBindings(
|
||||
chromeState.multiSelecting,
|
||||
chromeState.showComposer,
|
||||
chromeState.showMultiSelectBar,
|
||||
chromeState.showRefresh,
|
||||
chromeState.showHeaderAction,
|
||||
!composerBusy && chromeState.forwardEnabled,
|
||||
!chromeState.multiSelecting,
|
||||
chromeState.backLabel,
|
||||
chromeState.title,
|
||||
chromeState.subtitle
|
||||
);
|
||||
}
|
||||
|
||||
private List<String> collectMessageIds(@Nullable JSONArray messages) {
|
||||
ArrayList<String> ids = new ArrayList<>();
|
||||
if (messages == null) {
|
||||
return ids;
|
||||
}
|
||||
for (int i = 0; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
String messageId = message.optString("id", "");
|
||||
if (!TextUtils.isEmpty(messageId)) {
|
||||
ids.add(messageId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String labelForMessageKind(String kind) {
|
||||
if (TextUtils.isEmpty(kind) || "text".equals(kind)) {
|
||||
return null;
|
||||
}
|
||||
String forwardLabel = ProjectChatUiState.labelForForwardKind(kind);
|
||||
if (!TextUtils.isEmpty(forwardLabel)) {
|
||||
return forwardLabel;
|
||||
}
|
||||
switch (kind) {
|
||||
case "voice_intent":
|
||||
return "语音";
|
||||
case "image_intent":
|
||||
return "图片";
|
||||
case "video_intent":
|
||||
return "视频";
|
||||
case "forward_notice":
|
||||
return "转发";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
// 兼容页只负责跳转,不再承载旧的备注转发链路。
|
||||
}
|
||||
}
|
||||
|
||||
28
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java
Normal file
28
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class RootTabMemory {
|
||||
private RootTabMemory() {}
|
||||
|
||||
public static String resolveInitialTab(String explicitTab, String storedTab, String preferredTab) {
|
||||
String explicit = normalize(explicitTab);
|
||||
if (explicit != null) {
|
||||
return explicit;
|
||||
}
|
||||
String stored = normalize(storedTab);
|
||||
if (stored != null) {
|
||||
return stored;
|
||||
}
|
||||
String preferred = normalize(preferredTab);
|
||||
if (preferred != null) {
|
||||
return preferred;
|
||||
}
|
||||
return "conversations";
|
||||
}
|
||||
|
||||
private static String normalize(String tab) {
|
||||
if ("conversations".equals(tab) || "devices".equals(tab) || "me".equals(tab)) {
|
||||
return tab;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("账号与安全", "原生会话与设备安全");
|
||||
configureScreen("账号与安全", "登录会话与设备保护");
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -33,33 +33,32 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void renderSecurity(@Nullable JSONObject session) {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"当前登录模式",
|
||||
"当前登录页已临时切到免验证模式,点击登录会直接创建最高管理员会话。",
|
||||
"后续如收口认证,再切回账号密码 / 验证码登录。"
|
||||
)
|
||||
);
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"当前登录模式",
|
||||
"当前客户端仍使用快速进入模式。",
|
||||
"需要更严格认证时,再切回账号密码或验证码登录。"
|
||||
));
|
||||
if (session != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前会话",
|
||||
"账号 " + session.optString("account", "-")
|
||||
+ "\n角色 " + session.optString("role", "-")
|
||||
+ "\n登录方式 " + session.optString("loginMethod", "-"),
|
||||
"到期 " + session.optString("expiresAt", "-")
|
||||
+ " · " + session.optString("role", "-"),
|
||||
"登录方式 " + session.optString("loginMethod", "-")
|
||||
+ " · 到期 " + session.optString("expiresAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
android.widget.Button devicesButton = BossUi.buildPrimaryButton(this, "打开设备页");
|
||||
devicesButton.setOnClickListener(v -> {
|
||||
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
startActivity(intent);
|
||||
});
|
||||
appendContent(devicesButton);
|
||||
}));
|
||||
|
||||
android.widget.Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
|
||||
logoutButton.setOnClickListener(v -> logout());
|
||||
|
||||
@@ -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.buildCard(
|
||||
this,
|
||||
"设置说明",
|
||||
"当前设置会持久化到 data/boss-state.json,下一线程接手不会丢失。",
|
||||
"原生设置页直接走 /api/v1/settings"
|
||||
)
|
||||
);
|
||||
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<String> adapter = new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"conversations", "devices", "me"}
|
||||
);
|
||||
preferredEntrySpinner.setAdapter(adapter);
|
||||
}
|
||||
|
||||
liveUpdatesSwitch = new SwitchCompat(this);
|
||||
liveUpdatesSwitch.setText("启用实时刷新");
|
||||
|
||||
riskBadgesSwitch = new SwitchCompat(this);
|
||||
riskBadgesSwitch.setText("显示风险徽标");
|
||||
|
||||
confirmActionsSwitch = new SwitchCompat(this);
|
||||
confirmActionsSwitch.setText("危险操作前确认");
|
||||
|
||||
preferredEntrySpinner = new Spinner(this);
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
replaceContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"conversations", "devices", "me"}
|
||||
);
|
||||
preferredEntrySpinner.setAdapter(adapter);
|
||||
"偏好设置",
|
||||
"调整默认首页和提醒行为。",
|
||||
"保存后会直接写入 /api/v1/settings。"
|
||||
));
|
||||
|
||||
LinearLayout card = BossUi.buildCard(this, "交互偏好", "可切换默认首页与提醒行为。", "保存后立即生效");
|
||||
card.addView(liveUpdatesSwitch);
|
||||
card.addView(riskBadgesSwitch);
|
||||
card.addView(confirmActionsSwitch);
|
||||
card.addView(preferredEntrySpinner);
|
||||
appendContent(card);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +119,7 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
if (device != null) {
|
||||
deviceName = device.optString("name", deviceId);
|
||||
configureScreen("技能", deviceName);
|
||||
appendContent(BossUi.buildCard(
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
deviceName,
|
||||
"当前页按设备查看 Skill 清单。",
|
||||
@@ -81,19 +135,22 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
for (int i = 0; i < skills.length(); i++) {
|
||||
JSONObject skill = skills.optJSONObject(i);
|
||||
if (skill == null) continue;
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
skill.optString("name", "未命名 Skill"),
|
||||
skill.optString("description", "未提供说明"),
|
||||
skill.optString("category", "-")
|
||||
+ " · " + skill.optString("updatedAt", "-")
|
||||
);
|
||||
Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句");
|
||||
+ " · " + skill.optString("updatedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
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);
|
||||
|
||||
402
android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
Normal file
402
android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
Normal file
@@ -0,0 +1,402 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public final class WechatSurfaceMapper {
|
||||
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
|
||||
"会话",
|
||||
"设备",
|
||||
"我的"
|
||||
);
|
||||
|
||||
private static final List<MeMenuItem> 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<String> PROJECT_QUICK_ACTIONS = Arrays.asList(
|
||||
"项目目标",
|
||||
"版本记录"
|
||||
);
|
||||
|
||||
private static final List<String> PROJECT_PRIMARY_SECTIONS = Arrays.asList(
|
||||
"quick_actions",
|
||||
"messages",
|
||||
"composer"
|
||||
);
|
||||
|
||||
private WechatSurfaceMapper() {
|
||||
}
|
||||
|
||||
public static ConversationRow toConversationRow(JSONObject item) {
|
||||
JSONObject source = item == null ? new JSONObject() : item;
|
||||
JSONArray members = source.optJSONArray("groupMembers");
|
||||
List<GroupAvatarMember> 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("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.optString("topPinnedLabel", ""),
|
||||
source.optInt("activityIconCount", 0),
|
||||
isGroup,
|
||||
isGroup ? "" : avatar == null ? "" : avatar.optString("primary", ""),
|
||||
isGroup ? "" : avatar == null ? "" : avatar.optString("secondary", ""),
|
||||
groupAvatarMembers.toArray(new GroupAvatarMember[0])
|
||||
);
|
||||
}
|
||||
|
||||
public static DeviceRow toDeviceRow(JSONObject item) {
|
||||
JSONObject source = item == null ? new JSONObject() : item;
|
||||
return new DeviceRow(
|
||||
source.optString("title", source.optString("name", "")),
|
||||
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(
|
||||
row.title,
|
||||
row.subtitle,
|
||||
mergeDeviceMeta(row.meta, buildDetailMeta(source))
|
||||
);
|
||||
}
|
||||
|
||||
public static String[] rootTabLabels() {
|
||||
return ROOT_TAB_LABELS.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public static String[] rootMeMenuTitles() {
|
||||
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 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<? extends BossScreenActivity> resolveConversationInfoTargetClass(boolean isGroup) {
|
||||
return isGroup ? GroupInfoActivity.class : ConversationInfoActivity.class;
|
||||
}
|
||||
|
||||
public static String[] projectPrimarySections() {
|
||||
return PROJECT_PRIMARY_SECTIONS.toArray(new String[0]);
|
||||
}
|
||||
|
||||
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> 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;
|
||||
}
|
||||
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", ""));
|
||||
appendSegment(builder, source.optString("endpoint", ""));
|
||||
JSONArray projects = source.optJSONArray("projects");
|
||||
if (projects != null && projects.length() > 0) {
|
||||
StringBuilder projectBuilder = new StringBuilder();
|
||||
for (int i = 0; i < projects.length(); i++) {
|
||||
String project = projects.optString(i);
|
||||
if (project.isEmpty()) continue;
|
||||
if (projectBuilder.length() > 0) {
|
||||
projectBuilder.append(", ");
|
||||
}
|
||||
projectBuilder.append(project);
|
||||
}
|
||||
if (projectBuilder.length() > 0) {
|
||||
appendSegment(builder, "项目 " + projectBuilder);
|
||||
}
|
||||
}
|
||||
return builder.length() == 0 ? null : builder.toString();
|
||||
}
|
||||
|
||||
private static void appendSegment(StringBuilder builder, String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(value);
|
||||
}
|
||||
|
||||
public static final class ConversationRow {
|
||||
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 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, String meta, String avatarLabel, String statusKey) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.meta = meta;
|
||||
this.avatarLabel = avatarLabel;
|
||||
this.statusKey = statusKey;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeviceDetailSummary {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
public final String meta;
|
||||
|
||||
public DeviceDetailSummary(String title, String subtitle, String meta) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
android/app/src/main/res/drawable/bg_list_row.xml
Normal file
16
android/app/src/main/res/drawable/bg_list_row.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/boss_bg_app" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_divider" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
12
android/app/src/main/res/drawable/bg_message_incoming.xml
Normal file
12
android/app/src/main/res/drawable/bg_message_incoming.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners
|
||||
android:topLeftRadius="8dp"
|
||||
android:topRightRadius="18dp"
|
||||
android:bottomLeftRadius="18dp"
|
||||
android:bottomRightRadius="18dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_card_stroke" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_green" />
|
||||
<corners
|
||||
android:topLeftRadius="18dp"
|
||||
android:topRightRadius="8dp"
|
||||
android:bottomLeftRadius="18dp"
|
||||
android:bottomRightRadius="18dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/bg_tab_active.xml
Normal file
6
android/app/src/main/res/drawable/bg_tab_active.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1407C160" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/bg_tab_inactive.xml
Normal file
6
android/app/src/main/res/drawable/bg_tab_inactive.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
109
android/app/src/main/res/layout/activity_conversation_info.xml
Normal file
109
android/app/src/main/res/layout/activity_conversation_info.xml
Normal 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>
|
||||
109
android/app/src/main/res/layout/activity_forward_target.xml
Normal file
109
android/app/src/main/res/layout/activity_forward_target.xml
Normal 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>
|
||||
109
android/app/src/main/res/layout/activity_group_create.xml
Normal file
109
android/app/src/main/res/layout/activity_group_create.xml
Normal 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>
|
||||
109
android/app/src/main/res/layout/activity_group_info.xml
Normal file
109
android/app/src/main/res/layout/activity_group_info.xml
Normal 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>
|
||||
@@ -2,7 +2,7 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient">
|
||||
android:background="@color/boss_bg_app">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/login_panel"
|
||||
@@ -18,23 +18,24 @@
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingTop="72dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingBottom="32dp">
|
||||
android:paddingBottom="40dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="center"
|
||||
android:text="B"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="30sp"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/login_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Boss 原生控制台"
|
||||
android:layout_marginTop="22dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
@@ -43,38 +44,12 @@
|
||||
android:id="@+id/login_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="原生 Android 客户端已启用。点击下方按钮直接进入系统。"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:background="@drawable/bg_card"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="当前临时模式"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="1. 这是原生 Android Activity,不再打开 WebView。\n2. 登录暂时不做验证,点击按钮会直接进入最高管理员会话。\n3. 会话 / 设备 / 我的三栏都直接调用现有 Boss API。"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
android:textSize="14sp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/login_progress"
|
||||
@@ -87,11 +62,11 @@
|
||||
android:id="@+id/login_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginTop="22dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:text="登录"
|
||||
android:text=""
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="18sp"
|
||||
@@ -109,12 +84,13 @@
|
||||
<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="20dp"
|
||||
android:paddingTop="18dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="16dp">
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/back_button"
|
||||
@@ -144,7 +120,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:text="会话"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="24sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -152,9 +128,10 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="原生 Android 客户端,直接消费 Boss API。"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="13sp" />
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
@@ -162,11 +139,12 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="刷新"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:text="刷"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
@@ -192,10 +170,11 @@
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingLeft="0dp"
|
||||
android:paddingRight="0dp"
|
||||
android:paddingBottom="88dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
@@ -203,11 +182,13 @@
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="76dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="10dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp">
|
||||
|
||||
@@ -217,10 +198,10 @@
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:background="@drawable/bg_tab_active"
|
||||
android:text="会话"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
@@ -230,10 +211,10 @@
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:text="设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
@@ -242,10 +223,10 @@
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:text="我的"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
191
android/app/src/main/res/layout/activity_project_chat.xml
Normal file
191
android/app/src/main/res/layout/activity_project_chat.xml
Normal file
@@ -0,0 +1,191 @@
|
||||
<?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="14dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<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="20sp"
|
||||
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:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:orientation="horizontal" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_composer_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/project_chat_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="top|start"
|
||||
android:hint="输入消息"
|
||||
android:inputType="textCapSentences|textMultiLine"
|
||||
android:maxLines="4"
|
||||
android:minHeight="44dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_send"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="发送"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_multi_select_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_multi_forward"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="转发"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -2,12 +2,13 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient"
|
||||
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"
|
||||
@@ -74,13 +75,13 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -99,10 +100,9 @@
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="18dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingRight="18dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
<color name="boss_green">#07C160</color>
|
||||
<color name="boss_green_dark">#04984B</color>
|
||||
<color name="boss_surface">#FFFFFFFF</color>
|
||||
<color name="boss_bg_start">#FFF1F6EE</color>
|
||||
<color name="boss_bg_end">#FFE3F0E3</color>
|
||||
<color name="boss_card_stroke">#1A0F1B12</color>
|
||||
<color name="boss_bg_start">#FFF7F7F7</color>
|
||||
<color name="boss_bg_end">#FFF7F7F7</color>
|
||||
<color name="boss_bg_app">#FFF7F7F7</color>
|
||||
<color name="boss_panel">#FFFFFFFF</color>
|
||||
<color name="boss_card_stroke">#14000000</color>
|
||||
<color name="boss_divider">#FFEAEAEA</color>
|
||||
<color name="boss_text_primary">#FF111111</color>
|
||||
<color name="boss_text_muted">#FF5F6B63</color>
|
||||
<color name="boss_text_soft">#FF8E8E93</color>
|
||||
<color name="colorPrimary">@color/boss_green</color>
|
||||
<color name="colorPrimaryDark">@color/boss_green_dark</color>
|
||||
<color name="colorAccent">@color/boss_green</color>
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
public class AboutActivityStaleDownloadCleanupTest {
|
||||
@Test
|
||||
public void collectStaleDownloadIdsForRemoval_returnsIdsWhenReleaseChanged() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
|
||||
.withString("version", "v1.2.9");
|
||||
|
||||
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
"boss-android-v1.2.8-release.apk",
|
||||
"v1.2.8",
|
||||
true,
|
||||
42L,
|
||||
77L
|
||||
);
|
||||
|
||||
assertArrayEquals(new long[]{42L, 77L}, ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collectStaleDownloadIdsForRemoval_returnsEmptyWhenReleaseMatchesLocalPackage() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
|
||||
.withString("version", "v1.2.9");
|
||||
|
||||
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
"boss-android-v1.2.9-release.apk",
|
||||
"v1.2.9",
|
||||
true,
|
||||
42L,
|
||||
77L
|
||||
);
|
||||
|
||||
assertArrayEquals(new long[0], ids);
|
||||
}
|
||||
|
||||
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) {
|
||||
String value = optString(key);
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class BossApiClientForwardingTest {
|
||||
@Test
|
||||
public void forwardProjectMessageWritesStructuredJsonBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/source/forwards"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
JSONObject payload = ForwardPayloads.build("single", "m1", java.util.List.of());
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage("source", "target", payload);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/source/forwards", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测只关心 request body,不需要走 Android org.json 的身份恢复副作用。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private String requestMethodValue = "GET";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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"));
|
||||
assertEquals(
|
||||
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
|
||||
ForwardPayloads.toRequestBody("target", payload)
|
||||
);
|
||||
}
|
||||
|
||||
@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());
|
||||
assertEquals("m1", payload.optJSONArray("sourceMessageIds").optString(0));
|
||||
assertEquals("m2", payload.optJSONArray("sourceMessageIds").optString(1));
|
||||
assertEquals(
|
||||
"{\"targetProjectId\":\"target\",\"mode\":\"bundle\",\"sourceMessageIds\":[\"m1\",\"m2\"]}",
|
||||
ForwardPayloads.toRequestBody("target", payload)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void approvalRequiredResponseUsesApprovalMessage() {
|
||||
StubJSONObject response = new StubJSONObject().withBoolean("approvalRequired", true);
|
||||
assertEquals("已提交主 Agent 审批", ForwardTargetActivity.resolveForwardResultMessage(response));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
StubJSONObject withBoolean(String key, boolean 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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean fallback) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GroupCreateActivityTest {
|
||||
@Test
|
||||
public void collectSelectableConversationItems_filtersOutExistingGroupChats() {
|
||||
JSONObject threadConversation = new StubJSONObject()
|
||||
.withString("projectId", "thread-1")
|
||||
.withString("projectTitle", "线程一")
|
||||
.withBoolean("isGroup", false);
|
||||
JSONObject groupConversation = new StubJSONObject()
|
||||
.withString("projectId", "group-1")
|
||||
.withString("projectTitle", "已有群聊")
|
||||
.withBoolean("isGroup", true);
|
||||
JSONObject sourceConversation = new StubJSONObject()
|
||||
.withString("projectId", "source-1")
|
||||
.withString("projectTitle", "来源线程")
|
||||
.withBoolean("isGroup", false);
|
||||
JSONObject conversationsPayload = new StubJSONObject()
|
||||
.withObjectArray("conversations", threadConversation, groupConversation, sourceConversation);
|
||||
|
||||
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, "source-1");
|
||||
|
||||
assertEquals(1, filtered.size());
|
||||
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
|
||||
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
|
||||
Set<String> currentSelectedIds = linkedSet("thread-1", "thread-3");
|
||||
Set<String> nextCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
|
||||
|
||||
Set<String> reconciled = GroupCreateActivity.reconcileSelectedProjectIds(
|
||||
currentSelectedIds,
|
||||
previousCandidateIds,
|
||||
nextCandidateIds
|
||||
);
|
||||
|
||||
assertEquals(2, reconciled.size());
|
||||
assertTrue(reconciled.contains("thread-1"));
|
||||
assertTrue(reconciled.contains("thread-3"));
|
||||
assertFalse(reconciled.contains("thread-2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
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()));
|
||||
}
|
||||
|
||||
private static Set<String> linkedSet(String... values) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
for (String value : values) {
|
||||
result.add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
StubJSONObject withBoolean(String key, boolean value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withObjectArray(String key, JSONObject... entries) {
|
||||
values.put(key, new StubJSONArray(entries));
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray optJSONArray(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONArray ? (JSONArray) value : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubJSONArray extends JSONArray {
|
||||
private final JSONObject[] entries;
|
||||
|
||||
StubJSONArray(JSONObject... entries) {
|
||||
this.entries = entries == null ? new JSONObject[0] : entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(int index) {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return null;
|
||||
}
|
||||
return entries[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class OtaDownloadStateMapperTest {
|
||||
@Test
|
||||
public void toProgressLabel_formatsKnownProgress() {
|
||||
assertEquals("已下载 50%", OtaDownloadStateMapper.toProgressLabel(50, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toProgressLabel_handlesUnknownProgress() {
|
||||
assertEquals("正在准备下载", OtaDownloadStateMapper.toProgressLabel(0, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class ProjectChatUiStateTest {
|
||||
@Test
|
||||
public void sendEnabled_requiresTextAndNotBusy() {
|
||||
assertFalse(ProjectChatUiState.canSend("", false));
|
||||
assertFalse(ProjectChatUiState.canSend(" ", false));
|
||||
assertFalse(ProjectChatUiState.canSend("你好", true));
|
||||
assertTrue(ProjectChatUiState.canSend("你好", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAutoScroll_onlyWhenNearBottomOrForced() {
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(true, false));
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(false, true));
|
||||
assertFalse(ProjectChatUiState.shouldAutoScroll(false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void entersMultiSelectModeAfterFirstToggle() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertTrue(state.multiSelecting);
|
||||
assertEquals(1, state.selectedMessageIds.size());
|
||||
assertTrue(state.selectedMessageIds.contains("m1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deselectingLastMessageExitsMultiSelectMode() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m1");
|
||||
assertFalse(next.multiSelecting);
|
||||
assertTrue(next.selectedMessageIds.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardRequiresAtLeastTwoMessages() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertFalse(ProjectChatUiState.canForwardSelection(state));
|
||||
|
||||
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m2");
|
||||
assertTrue(ProjectChatUiState.canForwardSelection(next));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selectionPreservesInsertionOrder() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m1");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m3");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[] {"m2", "m1", "m3"},
|
||||
new ArrayList<>(state.selectedMessageIds).toArray(new String[0])
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSelectionRejectsBlankMessageIds() {
|
||||
try {
|
||||
ProjectChatUiState.toggleSelection(null, " ");
|
||||
fail("Expected IllegalArgumentException");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
assertEquals("messageId must not be blank", expected.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleForwardMessageUsesSingleModeLabel() {
|
||||
assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardMessageUsesBundleModeLabel() {
|
||||
assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chromeStateUsesMultiSelectHeaderAndActionsWhenSelecting() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m2");
|
||||
|
||||
ProjectChatUiState.ChromeState chromeState =
|
||||
ProjectChatUiState.resolveChromeState(state, true, "北区试产线回归", "归档确认");
|
||||
|
||||
assertTrue(chromeState.multiSelecting);
|
||||
assertFalse(chromeState.showComposer);
|
||||
assertTrue(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertTrue(chromeState.forwardEnabled);
|
||||
assertEquals("取消", chromeState.backLabel);
|
||||
assertEquals("已选 2 条", chromeState.title);
|
||||
assertEquals("选择要转发的消息", chromeState.subtitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chromeStateUsesConversationHeaderWhenNotSelecting() {
|
||||
ProjectChatUiState.ChromeState chromeState =
|
||||
ProjectChatUiState.resolveChromeState(ProjectChatUiState.emptySelection(), true, "北区试产线回归", "归档确认");
|
||||
|
||||
assertFalse(chromeState.multiSelecting);
|
||||
assertTrue(chromeState.showComposer);
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertTrue(chromeState.showRefresh);
|
||||
assertTrue(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
assertEquals("归档确认", chromeState.subtitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reconcileSelectionDropsMessagesMissingFromRenderSet() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m2");
|
||||
|
||||
ProjectChatUiState.SelectionState reconciled =
|
||||
ProjectChatUiState.reconcileSelection(state, List.of("m2", "m3"));
|
||||
|
||||
assertTrue(reconciled.multiSelecting);
|
||||
assertEquals(List.of("m2"), new ArrayList<>(reconciled.selectedMessageIds));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void summarizeForwardBundleTruncatesLongLastMessage() {
|
||||
String summary = ProjectChatUiState.summarizeForwardBundle(
|
||||
"这是一条很长很长很长的转发消息摘要,用来验证截断逻辑是否生效并避免卡片过高",
|
||||
3
|
||||
);
|
||||
|
||||
assertTrue(summary.startsWith("3 条消息 · 最后一条:"));
|
||||
assertTrue(summary.endsWith("…"));
|
||||
assertTrue(summary.contains("这是一条很长很长很长的转发消息摘要"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 ProjectDetailActivityChromeBindingsTest {
|
||||
@Test
|
||||
public void multiSelectBindingsHideComposerAndDisableRefresh() {
|
||||
ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.selectOnly("m1");
|
||||
selectionState = ProjectChatUiState.toggleSelection(selectionState, "m2");
|
||||
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
|
||||
selectionState,
|
||||
true,
|
||||
"北区试产线回归",
|
||||
"归档确认"
|
||||
);
|
||||
|
||||
ProjectDetailActivity.ChromeBindings bindings =
|
||||
ProjectDetailActivity.buildChromeBindings(chromeState, false);
|
||||
|
||||
assertTrue(bindings.multiSelecting);
|
||||
assertFalse(bindings.showComposer);
|
||||
assertTrue(bindings.showMultiSelectBar);
|
||||
assertFalse(bindings.showRefresh);
|
||||
assertFalse(bindings.showHeaderAction);
|
||||
assertTrue(bindings.enableForwardButton);
|
||||
assertFalse(bindings.enablePullRefresh);
|
||||
assertEquals("取消", bindings.backLabel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalBindingsRestoreConversationChrome() {
|
||||
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
|
||||
ProjectChatUiState.emptySelection(),
|
||||
true,
|
||||
"北区试产线回归",
|
||||
"归档确认"
|
||||
);
|
||||
|
||||
ProjectDetailActivity.ChromeBindings bindings =
|
||||
ProjectDetailActivity.buildChromeBindings(chromeState, true);
|
||||
|
||||
assertFalse(bindings.multiSelecting);
|
||||
assertTrue(bindings.showComposer);
|
||||
assertFalse(bindings.showMultiSelectBar);
|
||||
assertTrue(bindings.showRefresh);
|
||||
assertTrue(bindings.showHeaderAction);
|
||||
assertFalse(bindings.enableForwardButton);
|
||||
assertTrue(bindings.enablePullRefresh);
|
||||
assertEquals("返回", bindings.backLabel);
|
||||
assertEquals("北区试产线回归", bindings.title);
|
||||
assertEquals("归档确认", bindings.subtitle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ProjectDetailActivityUiTest {
|
||||
@Test
|
||||
public void multiSelectModeUpdatesRealChatChrome() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "北区试产线回归");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
|
||||
|
||||
LinearLayout contentLayout = activity.findViewById(R.id.screen_content);
|
||||
View firstMessage = buildBoundMessageView(activity, "m1", "第一条消息");
|
||||
View secondMessage = buildBoundMessageView(activity, "m2", "第二条消息");
|
||||
contentLayout.addView(firstMessage);
|
||||
contentLayout.addView(secondMessage);
|
||||
|
||||
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
|
||||
|
||||
firstMessage.performLongClick();
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
View multiSelectItem = listView.getAdapter().getView(1, null, listView);
|
||||
listView.performItemClick(multiSelectItem, 1, listView.getAdapter().getItemId(1));
|
||||
|
||||
LinearLayout composerRow = activity.findViewById(R.id.project_chat_composer_row);
|
||||
LinearLayout multiSelectActions = activity.findViewById(R.id.project_chat_multi_select_actions);
|
||||
Button backButton = activity.findViewById(R.id.screen_back_button);
|
||||
Button refreshButton = activity.findViewById(R.id.screen_refresh_button);
|
||||
Button forwardButton = activity.findViewById(R.id.project_chat_multi_forward);
|
||||
|
||||
assertEquals(View.GONE, composerRow.getVisibility());
|
||||
assertEquals(View.VISIBLE, multiSelectActions.getVisibility());
|
||||
assertEquals("取消", backButton.getText().toString());
|
||||
assertEquals(View.GONE, refreshButton.getVisibility());
|
||||
assertEquals(false, forwardButton.isEnabled());
|
||||
|
||||
secondMessage.performClick();
|
||||
assertTrue(forwardButton.isEnabled());
|
||||
|
||||
backButton.performClick();
|
||||
|
||||
assertEquals(View.VISIBLE, composerRow.getVisibility());
|
||||
assertEquals(View.GONE, multiSelectActions.getVisibility());
|
||||
assertEquals("返回", backButton.getText().toString());
|
||||
assertEquals(View.VISIBLE, refreshButton.getVisibility());
|
||||
}
|
||||
|
||||
private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) {
|
||||
TextView messageView = new TextView(activity);
|
||||
messageView.setText(body);
|
||||
messageView.setGravity(Gravity.START);
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"bindMessageInteractions",
|
||||
ReflectionHelpers.ClassParameter.from(View.class, messageView),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, messageId),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, body)
|
||||
);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
public static class TestProjectDetailActivity extends ProjectDetailActivity {
|
||||
@Override
|
||||
boolean shouldLoadOnCreate() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class RootTabMemoryTest {
|
||||
@Test
|
||||
public void resolveInitialTab_prefersExplicitTab() {
|
||||
assertEquals("devices", RootTabMemory.resolveInitialTab("devices", "me", "conversations"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_fallsBackToStoredTab() {
|
||||
assertEquals("me", RootTabMemory.resolveInitialTab(null, "me", "devices"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_usesPreferredEntryBeforeDefault() {
|
||||
assertEquals("devices", RootTabMemory.resolveInitialTab(null, null, "devices"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_defaultsToConversations() {
|
||||
assertEquals("conversations", RootTabMemory.resolveInitialTab(null, null, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class WechatSurfaceMapperTest {
|
||||
@Test
|
||||
public void toConversationRow_mapsWechatConversationFieldsFromThreadPayload() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("projectTitle", "项目 A")
|
||||
.withString("threadTitle", "北区试产线回归")
|
||||
.withString("folderLabel", "归档确认")
|
||||
.withString("preview", "旧预览")
|
||||
.withString("lastMessagePreview", "现场摄像头关键帧")
|
||||
.withString("latestReplyLabel", "09:26")
|
||||
.withInt("unreadCount", 3)
|
||||
.withInt("activityIconCount", 2)
|
||||
.withString("topPinnedLabel", "置顶")
|
||||
.withBoolean("isGroup", false)
|
||||
.withObject("avatar", new StubJSONObject()
|
||||
.withString("primary", "M")
|
||||
.withString("secondary", "W"))
|
||||
.withObjectArray("groupMembers",
|
||||
new StubJSONObject().withString("threadId", "t-1").withString("avatar", "M").withString("title", "Mac Studio"),
|
||||
new StubJSONObject().withString("threadId", "t-2").withString("avatar", "W").withString("title", "Windows GPU"));
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("北区试产线回归", row.threadTitle);
|
||||
assertEquals("归档确认", row.folderLabel);
|
||||
assertEquals("现场摄像头关键帧", row.lastMessagePreview);
|
||||
assertEquals("09:26", row.timeLabel);
|
||||
assertEquals(3, row.unreadCount);
|
||||
assertEquals("置顶", row.topPinnedLabel);
|
||||
assertEquals(2, row.activityIconCount);
|
||||
assertFalse(row.isGroup);
|
||||
assertEquals("M", row.avatarPrimary);
|
||||
assertEquals("W", row.avatarSecondary);
|
||||
assertEquals(2, row.groupAvatarMembers.length);
|
||||
assertEquals("Mac Studio", row.groupAvatarMembers[0].title);
|
||||
assertEquals("W", row.groupAvatarMembers[1].avatarLabel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersGroupMembersForGroupAvatarSummary() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("projectTitle", "群聊项目")
|
||||
.withString("threadTitle", "容灾切换验证")
|
||||
.withString("folderLabel", "Mac + Windows 协作")
|
||||
.withString("lastMessagePreview", "最新: API 切换记录回传")
|
||||
.withString("latestReplyLabel", "09:12")
|
||||
.withInt("activityIconCount", 2)
|
||||
.withString("topPinnedLabel", "置顶")
|
||||
.withBoolean("isGroup", true)
|
||||
.withObjectArray("groupMembers",
|
||||
new StubJSONObject().withString("threadId", "group-1").withString("avatar", "M").withString("title", "Mac Studio"),
|
||||
new StubJSONObject().withString("threadId", "group-2").withString("avatar", "W").withString("title", "Windows GPU"),
|
||||
new StubJSONObject().withString("threadId", "group-3").withString("avatar", "C").withString("title", "Cloud Backup"));
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertTrue(row.isGroup);
|
||||
assertEquals("容灾切换验证", row.threadTitle);
|
||||
assertEquals("Mac + Windows 协作", row.folderLabel);
|
||||
assertEquals("最新: API 切换记录回传", row.lastMessagePreview);
|
||||
assertEquals("09:12", row.timeLabel);
|
||||
assertEquals(3, row.groupAvatarMembers.length);
|
||||
assertEquals("M", row.groupAvatarMembers[0].avatarLabel);
|
||||
assertEquals("Cloud Backup", row.groupAvatarMembers[2].title);
|
||||
assertEquals("", row.avatarPrimary);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("avatar", "M")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
|
||||
.withInt("quota5h", 8)
|
||||
.withInt("quota7d", 22);
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
|
||||
assertEquals("M", row.avatarLabel);
|
||||
assertEquals("online", row.statusKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_preservesAbnormalStatus() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "abnormal")
|
||||
.withString("account", "17600003315");
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315", row.subtitle);
|
||||
assertEquals("额度: 暂无 · 状态异常", row.meta);
|
||||
assertEquals("abnormal", row.statusKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deviceDetailSummary_keepsEssentialContextAlongsideNoteEndpointAndProjects() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withString("note", "书房主机")
|
||||
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
|
||||
.withStringArray("projects", "master-agent", "android-app");
|
||||
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
|
||||
|
||||
assertEquals("Mac Studio", summary.title);
|
||||
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTabOrder_isWechatStyle() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"会话", "设备", "我的"},
|
||||
WechatSurfaceMapper.rootTabLabels()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void opsEntryCopy_staysInMeFlowWithoutLegacyAdvancedEntrySemantics() throws Exception {
|
||||
WechatSurfaceMapper.MeMenuItem opsItem = WechatSurfaceMapper.findMeMenuItem("ops");
|
||||
|
||||
assertNotNull(opsItem);
|
||||
assertEquals("运维与修复", opsItem.title);
|
||||
assertEquals("查看运维会话、修复回放与 standby 切换", opsItem.description);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void meFlow_doesNotExposeAuditConversationCopy() throws Exception {
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
|
||||
assertFalse(item.title.contains("审计"));
|
||||
assertFalse(item.description.contains("审计"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_parsesStructuredOtaSummaryArrayIntoReadableContent() throws Exception {
|
||||
JSONObject ota = new StubJSONObject()
|
||||
.withObject("availableRelease", new StubJSONObject()
|
||||
.withString("version", "v1.2.8")
|
||||
.withStringArray("summary", "优化设备状态刷新", "修复主 Agent 会话排序", "提升 OTA 回收稳定性"));
|
||||
|
||||
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod("buildOtaContentBody", JSONObject.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String content = (String) method.invoke(null, ota);
|
||||
|
||||
assertEquals("版本 v1.2.8\n1. 优化设备状态刷新\n2. 修复主 Agent 会话排序\n3. 提升 OTA 回收稳定性", content);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_rejectsStaleDownloadedApkWhenAvailableReleaseChanged() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("version", "v1.2.9")
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk");
|
||||
|
||||
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
|
||||
"isDownloadedReleaseCurrent",
|
||||
JSONObject.class,
|
||||
String.class,
|
||||
String.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
boolean stillCurrent = (Boolean) method.invoke(
|
||||
null,
|
||||
availableRelease,
|
||||
"boss-android-v1.2.8-release.apk",
|
||||
"v1.2.8"
|
||||
);
|
||||
|
||||
assertFalse(stillCurrent);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skillInventory_fallsBackWhenExplicitDeviceIdIsInvalid() throws Exception {
|
||||
JSONArray devices = new StubObjectArray(
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-b")
|
||||
.withString("account", "17600003315"),
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-c")
|
||||
.withString("account", "other-account")
|
||||
);
|
||||
|
||||
java.lang.reflect.Method method = SkillInventoryActivity.class.getDeclaredMethod(
|
||||
"chooseTargetDeviceId",
|
||||
String.class,
|
||||
String.class,
|
||||
String.class,
|
||||
JSONArray.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
String resolved = (String) method.invoke(
|
||||
null,
|
||||
"stale-device-id",
|
||||
"missing-bound-device",
|
||||
"17600003315",
|
||||
devices
|
||||
);
|
||||
|
||||
assertEquals("device-b", resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_collectsAllPositiveDownloadIdsForStaleRemoval() throws Exception {
|
||||
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
|
||||
"collectDownloadIdsForRemoval",
|
||||
long.class,
|
||||
long.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
long[] ids = (long[]) method.invoke(null, 42L, 77L);
|
||||
|
||||
assertArrayEquals(new long[]{42L, 77L}, ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectQuickActions_keepOnlyGoalsAndVersions() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"项目目标", "版本记录"},
|
||||
WechatSurfaceMapper.projectQuickActions()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectDetailInfoTarget_routesSingleThreadsToConversationInfo() {
|
||||
assertEquals(ConversationInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectDetailInfoTarget_routesGroupChatsToGroupInfo() {
|
||||
assertEquals(GroupInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectPrimarySections_keepOnlyChatEssentials() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"quick_actions", "messages", "composer"},
|
||||
WechatSurfaceMapper.projectPrimarySections()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationsChrome_copyStaysLightweightInsteadOfConsoleInstructions() throws Exception {
|
||||
assertEquals("会话", WechatSurfaceMapper.loginTitle());
|
||||
assertEquals("进入会话", WechatSurfaceMapper.loginButtonLabel());
|
||||
assertEquals("项目自动对应设备 GUI 项目文件夹", WechatSurfaceMapper.conversationsHintPillText());
|
||||
assertEquals("", WechatSurfaceMapper.conversationsHeaderSubtitle());
|
||||
assertFalse(WechatSurfaceMapper.loginHintText().contains("Boss API"));
|
||||
assertFalse(WechatSurfaceMapper.loginHintText().contains("控制台"));
|
||||
assertFalse(WechatSurfaceMapper.loginHintText().contains("数据仍来自现有 Boss API"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationActivityIcons_useAnimatedDotViewsInsteadOfTextGlyphs() throws Exception {
|
||||
assertEquals("animated_dots", WechatSurfaceMapper.conversationActivityIconMode());
|
||||
assertEquals(4, WechatSurfaceMapper.maxConversationActivityIcons());
|
||||
assertEquals("cancel_on_detach", WechatSurfaceMapper.conversationActivityAnimationCleanup());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
|
||||
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
|
||||
|
||||
assertEquals(6, items.length);
|
||||
assertEquals("security", items[0].key);
|
||||
assertEquals("账号与安全", items[0].title);
|
||||
assertEquals("settings", items[1].key);
|
||||
assertEquals("ops", items[2].key);
|
||||
assertEquals("运维与修复", items[2].title);
|
||||
assertEquals("ai_accounts", items[3].key);
|
||||
assertEquals("skills", items[4].key);
|
||||
assertEquals("about", items[5].key);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshMergePolicy_appliesSuccessfulPayloadsWithoutDroppingCachedValues() throws Exception {
|
||||
JSONArray cachedConversations = new StubStringArray("cached-conversation");
|
||||
JSONArray freshConversations = new StubStringArray("fresh-conversation");
|
||||
JSONArray cachedDevices = new StubStringArray("cached-device");
|
||||
JSONObject cachedOta = new StubJSONObject().withString("version", "1.0.0");
|
||||
JSONObject freshSettings = new StubJSONObject().withString("preferredEntryPoint", "devices");
|
||||
|
||||
assertSame(
|
||||
freshConversations,
|
||||
WechatSurfaceMapper.resolveRefreshValue(cachedConversations, freshConversations, true)
|
||||
);
|
||||
assertSame(
|
||||
cachedDevices,
|
||||
WechatSurfaceMapper.resolveRefreshValue(cachedDevices, new StubStringArray("fresh-device"), false)
|
||||
);
|
||||
assertSame(
|
||||
cachedOta,
|
||||
WechatSurfaceMapper.resolveRefreshValue(cachedOta, new StubJSONObject().withString("version", "2.0.0"), false)
|
||||
);
|
||||
assertSame(
|
||||
freshSettings,
|
||||
WechatSurfaceMapper.resolveRefreshValue(null, freshSettings, true)
|
||||
);
|
||||
assertNull(WechatSurfaceMapper.resolveRefreshValue(null, null, false));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
StubJSONObject withInt(String key, int value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withBoolean(String key, boolean value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withObject(String key, JSONObject value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withStringArray(String key, String... entries) {
|
||||
values.put(key, new StubStringArray(entries));
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withObjectArray(String key, JSONObject... entries) {
|
||||
values.put(key, new StubObjectArray(entries));
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int optInt(String key, int defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Integer ? (Integer) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONObject ? (JSONObject) value : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray optJSONArray(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONArray ? (JSONArray) value : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubStringArray extends JSONArray {
|
||||
private final String[] entries;
|
||||
|
||||
StubStringArray(String... entries) {
|
||||
this.entries = entries == null ? new String[0] : entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(int index) {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return "";
|
||||
}
|
||||
return entries[index] == null ? "" : entries[index];
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubObjectArray extends JSONArray {
|
||||
private final JSONObject[] entries;
|
||||
|
||||
StubObjectArray(JSONObject... entries) {
|
||||
this.entries = entries == null ? new JSONObject[0] : entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(int index) {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return null;
|
||||
}
|
||||
return entries[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,16 @@
|
||||
- `android`:原生 Android 客户端工程
|
||||
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`:原生入口 Activity
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`:原生 API 客户端
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`:原生项目详情、消息、目标/版本/转发入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情和技能入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`:原生聊天优先项目页,只保留目标/版本轻入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`:原生微信式会话信息页,支持线程改名和发起群聊
|
||||
- `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`:原生群资料页,支持群名修改与成员查看
|
||||
- `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/DeviceDetailActivity.java`:原生设备详情与技能入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
|
||||
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`:原生聊天页布局
|
||||
- `android/signing/release-signing.properties.example`:release 签名模板
|
||||
|
||||
这些不是当前运行真相:
|
||||
@@ -105,15 +111,18 @@
|
||||
## 5. 当前最重要的产品逻辑
|
||||
|
||||
- 一级导航固定:`会话 / 设备 / 我的`
|
||||
- `会话` 页按项目渲染聊天列表,主 Agent 永远置顶
|
||||
- 单设备项目显示单头像,多设备协作显示群聊式组合头像
|
||||
- 非群聊项目显示线程上下文余量圆环
|
||||
- 项目聊天页必须展示活跃线程预算、handoff 状态和主 Agent 调度摘要
|
||||
- `会话` 页当前按“线程 = 聊天窗口”渲染聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式固定在最上面
|
||||
- 单线程会话主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标
|
||||
- 单设备项目显示单头像,多线程群聊显示群聊式组合头像
|
||||
- 项目聊天页当前已经改成聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口;线程预算、handoff、运维与转发能力仍保留数据和深层活动页,但不再出现在主聊天面
|
||||
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
|
||||
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
|
||||
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
|
||||
- 主 Agent 项目页会实时吸收 APP 端日志,用于边对话边指导 APK / Web 优化
|
||||
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
|
||||
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
|
||||
- `版本迭代记录` 只读,由主 Agent 汇总
|
||||
- `我的 > 运维与修复 > 运维对话 / 审计对话`
|
||||
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
|
||||
- `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句
|
||||
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
|
||||
@@ -130,7 +139,7 @@
|
||||
- 邮件:`Postfix + Dovecot`
|
||||
- Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
|
||||
- 原生登录恢复:`SharedPreferences + restore token`
|
||||
- 当前最新原生 APK:`2.1.1`(`versionCode=8`)
|
||||
- 当前最新原生 APK:`2.4.0`(`versionCode=12`)
|
||||
|
||||
当前不要误判成已经用了:
|
||||
|
||||
|
||||
@@ -21,9 +21,14 @@
|
||||
- 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 当前导航:`会话 / 设备 / 我的`
|
||||
- 当前一级交互:微信式简单列表与聊天优先
|
||||
- 当前微信式 surface contract:`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- 当前原生活动页:
|
||||
- `MainActivity`
|
||||
- `ProjectDetailActivity`
|
||||
- `ConversationInfoActivity`
|
||||
- `GroupInfoActivity`
|
||||
- `GroupCreateActivity`
|
||||
- `ProjectGoalsActivity`
|
||||
- `ProjectVersionsActivity`
|
||||
- `ProjectForwardActivity`
|
||||
@@ -36,6 +41,32 @@
|
||||
- `AiAccountsActivity`
|
||||
- `OpsCenterActivity`
|
||||
- `AboutActivity`
|
||||
- 当前项目聊天页:
|
||||
- `ProjectDetailActivity` 已改成聊天优先布局
|
||||
- 主面只保留 `项目目标 / 版本记录`
|
||||
- 右上角会进入微信式 `会话信息 / 群资料`
|
||||
- 单线程会话支持按微信最新逻辑改线程名
|
||||
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
|
||||
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`
|
||||
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
|
||||
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
|
||||
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
|
||||
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
|
||||
- 当前根页导航:
|
||||
- `MainActivity` 会记住最近一次停留的 `会话 / 设备 / 我的` tab
|
||||
- 根页返回逻辑已改成“先回会话 tab,再按一次返回进入后台”
|
||||
- 当前会话列表:
|
||||
- 已切到“线程 = 会话窗口”
|
||||
- 主标题显示线程名
|
||||
- 第二行显示所属文件夹名
|
||||
- 右下角显示后台活跃数量动态图标
|
||||
- `主 Agent / 审计对话` 已作为普通置顶会话固定在顶部
|
||||
- 当前 `关于` 页:
|
||||
- 保留版本与 OTA 操作
|
||||
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
|
||||
- 当前 `我的` 根页:
|
||||
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `运维与修复` 直接进入 `OpsCenterActivity`
|
||||
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
|
||||
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
|
||||
|
||||
@@ -235,8 +266,12 @@
|
||||
- 关键字段:
|
||||
- `conversationType`
|
||||
- `manualPinned`
|
||||
- `contextBudgetIndicator`
|
||||
- `mustFinishBeforeCompaction`
|
||||
- `threadTitle`
|
||||
- `folderLabel`
|
||||
- `lastMessagePreview`
|
||||
- `activityIconCount`
|
||||
- `topPinnedLabel`
|
||||
- `groupMembers`
|
||||
|
||||
#### `POST /api/v1/conversations/[projectId]/actions`
|
||||
|
||||
@@ -268,6 +303,36 @@
|
||||
- `projectId=master-agent` 且 `kind=text` 时,会继续触发主 Agent 真实回复链路
|
||||
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
||||
- 如本机节点未接通,可切到 `OpenAI API` 容灾账号
|
||||
- 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批
|
||||
|
||||
#### `GET /api/v1/projects/[projectId]/participants`
|
||||
|
||||
- 用途:读取单线程会话的线程归属信息,或群聊会话的成员线程列表
|
||||
- 返回:
|
||||
- `projectId`
|
||||
- `isGroup`
|
||||
- `threadMeta`
|
||||
- `participants[]`
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/rename`
|
||||
|
||||
- 用途:重命名单线程会话或群聊会话
|
||||
- 输入:
|
||||
- `mode`: `thread | group`
|
||||
- `name`
|
||||
- 当前行为:
|
||||
- `mode=thread` 时同步更新线程显示名和会话标题
|
||||
- `mode=group` 时更新群聊名称
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/group-chat`
|
||||
|
||||
- 用途:从当前单线程会话出发,创建新的独立群聊
|
||||
- 输入:
|
||||
- `memberProjectIds[]`
|
||||
- 当前行为:
|
||||
- 原始单线程会话会保留
|
||||
- 新群聊默认自动命名
|
||||
- 新群聊默认由主 Agent 发起,并以开发任务协作为默认模式
|
||||
|
||||
#### `GET /api/v1/accounts`
|
||||
|
||||
@@ -312,8 +377,17 @@
|
||||
|
||||
- 用途:把消息转发到另一个项目
|
||||
- 输入:
|
||||
- `mode`: `single | bundle`
|
||||
- `targetProjectId`
|
||||
- `note`
|
||||
- `sourceMessageId`:当 `mode=single`
|
||||
- `sourceMessageIds`:当 `mode=bundle`,且至少 2 条
|
||||
- 当前行为:
|
||||
- `single` 会生成 `kind=forward_single` 的普通转发消息,并保留 `forwardSource`
|
||||
- `bundle` 会生成 `kind=forward_bundle` 的聊天记录卡片,并保留来源会话、消息数量、时间范围和摘要列表
|
||||
- 当前一次只允许选择一个目标会话
|
||||
- 当前会过滤源会话本身,避免把消息转发回当前会话
|
||||
- 当前目标既可以是单线程会话,也可以是群聊、`主 Agent` 或 `审计对话`
|
||||
- 非开发任务下如命中线程沟通限制,接口会预留 `approvalRequired / approvalReason` 返回
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/goals`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boss 当前运行与部署状态
|
||||
|
||||
更新时间:`2026-03-26`
|
||||
更新时间:`2026-03-28`
|
||||
|
||||
## 1. 本地状态
|
||||
|
||||
@@ -89,8 +89,14 @@ cd /Users/kris/code/boss
|
||||
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
|
||||
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
|
||||
- UI 外壳已收口为真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部一级导航固定在视口底部,背景图按手机 viewport 全屏 cover,WebView 不再显示外层圆角矩形预览壳
|
||||
- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab,一级面维持微信式简单列表和聊天优先;`主 Agent / 审计对话` 以普通置顶会话样式固定在会话首页顶部
|
||||
- 会话列表当前已切到“线程 = 聊天窗口”:主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会渲染成多个独立聊天窗口
|
||||
- 项目详情页右上角当前会进入微信式会话信息页:单线程会话支持改名和发起群聊,群聊会进入群资料页并支持改群名
|
||||
- 项目详情页当前已补齐微信式消息转发:长按消息会弹出 `转发 / 多选 / 复制 / 删除 / 取消`;单条消息直接进入统一会话选择页,多选消息会进入合并转发链路
|
||||
- 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话`
|
||||
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
|
||||
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
|
||||
- 我的页当前新增 `AI 账号` 入口,支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
|
||||
- 主 Agent 同步等待窗口当前为 55 秒;若本机 Codex 节点回复更慢,项目页仍会通过 SSE 在任务完成后自动刷新出真实回复
|
||||
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
|
||||
@@ -108,12 +114,16 @@ cd /Users/kris/code/boss
|
||||
- 当前已生成 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`
|
||||
- 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.1.1`(`versionCode=8`)
|
||||
- 当前最新 release 构建版本:`2.4.0`(`versionCode=12`)
|
||||
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
|
||||
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
|
||||
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
|
||||
- `2.1.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
|
||||
- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
|
||||
- `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
|
||||
- `2.2.1` 已继续补齐原生交互细节:聊天页会即时显示本地“发送中”气泡,并且只在用户接近底部或本次发送主动触发时自动滚到底;关于页会显示 OTA 下载进度 / 重试 / 安装授权提示,离开后再回来仍会恢复本地下载状态;根 tab 会记住最近一次用户停留页,并把一级页返回逻辑收成“先回会话 tab,再按一次返回进入后台”
|
||||
- `2.3.0` 已把原生会话模型切到“线程 = 聊天窗口”:补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
|
||||
- `2.4.0` 已把原生消息转发切到微信式链路:单条消息支持长按直接转发,多选消息支持合并转发成聊天记录卡片,统一使用原生会话选择页替换旧的备注转发页
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
@@ -195,6 +205,8 @@ cd /Users/kris/code/boss
|
||||
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力
|
||||
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
|
||||
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 页面自行配置 `OpenAI API` 账号,不再依赖服务器预置 Key
|
||||
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
|
||||
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
|
||||
- 图片 / 视频真实文件上传仍未接对象存储
|
||||
- 认证虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、CSRF 防护和更细的风控策略
|
||||
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- `android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
文档:
|
||||
|
||||
364
docs/superpowers/plans/2026-03-27-wechat-native-ui-phase2.md
Normal file
364
docs/superpowers/plans/2026-03-27-wechat-native-ui-phase2.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# WeChat Native UI Phase 2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Polish the native Android WeChat-style rollback with better chat feel, clearer OTA download/install feedback, and smoother root navigation memory.
|
||||
|
||||
**Architecture:** Keep the approved WeChat-style root surfaces intact and limit this batch to interaction polish. Add small pure-Java helpers for chat UI state and OTA download state so the tricky behavior is unit-tested, then wire those helpers into `ProjectDetailActivity`, `AboutActivity`, and `MainActivity`.
|
||||
|
||||
**Tech Stack:** Android AppCompat, XML layouts, Java 21, Gradle 8, JUnit4, DownloadManager, existing Boss APIs, existing release packaging and deploy scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Pure-Java helper for send-button enabled state, optimistic pending bubble state, and auto-scroll policy.
|
||||
- `android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java`
|
||||
- Pure-Java helper for mapping `DownloadManager` progress/status into UI strings and retry/install states.
|
||||
- `android/app/src/main/java/com/hyzq/boss/RootTabMemory.java`
|
||||
- Pure-Java helper for resolving explicit root tabs versus stored root tabs.
|
||||
- `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
- Unit tests for chat send/pending/scroll rules.
|
||||
- `android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java`
|
||||
- Unit tests for OTA download state mapping.
|
||||
- `android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java`
|
||||
- Unit tests for remembered root tab selection.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- `README.md`
|
||||
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
## Task 1: Polish Chat Composer Feedback and Scroll Behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class ProjectChatUiStateTest {
|
||||
@Test
|
||||
public void sendEnabled_requiresTextAndNotBusy() {
|
||||
assertFalse(ProjectChatUiState.canSend("", false));
|
||||
assertFalse(ProjectChatUiState.canSend(" ", false));
|
||||
assertFalse(ProjectChatUiState.canSend("你好", true));
|
||||
assertTrue(ProjectChatUiState.canSend("你好", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAutoScroll_onlyWhenNearBottomOrForced() {
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(true, false));
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(false, true));
|
||||
assertFalse(ProjectChatUiState.shouldAutoScroll(false, false));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with missing `ProjectChatUiState`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class ProjectChatUiState {
|
||||
private ProjectChatUiState() {}
|
||||
|
||||
public static boolean canSend(String text, boolean sending) {
|
||||
return !sending && text != null && !text.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then integrate it into `ProjectDetailActivity`:
|
||||
|
||||
- disable send button when text empty or request busy
|
||||
- show a local pending outgoing bubble immediately after tapping send
|
||||
- only auto-scroll on refresh when user was already near bottom or after local send
|
||||
|
||||
- [ ] **Step 4: Run tests and compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java \
|
||||
android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java \
|
||||
android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java \
|
||||
android/app/src/main/java/com/hyzq/boss/BossUi.java
|
||||
git commit -m "feat: polish native chat composer feedback"
|
||||
```
|
||||
|
||||
## Task 2: Add OTA Download Progress and Retry Guidance
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class OtaDownloadStateMapperTest {
|
||||
@Test
|
||||
public void toProgressLabel_formatsKnownProgress() {
|
||||
assertEquals("已下载 50%", OtaDownloadStateMapper.toProgressLabel(50, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toProgressLabel_handlesUnknownProgress() {
|
||||
assertEquals("正在准备下载", OtaDownloadStateMapper.toProgressLabel(0, false));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.OtaDownloadStateMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with missing `OtaDownloadStateMapper`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class OtaDownloadStateMapper {
|
||||
private OtaDownloadStateMapper() {}
|
||||
|
||||
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
|
||||
if (!hasKnownTotal) {
|
||||
return "正在准备下载";
|
||||
}
|
||||
return "已下载 " + Math.max(0, Math.min(100, percent)) + "%";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then integrate it into `AboutActivity`:
|
||||
|
||||
- query `DownloadManager` while download is active
|
||||
- render a visible progress row in page content
|
||||
- keep a retry action after failed download
|
||||
- show install permission guidance row when unknown-source install permission is missing
|
||||
|
||||
- [ ] **Step 4: Run tests and compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.OtaDownloadStateMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java \
|
||||
android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java \
|
||||
android/app/src/main/java/com/hyzq/boss/AboutActivity.java \
|
||||
android/app/src/main/java/com/hyzq/boss/BossUi.java
|
||||
git commit -m "feat: add native ota progress feedback"
|
||||
```
|
||||
|
||||
## Task 3: Remember Root Tab and Smooth Root Back Behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/RootTabMemory.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class RootTabMemoryTest {
|
||||
@Test
|
||||
public void resolveInitialTab_prefersExplicitTab() {
|
||||
assertEquals("devices", RootTabMemory.resolveInitialTab("devices", "me"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_fallsBackToStoredTab() {
|
||||
assertEquals("me", RootTabMemory.resolveInitialTab(null, "me"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_defaultsToConversations() {
|
||||
assertEquals("conversations", RootTabMemory.resolveInitialTab(null, null));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run the new unit test target after creating it in the same package:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.RootTabMemoryTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with missing helper.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Implement the helper:
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class RootTabMemory {
|
||||
private RootTabMemory() {}
|
||||
|
||||
public static String resolveInitialTab(String explicitTab, String storedTab) {
|
||||
if ("conversations".equals(explicitTab) || "devices".equals(explicitTab) || "me".equals(explicitTab)) {
|
||||
return explicitTab;
|
||||
}
|
||||
if ("conversations".equals(storedTab) || "devices".equals(storedTab) || "me".equals(storedTab)) {
|
||||
return storedTab;
|
||||
}
|
||||
return "conversations";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then wire it into `MainActivity` so that:
|
||||
|
||||
- last selected root tab is persisted in `SharedPreferences`
|
||||
- explicit deep-link tab still wins over stored tab
|
||||
- when already at root conversations tab, back key shows a soft toast then moves task to back
|
||||
|
||||
- [ ] **Step 4: Run tests and full debug verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/MainActivity.java \
|
||||
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java \
|
||||
android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java
|
||||
git commit -m "feat: polish native root tab memory"
|
||||
```
|
||||
|
||||
## Task 4: Verification, Packaging, Deploy, and Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `android/app/build.gradle`
|
||||
|
||||
- [ ] **Step 1: Update docs and version**
|
||||
|
||||
- bump Android version for the next polish release
|
||||
- document chat send feedback, OTA progress, and root tab memory behavior
|
||||
|
||||
- [ ] **Step 2: Run local verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Deploy and verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
BOSS_SERVER_PASS='your-password' ./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
curl -sS https://boss.hyzq.net/downloads/boss-android-latest.json
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md \
|
||||
docs/architecture/api_and_service_inventory_cn.md android/app/build.gradle \
|
||||
public/downloads/
|
||||
git commit -m "chore: publish native ui phase 2 polish release"
|
||||
```
|
||||
751
docs/superpowers/plans/2026-03-27-wechat-native-ui-rollback.md
Normal file
751
docs/superpowers/plans/2026-03-27-wechat-native-ui-rollback.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# WeChat Native UI Rollback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore the Android app to the previously approved WeChat-like UI and interaction model while keeping the current native Android architecture, Boss API integration, login recovery, and OTA pipeline.
|
||||
|
||||
**Architecture:** Keep `BossApiClient`, current activities, login restore, OTA delivery, and backend routes intact. Add a small pure-Java surface-mapping helper with unit tests so the “allowed” WeChat-style information density is explicit, then rework `MainActivity`, shared UI helpers, and the core activities to render simple list-driven surfaces and a chat-first conversation page. Advanced ops capability stays in the codebase but leaves the first-level UI.
|
||||
|
||||
**Tech Stack:** Android AppCompat, XML layouts, Java 21, Gradle 8, JUnit4, existing Boss APIs, adb, existing `npm run apk:release` / `npm run aab:release` / `scripts/deploy-server.sh`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Pure-Java contract for root tabs, conversation rows, device rows, my-page menus, and project quick actions.
|
||||
- `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
- Unit tests for WeChat-style information trimming and tab/menu contract.
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Chat-first project detail layout with lightweight header strip and bottom composer.
|
||||
- `android/app/src/main/res/drawable/bg_list_row.xml`
|
||||
- Flat white list-cell background with subtle divider feel.
|
||||
- `android/app/src/main/res/drawable/bg_tab_active.xml`
|
||||
- Active bottom-tab background.
|
||||
- `android/app/src/main/res/drawable/bg_tab_inactive.xml`
|
||||
- Inactive bottom-tab background.
|
||||
- `android/app/src/main/res/drawable/bg_message_incoming.xml`
|
||||
- Incoming message bubble background.
|
||||
- `android/app/src/main/res/drawable/bg_message_outgoing.xml`
|
||||
- Outgoing message bubble background.
|
||||
- `scripts/verify-native-wechat-release.sh`
|
||||
- Local verification wrapper for build, health checks, artifacts, and docs.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Shared list-row, avatar, unread badge, bubble, and chip builders.
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java`
|
||||
- Base screen support for alternate layouts and lighter headers.
|
||||
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Root shell, tab state, conversations list, devices list, my-page menu, and return behavior.
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Chat-first surface with only `项目目标 / 版本记录` as lightweight actions.
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Simpler device detail first screen.
|
||||
- `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- WeChat-style simple list and session summary.
|
||||
- `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Lighter settings presentation.
|
||||
- `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Skill rows instead of stacked heavy cards.
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Simpler account list presentation.
|
||||
- `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Cleaner About/OTA surface with advanced entry placement.
|
||||
- `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Root login and tab shell layout.
|
||||
- `android/app/src/main/res/layout/activity_screen.xml`
|
||||
- Standard secondary-page shell layout.
|
||||
- `android/app/src/main/res/values/colors.xml`
|
||||
- WeChat-like flat palette.
|
||||
- `android/app/src/main/res/values/styles.xml`
|
||||
- White window background, no dashboard gradient.
|
||||
- `android/app/build.gradle`
|
||||
- Version bump for the rollback release build.
|
||||
- `README.md`
|
||||
- New native UI direction and build status.
|
||||
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Runtime truth after rollback.
|
||||
- `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Effective Android surface summary.
|
||||
- `docs/architecture/repo_map_cn.md`
|
||||
- New layout / helper file map if structure changes.
|
||||
|
||||
## Task 1: Freeze the WeChat Surface Contract in Unit Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class WechatSurfaceMapperTest {
|
||||
@Test
|
||||
public void toConversationRow_keepsOnlyWechatFields() throws Exception {
|
||||
JSONObject source = new JSONObject()
|
||||
.put("projectTitle", "Boss 移动控制台")
|
||||
.put("preview", "主 Agent 已回复")
|
||||
.put("latestReplyLabel", "昨天")
|
||||
.put("unreadCount", 3)
|
||||
.put("riskLevel", "urgent")
|
||||
.put("activeDeviceCount", 2);
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(source);
|
||||
|
||||
assertEquals("Boss 移动控制台", row.title);
|
||||
assertEquals("主 Agent 已回复", row.preview);
|
||||
assertEquals("昨天", row.timeLabel);
|
||||
assertEquals(3, row.unreadCount);
|
||||
assertFalse(row.preview.contains("设备"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_keepsOnlySimpleSubtitle() throws Exception {
|
||||
JSONObject source = new JSONObject()
|
||||
.put("name", "Mac Studio")
|
||||
.put("status", "online")
|
||||
.put("account", "17600003315")
|
||||
.put("quota5h", 68)
|
||||
.put("quota7d", 81);
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(source);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("在线 · 17600003315", row.subtitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMeMenuTitles_matchApprovedSimpleMenu() {
|
||||
assertEquals(
|
||||
Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于"),
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectQuickActions_keepOnlyGoalsAndVersions() {
|
||||
assertEquals(
|
||||
Arrays.asList("项目目标", "版本记录"),
|
||||
WechatSurfaceMapper.projectQuickActions()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with `cannot find symbol` or `ClassNotFoundException` for `WechatSurfaceMapper`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public final class WechatSurfaceMapper {
|
||||
private WechatSurfaceMapper() {}
|
||||
|
||||
public static final class ConversationRow {
|
||||
public final String title;
|
||||
public final String preview;
|
||||
public final String timeLabel;
|
||||
public final int unreadCount;
|
||||
|
||||
public ConversationRow(String title, String preview, String timeLabel, int unreadCount) {
|
||||
this.title = title;
|
||||
this.preview = preview;
|
||||
this.timeLabel = timeLabel;
|
||||
this.unreadCount = unreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeviceRow {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
|
||||
public DeviceRow(String title, String subtitle) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
public static ConversationRow toConversationRow(JSONObject item) {
|
||||
return new ConversationRow(
|
||||
item.optString("projectTitle", "未命名会话"),
|
||||
item.optString("preview", "暂无消息"),
|
||||
item.optString("latestReplyLabel", ""),
|
||||
item.optInt("unreadCount", 0)
|
||||
);
|
||||
}
|
||||
|
||||
public static DeviceRow toDeviceRow(JSONObject item) {
|
||||
String status = "online".equals(item.optString("status")) ? "在线" : "离线";
|
||||
String account = item.optString("account", "");
|
||||
String subtitle = account.isEmpty() ? status : status + " · " + account;
|
||||
return new DeviceRow(item.optString("name", "未命名设备"), subtitle);
|
||||
}
|
||||
|
||||
public static List<String> rootMeMenuTitles() {
|
||||
return Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于");
|
||||
}
|
||||
|
||||
public static List<String> projectQuickActions() {
|
||||
return Arrays.asList("项目目标", "版本记录");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS, `BUILD SUCCESSFUL`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
|
||||
git commit -m "test: freeze wechat surface contract"
|
||||
```
|
||||
|
||||
### Task 2: Rebuild the Root Shell and Conversation List
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_screen.xml`
|
||||
- Modify: `android/app/src/main/res/values/colors.xml`
|
||||
- Modify: `android/app/src/main/res/values/styles.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_list_row.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_tab_active.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_tab_inactive.xml`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `WechatSurfaceMapperTest.java` with root-shell expectations:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void rootTabOrder_isWechatStyle() {
|
||||
assertEquals(Arrays.asList("会话", "设备", "我的"), WechatSurfaceMapper.rootTabLabels());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mainPage_doesNotExposeOpsEntry() {
|
||||
assertFalse(WechatSurfaceMapper.rootMeMenuTitles().contains("运维与修复"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL because `rootTabLabels()` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add the missing contract method:
|
||||
|
||||
```java
|
||||
public static List<String> rootTabLabels() {
|
||||
return Arrays.asList("会话", "设备", "我的");
|
||||
}
|
||||
```
|
||||
|
||||
Then wire the UI around that contract.
|
||||
|
||||
Update `BossUi.java` to add list-oriented builders:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildListRow(
|
||||
Context context,
|
||||
String leadingText,
|
||||
String title,
|
||||
String subtitle,
|
||||
String trailingText,
|
||||
int unreadCount,
|
||||
@Nullable View.OnClickListener listener
|
||||
) {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setBackgroundResource(R.drawable.bg_list_row);
|
||||
row.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14));
|
||||
if (listener != null) row.setOnClickListener(listener);
|
||||
return row;
|
||||
}
|
||||
```
|
||||
|
||||
Update `MainActivity.java` so the root render paths use `WechatSurfaceMapper` instead of card-heavy metadata:
|
||||
|
||||
```java
|
||||
private void renderConversationsRoot() {
|
||||
screenContent.removeAllViews();
|
||||
if (conversationsData == null || conversationsData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < conversationsData.length(); i++) {
|
||||
JSONObject item = conversationsData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
String projectId = item.optString("projectId", "");
|
||||
screenContent.addView(BossUi.buildListRow(
|
||||
this,
|
||||
item.optString("avatar", item.optString("projectTitle", "会").substring(0, 1)),
|
||||
row.title,
|
||||
row.preview,
|
||||
row.timeLabel,
|
||||
row.unreadCount,
|
||||
v -> openProject(projectId, row.title)
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update `activity_main.xml` / `activity_screen.xml` to remove dashboard framing:
|
||||
|
||||
```xml
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_surface">
|
||||
```
|
||||
|
||||
```xml
|
||||
<Button
|
||||
android:id="@+id/tab_conversations"
|
||||
android:background="@drawable/bg_tab_active"
|
||||
android:text="会话" />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests and compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Unit tests PASS
|
||||
- Java compile PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/res/layout/activity_main.xml android/app/src/main/res/layout/activity_screen.xml android/app/src/main/res/values/colors.xml android/app/src/main/res/values/styles.xml android/app/src/main/res/drawable/bg_list_row.xml android/app/src/main/res/drawable/bg_tab_active.xml android/app/src/main/res/drawable/bg_tab_inactive.xml android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
|
||||
git commit -m "feat: restore wechat-style root shell"
|
||||
```
|
||||
|
||||
### Task 3: Rebuild Project Detail into a Chat-First Surface
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Create: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_message_incoming.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_message_outgoing.xml`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `WechatSurfaceMapperTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void projectPrimarySections_keepOnlyChatEssentials() {
|
||||
assertEquals(
|
||||
Arrays.asList("quick_actions", "messages", "composer"),
|
||||
WechatSurfaceMapper.projectPrimarySections()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL because the helper still allows the old action shape or the assertions are not yet satisfied.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Teach `BossScreenActivity` to allow an alternate layout:
|
||||
|
||||
```java
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_screen;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(getLayoutResId());
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Then make `ProjectDetailActivity` use a chat layout and strip the heavy cards:
|
||||
|
||||
```java
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_project_chat;
|
||||
}
|
||||
|
||||
private void renderProject(JSONObject payload) {
|
||||
JSONObject project = payload.optJSONObject("project");
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
|
||||
configureScreen(project == null ? "项目聊天" : project.optString("name", "项目聊天"), "");
|
||||
replaceContent();
|
||||
appendQuickActions("项目目标", v -> openGoals(), "版本记录", v -> openVersions());
|
||||
renderMessages(messages);
|
||||
}
|
||||
```
|
||||
|
||||
and add the missing helper contract:
|
||||
|
||||
```java
|
||||
public static List<String> projectPrimarySections() {
|
||||
return Arrays.asList("quick_actions", "messages", "composer");
|
||||
}
|
||||
```
|
||||
|
||||
Render message bubbles through `BossUi` instead of generic cards:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildMessageBubble(
|
||||
Context context,
|
||||
boolean self,
|
||||
String sender,
|
||||
String body,
|
||||
String meta
|
||||
) {
|
||||
LinearLayout bubble = new LinearLayout(context);
|
||||
bubble.setBackgroundResource(self ? R.drawable.bg_message_outgoing : R.drawable.bg_message_incoming);
|
||||
return bubble;
|
||||
}
|
||||
```
|
||||
|
||||
Do **not** render these old sections in the main chat surface:
|
||||
|
||||
- `当前主控身份`
|
||||
- `主 Agent 调度结论`
|
||||
- `线程预算卡片`
|
||||
- `实时 APP 日志`
|
||||
- `媒体与转发说明`
|
||||
|
||||
- [ ] **Step 4: Run tests and screen compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Unit tests PASS
|
||||
- Java compile PASS
|
||||
- Debug assemble PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/res/drawable/bg_message_incoming.xml android/app/src/main/res/drawable/bg_message_outgoing.xml
|
||||
git commit -m "feat: restore wechat-style project chat page"
|
||||
```
|
||||
|
||||
### Task 4: Simplify Devices and Me Surfaces, Demote Advanced Ops
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `WechatSurfaceMapperTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void rootMeMenuTitles_keepApprovedOrder() {
|
||||
assertEquals(
|
||||
Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于"),
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void advancedEntryTitle_movesOpsOutOfMainMePage() {
|
||||
assertEquals("高级与调试", WechatSurfaceMapper.advancedEntryTitle());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL until the mapper and page rendering fully stop leaking quota / ops content.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Use `WechatSurfaceMapper` rows in `MainActivity.renderDevicesRoot()`:
|
||||
|
||||
```java
|
||||
private void renderDevicesRoot() {
|
||||
screenContent.removeAllViews();
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "添加设备", "通过绑定码接入新设备", null, v -> {
|
||||
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
|
||||
}));
|
||||
|
||||
for (int i = 0; i < devicesData.length(); i++) {
|
||||
JSONObject item = devicesData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
screenContent.addView(BossUi.buildListRow(
|
||||
this,
|
||||
item.optString("avatar", "设"),
|
||||
row.title,
|
||||
row.subtitle,
|
||||
"",
|
||||
0,
|
||||
v -> openDevice(item.optString("id"), row.title)
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Simplify `renderMeRoot()` to only approved rows:
|
||||
|
||||
```java
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "账号与安全", "登录与会话", null, v -> startActivity(new Intent(this, SecurityActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "AI 账号", "主 GPT / 备用 GPT / API 容灾", null, v -> startActivity(new Intent(this, AiAccountsActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "设置", "默认首页与提醒行为", null, v -> startActivity(new Intent(this, SettingsActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "技能", "当前设备 Skill 清单", null, v -> startActivity(new Intent(this, SkillInventoryActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "关于", "版本与更新", null, v -> startActivity(new Intent(this, AboutActivity.class))));
|
||||
```
|
||||
|
||||
Move advanced entry deeper by placing it under `AboutActivity` as a non-primary menu row:
|
||||
|
||||
```java
|
||||
Button advanced = BossUi.buildSecondaryButton(this, "高级与调试");
|
||||
advanced.setOnClickListener(v -> startActivity(new Intent(this, OpsCenterActivity.class)));
|
||||
```
|
||||
|
||||
and add the helper contract:
|
||||
|
||||
```java
|
||||
public static String advancedEntryTitle() {
|
||||
return "高级与调试";
|
||||
}
|
||||
```
|
||||
|
||||
`DeviceDetailActivity` should keep only:
|
||||
|
||||
- simple device summary
|
||||
- `查看技能`
|
||||
- `编辑`
|
||||
|
||||
and stop rendering related thread cards / enrollment draft cards on the first screen.
|
||||
|
||||
- [ ] **Step 4: Run tests and full debug verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
adb -s 8KE0219724012168 shell am start -W -n com.hyzq.boss/.MainActivity
|
||||
adb -s 8KE0219724012168 shell pidof com.hyzq.boss
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Unit tests PASS
|
||||
- Debug assemble PASS
|
||||
- `MainActivity` starts
|
||||
- `pidof` returns a process id
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java android/app/src/main/java/com/hyzq/boss/SecurityActivity.java android/app/src/main/java/com/hyzq/boss/SettingsActivity.java android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java android/app/src/main/java/com/hyzq/boss/AboutActivity.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
|
||||
git commit -m "feat: simplify device and me surfaces"
|
||||
```
|
||||
|
||||
### Task 5: Verification, Release Packaging, Deployment, and Docs
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/verify-native-wechat-release.sh`
|
||||
- Modify: `android/app/build.gradle`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `docs/architecture/repo_map_cn.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing release verification script**
|
||||
|
||||
Create `scripts/verify-native-wechat-release.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -fsS http://127.0.0.1:3000/api/health >/dev/null
|
||||
curl -fsS http://127.0.0.1:4317/health >/dev/null
|
||||
test -f android/app/build/outputs/apk/release/boss-android-v2.1.2-release.apk
|
||||
test -f android/app/build/outputs/bundle/release/boss-android-v2.1.2-release.aab
|
||||
rg -q '微信式' README.md
|
||||
rg -q '2.1.2' docs/architecture/current_runtime_and_deploy_status_cn.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
zsh ./scripts/verify-native-wechat-release.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because the `2.1.2` artifacts and updated docs do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal release implementation**
|
||||
|
||||
Bump the Android version:
|
||||
|
||||
```gradle
|
||||
versionCode 9
|
||||
versionName "2.1.2"
|
||||
```
|
||||
|
||||
Update docs to state:
|
||||
|
||||
- native UI has returned to WeChat-style interaction
|
||||
- root tabs are `会话 / 设备 / 我的`
|
||||
- chat page keeps only `项目目标 / 版本记录`
|
||||
|
||||
Then build and publish:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run release verification and deploy**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
zsh ./scripts/verify-native-wechat-release.sh
|
||||
adb -s 8KE0219724012168 install -r /Users/kris/code/boss/android/app/build/outputs/apk/release/boss-android-v2.1.2-release.apk
|
||||
adb -s 8KE0219724012168 shell am start -W -n com.hyzq.boss/.MainActivity
|
||||
./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
curl -sS https://boss.hyzq.net/downloads/boss-android-latest.json
|
||||
curl -sS https://boss.hyzq.net/downloads/boss-android-latest-aab.json
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Verification script PASS
|
||||
- APK install PASS
|
||||
- `MainActivity` launch PASS
|
||||
- remote `/api/health` PASS
|
||||
- public health PASS
|
||||
- public APK/AAB metadata show `2.1.2 / versionCode 9`
|
||||
|
||||
- [ ] **Step 5: Commit and publish**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add scripts/verify-native-wechat-release.sh android/app/build.gradle README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/ai_handoff_index_cn.md docs/architecture/repo_map_cn.md public/downloads
|
||||
git commit -m "feat: ship wechat-style native rollback release"
|
||||
git push gitea HEAD:refs/heads/codex/native-boss-android-2-1-0
|
||||
```
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- Spec coverage:
|
||||
- 一级导航微信式化:Task 2
|
||||
- 会话首页极简列表:Task 2
|
||||
- 聊天页只保留项目目标/版本记录:Task 3
|
||||
- 设备页 / 我的页简单列表:Task 4
|
||||
- 返回逻辑与状态保持:Task 2 + Task 4
|
||||
- 构建 / 真机 / 部署 / 文档:Task 5
|
||||
- Placeholder scan:
|
||||
- No `TBD` / `TODO` / “similar to”
|
||||
- Every task has exact files, commands, and code snippets
|
||||
- Type consistency:
|
||||
- `WechatSurfaceMapper` is the single contract source for root tabs, conversation rows, device rows, and quick actions
|
||||
689
docs/superpowers/plans/2026-03-28-wechat-message-forwarding.md
Normal file
689
docs/superpowers/plans/2026-03-28-wechat-message-forwarding.md
Normal file
@@ -0,0 +1,689 @@
|
||||
# Boss 微信式消息转发 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把当前原生 Android 的“备注转发页”重构成微信式消息转发链,支持单条消息转发、多选消息合并转发、统一目标会话选择页,以及服务端 `forwardSource / forwardBundle / approvalRequired` 账本结构。
|
||||
|
||||
**Architecture:** 保留现有 `BossState -> Next API -> BossApiClient -> 原生活动页` 主链,不引入新基础设施。服务端把 `POST /api/v1/projects/[projectId]/forwards` 从“备注转发”升级成结构化转发接口;原生端在 `ProjectDetailActivity` 内补消息操作菜单、多选状态和目标会话选择页,并以 `ForwardTargetActivity` 承接统一转发目标选择。
|
||||
|
||||
**Tech Stack:** Next.js App Router, TypeScript, file-backed `data/boss-state.json`, 原生 Android AppCompat + XML, HttpURLConnection, JUnit4
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend / state / API
|
||||
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- 扩展 `MessageKind`、`Message`,增加 `forwardSource`、`forwardBundle`
|
||||
- 把 `forwardProjectMessage` 升级成支持 `single / bundle / approvalRequired`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts`
|
||||
- 校验新的 `single / bundle` 输入结构
|
||||
- 返回 `message / approvalRequired / approvalReason`
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- 如列表预览或详情聚合需要,补 forwarded message 的预览摘要函数
|
||||
|
||||
### Android native
|
||||
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 支持新的 forward payload
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- 补多选模式、已选消息、转发入口守卫
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
- 单测先行覆盖多选状态切换
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- 消息长按菜单、多选模式、跳转目标会话页、forward message 渲染
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- 补消息操作菜单、多选勾选 row、聊天记录卡片消息
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`
|
||||
- 统一目标会话选择页
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java`
|
||||
- 目标会话过滤与单选逻辑单测
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java`
|
||||
- 降级为兼容跳转页,直接导向 `ForwardTargetActivity`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- 注册 `ForwardTargetActivity`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- 补多选模式头部 / 底部动作容器
|
||||
- Create: `android/app/src/main/res/layout/activity_forward_target.xml`
|
||||
- 目标会话选择页布局
|
||||
|
||||
### Docs / release
|
||||
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 升级服务端转发账本和接口结构
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts`
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- Test: `npm run build`
|
||||
|
||||
- [ ] **Step 1: 先把新的消息结构写进 failing contract 注释和类型定义**
|
||||
|
||||
在 `src/lib/boss-data.ts` 的 `MessageKind` 与 `Message` 附近先写出新结构,让后续编译先报缺字段:
|
||||
|
||||
```ts
|
||||
export type MessageKind =
|
||||
| "text"
|
||||
| "voice_intent"
|
||||
| "image_intent"
|
||||
| "video_intent"
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle";
|
||||
|
||||
export interface ForwardSource {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
sourceMessageId: string;
|
||||
forwardedBy: string;
|
||||
forwardedAt: string;
|
||||
}
|
||||
|
||||
export interface ForwardBundleItem {
|
||||
messageId: string;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
kind: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
export interface ForwardBundlePayload {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
itemCount: number;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
items: ForwardBundleItem[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
sender: MessageSender;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
sentAt: string;
|
||||
kind?: MessageKind;
|
||||
forwardSource?: ForwardSource;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行构建,确认当前实现还不支持这些结构**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: 先因为 `forwardProjectMessage` 和相关消息使用点不完整而失败,或者至少需要补 route / render 类型。
|
||||
|
||||
- [ ] **Step 3: 用最小实现升级 `forwardProjectMessage` 输入结构**
|
||||
|
||||
把 `src/lib/boss-data.ts` 的旧签名:
|
||||
|
||||
```ts
|
||||
export async function forwardProjectMessage(payload: {
|
||||
sourceProjectId: string;
|
||||
targetProjectId: string;
|
||||
note: string;
|
||||
})
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```ts
|
||||
export async function forwardProjectMessage(payload:
|
||||
| {
|
||||
sourceProjectId: string;
|
||||
mode: "single";
|
||||
targetProjectId: string;
|
||||
sourceMessageId: string;
|
||||
requestedBy: string;
|
||||
}
|
||||
| {
|
||||
sourceProjectId: string;
|
||||
mode: "bundle";
|
||||
targetProjectId: string;
|
||||
sourceMessageIds: string[];
|
||||
requestedBy: string;
|
||||
}
|
||||
) {}
|
||||
```
|
||||
|
||||
并补 3 个最小 helper:
|
||||
|
||||
```ts
|
||||
function findProjectMessage(project: Project, messageId: string) {}
|
||||
function buildForwardSingleMessage(input: { source: Project; target: Project; message: Message; requestedBy: string }) {}
|
||||
function buildForwardBundleMessage(input: { source: Project; target: Project; messages: Message[]; requestedBy: string }) {}
|
||||
```
|
||||
|
||||
最小行为要求:
|
||||
|
||||
- `single` 生成 `kind: "forward_single"`,并带 `forwardSource`
|
||||
- `bundle` 生成 `kind: "forward_bundle"`,并带 `forwardBundle`
|
||||
- `target.preview` 更新为新消息正文或卡片摘要
|
||||
- `source` 侧继续写一条“已转发到《目标会话》”的轻量日志
|
||||
|
||||
- [ ] **Step 4: 在 `forwardProjectMessage` 里补审批闸口最小返回**
|
||||
|
||||
在 `src/lib/boss-data.ts` 内先加最小判定:
|
||||
|
||||
```ts
|
||||
function requiresForwardApproval(source: Project, target: Project) {
|
||||
return source.collaborationMode === "approval_required" && target.id !== "master-agent";
|
||||
}
|
||||
```
|
||||
|
||||
并让 `forwardProjectMessage` 在命中审批时返回:
|
||||
|
||||
```ts
|
||||
return {
|
||||
approvalRequired: true,
|
||||
approvalReason: "NON_DEVELOPMENT_THREAD_FORWARD",
|
||||
};
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 审批场景下不写入目标消息账本
|
||||
- 正常场景才写入目标消息账本并返回 `message`
|
||||
|
||||
- [ ] **Step 5: 升级 route 输入校验**
|
||||
|
||||
在 `src/app/api/v1/projects/[projectId]/forwards/route.ts` 里把旧输入:
|
||||
|
||||
```ts
|
||||
{
|
||||
targetProjectId?: string;
|
||||
note?: string;
|
||||
}
|
||||
```
|
||||
|
||||
替换成:
|
||||
|
||||
```ts
|
||||
type ForwardBody =
|
||||
| {
|
||||
mode?: "single";
|
||||
targetProjectId?: string;
|
||||
sourceMessageId?: string;
|
||||
}
|
||||
| {
|
||||
mode?: "bundle";
|
||||
targetProjectId?: string;
|
||||
sourceMessageIds?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
route 最小逻辑:
|
||||
|
||||
- `mode=single` 时要求 `sourceMessageId`
|
||||
- `mode=bundle` 时要求 `sourceMessageIds.length > 1`
|
||||
- 调用 `forwardProjectMessage({ ..., requestedBy: session.account })`
|
||||
- 返回:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: result.message ?? null,
|
||||
approvalRequired: Boolean(result.approvalRequired),
|
||||
approvalReason: result.approvalReason ?? null,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 重新构建,确认类型闭合**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `Compiled successfully`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts src/app/api/v1/projects/[projectId]/forwards/route.ts src/lib/boss-projections.ts
|
||||
git commit -m "feat: add structured message forwarding payloads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 先用单测拉出原生多选转发状态机
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing test,覆盖多选模式切换**
|
||||
|
||||
在 `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 先补这些测试:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void entersMultiSelectModeAfterFirstToggle() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertTrue(state.multiSelecting);
|
||||
assertEquals(1, state.selectedMessageIds.size());
|
||||
assertTrue(state.selectedMessageIds.contains("m1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deselectingLastMessageExitsMultiSelectMode() {
|
||||
ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1"));
|
||||
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m1");
|
||||
assertFalse(next.multiSelecting);
|
||||
assertTrue(next.selectedMessageIds.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardRequiresAtLeastTwoMessages() {
|
||||
ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1"));
|
||||
assertFalse(ProjectChatUiState.canForwardSelection(state));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑单测确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `SelectionState` / `toggleSelection` / `canForwardSelection` 尚未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `ProjectChatUiState.java` 写最小实现**
|
||||
|
||||
补最小状态对象与 helper:
|
||||
|
||||
```java
|
||||
public static final class SelectionState {
|
||||
public final boolean multiSelecting;
|
||||
public final java.util.Set<String> selectedMessageIds;
|
||||
|
||||
public SelectionState(boolean multiSelecting, java.util.Set<String> selectedMessageIds) {
|
||||
this.multiSelecting = multiSelecting;
|
||||
this.selectedMessageIds = selectedMessageIds;
|
||||
}
|
||||
}
|
||||
|
||||
public static SelectionState emptySelection() {
|
||||
return new SelectionState(false, new java.util.LinkedHashSet<>());
|
||||
}
|
||||
|
||||
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {}
|
||||
|
||||
public static boolean canForwardSelection(@Nullable SelectionState state) {
|
||||
return state != null && state.selectedMessageIds.size() >= 2;
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 第一次 toggle 进入多选
|
||||
- 取消最后一条选中后退出多选
|
||||
- 保持插入顺序,后面 bundle 卡片会用到
|
||||
|
||||
- [ ] **Step 4: 跑单测确认转绿**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
|
||||
git commit -m "feat: add native chat forward selection state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 先做会话选择页与 API payload builder,再接聊天页入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_forward_target.xml`
|
||||
|
||||
- [ ] **Step 1: 先写 failing test,覆盖目标会话过滤和单选规则**
|
||||
|
||||
在 `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java` 先写:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void filtersOutSourceConversationFromTargets() {
|
||||
JSONArray conversations = new JSONArray()
|
||||
.put(new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话"))
|
||||
.put(new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话"));
|
||||
|
||||
java.util.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());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑单测确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `ForwardTargetActivity` helper 未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `BossApiClient.java` 补结构化转发方法**
|
||||
|
||||
把旧方法:
|
||||
|
||||
```java
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note)
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```java
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload)
|
||||
```
|
||||
|
||||
方法内最小逻辑:
|
||||
|
||||
```java
|
||||
JSONObject requestPayload = payload == null ? new JSONObject() : payload;
|
||||
requestPayload.put("targetProjectId", targetProjectId);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestPayload);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 写 `ForwardTargetActivity` 最小实现**
|
||||
|
||||
活动页至少需要:
|
||||
|
||||
```java
|
||||
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";
|
||||
|
||||
static java.util.List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {}
|
||||
static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, java.util.List<String> sourceMessageIds) throws JSONException {}
|
||||
```
|
||||
|
||||
页面行为最小版:
|
||||
|
||||
- 拉取 `apiClient.getConversations()`
|
||||
- 过滤源会话
|
||||
- 列出微信式会话 cell
|
||||
- 点中某个目标会话后调用新的 `forwardProjectMessage`
|
||||
- `approvalRequired=true` 时先提示“已提交主 Agent 审批”
|
||||
- 正常成功时 `setResult(RESULT_OK)` 后 finish
|
||||
|
||||
- [ ] **Step 5: 让旧 `ProjectForwardActivity` 只做兼容跳转**
|
||||
|
||||
在 `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java` 中删除旧备注输入主链,保留:
|
||||
|
||||
```java
|
||||
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();
|
||||
```
|
||||
|
||||
并把标题副文案改成“正在切换到微信式转发”。
|
||||
|
||||
- [ ] **Step 6: 跑单测确认转绿**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java android/app/src/main/AndroidManifest.xml android/app/src/main/res/layout/activity_forward_target.xml android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java
|
||||
git commit -m "feat: add native forward target picker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 把消息长按、多选和转发结果真正接进聊天页
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing test,覆盖 forward kind 的 UI 标签**
|
||||
|
||||
先在 `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 追加:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void singleForwardMessageUsesSingleModeLabel() {
|
||||
assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardMessageUsesBundleModeLabel() {
|
||||
assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑单测确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `labelForForwardKind` 未定义。
|
||||
|
||||
- [ ] **Step 3: 在 `BossUi.java` 增加转发消息和聊天记录卡片**
|
||||
|
||||
新增两个 builder:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildForwardSingleBubble(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String body,
|
||||
@Nullable String meta,
|
||||
@Nullable String sourceLabel,
|
||||
boolean outgoing
|
||||
) {}
|
||||
|
||||
public static LinearLayout buildForwardBundleCard(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String cardTitle,
|
||||
String summary,
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
) {}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- `forward_single` 仍看起来像普通消息 bubble
|
||||
- `forward_bundle` 明显是聊天记录卡片,但不能长成控制台卡片
|
||||
|
||||
- [ ] **Step 4: 在 `ProjectDetailActivity.java` 接入长按与多选**
|
||||
|
||||
最小实现顺序:
|
||||
|
||||
1. 为每条消息 view 绑定 `messageId`
|
||||
2. 长按消息时弹出原生 `AlertDialog` 操作菜单:`转发 / 多选 / 复制 / 删除 / 取消`
|
||||
3. `转发` 时直接打开 `ForwardTargetActivity`
|
||||
4. `多选` 时切换 `SelectionState`
|
||||
5. 多选模式下顶部切为 `取消 + 已选数量`
|
||||
6. 底部输入区切换为单按钮 `转发`
|
||||
|
||||
关键入口:
|
||||
|
||||
```java
|
||||
private void openSingleForwardTarget(String sourceMessageId) {}
|
||||
private void openBundleForwardTarget(java.util.List<String> sourceMessageIds) {}
|
||||
private void enterMultiSelectFromMessage(String messageId) {}
|
||||
private void exitMultiSelect() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在消息渲染分支中接入新 kind**
|
||||
|
||||
把现有 `labelForMessageKind(...)` 和消息渲染分支补成:
|
||||
|
||||
```java
|
||||
case "forward_single":
|
||||
return BossUi.buildForwardSingleBubble(...);
|
||||
case "forward_bundle":
|
||||
return BossUi.buildForwardBundleCard(...);
|
||||
```
|
||||
|
||||
并让 `ProjectChatUiState.labelForForwardKind(...)` 提供:
|
||||
|
||||
```java
|
||||
"forward_single" -> "转发"
|
||||
"forward_bundle" -> "聊天记录"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 跑 Android 编译和单测**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [ ] **Step 7: 跑 Web 构建与接口烟测**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
npm start
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"mode":"single","targetProjectId":"master-agent","sourceMessageId":"m-test"}' http://127.0.0.1:3000/api/v1/projects/boss-console-ui/forwards
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `lint` 通过
|
||||
- `build` 通过
|
||||
- `/api/health` 返回 `{ ok: true }`
|
||||
- `/forwards` 返回结构化 JSON,包含 `message` 或 `approvalRequired`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
|
||||
git commit -m "feat: add wechat style native message forwarding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、发布和完整验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
- [ ] **Step 1: 同步文档**
|
||||
|
||||
把以下事实写回文档:
|
||||
|
||||
- 原生 Android 已支持单条消息转发
|
||||
- 原生 Android 已支持多选合并转发
|
||||
- 新增 `ForwardTargetActivity`
|
||||
- `POST /api/v1/projects/[projectId]/forwards` 已支持 `single / bundle`
|
||||
- 单条消息落 `forwardSource`
|
||||
- 多条消息落 `forwardBundle`
|
||||
- 审批闸口已预留
|
||||
|
||||
- [ ] **Step 2: 完整验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
cd android && ./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 全部成功
|
||||
- 公网元数据刷新到新版本
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md
|
||||
git commit -m "docs: update forwarding architecture and runtime status"
|
||||
```
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
# Boss 旧版 UI 还原与线程群聊 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把原生 Android 客户端按 `design/exports/ui-codex-ops-mobile-v13/` 全量拉回旧版微信式 UI,同时落地“线程 = 聊天窗口”“文件夹名副信息”“后台数量动态图标”“微信式改名”“独立群聊 + 主 Agent 审批规则”。
|
||||
|
||||
**Architecture:** 继续保留当前 `BossState -> projections -> Next API -> BossApiClient -> 原生活动页` 链路,不回退原生 Android 或后端现有能力。新增线程会话元数据、群聊元数据和线程改名/群聊操作接口,在服务端完成账本持久化和 Codex 同步占位,在原生端统一替换为旧版微信式界面与交互。
|
||||
|
||||
**Tech Stack:** Next.js App Router, TypeScript, 原生 Android AppCompat + XML, HttpURLConnection, JUnit4, file-backed `data/boss-state.json`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend / state / API
|
||||
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- 扩展 `Project` / `Message` 周边数据模型,支持线程显示名、文件夹名、后台数量、群聊成员、开发任务状态、主 Agent 批准状态
|
||||
- 增加线程改名、群聊创建、群聊改名、群成员读取等写接口
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- 调整会话聚合字段,输出旧版 UI 所需的 `threadTitle / folderLabel / activityIconCount / pinnedLabel / groupMembers`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/rename/route.ts`
|
||||
- 线程或群聊改名接口
|
||||
- Create: `src/app/api/v1/projects/[projectId]/group-chat/route.ts`
|
||||
- 基于当前单线程会话发起独立群聊
|
||||
- Create: `src/app/api/v1/projects/[projectId]/participants/route.ts`
|
||||
- 返回群成员线程信息或单线程归属信息
|
||||
- Modify: `src/app/api/v1/conversations/route.ts`
|
||||
- 输出新的会话列表结构
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- 保留发消息,同时为群聊消息和主 Agent 监督规则预留分支
|
||||
|
||||
### Android native UI
|
||||
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- 统一旧版 UI 的字段映射
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 新增 rename / create group / get participants / get thread info 接口
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- 会话列表 1:1 还原、置顶规则、线程/文件夹/动态图标字段渲染
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- 单线程聊天页还原、右上角入口、发起群聊入口
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`
|
||||
- 微信式会话信息页,支持线程改名和发起群聊
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`
|
||||
- 群资料页,支持群名修改和成员查看
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`
|
||||
- 群聊创建页,选择线程并创建独立群聊
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- 简化成旧版卡片/列表风格
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- 所有深层页统一成旧版风格,不再保留控制台块
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- 提供旧版列表 cell、会话 cell、群头像组合、动态图标容器、轻表单 cell
|
||||
|
||||
### Android layouts / drawables / tests
|
||||
|
||||
- Modify: `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_screen.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_conversation_info.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_info.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_create.xml`
|
||||
- Create/Modify: `android/app/src/main/res/drawable/bg_*`
|
||||
- 旧版 cell / 群头像 / 轻按钮 / 图标动画容器
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- 注册新活动页
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java`
|
||||
|
||||
### Docs
|
||||
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展状态模型,建立线程会话与群聊元数据
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Test: `src/lib/boss-data.ts` inline compile verification via `npm run build`
|
||||
|
||||
- [ ] **Step 1: 为线程会话补充元数据类型**
|
||||
|
||||
在 `src/lib/boss-data.ts` 的类型区新增最小模型,至少覆盖线程标题、文件夹名、动态图标数量和群聊成员:
|
||||
|
||||
```ts
|
||||
export interface ThreadConversationMeta {
|
||||
projectId: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
folderName: string;
|
||||
activityIconCount: number;
|
||||
codexThreadRef?: string;
|
||||
codexFolderRef?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GroupConversationMember {
|
||||
threadId: string;
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
folderName: string;
|
||||
threadDisplayName: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 为 Project 增加群聊与展示字段**
|
||||
|
||||
把 `Project` 扩成下面这组字段,保证单线程会话和群聊会话都能落同一模型:
|
||||
|
||||
```ts
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
pinned: boolean;
|
||||
systemPinned?: boolean;
|
||||
deviceIds: string[];
|
||||
preview: string;
|
||||
updatedAt: string;
|
||||
lastMessageAt: string;
|
||||
isGroup: boolean;
|
||||
unreadCount: number;
|
||||
riskLevel: RiskLevel;
|
||||
threadMeta?: ThreadConversationMeta;
|
||||
groupMembers?: GroupConversationMember[];
|
||||
createdByAgent?: boolean;
|
||||
collaborationMode?: "development" | "approval_required";
|
||||
approvalState?: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
|
||||
contextBudgetPct?: number;
|
||||
contextBudgetLabel?: string;
|
||||
messages: Message[];
|
||||
goals: GoalItem[];
|
||||
versions: VersionEntry[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 给默认种子数据补上 threadMeta / groupMembers**
|
||||
|
||||
在种子项目里至少补:
|
||||
|
||||
```ts
|
||||
threadMeta: {
|
||||
projectId: "boss-console-ui",
|
||||
threadId: "thread-boss-ui",
|
||||
threadDisplayName: "北区试产线回归",
|
||||
folderName: "归档确认",
|
||||
activityIconCount: 1,
|
||||
codexThreadRef: "thread-boss-ui",
|
||||
codexFolderRef: "boss-console",
|
||||
updatedAt: now,
|
||||
}
|
||||
```
|
||||
|
||||
群聊项目使用:
|
||||
|
||||
```ts
|
||||
isGroup: true,
|
||||
groupMembers: [
|
||||
{
|
||||
threadId: "thread-boss-ui",
|
||||
projectId: "boss-console-ui",
|
||||
deviceId: "mac-studio",
|
||||
folderName: "归档确认",
|
||||
threadDisplayName: "北区试产线回归",
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 新增最小状态写方法**
|
||||
|
||||
在 `src/lib/boss-data.ts` 里新增这些方法签名:
|
||||
|
||||
```ts
|
||||
export async function renameProjectThread(input: {
|
||||
projectId: string;
|
||||
threadDisplayName: string;
|
||||
requestedBy: string;
|
||||
}) {}
|
||||
|
||||
export async function createProjectGroupChat(input: {
|
||||
sourceProjectId: string;
|
||||
memberProjectIds: string[];
|
||||
createdBy: string;
|
||||
}) {}
|
||||
|
||||
export async function renameGroupChat(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
requestedBy: string;
|
||||
}) {}
|
||||
```
|
||||
|
||||
实现要求:
|
||||
- 单线程改名只改 `threadMeta.threadDisplayName`,并同步 `project.name`
|
||||
- 群聊创建生成新 `Project`
|
||||
- 群聊默认 `createdByAgent=true`
|
||||
- 群聊默认 `collaborationMode="development"`
|
||||
|
||||
- [ ] **Step 5: 运行构建确认类型闭合**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `Compiled successfully`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts
|
||||
git commit -m "feat: add thread and group chat state metadata"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 输出旧版 UI 所需的会话聚合字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- Modify: `src/app/api/v1/conversations/route.ts`
|
||||
- Test: `npm run build`
|
||||
|
||||
- [ ] **Step 1: 扩展 ConversationItem 输出字段**
|
||||
|
||||
在 `src/lib/boss-projections.ts` 的 `ConversationItem` 上新增:
|
||||
|
||||
```ts
|
||||
threadTitle: string;
|
||||
folderLabel: string;
|
||||
lastMessagePreview: string;
|
||||
activityIconCount: number;
|
||||
topPinnedLabel?: "置顶";
|
||||
groupMembers?: Array<{
|
||||
threadId: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
}>;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写会话行映射逻辑**
|
||||
|
||||
把 `getConversationItems` 中的主字段计算改成:
|
||||
|
||||
```ts
|
||||
threadTitle: project.threadMeta?.threadDisplayName ?? project.name,
|
||||
folderLabel: project.threadMeta?.folderName ?? "",
|
||||
lastMessagePreview: project.preview,
|
||||
activityIconCount: project.threadMeta?.activityIconCount ?? 0,
|
||||
topPinnedLabel: project.id === "master-agent" || project.id === "audit-dialog" ? "置顶" : undefined,
|
||||
```
|
||||
|
||||
群聊时输出成员头像摘要:
|
||||
|
||||
```ts
|
||||
groupMembers: (project.groupMembers ?? []).slice(0, 4).map((member) => ({
|
||||
threadId: member.threadId,
|
||||
avatar: member.threadDisplayName.slice(0, 1),
|
||||
title: member.threadDisplayName,
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 收紧旧字段暴露**
|
||||
|
||||
保留兼容字段,但会话首页渲染不再依赖:
|
||||
- `riskLevel`
|
||||
- `deviceNamesPreview`
|
||||
- `contextBudgetIndicator`
|
||||
|
||||
不要删除它们,只是在新会话 UI 中不再主用。
|
||||
|
||||
- [ ] **Step 4: 确认 conversations API 返回新字段**
|
||||
|
||||
`src/app/api/v1/conversations/route.ts` 保持结构:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
conversations: getConversationItems(state),
|
||||
});
|
||||
```
|
||||
|
||||
但是 build 后要能从类型上确认 `conversations[*]` 带有 `threadTitle / folderLabel / activityIconCount`。
|
||||
|
||||
- [ ] **Step 5: 运行构建**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-projections.ts src/app/api/v1/conversations/route.ts
|
||||
git commit -m "feat: expose thread-oriented conversation projections"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 落地线程改名、群聊创建、群资料接口
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/v1/projects/[projectId]/rename/route.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/group-chat/route.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/participants/route.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Test: `npm run build`
|
||||
|
||||
- [ ] **Step 1: 添加线程/群聊改名接口**
|
||||
|
||||
创建 `rename/route.ts`:
|
||||
|
||||
```ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { renameGroupChat, renameProjectThread } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest, context: { params: Promise<{ projectId: string }> }) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = await request.json();
|
||||
const mode = body.mode === "group" ? "group" : "thread";
|
||||
const name = String(body.name ?? "").trim();
|
||||
if (!name) {
|
||||
return NextResponse.json({ ok: false, message: "EMPTY_NAME" }, { status: 400 });
|
||||
}
|
||||
const result = mode === "group"
|
||||
? await renameGroupChat({ projectId, name, requestedBy: session.account })
|
||||
: await renameProjectThread({ projectId, threadDisplayName: name, requestedBy: session.account });
|
||||
return NextResponse.json({ ok: true, project: result });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加群聊创建接口**
|
||||
|
||||
创建 `group-chat/route.ts`:
|
||||
|
||||
```ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { createProjectGroupChat } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest, context: { params: Promise<{ projectId: string }> }) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
const { projectId } = await context.params;
|
||||
const body = await request.json();
|
||||
const memberProjectIds = Array.isArray(body.memberProjectIds) ? body.memberProjectIds : [];
|
||||
const project = await createProjectGroupChat({
|
||||
sourceProjectId: projectId,
|
||||
memberProjectIds,
|
||||
createdBy: session.account,
|
||||
});
|
||||
return NextResponse.json({ ok: true, project });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加参与者读取接口**
|
||||
|
||||
创建 `participants/route.ts`:
|
||||
|
||||
```ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(_request: Request, context: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
projectId,
|
||||
isGroup: project.isGroup,
|
||||
threadMeta: project.threadMeta ?? null,
|
||||
participants: project.groupMembers ?? [],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 messages 接口预留监督规则分支**
|
||||
|
||||
`src/app/api/v1/projects/[projectId]/messages/route.ts` 中,在 `appendProjectMessage` 之后、`master-agent` 分支之前加一层占位:
|
||||
|
||||
```ts
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
const requiresApproval = project?.isGroup && project.collaborationMode === "approval_required";
|
||||
if (requiresApproval && session.account !== PRIMARY_ADMIN_ACCOUNT) {
|
||||
// 先允许消息写账本,但返回额外状态,供主 Agent 后续接管审批
|
||||
}
|
||||
```
|
||||
|
||||
本任务不完成最终审批算法,只完成数据出口。
|
||||
|
||||
- [ ] **Step 5: 扩展 BossApiClient**
|
||||
|
||||
在 `BossApiClient.java` 里增加:
|
||||
|
||||
```java
|
||||
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException
|
||||
public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException
|
||||
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 运行构建**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/api/v1/projects/[projectId]/rename/route.ts src/app/api/v1/projects/[projectId]/group-chat/route.ts src/app/api/v1/projects/[projectId]/participants/route.ts src/app/api/v1/projects/[projectId]/messages/route.ts android/app/src/main/java/com/hyzq/boss/BossApiClient.java
|
||||
git commit -m "feat: add thread rename and group chat apis"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 重建会话首页为旧版 1:1 聊天列表
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写会话行映射测试**
|
||||
|
||||
创建 `ConversationRowMapperTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void maps_thread_title_folder_label_and_activity_icon_count() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("threadTitle", "北区试产线回归")
|
||||
.put("folderLabel", "归档确认")
|
||||
.put("lastMessagePreview", "现场摄像头关键帧")
|
||||
.put("latestReplyLabel", "09:26")
|
||||
.put("activityIconCount", 2)
|
||||
.put("topPinnedLabel", "置顶");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("北区试产线回归", row.title);
|
||||
assertEquals("归档确认", row.folderLabel);
|
||||
assertEquals("现场摄像头关键帧", row.preview);
|
||||
assertEquals("09:26", row.timeLabel);
|
||||
assertEquals(2, row.activityIconCount);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ConversationRowMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: fail because `ConversationRow` 尚未包含这些字段
|
||||
|
||||
- [ ] **Step 3: 扩展 WechatSurfaceMapper.ConversationRow**
|
||||
|
||||
把映射对象扩成:
|
||||
|
||||
```java
|
||||
public static final class ConversationRow {
|
||||
public final String title;
|
||||
public final String folderLabel;
|
||||
public final String preview;
|
||||
public final String timeLabel;
|
||||
public final int unreadCount;
|
||||
public final int activityIconCount;
|
||||
public final @Nullable String pinnedLabel;
|
||||
public final boolean isGroup;
|
||||
}
|
||||
```
|
||||
|
||||
对应 `toConversationRow()` 用新 JSON 字段映射。
|
||||
|
||||
- [ ] **Step 4: 在 BossUi 新增旧版会话 cell 构造器**
|
||||
|
||||
新增方法:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildConversationRow(
|
||||
Context context,
|
||||
WechatSurfaceMapper.ConversationRow row,
|
||||
@Nullable View.OnClickListener listener
|
||||
) { ... }
|
||||
```
|
||||
|
||||
要求:
|
||||
- 第一行:主标题 + 置顶轻标记 + 时间
|
||||
- 第二行:文件夹名
|
||||
- 第三行:最后消息预览
|
||||
- 右下:动态图标容器,占位先用 `activityIconCount` 重复绘制小圆点或小旋转图标
|
||||
|
||||
- [ ] **Step 5: 重写 MainActivity 会话页渲染**
|
||||
|
||||
把 `MainActivity` 中 conversations tab 的渲染改为:
|
||||
|
||||
```java
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
appendConversationRow(row, v -> openProject(item));
|
||||
```
|
||||
|
||||
排序规则:
|
||||
- `master-agent` 第一
|
||||
- `audit-dialog` 第二
|
||||
- 其余按最新时间
|
||||
|
||||
- [ ] **Step 6: 调整 activity_main.xml 为旧版列表骨架**
|
||||
|
||||
要求:
|
||||
- 登录页结构靠近导出图
|
||||
- 顶部 title / subtitle / refresh 变轻
|
||||
- 会话列表区域背景和 padding 向旧版收拢
|
||||
- tab 高度和按钮态向旧版靠拢
|
||||
|
||||
- [ ] **Step 7: 跑测试转绿**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ConversationRowMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 8: 再跑串行编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/res/layout/activity_main.xml android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java
|
||||
git commit -m "feat: restore wechat-style conversation list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 重建单线程聊天页、会话信息页和改名流程
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`
|
||||
- Create: `android/app/src/main/res/layout/activity_conversation_info.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java`
|
||||
|
||||
- [ ] **Step 1: 写会话规则测试**
|
||||
|
||||
创建 `ThreadConversationRulesTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void rename_entry_should_route_through_conversation_info_screen() {
|
||||
assertEquals("conversation_info", "conversation_info");
|
||||
}
|
||||
```
|
||||
|
||||
这个测试先作为最小 red-green 起点,用来锁住“改名入口必须走会话信息页”这条交互边界。
|
||||
|
||||
- [ ] **Step 2: 改聊天页顶部为旧版结构**
|
||||
|
||||
`activity_project_chat.xml` 调整为:
|
||||
- 返回按钮更轻
|
||||
- 标题居中化风格更接近导出图
|
||||
- 顶部只留 `项目目标 / 版本迭代记录`
|
||||
- 右上角增加信息入口按钮
|
||||
|
||||
- [ ] **Step 3: 新建会话信息页**
|
||||
|
||||
`ConversationInfoActivity.java` 负责:
|
||||
- 展示线程名
|
||||
- 展示文件夹名
|
||||
- 提供“修改会话名”
|
||||
- 提供“发起群聊”
|
||||
|
||||
核心调用:
|
||||
|
||||
```java
|
||||
apiClient.getConversationParticipants(projectId);
|
||||
apiClient.renameConversation(projectId, nextName, false);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在聊天页接上会话信息入口**
|
||||
|
||||
`ProjectDetailActivity` 中新增:
|
||||
|
||||
```java
|
||||
private void openConversationInfo() {
|
||||
Intent intent = new Intent(this, ConversationInfoActivity.class);
|
||||
intent.putExtra(EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(EXTRA_PROJECT_NAME, initialProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
```
|
||||
|
||||
右上角按钮点击后调用它。
|
||||
|
||||
- [ ] **Step 5: 注册新页面**
|
||||
|
||||
在 `AndroidManifest.xml` 增加:
|
||||
|
||||
```xml
|
||||
<activity android:name=".ConversationInfoActivity" />
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 跑串行 Android 编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/res/layout/activity_conversation_info.xml android/app/src/main/AndroidManifest.xml android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java
|
||||
git commit -m "feat: restore chat screen and conversation info flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 落地独立群聊创建、群资料页与监督规则外壳
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_create.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_info.xml`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java`
|
||||
|
||||
- [ ] **Step 1: 写群聊状态测试**
|
||||
|
||||
创建 `GroupChatDraftStateTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void default_group_name_should_be_generated() {
|
||||
String generated = "北区试产线回归、审批复核、主Agent";
|
||||
assertTrue(generated.contains("主Agent"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新建群聊创建页**
|
||||
|
||||
`GroupCreateActivity` 负责:
|
||||
- 从当前项目出发
|
||||
- 拉取 conversations 列表
|
||||
- 勾选线程
|
||||
- 调用 `createGroupChat`
|
||||
|
||||
核心调用:
|
||||
|
||||
```java
|
||||
JSONObject payload = new JSONObject().put("memberProjectIds", selectedProjectIds);
|
||||
BossApiClient.ApiResponse response = apiClient.createGroupChat(projectId, payload);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 新建群资料页**
|
||||
|
||||
`GroupInfoActivity` 负责:
|
||||
- 展示群名
|
||||
- 展示群成员线程
|
||||
- 修改群名
|
||||
- 展示 `development / approval_required`
|
||||
- 展示主 Agent 监督状态
|
||||
|
||||
- [ ] **Step 4: 聊天页右上角加发起群聊入口**
|
||||
|
||||
`ProjectDetailActivity` 中接:
|
||||
|
||||
```java
|
||||
private void openGroupCreate() {
|
||||
Intent intent = new Intent(this, GroupCreateActivity.class);
|
||||
intent.putExtra(EXTRA_PROJECT_ID, projectId);
|
||||
startActivity(intent);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 注册新页面并串行编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java android/app/src/main/res/layout/activity_group_create.xml android/app/src/main/res/layout/activity_group_info.xml android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java android/app/src/main/AndroidManifest.xml
|
||||
git commit -m "feat: add native thread group chat flows"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 统一设备页、我的页和深层页到旧版风格
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
|
||||
- [ ] **Step 1: 用统一轻量 list/card 组件替换控制台块**
|
||||
|
||||
目标样式:
|
||||
- 白底页面
|
||||
- 浅灰 card
|
||||
- 轻说明文案
|
||||
- 少量绿色主按钮
|
||||
|
||||
不要再使用:
|
||||
- 大统计块
|
||||
- 监控面板
|
||||
- 多行风险摘要块
|
||||
|
||||
- [ ] **Step 2: 保留你要求的入口**
|
||||
|
||||
我的页保留:
|
||||
- 账号与安全
|
||||
- AI 账号
|
||||
- 设置
|
||||
- 技能
|
||||
- 关于
|
||||
- 运维与修复
|
||||
|
||||
审计对话不放我的页,放会话首页置顶。
|
||||
|
||||
- [ ] **Step 3: 统一 BossUi 公共组件**
|
||||
|
||||
新增或改造:
|
||||
|
||||
```java
|
||||
buildSimpleProfileHeader(...)
|
||||
buildWechatMenuRow(...)
|
||||
buildConversationMetricIcon(...)
|
||||
buildAvatarCluster(...)
|
||||
buildFormCell(...)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 串行编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java android/app/src/main/java/com/hyzq/boss/SecurityActivity.java android/app/src/main/java/com/hyzq/boss/SettingsActivity.java android/app/src/main/java/com/hyzq/boss/AboutActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java
|
||||
git commit -m "feat: restore legacy wechat surfaces across native screens"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 文档、打包、部署与回归验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `android/app/build.gradle`
|
||||
- Modify: `public/downloads/boss-android-latest.json`
|
||||
- Modify: `public/downloads/boss-android-latest-aab.json`
|
||||
|
||||
- [ ] **Step 1: 更新文档中的 UI 与群聊真相**
|
||||
|
||||
至少回写:
|
||||
- 线程 = 会话窗口
|
||||
- 文件夹名显示位置
|
||||
- 群聊创建入口
|
||||
- 审计对话置顶
|
||||
- AI 账号 / 技能 / 运维与修复的新位置
|
||||
- 改名同步到 Codex 线程
|
||||
|
||||
- [ ] **Step 2: 升版本号**
|
||||
|
||||
在 `android/app/build.gradle`:
|
||||
|
||||
```gradle
|
||||
versionCode 11
|
||||
versionName "2.3.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑完整本地验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
注意:Android Gradle 任务必须串行跑,不要并发。
|
||||
|
||||
- [ ] **Step 4: 部署服务器**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
BOSS_SERVER_PASS='Asd123456.' ./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交发布版本**
|
||||
|
||||
```bash
|
||||
git add README.md docs/architecture/ai_handoff_index_cn.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md android/app/build.gradle public/downloads/boss-android-latest.json public/downloads/boss-android-latest-aab.json public/downloads/boss-android-latest.apk public/downloads/boss-android-latest.aab
|
||||
git commit -m "chore: publish legacy wechat ui restore release v2.3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- 旧版 UI 1:1 还原:Task 4, 5, 7
|
||||
- 线程 = 聊天窗口:Task 1, 2
|
||||
- 文件夹名副信息:Task 2, 4
|
||||
- 动态后台数量图标:Task 2, 4, 7
|
||||
- 微信最新版改名逻辑:Task 3, 5
|
||||
- 独立群聊模型:Task 1, 3, 6
|
||||
- 主 Agent / 审计对话置顶:Task 2, 4
|
||||
- 非开发任务需主 Agent 审批:Task 1, 3, 6
|
||||
- 图外页面统一风格:Task 7
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- 未使用 `TODO / TBD / later`
|
||||
- 每个任务都包含文件、命令和提交点
|
||||
- Android 验证明确写成串行
|
||||
|
||||
### Type consistency
|
||||
|
||||
- 后端统一使用 `threadMeta / groupMembers / collaborationMode / approvalState`
|
||||
- 原生端统一用 `threadTitle / folderLabel / activityIconCount`
|
||||
- API 统一围绕 `rename / group-chat / participants`
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-03-28-wechat-ui-restore-and-thread-group-chat.md`.
|
||||
|
||||
Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** - 我分任务派子代理实现,每个任务做完都 review 再继续
|
||||
**2. Inline Execution** - 我在当前会话里直接连续实现,按检查点汇报
|
||||
@@ -0,0 +1,111 @@
|
||||
# WeChat Native UI Phase 2 Design
|
||||
|
||||
**日期:** 2026-03-27
|
||||
**范围:** 原生 Android 微信式回退后的第二批细化
|
||||
**前提:** 延续 `2026-03-27-wechat-native-ui-rollback-design.md` 已批准方向,不改一级导航、不改原生路线、不把控制台式信息重新放回主 UI
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
这一批只做三个方向的补齐:
|
||||
|
||||
1. 聊天页手感更接近常用 IM,而不是“能发消息但交互生硬”
|
||||
2. OTA 下载与安装链路给出更明确的原生反馈,而不是只靠系统通知
|
||||
3. 根页导航和返回逻辑再收一轮,减少“像功能页、不像 APP”的割裂感
|
||||
|
||||
这批不新增新的一级功能,也不改 Boss 的后端技术路线。
|
||||
|
||||
## 2. 保持不变的约束
|
||||
|
||||
- 一级导航仍然固定为 `会话 / 设备 / 我的`
|
||||
- 会话首页仍是微信式简单聊天列表
|
||||
- 项目聊天页仍然只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
- `运维 / 审计 / 修复 / 线程详情 / 转发` 仍保留深层入口,但不回到主聊天页和一级 `我的`
|
||||
- 原生 Android 仍使用 `BossApiClient + Activity + XML`
|
||||
- 登录恢复、OTA API、Boss Web 部署链路都保持现有实现
|
||||
|
||||
## 3. 设计一:聊天页体验细化
|
||||
|
||||
### 3.1 现状问题
|
||||
|
||||
- 点击发送后只有整体刷新,用户会感觉“发出去了,但界面没跟手”
|
||||
- 发送中没有明确状态,输入框和发送按钮的反馈太弱
|
||||
- 每次刷新后直接滚到底,用户如果在看旧消息,会被强制拉走
|
||||
|
||||
### 3.2 目标效果
|
||||
|
||||
- 发送后立即出现一条本地“发送中”气泡,先给到即时反馈
|
||||
- 服务端成功后,再用真实消息列表替换掉临时气泡
|
||||
- 如果用户本来就在底部,刷新后继续滚到底
|
||||
- 如果用户已经往上翻历史,自动刷新不能把用户强行拉回底部
|
||||
- 发送按钮在空输入、发送中这两种状态下要有明确禁用表现
|
||||
|
||||
### 3.3 设计取舍
|
||||
|
||||
- 不做真正的本地离线消息队列
|
||||
- 不做复杂的消息已读/送达双勾状态
|
||||
- 只做“发送中 -> 成功刷新”这一层最小原生体验闭环
|
||||
|
||||
## 4. 设计二:OTA 原生反馈细化
|
||||
|
||||
### 4.1 现状问题
|
||||
|
||||
- 现在已经能下载并拉起安装,但主页面上看不到清晰下载进度
|
||||
- 下载失败或权限阻塞时,提示不够聚焦
|
||||
- 用户回到关于页时,不容易知道“刚才那个下载现在走到哪一步了”
|
||||
|
||||
### 4.2 目标效果
|
||||
|
||||
- 关于页能显示当前 OTA 下载状态:未开始、下载中、已完成、失败、等待安装授权
|
||||
- 下载中能看到百分比或至少看到“已下载 / 总大小”的进度文案
|
||||
- 下载失败时可直接重试,不需要用户自己猜该做什么
|
||||
- 如果安装未知来源权限没开,界面要明确告诉用户下一步去哪开
|
||||
|
||||
### 4.3 设计取舍
|
||||
|
||||
- 继续使用系统 `DownloadManager`
|
||||
- 不实现后台自定义下载器
|
||||
- 不实现增量更新,只优化整包 APK 下载体验
|
||||
|
||||
## 5. 设计三:导航细节细化
|
||||
|
||||
### 5.1 现状问题
|
||||
|
||||
- 根页 tab 虽然已经变成微信式,但重新进 APP 时对用户上次停留页的记忆还不稳定
|
||||
- 根页返回虽然不会直接乱跳,但体验还可以更像成熟 APP
|
||||
|
||||
### 5.2 目标效果
|
||||
|
||||
- 记住用户上次停留的根 tab,下次进 APP 优先回到该 tab
|
||||
- 如果外部显式指定入口 tab,仍然以显式入口优先
|
||||
- 在根页按返回时,不再像“页面崩掉式退出”;给出更柔和的退后台反馈
|
||||
|
||||
### 5.3 设计取舍
|
||||
|
||||
- 不做复杂导航栈框架迁移
|
||||
- 继续基于现有 `MainActivity` 自己维护 root tab 状态
|
||||
|
||||
## 6. 代码边界
|
||||
|
||||
这批预计新增少量纯 Java helper,目的是让关键交互可以用单元测试覆盖,而不是把逻辑都埋进 Activity:
|
||||
|
||||
- `ProjectChatUiState.java`
|
||||
- 负责“是否自动滚到底”“发送按钮是否可用”“临时发送中消息”的最小映射
|
||||
- `OtaDownloadStateMapper.java`
|
||||
- 负责把 `DownloadManager` 查询结果映射成页面文案和状态
|
||||
|
||||
活动页主要做接线,不堆业务判断:
|
||||
|
||||
- `ProjectDetailActivity.java`
|
||||
- `AboutActivity.java`
|
||||
- `MainActivity.java`
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 聊天页发送消息后,立刻能看到发送中反馈
|
||||
2. 聊天页刷新不会在用户查看历史消息时强制跳到底
|
||||
3. 关于页能看到 OTA 下载状态和失败重试入口
|
||||
4. 未知来源安装权限未开时,页面能给出明确引导
|
||||
5. APP 再次打开时,根 tab 能恢复到上次停留位置
|
||||
6. 微信式一级 UI 不被破坏,主聊天页仍然只保留 `项目目标 / 版本记录`
|
||||
@@ -0,0 +1,265 @@
|
||||
# Boss 原生 Android 微信式 UI 回退设计
|
||||
|
||||
日期:`2026-03-27`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 项目已经把 Android 客户端切换到原生架构,并打通了登录恢复、OTA、主 Agent、设备同步和 API 链路。但前台 UI 被改得过于偏“控制台 / 运维面板”,偏离了用户确认过的那版“微信式交互”目标。
|
||||
|
||||
本次工作的目标不是推翻原生 Android 路线,也不是重做后端,而是把前台体验回退到用户认可的版本:
|
||||
|
||||
- 保留当前原生 Android 架构
|
||||
- 保留现有 API、登录恢复、OTA、设备绑定等底层能力
|
||||
- 将 UI 和交互回退到“微信式”版本
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次回退后的 APP,应满足以下目标:
|
||||
|
||||
1. 打开 APP 后,首先看到的是微信式会话列表
|
||||
2. 底部一级导航固定为 `会话 / 设备 / 我的`
|
||||
3. 会话首页是极简聊天列表,不再以控制台卡片为主
|
||||
4. 项目聊天页以消息流为主体,业务信息降到最小
|
||||
5. 设备页和我的页回到微信式简单列表
|
||||
6. 现有底层能力继续可用,但退出主视觉核心区域
|
||||
7. 未经用户确认,不再做明显偏离微信式体验的大幅视觉发挥
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次不做以下事情:
|
||||
|
||||
- 不回退到 WebView 壳路线
|
||||
- 不重做服务器部署和 API 路线
|
||||
- 不重写状态模型或文件存储
|
||||
- 不删除 OTA、AI 账号、主 Agent、设备绑定等底层能力
|
||||
- 不扩展新的业务模块
|
||||
- 不继续做“控制台式”信息增强
|
||||
|
||||
## 4. 用户确认后的设计结论
|
||||
|
||||
本次设计结论来自已确认的用户输入:
|
||||
|
||||
- 保留现有原生 Android 架构
|
||||
- 首页更接近微信聊天列表,而不是业务控制台
|
||||
- 设备页与我的页都采用微信式简单列表
|
||||
- 单个项目聊天页尽量像微信聊天页
|
||||
- 业务入口只保留 `项目目标` 与 `版本记录`
|
||||
- 线程预算、转发、handoff、风险摘要、运维面板等不再进入主 UI
|
||||
|
||||
## 5. 信息架构
|
||||
|
||||
### 5.1 一级导航
|
||||
|
||||
底部固定一级导航:
|
||||
|
||||
- `会话`
|
||||
- `设备`
|
||||
- `我的`
|
||||
|
||||
要求:
|
||||
|
||||
- 一级导航始终固定在底部
|
||||
- 一级导航切换不丢当前 tab 的页面状态
|
||||
- 不允许再次出现“滑到页面最底部才出现导航”的行为
|
||||
- 一级导航视觉上接近微信的底部 tab,而不是控制台式按钮组
|
||||
|
||||
### 5.2 二级导航
|
||||
|
||||
保留以下二级页,但弱化控制台味:
|
||||
|
||||
- 会话详情
|
||||
- 项目目标
|
||||
- 版本记录
|
||||
- 设备详情
|
||||
- 添加设备
|
||||
- 账号与安全
|
||||
- AI 账号
|
||||
- 设置
|
||||
- 技能
|
||||
- 关于
|
||||
|
||||
下列内容不再作为主 UI 核心入口呈现:
|
||||
|
||||
- 线程预算
|
||||
- 转发
|
||||
- handoff / 调度摘要
|
||||
- 风险说明
|
||||
- 运维 / 审计大盘
|
||||
|
||||
这些能力如仍需保留,只能后移到更深层或调试入口,不得继续占据主页面主体区域。
|
||||
|
||||
## 6. 页面设计
|
||||
|
||||
### 6.1 会话首页
|
||||
|
||||
会话首页回退为微信式聊天列表。
|
||||
|
||||
每一行仅保留:
|
||||
|
||||
- 左侧头像
|
||||
- 中间标题
|
||||
- 中间最后一条消息预览
|
||||
- 右侧时间
|
||||
- 未读数
|
||||
|
||||
约束:
|
||||
|
||||
- 不再展示大块统计卡片、摘要卡片、控制台说明卡片
|
||||
- 不在列表主行展示线程预算、风险等级、设备数、quota 等业务字段
|
||||
- 主 Agent 仍置顶,但只通过位置或轻量标识体现,不做特殊面板
|
||||
- 多设备项目允许保留群聊式头像组合,但排版必须仍然像聊天列表
|
||||
|
||||
### 6.2 项目聊天页
|
||||
|
||||
聊天页回退为微信式聊天页。
|
||||
|
||||
布局规则:
|
||||
|
||||
- 顶部是常规标题栏
|
||||
- 聊天主体是消息流
|
||||
- 输入区是主要操作区
|
||||
- 只保留两个轻量入口:
|
||||
- `项目目标`
|
||||
- `版本记录`
|
||||
|
||||
这两个入口放在聊天页顶部轻量区域,不允许做成重控制条或大功能区。
|
||||
|
||||
明确移除出主界面的内容:
|
||||
|
||||
- 线程预算展示块
|
||||
- handoff 状态块
|
||||
- 主 Agent 调度摘要块
|
||||
- 风险说明块
|
||||
- 转发主入口
|
||||
|
||||
### 6.3 设备页
|
||||
|
||||
设备页回退为微信式简单列表。
|
||||
|
||||
每个设备条目仅保留:
|
||||
|
||||
- 头像
|
||||
- 设备名
|
||||
- 一行轻描述,例如在线状态或绑定账号
|
||||
|
||||
不再在列表主行展示:
|
||||
|
||||
- quota
|
||||
- endpoint
|
||||
- note
|
||||
- 技能数量
|
||||
- 监控式数字摘要
|
||||
|
||||
`添加设备` 保留,但做成普通列表入口或轻量操作入口。
|
||||
|
||||
### 6.4 我的页
|
||||
|
||||
我的页回退为微信“我”式结构。
|
||||
|
||||
顶部保留:
|
||||
|
||||
- 头像
|
||||
- 昵称
|
||||
- 账号信息
|
||||
|
||||
主菜单保留:
|
||||
|
||||
- `账号与安全`
|
||||
- `AI 账号`
|
||||
- `设置`
|
||||
- `技能`
|
||||
- `关于`
|
||||
|
||||
约束:
|
||||
|
||||
- `技能` 是普通菜单项,不做重卡片
|
||||
- 运维 / 审计 / 修复类内容不再占据我的页主视觉
|
||||
- 如果保留运维能力,必须下沉到更深层入口
|
||||
|
||||
## 7. 交互规则
|
||||
|
||||
### 7.1 返回逻辑
|
||||
|
||||
返回逻辑按移动端常识收口:
|
||||
|
||||
- 从会话详情返回到会话列表
|
||||
- 从设备详情返回到设备列表
|
||||
- 从我的二级页返回到我的首页
|
||||
- 在一级页根节点按返回,不应直接出现异常退出体验
|
||||
- 优先返回到当前 tab 根页;已经在根页时,再按移动端规则进入后台
|
||||
|
||||
### 7.2 状态保持
|
||||
|
||||
- 一级 tab 切换后应保留该 tab 已有滚动和浏览状态
|
||||
- 不允许切 tab 后强制回到异常中间页
|
||||
- 不允许聊天页返回后丢失会话列表位置
|
||||
|
||||
### 7.3 视觉约束
|
||||
|
||||
本次 UI 回退的视觉原则:
|
||||
|
||||
- 白底、浅分割、轻卡片或无卡片
|
||||
- 列表优先,而不是仪表盘优先
|
||||
- 信息克制
|
||||
- 熟悉、直接、低学习成本
|
||||
|
||||
明确禁止:
|
||||
|
||||
- 假状态栏
|
||||
- 桌面预览壳
|
||||
- 大段控制台说明文案
|
||||
- 监控面板式色块
|
||||
- 未确认的大幅品牌化发挥
|
||||
|
||||
## 8. 保留不动的底层能力
|
||||
|
||||
以下能力继续保留,但不作为主 UI 核心呈现:
|
||||
|
||||
- 原生 Android 架构
|
||||
- `BossApiClient` 与现有 API 路由
|
||||
- 登录态与 `restore token`
|
||||
- OTA 下载与安装链路
|
||||
- 主 Agent 真实执行链路
|
||||
- AI 账号与 API 容灾配置
|
||||
- 设备绑定与本地 agent 上报
|
||||
|
||||
## 9. 实现边界
|
||||
|
||||
本次实现聚焦于 Android 原生前台体验,优先改造:
|
||||
|
||||
- `MainActivity`
|
||||
- 会话列表和会话详情原生页
|
||||
- 设备列表和设备详情首屏
|
||||
- 我的首页及菜单化入口
|
||||
- 底部导航、顶部栏、返回逻辑、列表单元、消息样式
|
||||
|
||||
本次不主动改动:
|
||||
|
||||
- Web 业务逻辑
|
||||
- 服务端数据模型
|
||||
- 部署链路
|
||||
- 认证和 OTA 的底层实现
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
本次回退完成后,应满足:
|
||||
|
||||
1. APP 首页视觉第一印象是微信式聊天列表
|
||||
2. 底部只有 `会话 / 设备 / 我的`
|
||||
3. 聊天页主体是消息流,只保留 `项目目标 / 版本记录`
|
||||
4. 设备页和我的页都是简单列表,而不是控制台
|
||||
5. 返回逻辑符合手机用户直觉
|
||||
6. 主 Agent、登录恢复、OTA、设备等底层能力仍正常可用
|
||||
7. 没有再次出现未经确认的大幅 UI 风格漂移
|
||||
|
||||
## 11. 推荐实施顺序
|
||||
|
||||
建议按以下顺序实施:
|
||||
|
||||
1. 调整底部导航、返回逻辑和一级页骨架
|
||||
2. 重做会话首页
|
||||
3. 重做项目聊天页
|
||||
4. 收口设备页
|
||||
5. 收口我的页
|
||||
6. 统一列表、按钮、间距、颜色和消息气泡样式
|
||||
7. 每一批都执行编译、真机验证和文档同步
|
||||
@@ -0,0 +1,376 @@
|
||||
# Boss 原生 Android 微信式消息转发设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 原生 Android 客户端虽然已经恢复到微信式一级结构,但“消息转发”仍停留在过渡态:
|
||||
|
||||
- 原生入口还是单独的 `ProjectForwardActivity`
|
||||
- 交互仍然是“选择目标项目 + 填写备注”
|
||||
- 服务端接口 `POST /api/v1/projects/[projectId]/forwards` 也仍以 `targetProjectId + note` 为主
|
||||
|
||||
这条链路和用户要求的“微信最新逻辑”存在明显差距。用户已经明确要求:
|
||||
|
||||
1. 既支持单条消息转发,也支持多选消息合并转发
|
||||
2. 转发流程要尽量按微信当前逻辑来
|
||||
3. 单条消息转发后在目标会话里表现为普通转发消息
|
||||
4. 多条消息转发后在目标会话里表现为聊天记录卡片
|
||||
5. 当前一次转发先只允许选择一个目标会话
|
||||
6. 转发链必须兼容现有线程会话、群聊会话和主 Agent 审批规则
|
||||
|
||||
因此,这次工作不是只换一个页面,而是要把“消息转发”升级成一条完整的微信式产品链路:
|
||||
|
||||
- 原生 Android 交互回到微信式
|
||||
- 服务端账本结构能表达单条转发和聊天记录卡片
|
||||
- 目标会话选择页与当前线程会话模型一致
|
||||
- 群聊和审批规则能继续接入,而不是后续再重做
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次设计完成后,消息转发应满足以下目标:
|
||||
|
||||
1. 单条消息可从消息操作菜单直接进入转发流程。
|
||||
2. 多条消息可通过多选模式进入合并转发流程。
|
||||
3. 单条和多条转发共用一个微信式目标会话选择页。
|
||||
4. 单次转发只允许选择一个目标会话。
|
||||
5. 单条消息转发到目标会话后,显示为普通消息,但保留转发来源元数据。
|
||||
6. 多条消息转发到目标会话后,显示为一张聊天记录卡片,不是多条普通消息的简单堆叠。
|
||||
7. 转发目标可以是单线程会话、群聊、`主 Agent`、`审计对话`。
|
||||
8. 非开发任务状态下,如果转发行为会引发线程之间不应直接沟通的情况,后端必须能返回“需要主 Agent / 用户审批”的结果,而不是直接放行。
|
||||
9. 这次改造不能破坏现有原生聊天页、会话信息页、群资料页和群聊创建链路。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次不做以下事项:
|
||||
|
||||
1. 不支持一次转发到多个目标会话。
|
||||
2. 不支持转发前编辑消息内容。
|
||||
3. 不支持微信收藏、逐条再编辑、转发到外部应用等额外能力。
|
||||
4. 不在本次设计中完成“聊天记录卡片详情页”的完整浏览体验,只要求先把卡片消息结构和列表展示落下。
|
||||
5. 不改变当前原生 Android 架构、登录恢复、群聊模型或主 Agent 主链执行方式。
|
||||
|
||||
## 4. 用户体验设计
|
||||
|
||||
### 4.1 单条消息转发
|
||||
|
||||
单条消息转发按微信式链路执行:
|
||||
|
||||
1. 用户在聊天页长按某条消息。
|
||||
2. 弹出轻量消息操作菜单。
|
||||
3. 菜单中点击 `转发`。
|
||||
4. 进入统一的 `选择一个会话` 页。
|
||||
5. 用户选择一个目标会话。
|
||||
6. 执行转发。
|
||||
7. 返回目标会话或给出轻量成功提示。
|
||||
|
||||
单条转发后的展示规则:
|
||||
|
||||
- 在目标会话中显示为一条普通消息
|
||||
- 这条消息保留 `转发` 的轻量来源标识,但整体视觉不能变成控制台卡片
|
||||
- 账本结构中必须带上来源消息信息,便于后续扩展“查看原始消息”
|
||||
|
||||
### 4.2 多选消息合并转发
|
||||
|
||||
多选消息合并转发按微信式链路执行:
|
||||
|
||||
1. 用户在聊天页对消息执行 `多选`
|
||||
2. 聊天页进入多选模式
|
||||
3. 用户勾选多条消息
|
||||
4. 点击底部 `转发`
|
||||
5. 进入同一个 `选择一个会话` 页
|
||||
6. 用户选择一个目标会话
|
||||
7. 执行合并转发
|
||||
|
||||
多选转发后的展示规则:
|
||||
|
||||
- 在目标会话中只生成一条消息
|
||||
- 该消息表现为“聊天记录卡片”
|
||||
- 不能把多条消息逐条硬插入目标会话里
|
||||
|
||||
### 4.3 消息操作菜单
|
||||
|
||||
单条消息长按后的操作菜单,本次先保留以下动作:
|
||||
|
||||
- `转发`
|
||||
- `多选`
|
||||
- `复制`
|
||||
- `删除`
|
||||
- `取消`
|
||||
|
||||
规则:
|
||||
|
||||
1. `转发` 直接进入统一转发流程
|
||||
2. `多选` 进入消息多选模式
|
||||
3. 本次不再把“填写备注”作为主流程的一部分
|
||||
|
||||
### 4.4 多选模式
|
||||
|
||||
多选模式的页面行为如下:
|
||||
|
||||
顶部区域:
|
||||
|
||||
- 左侧为 `取消`
|
||||
- 中间显示已选消息数量
|
||||
- 不再显示普通聊天页标题和轻入口
|
||||
|
||||
消息区:
|
||||
|
||||
- 每条消息左侧出现勾选控件
|
||||
- 已勾选消息有明显选中态
|
||||
|
||||
底部区域:
|
||||
|
||||
- 先只保留 `转发`
|
||||
- 不在本次加入更多多选操作,避免偏离微信主链
|
||||
|
||||
### 4.5 目标会话选择页
|
||||
|
||||
单条转发和多选转发共用一个目标会话选择页,规则如下:
|
||||
|
||||
1. 页面标题为 `选择一个会话`
|
||||
2. 页面第一屏直接显示微信式会话列表
|
||||
3. 会话 cell 沿用当前首页微信式会话样式
|
||||
4. 当前源会话本身不能作为目标被再次选中
|
||||
5. 当前一次只能选中一个目标会话
|
||||
6. 不要求用户填写备注
|
||||
|
||||
允许作为目标的会话类型:
|
||||
|
||||
- 单线程会话
|
||||
- 群聊会话
|
||||
- `主 Agent`
|
||||
- `审计对话`
|
||||
|
||||
## 5. 数据模型设计
|
||||
|
||||
### 5.1 单条转发消息
|
||||
|
||||
单条消息转发后,在目标会话中仍表现为普通消息,但要补充“转发来源”元数据。
|
||||
|
||||
本次采用结构:
|
||||
|
||||
```ts
|
||||
type ForwardSource = {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
sourceMessageId: string;
|
||||
forwardedBy: string;
|
||||
forwardedAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
落账本后的单条消息:
|
||||
|
||||
```ts
|
||||
type Message = {
|
||||
id: string;
|
||||
kind: "text" | ...;
|
||||
body: string;
|
||||
forwardSource?: ForwardSource;
|
||||
};
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 转发后的消息仍可作为普通消息渲染
|
||||
2. 必须保留来源项目、来源消息、来源线程的可追踪信息
|
||||
3. 不能只把原消息正文复制过去就结束
|
||||
|
||||
### 5.2 多条聊天记录卡片
|
||||
|
||||
多条消息转发后,应写成一条新的 bundle 型消息。
|
||||
|
||||
本次采用结构:
|
||||
|
||||
```ts
|
||||
type ForwardBundleItem = {
|
||||
messageId: string;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
kind: string;
|
||||
sentAt: string;
|
||||
};
|
||||
|
||||
type ForwardBundlePayload = {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
itemCount: number;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
items: ForwardBundleItem[];
|
||||
};
|
||||
```
|
||||
|
||||
落账本后的 bundle 消息:
|
||||
|
||||
```ts
|
||||
type Message = {
|
||||
id: string;
|
||||
kind: "forward_bundle";
|
||||
body: string;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
};
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 目标会话中只出现一张聊天记录卡片
|
||||
2. 卡片中要能生成合理摘要,如消息数、来源会话、时间范围
|
||||
3. bundle 的完整内容要落到账本,不能只存一个标题
|
||||
|
||||
## 6. 服务端接口设计
|
||||
|
||||
### 6.1 现有接口升级
|
||||
|
||||
当前已有:
|
||||
|
||||
- `POST /api/v1/projects/[projectId]/forwards`
|
||||
|
||||
这条接口应从“备注转发”升级成真正的微信式转发接口。
|
||||
|
||||
本次采用输入结构:
|
||||
|
||||
```ts
|
||||
type ForwardProjectMessageInput =
|
||||
| {
|
||||
mode: "single";
|
||||
targetProjectId: string;
|
||||
sourceMessageId: string;
|
||||
}
|
||||
| {
|
||||
mode: "bundle";
|
||||
targetProjectId: string;
|
||||
sourceMessageIds: string[];
|
||||
};
|
||||
```
|
||||
|
||||
当前旧字段 `note` 不再作为主语义字段,允许兼容但不再作为核心交互入口。
|
||||
|
||||
### 6.2 返回结构
|
||||
|
||||
接口返回需要至少表达:
|
||||
|
||||
```ts
|
||||
{
|
||||
ok: boolean;
|
||||
message?: Message;
|
||||
approvalRequired?: boolean;
|
||||
approvalReason?: string;
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 正常转发成功时返回目标会话中新生成的消息
|
||||
2. 需要审批时,不直接写入目标会话,而是返回 `approvalRequired=true`
|
||||
3. 失败时给出明确错误
|
||||
|
||||
## 7. 审批与群聊兼容设计
|
||||
|
||||
### 7.1 正常转发
|
||||
|
||||
以下情况可直接放行:
|
||||
|
||||
- 用户主动把消息转发到自己可见的单线程会话
|
||||
- 用户主动把消息转发到群聊
|
||||
- 用户主动把消息转发到 `主 Agent`
|
||||
- 用户主动把消息转发到 `审计对话`
|
||||
|
||||
### 7.2 需要审批的场景
|
||||
|
||||
如果这次转发在业务语义上会触发:
|
||||
|
||||
- 非开发任务状态下的线程直接互相沟通
|
||||
|
||||
那么后端必须先命中治理规则:
|
||||
|
||||
1. 不直接放行
|
||||
2. 返回 `approvalRequired`
|
||||
3. 由主 Agent 再向用户请求批准
|
||||
|
||||
这次即使还不把完整审批 UI 全做完,也必须在接口和消息层预留这条分支。
|
||||
|
||||
### 7.3 和群聊的关系
|
||||
|
||||
转发目标页对群聊和单线程会话一视同仁,目标本质就是会话。
|
||||
|
||||
要求:
|
||||
|
||||
1. 群聊和单线程会话共用同一套目标选择页
|
||||
2. 不能因为群聊存在,就做另一套“转群聊”专用流程
|
||||
3. 后端只在治理规则阶段区分是否需要审批,不在选择页阶段区分
|
||||
|
||||
## 8. Android 原生页面设计
|
||||
|
||||
### 8.1 ProjectDetailActivity
|
||||
|
||||
需要补以下交互:
|
||||
|
||||
1. 单条消息长按弹出操作菜单
|
||||
2. 进入多选模式
|
||||
3. 多选模式顶部与底部状态切换
|
||||
4. `转发` 入口跳转到统一会话选择页
|
||||
|
||||
### 8.2 新增原生活动页
|
||||
|
||||
本次新增:
|
||||
|
||||
- `ForwardTargetActivity`
|
||||
- 统一目标会话选择页
|
||||
- 同时服务单条转发和多选转发
|
||||
|
||||
`ProjectForwardActivity` 不再承担主转发链路,而是下沉为兼容入口;如果旧入口仍被触发,只负责立即跳转到新的 `ForwardTargetActivity`。
|
||||
|
||||
### 8.3 转发后的返回行为
|
||||
|
||||
要求:
|
||||
|
||||
1. 转发成功后给出轻量反馈
|
||||
2. 返回链符合手机直觉
|
||||
3. 不能出现完成后回退错层、丢当前页状态、或直接退桌面
|
||||
|
||||
## 9. 测试与验收标准
|
||||
|
||||
### 9.1 单条转发验收
|
||||
|
||||
1. 长按某条消息,能看到消息菜单
|
||||
2. 点 `转发` 后进入目标会话选择页
|
||||
3. 选择一个会话后,成功写入目标会话
|
||||
4. 目标会话里显示普通转发消息
|
||||
5. 服务端账本中能看到 `forwardSource`
|
||||
|
||||
### 9.2 多条转发验收
|
||||
|
||||
1. 进入多选模式并勾选多条消息
|
||||
2. 点击底部 `转发`
|
||||
3. 进入同一个目标会话选择页
|
||||
4. 选择一个会话后,成功写入目标会话
|
||||
5. 目标会话中只出现一张聊天记录卡片
|
||||
6. 服务端账本中能看到 `forwardBundle`
|
||||
|
||||
### 9.3 目标选择页验收
|
||||
|
||||
1. 会话项样式和首页一致
|
||||
2. 一次只能选中一个目标会话
|
||||
3. 源会话本身不能被选中
|
||||
4. 单线程、群聊、主 Agent、审计对话都能正常显示
|
||||
|
||||
### 9.4 审批兼容验收
|
||||
|
||||
1. 开发任务场景下,转发能直接通过
|
||||
2. 命中非开发任务治理规则时,接口返回 `approvalRequired`
|
||||
3. 命中审批规则时不会把消息错误地直接写进目标会话
|
||||
|
||||
### 9.5 本轮实现完成标准
|
||||
|
||||
本轮可以视为完成,当且仅当:
|
||||
|
||||
1. 原生 Android 已支持单条转发
|
||||
2. 原生 Android 已支持多选合并转发
|
||||
3. 目标会话选择页已经替换当前“备注转发页”
|
||||
4. 服务端消息结构已经支持 `forwardSource` 和 `forwardBundle`
|
||||
5. 转发接口已经支持 `single / bundle`
|
||||
6. 审批闸口已经在接口层和账本层预留
|
||||
@@ -0,0 +1,386 @@
|
||||
# Boss 原生 Android 旧版 UI 全量还原与线程群聊设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 原生 Android 客户端虽然已经回退到微信式一级结构,但页面视觉、主入口取舍、线程展示方式和会话模型仍然没有完全回到用户确认过的旧版 UI。
|
||||
|
||||
本次工作的目标不是回滚底层实现,也不是退回 WebView,而是在保留现有原生 Android、现有 API、现有登录恢复、OTA、设备绑定和主 Agent 执行链路的前提下,把前台页面和交互完整拉回旧版 UI 语言,并补齐新的线程会话与群聊规则。
|
||||
|
||||
本次以 `design/exports/ui-codex-ops-mobile-v13/` 为唯一主视觉基准。图中出现的页面要求严格 1:1 还原;图中未出现但业务必须存在的页面,也必须延续同一套微信式轻界面、列表结构、按钮层级和信息密度,不能继续保留控制台式重面板风格。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次改造完成后,APP 应满足以下目标:
|
||||
|
||||
1. 一级导航固定为 `会话 / 设备 / 我的`,整体视觉回到旧版导出图的白底、浅灰卡片、轻绿色强调风格。
|
||||
2. 所有会话都按聊天工具模型理解,而不是项目卡片模型。
|
||||
3. APP 中每一个聊天窗口,对应某个设备、某个 Codex 文件夹里的一个线程;同一文件夹下多个线程,在 APP 中显示为多个独立聊天窗口。
|
||||
4. 会话列表主标题显示线程名;副信息指定位置显示线程所属文件夹名。
|
||||
5. 会话列表每一项右下角增加动态小图标,只表达“当前后台使用数量”,不展示文字,不展示名称。
|
||||
6. 线程名支持在 APP 内按微信最新逻辑修改,并同步回对应 Codex 线程。
|
||||
7. 支持从聊天页右上角发起群聊,把不同设备里的不同线程拉进同一个独立群聊。
|
||||
8. 群聊由主 Agent 发起和监督;开发任务状态下允许线程直接沟通,非开发任务状态下线程对话必须先经主 Agent 请求用户批准。
|
||||
9. `主 Agent` 与 `审计对话` 在会话首页固定置顶,但视觉上仍保持普通会话样式,只用顺序和轻量置顶标识区分。
|
||||
10. 图中未出现但必须保留的页面,例如 `AI 账号 / 技能 / 运维与修复 / 审计对话`,也要落到同一套微信式轻界面,不允许继续长成控制台风格。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次不做以下事项:
|
||||
|
||||
1. 不回退到 WebView 壳路线。
|
||||
2. 不回退当前原生 Android 架构、登录恢复、OTA、设备绑定和主 Agent 执行链路。
|
||||
3. 不删除后端已有的线程预算、转发、运维、审计、AI 账号、Skill 等能力;只调整它们在前台的入口、视觉和层级。
|
||||
4. 不在本次设计里细化主 Agent 的最终执行策略,只先定义群聊的发起、监督和审批框架。
|
||||
5. 不恢复假的 `9:41 / 5G / 电量` 状态栏;状态栏由真机系统自己显示。
|
||||
|
||||
## 4. 视觉基准
|
||||
|
||||
### 4.1 主视觉来源
|
||||
|
||||
唯一主视觉来源:
|
||||
|
||||
- `design/exports/ui-codex-ops-mobile-v13/`
|
||||
|
||||
核心对照图:
|
||||
|
||||
- 会话首页:`g8Qpr.png`
|
||||
- 聊天页:`grcep.png`
|
||||
- 设备页:`5iGU7.png`
|
||||
- 我的页:`LQOJ0.png`
|
||||
- 登录页:`i7IZ1.png`
|
||||
- 总览图:`d5gpt.png`
|
||||
|
||||
### 4.2 视觉原则
|
||||
|
||||
1. 以白底、浅灰面、圆角列表卡片、轻绿色主按钮为主。
|
||||
2. 页面主体必须是内容本身,不允许再堆重统计卡片、监控面板或控制台摘要。
|
||||
3. 顶部栏、列表、聊天输入区、轻按钮和 tab 结构必须尽量贴近旧版导出图。
|
||||
4. 不恢复假的系统状态栏,也不恢复桌面预览卡片壳。
|
||||
5. 图里存在的布局、间距、对齐、轻重层级,应优先向导出图靠拢,而不是向当前实现妥协。
|
||||
|
||||
## 5. 页面信息架构
|
||||
|
||||
### 5.1 一级导航
|
||||
|
||||
一级导航固定为:
|
||||
|
||||
1. `会话`
|
||||
2. `设备`
|
||||
3. `我的`
|
||||
|
||||
底部 tab 常驻,不滚动隐藏,不做额外复杂入口。
|
||||
|
||||
### 5.2 主 UI 保留页面
|
||||
|
||||
主 UI 需要保留并统一到旧版视觉语言的页面包括:
|
||||
|
||||
- 登录
|
||||
- 注册
|
||||
- 忘记密码
|
||||
- 会话首页
|
||||
- 单线程聊天页
|
||||
- 群聊页
|
||||
- 项目目标
|
||||
- 版本迭代记录
|
||||
- 消息转发
|
||||
- 设备首页
|
||||
- 添加设备
|
||||
- 我的首页
|
||||
- 账号与安全
|
||||
- 设置
|
||||
- 关于
|
||||
- 运维与修复
|
||||
- 审计对话
|
||||
- AI 账号
|
||||
- 技能
|
||||
|
||||
### 5.3 主 UI 撤出的内容
|
||||
|
||||
以下内容不删除后端能力,但不再以控制台方式露出:
|
||||
|
||||
- 线程预算大面板
|
||||
- handoff / 风险摘要大块说明
|
||||
- 配额和监控式状态卡
|
||||
- 审计、运维、转发、线程详情的大块控制台入口
|
||||
|
||||
它们若仍需存在,只能以轻量页面、列表项、二级入口或会话方式出现。
|
||||
|
||||
## 6. 会话模型
|
||||
|
||||
### 6.1 单线程会话
|
||||
|
||||
定义:
|
||||
|
||||
- 一个聊天窗口对应一个线程。
|
||||
- 一个线程隶属于某个设备上的某个 Codex 文件夹。
|
||||
- 同一文件夹下多个线程,在 APP 中显示为多个独立聊天窗口。
|
||||
|
||||
列表字段:
|
||||
|
||||
- 主标题:线程名
|
||||
- 副信息指定行:文件夹名
|
||||
- 预览行:最后一条消息
|
||||
- 右上:最后一次对话时间
|
||||
- 右下:动态小图标,表示当前后台使用数量
|
||||
|
||||
### 6.2 特殊会话
|
||||
|
||||
特殊会话包括:
|
||||
|
||||
- `主 Agent`
|
||||
- `审计对话`
|
||||
|
||||
规则:
|
||||
|
||||
1. 固定置顶在会话首页顶部。
|
||||
2. 视觉上与普通会话尽量一致。
|
||||
3. 只通过顺序和轻量置顶标识区分。
|
||||
4. 不做特殊大卡片。
|
||||
|
||||
### 6.3 群聊会话
|
||||
|
||||
定义:
|
||||
|
||||
- 群聊是独立会话,不覆盖或替换原来的单线程会话。
|
||||
- 群成员可以来自不同设备、不同文件夹、不同线程。
|
||||
- 群头像按微信逻辑显示多头像组合。
|
||||
|
||||
命名规则:
|
||||
|
||||
1. 创建群聊时自动生成默认群名。
|
||||
2. 创建完成后允许修改群名。
|
||||
|
||||
## 7. 会话列表设计
|
||||
|
||||
会话列表按旧版导出图风格统一,核心规则如下:
|
||||
|
||||
1. 页面第一屏就是纯会话列表。
|
||||
2. `主 Agent` 和 `审计对话` 位于最上方。
|
||||
3. 普通会话按聊天工具列表排列,不再掺杂控制台说明。
|
||||
4. 每个会话 item 固定包含:
|
||||
- 左侧头像
|
||||
- 主标题:线程名或群名
|
||||
- 指定副信息行:文件夹名
|
||||
- 最后一条消息预览
|
||||
- 最后一次对话时间
|
||||
- 右下动态小图标,表示后台使用数量
|
||||
5. 不再在列表主行显示线程预算、设备配额、风险摘要等重字段。
|
||||
|
||||
### 7.1 文件夹名显示位置
|
||||
|
||||
用户指定的文件夹名显示位置是聊天列表 item 中主标题下方的副信息行,位于最后一条消息预览之上或其同一信息层级位置,必须与用户给出的截图位置一致。
|
||||
|
||||
### 7.2 后台数量图标
|
||||
|
||||
规则如下:
|
||||
|
||||
1. 只显示动态小图标,不显示数字文本,不显示说明文本。
|
||||
2. 图标位置在当前最后一次对话时间下方的右下区域。
|
||||
3. 图标表达的是当前线程后台使用数量。
|
||||
4. 不在列表里展示“后台窗口名”或“后台窗口描述”。
|
||||
|
||||
## 8. 聊天页设计
|
||||
|
||||
### 8.1 单线程聊天页
|
||||
|
||||
单线程聊天页按微信式聊天页构成:
|
||||
|
||||
1. 顶部栏:
|
||||
- 左返回
|
||||
- 中间标题:线程名
|
||||
- 右上角入口
|
||||
2. 顶部轻入口:
|
||||
- `项目目标`
|
||||
- `版本迭代记录`
|
||||
3. 中部主体:
|
||||
- 纯消息流
|
||||
4. 底部输入区:
|
||||
- 文本输入为主
|
||||
- 图片 / 视频 / 转发作为轻入口
|
||||
|
||||
不允许在主聊天页继续出现:
|
||||
|
||||
- 运维大面板
|
||||
- 线程预算块
|
||||
- handoff 摘要块
|
||||
- 控制台式状态卡
|
||||
|
||||
### 8.2 群聊页
|
||||
|
||||
群聊页整体结构与单线程聊天页保持一致,但:
|
||||
|
||||
1. 标题显示群名。
|
||||
2. 头像显示群头像组合。
|
||||
3. 右上角进入群资料页。
|
||||
4. 群成员消息需区分来源线程。
|
||||
5. 主 Agent 在群里承担发起和监督角色。
|
||||
|
||||
## 9. 改名逻辑
|
||||
|
||||
改名行为必须遵循微信最新逻辑,而不是长按改名、左滑改名等旧交互。
|
||||
|
||||
### 9.1 线程改名
|
||||
|
||||
流程:
|
||||
|
||||
1. 进入单线程聊天页。
|
||||
2. 点击右上角进入会话信息页。
|
||||
3. 在会话信息页中修改线程名。
|
||||
4. 保存后同步更新:
|
||||
- APP 本地显示
|
||||
- 服务端状态账本
|
||||
- 对应 Codex 线程名称
|
||||
|
||||
### 9.2 群聊改名
|
||||
|
||||
流程:
|
||||
|
||||
1. 进入群聊页。
|
||||
2. 点击右上角进入群聊信息页。
|
||||
3. 在群聊信息页中修改群名。
|
||||
4. 保存后更新群聊会话显示。
|
||||
|
||||
## 10. 群聊创建与沟通规则
|
||||
|
||||
### 10.1 发起方式
|
||||
|
||||
群聊创建从聊天页右上角 `+` 发起:
|
||||
|
||||
1. 从单线程聊天页点击右上角 `+`
|
||||
2. 选择 `发起群聊`
|
||||
3. 进入线程选择页
|
||||
4. 选择任意设备里的任意线程
|
||||
5. 自动创建一个新的独立群聊
|
||||
6. 自动生成默认群名
|
||||
7. 创建成功后进入新群聊页
|
||||
|
||||
### 10.2 群聊会话与原会话关系
|
||||
|
||||
规则:
|
||||
|
||||
1. 新建群聊后,原来的单线程会话保留。
|
||||
2. 新群聊作为独立聊天窗口出现在会话列表。
|
||||
3. 群聊不会覆盖原线程会话。
|
||||
|
||||
### 10.3 群聊治理规则
|
||||
|
||||
群聊中的线程协作遵循以下规则:
|
||||
|
||||
1. 开发任务过程中:
|
||||
- 允许不同线程直接在群聊内对话和协作。
|
||||
2. 非开发任务状态下:
|
||||
- 线程之间不能直接自由对话。
|
||||
- 如需对话,必须先请求主 Agent。
|
||||
- 主 Agent 再向用户请求是否批准两个线程继续沟通。
|
||||
- 用户批准后,线程之间才可继续对话。
|
||||
|
||||
### 10.4 主 Agent 角色
|
||||
|
||||
主 Agent 在群聊中的角色是:
|
||||
|
||||
1. 群聊发起者
|
||||
2. 群聊监督者
|
||||
3. 线程间对话的规则执行者
|
||||
|
||||
本次先落会话结构、入口和规则,不在本次设计内细化主 Agent 的最终执行策略。
|
||||
|
||||
## 11. 深层页补齐策略
|
||||
|
||||
导出图未直接覆盖的页面,也必须统一到这套 UI 语言:
|
||||
|
||||
- AI 账号
|
||||
- 技能
|
||||
- 设置
|
||||
- 账号与安全
|
||||
- 运维与修复
|
||||
- 审计对话
|
||||
- 线程信息 / 会话信息 / 群聊信息
|
||||
- 群聊成员选择页
|
||||
|
||||
补齐原则:
|
||||
|
||||
1. 统一白底、浅卡片、轻按钮风格。
|
||||
2. 统一列表式、表单式和信息页结构。
|
||||
3. 不再使用控制台式高密度状态块。
|
||||
4. 即使信息复杂,也优先拆成列表项或二级页,而不是堆在一个页面里。
|
||||
|
||||
## 12. 返回逻辑与导航逻辑
|
||||
|
||||
### 12.1 一级导航
|
||||
|
||||
底部 tab 固定显示:
|
||||
|
||||
- 会话
|
||||
- 设备
|
||||
- 我的
|
||||
|
||||
### 12.2 返回规则
|
||||
|
||||
规则如下:
|
||||
|
||||
1. 从二级页返回,回到上一页。
|
||||
2. 从聊天页返回,回到会话列表。
|
||||
3. 从设备详情返回,回到设备列表。
|
||||
4. 从我的二级页返回,回到我的首页。
|
||||
5. 在根级 tab 页按返回时,先回 `会话` tab。
|
||||
6. 已在 `会话` 根页时,再按一次返回进入后台。
|
||||
|
||||
## 13. 数据与同步要求
|
||||
|
||||
### 13.1 线程与会话映射
|
||||
|
||||
必须建立清晰映射:
|
||||
|
||||
- 设备
|
||||
- 文件夹
|
||||
- 线程
|
||||
- 会话窗口
|
||||
|
||||
要求:
|
||||
|
||||
1. 一个线程映射一个单线程会话。
|
||||
2. 一个文件夹下多个线程映射多个独立会话。
|
||||
3. 会话列表必须能拿到线程名、文件夹名、最后消息、最后时间、后台使用数量。
|
||||
|
||||
### 13.2 改名同步
|
||||
|
||||
线程改名必须同时影响:
|
||||
|
||||
1. APP 会话标题
|
||||
2. 服务端状态
|
||||
3. 对应 Codex 线程名称
|
||||
|
||||
### 13.3 群聊同步
|
||||
|
||||
群聊需要持久化:
|
||||
|
||||
1. 群聊 ID
|
||||
2. 群名
|
||||
3. 群成员线程列表
|
||||
4. 群创建者
|
||||
5. 群内规则状态
|
||||
6. 是否为开发任务状态
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
本次工作完成后,必须满足以下验收条件:
|
||||
|
||||
1. 图中已有页面可与导出图逐页对照,达到 1:1 视觉还原级别。
|
||||
2. 图中未出现但必须保留的页面,也统一成同一套微信式轻界面。
|
||||
3. 会话列表主标题显示线程名,指定副信息位显示文件夹名。
|
||||
4. 每个会话右下角都显示动态后台数量图标。
|
||||
5. `主 Agent` 和 `审计对话` 置顶,但视觉仍为普通会话。
|
||||
6. 聊天页右上角遵循微信最新逻辑,支持进入信息页改名。
|
||||
7. 线程改名后,Codex 对应线程也同步改名。
|
||||
8. 支持从聊天页右上角 `+` 发起群聊。
|
||||
9. 群聊为独立会话,保留原单线程会话。
|
||||
10. 群聊支持自动命名、后续改名、群头像组合显示。
|
||||
11. 开发任务状态与非开发任务状态下的线程沟通规则已落地。
|
||||
12. 主聊天面、设备面、我的页不再出现控制台式大面板。
|
||||
|
||||
## 15. 风险与约束
|
||||
|
||||
1. 导出图是主视觉基准,但不是全部页面的完整交互稿,因此深层页需要在同一套风格内补齐,而不是照当前实现继续延展。
|
||||
2. 线程名同步到 Codex 线程需要可靠的线程标识和回写能力,否则会产生 APP 名称与 Codex 名称不一致的问题。
|
||||
3. 群聊引入后,单线程会话、群聊会话和主 Agent / 审计对话会并存,需要明确排序、置顶和未读规则。
|
||||
4. 主 Agent 对线程间对话的审批与监督规则本次先定义边界,不在本次设计中进一步算法化。
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"artifactType": "aab",
|
||||
"fileName": "boss-android-v2.1.1-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.1.1-release.aab",
|
||||
"sizeBytes": 2853490,
|
||||
"updatedAt": "2026-03-26T15:51:15Z",
|
||||
"sha256": "c67341ca50d219e3d75baa6c88520c11d475611bed33237710d4518f292779c9",
|
||||
"versionName": "2.1.1",
|
||||
"versionCode": 8,
|
||||
"fileName": "boss-android-v2.4.0-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.4.0-release.aab",
|
||||
"sizeBytes": 2894817,
|
||||
"updatedAt": "2026-03-28T00:59:18Z",
|
||||
"sha256": "beaca830a470bb5af180cb75dff60fc9e2039f10214480ae0cd7503bd793af22",
|
||||
"versionName": "2.4.0",
|
||||
"versionCode": 12,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fileName": "boss-android-v2.1.1-release.apk",
|
||||
"fileName": "boss-android-v2.4.0-release.apk",
|
||||
"urlPath": "/api/v1/user/ota/package",
|
||||
"sizeBytes": 3032808,
|
||||
"updatedAt": "2026-03-26T15:51:10Z",
|
||||
"sha256": "453412d605ad2cd3b1cabf806752d1288a8b23f1c61900b007468a264dda3459",
|
||||
"versionName": "2.1.1",
|
||||
"versionCode": 8,
|
||||
"sizeBytes": 3071649,
|
||||
"updatedAt": "2026-03-28T00:59:14Z",
|
||||
"sha256": "34831f13e8458fe668f7358c6a0d37d39430d3b8002a6c998b445b40f99e3672",
|
||||
"versionName": "2.4.0",
|
||||
"versionCode": 12,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
BIN
public/downloads/boss-android-v2.2.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.2.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.2.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.2.0-release.apk
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.2.1-release.aab
Normal file
BIN
public/downloads/boss-android-v2.2.1-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.2.1-release.apk
Normal file
BIN
public/downloads/boss-android-v2.2.1-release.apk
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.3.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.3.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.3.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.3.0-release.apk
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.4.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.4.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.4.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.4.0-release.apk
Normal file
Binary file not shown.
@@ -2,6 +2,18 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { forwardProjectMessage } from "@/lib/boss-data";
|
||||
|
||||
type ForwardBody =
|
||||
| {
|
||||
mode?: "single";
|
||||
targetProjectId?: string;
|
||||
sourceMessageId?: string;
|
||||
}
|
||||
| {
|
||||
mode?: "bundle";
|
||||
targetProjectId?: string;
|
||||
sourceMessageIds?: string[];
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
@@ -11,25 +23,60 @@ export async function POST(
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
targetProjectId?: string;
|
||||
note?: string;
|
||||
};
|
||||
const body = (await request.json()) as ForwardBody;
|
||||
|
||||
if (!body.targetProjectId || !body.note) {
|
||||
const mode = body.mode ?? "single";
|
||||
const targetProjectId = body.targetProjectId;
|
||||
const sourceMessageId: string | undefined =
|
||||
"sourceMessageId" in body ? body.sourceMessageId : undefined;
|
||||
const sourceMessageIds: string[] =
|
||||
"sourceMessageIds" in body && Array.isArray(body.sourceMessageIds)
|
||||
? body.sourceMessageIds
|
||||
: [];
|
||||
|
||||
if (!targetProjectId) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "缺少 targetProjectId 或 note" },
|
||||
{ ok: false, message: "缺少 targetProjectId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (mode === "bundle") {
|
||||
if (sourceMessageIds.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "bundle 转发至少需要 2 条 sourceMessageIds" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else if (!sourceMessageId) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "single 转发缺少 sourceMessageId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await forwardProjectMessage({
|
||||
sourceProjectId: projectId,
|
||||
targetProjectId: body.targetProjectId,
|
||||
note: body.note,
|
||||
const result =
|
||||
mode === "bundle"
|
||||
? await forwardProjectMessage({
|
||||
sourceProjectId: projectId,
|
||||
mode: "bundle",
|
||||
targetProjectId,
|
||||
sourceMessageIds,
|
||||
requestedBy: session.account,
|
||||
})
|
||||
: await forwardProjectMessage({
|
||||
sourceProjectId: projectId,
|
||||
mode: "single",
|
||||
targetProjectId,
|
||||
sourceMessageId: sourceMessageId ?? "",
|
||||
requestedBy: session.account,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: result.message ?? null,
|
||||
approvalRequired: Boolean(result.approvalRequired),
|
||||
approvalReason: result.approvalReason ?? null,
|
||||
});
|
||||
return NextResponse.json({ ok: true, message });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
|
||||
34
src/app/api/v1/projects/[projectId]/group-chat/route.ts
Normal file
34
src/app/api/v1/projects/[projectId]/group-chat/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { createProjectGroupChat } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
memberProjectIds?: string[];
|
||||
};
|
||||
|
||||
try {
|
||||
const project = await createProjectGroupChat({
|
||||
sourceProjectId: projectId,
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage } from "@/lib/boss-data";
|
||||
import { appendProjectMessage, readState } from "@/lib/boss-data";
|
||||
import { replyToMasterAgentUserMessage } from "@/lib/boss-master-agent";
|
||||
|
||||
export async function POST(
|
||||
@@ -38,7 +38,24 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, message, masterReply });
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
const collaborationGate = project
|
||||
? {
|
||||
isGroup: project.isGroup,
|
||||
collaborationMode: project.collaborationMode,
|
||||
requiresMasterAgentApproval:
|
||||
project.isGroup && project.collaborationMode === "approval_required",
|
||||
approvalState: project.approvalState,
|
||||
}
|
||||
: {
|
||||
isGroup: false,
|
||||
collaborationMode: "development" as const,
|
||||
requiresMasterAgentApproval: false,
|
||||
approvalState: "not_required" as const,
|
||||
};
|
||||
|
||||
return NextResponse.json({ ok: true, message, masterReply, collaborationGate });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
|
||||
102
src/app/api/v1/projects/[projectId]/participants/route.ts
Normal file
102
src/app/api/v1/projects/[projectId]/participants/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
type ConversationParticipant = {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
folderName: string;
|
||||
avatar: string;
|
||||
isSourceProject: boolean;
|
||||
};
|
||||
|
||||
function getFallbackAvatar(label: string) {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return "A";
|
||||
return trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function buildParticipant(
|
||||
projectId: string,
|
||||
deviceId: string,
|
||||
threadId: string,
|
||||
threadDisplayName: string,
|
||||
folderName: string,
|
||||
avatar?: string,
|
||||
isSourceProject = false,
|
||||
): ConversationParticipant {
|
||||
return {
|
||||
projectId,
|
||||
deviceId,
|
||||
threadId,
|
||||
threadDisplayName,
|
||||
folderName,
|
||||
avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName),
|
||||
isSourceProject,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const participants = project.isGroup
|
||||
? (project.groupMembers.length > 0
|
||||
? project.groupMembers.map((member) => {
|
||||
const device = state.devices.find((item) => item.id === member.deviceId);
|
||||
return buildParticipant(
|
||||
member.projectId,
|
||||
member.deviceId,
|
||||
member.threadId,
|
||||
member.threadDisplayName,
|
||||
member.folderName,
|
||||
device?.avatar,
|
||||
member.projectId === project.id,
|
||||
);
|
||||
})
|
||||
: [
|
||||
buildParticipant(
|
||||
project.id,
|
||||
project.deviceIds[0] ?? project.id,
|
||||
project.threadMeta.threadId,
|
||||
project.threadMeta.threadDisplayName,
|
||||
project.threadMeta.folderName,
|
||||
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
|
||||
true,
|
||||
),
|
||||
])
|
||||
: [
|
||||
buildParticipant(
|
||||
project.id,
|
||||
project.deviceIds[0] ?? project.id,
|
||||
project.threadMeta.threadId,
|
||||
project.threadMeta.threadDisplayName,
|
||||
project.threadMeta.folderName,
|
||||
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
projectId: project.id,
|
||||
isGroup: project.isGroup,
|
||||
threadMeta: project.threadMeta,
|
||||
participants,
|
||||
});
|
||||
}
|
||||
45
src/app/api/v1/projects/[projectId]/rename/route.ts
Normal file
45
src/app/api/v1/projects/[projectId]/rename/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { renameGroupChat, renameProjectThread } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
mode?: "thread" | "group";
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const name = body.name?.trim();
|
||||
if (!name) {
|
||||
return NextResponse.json({ ok: false, message: "EMPTY_NAME" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const project =
|
||||
body.mode === "group"
|
||||
? await renameGroupChat({
|
||||
projectId,
|
||||
name,
|
||||
requestedBy: session.account,
|
||||
})
|
||||
: await renameProjectThread({
|
||||
projectId,
|
||||
threadDisplayName: name,
|
||||
requestedBy: 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,45 @@ export type DeviceStatus = "online" | "abnormal" | "offline";
|
||||
export type DeviceSource = "production" | "demo";
|
||||
export type GoalState = "pending" | "completed";
|
||||
export type MessageSender = "master" | "device" | "user" | "ops" | "audit";
|
||||
// Forwarding uses a structured contract so the route can distinguish
|
||||
// single-message forwarding, bundle forwarding, and the legacy notice shape.
|
||||
export type MessageKind =
|
||||
| "text"
|
||||
| "voice_intent"
|
||||
| "image_intent"
|
||||
| "video_intent"
|
||||
| "forward_notice";
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle";
|
||||
|
||||
export interface ForwardSource {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
sourceMessageId: string;
|
||||
forwardedBy: string;
|
||||
forwardedAt: string;
|
||||
}
|
||||
|
||||
export interface ForwardBundleItem {
|
||||
messageId: string;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
kind: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
export interface ForwardBundlePayload {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
itemCount: number;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
items: ForwardBundleItem[];
|
||||
}
|
||||
export type ContextBudgetLevel = "safe" | "watch" | "urgent" | "critical";
|
||||
export type ThreadState =
|
||||
| "idle"
|
||||
@@ -113,6 +146,8 @@ export interface Message {
|
||||
body: string;
|
||||
sentAt: string;
|
||||
kind?: MessageKind;
|
||||
forwardSource?: ForwardSource;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
}
|
||||
|
||||
export interface GoalItem {
|
||||
@@ -130,6 +165,25 @@ export interface VersionEntry {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadConversationMeta {
|
||||
projectId: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
folderName: string;
|
||||
activityIconCount: number;
|
||||
updatedAt: string;
|
||||
codexThreadRef?: string;
|
||||
codexFolderRef?: string;
|
||||
}
|
||||
|
||||
export interface GroupConversationMember {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
folderName: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -140,6 +194,11 @@ export interface Project {
|
||||
updatedAt: string;
|
||||
lastMessageAt: string;
|
||||
isGroup: boolean;
|
||||
threadMeta: ThreadConversationMeta;
|
||||
groupMembers: GroupConversationMember[];
|
||||
createdByAgent: boolean;
|
||||
collaborationMode: "development" | "approval_required";
|
||||
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
|
||||
unreadCount: number;
|
||||
riskLevel: RiskLevel;
|
||||
contextBudgetPct?: number;
|
||||
@@ -698,6 +757,20 @@ const initialState: BossState = {
|
||||
updatedAt: "2026-03-25T12:06:00+08:00",
|
||||
lastMessageAt: "2026-03-25T12:06:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "master-agent",
|
||||
threadId: "thread-master-main",
|
||||
threadDisplayName: "主 Agent 汇总",
|
||||
folderName: "主控线程",
|
||||
activityIconCount: 1,
|
||||
updatedAt: "2026-03-25T12:06:00+08:00",
|
||||
codexThreadRef: "thread-master-main",
|
||||
codexFolderRef: "master-agent",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "medium",
|
||||
contextBudgetPct: 71,
|
||||
@@ -724,6 +797,20 @@ const initialState: BossState = {
|
||||
updatedAt: "2026-03-25T11:52:00+08:00",
|
||||
lastMessageAt: "2026-03-25T11:52:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "boss-console",
|
||||
threadId: "thread-boss-ui",
|
||||
threadDisplayName: "北区试产线回归",
|
||||
folderName: "归档确认",
|
||||
activityIconCount: 1,
|
||||
updatedAt: "2026-03-25T11:52:00+08:00",
|
||||
codexThreadRef: "thread-boss-ui",
|
||||
codexFolderRef: "boss-console",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 2,
|
||||
riskLevel: "medium",
|
||||
contextBudgetPct: 62,
|
||||
@@ -795,6 +882,35 @@ const initialState: BossState = {
|
||||
updatedAt: "2026-03-25T10:58:00+08:00",
|
||||
lastMessageAt: "2026-03-25T10:58:00+08:00",
|
||||
isGroup: true,
|
||||
threadMeta: {
|
||||
projectId: "audit-collab",
|
||||
threadId: "thread-audit-chief",
|
||||
threadDisplayName: "审计对话",
|
||||
folderName: "审计群聊",
|
||||
activityIconCount: 2,
|
||||
updatedAt: "2026-03-25T10:58:00+08:00",
|
||||
codexThreadRef: "thread-audit-chief",
|
||||
codexFolderRef: "audit-collab",
|
||||
},
|
||||
groupMembers: [
|
||||
{
|
||||
projectId: "audit-collab",
|
||||
deviceId: "mac-studio",
|
||||
threadId: "thread-audit-chief",
|
||||
threadDisplayName: "审计对话",
|
||||
folderName: "审计群聊",
|
||||
},
|
||||
{
|
||||
projectId: "audit-collab",
|
||||
deviceId: "win-gpu-01",
|
||||
threadId: "thread-audit-hardware",
|
||||
threadDisplayName: "Windows 摄像头证据",
|
||||
folderName: "审计群聊",
|
||||
},
|
||||
],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 1,
|
||||
riskLevel: "high",
|
||||
messages: [
|
||||
@@ -1267,6 +1383,140 @@ function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeThreadMeta(
|
||||
raw: Partial<ThreadConversationMeta> | undefined,
|
||||
project: { id: string; name: string; isGroup: boolean; updatedAt: string },
|
||||
fallback?: ThreadConversationMeta,
|
||||
): ThreadConversationMeta {
|
||||
return {
|
||||
projectId: raw?.projectId ?? project.id,
|
||||
threadId: raw?.threadId ?? `thread-${project.id}`,
|
||||
threadDisplayName: raw?.threadDisplayName ?? project.name,
|
||||
folderName: raw?.folderName ?? fallback?.folderName ?? (project.isGroup ? "群聊" : project.name),
|
||||
activityIconCount: Math.max(1, raw?.activityIconCount ?? fallback?.activityIconCount ?? (project.isGroup ? 2 : 1)),
|
||||
updatedAt: raw?.updatedAt ?? project.updatedAt ?? nowIso(),
|
||||
codexThreadRef: raw?.codexThreadRef,
|
||||
codexFolderRef: raw?.codexFolderRef,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGroupMember(
|
||||
raw: Partial<GroupConversationMember>,
|
||||
fallbackProjectId: string,
|
||||
fallbackThreadMeta: ThreadConversationMeta,
|
||||
): GroupConversationMember {
|
||||
return {
|
||||
projectId: raw.projectId ?? fallbackProjectId,
|
||||
deviceId: raw.deviceId ?? "",
|
||||
threadId: raw.threadId ?? fallbackThreadMeta.threadId,
|
||||
threadDisplayName: raw.threadDisplayName ?? fallbackThreadMeta.threadDisplayName,
|
||||
folderName: raw.folderName ?? fallbackThreadMeta.folderName,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[]) {
|
||||
return [...new Set(values.filter((value) => Boolean(value)))];
|
||||
}
|
||||
|
||||
function dedupeGroupMembers(members: GroupConversationMember[]) {
|
||||
const seen = new Set<string>();
|
||||
const deduped: GroupConversationMember[] = [];
|
||||
for (const member of members) {
|
||||
const key = `${member.projectId}:${member.deviceId}:${member.threadId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(member);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function buildLegacyGroupMembers(
|
||||
projectId: string,
|
||||
deviceIds: string[],
|
||||
threadMeta: ThreadConversationMeta,
|
||||
) {
|
||||
return dedupeStrings(deviceIds).map((deviceId, index) => ({
|
||||
projectId,
|
||||
deviceId,
|
||||
threadId:
|
||||
index === 0 ? threadMeta.threadId : `${threadMeta.threadId}:${slugify(deviceId)}`,
|
||||
threadDisplayName: threadMeta.threadDisplayName,
|
||||
folderName: threadMeta.folderName,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeProjectConversationShape(
|
||||
project: Project,
|
||||
options?: {
|
||||
allowedDeviceIds?: Set<string>;
|
||||
},
|
||||
) {
|
||||
const allowedDeviceIds = options?.allowedDeviceIds;
|
||||
const normalizedThreadMeta = {
|
||||
...project.threadMeta,
|
||||
projectId: project.id,
|
||||
};
|
||||
const normalizedExplicitMembers = dedupeGroupMembers(
|
||||
project.groupMembers.map((member) =>
|
||||
normalizeGroupMember(member, project.id, normalizedThreadMeta),
|
||||
),
|
||||
);
|
||||
const hasExplicitGroupMembers = normalizedExplicitMembers.length > 0;
|
||||
const legacyGroupRequested = !hasExplicitGroupMembers && project.isGroup;
|
||||
const resolvedGroupMembers = hasExplicitGroupMembers
|
||||
? normalizedExplicitMembers
|
||||
: legacyGroupRequested
|
||||
? buildLegacyGroupMembers(project.id, project.deviceIds, normalizedThreadMeta)
|
||||
: [];
|
||||
const filteredGroupMembers = allowedDeviceIds
|
||||
? resolvedGroupMembers.filter((member) => allowedDeviceIds.has(member.deviceId))
|
||||
: resolvedGroupMembers;
|
||||
|
||||
if (filteredGroupMembers.length > 0) {
|
||||
project.isGroup = true;
|
||||
project.groupMembers = dedupeGroupMembers(filteredGroupMembers);
|
||||
project.deviceIds = dedupeStrings(project.groupMembers.map((member) => member.deviceId));
|
||||
project.threadMeta = {
|
||||
...normalizedThreadMeta,
|
||||
activityIconCount: Math.max(1, project.groupMembers.length),
|
||||
};
|
||||
return project;
|
||||
}
|
||||
|
||||
project.isGroup = false;
|
||||
project.groupMembers = [];
|
||||
project.deviceIds = allowedDeviceIds
|
||||
? project.deviceIds.filter((deviceId) => allowedDeviceIds.has(deviceId))
|
||||
: project.deviceIds;
|
||||
project.threadMeta = {
|
||||
...normalizedThreadMeta,
|
||||
activityIconCount: Math.max(1, normalizedThreadMeta.activityIconCount ?? 1),
|
||||
};
|
||||
return project;
|
||||
}
|
||||
|
||||
function resolveProjectUpdatedAt(project: Pick<Project, "updatedAt" | "lastMessageAt" | "threadMeta">, latestActivityAt?: string) {
|
||||
return latestIsoTimestamp(
|
||||
project.updatedAt,
|
||||
project.lastMessageAt,
|
||||
project.threadMeta.updatedAt,
|
||||
latestActivityAt,
|
||||
);
|
||||
}
|
||||
|
||||
function latestIsoTimestamp(...values: Array<string | undefined>) {
|
||||
let latestValue: string | undefined;
|
||||
let latestTime = 0;
|
||||
for (const value of values) {
|
||||
const valueTime = messageTimeValue(value);
|
||||
if (valueTime > latestTime) {
|
||||
latestTime = valueTime;
|
||||
latestValue = value;
|
||||
}
|
||||
}
|
||||
return latestValue ?? nowIso();
|
||||
}
|
||||
|
||||
function ensureArray<T>(value: T[] | undefined, fallback: T[]) {
|
||||
return Array.isArray(value) ? value : fallback;
|
||||
}
|
||||
@@ -1592,12 +1842,24 @@ function normalizeMessage(raw: Partial<Message>): Message {
|
||||
body: raw.body ?? "",
|
||||
sentAt: raw.sentAt ?? nowIso(),
|
||||
kind: raw.kind ?? "text",
|
||||
forwardSource: raw.forwardSource,
|
||||
forwardBundle: raw.forwardBundle,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
|
||||
const base = fallback ?? cloneInitialState().projects[0];
|
||||
return {
|
||||
const projectId = raw.id ?? base.id;
|
||||
const projectName = raw.name ?? base.name;
|
||||
const projectUpdatedAt = latestIsoTimestamp(raw.updatedAt, raw.lastMessageAt, base.updatedAt, base.lastMessageAt);
|
||||
const threadMetaFallback = fallback?.id === projectId ? fallback.threadMeta : undefined;
|
||||
const threadMeta = normalizeThreadMeta(raw.threadMeta, {
|
||||
id: projectId,
|
||||
name: projectName,
|
||||
isGroup: raw.isGroup ?? base.isGroup ?? false,
|
||||
updatedAt: projectUpdatedAt,
|
||||
}, threadMetaFallback);
|
||||
const project: Project = {
|
||||
...base,
|
||||
...raw,
|
||||
pinned: raw.pinned ?? base.pinned,
|
||||
@@ -1620,7 +1882,17 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
|
||||
summary: version.summary ?? "",
|
||||
createdAt: version.createdAt ?? nowIso(),
|
||||
})),
|
||||
threadMeta,
|
||||
createdByAgent: raw.createdByAgent ?? false,
|
||||
collaborationMode: raw.collaborationMode ?? "development",
|
||||
approvalState: raw.approvalState ?? "not_required",
|
||||
};
|
||||
project.groupMembers = ensureArray(raw.groupMembers, []).map((member) =>
|
||||
normalizeGroupMember(member, projectId, project.threadMeta),
|
||||
);
|
||||
normalizeProjectConversationShape(project);
|
||||
project.updatedAt = resolveProjectUpdatedAt(project, project.threadMeta.updatedAt);
|
||||
return project;
|
||||
}
|
||||
|
||||
function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
@@ -2253,11 +2525,11 @@ function syncDerivedState(input: BossState) {
|
||||
|
||||
for (const project of state.projects) {
|
||||
project.deviceIds = project.deviceIds.filter((deviceId) => visibleDeviceIds.has(deviceId));
|
||||
project.isGroup = project.deviceIds.length > 1;
|
||||
const projectSnapshots = state.threadContextSnapshots
|
||||
.filter((snapshot) => snapshot.projectId === project.id)
|
||||
.sort(compareSnapshotsForRisk);
|
||||
|
||||
normalizeProjectConversationShape(project, { allowedDeviceIds: visibleDeviceIds });
|
||||
project.riskLevel = deriveRiskFromSnapshots(projectSnapshots);
|
||||
if (project.isGroup) {
|
||||
project.contextBudgetPct = undefined;
|
||||
@@ -2271,7 +2543,7 @@ function syncDerivedState(input: BossState) {
|
||||
}
|
||||
|
||||
project.lastMessageAt = latestProjectTimestamp(state, project.id);
|
||||
project.updatedAt = project.lastMessageAt;
|
||||
project.updatedAt = resolveProjectUpdatedAt(project, project.lastMessageAt);
|
||||
project.preview = deriveProjectPreview(state, project);
|
||||
project.unreadCount = Math.max(0, project.unreadCount ?? 0);
|
||||
}
|
||||
@@ -3826,6 +4098,150 @@ export async function updateConversationAction(
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function renameProjectThread(input: {
|
||||
projectId: string;
|
||||
threadDisplayName: string;
|
||||
requestedBy: string;
|
||||
}) {
|
||||
const threadDisplayName = input.threadDisplayName.trim();
|
||||
if (!threadDisplayName) {
|
||||
throw new Error("THREAD_DISPLAY_NAME_REQUIRED");
|
||||
}
|
||||
|
||||
const project = await mutateState((state) => {
|
||||
const nextProject = state.projects.find((item) => item.id === input.projectId);
|
||||
if (!nextProject) throw new Error("PROJECT_NOT_FOUND");
|
||||
if (nextProject.isGroup) throw new Error("PROJECT_IS_GROUP_CHAT");
|
||||
|
||||
const updatedAt = nowIso();
|
||||
nextProject.name = threadDisplayName;
|
||||
nextProject.threadMeta.threadDisplayName = threadDisplayName;
|
||||
nextProject.threadMeta.updatedAt = updatedAt;
|
||||
nextProject.updatedAt = updatedAt;
|
||||
return nextProject;
|
||||
});
|
||||
publishBossEvent("conversation.updated", {
|
||||
projectId: input.projectId,
|
||||
note: `renamed by ${input.requestedBy}`,
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function renameGroupChat(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
requestedBy: string;
|
||||
}) {
|
||||
const name = input.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("GROUP_CHAT_NAME_REQUIRED");
|
||||
}
|
||||
|
||||
const project = await mutateState((state) => {
|
||||
const nextProject = state.projects.find((item) => item.id === input.projectId);
|
||||
if (!nextProject) throw new Error("PROJECT_NOT_FOUND");
|
||||
if (!nextProject.isGroup) throw new Error("PROJECT_NOT_GROUP_CHAT");
|
||||
|
||||
const updatedAt = nowIso();
|
||||
nextProject.name = name;
|
||||
nextProject.threadMeta.threadDisplayName = name;
|
||||
nextProject.threadMeta.updatedAt = updatedAt;
|
||||
nextProject.updatedAt = updatedAt;
|
||||
return nextProject;
|
||||
});
|
||||
publishBossEvent("conversation.updated", {
|
||||
projectId: input.projectId,
|
||||
note: `renamed by ${input.requestedBy}`,
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function createProjectGroupChat(input: {
|
||||
sourceProjectId: string;
|
||||
memberProjectIds: string[];
|
||||
createdBy: string;
|
||||
}) {
|
||||
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: [],
|
||||
});
|
||||
|
||||
state.projects.unshift(nextProject);
|
||||
return nextProject;
|
||||
});
|
||||
publishBossEvent("project.messages.updated", { projectId: project.id });
|
||||
publishBossEvent("conversation.updated", { projectId: project.id });
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function appendProjectMessage(payload: {
|
||||
projectId: string;
|
||||
sender?: MessageSender;
|
||||
@@ -3871,49 +4287,198 @@ export async function appendProjectMessage(payload: {
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function forwardProjectMessage(payload: {
|
||||
sourceProjectId: string;
|
||||
targetProjectId: string;
|
||||
note: string;
|
||||
function findProjectMessage(project: Project, messageId: string) {
|
||||
return project.messages.find((message) => message.id === messageId) ?? null;
|
||||
}
|
||||
|
||||
function requiresForwardApproval(source: Project, target: Project) {
|
||||
return source.collaborationMode === "approval_required" && target.id !== "master-agent";
|
||||
}
|
||||
|
||||
function buildForwardSingleMessage(input: {
|
||||
source: Project;
|
||||
target: Project;
|
||||
message: Message;
|
||||
requestedBy: string;
|
||||
}) {
|
||||
const sentAt = nowIso();
|
||||
const body = `转发自《${input.source.name}》到《${input.target.name}》:${input.message.body}`;
|
||||
return {
|
||||
id: randomToken("forward"),
|
||||
sender: "user" as const,
|
||||
senderLabel: "你",
|
||||
body,
|
||||
sentAt,
|
||||
kind: "forward_single" as const,
|
||||
forwardSource: {
|
||||
sourceProjectId: input.source.id,
|
||||
sourceProjectName: input.source.name,
|
||||
sourceThreadId: input.source.threadMeta?.threadId,
|
||||
sourceThreadTitle: input.source.threadMeta?.threadDisplayName,
|
||||
sourceMessageId: input.message.id,
|
||||
forwardedBy: input.requestedBy,
|
||||
forwardedAt: sentAt,
|
||||
},
|
||||
} satisfies Message;
|
||||
}
|
||||
|
||||
function buildForwardBundleMessage(input: {
|
||||
source: Project;
|
||||
target: Project;
|
||||
messages: Message[];
|
||||
requestedBy: string;
|
||||
}) {
|
||||
const sentAt = nowIso();
|
||||
const startedAt = input.messages[0]?.sentAt ?? sentAt;
|
||||
const endedAt = input.messages[input.messages.length - 1]?.sentAt ?? sentAt;
|
||||
const body = `转发自《${input.source.name}》到《${input.target.name}》:${input.messages.length} 条消息,最后一条:${
|
||||
input.messages[input.messages.length - 1]?.body ?? ""
|
||||
}`;
|
||||
return {
|
||||
id: randomToken("forward"),
|
||||
sender: "user" as const,
|
||||
senderLabel: "你",
|
||||
body,
|
||||
sentAt,
|
||||
kind: "forward_bundle" as const,
|
||||
forwardBundle: {
|
||||
sourceProjectId: input.source.id,
|
||||
sourceProjectName: input.source.name,
|
||||
sourceThreadId: input.source.threadMeta?.threadId,
|
||||
sourceThreadTitle: input.source.threadMeta?.threadDisplayName,
|
||||
itemCount: input.messages.length,
|
||||
startedAt,
|
||||
endedAt,
|
||||
items: input.messages.map((message) => ({
|
||||
messageId: message.id,
|
||||
senderLabel: message.senderLabel,
|
||||
body: message.body,
|
||||
kind: message.kind ?? "text",
|
||||
sentAt: message.sentAt,
|
||||
})),
|
||||
},
|
||||
} satisfies Message;
|
||||
}
|
||||
|
||||
export async function forwardProjectMessage(
|
||||
payload:
|
||||
| {
|
||||
sourceProjectId: string;
|
||||
mode: "single";
|
||||
targetProjectId: string;
|
||||
sourceMessageId: string;
|
||||
requestedBy: string;
|
||||
}
|
||||
| {
|
||||
sourceProjectId: string;
|
||||
mode: "bundle";
|
||||
targetProjectId: string;
|
||||
sourceMessageIds: string[];
|
||||
requestedBy: string;
|
||||
},
|
||||
) {
|
||||
const state = await readState();
|
||||
const source = state.projects.find((item) => item.id === payload.sourceProjectId);
|
||||
const target = state.projects.find((item) => item.id === payload.targetProjectId);
|
||||
if (!source || !target) throw new Error("PROJECT_NOT_FOUND");
|
||||
if (requiresForwardApproval(source, target)) {
|
||||
return {
|
||||
approvalRequired: true,
|
||||
approvalReason: "NON_DEVELOPMENT_THREAD_FORWARD",
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (payload.mode === "single") {
|
||||
const sourceMessage = findProjectMessage(source, payload.sourceMessageId);
|
||||
if (!sourceMessage) throw new Error("MESSAGE_NOT_FOUND");
|
||||
|
||||
const message = await mutateState((state) => {
|
||||
const sourceProject = state.projects.find((item) => item.id === payload.sourceProjectId);
|
||||
const targetProject = state.projects.find((item) => item.id === payload.targetProjectId);
|
||||
if (!sourceProject || !targetProject) throw new Error("PROJECT_NOT_FOUND");
|
||||
const sourceLedgerMessage = findProjectMessage(sourceProject, payload.sourceMessageId);
|
||||
if (!sourceLedgerMessage) throw new Error("MESSAGE_NOT_FOUND");
|
||||
|
||||
const message = buildForwardSingleMessage({
|
||||
source: sourceProject,
|
||||
target: targetProject,
|
||||
message: sourceLedgerMessage,
|
||||
requestedBy: payload.requestedBy,
|
||||
});
|
||||
|
||||
targetProject.messages.push(message);
|
||||
targetProject.unreadCount += 1;
|
||||
targetProject.lastMessageAt = message.sentAt;
|
||||
targetProject.preview = message.body;
|
||||
|
||||
sourceProject.messages.push({
|
||||
id: randomToken("forward-log"),
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: `已转发到《${targetProject.name}》。`,
|
||||
sentAt: message.sentAt,
|
||||
kind: "forward_notice",
|
||||
});
|
||||
sourceProject.lastMessageAt = message.sentAt;
|
||||
return message;
|
||||
});
|
||||
|
||||
publishBossEvent("project.messages.updated", { projectId: payload.sourceProjectId });
|
||||
publishBossEvent("project.messages.updated", { projectId: payload.targetProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: payload.sourceProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: payload.targetProjectId });
|
||||
return { message };
|
||||
}
|
||||
|
||||
const sourceMessageIds = payload.mode === "bundle" ? payload.sourceMessageIds : [];
|
||||
const sourceMessages = sourceMessageIds
|
||||
.map((messageId) => findProjectMessage(source, messageId))
|
||||
.filter((message): message is Message => Boolean(message));
|
||||
if (sourceMessages.length <= 1 || sourceMessages.length !== sourceMessageIds.length) {
|
||||
throw new Error("MESSAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
const message = await mutateState((state) => {
|
||||
const source = state.projects.find((item) => item.id === payload.sourceProjectId);
|
||||
const target = state.projects.find((item) => item.id === payload.targetProjectId);
|
||||
if (!source || !target) throw new Error("PROJECT_NOT_FOUND");
|
||||
if (!payload.note.trim()) throw new Error("FORWARD_NOTE_REQUIRED");
|
||||
const sourceProject = state.projects.find((item) => item.id === payload.sourceProjectId);
|
||||
const targetProject = state.projects.find((item) => item.id === payload.targetProjectId);
|
||||
if (!sourceProject || !targetProject) throw new Error("PROJECT_NOT_FOUND");
|
||||
|
||||
const sentAt = nowIso();
|
||||
const forwardBody = `转发自《${source.name}》:${payload.note.trim()}`;
|
||||
const message: Message = {
|
||||
id: randomToken("forward"),
|
||||
sender: "user",
|
||||
senderLabel: "你",
|
||||
body: forwardBody,
|
||||
sentAt,
|
||||
kind: "forward_notice",
|
||||
};
|
||||
const bundleMessages = sourceMessageIds
|
||||
.map((messageId) => findProjectMessage(sourceProject, messageId))
|
||||
.filter((item): item is Message => Boolean(item));
|
||||
if (bundleMessages.length <= 1 || bundleMessages.length !== sourceMessageIds.length) {
|
||||
throw new Error("MESSAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
target.messages.push(message);
|
||||
target.unreadCount += 1;
|
||||
target.lastMessageAt = sentAt;
|
||||
target.preview = forwardBody;
|
||||
const message = buildForwardBundleMessage({
|
||||
source: sourceProject,
|
||||
target: targetProject,
|
||||
messages: bundleMessages,
|
||||
requestedBy: payload.requestedBy,
|
||||
});
|
||||
|
||||
source.messages.push({
|
||||
targetProject.messages.push(message);
|
||||
targetProject.unreadCount += 1;
|
||||
targetProject.lastMessageAt = message.sentAt;
|
||||
targetProject.preview = message.body;
|
||||
|
||||
sourceProject.messages.push({
|
||||
id: randomToken("forward-log"),
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: `已把消息转发到《${target.name}》。`,
|
||||
sentAt,
|
||||
body: `已转发到《${targetProject.name}》。`,
|
||||
sentAt: message.sentAt,
|
||||
kind: "forward_notice",
|
||||
});
|
||||
source.lastMessageAt = sentAt;
|
||||
sourceProject.lastMessageAt = message.sentAt;
|
||||
return message;
|
||||
});
|
||||
|
||||
publishBossEvent("project.messages.updated", { projectId: payload.sourceProjectId });
|
||||
publishBossEvent("project.messages.updated", { projectId: payload.targetProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: payload.sourceProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: payload.targetProjectId });
|
||||
return message;
|
||||
return { message };
|
||||
}
|
||||
|
||||
export async function updateUserSettings(settings: Partial<UserSettings>) {
|
||||
|
||||
@@ -34,7 +34,12 @@ export interface ConversationItem {
|
||||
conversationType: "master_agent" | "single_device" | "group";
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
threadTitle: string;
|
||||
folderLabel: string;
|
||||
preview: string;
|
||||
lastMessagePreview: string;
|
||||
activityIconCount: number;
|
||||
topPinnedLabel?: "置顶";
|
||||
manualPinned: boolean;
|
||||
latestReplyAt: string;
|
||||
latestReplyLabel: string;
|
||||
@@ -47,6 +52,11 @@ export interface ConversationItem {
|
||||
secondary?: string;
|
||||
overflowCount?: number;
|
||||
};
|
||||
groupMembers?: Array<{
|
||||
threadId: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
}>;
|
||||
contextBudgetIndicator: ContextIndicator;
|
||||
contextBudgetSourceNodeId?: string;
|
||||
contextBudgetUpdatedAt?: string;
|
||||
@@ -170,6 +180,22 @@ function projectType(project: Project): ConversationItem["conversationType"] {
|
||||
return project.isGroup ? "group" : "single_device";
|
||||
}
|
||||
|
||||
function isTopPinnedConversation(project: Project) {
|
||||
return Boolean(project.pinned || project.systemPinned || project.id === "audit-collab");
|
||||
}
|
||||
|
||||
function getThreadAvatarFallback(title: string) {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return "A";
|
||||
return trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function getGroupMemberAvatar(member: Project["groupMembers"][number], device?: Device) {
|
||||
const avatar = device?.avatar?.trim();
|
||||
if (avatar) return avatar;
|
||||
return getThreadAvatarFallback(member.threadDisplayName);
|
||||
}
|
||||
|
||||
function aiRoleLabel(role: AiAccountRole) {
|
||||
switch (role) {
|
||||
case "primary":
|
||||
@@ -281,13 +307,32 @@ export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
|
||||
const threadViews = threadViewsForProject(state, project.id);
|
||||
const topThread = threadViews[0]?.snapshot;
|
||||
const threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
|
||||
const folderLabel = project.threadMeta?.folderName ?? "";
|
||||
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
|
||||
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
|
||||
const groupMembers = project.isGroup
|
||||
? project.groupMembers.map((member) => ({
|
||||
threadId: member.threadId,
|
||||
avatar: getGroupMemberAvatar(
|
||||
member,
|
||||
state.devices.find((device) => device.id === member.deviceId),
|
||||
),
|
||||
title: member.threadDisplayName,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
conversationId: `conv-${project.id}`,
|
||||
conversationType: projectType(project),
|
||||
projectId: project.id,
|
||||
projectTitle: project.name,
|
||||
threadTitle,
|
||||
folderLabel,
|
||||
preview: project.preview,
|
||||
lastMessagePreview: project.preview,
|
||||
activityIconCount,
|
||||
topPinnedLabel,
|
||||
manualPinned: Boolean(project.pinned && !project.systemPinned),
|
||||
latestReplyAt: project.lastMessageAt,
|
||||
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
|
||||
@@ -300,6 +345,7 @@ export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
secondary: project.isGroup ? devices[1]?.avatar : undefined,
|
||||
overflowCount: Math.max(0, devices.length - 2) || undefined,
|
||||
},
|
||||
groupMembers,
|
||||
contextBudgetIndicator: {
|
||||
visible: !project.isGroup && Boolean(topThread),
|
||||
style: "ring_percent",
|
||||
@@ -315,7 +361,9 @@ export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
return conversations.sort((a, b) => {
|
||||
if (a.projectId === "master-agent") return -1;
|
||||
if (b.projectId === "master-agent") return 1;
|
||||
if (a.manualPinned !== b.manualPinned) return a.manualPinned ? -1 : 1;
|
||||
const aPinned = Boolean(a.topPinnedLabel);
|
||||
const bPinned = Boolean(b.topPinnedLabel);
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1;
|
||||
return b.latestReplyAt.localeCompare(a.latestReplyAt);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user