订单页面

This commit is contained in:
tommy
2025-11-05 14:46:54 +08:00
parent 777467c0c4
commit 7b53288c76
7 changed files with 1558 additions and 5 deletions

View File

@@ -198,6 +198,69 @@
font-weight: 600;
}
.orders-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
align-items: stretch;
}
.orders-metric-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 20px;
border-radius: calc(var(--radius));
border: 1px solid hsl(var(--border));
background-color: hsl(var(--card));
box-shadow: var(--shadow-sm);
}
.orders-metric-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: hsl(var(--muted-foreground));
}
.orders-metric-value {
font-size: 2rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.orders-metric-sub {
font-size: 0.85rem;
color: hsl(var(--muted-foreground));
line-height: 1.4;
}
.orders-filter-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
align-items: start;
}
.orders-filter-wide {
grid-column: 1 / -1;
}
.orders-tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.orders-empty {
padding: 60px 20px;
text-align: center;
border: 1px dashed hsl(var(--border));
border-radius: calc(var(--radius));
color: hsl(var(--muted-foreground));
background-color: hsl(var(--muted) / 0.25);
}
.sidebar-profile-role {
color: hsl(var(--muted-foreground));
font-size: 0.8rem;

View File

@@ -907,6 +907,57 @@ fn TableSample() -> Element {
}
```
`InteractiveTable` 在基础结构上封装了行多选与列显隐控制:
- `columns: Vec<TableColumnConfig>` 定义列元数据,`fixed()` 列不可隐藏,`hide_by_default()` 默认隐藏。
- `rows: Vec<TableRowData>` 通过 `from_pairs``with_cell` 构造行数据,`id` 必须唯一。
- `on_selection_change` / `on_visibility_change` 回调分别返回当前选中的行 ID 和可见列 ID按定义顺序
- `default_selected` 设置初始选中行,`empty_state` 自定义空数据提示。
```rust
use crate::components::ui::*;
use dioxus::prelude::*;
#[component]
fn InteractiveTableSample() -> Element {
let columns = vec![
TableColumnConfig::new("id", "ID").fixed(),
TableColumnConfig::new("project", "项目"),
TableColumnConfig::new("status", "状态"),
TableColumnConfig::new("updated", "更新时间"),
];
let rows = vec![
TableRowData::from_pairs(
"1",
[
("project", "Alpha"),
("status", "部署中"),
("updated", "2024-06-12"),
],
),
TableRowData::from_pairs(
"2",
[
("project", "Beta"),
("status", "通过"),
("updated", "2024-06-10"),
],
),
];
rsx! {
InteractiveTable {
columns,
rows,
default_selected: Some(vec!["1".to_string()]),
on_selection_change: move |ids| log::info!("选中: {ids:?}"),
on_visibility_change: move |cols| log::info!("可见列: {cols:?}"),
}
}
}
```
### Calendar
单月日期选择器。

View File

@@ -1,3 +1,10 @@
use std::{
cmp::max,
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::components::ui::Checkbox;
use dioxus::prelude::*;
fn merge_class(base: &str, extra: Option<String>) -> String {
@@ -104,3 +111,365 @@ pub fn TableCaption(#[props(into, default)] class: Option<String>, children: Ele
}
}
}
#[derive(Clone, PartialEq)]
pub struct TableColumnConfig {
pub id: String,
pub label: String,
pub toggleable: bool,
pub visible_by_default: bool,
}
#[allow(dead_code)]
impl TableColumnConfig {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
toggleable: true,
visible_by_default: true,
}
}
pub fn fixed(mut self) -> Self {
self.toggleable = false;
self
}
pub fn hide_by_default(mut self) -> Self {
self.visible_by_default = false;
self
}
}
#[derive(Clone, PartialEq)]
pub struct TableRowData {
pub id: String,
pub cells: HashMap<String, String>,
}
#[allow(dead_code)]
impl TableRowData {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
cells: HashMap::new(),
}
}
pub fn with_cell(mut self, column_id: impl Into<String>, value: impl Into<String>) -> Self {
self.cells.insert(column_id.into(), value.into());
self
}
pub fn from_pairs<T, K, V>(id: impl Into<String>, cells: T) -> Self
where
T: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let mut row = Self::new(id);
for (column, value) in cells {
row.cells.insert(column.into(), value.into());
}
row
}
}
#[component]
pub fn InteractiveTable(
#[props(into)] columns: Vec<TableColumnConfig>,
#[props(into)] rows: Vec<TableRowData>,
#[props(into, default)] class: Option<String>,
#[props(into, default)] table_class: Option<String>,
#[props(into, default)] default_selected: Option<Vec<String>>,
#[props(into, default)] empty_state: Option<String>,
#[props(optional)] on_selection_change: Option<EventHandler<Vec<String>>>,
#[props(optional)] on_visibility_change: Option<EventHandler<Vec<String>>>,
) -> Element {
let wrapper_class = merge_class("ui-data-table", class);
let inner_table_class = merge_class("ui-table", table_class);
let initial_selected: HashSet<String> =
default_selected.unwrap_or_default().into_iter().collect();
let selected_rows = use_signal({
let initial_selected = initial_selected.clone();
move || initial_selected.clone()
});
let initial_visible: HashSet<String> = {
let mut visible = HashSet::new();
for column in &columns {
if column.visible_by_default || !column.toggleable {
visible.insert(column.id.clone());
}
}
if visible.is_empty() {
if let Some(first) = columns.first() {
visible.insert(first.id.clone());
}
}
visible
};
let visible_columns = use_signal({
let initial_visible = initial_visible.clone();
move || initial_visible.clone()
});
let toggleable_columns = Rc::new(
columns
.iter()
.filter(|column| column.toggleable)
.cloned()
.collect::<Vec<_>>(),
);
let column_order = Rc::new(
columns
.iter()
.map(|column| column.id.clone())
.collect::<Vec<_>>(),
);
let row_order = Rc::new(rows.iter().map(|row| row.id.clone()).collect::<Vec<_>>());
let min_visible_columns = max(
columns.iter().filter(|column| !column.toggleable).count(),
1,
);
let selection_handler = on_selection_change.clone();
let visibility_handler = on_visibility_change.clone();
let columns_menu_open = use_signal(|| false);
let selected_snapshot = selected_rows();
let visible_snapshot = visible_columns();
let selected_count = row_order
.iter()
.filter(|id| selected_snapshot.contains(*id))
.count();
let all_selected = !rows.is_empty() && selected_count == row_order.len();
let empty_message = empty_state.unwrap_or_else(|| "暂无数据".to_string());
let mut selection_signal_header = selected_rows.clone();
let row_order_for_header = row_order.clone();
let selection_handler_header = selection_handler.clone();
rsx! {
div {
class: wrapper_class,
div {
class: "ui-data-table-toolbar",
if !toggleable_columns.is_empty() {
div {
class: "ui-data-table-columns",
onfocusout: {
let mut open = columns_menu_open.clone();
move |_| open.set(false)
},
button {
class: "ui-data-table-columns-trigger",
"data-open": if columns_menu_open() { "true" } else { "false" },
onclick: {
let mut open = columns_menu_open.clone();
move |_| {
let next = !open();
open.set(next);
}
},
"列显隐"
}
if columns_menu_open() {
div {
class: "ui-data-table-columns-popover",
for column in toggleable_columns.iter() {
{
let column_id = column.id.clone();
let column_label = column.label.clone();
let mut visible_signal = visible_columns.clone();
let handler = visibility_handler.clone();
let column_order = column_order.clone();
let min_visible = min_visible_columns;
let is_visible = visible_snapshot.contains(&column_id);
rsx! {
label {
class: "ui-data-table-columns-option",
Checkbox {
checked: is_visible,
on_checked_change: move |checked| {
let mut next = visible_signal();
if checked {
if next.insert(column_id.clone()) {
visible_signal.set(next.clone());
if let Some(handler) = handler.clone() {
let payload = column_order
.iter()
.filter(|id| next.contains(*id))
.cloned()
.collect::<Vec<_>>();
handler.call(payload);
}
}
} else if next.len() > min_visible && next.remove(&column_id) {
visible_signal.set(next.clone());
if let Some(handler) = handler.clone() {
let payload = column_order
.iter()
.filter(|id| next.contains(*id))
.cloned()
.collect::<Vec<_>>();
handler.call(payload);
}
}
},
}
span { "{column_label}" }
}
}
}
}
}
}
}
}
if selected_count > 0 {
span {
class: "ui-data-table-selection-indicator",
"已选择 {selected_count} 行"
}
}
}
div {
class: "ui-data-table-scroll",
Table {
class: Some(inner_table_class.clone()),
TableHeader {
TableRow {
TableHead {
class: Some("ui-data-table-checkbox-cell".to_string()),
Checkbox {
checked: all_selected,
disabled: rows.is_empty(),
on_checked_change: move |checked| {
let mut next = selection_signal_header();
let handler = selection_handler_header.clone();
if checked {
let mut changed = false;
for id in row_order_for_header.iter() {
if next.insert(id.clone()) {
changed = true;
}
}
if changed {
selection_signal_header.set(next.clone());
if let Some(handler) = handler.clone() {
let payload = row_order_for_header
.iter()
.filter(|id| next.contains(*id))
.cloned()
.collect::<Vec<_>>();
handler.call(payload);
}
}
} else {
let mut changed = false;
for id in row_order_for_header.iter() {
if next.remove(id) {
changed = true;
}
}
if changed {
selection_signal_header.set(next.clone());
if let Some(handler) = handler.clone() {
let payload = row_order_for_header
.iter()
.filter(|id| next.contains(*id))
.cloned()
.collect::<Vec<_>>();
handler.call(payload);
}
}
}
},
}
}
for column in columns.iter().cloned() {
if visible_snapshot.contains(&column.id) {
TableHead { "{column.label}" }
}
}
}
}
TableBody {
for row in rows.iter().cloned() {
{
let row_id = row.id.clone();
let is_selected = selected_snapshot.contains(&row_id);
let mut selection_signal = selected_rows.clone();
let handler = selection_handler.clone();
let row_order = row_order.clone();
rsx! {
TableRow {
class: Some(if is_selected { "is-selected".to_string() } else { String::new() }),
TableCell {
class: Some("ui-data-table-checkbox-cell".to_string()),
Checkbox {
checked: is_selected,
on_checked_change: move |checked| {
let mut next = selection_signal();
if checked {
if next.insert(row_id.clone()) {
selection_signal.set(next.clone());
if let Some(handler) = handler.clone() {
let payload = row_order
.iter()
.filter(|id| next.contains(*id))
.cloned()
.collect::<Vec<_>>();
handler.call(payload);
}
}
} else if next.remove(&row_id) {
selection_signal.set(next.clone());
if let Some(handler) = handler.clone() {
let payload = row_order
.iter()
.filter(|id| next.contains(*id))
.cloned()
.collect::<Vec<_>>();
handler.call(payload);
}
}
}
}
}
for column in columns.iter().cloned() {
if visible_snapshot.contains(&column.id) {
{
let value = row
.cells
.get(&column.id)
.cloned()
.unwrap_or_default();
rsx! { TableCell { "{value}" } }
}
}
}
}
}
}
}
}
}
}
if rows.is_empty() {
div {
class: "ui-data-table-empty",
"{empty_message}"
}
}
}
}
}

View File

@@ -2,7 +2,7 @@
// need dioxus
use dioxus::prelude::*;
use views::{Components, Home, Navbar};
use views::{Components, Home, Navbar, Orders};
/// Define a components module that contains all shared components for our app.
mod components;
@@ -27,6 +27,8 @@ enum Route {
#[route("/components")]
// The components gallery reuses the full UI showcase so new primitives stay discoverable.
Components {},
#[route("/orders")]
Orders {},
}
// We can import assets in dioxus with the `asset!` macro. This macro takes a path to an asset relative to the crate root.

View File

@@ -10,9 +10,11 @@
mod components;
mod home;
mod orders;
pub use components::Components;
pub use home::Home;
pub use orders::Orders;
mod navbar;
pub use navbar::Navbar;

View File

@@ -1,9 +1,8 @@
use crate::{
components::ui::{
Avatar, Button, ButtonSize, ButtonVariant, Sidebar, SidebarContent, SidebarFooter,
SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset,
SidebarLayout, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail,
SidebarSeparator,
Button, ButtonSize, ButtonVariant, Sidebar, SidebarContent, SidebarFooter, SidebarGroup,
SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarLayout,
SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarSeparator,
},
Route,
};
@@ -13,6 +12,7 @@ fn page_title(route: &Route) -> &'static str {
match route {
Route::Home {} => "Dashboard overview",
Route::Components {} => "Component library",
Route::Orders {} => "Order management",
}
}
@@ -29,6 +29,7 @@ pub fn Navbar() -> Element {
let _title = page_title(&current_route);
let is_dashboard = matches!(current_route, Route::Home { .. });
let is_components = matches!(current_route, Route::Components { .. });
let is_orders = matches!(current_route, Route::Orders { .. });
let theme_label = {
let is_dark = is_dark.clone();
@@ -72,6 +73,15 @@ pub fn Navbar() -> Element {
href: Some(Route::Home {}.to_string()),
}
}
SidebarMenuItem {
SidebarMenuButton {
label: "Orders",
description: Some("Manage filters and fulfillment queues".to_string()),
icon: Some("🧾".to_string()),
active: is_orders,
href: Some(Route::Orders {}.to_string()),
}
}
SidebarMenuItem {
SidebarMenuButton {
label: "Components",

1056
src/views/orders.rs Normal file

File diff suppressed because it is too large Load Diff