admin基本布局

This commit is contained in:
tommy
2025-11-03 14:55:07 +08:00
parent 6762b84fd8
commit 68ae1b27eb
6 changed files with 738 additions and 78 deletions

319
assets/styling/admin.css Normal file
View File

@@ -0,0 +1,319 @@
:root {
--sidebar-width: 280px;
--sidebar-collapsed-width: 72px;
--topbar-height: 72px;
}
.ui-shell {
min-height: 100vh;
margin: 0;
width: 100%;
max-width: none;
padding: 0;
background-color: hsl(var(--muted));
border-radius: 0;
}
.ui-sidebar-layout.admin-shell {
display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
min-height: 100vh;
background-color: hsl(var(--background));
}
.ui-sidebar-layout.admin-shell .ui-sidebar-rail {
display: none;
}
.ui-sidebar {
display: flex;
flex-direction: column;
gap: 24px;
background-color: hsl(var(--card));
border-right: 1px solid hsl(var(--border));
padding: 28px 24px;
transition: width 0.25s ease, padding 0.25s ease;
}
.ui-sidebar[data-collapsed="true"] {
width: var(--sidebar-collapsed-width);
padding: 28px 14px;
}
.ui-sidebar-header,
.ui-sidebar-content,
.ui-sidebar-footer {
display: flex;
flex-direction: column;
gap: 20px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
}
.sidebar-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
color: hsl(var(--primary-foreground));
font-size: 18px;
}
.sidebar-name {
font-size: 1rem;
font-weight: 600;
}
.sidebar-subtitle {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ui-sidebar-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.ui-sidebar-group-label {
font-size: 0.7rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
letter-spacing: 0.08em;
}
.ui-sidebar-menu {
width: 100%;
}
.ui-sidebar-menu-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.ui-sidebar-menu-button {
display: inline-flex;
width: 100%;
gap: 12px;
padding: 10px 12px;
border-radius: calc(var(--radius) - 2px);
border: 1px solid transparent;
background-color: transparent;
color: hsl(var(--foreground));
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
text-align: left;
text-decoration: none;
}
.ui-sidebar-menu-button:hover {
background-color: hsl(var(--muted));
}
.ui-sidebar-menu-button[data-active="true"] {
background-color: hsl(var(--accent));
border-color: hsl(var(--border));
}
.ui-sidebar-icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: hsl(var(--muted));
font-size: 0.95rem;
}
.ui-sidebar-text {
display: flex;
flex-direction: column;
gap: 3px;
}
.ui-sidebar-label {
font-size: 0.9rem;
font-weight: 600;
}
.ui-sidebar-description {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.ui-sidebar-badge {
margin-left: auto;
font-size: 0.75rem;
background-color: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.sidebar-profile {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius));
background-color: hsl(var(--muted));
}
.sidebar-profile-name {
font-weight: 600;
}
.sidebar-profile-role {
color: hsl(var(--muted-foreground));
font-size: 0.8rem;
}
.admin-shell-inset {
display: flex;
flex-direction: column;
background-color: hsl(var(--background));
min-height: 100vh;
}
.admin-shell-topbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 18px;
justify-content: space-between;
padding: 24px 32px;
border-bottom: 1px solid hsl(var(--border));
background-color: hsla(var(--background), 0.95);
backdrop-filter: blur(12px);
min-height: var(--topbar-height);
}
.admin-shell-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-shell-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.admin-shell-description {
margin: 0;
color: hsl(var(--muted-foreground));
font-size: 0.95rem;
}
.admin-shell-actions {
display: flex;
align-items: center;
gap: 12px;
}
.admin-shell-content {
flex: 1;
width: 100%;
padding: 32px;
background-color: hsl(var(--muted));
min-height: calc(100vh - var(--topbar-height));
}
.dashboard-root {
width: 100%;
margin: 0;
}
.ui-sidebar-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid hsl(var(--border));
background-color: hsl(var(--card));
color: hsl(var(--foreground));
border-radius: calc(var(--radius) - 2px);
padding: 0.5rem 0.8rem;
font-weight: 500;
}
.ui-sidebar[data-collapsed="true"] .sidebar-name,
.ui-sidebar[data-collapsed="true"] .sidebar-subtitle,
.ui-sidebar[data-collapsed="true"] .ui-sidebar-text,
.ui-sidebar[data-collapsed="true"] .ui-sidebar-badge,
.ui-sidebar[data-collapsed="true"] .sidebar-profile div,
.ui-sidebar[data-collapsed="true"] .sidebar-profile-role,
.ui-sidebar[data-collapsed="true"] .ui-sidebar-group-label {
display: none;
}
.ui-sidebar[data-collapsed="true"] .sidebar-profile {
justify-content: center;
padding: 12px 8px;
}
.ui-sidebar[data-collapsed="true"] .ui-sidebar-menu-button {
justify-content: center;
}
@media (max-width: 960px) {
.ui-sidebar-layout.admin-shell {
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
}
}
@media (max-width: 768px) {
.ui-sidebar-layout.admin-shell {
grid-template-columns: 1fr;
}
.ui-sidebar {
position: fixed;
inset: 0 auto 0 0;
transform: translateX(-100%);
width: var(--sidebar-width);
max-width: 90vw;
box-shadow: 0 20px 50px -24px rgba(15, 23, 42, 0.45);
z-index: 20;
}
.ui-sidebar[data-collapsed="true"] {
transform: translateX(-100%);
}
.ui-sidebar[data-collapsed="false"] {
transform: translateX(0);
}
.admin-shell-topbar {
gap: 12px;
}
.admin-shell-actions {
display: none;
}
.admin-shell-content {
padding: 24px;
}
.dashboard-root {
width: 100%;
}
}

View File

@@ -1,42 +1,12 @@
html,
body {
background-color: #0f1116;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
height: 100%;
}
#hero {
body {
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: hsl(var(--muted));
color: hsl(var(--foreground));
font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
-webkit-font-smoothing: antialiased;
}
#links {
width: 400px;
text-align: left;
font-size: x-large;
color: white;
display: flex;
flex-direction: column;
}
#links a {
color: white;
text-decoration: none;
margin-top: 20px;
margin: 10px 0px;
border: white 1px solid;
border-radius: 5px;
padding: 10px;
}
#links a:hover {
background-color: #1f1f1f;
cursor: pointer;
}
#header {
max-width: 1200px;
}

View File

@@ -2,7 +2,7 @@
// need dioxus
use dioxus::prelude::*;
use views::{Blog, Home, Navbar};
use views::{Components, Home, Navbar};
/// Define a components module that contains all shared components for our app.
mod components;
@@ -11,7 +11,7 @@ mod views;
/// The Route enum is used to define the structure of internal routes in our app. All route enums need to derive
/// the [`Routable`] trait, which provides the necessary methods for the router to work.
///
///
/// Each variant represents a different URL pattern that can be matched by the router. If that pattern is matched,
/// the components for that route will be rendered.
#[derive(Debug, Clone, Routable, PartialEq)]
@@ -24,12 +24,9 @@ enum Route {
// the component for that route will be rendered. The component name that is rendered defaults to the variant name.
#[route("/")]
Home {},
// The route attribute can include dynamic parameters that implement [`std::str::FromStr`] and [`std::fmt::Display`] with the `:` syntax.
// In this case, id will match any integer like `/blog/123` or `/blog/-456`.
#[route("/blog/:id")]
// Fields of the route variant will be passed to the component as props. In this case, the blog component must accept
// an `id` prop of type `i32`.
Blog { id: i32 },
#[route("/components")]
// The components gallery reuses the full UI showcase so new primitives stay discoverable.
Components {},
}
// We can import assets in dioxus with the `asset!` macro. This macro takes a path to an asset relative to the crate root.
@@ -38,6 +35,7 @@ const FAVICON: Asset = asset!("/assets/favicon.ico");
// The asset macro also minifies some assets like CSS and JS to make bundled smaller
const MAIN_CSS: Asset = asset!("/assets/styling/main.css");
const SHADCN_CSS: Asset = asset!("/assets/styling/shadcn.css");
const ADMIN_CSS: Asset = asset!("/assets/styling/admin.css");
fn main() {
// The `launch` function is the main entry point for a dioxus app. It takes a component and renders it with the platform feature
@@ -58,6 +56,7 @@ fn App() -> Element {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Link { rel: "stylesheet", href: SHADCN_CSS }
document::Link { rel: "stylesheet", href: ADMIN_CSS }
// The router component renders the route enum we defined above. It will handle synchronization of the URL and render

View File

@@ -15,6 +15,256 @@ use dioxus::prelude::*;
/// The Home page component that will be rendered when the current route is `[Route::Home]`
#[component]
pub fn Home() -> Element {
let kpis = [
("Monthly Recurring Revenue", "$82.4k", "+4.1% vs last month"),
("Active Accounts", "18,245", "+320 this week"),
("Trial Conversion Rate", "32%", "Goal: 40%"),
("Support CSAT", "94%", "230 responses"),
];
let experiments = [
(
"Guided onboarding checklist",
"QA",
"Validating instrumentation with the analytics team.",
),
(
"Usage-based alerts",
"Design",
"Final polish on notification copy before launch.",
),
(
"Unified billing dashboard",
"Build",
"Integration tests are running against staging.",
),
];
let adoption_segments = [
(
"Onboarding completion",
74.0f32,
"Teams finished the setup checklist with no blockers.",
),
(
"Automation adoption",
58.0f32,
"Workflows triggered in the past 7 days across customers.",
),
(
"Weekly active accounts",
83.0f32,
"Organizations engaging with analytics at least twice.",
),
];
let alert_feed = [
(
"Incident response playbook",
"Workspace incidents resolved in under 4h for 92% of cases.",
),
(
"Billing sync delays",
"Elevated Stripe webhook latency observed over the weekend.",
),
(
"API deprecations",
"Notify integrators about the upcoming reporting v2 changes.",
),
];
let standup_sections: [(&str, [&str; 2]); 3] = [
(
"Platform",
[
"Ship metering hotfix across clusters",
"Pair with data engineering for realtime audits",
],
),
(
"Product",
[
"Enable proactive insights beta for 10 design partners",
"Finalize empty state content review with brand",
],
),
(
"Customer Success",
[
"Schedule QBR with Globex expansion team",
"Collect testimonials for the website refresh",
],
),
];
let priority_backlog = [
(
"Lifecycle emails",
"Growth",
"Net-new education journey that nurtures power users.",
),
(
"Data residency",
"Platform",
"Coordinate EU workspace pilot timeline with legal.",
),
(
"Journeys analytics",
"Product",
"Scope dashboards that highlight activation funnels.",
),
];
rsx! {
div {
class: "dashboard-root",
style: "display: flex; flex-direction: column; gap: 24px;",
section {
class: "dashboard-kpis",
style: "display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));",
for (title, value, hint) in kpis {
Card {
CardHeader {
CardTitle { "{title}" }
CardDescription { "{hint}" }
}
CardContent {
span {
style: "font-size: 2rem; font-weight: 600;",
"{value}"
}
}
}
}
}
section {
class: "dashboard-panels",
style: "display: grid; gap: 24px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: start;",
Card {
CardHeader {
CardTitle { "Product health" }
CardDescription { "Experiments, adoption, and alerts surfaced from telemetry." }
}
CardContent {
Tabs {
default_value: "experiments".to_string(),
TabsList {
TabsTrigger { value: "experiments".to_string(), "Experiments" }
TabsTrigger { value: "adoption".to_string(), "Adoption" }
TabsTrigger { value: "alerts".to_string(), "Alerts" }
}
TabsContent { value: "experiments".to_string(),
div {
style: "display: flex; flex-direction: column; gap: 16px;",
for (title, stage, note) in experiments {
div {
style: "display: flex; flex-direction: column; gap: 6px; background: hsl(var(--muted)); padding: 16px; border-radius: calc(var(--radius) - 2px);",
div {
style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;",
span { style: "font-weight: 600;", "{title}" }
Badge { variant: BadgeVariant::Secondary, "{stage}" }
}
p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" }
}
}
}
}
TabsContent { value: "adoption".to_string(),
div {
style: "display: flex; flex-direction: column; gap: 16px;",
for (label, percent, note) in adoption_segments {
div {
style: "display: flex; flex-direction: column; gap: 8px;",
div {
style: "display: flex; justify-content: space-between; align-items: baseline;",
span { style: "font-weight: 600;", "{label}" }
span { style: "font-size: 0.9rem; color: hsl(var(--muted-foreground));", "{percent:.0}%" }
}
Progress { value: percent }
p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" }
}
}
}
}
TabsContent { value: "alerts".to_string(),
div {
style: "display: flex; flex-direction: column; gap: 12px;",
for (title, note) in alert_feed {
div {
style: "display: flex; flex-direction: column; gap: 4px;",
span { style: "font-weight: 600;", "{title}" }
p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" }
}
}
}
}
}
}
CardFooter {
Button {
variant: ButtonVariant::Secondary,
r#type: "button".to_string(),
"Open product review"
}
}
}
Card {
CardHeader {
CardTitle { "Team stand-up" }
CardDescription { "Snapshots ready to paste into the leadership sync." }
}
CardContent {
Accordion {
default_value: Some("Platform".to_string()),
collapsible: true,
for (team, updates) in standup_sections {
AccordionItem {
value: team.to_string(),
AccordionTrigger { "{team}" }
AccordionContent {
ul {
style: "margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 6px;",
for update in updates {
li { "{update}" }
}
}
}
}
}
}
}
}
Card {
CardHeader {
CardTitle { "Escalation queue" }
CardDescription { "Cross-functional work that needs executive unblockers." }
}
CardContent {
div {
style: "display: flex; flex-direction: column; gap: 16px;",
for (title, owner, note) in priority_backlog {
div {
style: "display: flex; flex-direction: column; gap: 4px;",
div {
style: "display: flex; justify-content: space-between; align-items: center;",
span { style: "font-weight: 600;", "{title}" }
Badge { variant: BadgeVariant::Outline, "{owner}" }
}
p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" }
}
}
}
}
CardFooter {
Button {
variant: ButtonVariant::Ghost,
r#type: "button".to_string(),
"View roadmap"
}
}
}
}
}
}
}
#[component]
pub fn Components() -> Element {
rsx! {
UiShowcase {}
}

View File

@@ -2,17 +2,14 @@
//! enum will render one of these components.
//!
//!
//! The [`Home`] and [`Blog`] components will be rendered when the current route is [`Route::Home`] or [`Route::Blog`] respectively.
//! The [`Home`] and [`Components`] views back the dashboard and component gallery routes.
//!
//!
//! The [`Navbar`] component will be rendered on all pages of our app since every page is under the layout. The layout defines
//! a common wrapper around all child routes.
mod home;
pub use home::Home;
mod blog;
pub use blog::Blog;
pub use home::{Components, Home};
mod navbar;
pub use navbar::Navbar;

View File

@@ -1,32 +1,157 @@
use crate::Route;
use crate::{
components::ui::{
Avatar, Badge, BadgeVariant, Button, ButtonVariant, Sidebar, SidebarContent, SidebarFooter,
SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset,
SidebarLayout, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail,
SidebarSeparator, SidebarTrigger,
},
Route,
};
use dioxus::prelude::*;
const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
/// The Navbar component that will be rendered on all pages of our app since every page is under the layout.
///
///
/// This layout component wraps the UI of [Route::Home] and [Route::Blog] in a common navbar. The contents of the Home and Blog
/// routes will be rendered under the outlet inside this component
#[component]
pub fn Navbar() -> Element {
rsx! {
document::Link { rel: "stylesheet", href: NAVBAR_CSS }
div {
id: "navbar",
Link {
to: Route::Home {},
"Home"
}
Link {
to: Route::Blog { id: 1 },
"Blog"
}
}
// The `Outlet` component is used to render the next component inside the layout. In this case, it will render either
// the [`Home`] or [`Blog`] component depending on the current route.
Outlet::<Route> {}
fn page_meta(route: &Route) -> (&'static str, &'static str) {
match route {
Route::Home {} => (
"Dashboard overview",
"Track product health, monitor key funnels, and coordinate the team from one place.",
),
Route::Components {} => (
"Component library",
"Browse every primitive wired into this starter so new views stay consistent.",
),
}
}
#[component]
pub fn Navbar() -> Element {
let collapsed = use_signal(|| false);
let current_route: Route = use_route();
let collapsed_state = collapsed();
let collapsed_setter = collapsed.clone();
let (page_title, page_description) = page_meta(&current_route);
let is_dashboard = matches!(current_route, Route::Home { .. });
let is_components = matches!(current_route, Route::Components { .. });
rsx! {
section {
class: "ui-shell shadcn",
SidebarLayout {
class: "admin-shell",
SidebarRail { }
Sidebar {
collapsed: collapsed_state,
SidebarHeader {
div { class: "sidebar-brand",
span { class: "sidebar-logo", "" }
div {
span { class: "sidebar-name", "Dioxus Admin" }
span { class: "sidebar-subtitle", "v0.7 toolkit" }
}
}
}
SidebarContent {
SidebarGroup {
SidebarGroupLabel { "Overview" }
SidebarGroupContent {
SidebarMenu {
SidebarMenuItem {
SidebarMenuButton {
label: "Dashboard",
description: Some("KPIs, monitors, and recent activity".to_string()),
icon: Some("📊".to_string()),
active: is_dashboard,
href: Some(Route::Home {}.to_string()),
}
}
SidebarMenuItem {
SidebarMenuButton {
label: "Components",
description: Some("Living style guide of primitives".to_string()),
icon: Some("🧩".to_string()),
active: is_components,
href: Some(Route::Components {}.to_string()),
}
}
}
}
}
SidebarSeparator { }
SidebarGroup {
SidebarGroupLabel { "Shortcuts" }
SidebarGroupContent {
SidebarMenu {
SidebarMenuItem {
SidebarMenuButton {
label: "Team",
description: Some("Invite and manage collaborators".to_string()),
icon: Some("👥".to_string()),
badge: Some("4 pending".to_string()),
href: Some("#team".to_string()),
}
}
SidebarMenuItem {
SidebarMenuButton {
label: "Settings",
description: Some("Branding, auth, billing".to_string()),
icon: Some("⚙️".to_string()),
href: Some("#settings".to_string()),
}
}
}
}
}
}
SidebarFooter {
div { class: "sidebar-profile",
Avatar {
src: Some("https://avatars.githubusercontent.com/u/3236120?v=4".to_string()),
alt: Some("Administrator avatar".to_string()),
fallback: Some("DX".to_string()),
}
div {
span { class: "sidebar-profile-name", "Taylor Chen" }
span { class: "sidebar-profile-role", "Product Manager" }
}
}
Button {
class: Some("mt-2 w-full".to_string()),
variant: ButtonVariant::Secondary,
r#type: "button".to_string(),
"Switch account"
}
}
}
SidebarInset {
class: "admin-shell-inset",
header {
class: "admin-shell-topbar",
SidebarTrigger {
collapsed: collapsed_state,
on_toggle: move |next_collapsed: bool| {
collapsed_setter.clone().set(next_collapsed);
},
}
div { class: "admin-shell-meta",
h1 { class: "admin-shell-title", "{page_title}" }
p { class: "admin-shell-description", "{page_description}" }
}
div { class: "admin-shell-actions",
Badge { variant: BadgeVariant::Secondary, "Operational" }
Button {
variant: ButtonVariant::Default,
class: Some("admin-shell-report".to_string()),
r#type: "button".to_string(),
"New report"
}
}
}
main {
class: "admin-shell-content",
Outlet::<Route> {}
}
}
}
}
}
}