Improve biz_state monitor-item table UX with select-all and visible scroll.

Keep the horizontal scrollbar in the viewport, sticky enable/title columns, and show selected/total (and unbound) counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-23 11:40:01 +08:00
parent a1acd4c2af
commit 86fcb98218
4 changed files with 193 additions and 38 deletions

View file

@ -239,6 +239,12 @@ const en = {
scheduleSaved: "Schedule saved",
colInterval: "Interval",
profiles: "Monitor items",
profilesSelectAll: "Select all",
profilesDeselectAll: "Deselect all",
profilesSelectAllOk: "Enabled {{n}} item(s)",
profilesDeselectAllOk: "All items disabled",
profilesSelectionStats: "Selected {{enabled}} / {{total}}",
profilesUnboundStats: "{{n}} unbound",
colLane: "Collect lane",
laneLight: "Light",
laneHeavy: "Heavy",

View file

@ -239,6 +239,12 @@ const zh = {
scheduleSaved: "周期配置已保存",
colInterval: "周期",
profiles: "监控项",
profilesSelectAll: "全选",
profilesDeselectAll: "取消全选",
profilesSelectAllOk: "已启用 {{n}} 项",
profilesDeselectAllOk: "已全部取消启用",
profilesSelectionStats: "已选 {{enabled}} / 共 {{total}} 项",
profilesUnboundStats: "未关联 {{n}}",
colLane: "采集车道",
laneLight: "轻车道",
laneHeavy: "重车道",

View file

@ -11396,13 +11396,35 @@ html.login-page--paused .login-page__flare {
margin-bottom: 4px;
}
.bs-profiles-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
flex: 1 1 auto;
max-height: min(62vh, 620px);
}
.bs-profiles-toolbar {
flex: 0 0 auto;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bs-profiles-stats {
font-size: 12px;
white-space: nowrap;
}
.bs-profiles-table-wrap {
overflow-x: scroll !important;
overflow-y: auto !important;
max-height: min(58vh, 560px);
flex: 1 1 auto;
min-height: 0;
overflow: auto !important;
max-height: none;
max-width: 100%;
/* Keep horizontal scrollbar gutter visible. */
scrollbar-gutter: stable;
/* Always reserve room for both scrollbars so H-bar stays in the visible viewport. */
scrollbar-gutter: stable both-edges;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.65) rgba(15, 23, 42, 0.9);
}
@ -11428,8 +11450,8 @@ html.login-page--paused .login-page__flare {
}
.bs-profiles-table {
width: 100%;
min-width: 1180px;
width: max-content;
min-width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
@ -11443,9 +11465,10 @@ html.login-page--paused .login-page__flare {
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.25);
}
/* enable | title | lane | actions | params | command | aux */
.bs-profiles-table th:nth-child(1),
.bs-profiles-table td:nth-child(1) {
width: 48px;
width: 52px;
}
.bs-profiles-table th:nth-child(2),
@ -11455,22 +11478,53 @@ html.login-page--paused .login-page__flare {
.bs-profiles-table th:nth-child(3),
.bs-profiles-table td:nth-child(3) {
width: 200px;
width: 88px;
}
.bs-profiles-table th:nth-child(4),
.bs-profiles-table td:nth-child(4) {
width: 100px;
width: 120px;
}
.bs-profiles-table th:nth-child(5),
.bs-profiles-table td:nth-child(5) {
width: 280px;
width: 200px;
}
.bs-profiles-table th:nth-child(6),
.bs-profiles-table td:nth-child(6) {
width: auto;
width: 360px;
}
.bs-profiles-table th:nth-child(7),
.bs-profiles-table td:nth-child(7) {
width: 280px;
}
.bs-profiles-sticky-col {
position: sticky;
z-index: 2;
background: rgba(15, 23, 42, 0.97);
}
.bs-profiles-table thead .bs-profiles-sticky-col,
.bs-profiles-table thead th:nth-child(1),
.bs-profiles-table thead th:nth-child(2) {
z-index: 4;
}
.bs-profiles-sticky-col--1,
.bs-profiles-table th:nth-child(1),
.bs-profiles-table td.bs-profiles-sticky-col--1 {
left: 0;
box-shadow: 1px 0 0 rgba(148, 163, 184, 0.2);
}
.bs-profiles-sticky-col--2,
.bs-profiles-table th:nth-child(2),
.bs-profiles-table td.bs-profiles-sticky-col--2 {
left: 52px;
box-shadow: 1px 0 0 rgba(148, 163, 184, 0.2);
}
.bs-profiles-table .bs-cmd-cell {
@ -14450,17 +14504,23 @@ option {
white-space: nowrap;
}
/* Biz-state profiles: keep wide table + visible H-scroll (overrides modal fill rules). */
/* Biz-state profiles: keep wide table + H-scroll inside a fixed viewport. */
.app-heroui-modal__dialog [data-slot="modal-body"] > .bs-profiles-panel,
.app-heroui-modal__dialog .modal__body > .bs-profiles-panel {
flex-shrink: 1;
min-height: 0;
}
.app-heroui-modal .pt-list-table-wrap.bs-profiles-table-wrap {
overflow-x: scroll !important;
overflow-y: auto !important;
overflow: auto !important;
max-width: 100%;
width: 100%;
max-height: none;
}
.app-heroui-modal .pt-list-table.bs-profiles-table {
width: 100% !important;
min-width: 960px !important;
width: max-content !important;
min-width: 100% !important;
table-layout: fixed !important;
}

View file

@ -455,6 +455,28 @@ export function BizStatePage() {
[profiles],
);
const profileSelectionStats = useMemo(() => {
const items = (detail?.items || []) as Array<{
source_profile_id?: string;
enabled?: boolean;
bindings?: unknown[];
}>;
const byId = new Map(items.map((it) => [String(it.source_profile_id || ""), it]));
let enabled = 0;
let unbound = 0;
for (const prof of collectProfiles) {
const it = byId.get(prof.profile_id);
if (!it?.enabled) continue;
enabled += 1;
if ((prof.placeholders || []).length > 0) {
const binds = (it.bindings || []) as Array<{ value?: string }>;
const hasVal = binds.some((b) => String(b.value || "").trim());
if (!hasVal) unbound += 1;
}
}
return { total: collectProfiles.length, enabled, unbound };
}, [collectProfiles, detail?.items]);
const sheetTabs = useMemo(
() => (batchDetail ? buildSheetTabs(batchDetail, t) : []),
[batchDetail, t],
@ -951,6 +973,40 @@ export function BizStatePage() {
}
};
const setAllProfilesEnabled = async (enable: boolean) => {
if (!taskId || !detail || !collectProfiles.length) return;
const items = [...(detail.items || [])] as any[];
const byId = new Map(items.map((it, i) => [String(it.source_profile_id || ""), i]));
for (const prof of collectProfiles) {
const idx = byId.get(prof.profile_id);
if (idx != null) {
items[idx] = { ...items[idx], enabled: enable };
} else if (enable) {
items.push({
source_profile_id: prof.profile_id,
kind: "catalog",
enabled: true,
title: prof.title,
bindings: [],
});
}
}
setBusy(true);
try {
await bizStatePatchTask(taskId, { items });
await loadTask(taskId);
showOk(
enable
? t("bizState.profilesSelectAllOk", { n: collectProfiles.length })
: t("bizState.profilesDeselectAllOk"),
);
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const closeBindModal = () => {
setBindItemId("");
setCandidates([]);
@ -1650,7 +1706,34 @@ export function BizStatePage() {
</div>
{taskTab === "profiles" ? (
<>
<div className="bs-profiles-panel">
<div className="btn-row bs-profiles-toolbar">
<Button
size="sm"
variant="secondary"
isDisabled={busy || !collectProfiles.length}
onPress={() => void setAllProfilesEnabled(true)}
>
{t("bizState.profilesSelectAll")}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={busy || !profileSelectionStats.enabled}
onPress={() => void setAllProfilesEnabled(false)}
>
{t("bizState.profilesDeselectAll")}
</Button>
<span className="muted bs-profiles-stats">
{t("bizState.profilesSelectionStats", {
enabled: profileSelectionStats.enabled,
total: profileSelectionStats.total,
})}
{profileSelectionStats.unbound
? ` · ${t("bizState.profilesUnboundStats", { n: profileSelectionStats.unbound })}`
: ""}
</span>
</div>
<div className="pt-list-table-wrap bs-profiles-table-wrap">
<table className="data-table pt-list-table bs-profiles-table">
<thead>
@ -1658,8 +1741,8 @@ export function BizStatePage() {
<th>{t("bizState.enable")}</th>
<th>{t("bizState.profiles")}</th>
<th>{t("bizState.colLane")}</th>
<th>{t("bizState.params")}</th>
<th>{t("bizState.colActions")}</th>
<th>{t("bizState.params")}</th>
<th>{t("bizState.command")}</th>
<th>{t("bizState.colAux")}</th>
</tr>
@ -1710,7 +1793,7 @@ export function BizStatePage() {
: "—";
return (
<tr key={prof.profile_id}>
<td>
<td className="bs-profiles-sticky-col bs-profiles-sticky-col--1">
<input
type="checkbox"
checked={enabled}
@ -1718,7 +1801,7 @@ export function BizStatePage() {
onChange={(e) => void toggleProfileItem(prof, e.target.checked)}
/>
</td>
<td>
<td className="bs-profiles-sticky-col bs-profiles-sticky-col--2">
<div className="pt-list-task-name">{prof.title}</div>
{prof.description ? <div className="muted">{prof.description}</div> : null}
{needsBind ? (
@ -1736,6 +1819,22 @@ export function BizStatePage() {
</span>
)}
</td>
<td className="bs-actions-cell">
{needsBind && enabled && it ? (
<Button
size="sm"
variant="secondary"
isDisabled={busy || discoverLoading}
onPress={() => void startDiscover(it)}
>
{t("bizState.discoverBind")}
</Button>
) : needsBind ? (
<span className="muted">—</span>
) : (
"—"
)}
</td>
<td className="bs-params-cell">
{needsBind ? (
<div
@ -1761,22 +1860,6 @@ export function BizStatePage() {
"—"
)}
</td>
<td className="bs-actions-cell">
{needsBind && enabled && it ? (
<Button
size="sm"
variant="secondary"
isDisabled={busy || discoverLoading}
onPress={() => void startDiscover(it)}
>
{t("bizState.discoverBind")}
</Button>
) : needsBind ? (
<span className="muted">—</span>
) : (
"—"
)}
</td>
<td>
<code className="bs-cmd-cell" title={prof.command_template}>
{prof.command_template}
@ -1809,7 +1892,7 @@ export function BizStatePage() {
</tbody>
</table>
</div>
</>
</div>
) : (
<div className="pt-list-table-wrap">
<div className="btn-row" style={{ marginBottom: 8, gap: 8, flexWrap: "wrap" }}>