refactor(ohos): PluginsPage 995→374 行(接口/状态/列表/详情/浮层拆分)

拆分方向按职责切,页面只留导航与数据编排:
- common/PluginApi.ets(174):fetchPluginRows / fetchPluginDetail
- common/PluginStatus.ets(78):状态判定、文案、颜色、副标题
- components/PluginListView.ets(166):列表卡 + 列表
- components/PluginDetailPane.ets(291):详情面板
- components/PluginsOverlays.ets(65):安装表单
- components/ToastBar.ets(48):提示条,PluginsToast 与 SettingsPage 的
  toast 合并成这一个组件(此前两处各写一份)

UI 结构与文案逐字保留(含 92%/layoutWeight 等既有布局修法)。

验证:hvigorw assembleHap BUILD SUCCESSFUL。
This commit is contained in:
root
2026-09-13 22:18:49 +08:00
parent 414e6a1627
commit 131c1ff0e9
7 changed files with 877 additions and 676 deletions

View File

@ -0,0 +1,174 @@
/**
* 插件数据获取(无 UI 依赖)。
*
* 从 pages/PluginsPage.ets 抽出:三处接口的取数、合并口径与字段解析
* 都放在这里,页面只负责把结果落到 @State。
*
* 数据源对齐 WebGUI renderPlugins:
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
* - GET /plugins → 已安装外部插件元数据(version/description 等)
* - GET /plugins/disabled → {disabled:[{name,...}]}
* 三方按名称合并去重排序。
*/
import { apiClient } from './ApiClient';
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
/** 拉取并合并插件列表;失败时抛错,由调用方转成人话提示。 */
export async function fetchPluginRows(): Promise<PluginRow[]> {
// ---- kernel: loaded plugins + tool ownership ----
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const kpRaw: Object | undefined = kernelObj['plugins'];
if (kpRaw !== undefined && kpRaw !== null) {
const kpArr: Object[] = kpRaw as Object[];
for (let i = 0; i < kpArr.length; i++) {
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length === 0) {
continue;
}
loadedMap.set(n, item['loaded'] as boolean ?? true);
}
}
const tRaw: Object | undefined = kernelObj['tools'];
if (tRaw !== undefined && tRaw !== null) {
const tArr: Object[] = tRaw as Object[];
for (let i = 0; i < tArr.length; i++) {
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
const tn: string = item['name'] as string ?? '';
const owner: string = item['plugin'] as string ?? '';
if (tn.length === 0 || owner.length === 0) {
continue;
}
let list: string[] | undefined = toolsByPlugin.get(owner);
if (list === undefined) {
list = [];
toolsByPlugin.set(owner, list);
}
// 每插件最多展示 8 个工具名,避免卡片过长
if (list.length < 8) {
list.push(tn);
}
}
}
// ---- installed external plugins metadata ----
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
try {
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
const bodyTrim = pResp.body.trim();
let arr: Object[] = [];
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
arr = JSON.parse(pResp.body) as Object[];
} else {
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
const rawList: Object = obj['plugins'] ?? obj['data'];
if (rawList !== undefined && rawList !== null) {
arr = rawList as Object[];
}
}
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length > 0) {
externalMeta.set(n, item);
}
}
} catch (e) {
// 外部列表失败不阻塞内置展示
}
// ---- disabled list ----
const disabledNames: Set<string> = new Set<string>();
try {
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
const dArr: Object | undefined = dObj['disabled'];
if (dArr !== undefined && dArr !== null) {
const items: Object[] = dArr as Object[];
for (let di = 0; di < items.length; di++) {
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
const dn: string = dItem['name'] as string ?? '';
if (dn.length > 0) {
disabledNames.add(dn);
}
}
}
} catch (e) {
// disabled endpoint may not exist; ignore
}
// ---- merge: allNames sorted(与 GUI 一致)----
const allNames: Set<string> = new Set<string>();
loadedMap.forEach((v: boolean, k: string) => {
allNames.add(k);
});
externalMeta.forEach((v: Record<string, Object>, k: string) => {
allNames.add(k);
});
disabledNames.forEach((n: string) => {
allNames.add(n);
});
const names: string[] = Array.from(allNames);
names.sort();
const rows: PluginRow[] = [];
for (let i = 0; i < names.length; i++) {
const name: string = names[i];
const meta: Record<string, Object> | undefined = externalMeta.get(name);
const tools: string[] | undefined = toolsByPlugin.get(name);
const row: PluginRow = {
name: name,
loaded: loadedMap.get(name) ?? false,
disabled: disabledNames.has(name),
external: externalMeta.has(name),
version: meta !== undefined ? meta['version'] as string ?? '' : '',
description: meta !== undefined ? meta['description'] as string ?? '' : '',
tools: tools,
};
rows.push(row);
}
return rows;
}
function strArray(raw: Object | undefined): string[] {
const out: string[] = [];
if (raw === undefined || raw === null) {
return out;
}
const arr: Object[] = raw as Object[];
for (let i = 0; i < arr.length; i++) {
const s: string = arr[i] as string ?? '';
if (s.length > 0) {
out.push(s);
}
}
return out;
}
/**
* GET /plugins/{name} —— 后端返回插件清单字段。
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
* 内置插件不在 /plugins 里,取不到详情时由调用方退回列表已有信息。
*/
export async function fetchPluginDetail(name: string): Promise<PluginDetail> {
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const d: PluginDetail = emptyPluginDetail();
d.name = o['name'] as string ?? name;
d.version = o['version'] as string ?? '';
d.description = o['description'] as string ?? '';
d.author = o['author'] as string ?? '';
d.license = o['license'] as string ?? '';
d.homepage = o['homepage'] as string ?? '';
d.repository = o['repository'] as string ?? '';
d.entry = o['entry'] as string ?? '';
d.minVersion = o['min_version'] as string ?? '';
d.deprecated = o['deprecated'] as boolean ?? false;
d.tags = strArray(o['tags']);
d.files = strArray(o['files']);
return d;
}

View File

@ -0,0 +1,78 @@
/**
* 插件状态展示口径(纯函数,无 UI 依赖)。
*
* 从 pages/PluginsPage.ets 抽出:列表行与详情页共用同一套状态判定/文案/配色,
* 抽成模块后两处不会再各写一份。
*
* 状态口径与 WebGUI 一致:已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。
*/
import { PluginRow } from '../model/Model';
/** 'loaded' | 'pending' | 'disabled' | 'notloaded' */
export function pluginStatusOf(plugin: PluginRow): string {
if (plugin.loaded && !plugin.disabled) {
return 'loaded'; // 已加载
}
if (plugin.loaded && plugin.disabled) {
return 'pending'; // 运行中(禁用待生效)
}
if (plugin.disabled) {
return 'disabled'; // 已禁用
}
return 'notloaded'; // 未加载
}
export function pluginStatusText(plugin: PluginRow): string {
const s: string = pluginStatusOf(plugin);
if (s === 'loaded') {
return '已加载';
}
if (s === 'pending') {
return '待生效';
}
if (s === 'disabled') {
return '已禁用';
}
return '未加载';
}
/** 状态点颜色;未加载态用主题里的弱化色(mutedColor 由调用方从 palette 取)。 */
export function pluginStatusColor(plugin: PluginRow, mutedColor: string): string {
const s: string = pluginStatusOf(plugin);
if (s === 'loaded') {
return '#17A964';
}
if (s === 'pending') {
return '#D99A2B';
}
if (s === 'disabled') {
return '#E84026';
}
return mutedColor;
}
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
export function pluginRowSubtitle(plugin: PluginRow): string {
const parts: string[] = [];
parts.push(pluginStatusText(plugin));
parts.push(plugin.external ? '外部' : '内置');
if (plugin.tools !== undefined && plugin.tools.length > 0) {
parts.push(plugin.tools.length.toString() + ' 工具');
}
return parts.join(' · ');
}
/** 详情页状态行:状态 + 内置/外部 + 已废弃 + 工具数(顺序与原实现一致) */
export function pluginDetailStatusLine(row: PluginRow, deprecated: boolean): string {
const parts: string[] = [];
parts.push(pluginStatusText(row));
parts.push(row.external ? '外部' : '内置');
if (deprecated) {
parts.push('已废弃');
}
if (row.tools !== undefined && row.tools.length > 0) {
parts.push(row.tools.length.toString() + ' 个工具');
}
return parts.join(' · ');
}

View File

@ -0,0 +1,291 @@
/**
* 插件详情(二级页面内容)。
*
* 从 pages/PluginsPage.ets 抽出。
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 配置 / 操作。
*/
import { PluginRow, PluginDetail } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
ANIM_FAST, COLOR_ERROR } from '../common/Constants';
import { pluginDetailStatusLine, pluginStatusColor } from '../common/PluginStatus';
import { PlainCard } from './SubPage';
import { SettingsEditor } from './SettingsEditor';
@Component
export struct PluginDetailPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop detail: PluginDetail;
/** 当前查看详情的插件名(可能还没拿到 detail.name) */
@Prop activeName: string = '';
@Prop busy: boolean = false;
@Prop errorText: string = '';
/** 一级列表里对应的那一行;取不到详情时用它兜底,也为操作按钮提供状态 */
@Prop row: PluginRow | undefined = undefined;
onToggle?: () => void;
onRemove?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
// ---- 取值助手:ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
private hasManifest(): boolean {
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
}
private isBuiltin(): boolean {
const r: PluginRow | undefined = this.row;
return r !== undefined ? !r.external : false;
}
private isDisabled(): boolean {
const r: PluginRow | undefined = this.row;
return r !== undefined ? r.disabled : false;
}
private tools(): string[] {
const r: PluginRow | undefined = this.row;
if (r === undefined) {
return [];
}
return r.tools ?? [];
}
private statusLine(): string {
const r: PluginRow | undefined = this.row;
return r !== undefined ? pluginDetailStatusLine(r, this.detail.deprecated) : '未加载';
}
private statusColor(): string {
const r: PluginRow | undefined = this.row;
return r !== undefined ? pluginStatusColor(r, this.palette().textMuted) : this.palette().textMuted;
}
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
@Builder
KvRow(label: string, value: string) {
if (value.length > 0) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 220 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
}
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
@Builder
Badges() {
Row({ space: 6 }) {
Circle({ width: 7, height: 7 })
.fill(this.statusColor())
Text(this.statusLine())
.fontSize(12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
@Builder
ToolsCard() {
if (this.tools().length > 0) {
PlainCard({ caption: '注册的工具 (' + this.tools().length.toString() + ')' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.tools(), (tool: string) => {
Text(tool)
.fontSize(11)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (tool: string) => tool)
}
}
}
}
@Builder
ActionsCard() {
if (this.activeName.length > 0) {
PlainCard({ caption: '操作' }) {
Row() {
Button(this.isDisabled() ? '启用' : '禁用')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({
width: 1,
color: this.isDisabled()
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
})
.fontColor(this.isDisabled()
? this.palette().textSecondary : '#D99A2B')
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
const cb: (() => void) | undefined = this.onToggle;
if (cb !== undefined) {
cb();
}
})
Blank()
if (!this.isBuiltin()) {
Button('卸载')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor(COLOR_ERROR)
.onClick(() => {
const cb: (() => void) | undefined = this.onRemove;
if (cb !== undefined) {
cb();
}
})
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
}
}
}
build() {
// 单一根节点:与原来作为 @Builder 直接铺在 SubPageLayer 的 Column 里同构,
// 保持 alignItems Start,避免文本被默认居中对齐。
Column() {
if (this.busy) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 30, bottom: 30 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (this.errorText.length > 0) {
Text(this.errorText)
.fontSize(12)
.fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 概览卡:名称、版本、状态徽标、描述
PlainCard({ caption: '概览' }) {
Row({ space: 8 }) {
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.detail.version.length > 0) {
Text('v' + this.detail.version)
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
}
.width('100%')
.margin({ bottom: 10 })
this.Badges()
if (this.detail.description.length > 0) {
Text(this.detail.description)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.width('100%')
.margin({ top: 10 })
}
}
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
if (this.hasManifest()) {
PlainCard({ caption: '清单' }) {
this.KvRow('作者', this.detail.author)
this.KvRow('许可证', this.detail.license)
this.KvRow('主页', this.detail.homepage)
this.KvRow('仓库', this.detail.repository)
this.KvRow('入口', this.detail.entry)
this.KvRow('最低内核版本', this.detail.minVersion)
}
}
if (this.detail.tags.length > 0) {
PlainCard({ caption: '标签' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (t: string) => t)
}
}
}
this.ToolsCard()
// 插件配置:plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
if (this.activeName.length > 0) {
PlainCard({ caption: '插件配置' }) {
SettingsEditor({
prefix: 'plugin.' + this.activeName + '.',
emptyHint: '该插件没有暴露可配置项',
})
}
}
if (this.detail.files.length > 0) {
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
ForEach(this.detail.files, (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 4 })
}, (f: string) => f)
}
}
this.ActionsCard()
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,166 @@
/**
* 插件一级列表(列表卡 + 三种占位态)。
*
* 从 pages/PluginsPage.ets 抽出:列表只负责"选谁",
* 描述/工具/启停全部下沉到详情页。
*/
import { PluginRow } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD,
ANIM_FAST, ANIM_ENTER } from '../common/Constants';
import { pluginRowSubtitle, pluginStatusColor } from '../common/PluginStatus';
import { noConnectionMessage } from '../common/UserError';
import { MotionBase } from './MotionBase';
/**
* 单个插件行卡。
*
* 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
* 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
* 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
*/
@Component
export struct PluginListCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop plugin: PluginRow;
/** 是否当前选中(宽屏高亮左侧列表项)。单独用基本类型传,选中态变更才能触发更新 */
@Prop active: boolean = false;
onTap?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
MotionBase({ pressEnabled: true }) {
Row() {
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(this.plugin.name)
.fontSize(15)
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.active ? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
if (this.plugin.version !== undefined && this.plugin.version.length > 0) {
Text('v' + this.plugin.version)
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
Row({ space: 6 }) {
Circle({ width: 6, height: 6 })
.fill(pluginStatusColor(this.plugin, this.palette().textMuted))
Text(pluginRowSubtitle(this.plugin))
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Image($r('app.media.ic_chevron_right'))
.width(15)
.height(15)
.fillColor(this.active ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.draggable(false)
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_LG)
.backgroundColor(this.active ? this.palette().accentBg : this.palette().bgCard)
.border({
width: 1,
color: this.active ? this.palette().accent : this.palette().glassBorder,
})
// 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.alignItems(VerticalAlign.Center)
.onClick(() => {
const cb: (() => void) | undefined = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
.margin({ bottom: 10 })
// ForEach key 含 loaded/disabled:启停会整行重挂载,靠 transition 变成交叉淡入
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}
/**
* 一级列表整体:占位态 + 行卡 ForEach。
* 取数留在页面里,这里只吃数据。
*/
@Component
export struct PluginListView {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop plugins: PluginRow[] = [];
@Prop busy: boolean = false;
@Prop activeName: string = '';
@Prop hasConn: boolean = false;
onSelect?: (name: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
// ── 加载中 / 未配置 / 空列表三种占位态 ──
if (this.busy && this.plugins.length === 0) {
LoadingProgress()
.width(32)
.height(32)
.color(this.palette().accent)
.margin({ top: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.hasConn) {
Text(noConnectionMessage())
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.busy && this.plugins.length === 0 && this.hasConn) {
Text('暂无已加载插件')
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.plugins, (plugin: PluginRow) => {
PluginListCard({
plugin: plugin,
active: this.activeName === plugin.name,
onTap: () => {
const cb: ((name: string) => void) | undefined = this.onSelect;
if (cb !== undefined) {
cb(plugin.name);
}
},
})
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
}
.width('100%')
}
}

View File

@ -0,0 +1,65 @@
/**
* 安装表单悬浮卡。
*
* 从 pages/PluginsPage.ets 抽出(浮在内容之上的独立图层,与列表/详情无关)。
* 轻提示条与设置页共用,见 components/ToastBar.ets。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
ANIM_ENTER } from '../common/Constants';
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
@Component
export struct PluginInstallForm {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop url: string = '';
onUrlChange?: (v: string) => void;
onInstall?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Row() {
TextInput({ placeholder: '.hmap 包下载 URL', text: this.url })
.layoutWeight(1)
.height(36)
.fontSize(14)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.onChange((v: string) => {
const cb: ((v: string) => void) | undefined = this.onUrlChange;
if (cb !== undefined) {
cb(v);
}
})
Button('安装')
.height(36)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.margin({ left: 6 })
.onClick(() => {
const cb: (() => void) | undefined = this.onInstall;
if (cb !== undefined) {
cb();
}
})
}
.width('100%')
.padding(10)
.margin({ bottom: 10 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.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 }))
}
}

View File

@ -0,0 +1,48 @@
/**
* 右下角轻提示条(设置页与插件页共用)。
*
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
* 用 bordered 表达这个差异(width 0 的边框不占位、不可见)。
*
* 配色由调用方决定:颜色标志必须在调用方落 animateTo 之前先写好,
* 否则第一帧会用上一条 toast 的配色。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, ANIM_NORMAL } from '../common/Constants';
@Component
export struct ToastBar {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop msg: string = '';
@Prop isError: boolean = false;
/** 是否带一圈语义色描边(插件页用,设置页不用) */
@Prop bordered: boolean = false;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
if (this.msg.length > 0) {
Row() {
Text(this.msg)
.fontSize(13)
.fontColor(this.isError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.isError ? this.palette().toastErrorBg : this.palette().toastBg)
.border({
width: this.bordered ? 1 : 0,
color: this.isError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
}

View File

@ -1,24 +1,26 @@
import { apiClient } from '../common/ApiClient';
import { userMessage, noConnectionMessage } from '../common/UserError';
import { userMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController';
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';
interface InstallBody {
url: string;
}
import { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
import { PluginDetailPane } from '../components/PluginDetailPane';
import { PluginListView } from '../components/PluginListView';
import { PluginInstallForm } from '../components/PluginsOverlays';
import { ToastBar } from '../components/ToastBar';
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
/** 二级页面标识:插件详情 */
const SUB_NONE: string = '';
const SUB_DETAIL: string = 'detail';
interface InstallBody {
url: string;
}
@Component
export struct PluginsPage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@ -58,134 +60,14 @@ export struct PluginsPage {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 数据源对齐 WebGUI renderPlugins:
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
* - GET /plugins → 已安装外部插件元数据(version/description 等)
* - GET /plugins/disabled → {disabled:[{name,...}]}
* 三方按名称合并去重排序。
*/
/** 取数口径见 common/PluginApi.ets;这里只负责把结果落到 @State。 */
private async loadPlugins(): Promise<void> {
if (!apiClient.hasConnection()) {
return;
}
this.loading = true;
try {
// ---- kernel: loaded plugins + tool ownership ----
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const kpRaw: Object | undefined = kernelObj['plugins'];
if (kpRaw !== undefined && kpRaw !== null) {
const kpArr: Object[] = kpRaw as Object[];
for (let i = 0; i < kpArr.length; i++) {
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length === 0) {
continue;
}
loadedMap.set(n, item['loaded'] as boolean ?? true);
}
}
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
const tRaw: Object | undefined = kernelObj['tools'];
if (tRaw !== undefined && tRaw !== null) {
const tArr: Object[] = tRaw as Object[];
for (let i = 0; i < tArr.length; i++) {
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
const tn: string = item['name'] as string ?? '';
const owner: string = item['plugin'] as string ?? '';
if (tn.length === 0 || owner.length === 0) {
continue;
}
let list: string[] | undefined = toolsByPlugin.get(owner);
if (list === undefined) {
list = [];
toolsByPlugin.set(owner, list);
}
// 每插件最多展示 8 个工具名,避免卡片过长
if (list.length < 8) {
list.push(tn);
}
}
}
// ---- installed external plugins metadata ----
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
try {
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
const bodyTrim = pResp.body.trim();
let arr: Object[] = [];
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
arr = JSON.parse(pResp.body) as Object[];
} else {
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
const rawList: Object = obj['plugins'] ?? obj['data'];
if (rawList !== undefined && rawList !== null) {
arr = rawList as Object[];
}
}
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length > 0) {
externalMeta.set(n, item);
}
}
} catch (e) {
// 外部列表失败不阻塞内置展示
}
// ---- disabled list ----
const disabledNames: Set<string> = new Set<string>();
try {
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
const dArr: Object | undefined = dObj['disabled'];
if (dArr !== undefined && dArr !== null) {
const items: Object[] = dArr as Object[];
for (let di = 0; di < items.length; di++) {
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
const dn: string = dItem['name'] as string ?? '';
if (dn.length > 0) {
disabledNames.add(dn);
}
}
}
} catch (e) {
// disabled endpoint may not exist; ignore
}
// ---- merge: allNames sorted(与 GUI 一致)----
const allNames: Set<string> = new Set<string>();
loadedMap.forEach((v: boolean, k: string) => {
allNames.add(k);
});
externalMeta.forEach((v: Record<string, Object>, k: string) => {
allNames.add(k);
});
disabledNames.forEach((n: string) => {
allNames.add(n);
});
const names: string[] = Array.from(allNames);
names.sort();
const rows: PluginRow[] = [];
for (let i = 0; i < names.length; i++) {
const name: string = names[i];
const meta: Record<string, Object> | undefined = externalMeta.get(name);
const tools: string[] | undefined = toolsByPlugin.get(name);
const row: PluginRow = {
name: name,
loaded: loadedMap.get(name) ?? false,
disabled: disabledNames.has(name),
external: externalMeta.has(name),
version: meta !== undefined ? meta['version'] as string ?? '' : '',
description: meta !== undefined ? meta['description'] as string ?? '' : '',
tools: tools,
};
rows.push(row);
}
const rows: PluginRow[] = await fetchPluginRows();
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
@ -199,20 +81,6 @@ export struct PluginsPage {
});
}
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
private statusOf(plugin: PluginRow): string {
if (plugin.loaded && !plugin.disabled) {
return 'loaded'; // 已加载
}
if (plugin.loaded && plugin.disabled) {
return 'pending'; // 运行中(禁用待生效)
}
if (plugin.disabled) {
return 'disabled'; // 已禁用
}
return 'notloaded'; // 未加载
}
private async togglePlugin(plugin: PluginRow): Promise<void> {
const name: string = plugin.name;
const action: string = plugin.disabled ? 'enable' : 'disable';
@ -280,29 +148,14 @@ export struct PluginsPage {
}
/**
* GET /plugins/{name} —— 后端返回插件清单字段。
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
* 详情数据落地。
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
*/
private async loadDetail(name: string): Promise<void> {
this.detailLoading = true;
this.detailError = '';
try {
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const d: PluginDetail = emptyPluginDetail();
d.name = o['name'] as string ?? name;
d.version = o['version'] as string ?? '';
d.description = o['description'] as string ?? '';
d.author = o['author'] as string ?? '';
d.license = o['license'] as string ?? '';
d.homepage = o['homepage'] as string ?? '';
d.repository = o['repository'] as string ?? '';
d.entry = o['entry'] as string ?? '';
d.minVersion = o['min_version'] as string ?? '';
d.deprecated = o['deprecated'] as boolean ?? false;
d.tags = this.strArray(o['tags']);
d.files = this.strArray(o['files']);
const d: PluginDetail = await fetchPluginDetail(name);
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detail = d;
@ -327,21 +180,6 @@ export struct PluginsPage {
});
}
private strArray(raw: Object | undefined): string[] {
const out: string[] = [];
if (raw === undefined || raw === null) {
return out;
}
const arr: Object[] = raw as Object[];
for (let i = 0; i < arr.length; i++) {
const s: string = arr[i] as string ?? '';
if (s.length > 0) {
out.push(s);
}
}
return out;
}
private findRow(name: string): PluginRow | null {
for (let i = 0; i < this.plugins.length; i++) {
if (this.plugins[i].name === name) {
@ -387,8 +225,15 @@ export struct PluginsPage {
Column() {
Scroll() {
Column() {
this.ListStates()
this.PluginList()
PluginListView({
plugins: this.plugins,
busy: this.loading,
activeName: this.activeName,
hasConn: apiClient.hasConnection(),
onSelect: (name: string) => {
this.openDetail(name);
},
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
@ -411,7 +256,15 @@ export struct PluginsPage {
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
NavFloatOverlay({ tab: 1 }) {
if (this.showInstallForm) {
this.InstallForm()
PluginInstallForm({
url: this.installUrl,
onUrlChange: (v: string) => {
this.installUrl = v;
},
onInstall: () => {
this.installPlugin();
},
})
}
NavFloatRow() {
FloatIconButton({
@ -441,7 +294,7 @@ export struct PluginsPage {
}
}
this.Toast()
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
}
.width('100%')
.height('100%')
@ -493,503 +346,29 @@ export struct PluginsPage {
this.loadDetail(this.activeName);
},
}) {
this.DetailContent()
PluginDetailPane({
detail: this.detail,
activeName: this.activeName,
busy: this.detailLoading,
errorText: this.detailError,
row: this.activeRow() ?? undefined,
onToggle: () => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.togglePlugin(r);
}
},
onRemove: () => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.removePlugin(r);
}
},
})
}
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
@Builder
InstallForm() {
Row() {
TextInput({ placeholder: '.hmap 包下载 URL', text: this.installUrl })
.layoutWeight(1)
.height(36)
.fontSize(14)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.onChange((v: string) => {
this.installUrl = v;
})
Button('安装')
.height(36)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.margin({ left: 6 })
.onClick(() => {
this.installPlugin();
})
}
.width('100%')
.padding(10)
.margin({ bottom: 10 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.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 }))
}
/** 加载中 / 未配置 / 空列表三种占位态 */
@Builder
ListStates() {
if (this.loading && this.plugins.length === 0) {
LoadingProgress()
.width(32)
.height(32)
.color(this.palette().accent)
.margin({ top: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!apiClient.hasConnection()) {
Text(noConnectionMessage())
.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()) {
Text('暂无已加载插件')
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
/**
* 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
* 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
*/
@Builder
PluginList() {
ForEach(this.plugins, (plugin: PluginRow) => {
// 外层 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)
}
.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);
})
}
}
.width('100%')
.margin({ bottom: 10 })
// 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' : ''))
}
/**
* 二级页面:插件详情。
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
*/
@Builder
DetailContent() {
if (this.detailLoading) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.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) {
Text(this.detailError)
.fontSize(12)
.fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 概览卡:名称、版本、状态徽标、描述
PlainCard({ caption: '概览' }) {
Row({ space: 8 }) {
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.detail.version.length > 0) {
Text('v' + this.detail.version)
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
}
.width('100%')
.margin({ bottom: 10 })
this.DetailBadges()
if (this.detail.description.length > 0) {
Text(this.detail.description)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.width('100%')
.margin({ top: 10 })
}
}
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
if (this.hasManifest()) {
PlainCard({ caption: '清单' }) {
this.KvRow('作者', this.detail.author)
this.KvRow('许可证', this.detail.license)
this.KvRow('主页', this.detail.homepage)
this.KvRow('仓库', this.detail.repository)
this.KvRow('入口', this.detail.entry)
this.KvRow('最低内核版本', this.detail.minVersion)
}
}
if (this.detail.tags.length > 0) {
PlainCard({ caption: '标签' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (t: string) => t)
}
}
}
this.DetailTools()
// 插件配置:plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
if (this.activeName.length > 0) {
PlainCard({ caption: '插件配置' }) {
SettingsEditor({
prefix: 'plugin.' + this.activeName + '.',
emptyHint: '该插件没有暴露可配置项',
})
}
}
if (this.detail.files.length > 0) {
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
ForEach(this.detail.files, (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 4 })
}, (f: string) => f)
}
}
this.DetailActions()
}
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
@Builder
DetailBadges() {
Row({ space: 6 }) {
Circle({ width: 7, height: 7 })
.fill(this.activeStatusColor())
Text(this.detailStatusLine())
.fontSize(12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
private detailStatusLine(): string {
const parts: string[] = [];
parts.push(this.activeStatusText());
parts.push(this.activeIsBuiltin() ? '内置' : '外部');
if (this.detail.deprecated) {
parts.push('已废弃');
}
const n: number = this.activeTools().length;
if (n > 0) {
parts.push(n.toString() + ' 个工具');
}
return parts.join(' · ');
}
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
@Builder
DetailTools() {
if (this.activeTools().length > 0) {
PlainCard({ caption: '注册的工具 (' + this.activeTools().length.toString() + ')' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.activeTools(), (tool: string) => {
Text(tool)
.fontSize(11)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (tool: string) => tool)
}
}
}
}
@Builder
DetailActions() {
if (this.activeName.length > 0) {
PlainCard({ caption: '操作' }) {
Row() {
Button(this.activeIsDisabled() ? '启用' : '禁用')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({
width: 1,
color: this.activeIsDisabled()
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
})
.fontColor(this.activeIsDisabled()
? this.palette().textSecondary : '#D99A2B')
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.togglePlugin(r);
}
})
Blank()
if (!this.activeIsBuiltin()) {
Button('卸载')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor(COLOR_ERROR)
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.removePlugin(r);
}
})
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
}
}
}
// ---- 详情页取值助手:ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
private hasManifest(): boolean {
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
}
private activeIsBuiltin(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? !r.external : false;
}
private activeIsDisabled(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? r.disabled : false;
}
private activeTools(): string[] {
const r: PluginRow | null = this.activeRow();
if (r === null) {
return [];
}
return r.tools ?? [];
}
private activeStatusText(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeText(r) : '未加载';
}
private activeStatusColor(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeColor(r) : this.palette().textMuted;
}
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
@Builder
KvRow(label: string, value: string) {
if (value.length > 0) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 220 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
}
@Builder
Toast() {
if (this.toastMsg.length > 0) {
Row() {
Text(this.toastMsg)
.fontSize(13)
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
.border({
width: 1,
color: this.toastIsError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
/** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
private statusDotColor(plugin: PluginRow): string {
return this.statusBadgeColor(plugin);
}
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
private rowSubtitle(plugin: PluginRow): string {
const parts: string[] = [];
parts.push(this.statusBadgeText(plugin));
parts.push(plugin.external ? '外部' : '内置');
if (plugin.tools !== undefined && plugin.tools.length > 0) {
parts.push(plugin.tools.length.toString() + ' 工具');
}
return parts.join(' · ');
}
private statusBadgeText(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '已加载';
}
if (s === 'pending') {
return '待生效';
}
if (s === 'disabled') {
return '已禁用';
}
return '未加载';
}
private statusBadgeColor(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '#17A964';
}
if (s === 'pending') {
return '#D99A2B';
}
if (s === 'disabled') {
return '#E84026';
}
return this.palette().textMuted;
}
}