fix(ohos): 未连接后端时给出不可错过的连接入口

问题:全新安装(未配置后端)时,聊天页只有空列表+输入框,用户找不到
任何连后端的入口;设置页的连接入口在列表里也容易被略过。

改动:
- 聊天空态按连接状态分流:未连接显示「尚未连接后端服务」+「去设置连接」
  按钮;已连接显示「开始新的对话」。
- 跨页信号(AppStorage:K_HAS_CONN / K_REQUESTED_TAB / K_SETTINGS_SUB):
  按钮 → Index 切到设置 Tab → SettingsPage 直接打开「后端连接」二级页。
- 设置页在未连接时主动把连接表单推到面前:窄屏 onNavigationModeChange(Stack)
  直接 push,宽屏右栏默认页从「运行状态」改为「后端连接」。
- 连接增删改切后广播 K_HAS_CONN,聊天空态即时切换文案与入口。

已在手机(窄屏)与折叠展开(宽屏)模拟器验证:空态按钮可达、点击后
落到带「+ 添加」的连接表单;冷启动点设置 Tab 亦自动打开连接页。
This commit is contained in:
JianFeeeee
2026-09-15 10:52:48 +08:00
parent acc94723fd
commit a9ad97240b
5 changed files with 140 additions and 5 deletions

View File

@ -17,6 +17,18 @@ export const DEFAULT_WS_PORT: number = 9890;
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
export const CHAT_PAGE_SIZE: number = 40;
// ===== AppStorage 跨页面信号键 =====
//
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
// 中间还隔着 Index,逐层传回调会把两个无关页面耦在一起。
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
export const K_HAS_CONN: string = 'hasConn';
/** 外部请求切换主 Tab(-1 = 无请求),由 Index 监听 */
export const K_REQUESTED_TAB: string = 'requestedTab';
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
// ===== sakura / frost palette (style.css :root) =====
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';

View File

@ -8,7 +8,8 @@
*/
import { ChatMessage } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { SUB_CONNECTIONS } from '../common/SettingsModel';
import { navBar } from '../common/NavBarController';
import { ChatAttachment } from '../model/Model';
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
@ -20,6 +21,8 @@ import { PageTopBar } from './PageTopBar';
@Component
export struct ChatStream {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 是否已配置后端连接(决定空态是引导连接还是引导开聊) */
@StorageProp(K_HAS_CONN) private hasConn: boolean = false;
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
@StorageProp(K_CHAT_STAGE) private stage: string = '';
/** 数组快照的订阅信号 */
@ -186,6 +189,50 @@ export struct ChatStream {
.width('100%')
.height('100%')
// 层1.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
//
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
if (this.messages.length === 0 && !this.loading) {
Column({ space: 10 }) {
Image($r('app.media.ic_link'))
.width(34)
.height(34)
.fillColor(this.palette().textMuted)
.draggable(false)
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
Text(this.hasConn
? '在下方输入框发送第一条消息'
: '请先在“后端连接”里填写服务地址与 API Key')
.fontSize(12)
.fontColor(this.palette().textMuted)
.textAlign(TextAlign.Center)
if (!this.hasConn) {
Button('去设置连接')
.height(34)
.fontSize(13)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.margin({ top: 4 })
.onClick(() => {
// 跨页信号:切到设置 Tab,并让设置页直接打开连接二级页
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, 3);
})
}
}
.width('100%')
.height('100%')
.padding({ left: 44, right: 44 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
// 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应
.hitTestBehavior(HitTestMode.Transparent)
}
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透)
PageTopBar({ title: '聊天' })

View File

@ -13,7 +13,7 @@ import { apiClient } from '../common/ApiClient';
import { connStore } from '../common/ConnStore';
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, K_HAS_CONN } from '../common/Constants';
import { SubPageLayer, PlainCard } from './SubPage';
import { common } from '@kit.AbilityKit';
@ -49,6 +49,11 @@ export struct ConnectionsPane {
}
}
/** 连接变更后广播状态:聊天空态据此隐藏“去设置连接”入口。 */
private syncConnFlag(): void {
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
}
private currentConnName(): string {
for (let i = 0; i < this.connections.length; i++) {
if (this.connections[i].id === this.currentId) {
@ -64,6 +69,7 @@ export struct ConnectionsPane {
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('已切换连接', false);
@ -88,6 +94,7 @@ export struct ConnectionsPane {
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已添加', false);
@ -107,6 +114,7 @@ export struct ConnectionsPane {
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已更新', false);
@ -151,6 +159,7 @@ export struct ConnectionsPane {
} else {
apiClient.clearConnection();
}
this.syncConnFlag();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已删除', false);
});

View File

@ -7,7 +7,7 @@ import { apiClient } from '../common/ApiClient';
import { navBar } from '../common/NavBarController';
import { handleBackPress } from '../common/NavStackRegistry';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { GradientBackground } from '../components/GradientBackground';
@ -83,6 +83,8 @@ struct Index {
/** 底部手势条高度(vp) */
@State bottomGesture: number = 16;
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
/** 外部请求切换主 Tab(未连接时聊天空态的“去设置连接”用) */
@StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1;
private swiper: SwiperController = new SwiperController();
private screensueTimer: number = -1;
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
@ -101,6 +103,12 @@ struct Index {
if (cur !== null) {
apiClient.setConnection(cur);
}
// 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。
// Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑,
// 所以此处读到的连接状态就是真实的启动态。
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
const st = connStore.getSettings();
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
@ -191,6 +199,23 @@ struct Index {
AppStorage.set<number>('currentTab', this.currentTab);
}
/**
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
*
* 为什么不能直接改 AppStorage 的 currentTab:Index 的 currentTab 是
* @State,Swiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
*/
private onRequestedTab(): void {
const t: number = this.requestedTab;
AppStorage.set<number>(K_REQUESTED_TAB, -1);
if (t < 0 || t >= this.tabs.length) {
return;
}
this.currentTab = t;
navBar.setVisible(true);
}
private syncSystemBar(): void {
const dark: boolean = this.isDark;
const bg: string = dark ? '#000000' : '#F1F3F5';

View File

@ -3,7 +3,7 @@ import { userMessage, noConnectionMessage } from '../common/UserError';
import { connStore } from '../common/ConnStore';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { ConnectionConfig, AppSettings } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, K_HAS_CONN, K_SETTINGS_SUB } from '../common/Constants';
import { RADIUS_MD, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
import { StatusDetailContent } from '../components/StatusCards';
@ -42,6 +42,10 @@ export struct SettingsPage {
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
/** 未连接时是否已自动弹过连接页——避免用户关掉后又被 onNavigationModeChange 弹回来 */
private autoOpenedConn: boolean = false;
/** 外部请求打开某个二级页(聊天空态的“去设置连接”用) */
@StorageProp(K_SETTINGS_SUB) @Watch('onSubRequest') private subRequest: string = '';
// ===== backend key/value editor state =====
@State sections: SettingsSection[] = [];
@ -86,6 +90,39 @@ export struct SettingsPage {
this.connections = connStore.getConnections();
const cur = connStore.getCurrentConnection();
this.currentId = cur !== null ? cur.id : '';
// 广播连接状态:聊天空态据此显示“去设置连接”
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
// 可能从聊天空态带着“打开连接页”的请求进来(本页尚未挂载时请求已写入)
const pending: string = AppStorage.get<string>(K_SETTINGS_SUB) ?? '';
if (pending.length > 0) {
AppStorage.set<string>(K_SETTINGS_SUB, '');
this.autoOpenedConn = true;
setTimeout(() => {
this.openSub(pending);
}, 0);
}
}
/**
* 宽屏右栏默认该展示哪一页:没连上就把“后端连接”给出来。
*
* 为什么不能只靠一级入口行:入口行在列表里,用户很容易略过;
* 而“未连接”恰恰是最需要直接看到连接表单的时刻。窄屏同理,
* 在 onNavigationModeChange(Stack) 里会把连接页直接推到面前。
*/
private initialSub(): string {
return apiClient.hasConnection() ? SUB_STATUS : SUB_CONNECTIONS;
}
/** 外部请求打开二级页(“去设置连接”) */
private onSubRequest(): void {
const id: string = this.subRequest;
if (id.length === 0) {
return;
}
AppStorage.set<string>(K_SETTINGS_SUB, '');
this.autoOpenedConn = true;
this.openSub(id);
}
private palette(): ThemePalette {
@ -287,12 +324,17 @@ export struct SettingsPage {
if (mode === NavigationMode.Split) {
markSubPageOpen(true);
if (this.navStack.size() === 0) {
this.openSub(SUB_STATUS);
this.openSub(this.initialSub());
}
} else {
this.navStack.clear(false);
this.activeSub = SUB_NONE;
markSubPageOpen(false);
// 窄屏:未配置后端时直接推连接页,保证“设置里一定能找到连后端的入口”。
if (!this.autoOpenedConn && !apiClient.hasConnection()) {
this.autoOpenedConn = true;
this.openSub(SUB_CONNECTIONS);
}
}
})
}