mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
feat(ohos): MotionBase 统一按压动效 + 分层图标随主题切换
## 动效统一 各组件重复实现按压反馈(@State pressed + scale + animation + onTouch 四件套), 时长各写魔数导致全局手感不一致。 - 新增 components/MotionBase.ets:通用动效的"父组件"。ArkUI V1 的 @Component struct 无法继承他人 build,改用组合表达继承——调用组件把内容经 @BuilderParam 内容插槽传入,MotionBase 在包装节点统一挂动效修饰器。 pressEnabled 默认关(纯展示容器零开销);fillWidth=false 供气泡内卡片按内容 自适应宽度;flexWeight 供等分排列按钮参与剩余空间分配。 onPress 只作按压瞬时轻量钩子,导航/提交语义仍由调用组件 onClick 负责, 避免"按下即触发"的手感偏差。 - Constants.ets 新增动效 token:ANIM_FAST(150) / ANIM_NORMAL(220) / ANIM_ENTER(280) / ANIM_SLOW(400) / PRESS_SCALE(0.97)。 取值依据:状态切换 150-250ms(>300ms 显拖沓),大位移进出场 300-400ms 才不突兀;曲线统一 EaseOut 起步快收尾缓。 - NavRow / StatusSummaryCard / AttachmentCard / 插件卡片等公共组件接入 MotionBase,移除各自的按压四件套。组件专属动效(聊天输入框上弹、加号菜单 浮起、折叠面板展开、toast 进出场)保留在各组件内,不塞进父组件。 ## 图标与启动页随主题切换 原先直接指向位图 app_icon.png,浅色底被烧进图标,深色模式下桌面与启动页跳脱。 - 改用分层图标 layered_image:foreground 为字形,background(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。app.json5 与 module.json5 的 icon 均指向 :layered_image。 - startWindowIcon 改用透明底 start_icon.png,配合 start_window_background 的 base(#F1F3F5) / dark(#000000) 两份取值,浅深模式遮罩与图标都能对上。 ## 验证 清空 entry/build 后全量重编:hvigorw assembleHap BUILD SUCCESSFUL(9.8s), 零 ArkTS 错误。提交内容已确认不含 build/ oh_modules/ .hap 与签名材料。
This commit is contained in:
@ -4,7 +4,9 @@
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"icon": "$media:app_icon",
|
||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png
Normal file
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 989 B |
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png
Normal file
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,6 @@
|
||||
{
|
||||
"layered-image": {
|
||||
"background": "$media:background",
|
||||
"foreground": "$media:foreground"
|
||||
}
|
||||
}
|
||||
BIN
cmd/ohos/HomeAgent/AppScope/resources/dark/media/background.png
Normal file
BIN
cmd/ohos/HomeAgent/AppScope/resources/dark/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 988 B |
@ -52,6 +52,25 @@ export const WIDE_MIN_CONTENT: number = 380;
|
||||
/** 悬浮导航胶囊的最大宽度(vp):宽屏下避免被拉成长条 */
|
||||
export const NAV_PILL_MAX_WIDTH: number = 620;
|
||||
|
||||
// ===== 动效时长(ms)=====
|
||||
/**
|
||||
* 统一动效节奏,避免每处各写一个魔数导致快慢不一。
|
||||
*
|
||||
* 取值依据:状态切换类交互(按下、展开、选中)控制在 150-250ms,
|
||||
* 超过 300ms 会显得"拖";进出场大位移(导航栏收起、悬浮层)用 300-400ms
|
||||
* 才不突兀。曲线统一 EaseOut:起步快、收尾缓,手感上更"跟手"。
|
||||
*/
|
||||
/** 按压反馈、图标旋转等即时反馈 */
|
||||
export const ANIM_FAST: number = 150;
|
||||
/** 展开/折叠、选中态迁移等常规状态切换 */
|
||||
export const ANIM_NORMAL: number = 220;
|
||||
/** 卡片入场、列表项出现 */
|
||||
export const ANIM_ENTER: number = 280;
|
||||
/** 导航栏收起、悬浮层进出等大位移 */
|
||||
export const ANIM_SLOW: number = 400;
|
||||
/** 按压态缩放:轻微下沉即可,过度缩放显廉价 */
|
||||
export const PRESS_SCALE: number = 0.97;
|
||||
|
||||
/** Every color token the app renders with. */
|
||||
export interface ThemePalette {
|
||||
bgPrimary: string;
|
||||
|
||||
@ -4,8 +4,9 @@ import { common } from '@kit.AbilityKit';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { PlainCard } from './SubPage';
|
||||
|
||||
/**
|
||||
@ -131,6 +132,9 @@ export struct AttachmentCard {
|
||||
}
|
||||
|
||||
build() {
|
||||
// 整卡按压反馈统一收进 MotionBase(父组件):scale 回弹 + 透明度压暗。
|
||||
// fillWidth: false —— 附件卡在聊天气泡内按内容自适应宽度,不能撑满整行。
|
||||
MotionBase({ pressEnabled: true, pressOpacity: 0.88, fillWidth: false }) {
|
||||
Column({ space: 6 }) {
|
||||
Text(this.mine ? '你发送的' : '小宅发送的')
|
||||
.fontSize(11)
|
||||
@ -144,6 +148,9 @@ export struct AttachmentCard {
|
||||
.objectFit(ImageFit.Cover)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.draggable(false)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.scale({ x: 0.98, y: 0.98 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
} else if (this.failed) {
|
||||
Row({ space: 6 }) {
|
||||
Image($r('app.media.ic_error'))
|
||||
@ -158,6 +165,7 @@ export struct AttachmentCard {
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
} else {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
@ -170,6 +178,7 @@ export struct AttachmentCard {
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
} else {
|
||||
Row({ space: 8 }) {
|
||||
@ -212,6 +221,7 @@ export struct AttachmentCard {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
@ -259,7 +269,9 @@ export struct AttachmentDetailContent {
|
||||
} catch (e) {
|
||||
this.err = userMessage('attachment.load', e);
|
||||
}
|
||||
this.loading = false;
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */
|
||||
@ -300,6 +312,9 @@ export struct AttachmentDetailContent {
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: -12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 预览:图片铺满宽度可缩放查看;文件给一个大图标占位
|
||||
@ -315,6 +330,7 @@ export struct AttachmentDetailContent {
|
||||
.width('100%')
|
||||
.height(200)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
} else if (this.pixel !== undefined) {
|
||||
Image(this.pixel)
|
||||
.width('100%')
|
||||
@ -322,6 +338,7 @@ export struct AttachmentDetailContent {
|
||||
.objectFit(ImageFit.Contain)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.draggable(false)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
} else {
|
||||
Column({ space: 10 }) {
|
||||
@ -375,6 +392,9 @@ export struct AttachmentDetailContent {
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 6 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -401,6 +421,7 @@ export struct AttachmentDetailContent {
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* MotionBase —— 全局通用动效的"父组件"。
|
||||
*
|
||||
* 目标:把各个组件重复实现的按压反馈收拢到这唯一一个父组件里,
|
||||
* 全局统一手感(时长 token + EaseOut 曲线 + 形变幅度)。
|
||||
* 公共组件(NavRow / StatusSummaryCard / AttachmentCard / 插件卡片 …)
|
||||
* 在自己的 build 根节点接入 MotionBase,通用按压动效即随父组件生效,
|
||||
* 子组件无需再各自维护 @State pressed + scale + animation + onTouch 四件套。
|
||||
*
|
||||
* 特有动画(多态扩展):留在各组件内部。例如聊天输入框向上弹出
|
||||
* (ChatPage 的 inputMultiLine 上移 + Curve.Friction 展开)、加号菜单浮起、
|
||||
* 折叠面板展开、toast 进出场——这些是"某个组件专属"的动效,
|
||||
* 不应塞进父组件,由各组件按需声明。
|
||||
*
|
||||
* ArkUI V1 的 @Component struct 不能继承另一位 struct 的 build,
|
||||
* 这里用「组合」表达继承:调用组件把内容经唯一内容插槽传给 MotionBase,
|
||||
* MotionBase 在包装节点上统一挂通用动效修饰器。
|
||||
*
|
||||
* 通用动效契约:
|
||||
* - 按压反馈:pressEnabled 时整容器 scale → pressScale,快速回弹。
|
||||
* V1 自带按压态在玻璃/透明底上几乎不可见,这里统一补足;
|
||||
* scale 只作用于容器自身,不侵入子节点外观。
|
||||
* - onPress 只是按压瞬时的轻量钩子(如提前高亮),
|
||||
* 真正的导航/提交语义仍由调用组件的 onClick(点击释放)负责,
|
||||
* 二者不耦合,避免"按下即触发"的手感偏差。
|
||||
*
|
||||
* 用法:
|
||||
* MotionBase({ pressEnabled: true }) {
|
||||
* // 实际内容;外层 onClick 由调用组件自己绑在内容上
|
||||
* }
|
||||
*
|
||||
* 注意:包装节点默认 width('100%')。像聊天气泡里的附件卡那样需要
|
||||
* 「内容自适应宽度」的组件传 fillWidth: false,否则会被撑满整行。
|
||||
*/
|
||||
import { ANIM_FAST, PRESS_SCALE } from '../common/Constants';
|
||||
|
||||
@Component
|
||||
export struct MotionBase {
|
||||
/** 内容插槽:调用组件把实际内容传进来 */
|
||||
@BuilderParam content: () => void;
|
||||
/** 是否启用按压反馈(默认关,纯展示容器传 false 零开销) */
|
||||
@Prop pressEnabled: boolean = false;
|
||||
/** 按下的瞬间回调(可选):只做轻量即时反馈,不要在这里做导航/提交 */
|
||||
onPress?: () => void;
|
||||
/** 按压缩放幅度,默认全局 PRESS_SCALE */
|
||||
@Prop pressScale: number = PRESS_SCALE;
|
||||
/** 按压时整体透明度(1 = 不启用透明度反馈) */
|
||||
@Prop pressOpacity: number = 1;
|
||||
/** 包装节点是否撑满父宽;false = 由内容决定宽度(气泡内的卡片必须传 false) */
|
||||
@Prop fillWidth: boolean = true;
|
||||
/** 在 Row/Column 中参与剩余空间分配的权重;0 = 不参与(等分排列的按钮传 1) */
|
||||
@Prop flexWeight: number = 0;
|
||||
|
||||
/** 按压态:只对容器自身 scale 生效 */
|
||||
@State private pressed: boolean = false;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width(this.fillWidth && this.flexWeight <= 0 ? '100%' : undefined)
|
||||
.layoutWeight(this.flexWeight)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// ===== 通用动效(父组件统一声明,子组件继承) =====
|
||||
// 按压回弹:pressed 驱动 scale 增量,.animation 在下方覆盖该增量属性
|
||||
.scale({
|
||||
x: this.pressed ? this.pressScale : 1,
|
||||
y: this.pressed ? this.pressScale : 1,
|
||||
})
|
||||
.opacity(this.pressed ? this.pressOpacity : 1)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onTouch((e: TouchEvent) => {
|
||||
if (!this.pressEnabled) {
|
||||
return;
|
||||
}
|
||||
if (e.type === TouchType.Down) {
|
||||
this.pressed = true;
|
||||
const cb = this.onPress;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
} else if (e.type === TouchType.Up || e.type === TouchType.Cancel) {
|
||||
this.pressed = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_SLOW } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
|
||||
/**
|
||||
* 顶栏高度(vp)。页面 padding-top 应略大于此值,避免内容被标题压住,
|
||||
@ -123,31 +125,36 @@ export struct FloatIconButton {
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
Button() {
|
||||
Image(this.icon)
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
|
||||
}
|
||||
.width(FLOAT_NODE_SIZE)
|
||||
.height(FLOAT_NODE_SIZE)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 24,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 8,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
// 按压缩放交给 MotionBase(父组件统一手感);
|
||||
// fillWidth: false —— 悬浮区靠右对齐,包装节点必须按内容 42vp 收窄。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button() {
|
||||
Image(this.icon)
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
}
|
||||
})
|
||||
.width(FLOAT_NODE_SIZE)
|
||||
.height(FLOAT_NODE_SIZE)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 24,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 8,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
@ -203,6 +210,6 @@ export struct NavFloatOverlay {
|
||||
.margin({ bottom: 94 })
|
||||
.translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 })
|
||||
.opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1)
|
||||
.animation({ duration: 400, curve: Curve.EaseOut })
|
||||
.animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_SM } from '../common/Constants';
|
||||
import { COLOR_ERROR } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。
|
||||
@ -243,6 +244,7 @@ export struct SettingsEditor {
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 14, bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.err.length > 0) {
|
||||
@ -250,6 +252,8 @@ export struct SettingsEditor {
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.loading && this.err.length === 0 && this.entries.length === 0) {
|
||||
@ -257,6 +261,7 @@ export struct SettingsEditor {
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.entries, (entry: EditorEntry) => {
|
||||
@ -268,6 +273,8 @@ export struct SettingsEditor {
|
||||
.fontSize(11)
|
||||
.fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent)
|
||||
.margin({ top: 4 })
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
@ -302,6 +309,7 @@ export struct SettingsEditor {
|
||||
Text(entry.value === 'true' ? '已开启' : '已关闭')
|
||||
.fontSize(12)
|
||||
.fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
|
||||
.selectedColor(this.palette().accent)
|
||||
@ -325,6 +333,7 @@ export struct SettingsEditor {
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
|
||||
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.save(entry, opt);
|
||||
})
|
||||
@ -352,6 +361,7 @@ export struct SettingsEditor {
|
||||
.fontSize(12)
|
||||
.backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.save(entry, this.latestValue(entry.key));
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
|
||||
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import {
|
||||
statusStore, StatGroup, StatField, compactDuration,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
|
||||
@ -43,6 +45,10 @@ export struct StatusSummaryCard {
|
||||
}
|
||||
|
||||
build() {
|
||||
// 整卡按压反馈统一收进 MotionBase(父组件):这里是进明细页的唯一入口,
|
||||
// 父组件负责 scale 回弹;导航语义仍由本卡自己的 onClick(点击释放)负责,
|
||||
// 避免"按下即跳转"的误触手感。
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Column() {
|
||||
if (this.err.length > 0) {
|
||||
// 连接失败:只显示一句人话,技术细节在 hilog 里
|
||||
@ -60,6 +66,7 @@ export struct StatusSummaryCard {
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
} else if (this.loading && !this.up) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
@ -70,6 +77,7 @@ export struct StatusSummaryCard {
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 20, bottom: 20 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
} else {
|
||||
// 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。
|
||||
// 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量,
|
||||
@ -81,18 +89,21 @@ export struct StatusSummaryCard {
|
||||
.width(24)
|
||||
.height(24)
|
||||
.fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
|
||||
Column({ space: 3 }) {
|
||||
Text(this.up ? '运行中' : '离线')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
@ -104,6 +115,7 @@ export struct StatusSummaryCard {
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
|
||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
|
||||
Row({ space: 10 }) {
|
||||
@ -155,6 +167,7 @@ export struct StatusSummaryCard {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
@ -237,6 +250,7 @@ export struct StatusDetailContent {
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.loading && this.groups.length === 0) {
|
||||
@ -249,6 +263,7 @@ export struct StatusDetailContent {
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 40, bottom: 40 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。
|
||||
@ -295,6 +310,7 @@ export struct StatusDetailContent {
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 14 })
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (g: StatGroup) => g.title)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar';
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
@ -69,6 +71,7 @@ export struct SubPageLayer {
|
||||
.width('100%')
|
||||
// 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 })
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
@ -88,25 +91,34 @@ export struct SubPageLayer {
|
||||
NavFloatOverlay({ tab: this.tab }) {
|
||||
NavFloatRow() {
|
||||
if (!this.isWide) {
|
||||
SubPageBackButton({
|
||||
onTap: () => {
|
||||
const cb = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
})
|
||||
if (this.showRefresh) {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_refresh'),
|
||||
// 自定义组件不能直接挂 .transition()(会生成 __Common__ 包装节点),
|
||||
// 所以各自套一层无 padding 的 Column,由它承载出入场动画,
|
||||
// 让悬浮键与 400ms 平滑滑出的悬浮层不再"一帧硬切"。
|
||||
Column() {
|
||||
SubPageBackButton({
|
||||
onTap: () => {
|
||||
const cb = this.onRefresh;
|
||||
const cb = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
if (this.showRefresh) {
|
||||
Column() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_refresh'),
|
||||
onTap: () => {
|
||||
const cb = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -138,71 +150,82 @@ export struct NavRow {
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
if (this.icon !== undefined) {
|
||||
Row() {
|
||||
Image(this.icon)
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.iconColor())
|
||||
.draggable(false)
|
||||
// 通用按压动画收进 MotionBase(父组件):scale 回弹统一。
|
||||
// 选中态底色仍在这里管:它是 NavRow 特有外观,不归父组件。
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row() {
|
||||
if (this.icon !== undefined) {
|
||||
Row() {
|
||||
Image(this.icon)
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.iconColor())
|
||||
.draggable(false)
|
||||
}
|
||||
.width(32)
|
||||
.height(32)
|
||||
.borderRadius(10)
|
||||
.backgroundColor(this.iconBg())
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.margin({ right: 12 })
|
||||
}
|
||||
.width(32)
|
||||
.height(32)
|
||||
.borderRadius(10)
|
||||
.backgroundColor(this.iconBg())
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.margin({ right: 12 })
|
||||
}
|
||||
|
||||
Column({ space: 2 }) {
|
||||
Text(this.title)
|
||||
.fontSize(15)
|
||||
.fontColor(this.selected ? this.palette().accent : this.palette().textPrimary)
|
||||
.fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.subtitle.length > 0) {
|
||||
Text(this.subtitle)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Column({ space: 2 }) {
|
||||
Text(this.title)
|
||||
.fontSize(15)
|
||||
.fontColor(this.selected ? this.palette().accent : this.palette().textPrimary)
|
||||
// 父行 MotionBase 的 .animation() 到不了子节点,选中态字色自己缓动
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.subtitle.length > 0) {
|
||||
Text(this.subtitle)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (this.value.length > 0) {
|
||||
Text(this.value)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 130 })
|
||||
.margin({ right: 8 })
|
||||
}
|
||||
if (this.value.length > 0) {
|
||||
Text(this.value)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 130 })
|
||||
.margin({ right: 8 })
|
||||
}
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.selected ? this.palette().accent : this.palette().textMuted)
|
||||
.draggable(false)
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.selected ? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
// 选中态底色:写在 .border 之前,避免分割线宽度被纳入动画。
|
||||
// MotionBase 的 .animation() 只作用在包装 Column 上,到不了这里,
|
||||
// 选中高亮的底色迁移要本行自己声明。
|
||||
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.border({
|
||||
width: { bottom: this.showDivider ? 1 : 0 },
|
||||
color: this.palette().kvBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
|
||||
.border({
|
||||
width: { bottom: this.showDivider ? 1 : 0 },
|
||||
color: this.palette().kvBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private iconColor(): string {
|
||||
@ -301,30 +324,34 @@ export struct SubPageBackButton {
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
Button() {
|
||||
Row({ space: 4 }) {
|
||||
Image($r('app.media.ic_arrow_back'))
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
// 玻璃底自带的按压变暗在 45% 透明度上几乎看不见,缩放反馈由 MotionBase 统一提供。
|
||||
// fillWidth: false —— 悬浮区按内容宽度排列,不能撑满整行。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button() {
|
||||
Row({ space: 4 }) {
|
||||
Image($r('app.media.ic_arrow_back'))
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
}
|
||||
}
|
||||
.width(42)
|
||||
.height(42)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
.width(42)
|
||||
.height(42)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
|
||||
@ -9,6 +9,8 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_MD, RADIUS_PILL, RADIUS_SM } from '../common/Constants';
|
||||
import { WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { CHAT_PAGE_SIZE } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { MarkdownView } from '../components/MarkdownView';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
@ -746,7 +748,9 @@ export struct ChatPage {
|
||||
|
||||
/** 从图库挑一张图 */
|
||||
private async pickImage(): Promise<void> {
|
||||
this.attachMenuOpen = false;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = false;
|
||||
});
|
||||
try {
|
||||
const options = new picker.PhotoSelectOptions();
|
||||
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
@ -765,7 +769,9 @@ export struct ChatPage {
|
||||
|
||||
/** 从文件管理器挑一个文件 */
|
||||
private async pickFile(): Promise<void> {
|
||||
this.attachMenuOpen = false;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = false;
|
||||
});
|
||||
try {
|
||||
const options = new picker.DocumentSelectOptions();
|
||||
options.maxSelectNumber = 1;
|
||||
@ -993,8 +999,12 @@ export struct ChatPage {
|
||||
}
|
||||
|
||||
private toggleReasoning(msg: ChatMessage): void {
|
||||
msg.reasoningOpen = !(msg.reasoningOpen === true);
|
||||
this.forceRefresh();
|
||||
// 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation,
|
||||
// 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
msg.reasoningOpen = !(msg.reasoningOpen === true);
|
||||
this.forceRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1168,7 +1178,9 @@ export struct ChatPage {
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.padding({ left: 52, top: 6, bottom: 6 })
|
||||
.opacity(1)
|
||||
// if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新);
|
||||
// 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
@ -1229,7 +1241,11 @@ export struct ChatPage {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_plus'),
|
||||
onTap: () => {
|
||||
this.attachMenuOpen = !this.attachMenuOpen;
|
||||
// 菜单展开会同时改变 listBottomPad,用 animateTo 把
|
||||
// 列表内边距和菜单进出场拉到同一个时钟上。
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = !this.attachMenuOpen;
|
||||
});
|
||||
},
|
||||
})
|
||||
Blank()
|
||||
@ -1244,6 +1260,8 @@ export struct ChatPage {
|
||||
.height(44)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor('#77809A')
|
||||
// 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
.onClick(() => {
|
||||
this.interruptChat();
|
||||
})
|
||||
@ -1259,6 +1277,7 @@ export struct ChatPage {
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0)
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
.onClick(() => {
|
||||
this.sendChat();
|
||||
})
|
||||
@ -1353,73 +1372,83 @@ export struct ChatPage {
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.margin({ bottom: 8 })
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
// 加号菜单由 if 控制,进出场只能靠 transition;配合下面 toggle 处的
|
||||
// animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
@Builder
|
||||
AttachOption(icon: Resource, label: string, tap: () => void) {
|
||||
Row({ space: 6 }) {
|
||||
Image(icon)
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
// 按压反馈交给 MotionBase:每个实例自带独立按压态,
|
||||
// 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Row({ space: 6 }) {
|
||||
Image(icon)
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
}
|
||||
.padding({ left: 12, right: 14, top: 8, bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.onClick(tap)
|
||||
}
|
||||
.padding({ left: 12, right: 14, top: 8, bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.onClick(tap)
|
||||
}
|
||||
|
||||
/** 待发送附件预览条:缩略信息 + 一个移除按钮 */
|
||||
@Builder
|
||||
PendingAttachmentChip() {
|
||||
Row({ space: 8 }) {
|
||||
Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
Column({ space: 1 }) {
|
||||
Text(this.pendingName)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(this.uploading ? '上传中...' : formatBytes(this.pendingSize))
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
if (this.uploading) {
|
||||
LoadingProgress()
|
||||
.width(14)
|
||||
.height(14)
|
||||
.color(this.palette().accent)
|
||||
} else {
|
||||
Image($r('app.media.ic_close'))
|
||||
.width(13)
|
||||
.height(13)
|
||||
.fillColor(this.palette().textMuted)
|
||||
// 按压反馈交给 MotionBase(全宽预览条)
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row({ space: 8 }) {
|
||||
Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
.onClick(() => {
|
||||
this.clearPendingFile();
|
||||
this.forceRefresh();
|
||||
})
|
||||
Column({ space: 1 }) {
|
||||
Text(this.pendingName)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(this.uploading ? '上传中...' : formatBytes(this.pendingSize))
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
if (this.uploading) {
|
||||
LoadingProgress()
|
||||
.width(14)
|
||||
.height(14)
|
||||
.color(this.palette().accent)
|
||||
} else {
|
||||
Image($r('app.media.ic_close'))
|
||||
.width(13)
|
||||
.height(13)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
.onClick(() => {
|
||||
this.clearPendingFile();
|
||||
this.forceRefresh();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.margin({ bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.margin({ bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
@ -1487,6 +1516,8 @@ export struct ChatPage {
|
||||
.maxLines(24)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
// 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep)
|
||||
@ -1614,38 +1645,44 @@ export struct ChatPage {
|
||||
})
|
||||
|
||||
if (tc.open === true) {
|
||||
if (tc.args.length > 0 && tc.args !== '{}') {
|
||||
Text('参数')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(tc.args)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}
|
||||
if (tc.result !== undefined && tc.result.length > 0) {
|
||||
Text('结果')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(tc.result)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.maxLines(8)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Column() {
|
||||
if (tc.args.length > 0 && tc.args !== '{}') {
|
||||
Text('参数')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(tc.args)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}
|
||||
if (tc.result !== undefined && tc.result.length > 0) {
|
||||
Text('结果')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(tc.result)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.maxLines(8)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// 展开内容整体用 if 挂载:transition 让参数/结果随 chevron 一起淡入
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
@ -1710,8 +1747,10 @@ export struct ChatPage {
|
||||
/** 工具卡/思考卡在气泡内是独占一行的块,气泡 78% 宽度对它们太窄 —— 见 BubbleBody */
|
||||
|
||||
private toggleToolCard(tc: ToolCallInfo): void {
|
||||
tc.open = !(tc.open === true);
|
||||
this.forceRefresh();
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
tc.open = !(tc.open === true);
|
||||
this.forceRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,7 +6,8 @@ import { noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { DeviceInfo } 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, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
@ -79,7 +80,9 @@ export struct DevicePage {
|
||||
// ignore context errors
|
||||
}
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
this.bridgeConnected = open;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.bridgeConnected = open;
|
||||
});
|
||||
if (open) {
|
||||
this.lastError = '';
|
||||
this.showToast('设备网关已连接', false);
|
||||
@ -140,7 +143,9 @@ export struct DevicePage {
|
||||
* 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
|
||||
*/
|
||||
private openSub(id: string): void {
|
||||
this.activeSub = id;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.activeSub = id;
|
||||
});
|
||||
if (this.isWide && this.navStack.size() > 0) {
|
||||
this.navStack.replacePathByName(id, subPageParam(id), false);
|
||||
} else {
|
||||
@ -151,7 +156,9 @@ export struct DevicePage {
|
||||
/** 二级页面返回键:宽屏下左栏常驻可见,SubPageLayer 已隐藏返回键 */
|
||||
private closeSub(): void {
|
||||
this.navStack.pop();
|
||||
this.activeSub = SUB_NONE;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle local authorization flag; hello 同步到网关。 */
|
||||
@ -203,10 +210,15 @@ export struct DevicePage {
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
this.toastMsg = msg;
|
||||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||||
this.toastIsError = isError;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = msg;
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.toastMsg = '';
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = '';
|
||||
});
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
@ -244,7 +256,9 @@ export struct DevicePage {
|
||||
list.push(info);
|
||||
}
|
||||
}
|
||||
this.devices = list;
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.devices = list;
|
||||
});
|
||||
} else {
|
||||
this.devices = [];
|
||||
}
|
||||
@ -327,7 +341,9 @@ export struct DevicePage {
|
||||
}
|
||||
} else {
|
||||
this.navStack.clear(false);
|
||||
this.activeSub = SUB_NONE;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
});
|
||||
markSubPageOpen(false);
|
||||
}
|
||||
})
|
||||
@ -403,6 +419,9 @@ export struct DevicePage {
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -499,10 +518,12 @@ export struct DevicePage {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.authorized ? '#17A964' : '#E84026')
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.margin({ right: 8 })
|
||||
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
|
||||
.fontSize(12)
|
||||
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
@ -551,36 +572,46 @@ export struct DevicePage {
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: -8 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
||||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
||||
.border({
|
||||
width: this.bridgeConnected ? 1 : 0,
|
||||
color: this.palette().btnGhostBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
if (this.bridgeConnected) {
|
||||
deviceBridge.disconnect();
|
||||
} else {
|
||||
this.connectBridge();
|
||||
}
|
||||
})
|
||||
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
|
||||
// 因为父容器的 .animation() 到不了子节点。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
||||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.border({
|
||||
width: this.bridgeConnected ? 1 : 0,
|
||||
color: this.palette().btnGhostBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
if (this.bridgeConnected) {
|
||||
deviceBridge.disconnect();
|
||||
} else {
|
||||
this.connectBridge();
|
||||
}
|
||||
})
|
||||
}
|
||||
Blank()
|
||||
Button('刷新设备')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.refreshDevices();
|
||||
})
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('刷新设备')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.refreshDevices();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
@ -600,31 +631,40 @@ export struct DevicePage {
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.devices, (dev: DeviceInfo) => {
|
||||
PlainCard({ caption: '' }) {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(dev.online ? '#17A964' : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
|
||||
.fontSize(11)
|
||||
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装),
|
||||
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
|
||||
Column() {
|
||||
PlainCard({ caption: '' }) {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(dev.online ? '#17A964' : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
|
||||
.fontSize(11)
|
||||
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(dev.caps.length.toString() + ' 能力')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Text(dev.caps.length.toString() + ' 能力')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ 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 { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { GradientBackground } from '../components/GradientBackground';
|
||||
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
|
||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||
@ -33,24 +35,32 @@ export struct RailTab {
|
||||
@Prop muted: string = '';
|
||||
|
||||
build() {
|
||||
Column({ space: 3 }) {
|
||||
Image(this.icon)
|
||||
.width(22)
|
||||
.height(22)
|
||||
.fillColor(this.active ? this.accent : this.muted)
|
||||
.draggable(false)
|
||||
// 底部导航按压反馈:交给 MotionBase 统一(flexWeight 由外层调用点的
|
||||
// .layoutWeight(1) 负责,这里内部只需撑满自己那一格)。
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Column({ space: 3 }) {
|
||||
Image(this.icon)
|
||||
.width(22)
|
||||
.height(22)
|
||||
.fillColor(this.active ? this.accent : this.muted)
|
||||
// 选中态的颜色迁移必须写在子节点上:父容器的 .animation()
|
||||
// 只驱动它自己的属性,不会下传给 Image / Text。
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
|
||||
Text(this.label)
|
||||
.fontSize(10)
|
||||
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.active ? this.accent : this.muted)
|
||||
.maxLines(1)
|
||||
Text(this.label)
|
||||
.fontSize(10)
|
||||
// fontWeight 不可插值,切换时仍是跳变,只对颜色做过渡
|
||||
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.active ? this.accent : this.muted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.maxLines(1)
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.width('100%')
|
||||
.height(48)
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.animation({ duration: 200, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,7 +335,7 @@ struct Index {
|
||||
// 所有页面滑动时隐藏导航栏
|
||||
.translate({ y: !this.navVisible ? 130 : 0 })
|
||||
.opacity(!this.navVisible ? 0 : 1)
|
||||
.animation({ duration: 400, curve: Curve.EaseOut })
|
||||
.animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
|
||||
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { SettingsEditor } from '../components/SettingsEditor';
|
||||
@ -184,11 +186,17 @@ export struct PluginsPage {
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
this.plugins = rows;
|
||||
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.plugins = rows;
|
||||
});
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.load', e), true);
|
||||
}
|
||||
this.loading = false;
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
|
||||
@ -239,7 +247,9 @@ export struct PluginsPage {
|
||||
await apiClient.post('/plugins', bodyObj);
|
||||
this.showToast('安装请求已发送', false);
|
||||
this.installUrl = '';
|
||||
this.showInstallForm = false;
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.showInstallForm = false;
|
||||
});
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.install', e), true);
|
||||
@ -293,7 +303,10 @@ export struct PluginsPage {
|
||||
d.deprecated = o['deprecated'] as boolean ?? false;
|
||||
d.tags = this.strArray(o['tags']);
|
||||
d.files = this.strArray(o['files']);
|
||||
this.detail = d;
|
||||
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
});
|
||||
} catch (e) {
|
||||
// 内置插件没有清单,属于预期情况,不当成错误刷红
|
||||
const row: PluginRow | null = this.findRow(name);
|
||||
@ -303,12 +316,15 @@ export struct PluginsPage {
|
||||
d.version = row.version ?? '';
|
||||
d.description = row.description ?? '';
|
||||
}
|
||||
this.detail = d;
|
||||
if (row !== null && row.external) {
|
||||
this.detailError = userMessage('plugins.detail', e);
|
||||
}
|
||||
const errText: string = (row !== null && row.external) ? userMessage('plugins.detail', e) : '';
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
this.detailError = errText;
|
||||
});
|
||||
}
|
||||
this.detailLoading = false;
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detailLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private strArray(raw: Object | undefined): string[] {
|
||||
@ -351,10 +367,15 @@ export struct PluginsPage {
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
this.toastMsg = msg;
|
||||
// 颜色标志必须在 animateTo 闭包之外先落地,否则第一帧配色是上一次的
|
||||
this.toastIsError = isError;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = msg;
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.toastMsg = '';
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = '';
|
||||
});
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
@ -404,6 +425,8 @@ export struct PluginsPage {
|
||||
.width(20)
|
||||
.height(20)
|
||||
.fillColor(Color.White)
|
||||
.rotate({ angle: this.showInstallForm ? 45 : 0 })
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.width(50)
|
||||
.height(50)
|
||||
@ -411,7 +434,9 @@ export struct PluginsPage {
|
||||
.backgroundColor(this.palette().accent)
|
||||
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
|
||||
.onClick(() => {
|
||||
this.showInstallForm = !this.showInstallForm;
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.showInstallForm = !this.showInstallForm;
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -510,6 +535,9 @@ export struct PluginsPage {
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
/** 加载中 / 未配置 / 空列表三种占位态 */
|
||||
@ -521,6 +549,7 @@ export struct PluginsPage {
|
||||
.height(32)
|
||||
.color(this.palette().accent)
|
||||
.margin({ top: 40 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!apiClient.hasConnection()) {
|
||||
@ -528,6 +557,7 @@ export struct PluginsPage {
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
|
||||
@ -535,6 +565,7 @@ export struct PluginsPage {
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -545,62 +576,78 @@ export struct PluginsPage {
|
||||
@Builder
|
||||
PluginList() {
|
||||
ForEach(this.plugins, (plugin: PluginRow) => {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(plugin.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (plugin.version !== undefined && plugin.version.length > 0) {
|
||||
Text('v' + plugin.version)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
// 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
|
||||
// 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
|
||||
// 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
|
||||
Column() {
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(plugin.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
if (plugin.version !== undefined && plugin.version.length > 0) {
|
||||
Text('v' + plugin.version)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
|
||||
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 6, height: 6 })
|
||||
.fill(this.statusDotColor(plugin))
|
||||
Text(this.rowSubtitle(plugin))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
|
||||
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 6, height: 6 })
|
||||
.fill(this.statusDotColor(plugin))
|
||||
Text(this.rowSubtitle(plugin))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.activeName === plugin.name
|
||||
? this.palette().accentBg : this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().glassBorder,
|
||||
})
|
||||
// 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openDetail(plugin.name);
|
||||
})
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textMuted)
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.activeName === plugin.name
|
||||
? this.palette().accentBg : this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().glassBorder,
|
||||
})
|
||||
.margin({ bottom: 10 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openDetail(plugin.name);
|
||||
})
|
||||
// ForEach key 含 loaded/disabled:启停会整行重挂载,靠 transition 变成交叉淡入
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
|
||||
}
|
||||
|
||||
@ -621,6 +668,7 @@ export struct PluginsPage {
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 30, bottom: 30 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.detailError.length > 0) {
|
||||
@ -628,6 +676,7 @@ export struct PluginsPage {
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.padding({ left: 4, bottom: 12 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 概览卡:名称、版本、状态徽标、描述
|
||||
@ -786,6 +835,7 @@ export struct PluginsPage {
|
||||
})
|
||||
.fontColor(this.activeIsDisabled()
|
||||
? this.palette().textSecondary : '#D99A2B')
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
@ -806,6 +856,7 @@ export struct PluginsPage {
|
||||
this.removePlugin(r);
|
||||
}
|
||||
})
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
@ -892,6 +943,9 @@ export struct PluginsPage {
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { ConnectionConfig, AppSettings, emptySettings } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, applyThemeMode, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { picker, fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
@ -572,10 +574,15 @@ export struct SettingsPage {
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
this.toastMsg = msg;
|
||||
// 颜色标记必须在 animateTo 之外先定好,否则第一帧会用上一条 toast 的配色
|
||||
this.toastIsError = isError;
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = msg;
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.toastMsg = '';
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.toastMsg = '';
|
||||
});
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
@ -748,6 +755,8 @@ export struct SettingsPage {
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
// if 控制的节点靠 transition 做进出场,配合 showToast 里的 animateTo
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1139,23 +1148,31 @@ export struct SettingsPage {
|
||||
|
||||
@Builder
|
||||
ThemeOption(label: string, mode: string) {
|
||||
Column() {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
|
||||
// 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。
|
||||
MotionBase({ pressEnabled: true, flexWeight: 1 }) {
|
||||
Column() {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
|
||||
// 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.width('100%')
|
||||
.height(34)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
|
||||
// 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、
|
||||
// 覆盖它上面的所有状态驱动属性。
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
|
||||
})
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.applyTheme(mode);
|
||||
})
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(34)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
this.applyTheme(mode);
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
@ -1200,6 +1217,7 @@ export struct SettingsPage {
|
||||
Text(entry.value === 'true' ? 'true' : 'false')
|
||||
.fontSize(12)
|
||||
.fontColor(entry.value === 'true' ? '#17A964' : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
|
||||
.selectedColor(this.palette().accent)
|
||||
@ -1223,6 +1241,8 @@ export struct SettingsPage {
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
|
||||
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
|
||||
// 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.saveSetting(entry, opt);
|
||||
})
|
||||
|
||||
@ -24,9 +24,11 @@
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "HomeAgent main entry",
|
||||
"icon": "$media:app_icon",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name",
|
||||
"startWindowIcon": "$media:app_icon",
|
||||
// 启动页图标用透明底字形,配合随主题切换的 start_window_background,
|
||||
// 浅色/深色模式下遮罩与图标都能对上,不再恒为深色样式。
|
||||
"startWindowIcon": "$media:start_icon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"skills": [
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#0B1020"
|
||||
"value": "#F1F3F5"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user