Admin UX rework: shared foundation + main console + LLM Gateway (start)#40
Conversation
Reusable admin building blocks for the admin UX rework: - StatusBadge: one tone+icon status pill (centralizes the per-page status_badge_class helpers) with dark-mode variants. - CopyButton: shared copy-to-clipboard with a "copied" checkmark flash (replaces the duplicated copy_text FFI + copy_icon_button). - EmptyState: centered empty/error placeholder for admin lists/tables. - Drawer: right-side slide-out detail panel so a record's detail no longer pushes the table out of view. - CSS: .btn-fluent-danger for destructive actions, a clearer disabled state, plus the status/empty/drawer styling. First adoption in the main console (admin.rs): music-wish & article-request status pills and the tasks row use StatusBadge; the behavior events table uses CopyButton; delete buttons use .btn-fluent-danger (were plain secondary). The now-superseded copy_text FFI + copy_icon_button are removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…awers The Tasks and Published tabs rendered a record's detail editor inline below the table, so selecting a row pushed the table out of view and the editor drowned the list on mobile. Both now open in the shared right-side Drawer (dismiss via backdrop / close button), keeping the table in place. The detail textareas also get explicit surface/text colors so they read correctly in dark mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The music-wish and article-request tabs showed bare one-line "No wishes yet" / "No article requests yet" text for both the empty-collection and filtered-to-nothing cases. They now use the shared EmptyState (icon + title + hint) so an empty board reads clearly and the filtered-empty case nudges the user to adjust their search. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Published and Audit tabs rendered a header-only table with an empty body when there was no data, so "nothing here" was indistinguishable from a glitch. Both now show the shared EmptyState when their list is empty. The Published delete button also adopts the danger style (was a plain secondary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er filters
- Tasks row: the 5 crammed action buttons now separate the positive actions
(Approve / Approve+Codex / Retry) from the destructive ones with a divider,
and Delete adopts the danger style — much harder to mis-click delete.
- Audit log: the raw action string ("approve" / "reject" / "delete" / …) is now
a colored StatusBadge (the badge map gained the action verbs), so the log is
scannable at a glance.
- Behavior: the HTTP-status filter placeholder/title now says what it expects
("HTTP 状态码 (200)") instead of a bare "status".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Start of the LLM Gateway pass. The gateway admin uses its own terminal-style button language (.btn-terminal), so destructive actions get a matching .btn-terminal-danger variant (clear red border/fill, dark-mode aware) instead of a near-invisible red-text-on-plain-button. Applied to the key / group / proxy-config / account delete buttons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sBadge Replace two hand-rolled status-pill implementations in the gateway (the key_status_badge helper and the token-request inline status_class map) with the shared StatusBadge so they get the same tone+icon and one dark-mode source of truth. Extended the badge map with "issued" and grouped active/enabled/healthy/ ok/valid under the green "done" tone (matching the gateway's prior emerald convention) while keeping task "approved" on the blue tone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request establishes a shared admin foundation by introducing reusable UI components—CopyButton, Drawer, EmptyState, and StatusBadge—along with corresponding CSS styles, and refactors the admin pages to use them. The review feedback highlights opportunities to optimize performance by avoiding unnecessary heap allocations from .to_string() calls on AttrValue and boolean properties. Additionally, it identifies a UX bug where empty states could render simultaneously with loading spinners, recommending that empty states only display once loading is complete.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| pub fn copy_button(props: &CopyButtonProps) -> Html { | ||
| let copied = use_state(|| false); | ||
| let onclick = { | ||
| let text = props.text.to_string(); |
There was a problem hiding this comment.
Avoid calling .to_string() on AttrValue as it allocates a new String on the heap. Since AttrValue is cheaply cloneable (under the hood it is either an Rc<str> or a &'static str), using .clone() is much more efficient and avoids unnecessary heap allocations.
| let text = props.text.to_string(); | |
| let text = props.text.clone(); |
| html! { | ||
| <div | ||
| class={classes!("admin-drawer-root", open_class)} | ||
| aria-hidden={(!props.open).to_string()} |
There was a problem hiding this comment.
Avoid calling (!props.open).to_string() as it allocates a String on the heap on every render. Using a conditional expression like if props.open { "false" } else { "true" } returns a &'static str and avoids heap allocation entirely.
| aria-hidden={(!props.open).to_string()} | |
| aria-hidden={if props.open { "false" } else { "true" }} |
| let tone_class = (props.tone == "error").then_some("admin-empty--error"); | ||
| html! { | ||
| <div class={classes!("admin-empty", tone_class)}> | ||
| <i class={classes!("fas", props.icon.to_string(), "admin-empty__icon")} aria-hidden="true"></i> |
There was a problem hiding this comment.
Avoid calling props.icon.to_string() as it allocates a String on the heap. Since props.icon is an AttrValue, you can use .clone() instead, which is cheaply cloneable and avoids heap allocation.
| <i class={classes!("fas", props.icon.to_string(), "admin-empty__icon")} aria-hidden="true"></i> | |
| <i class={classes!("fas", props.icon.clone(), "admin-empty__icon")} aria-hidden="true"></i> |
| if published_comments.is_empty() { | ||
| <EmptyState icon="fa-comments" title="暂无已发布评论" hint="审核通过的评论会显示在这里。" /> | ||
| } else { |
There was a problem hiding this comment.
There is a UX bug here: when the tab is still loading and published_comments is empty, both the loading spinner and the EmptyState component will be rendered simultaneously. To prevent this, ensure that EmptyState is only rendered when the tab is not currently loading.
| if published_comments.is_empty() { | |
| <EmptyState icon="fa-comments" title="暂无已发布评论" hint="审核通过的评论会显示在这里。" /> | |
| } else { | |
| if published_comments.is_empty() { | |
| if !tab_loading.contains(&AdminTab::Published) { | |
| <EmptyState icon="fa-comments" title="暂无已发布评论" hint="审核通过的评论会显示在这里。" /> | |
| } | |
| } else { |
| if audit_logs.is_empty() { | ||
| <EmptyState icon="fa-clipboard-list" title="暂无审计记录" hint="对任务的操作会记录在这里。" /> | ||
| } else { |
There was a problem hiding this comment.
There is a UX bug here: when the tab is still loading and audit_logs is empty, both the loading spinner and the EmptyState component will be rendered simultaneously. To prevent this, ensure that EmptyState is only rendered when the tab is not currently loading.
| if audit_logs.is_empty() { | |
| <EmptyState icon="fa-clipboard-list" title="暂无审计记录" hint="对任务的操作会记录在这里。" /> | |
| } else { | |
| if audit_logs.is_empty() { | |
| if !tab_loading.contains(&AdminTab::Audit) { | |
| <EmptyState icon="fa-clipboard-list" title="暂无审计记录" hint="对任务的操作会记录在这里。" /> | |
| } | |
| } else { |
| if music_wishes.is_empty() { | ||
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "No wishes yet." }</p> | ||
| <EmptyState icon="fa-music" title="暂无点歌" hint="还没有用户提交点歌请求。" /> | ||
| } else if filtered_music_wishes.is_empty() { | ||
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "当前过滤条件下没有匹配项。" }</p> | ||
| <EmptyState icon="fa-filter" title="没有匹配项" hint="当前过滤条件下没有匹配的点歌,试试调整搜索。" /> | ||
| } else { |
There was a problem hiding this comment.
There is a UX bug here: when the tab is still loading and music_wishes is empty, both the loading spinner and the EmptyState component will be rendered simultaneously. To prevent this, ensure that EmptyState is only rendered when the tab is not currently loading.
| if music_wishes.is_empty() { | |
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "No wishes yet." }</p> | |
| <EmptyState icon="fa-music" title="暂无点歌" hint="还没有用户提交点歌请求。" /> | |
| } else if filtered_music_wishes.is_empty() { | |
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "当前过滤条件下没有匹配项。" }</p> | |
| <EmptyState icon="fa-filter" title="没有匹配项" hint="当前过滤条件下没有匹配的点歌,试试调整搜索。" /> | |
| } else { | |
| if music_wishes.is_empty() { | |
| if !tab_loading.contains(&AdminTab::MusicWishes) { | |
| <EmptyState icon="fa-music" title="暂无点歌" hint="还没有用户提交点歌请求。" /> | |
| } | |
| } else if filtered_music_wishes.is_empty() { | |
| <EmptyState icon="fa-filter" title="没有匹配项" hint="当前过滤条件下没有匹配的点歌,试试调整搜索。" /> | |
| } else { |
| if article_requests.is_empty() { | ||
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "No article requests yet." }</p> | ||
| <EmptyState icon="fa-file-lines" title="暂无投稿请求" hint="还没有用户提交文章投稿请求。" /> | ||
| } else if filtered_article_requests.is_empty() { | ||
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "当前过滤条件下没有匹配项。" }</p> | ||
| <EmptyState icon="fa-filter" title="没有匹配项" hint="当前过滤条件下没有匹配的请求,试试调整搜索。" /> | ||
| } else { |
There was a problem hiding this comment.
There is a UX bug here: when the tab is still loading and article_requests is empty, both the loading spinner and the EmptyState component will be rendered simultaneously. To prevent this, ensure that EmptyState is only rendered when the tab is not currently loading.
| if article_requests.is_empty() { | |
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "No article requests yet." }</p> | |
| <EmptyState icon="fa-file-lines" title="暂无投稿请求" hint="还没有用户提交文章投稿请求。" /> | |
| } else if filtered_article_requests.is_empty() { | |
| <p class={classes!("m-0", "text-sm", "text-[var(--muted)]")}>{ "当前过滤条件下没有匹配项。" }</p> | |
| <EmptyState icon="fa-filter" title="没有匹配项" hint="当前过滤条件下没有匹配的请求,试试调整搜索。" /> | |
| } else { | |
| if article_requests.is_empty() { | |
| if !tab_loading.contains(&AdminTab::ArticleRequests) { | |
| <EmptyState icon="fa-file-lines" title="暂无投稿请求" hint="还没有用户提交文章投稿请求。" /> | |
| } | |
| } else if filtered_article_requests.is_empty() { | |
| <EmptyState icon="fa-filter" title="没有匹配项" hint="当前过滤条件下没有匹配的请求,试试调整搜索。" /> | |
| } else { |
…components
Apply the valid performance notes from the PR code review: AttrValue / bool
props no longer round-trip through .to_string() (a heap allocation each render).
CopyButton clones the cheap AttrValue, EmptyState passes the icon AttrValue
straight into classes!, and Drawer's aria-hidden uses a &'static str.
The review's "empty state renders alongside the loading spinner" notes do not
apply: each list's outer `if loading && list.is_empty() { spinner } else { … }`
guard already makes the empty branch reachable only when not loading, so the two
are mutually exclusive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admin UX rework — foundation + main console + LLM Gateway (start)
Improves the operability and visual consistency of the admin interface, in
reviewable phases. Each commit is
cargo check-clean (wasm target) with the CSSrebuilt and verified.
Phase 0 — shared foundation (propagates to every admin page)
status_badge_class/key_status_badgehelpers) with dark-mode variants..btn-fluent-danger/.btn-terminal-dangerfor destructiveactions, a clearer disabled state, and the status/empty/drawer styling.
Phase 1 — main console (
admin.rs, 7 tabs)pushing the table out of view; their textareas read correctly in dark mode.
loading vs failed are now distinguishable).
actions from Reject/Delete with a divider so delete is hard to mis-click.
filter explains it expects an HTTP code.
Phase 2 — LLM Gateway (
admin_llm_gateway.rs, started).btn-terminal-dangervariant.Remaining (follow-up)
Deep rework of the LLM Gateway IA (8 tabs / unified modals / usage views), the
Kiro Gateway, gpt2api-rs, the monitor, local-media, and the AI stream pages.
🤖 Generated with Claude Code