diff --git a/assets/styling/admin.css b/assets/styling/admin.css index 008aa57..c276f01 100644 --- a/assets/styling/admin.css +++ b/assets/styling/admin.css @@ -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; diff --git a/src/components/ui/USAGE.md b/src/components/ui/USAGE.md index d0136a3..bd10d27 100644 --- a/src/components/ui/USAGE.md +++ b/src/components/ui/USAGE.md @@ -907,6 +907,57 @@ fn TableSample() -> Element { } ``` +`InteractiveTable` 在基础结构上封装了行多选与列显隐控制: + +- `columns: Vec` 定义列元数据,`fixed()` 列不可隐藏,`hide_by_default()` 默认隐藏。 +- `rows: Vec` 通过 `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 单月日期选择器。 diff --git a/src/components/ui/table.rs b/src/components/ui/table.rs index 16ad23c..fc1ced3 100644 --- a/src/components/ui/table.rs +++ b/src/components/ui/table.rs @@ -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 { @@ -104,3 +111,365 @@ pub fn TableCaption(#[props(into, default)] class: Option, 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, label: impl Into) -> 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, +} + +#[allow(dead_code)] +impl TableRowData { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + cells: HashMap::new(), + } + } + + pub fn with_cell(mut self, column_id: impl Into, value: impl Into) -> Self { + self.cells.insert(column_id.into(), value.into()); + self + } + + pub fn from_pairs(id: impl Into, cells: T) -> Self + where + T: IntoIterator, + K: Into, + V: Into, + { + 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, + #[props(into)] rows: Vec, + #[props(into, default)] class: Option, + #[props(into, default)] table_class: Option, + #[props(into, default)] default_selected: Option>, + #[props(into, default)] empty_state: Option, + #[props(optional)] on_selection_change: Option>>, + #[props(optional)] on_visibility_change: Option>>, +) -> Element { + let wrapper_class = merge_class("ui-data-table", class); + let inner_table_class = merge_class("ui-table", table_class); + + let initial_selected: HashSet = + 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 = { + 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::>(), + ); + let column_order = Rc::new( + columns + .iter() + .map(|column| column.id.clone()) + .collect::>(), + ); + let row_order = Rc::new(rows.iter().map(|row| row.id.clone()).collect::>()); + 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::>(); + 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::>(); + 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::>(); + 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::>(); + 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::>(); + 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::>(); + 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}" + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index ca68c68..fd9f0c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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. diff --git a/src/views/mod.rs b/src/views/mod.rs index 8124705..cc5000d 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -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; diff --git a/src/views/navbar.rs b/src/views/navbar.rs index c438c4e..40e34d8 100644 --- a/src/views/navbar.rs +++ b/src/views/navbar.rs @@ -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(¤t_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", diff --git a/src/views/orders.rs b/src/views/orders.rs new file mode 100644 index 0000000..8a94bad --- /dev/null +++ b/src/views/orders.rs @@ -0,0 +1,1056 @@ +use crate::components::ui::{ + Avatar, Badge, BadgeVariant, Button, ButtonSize, ButtonVariant, Card, CardContent, + CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, DateRange, DateRangePicker, + Input, Label, Pagination, Select, SelectOption, Slider, Switch, Table, TableBody, TableCell, + TableHead, TableHeader, TableRow, ToggleGroup, ToggleGroupItem, ToggleGroupMode, +}; +use chrono::NaiveDate; +use dioxus::prelude::*; +use std::collections::HashSet; + +const PAGE_SIZE: usize = 8; +const AVAILABLE_TAGS: &[&str] = &["加急", "赠品", "VIP", "缺货", "重复下单", "需回访"]; + +#[derive(Clone)] +struct Order { + number: String, + placed_on: NaiveDate, + customer_name: String, + customer_email: String, + status: OrderStatus, + payment_status: PaymentStatus, + fulfillment_status: FulfillmentStatus, + payment_method: PaymentMethod, + channel: SalesChannel, + total: f32, + tags: Vec, + flagged: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OrderStatus { + Draft, + PendingPayment, + Processing, + Fulfilled, + Cancelled, +} + +impl OrderStatus { + fn label(&self) -> &'static str { + match self { + OrderStatus::Draft => "草稿", + OrderStatus::PendingPayment => "待支付", + OrderStatus::Processing => "处理中", + OrderStatus::Fulfilled => "已完成", + OrderStatus::Cancelled => "已取消", + } + } + + fn key(&self) -> &'static str { + match self { + OrderStatus::Draft => "draft", + OrderStatus::PendingPayment => "pending", + OrderStatus::Processing => "processing", + OrderStatus::Fulfilled => "fulfilled", + OrderStatus::Cancelled => "cancelled", + } + } + + fn badge(&self) -> BadgeVariant { + match self { + OrderStatus::Draft => BadgeVariant::Secondary, + OrderStatus::PendingPayment => BadgeVariant::Outline, + OrderStatus::Processing => BadgeVariant::Default, + OrderStatus::Fulfilled => BadgeVariant::Default, + OrderStatus::Cancelled => BadgeVariant::Destructive, + } + } + + fn all() -> &'static [OrderStatus] { + &[ + OrderStatus::Draft, + OrderStatus::PendingPayment, + OrderStatus::Processing, + OrderStatus::Fulfilled, + OrderStatus::Cancelled, + ] + } + + fn from_key(value: &str) -> Option { + match value { + "draft" => Some(OrderStatus::Draft), + "pending" => Some(OrderStatus::PendingPayment), + "processing" => Some(OrderStatus::Processing), + "fulfilled" => Some(OrderStatus::Fulfilled), + "cancelled" => Some(OrderStatus::Cancelled), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PaymentStatus { + Pending, + Paid, + Refunded, + Overdue, +} + +impl PaymentStatus { + fn label(&self) -> &'static str { + match self { + PaymentStatus::Pending => "待入账", + PaymentStatus::Paid => "已支付", + PaymentStatus::Refunded => "已退款", + PaymentStatus::Overdue => "逾期", + } + } + + fn key(&self) -> &'static str { + match self { + PaymentStatus::Pending => "pending", + PaymentStatus::Paid => "paid", + PaymentStatus::Refunded => "refunded", + PaymentStatus::Overdue => "overdue", + } + } + + fn all() -> &'static [PaymentStatus] { + &[ + PaymentStatus::Pending, + PaymentStatus::Paid, + PaymentStatus::Refunded, + PaymentStatus::Overdue, + ] + } + + fn from_key(value: &str) -> Option { + match value { + "pending" => Some(PaymentStatus::Pending), + "paid" => Some(PaymentStatus::Paid), + "refunded" => Some(PaymentStatus::Refunded), + "overdue" => Some(PaymentStatus::Overdue), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FulfillmentStatus { + Unfulfilled, + Picking, + Shipped, + Delivered, + Returned, +} + +impl FulfillmentStatus { + fn label(&self) -> &'static str { + match self { + FulfillmentStatus::Unfulfilled => "待打包", + FulfillmentStatus::Picking => "拣货中", + FulfillmentStatus::Shipped => "运输中", + FulfillmentStatus::Delivered => "已签收", + FulfillmentStatus::Returned => "已退回", + } + } + + fn key(&self) -> &'static str { + match self { + FulfillmentStatus::Unfulfilled => "unfulfilled", + FulfillmentStatus::Picking => "picking", + FulfillmentStatus::Shipped => "shipped", + FulfillmentStatus::Delivered => "delivered", + FulfillmentStatus::Returned => "returned", + } + } + + fn all() -> &'static [FulfillmentStatus] { + &[ + FulfillmentStatus::Unfulfilled, + FulfillmentStatus::Picking, + FulfillmentStatus::Shipped, + FulfillmentStatus::Delivered, + FulfillmentStatus::Returned, + ] + } + + fn from_key(value: &str) -> Option { + match value { + "unfulfilled" => Some(FulfillmentStatus::Unfulfilled), + "picking" => Some(FulfillmentStatus::Picking), + "shipped" => Some(FulfillmentStatus::Shipped), + "delivered" => Some(FulfillmentStatus::Delivered), + "returned" => Some(FulfillmentStatus::Returned), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SalesChannel { + OnlineStore, + Marketplace, + Wholesale, + PopUp, + Subscription, +} + +impl SalesChannel { + fn label(&self) -> &'static str { + match self { + SalesChannel::OnlineStore => "官网商城", + SalesChannel::Marketplace => "第三方平台", + SalesChannel::Wholesale => "批发", + SalesChannel::PopUp => "快闪店", + SalesChannel::Subscription => "订阅", + } + } + + fn key(&self) -> &'static str { + match self { + SalesChannel::OnlineStore => "store", + SalesChannel::Marketplace => "marketplace", + SalesChannel::Wholesale => "wholesale", + SalesChannel::PopUp => "popup", + SalesChannel::Subscription => "subscription", + } + } + + fn all() -> &'static [SalesChannel] { + &[ + SalesChannel::OnlineStore, + SalesChannel::Marketplace, + SalesChannel::Wholesale, + SalesChannel::PopUp, + SalesChannel::Subscription, + ] + } + + fn from_key(value: &str) -> Option { + match value { + "store" => Some(SalesChannel::OnlineStore), + "marketplace" => Some(SalesChannel::Marketplace), + "wholesale" => Some(SalesChannel::Wholesale), + "popup" => Some(SalesChannel::PopUp), + "subscription" => Some(SalesChannel::Subscription), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PaymentMethod { + CreditCard, + BankTransfer, + Cash, + Paypal, + WechatPay, + Alipay, +} + +impl PaymentMethod { + fn label(&self) -> &'static str { + match self { + PaymentMethod::CreditCard => "信用卡", + PaymentMethod::BankTransfer => "银行转账", + PaymentMethod::Cash => "现金", + PaymentMethod::Paypal => "PayPal", + PaymentMethod::WechatPay => "微信支付", + PaymentMethod::Alipay => "支付宝", + } + } + + fn key(&self) -> &'static str { + match self { + PaymentMethod::CreditCard => "card", + PaymentMethod::BankTransfer => "transfer", + PaymentMethod::Cash => "cash", + PaymentMethod::Paypal => "paypal", + PaymentMethod::WechatPay => "wechat", + PaymentMethod::Alipay => "alipay", + } + } + + fn all() -> &'static [PaymentMethod] { + &[ + PaymentMethod::CreditCard, + PaymentMethod::BankTransfer, + PaymentMethod::Cash, + PaymentMethod::Paypal, + PaymentMethod::WechatPay, + PaymentMethod::Alipay, + ] + } + + fn from_key(value: &str) -> Option { + match value { + "card" => Some(PaymentMethod::CreditCard), + "transfer" => Some(PaymentMethod::BankTransfer), + "cash" => Some(PaymentMethod::Cash), + "paypal" => Some(PaymentMethod::Paypal), + "wechat" => Some(PaymentMethod::WechatPay), + "alipay" => Some(PaymentMethod::Alipay), + _ => None, + } + } +} + +fn seeded_orders() -> Vec { + vec![ + Order { + number: "DX-1050".to_string(), + placed_on: date(2024, 7, 23), + customer_name: "孙若水".to_string(), + customer_email: "ruoshui@example.com".to_string(), + status: OrderStatus::Processing, + payment_status: PaymentStatus::Paid, + fulfillment_status: FulfillmentStatus::Picking, + payment_method: PaymentMethod::CreditCard, + channel: SalesChannel::OnlineStore, + total: 1288.0, + tags: tags(&["VIP", "加急"]), + flagged: true, + }, + Order { + number: "DX-1049".to_string(), + placed_on: date(2024, 7, 22), + customer_name: "李倩".to_string(), + customer_email: "lian@example.com".to_string(), + status: OrderStatus::PendingPayment, + payment_status: PaymentStatus::Pending, + fulfillment_status: FulfillmentStatus::Unfulfilled, + payment_method: PaymentMethod::WechatPay, + channel: SalesChannel::Marketplace, + total: 342.0, + tags: tags(&["需回访"]), + flagged: true, + }, + Order { + number: "DX-1048".to_string(), + placed_on: date(2024, 7, 21), + customer_name: "Zoe Chen".to_string(), + customer_email: "zoe@example.com".to_string(), + status: OrderStatus::Processing, + payment_status: PaymentStatus::Paid, + fulfillment_status: FulfillmentStatus::Shipped, + payment_method: PaymentMethod::Paypal, + channel: SalesChannel::Marketplace, + total: 812.5, + tags: tags(&["加急"]), + flagged: false, + }, + Order { + number: "DX-1047".to_string(), + placed_on: date(2024, 7, 20), + customer_name: "王宏".to_string(), + customer_email: "hong@example.com".to_string(), + status: OrderStatus::Fulfilled, + payment_status: PaymentStatus::Paid, + fulfillment_status: FulfillmentStatus::Delivered, + payment_method: PaymentMethod::CreditCard, + channel: SalesChannel::OnlineStore, + total: 1560.0, + tags: tags(&["VIP", "赠品"]), + flagged: false, + }, + Order { + number: "DX-1046".to_string(), + placed_on: date(2024, 7, 18), + customer_name: "刘洋".to_string(), + customer_email: "yang@example.com".to_string(), + status: OrderStatus::Processing, + payment_status: PaymentStatus::Overdue, + fulfillment_status: FulfillmentStatus::Unfulfilled, + payment_method: PaymentMethod::BankTransfer, + channel: SalesChannel::Wholesale, + total: 2890.4, + tags: tags(&["缺货", "需回访"]), + flagged: true, + }, + Order { + number: "DX-1045".to_string(), + placed_on: date(2024, 7, 17), + customer_name: "陈浩".to_string(), + customer_email: "hao@example.com".to_string(), + status: OrderStatus::Cancelled, + payment_status: PaymentStatus::Refunded, + fulfillment_status: FulfillmentStatus::Returned, + payment_method: PaymentMethod::CreditCard, + channel: SalesChannel::OnlineStore, + total: 420.0, + tags: tags(&["重复下单"]), + flagged: false, + }, + Order { + number: "DX-1044".to_string(), + placed_on: date(2024, 7, 16), + customer_name: "Marvin Zhou".to_string(), + customer_email: "marvin@example.com".to_string(), + status: OrderStatus::Processing, + payment_status: PaymentStatus::Paid, + fulfillment_status: FulfillmentStatus::Shipped, + payment_method: PaymentMethod::CreditCard, + channel: SalesChannel::PopUp, + total: 980.0, + tags: tags(&["赠品"]), + flagged: false, + }, + Order { + number: "DX-1043".to_string(), + placed_on: date(2024, 7, 15), + customer_name: "张伟".to_string(), + customer_email: "zhangwei@example.com".to_string(), + status: OrderStatus::Fulfilled, + payment_status: PaymentStatus::Paid, + fulfillment_status: FulfillmentStatus::Delivered, + payment_method: PaymentMethod::WechatPay, + channel: SalesChannel::Subscription, + total: 652.0, + tags: tags(&["VIP"]), + flagged: false, + }, + Order { + number: "DX-1042".to_string(), + placed_on: date(2024, 7, 13), + customer_name: "李雷".to_string(), + customer_email: "lilei@example.com".to_string(), + status: OrderStatus::PendingPayment, + payment_status: PaymentStatus::Pending, + fulfillment_status: FulfillmentStatus::Unfulfilled, + payment_method: PaymentMethod::BankTransfer, + channel: SalesChannel::Wholesale, + total: 3750.0, + tags: tags(&["缺货", "加急"]), + flagged: true, + }, + Order { + number: "DX-1041".to_string(), + placed_on: date(2024, 7, 11), + customer_name: "丁一".to_string(), + customer_email: "dingyi@example.com".to_string(), + status: OrderStatus::Processing, + payment_status: PaymentStatus::Paid, + fulfillment_status: FulfillmentStatus::Picking, + payment_method: PaymentMethod::Alipay, + channel: SalesChannel::OnlineStore, + total: 512.8, + tags: tags(&["赠品"]), + flagged: false, + }, + Order { + number: "DX-1040".to_string(), + placed_on: date(2024, 7, 10), + customer_name: "Grace Li".to_string(), + customer_email: "grace@example.com".to_string(), + status: OrderStatus::Fulfilled, + payment_status: PaymentStatus::Paid, + fulfillment_status: FulfillmentStatus::Delivered, + payment_method: PaymentMethod::CreditCard, + channel: SalesChannel::Marketplace, + total: 786.2, + tags: tags(&["VIP", "赠品"]), + flagged: false, + }, + Order { + number: "DX-1039".to_string(), + placed_on: date(2024, 7, 9), + customer_name: "赵敏".to_string(), + customer_email: "zhaomin@example.com".to_string(), + status: OrderStatus::Draft, + payment_status: PaymentStatus::Pending, + fulfillment_status: FulfillmentStatus::Unfulfilled, + payment_method: PaymentMethod::Cash, + channel: SalesChannel::PopUp, + total: 210.0, + tags: tags(&["需回访"]), + flagged: false, + }, + ] +} + +fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid mock date") +} + +fn tags(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn initials(input: &str) -> String { + let initials: String = input + .split_whitespace() + .filter_map(|part| part.chars().next()) + .take(2) + .collect(); + if initials.is_empty() { + input.chars().take(2).collect() + } else { + initials.to_uppercase() + } +} + +#[component] +pub fn Orders() -> Element { + let search = use_signal(|| String::new()); + let status_filter = use_signal(|| None::); + let payment_filter = use_signal(|| None::); + let fulfillment_filter = use_signal(|| None::); + let channel_filter = use_signal(|| None::); + let method_filter = use_signal(|| None::); + let tags_filter = use_signal(|| HashSet::::new()); + let min_total = use_signal(|| 0.0f32); + let flagged_only = use_signal(|| false); + let date_range = use_signal(|| None::); + let pipeline = use_signal(|| vec!["all".to_string()]); + let page = use_signal(|| 1usize); + + { + let search_signal = search.clone(); + let status_signal = status_filter.clone(); + let payment_signal = payment_filter.clone(); + let fulfillment_signal = fulfillment_filter.clone(); + let channel_signal = channel_filter.clone(); + let method_signal = method_filter.clone(); + let tags_signal = tags_filter.clone(); + let range_signal = date_range.clone(); + let min_total_signal = min_total.clone(); + let flagged_signal = flagged_only.clone(); + let pipeline_signal = pipeline.clone(); + let mut page_signal = page.clone(); + + use_effect(move || { + search_signal(); + status_signal(); + payment_signal(); + fulfillment_signal(); + channel_signal(); + method_signal(); + tags_signal(); + range_signal(); + min_total_signal(); + flagged_signal(); + pipeline_signal(); + page_signal.set(1); + }); + } + + let all_orders = seeded_orders(); + let total_orders_count = all_orders.len(); + + let search_value = search(); + let search_term = search_value.to_lowercase(); + let status_selected = status_filter(); + let payment_selected = payment_filter(); + let fulfillment_selected = fulfillment_filter(); + let channel_selected = channel_filter(); + let method_selected = method_filter(); + let tag_filter_set = tags_filter(); + let active_tag_count = tag_filter_set.len(); + let min_total_value = min_total(); + let flagged_only_value = flagged_only(); + let date_range_selected = date_range(); + let pipeline_values = pipeline(); + let pipeline_value = pipeline_values + .first() + .cloned() + .unwrap_or_else(|| "all".to_string()); + + let filtered: Vec = all_orders + .iter() + .cloned() + .filter(|order| { + let matches_search = if !search_term.is_empty() { + let composite = format!( + "{} {} {} {} {}", + order.number, + order.customer_name, + order.customer_email, + order.channel.label(), + order.tags.join(" ") + ) + .to_lowercase(); + composite.contains(&search_term) + } else { + true + }; + + let matches_status = status_selected + .map(|expected| order.status == expected) + .unwrap_or(true); + let matches_payment = payment_selected + .map(|expected| order.payment_status == expected) + .unwrap_or(true); + let matches_fulfillment = fulfillment_selected + .map(|expected| order.fulfillment_status == expected) + .unwrap_or(true); + let matches_channel = channel_selected + .map(|expected| order.channel == expected) + .unwrap_or(true); + let matches_method = method_selected + .map(|expected| order.payment_method == expected) + .unwrap_or(true); + + let matches_tags = if tag_filter_set.is_empty() { + true + } else { + tag_filter_set + .iter() + .all(|tag| order.tags.iter().any(|candidate| candidate == tag)) + }; + + let matches_range = if let Some(range) = date_range_selected { + let date = order.placed_on; + date >= range.start && date <= range.end + } else { + true + }; + + let matches_total = if min_total_value > 0.0 { + order.total >= min_total_value + } else { + true + }; + + let matches_flagged = if flagged_only_value { + order.flagged + } else { + true + }; + + let matches_pipeline = match pipeline_value.as_str() { + "awaiting_fulfillment" => !matches!( + order.fulfillment_status, + FulfillmentStatus::Delivered | FulfillmentStatus::Returned + ), + "overdue" => matches!(order.payment_status, PaymentStatus::Overdue), + "vip" => order.tags.iter().any(|tag| tag == "VIP"), + _ => true, + }; + + matches_search + && matches_status + && matches_payment + && matches_fulfillment + && matches_channel + && matches_method + && matches_tags + && matches_range + && matches_total + && matches_flagged + && matches_pipeline + }) + .collect(); + + let filtered_total = filtered.len(); + let gross_revenue = filtered.iter().map(|order| order.total).sum::(); + let average_order_value = if filtered_total > 0 { + gross_revenue / filtered_total as f32 + } else { + 0.0 + }; + let outstanding_payments = filtered + .iter() + .filter(|order| { + matches!( + order.payment_status, + PaymentStatus::Pending | PaymentStatus::Overdue + ) + }) + .count(); + let awaiting_fulfillment = filtered + .iter() + .filter(|order| { + !matches!( + order.fulfillment_status, + FulfillmentStatus::Delivered | FulfillmentStatus::Returned + ) + }) + .count(); + let flagged_orders = filtered.iter().filter(|order| order.flagged).count(); + + let page_count = ((filtered_total + PAGE_SIZE - 1) / PAGE_SIZE).max(1); + let active_page = page(); + let effective_page = active_page.max(1).min(page_count); + if active_page != effective_page { + let mut page_signal = page.clone(); + page_signal.set(effective_page); + } + let offset = effective_page.saturating_sub(1) * PAGE_SIZE; + let paginated_orders: Vec = filtered + .iter() + .skip(offset) + .take(PAGE_SIZE) + .cloned() + .collect(); + + let status_options = OrderStatus::all() + .iter() + .map(|status| SelectOption::new(status.label(), status.key())) + .collect::>(); + let payment_options = PaymentStatus::all() + .iter() + .map(|status| SelectOption::new(status.label(), status.key())) + .collect::>(); + let fulfillment_options = FulfillmentStatus::all() + .iter() + .map(|status| SelectOption::new(status.label(), status.key())) + .collect::>(); + let channel_options = SalesChannel::all() + .iter() + .map(|channel| SelectOption::new(channel.label(), channel.key())) + .collect::>(); + let method_options = PaymentMethod::all() + .iter() + .map(|method| SelectOption::new(method.label(), method.key())) + .collect::>(); + + rsx! { + div { + class: "ui-stack", + style: "gap: 1.5rem;", + Card { + CardHeader { + CardTitle { "订单管理" } + CardDescription { "综合筛选订单、监控支付与履约状态,获取健康度指标。" } + } + CardContent { + div { class: "orders-metrics", + div { class: "orders-metric-card", + span { class: "orders-metric-label", "筛选后订单" } + span { class: "orders-metric-value", "{filtered_total}" } + span { class: "orders-metric-sub", {format!("共 {} 条记录", total_orders_count)} } + } + div { class: "orders-metric-card", + span { class: "orders-metric-label", "筛选总收入" } + span { class: "orders-metric-value", {format!("¥{:.0}", gross_revenue)} } + span { class: "orders-metric-sub", {format!("平均客单价 ¥{:.0}", average_order_value)} } + } + div { class: "orders-metric-card", + span { class: "orders-metric-label", "待处理支付" } + span { class: "orders-metric-value", "{outstanding_payments}" } + span { class: "orders-metric-sub", "包含逾期与待入账订单" } + } + div { class: "orders-metric-card", + span { class: "orders-metric-label", "履约队列" } + span { class: "orders-metric-value", "{awaiting_fulfillment}" } + span { class: "orders-metric-sub", {format!("标记关注 {} 单", flagged_orders)} } + } + } + } + } + + Card { + CardHeader { + CardTitle { "筛选器" } + CardDescription { "组合多个维度快速圈定目标订单,可随时重置。" } + } + CardContent { + div { class: "orders-filter-grid", + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { html_for: "order-search", "关键词" } + Input { + id: Some("order-search".to_string()), + placeholder: Some("订单号 / 客户 / 邮箱".to_string()), + value: Some(search_value.clone()), + on_input: { + let mut setter = search.clone(); + move |event: FormEvent| setter.set(event.value()) + }, + } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "订单状态" } + Select { + placeholder: "全部状态", + options: status_options.clone(), + selected: status_selected.map(|status| status.key().to_string()), + on_change: { + let mut setter = status_filter.clone(); + move |value: String| setter.set(OrderStatus::from_key(&value)) + }, + } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "支付状态" } + Select { + placeholder: "全部支付".to_string(), + options: payment_options.clone(), + selected: payment_selected.map(|status| status.key().to_string()), + on_change: { + let mut setter = payment_filter.clone(); + move |value: String| setter.set(PaymentStatus::from_key(&value)) + }, + } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "履约状态" } + Select { + placeholder: "全部履约".to_string(), + options: fulfillment_options.clone(), + selected: fulfillment_selected.map(|status| status.key().to_string()), + on_change: { + let mut setter = fulfillment_filter.clone(); + move |value: String| setter.set(FulfillmentStatus::from_key(&value)) + }, + } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "销售渠道" } + Select { + placeholder: "全部渠道".to_string(), + options: channel_options.clone(), + selected: channel_selected.map(|channel| channel.key().to_string()), + on_change: { + let mut setter = channel_filter.clone(); + move |value: String| setter.set(SalesChannel::from_key(&value)) + }, + } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "支付方式" } + Select { + placeholder: "全部方式".to_string(), + options: method_options.clone(), + selected: method_selected.map(|method| method.key().to_string()), + on_change: { + let mut setter = method_filter.clone(); + move |value: String| setter.set(PaymentMethod::from_key(&value)) + }, + } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "下单日期" } + DateRangePicker { + value: date_range.clone(), + on_change: { + let mut setter = date_range.clone(); + move |range: Option| setter.set(range) + }, + } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "订单金额 (¥)" } + Slider { + value: min_total_value, + min: 0.0, + max: 4000.0, + step: 50.0, + on_value_change: { + let mut setter = min_total.clone(); + move |value: f32| setter.set(value) + }, + } + span { class: "ui-field-helper", {format!("最低金额:¥{}", min_total_value as i32)} } + } + div { class: "ui-stack orders-filter-wide", style: "gap: 0.5rem;", + Label { "标签" } + div { class: "orders-tag-cloud", + for tag in AVAILABLE_TAGS.iter() { + { + let tag_value = tag.to_string(); + let is_checked = tag_filter_set.contains(*tag); + rsx! { + label { style: "display: inline-flex; align-items: center; gap: 0.4rem;", + Checkbox { + checked: is_checked, + on_checked_change: { + let mut setter = tags_filter.clone(); + let tag_value = tag_value.clone(); + move |checked: bool| { + setter.with_mut(|tags| { + if checked { + tags.insert(tag_value.clone()); + } else { + tags.remove(&tag_value); + } + }); + } + }, + } + span { "{tag}" } + } + } + } + } + } + span { class: "ui-field-helper", {format!("已选标签:{}", active_tag_count)} } + } + div { class: "ui-stack", style: "gap: 0.5rem;", + Label { "重点订单" } + div { class: "ui-cluster", style: "gap: 0.75rem;", + Switch { + checked: flagged_only_value, + on_checked_change: { + let mut setter = flagged_only.clone(); + move |state: bool| setter.set(state) + }, + } + span { "仅查看标记关注的订单" } + } + } + } + } + CardFooter { + div { class: "ui-cluster", style: "gap: 0.75rem;", + Button { + variant: ButtonVariant::Secondary, + size: ButtonSize::Sm, + on_click: { + let mut setter_search = search.clone(); + let mut setter_status = status_filter.clone(); + let mut setter_payment = payment_filter.clone(); + let mut setter_fulfillment = fulfillment_filter.clone(); + let mut setter_channel = channel_filter.clone(); + let mut setter_method = method_filter.clone(); + let mut setter_tags = tags_filter.clone(); + let mut setter_range = date_range.clone(); + let mut setter_min_total = min_total.clone(); + let mut setter_flagged = flagged_only.clone(); + let mut setter_pipeline = pipeline.clone(); + let mut setter_page = page.clone(); + move |_| { + setter_search.set(String::new()); + setter_status.set(None); + setter_payment.set(None); + setter_fulfillment.set(None); + setter_channel.set(None); + setter_method.set(None); + setter_tags.set(HashSet::new()); + setter_range.set(None); + setter_min_total.set(0.0); + setter_flagged.set(false); + setter_pipeline.set(vec!["all".to_string()]); + setter_page.set(1); + } + }, + "重置筛选" + } + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Sm, + "导出报表" + } + } + } + } + + Card { + CardHeader { + CardTitle { "订单列表" } + CardDescription { "结果会实时反映筛选条件,可分页浏览。" } + } + CardContent { + div { class: "ui-stack", style: "gap: 1rem;", + ToggleGroup { + values: pipeline.clone(), + mode: ToggleGroupMode::Single, + on_value_change: { + let mut setter = pipeline.clone(); + move |values: Vec| setter.set(values) + }, + ToggleGroupItem { value: "all".to_string(), "全部订单" } + ToggleGroupItem { value: "awaiting_fulfillment".to_string(), "待履约" } + ToggleGroupItem { value: "overdue".to_string(), "支付逾期" } + ToggleGroupItem { value: "vip".to_string(), "VIP 客户" } + } + if paginated_orders.is_empty() { + div { class: "orders-empty", + span { class: "orders-metric-label", "没有匹配的订单" } + span { class: "orders-metric-sub", "调整筛选条件或清除限制重新查看。" } + } + } else { + Table { + TableHeader { + TableRow { + TableHead { "订单信息" } + TableHead { "日期" } + TableHead { "状态" } + TableHead { "支付" } + TableHead { "履约" } + TableHead { "渠道" } + TableHead { "金额" } + TableHead { "标签" } + TableHead { "操作" } + } + } + TableBody { + for order in paginated_orders.iter().cloned() { + { + let badge_variant = order.status.badge(); + let initials = initials(&order.customer_name); + let date_display = order.placed_on.format("%Y-%m-%d").to_string(); + rsx! { + TableRow { + TableCell { + div { style: "display: flex; align-items: center; gap: 0.75rem;", + Avatar { + fallback: Some(initials.clone()), + alt: Some(order.customer_name.clone()), + } + div { style: "display: flex; flex-direction: column; gap: 0.25rem;", + span { style: "font-weight: 600;", "{order.customer_name}" } + span { class: "ui-field-helper", "{order.customer_email}" } + } + } + } + TableCell { "{date_display}" } + TableCell { + Badge { variant: badge_variant, "{order.status.label()}" } + } + TableCell { + Badge { + variant: match order.payment_status { + PaymentStatus::Paid => BadgeVariant::Secondary, + PaymentStatus::Refunded => BadgeVariant::Outline, + PaymentStatus::Overdue => BadgeVariant::Destructive, + PaymentStatus::Pending => BadgeVariant::Default, + }, + "{order.payment_status.label()}" + } + } + TableCell { "{order.fulfillment_status.label()}" } + TableCell { "{order.channel.label()}" } + TableCell { {format!("¥{:.2}", order.total)} } + TableCell { + div { class: "orders-tag-cloud", + for tag in order.tags.iter().cloned() { + { + rsx! { Badge { variant: BadgeVariant::Outline, "{tag}" } } + } + } + } + } + TableCell { + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::Sm, + "查看" + } + } + } + } + } + } + } + } + } + } + } + CardFooter { + if filtered_total > PAGE_SIZE { + Pagination { + total_pages: page_count, + current_page: effective_page, + on_page_change: { + let mut setter = page.clone(); + move |page_index: usize| setter.set(page_index) + }, + } + } + } + } + } + } +}