Skip to content

Admin UX rework: shared foundation + main console + LLM Gateway (start)#40

Merged
acking-you merged 8 commits into
masterfrom
feat/admin-ux
Jun 13, 2026
Merged

Admin UX rework: shared foundation + main console + LLM Gateway (start)#40
acking-you merged 8 commits into
masterfrom
feat/admin-ux

Conversation

@acking-you

Copy link
Copy Markdown
Owner

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 CSS
rebuilt and verified.

Phase 0 — shared foundation (propagates to every admin page)

  • StatusBadge — one tone+icon status pill (centralizes the per-page
    status_badge_class / key_status_badge helpers) with dark-mode variants.
  • CopyButton — shared copy-to-clipboard with a "copied ✓" flash.
  • EmptyState — centered empty/error placeholder for lists/tables.
  • Drawer — right-side slide-out detail panel.
  • CSS.btn-fluent-danger / .btn-terminal-danger for destructive
    actions, a clearer disabled state, and the status/empty/drawer styling.

Phase 1 — main console (admin.rs, 7 tabs)

  • Task & Published detail editors now open in a side Drawer instead of
    pushing the table out of view; their textareas read correctly in dark mode.
  • EmptyState across the music / article / published / audit lists (empty vs
    loading vs failed are now distinguishable).
  • Destructive actions use the danger style; the Tasks row separates positive
    actions from Reject/Delete with a divider so delete is hard to mis-click.
  • Audit log actions render as colored StatusBadges; the Behavior status
    filter explains it expects an HTTP code.

Phase 2 — LLM Gateway (admin_llm_gateway.rs, started)

  • Delete buttons use the terminal-style .btn-terminal-danger variant.
  • Two hand-rolled status-pill implementations unified onto StatusBadge.

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

acking-you and others added 7 commits June 14, 2026 00:18
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
<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>

Comment on lines +3296 to +3298
if published_comments.is_empty() {
<EmptyState icon="fa-comments" title="暂无已发布评论" hint="审核通过的评论会显示在这里。" />
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 {

Comment on lines +3415 to +3417
if audit_logs.is_empty() {
<EmptyState icon="fa-clipboard-list" title="暂无审计记录" hint="对任务的操作会记录在这里。" />
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 {

Comment on lines 3869 to 3873
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 {

Comment on lines 3944 to 3948
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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>
@acking-you acking-you merged commit 6685105 into master Jun 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant