From c1ae0eb401b81cfbb9b4aa7ba4acb206545cdfb1 Mon Sep 17 00:00:00 2001 From: tommy Date: Thu, 6 Nov 2025 09:54:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=95=E9=80=89=E6=A1=86=E5=92=8C=E5=A4=8D?= =?UTF-8?q?=E9=80=89=E6=A1=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 + assets/styling/shadcn.css | 83 +++++++++++++++++++++++ src/components/ui/checkbox.rs | 109 ++++++++++++++++++++++++++++++ src/components/ui/radio_group.rs | 112 +++++++++++++++++++++++++++++++ src/views/orders.rs | 57 +++++----------- 5 files changed, 325 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f4973d..82c0442 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,9 @@ You are an expert [0.7 Dioxus](https://dioxuslabs.com/learn/0.7) assistant. Diox Provide concise code examples with detailed descriptions + +You can query documents through context7 mcp. + # Dioxus Dependency You can add Dioxus to your `Cargo.toml` like this: diff --git a/assets/styling/shadcn.css b/assets/styling/shadcn.css index 3a353bd..44d3a77 100644 --- a/assets/styling/shadcn.css +++ b/assets/styling/shadcn.css @@ -432,6 +432,89 @@ gap: 0.75rem; } +.ui-radio-chip-group, +.ui-checkbox-chip-group { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.ui-choice-group-label { + font-size: 0.9rem; + font-weight: 500; + color: hsl(var(--foreground)); + white-space: nowrap; +} + +.ui-choice-group-options { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.ui-radio-chip, +.ui-checkbox-chip { + appearance: none; + background-color: hsl(var(--background)); + border: 1px dashed hsl(var(--border)); + border-radius: 0.65rem; + color: hsl(var(--muted-foreground)); + cursor: pointer; + display: inline-flex; + flex-direction: column; + gap: 0.15rem; + justify-content: center; + min-height: 2.2rem; + min-width: 3.5rem; + padding: 0.45rem 1.1rem; + position: relative; + text-align: center; + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +.ui-radio-chip:hover:not([data-disabled="true"]), +.ui-checkbox-chip:hover:not([data-disabled="true"]) { + border-color: hsl(var(--foreground) / 0.6); + color: hsl(var(--foreground)); +} + +.ui-radio-chip[data-state="selected"], +.ui-checkbox-chip[data-state="selected"] { + border: 1px solid hsl(var(--foreground)); + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.2); +} + +.ui-radio-chip[data-disabled="true"], +.ui-checkbox-chip[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.55; +} + +.ui-radio-chip:focus-visible, +.ui-checkbox-chip:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px hsl(var(--background)), + 0 0 0 4px hsl(var(--ring) / 0.45); +} + +.ui-chip-label { + font-size: 0.95rem; + font-weight: 500; +} + +.ui-chip-description { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + .ui-switch { appearance: none; background-color: hsl(var(--muted)); diff --git a/src/components/ui/checkbox.rs b/src/components/ui/checkbox.rs index e241477..e00808d 100644 --- a/src/components/ui/checkbox.rs +++ b/src/components/ui/checkbox.rs @@ -56,3 +56,112 @@ pub fn Checkbox( } } } + +#[derive(Clone, PartialEq)] +pub struct CheckboxChipOption { + pub label: String, + pub value: String, + pub description: Option, + pub disabled: bool, +} + +impl CheckboxChipOption { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + description: None, + disabled: false, + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn disabled(mut self) -> Self { + self.disabled = true; + self + } +} + +#[component] +pub fn CheckboxChipGroup( + mut values: Signal>, + #[props(into)] options: Vec, + #[props(into, default)] label: Option, + #[props(default)] disabled: bool, + #[props(into, default)] class: Option, + #[props(optional)] on_values_change: Option>>, +) -> Element { + let classes = merge_class("ui-checkbox-chip-group", class); + let current_values = values(); + let group_disabled = disabled; + + rsx! { + div { + class: classes, + role: "group", + "aria-disabled": group_disabled, + if let Some(label_text) = label.clone() { + span { + class: "ui-choice-group-label", + "{label_text}" + } + } + div { + class: "ui-choice-group-options", + for option in options.iter().cloned() { + { + let option_label = option.label.clone(); + let option_value = option.value.clone(); + let option_description = option.description.clone(); + let is_disabled = group_disabled || option.disabled; + let is_selected = current_values.iter().any(|item| item == &option_value); + let mut values_signal = values.clone(); + let handler = on_values_change.clone(); + + rsx! { + button { + class: "ui-checkbox-chip", + "data-state": if is_selected { "selected" } else { "idle" }, + "data-disabled": is_disabled, + role: "checkbox", + "aria-checked": if is_selected { "true" } else { "false" }, + "aria-disabled": if is_disabled { "true" } else { "false" }, + r#type: "button", + disabled: is_disabled, + onclick: move |_| { + if is_disabled { + return; + } + values_signal.with_mut(|items| { + if let Some(index) = items.iter().position(|item| item == &option_value) { + items.remove(index); + } else { + items.push(option_value.clone()); + } + }); + if let Some(callback) = handler.clone() { + callback.call(values_signal()); + } + }, + span { + class: "ui-chip-label", + "{option_label}" + } + if let Some(description) = option_description.clone() { + span { + class: "ui-chip-description", + "{description}" + } + } + } + } + } + } + } + } + } +} diff --git a/src/components/ui/radio_group.rs b/src/components/ui/radio_group.rs index 0cc5405..766d1dc 100644 --- a/src/components/ui/radio_group.rs +++ b/src/components/ui/radio_group.rs @@ -103,3 +103,115 @@ pub fn RadioGroupItem( } } } + +#[derive(Clone, PartialEq)] +pub struct RadioChipOption { + pub label: String, + pub value: String, + pub description: Option, + pub disabled: bool, +} + +impl RadioChipOption { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + description: None, + disabled: false, + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn disabled(mut self) -> Self { + self.disabled = true; + self + } +} + +#[component] +pub fn RadioChipGroup( + mut value: Signal>, + #[props(into)] options: Vec, + #[props(into, default)] label: Option, + #[props(default)] disabled: bool, + #[props(into, default)] class: Option, + #[props(optional)] on_value_change: Option>, +) -> Element { + let classes = merge_class("ui-radio-chip-group", class); + let current_value = value(); + let group_disabled = disabled; + + rsx! { + div { + class: classes, + role: "radiogroup", + "aria-disabled": group_disabled, + if let Some(label_text) = label.clone() { + span { + class: "ui-choice-group-label", + "{label_text}" + } + } + div { + class: "ui-choice-group-options", + for option in options.iter().cloned() { + { + let option_label = option.label.clone(); + let option_value = option.value.clone(); + let option_description = option.description.clone(); + let is_disabled = group_disabled || option.disabled; + let is_selected = current_value + .as_ref() + .map(|selected| selected == &option_value) + .unwrap_or(false); + let mut value_signal = value.clone(); + let handler = on_value_change.clone(); + + rsx! { + button { + class: "ui-radio-chip", + "data-state": if is_selected { "selected" } else { "idle" }, + "data-disabled": is_disabled, + role: "radio", + "aria-checked": if is_selected { "true" } else { "false" }, + "aria-disabled": if is_disabled { "true" } else { "false" }, + r#type: "button", + disabled: is_disabled, + onclick: move |_| { + if is_disabled { + return; + } + let already_selected = value_signal() + .as_ref() + .map(|selected| selected == &option_value) + .unwrap_or(false); + if !already_selected { + value_signal.set(Some(option_value.clone())); + if let Some(callback) = handler.clone() { + callback.call(option_value.clone()); + } + } + }, + span { + class: "ui-chip-label", + "{option_label}" + } + if let Some(description) = option_description.clone() { + span { + class: "ui-chip-description", + "{description}" + } + } + } + } + } + } + } + } + } +} diff --git a/src/views/orders.rs b/src/views/orders.rs index 3cd542d..9ce0ae4 100644 --- a/src/views/orders.rs +++ b/src/views/orders.rs @@ -1,12 +1,12 @@ use crate::components::ui::{ Avatar, Badge, BadgeVariant, Button, ButtonSize, ButtonVariant, Card, CardContent, - CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, DateRange, DateRangePicker, - Input, Label, Pagination, Popover, Select, SelectOption, Slider, Switch, Table, TableBody, - TableCell, TableHead, TableHeader, TableRow, ToggleGroup, ToggleGroupItem, ToggleGroupMode, + CardDescription, CardFooter, CardHeader, CardTitle, CheckboxChipGroup, CheckboxChipOption, + DateRange, DateRangePicker, Input, Label, Pagination, Popover, 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", "缺货", "重复下单", "需回访"]; @@ -499,7 +499,7 @@ pub fn Orders() -> Element { 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 tags_filter = use_signal(|| Vec::::new()); let min_total = use_signal(|| 0.0f32); let flagged_only = use_signal(|| false); let date_range = use_signal(|| None::); @@ -546,8 +546,8 @@ pub fn Orders() -> Element { 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 selected_tags = tags_filter(); + let active_tag_count = selected_tags.len(); let min_total_value = min_total(); let flagged_only_value = flagged_only(); let date_range_selected = date_range(); @@ -573,6 +573,10 @@ pub fn Orders() -> Element { .first() .cloned() .unwrap_or_else(|| "all".to_string()); + let tag_chip_options: Vec = AVAILABLE_TAGS + .iter() + .map(|tag| CheckboxChipOption::new(*tag, *tag)) + .collect(); let filtered: Vec = all_orders .iter() @@ -609,10 +613,10 @@ pub fn Orders() -> Element { .map(|expected| order.payment_method == expected) .unwrap_or(true); - let matches_tags = if tag_filter_set.is_empty() { + let matches_tags = if selected_tags.is_empty() { true } else { - tag_filter_set + selected_tags .iter() .all(|tag| order.tags.iter().any(|candidate| candidate == tag)) }; @@ -889,35 +893,10 @@ pub fn Orders() -> Element { 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}" } - } - } - } - } + CheckboxChipGroup { + label: "标签", + values: tags_filter.clone(), + options: tag_chip_options.clone(), } span { class: "ui-field-helper", {format!("已选标签:{}", active_tag_count)} } } @@ -961,7 +940,7 @@ pub fn Orders() -> Element { setter_fulfillment.set(None); setter_channel.set(None); setter_method.set(None); - setter_tags.set(HashSet::new()); + setter_tags.set(Vec::new()); setter_range.set(None); setter_min_total.set(0.0); setter_flagged.set(false);