feat(ui): restructure PluginsPage layout with improved styling and new section headers

This commit is contained in:
LTbinglingfeng
2026-06-11 21:31:26 +08:00
Unverified
parent 947f9f880a
commit 848eefb6e8
2 changed files with 444 additions and 276 deletions
+294 -172
View File
@@ -1,6 +1,8 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
// ─── Page Container ─────────────────────────────────────
.page {
display: flex;
flex-direction: column;
@@ -8,22 +10,12 @@
width: 100%;
}
.header {
// ─── Header ─────────────────────────────────────────────
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-md;
:global(.btn > span) {
display: inline-flex;
align-items: center;
gap: 8px;
}
@include mobile {
flex-direction: column;
align-items: stretch;
}
flex-direction: column;
gap: $spacing-sm;
}
.title {
@@ -35,12 +27,14 @@
}
.description {
margin: $spacing-sm 0 0;
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
// ─── Alert Boxes ────────────────────────────────────────
.errorBox,
.warningBox {
padding: $spacing-md;
@@ -61,53 +55,89 @@
color: var(--text-primary);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: $spacing-md;
// ─── Status Bar ─────────────────────────────────────────
@media (max-width: 1100px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.statusBar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
flex-wrap: wrap;
}
.statusPill {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: $radius-full;
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
background: color-mix(in srgb, var(--bg-primary) 56%, transparent);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.statusDot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.statusDotOn {
background: $success-color;
box-shadow: 0 0 6px rgba($success-color, 0.5);
}
.statusDotOff {
background: var(--text-tertiary);
}
.statusLabel {
color: var(--text-secondary);
}
.statusValue {
color: var(--text-primary);
font-weight: 700;
}
.statusPathValue {
display: block;
min-width: 0;
max-width: min(360px, 58vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusDivider {
width: 1px;
height: 20px;
background: color-mix(in srgb, var(--border-color) 60%, transparent);
flex-shrink: 0;
@include mobile {
grid-template-columns: 1fr;
display: none;
}
}
.statTile {
display: flex;
min-width: 0;
min-height: 76px;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
span {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
strong {
min-width: 0;
color: var(--text-primary);
font-size: 20px;
line-height: 1.2;
overflow-wrap: anywhere;
}
}
// ─── Toolbar ────────────────────────────────────────────
.toolbar {
display: flex;
max-width: 520px;
align-items: center;
gap: $spacing-sm;
:global(.form-group) {
width: 100%;
flex: 1;
max-width: 480px;
margin: 0;
}
@@ -115,111 +145,158 @@
padding-right: 36px;
}
:global(.btn > span) {
display: inline-flex;
align-items: center;
gap: 8px;
}
@include mobile {
max-width: none;
flex-direction: column;
align-items: stretch;
:global(.form-group) {
max-width: none;
}
}
}
.pluginGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: $spacing-md;
// ─── Plugin List ────────────────────────────────────────
@include mobile {
grid-template-columns: 1fr;
}
}
.pluginCard {
.pluginList {
display: flex;
min-width: 0;
min-height: 260px;
flex-direction: column;
gap: $spacing-md;
padding: $spacing-lg;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.04);
gap: 1px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-radius: $radius-lg;
overflow: hidden;
background: color-mix(in srgb, var(--border-color) 30%, transparent);
}
.skeletonCard {
min-height: 260px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
);
background-size: 400% 100%;
animation: skeletonPulse 1.35s ease-in-out infinite;
}
// ─── Plugin Row ─────────────────────────────────────────
@keyframes skeletonPulse {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.cardHeader {
.pluginRow {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) auto;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: $spacing-md;
padding: $spacing-md $spacing-lg;
background: var(--bg-primary);
transition: background-color $transition-fast;
&:hover {
background: var(--bg-hover);
}
@include mobile {
grid-template-columns: 36px 1fr;
grid-template-rows: auto auto;
gap: $spacing-sm;
padding: $spacing-md;
}
}
.logoBox {
display: inline-flex;
width: 48px;
height: 48px;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
color: var(--text-secondary);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
@include mobile {
width: 36px;
height: 36px;
border-radius: 8px;
}
}
.pluginIdentity {
.pluginInfo {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.pluginName {
display: flex;
align-items: center;
gap: $spacing-sm;
min-width: 0;
h2 {
min-width: 0;
margin: 0;
color: var(--text-primary);
font-size: 17px;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
span {
display: block;
margin-top: 4px;
color: var(--text-tertiary);
font-family: $font-mono;
font-size: 12px;
overflow-wrap: anywhere;
font-size: 15px;
font-weight: 650;
line-height: 1.3;
@include text-ellipsis;
}
}
.pluginId {
color: var(--text-tertiary);
font-family: $font-mono;
font-size: 12px;
@include text-ellipsis;
}
.pluginMeta {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
margin-top: 2px;
}
.metaItem {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 4px;
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
strong {
color: var(--text-secondary);
font-weight: 600;
}
}
.metaPath {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.metaDot {
width: 3px;
height: 3px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-tertiary) 50%, transparent);
flex-shrink: 0;
}
// ─── Badges ─────────────────────────────────────────────
.badgeRow {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 5px;
}
.badge,
@@ -227,83 +304,75 @@
.badgeWarning,
.badgeMuted {
display: inline-flex;
min-height: 24px;
min-height: 22px;
align-items: center;
border-radius: $radius-sm;
padding: 3px 8px;
font-size: 12px;
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
line-height: 1.25;
}
.badge {
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
color: var(--text-secondary);
}
.badgeSuccess {
background: rgba($success-color, 0.12);
background: rgba($success-color, 0.1);
border: 1px solid rgba($success-color, 0.2);
color: var(--success-color);
}
.badgeWarning {
background: rgba($warning-color, 0.12);
background: rgba($warning-color, 0.1);
border: 1px solid rgba($warning-color, 0.2);
color: var(--warning-color);
}
.badgeMuted {
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
color: var(--text-tertiary);
}
.metaList {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px $spacing-sm;
margin: 0;
color: var(--text-secondary);
font-size: 13px;
// ─── Row Actions ────────────────────────────────────────
dt {
color: var(--text-tertiary);
font-weight: 600;
}
dd {
min-width: 0;
margin: 0;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.cardActions {
.rowActions {
display: flex;
min-width: 0;
max-width: min(560px, 48vw);
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $spacing-sm;
margin-top: auto;
flex-shrink: 0;
:global(.btn > span) {
display: inline-flex;
align-items: center;
gap: 8px;
gap: 6px;
}
@include mobile {
grid-column: 1 / -1;
justify-content: flex-start;
max-width: none;
}
}
.linkButton,
.iconLink {
display: inline-flex;
min-height: 34px;
min-height: 30px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
font-size: 12px;
font-weight: 600;
text-decoration: none;
transition:
@@ -319,17 +388,75 @@
}
.linkButton {
gap: 8px;
max-width: 100%;
padding: 0 12px;
gap: 5px;
min-width: 0;
max-width: min(180px, 100%);
padding: 0 10px;
overflow-wrap: anywhere;
white-space: normal;
}
.iconLink {
width: 34px;
width: 30px;
flex: 0 0 auto;
}
// ─── Skeleton ───────────────────────────────────────────
.skeletonRow {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-md $spacing-lg;
background: var(--bg-primary);
}
.skeletonAvatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
);
background-size: 400% 100%;
animation: skeletonPulse 1.35s ease-in-out infinite;
flex-shrink: 0;
}
.skeletonText {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.skeletonLine {
height: 14px;
border-radius: 4px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
);
background-size: 400% 100%;
animation: skeletonPulse 1.35s ease-in-out infinite;
&:first-child {
width: 45%;
}
&:last-child {
width: 70%;
height: 10px;
}
}
// ─── Sheet Config Form ──────────────────────────────────
.sheetFooter {
display: flex;
justify-content: flex-end;
@@ -493,29 +620,24 @@
font-size: 14px;
}
// ─── Animation ──────────────────────────────────────────
@keyframes skeletonPulse {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
// ─── Mobile Overrides ───────────────────────────────────
@include mobile {
.page {
gap: $spacing-md;
}
.pluginCard {
padding: $spacing-md;
}
.cardHeader {
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: $spacing-sm;
}
.logoBox {
width: 44px;
height: 44px;
}
.metaList {
grid-template-columns: 1fr;
}
.sheetFooter {
flex-direction: column-reverse;
+150 -104
View File
@@ -593,11 +593,76 @@ export function PluginsPage() {
return (
<div className={styles.page}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{t('plugin_management.title')}</h1>
<p className={styles.description}>{t('plugin_management.description')}</p>
{/* ── Page Header ── */}
<div className={styles.pageHeader}>
<h1 className={styles.title}>{t('plugin_management.title')}</h1>
<p className={styles.description}>{t('plugin_management.description')}</p>
</div>
{/* ── Alerts ── */}
{error ? <div className={styles.errorBox}>{error}</div> : null}
{data && !data.pluginsEnabled ? (
<div className={styles.warningBox}>{t('plugin_management.global_disabled_hint')}</div>
) : null}
{/* ── Status Bar ── */}
{data ? (
<div className={styles.statusBar}>
<div className={styles.statusPill}>
<span
className={`${styles.statusDot} ${
data.pluginsEnabled ? styles.statusDotOn : styles.statusDotOff
}`}
/>
<span className={styles.statusLabel}>{t('plugin_management.global_status')}</span>
<span className={styles.statusValue}>
{data.pluginsEnabled
? t('plugin_management.global_enabled')
: t('plugin_management.global_disabled')}
</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_management.plugins_dir')}</span>
<span
className={`${styles.statusValue} ${styles.statusPathValue}`}
title={data.pluginsDir || 'plugins'}
>
{data.pluginsDir || 'plugins'}
</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_management.discovered')}</span>
<span className={styles.statusValue}>{pluginStats.discovered}</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_management.effective')}</span>
<span className={styles.statusValue}>
{pluginStats.effective}/{pluginStats.registered}
</span>
</div>
</div>
) : null}
{/* ── Toolbar ── */}
<div className={styles.toolbar}>
<Input
type="search"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder={t('plugin_management.search_placeholder')}
aria-label={t('plugin_management.search_label')}
rightElement={<IconSearch size={16} />}
/>
<Button
variant="secondary"
size="sm"
@@ -610,52 +675,17 @@ export function PluginsPage() {
</Button>
</div>
{error ? <div className={styles.errorBox}>{error}</div> : null}
{data && !data.pluginsEnabled ? (
<div className={styles.warningBox}>{t('plugin_management.global_disabled_hint')}</div>
) : null}
<div className={styles.statsGrid}>
<div className={styles.statTile}>
<span>{t('plugin_management.global_status')}</span>
<strong>
{data?.pluginsEnabled
? t('plugin_management.global_enabled')
: t('plugin_management.global_disabled')}
</strong>
</div>
<div className={styles.statTile}>
<span>{t('plugin_management.plugins_dir')}</span>
<strong>{data?.pluginsDir || 'plugins'}</strong>
</div>
<div className={styles.statTile}>
<span>{t('plugin_management.discovered')}</span>
<strong>{pluginStats.discovered}</strong>
</div>
<div className={styles.statTile}>
<span>{t('plugin_management.effective')}</span>
<strong>
{pluginStats.effective}/{pluginStats.registered}
</strong>
</div>
</div>
<div className={styles.toolbar}>
<Input
type="search"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder={t('plugin_management.search_placeholder')}
aria-label={t('plugin_management.search_label')}
rightElement={<IconSearch size={16} />}
/>
</div>
{/* ── Plugin List ── */}
{loading ? (
<div className={styles.pluginGrid} aria-busy="true">
<div className={styles.pluginList}>
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className={styles.skeletonCard} />
<div key={index} className={styles.skeletonRow}>
<div className={styles.skeletonAvatar} />
<div className={styles.skeletonText}>
<div className={styles.skeletonLine} />
<div className={styles.skeletonLine} />
</div>
</div>
))}
</div>
) : visiblePlugins.length === 0 ? (
@@ -670,79 +700,94 @@ export function PluginsPage() {
}
/>
) : (
<div className={styles.pluginGrid}>
<div className={styles.pluginList}>
{visiblePlugins.map((plugin) => {
const logo = resolvePluginAssetURL(plugin.logo || plugin.metadata?.logo || '');
const github = plugin.metadata?.githubRepository.trim();
const mutating = mutatingID === plugin.id;
const version = plugin.metadata?.version;
const author = plugin.metadata?.author;
return (
<article key={plugin.id} className={styles.pluginCard}>
<div className={styles.cardHeader}>
<div className={styles.logoBox} aria-hidden="true">
{logo ? <img src={logo} alt="" /> : <IconPlug size={22} />}
</div>
<div className={styles.pluginIdentity}>
<article key={plugin.id} className={styles.pluginRow}>
{/* Logo */}
<div className={styles.logoBox} aria-hidden="true">
{logo ? <img src={logo} alt="" /> : <IconPlug size={18} />}
</div>
{/* Info */}
<div className={styles.pluginInfo}>
<div className={styles.pluginName}>
<h2>{getPluginTitle(plugin)}</h2>
<span>{plugin.id}</span>
<div className={styles.badgeRow}>
<span
className={
plugin.effectiveEnabled ? styles.badgeSuccess : styles.badgeMuted
}
>
{plugin.effectiveEnabled
? t('plugin_management.status_effective')
: t('plugin_management.status_inactive')}
</span>
<span className={plugin.registered ? styles.badge : styles.badgeWarning}>
{plugin.registered
? t('plugin_management.registered')
: t('plugin_management.not_registered')}
</span>
<span className={plugin.configured ? styles.badge : styles.badgeMuted}>
{plugin.configured
? t('plugin_management.configured')
: t('plugin_management.not_configured')}
</span>
{plugin.supportsOAuth ? (
<span className={styles.badge}>{t('plugin_management.oauth')}</span>
) : null}
</div>
</div>
<span className={styles.pluginId}>{plugin.id}</span>
{version || author || plugin.path ? (
<div className={styles.pluginMeta}>
{version ? (
<span className={styles.metaItem}>
<strong>{version}</strong>
</span>
) : null}
{version && author ? (
<span className={styles.metaDot} aria-hidden="true" />
) : null}
{author ? <span className={styles.metaItem}>{author}</span> : null}
{(version || author) && plugin.path ? (
<span className={styles.metaDot} aria-hidden="true" />
) : null}
{plugin.path ? (
<span
className={`${styles.metaItem} ${styles.metaPath}`}
title={plugin.path}
>
{plugin.path}
</span>
) : null}
</div>
) : null}
</div>
{/* Actions */}
<div className={styles.rowActions}>
<ToggleSwitch
checked={plugin.enabled}
onChange={(enabled) => handleTogglePlugin(plugin, enabled)}
disabled={!connected || mutating}
ariaLabel={t('plugin_management.enabled')}
/>
</div>
<div className={styles.badgeRow}>
<span className={plugin.effectiveEnabled ? styles.badgeSuccess : styles.badgeMuted}>
{plugin.effectiveEnabled
? t('plugin_management.status_effective')
: t('plugin_management.status_inactive')}
</span>
<span className={plugin.registered ? styles.badge : styles.badgeWarning}>
{plugin.registered
? t('plugin_management.registered')
: t('plugin_management.not_registered')}
</span>
<span className={plugin.configured ? styles.badge : styles.badgeMuted}>
{plugin.configured
? t('plugin_management.configured')
: t('plugin_management.not_configured')}
</span>
{plugin.supportsOAuth ? (
<span className={styles.badge}>{t('plugin_management.oauth')}</span>
) : null}
</div>
<dl className={styles.metaList}>
{plugin.metadata?.version ? (
<>
<dt>{t('plugin_management.version_label')}</dt>
<dd>{plugin.metadata.version}</dd>
</>
) : null}
{plugin.metadata?.author ? (
<>
<dt>{t('plugin_management.author_label')}</dt>
<dd>{plugin.metadata.author}</dd>
</>
) : null}
{plugin.path ? (
<>
<dt>{t('plugin_management.path_label')}</dt>
<dd title={plugin.path}>{plugin.path}</dd>
</>
) : null}
</dl>
<div className={styles.cardActions}>
<Button
variant="secondary"
size="sm"
onClick={() => openConfigSheet(plugin)}
disabled={!connected}
>
<IconSettings size={16} />
<IconSettings size={14} />
{t('plugin_management.edit_config')}
</Button>
{plugin.menus.map((menu) => (
@@ -754,7 +799,7 @@ export function PluginsPage() {
rel="noreferrer"
title={menu.description || menu.menu}
>
<IconExternalLink size={16} />
<IconExternalLink size={12} />
{menu.menu || t('plugin_management.open_resource')}
</a>
))}
@@ -767,7 +812,7 @@ export function PluginsPage() {
title={t('plugin_management.open_repository')}
aria-label={t('plugin_management.open_repository')}
>
<IconGithub size={16} />
<IconGithub size={14} />
</a>
) : null}
</div>
@@ -777,6 +822,7 @@ export function PluginsPage() {
</div>
)}
{/* ── Config Sheet ── */}
<Sheet
open={Boolean(editingPlugin && draft)}
onClose={closeConfigSheet}