diff --git a/Cargo.lock b/Cargo.lock index f8bdd29..6236b13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1874,6 +1874,7 @@ dependencies = [ "chrono", "dioxus 0.7.0", "dioxus-motion", + "js-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cc01449..40834d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ dioxus = { version = "0.7.0", features = ["router", "fullstack"] } dioxus-motion = "0.3.1" chrono = { version = "0.4", default-features = false, features = ["std"] } +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" + [features] default = ["web"] # The feature that are only required for the web = ["dioxus/web"] build target should be optional and only enabled in the web = ["dioxus/web"] feature diff --git a/assets/styling/shadcn.css b/assets/styling/shadcn.css index 626f43a..031b21a 100644 --- a/assets/styling/shadcn.css +++ b/assets/styling/shadcn.css @@ -1421,6 +1421,20 @@ gap: 0.75rem; } +.ui-date-range-nav-group { + display: flex; + gap: 0.25rem; +} + +.ui-date-range-nav { + width: 2rem; + height: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + .ui-date-range-title { font-weight: 600; color: hsl(var(--foreground)); @@ -1458,6 +1472,27 @@ color: hsl(var(--muted-foreground)); } +.ui-date-range-shortcuts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.ui-date-range-controls { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + width: 100%; + margin-top: 0.5rem; +} + +.ui-date-range-shortcut, +.ui-date-range-clear, +.ui-date-range-confirm { + min-width: 0; +} + .ui-date-range-calendars { display: grid; gap: 1rem; diff --git a/src/components/ui/date_range_picker.rs b/src/components/ui/date_range_picker.rs index e4b5f4d..31cebf5 100644 --- a/src/components/ui/date_range_picker.rs +++ b/src/components/ui/date_range_picker.rs @@ -1,5 +1,9 @@ +use super::button::{Button, ButtonSize, ButtonVariant}; use chrono::{Datelike, Duration, NaiveDate}; use dioxus::prelude::*; +#[cfg(not(target_arch = "wasm32"))] +use std::time::SystemTime; +use crate::components::ui::PopoverHandle; fn merge_class(base: &str, extra: Option) -> String { if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { @@ -49,6 +53,53 @@ fn add_months(date: NaiveDate, offset: i32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month as u32, 1).unwrap_or(date) } +fn last_day_of_month(date: NaiveDate) -> NaiveDate { + let first = first_day_of_month(date); + let next_month = add_months(first, 1); + next_month - Duration::days(1) +} + +fn quick_range_week(today: NaiveDate) -> DateRange { + let start = today - Duration::days(today.weekday().num_days_from_monday() as i64); + let end = start + Duration::days(6); + DateRange::new(start, end) +} + +fn quick_range_month(today: NaiveDate) -> DateRange { + let start = first_day_of_month(today); + let end = last_day_of_month(today); + DateRange::new(start, end) +} + +#[cfg(target_arch = "wasm32")] +fn today_date() -> NaiveDate { + use js_sys::Date; + + let date = Date::new_0(); + let year = date.get_full_year() as i32; + let month = (date.get_month() + 1) as u32; + let day = date.get_date() as u32; + NaiveDate::from_ymd_opt(year, month, day) + .unwrap_or_else(|| NaiveDate::from_ymd_opt(1970, 1, 1).expect("unix epoch")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn today_date() -> NaiveDate { + let now = SystemTime::now(); + let datetime: chrono::DateTime = now.into(); + datetime.date_naive() +} + +fn describe_range(range: DateRange) -> String { + if range.start == range.end { + range.start.format("%b %d %Y").to_string() + } else { + let start_label = range.start.format("%b %d"); + let end_label = range.end.format("%b %d %Y"); + format!("{start_label} → {end_label}") + } +} + fn days_for_month(month_start: NaiveDate) -> Vec { let start_offset = month_start.weekday().num_days_from_monday() as i64; let mut cursor = month_start - Duration::days(start_offset); @@ -77,81 +128,176 @@ pub fn DateRangePicker( #[props(optional)] initial_month: Option, #[props(into, default)] class: Option, ) -> Element { + let today = today_date(); + let initial_month = value() .map(|range| range.start) .or(initial_month) - .unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid default date")); - let month = use_signal(move || first_day_of_month(initial_month)); + .map(first_day_of_month) + .unwrap_or_else(|| first_day_of_month(today)); + let month = use_signal(move || initial_month); let hover_date = use_signal(|| None::); + let draft_range = use_signal({ + let initial_value = value(); + move || initial_value + }); + let popover_handle = try_use_context::(); let classes = merge_class("ui-date-range", class); let on_change_handler = on_change.clone(); - let active_range = value(); + { + let mut draft_signal = draft_range.clone(); + let value_signal = value.clone(); + use_effect(move || { + draft_signal.set(value_signal()); + }); + } + + let confirmed_range = value(); + let pending_range = draft_range(); + let base_month = month(); + let month_label = |date: NaiveDate| date.format("%B %Y").to_string(); + let preview_primary_text = match (pending_range, confirmed_range) { + (Some(pending), Some(confirmed)) if pending == confirmed => { + format!("当前: {}", describe_range(pending)) + } + (Some(pending), _) => format!("待确认: {}", describe_range(pending)), + (None, Some(_)) => "待确认: 清除日期".to_string(), + (None, None) => "选择一个开始日期以创建范围。".to_string(), + }; + let preview_secondary_text = match (pending_range, confirmed_range) { + (Some(pending), Some(confirmed)) if pending != confirmed => { + Some(format!("当前: {}", describe_range(confirmed))) + } + (None, Some(confirmed)) => Some(format!("当前: {}", describe_range(confirmed))), + _ => None, + }; + let confirm_disabled = match (pending_range, confirmed_range) { + (None, None) => true, + (Some(pending), Some(confirmed)) if pending == confirmed => true, + _ => false, + }; + rsx! { div { class: classes, div { class: "ui-date-range-toolbar", - button { - class: "ui-date-range-nav", - r#type: "button", - "aria-label": "Previous month", - onclick: { - let mut month_signal = month.clone(); - move |_| { - let next = add_months(month_signal(), -1); - month_signal.set(next); - } - }, - "‹" + div { + class: "ui-date-range-nav-group", + Button { + class: "ui-date-range-nav", + variant: ButtonVariant::Ghost, + on_click: { + let mut month_signal = month.clone(); + move |_| { + let next = add_months(month_signal(), -12); + month_signal.set(next); + } + }, + "«" + } + Button { + class: "ui-date-range-nav", + variant: ButtonVariant::Ghost, + on_click: { + let mut month_signal = month.clone(); + move |_| { + let next = add_months(month_signal(), -1); + month_signal.set(next); + } + }, + "‹" + } } div { class: "ui-date-range-labels", - span { class: "ui-date-range-title", "{month_label(month())}" } - span { class: "ui-date-range-title", "{month_label(add_months(month(), 1))}" } + span { class: "ui-date-range-title", "{month_label(base_month)}" } + span { class: "ui-date-range-title", "{month_label(add_months(base_month, 1))}" } } - button { - class: "ui-date-range-nav", - r#type: "button", - "aria-label": "Next month", - onclick: { + div { + class: "ui-date-range-nav-group", + Button { + class: "ui-date-range-nav", + variant: ButtonVariant::Ghost, + on_click: { + let mut month_signal = month.clone(); + move |_| { + let next = add_months(month_signal(), 1); + month_signal.set(next); + } + }, + "›" + } + Button { + class: "ui-date-range-nav", + variant: ButtonVariant::Ghost, + on_click: { + let mut month_signal = month.clone(); + move |_| { + let next = add_months(month_signal(), 12); + month_signal.set(next); + } + }, + "»" + } + } + } + div { + class: "ui-date-range-shortcuts", + Button { + class: "ui-date-range-shortcut", + size: ButtonSize::Sm, + variant: ButtonVariant::Secondary, + on_click: { + let mut draft_signal = draft_range.clone(); let mut month_signal = month.clone(); + let mut hover_signal = hover_date.clone(); move |_| { - let next = add_months(month_signal(), 1); - month_signal.set(next); + let selected = quick_range_week(today); + draft_signal.set(Some(selected)); + month_signal.set(first_day_of_month(selected.start)); + hover_signal.set(None); } }, - "›" + "本周" + } + Button { + class: "ui-date-range-shortcut", + size: ButtonSize::Sm, + variant: ButtonVariant::Secondary, + on_click: { + let mut draft_signal = draft_range.clone(); + let mut month_signal = month.clone(); + let mut hover_signal = hover_date.clone(); + move |_| { + let selected = quick_range_month(today); + draft_signal.set(Some(selected)); + month_signal.set(first_day_of_month(selected.start)); + hover_signal.set(None); + } + }, + "本月" } } div { class: "ui-date-range-preview", - match active_range { - Some(range) if range.start != range.end => { - let start_label = range.start.format("%b %d").to_string(); - let end_label = range.end.format("%b %d %Y").to_string(); - rsx! { span { "{start_label} → {end_label}" } } - } - Some(range) => { - let label = range.start.format("%b %d %Y").to_string(); - rsx! { span { "Selected {label}" } } - } - None => rsx! { span { "Pick a start date to begin the range." } }, + span { class: "ui-date-range-preview-primary", "{preview_primary_text}" } + for text in preview_secondary_text.iter() { + span { class: "ui-date-range-preview-secondary", "{text}" } } } div { class: "ui-date-range-calendars", for offset in 0..2 { { - let calendar_month = add_months(month(), offset); + let calendar_month = add_months(base_month, offset); let days = days_for_month(calendar_month); let active_month = calendar_month.month(); - let range_signal = value.clone(); + let range_signal = draft_range.clone(); let hover_signal = hover_date.clone(); - let on_change_handler = on_change_handler.clone(); - rsx! { div { class: "ui-date-range-calendar", @@ -165,18 +311,9 @@ pub fn DateRangePicker( { let preview = range_preview(range_signal(), hover_signal()); let in_current_month = day.month() == active_month; - let mut range_signal = range_signal.clone(); - let mut hover_signal = hover_signal.clone(); - let is_selected_start = preview - .map(|range| range.start == day) - .unwrap_or(false); - let is_selected_end = preview - .map(|range| range.end == day) - .unwrap_or(false); - let is_in_range = preview - .map(|range| range.contains(&day)) - .unwrap_or(false); - + let is_selected_start = preview.map(|range| range.start == day).unwrap_or(false); + let is_selected_end = preview.map(|range| range.end == day).unwrap_or(false); + let is_in_range = preview.map(|range| range.contains(&day)).unwrap_or(false); rsx! { button { class: "ui-calendar-day", @@ -186,51 +323,43 @@ pub fn DateRangePicker( "data-outside": if in_current_month { "false" } else { "true" }, onclick: { let day_value = day; - let on_change_handler = on_change_handler.clone(); + let mut draft_signal = range_signal.clone(); + let mut hover_signal = hover_signal.clone(); move |_| { - let current_range = range_signal(); - match current_range { + let current_range = draft_signal(); + let next_range = match current_range { Some(current) if current.start != current.end => { - let new_range = DateRange::new(day_value, day_value); - range_signal.set(Some(new_range)); - if let Some(handler) = on_change_handler.clone() { - handler.call(Some(new_range)); - } + Some(DateRange::new(day_value, day_value)) } Some(current) => { if day_value == current.start { - let new_range = DateRange::new(day_value, day_value); - range_signal.set(Some(new_range)); - if let Some(handler) = on_change_handler.clone() { - handler.call(Some(new_range)); - } + Some(DateRange::new(day_value, day_value)) } else { - let new_range = DateRange::new(current.start, day_value); - range_signal.set(Some(new_range)); - if let Some(handler) = on_change_handler.clone() { - handler.call(Some(new_range)); - } + Some(DateRange::new(current.start, day_value)) } } - None => { - let new_range = DateRange::new(day_value, day_value); - range_signal.set(Some(new_range)); - if let Some(handler) = on_change_handler.clone() { - handler.call(Some(new_range)); - } + None => Some(DateRange::new(day_value, day_value)), + }; + draft_signal.set(next_range); + hover_signal.set(None); + } + }, + onmouseenter: { + let day_value = day; + let draft_signal = range_signal.clone(); + let mut hover_signal = hover_signal.clone(); + move |_| { + if let Some(range) = draft_signal() { + if range.start == range.end { + hover_signal.set(Some(day_value)); } } } }, - onmouseenter: move |_| { - let range = range_signal(); - if let Some(range) = range { - if range.start == range.end { - hover_signal.set(Some(day)); - } - } + onmouseleave: { + let mut hover_signal = hover_signal.clone(); + move |_| hover_signal.set(None) }, - onmouseleave: move |_| hover_signal.set(None), "{day.day()}" } } @@ -242,6 +371,50 @@ pub fn DateRangePicker( } } } + div { + class: "ui-date-range-controls", + Button { + class: "ui-date-range-clear", + variant: ButtonVariant::Ghost, + size: ButtonSize::Sm, + on_click: { + let mut draft_signal = draft_range.clone(); + let mut hover_signal = hover_date.clone(); + move |_| { + draft_signal.set(None); + hover_signal.set(None); + } + }, + "清除" + } + Button { + class: "ui-date-range-confirm", + size: ButtonSize::Sm, + disabled: confirm_disabled, + on_click: { + let mut value_signal = value.clone(); + let mut hover_signal = hover_date.clone(); + let draft_signal = draft_range.clone(); + let on_change = on_change_handler.clone(); + let popover_handle = popover_handle.clone(); + move |_| { + let selection = draft_signal(); + if selection.is_none() && value_signal().is_none() { + return; + } + value_signal.set(selection); + if let Some(handler) = on_change.clone() { + handler.call(selection); + } + if let Some(mut handle) = popover_handle { + handle.state.set(false); + } + hover_signal.set(None); + } + }, + "确定" + } + } } } } diff --git a/src/components/ui/popover.rs b/src/components/ui/popover.rs index a68b7fe..88e7aa0 100644 --- a/src/components/ui/popover.rs +++ b/src/components/ui/popover.rs @@ -1,5 +1,10 @@ use dioxus::prelude::*; +#[derive(Clone, Copy)] +pub struct PopoverHandle { + pub state: Signal, +} + #[component] pub fn Popover( trigger: Element, @@ -7,6 +12,7 @@ pub fn Popover( #[props(into, default = "bottom".to_string())] placement: String, ) -> Element { let mut open = use_signal(|| false); + use_context_provider(|| PopoverHandle { state: open.clone() }); rsx! { div { diff --git a/src/views/orders.rs b/src/views/orders.rs index 50e29da..d8541e4 100644 --- a/src/views/orders.rs +++ b/src/views/orders.rs @@ -862,17 +862,6 @@ pub fn Orders() -> Element { move |range: Option| setter.set(range) }, } - div { class: "orders-date-actions", - Button { - variant: ButtonVariant::Ghost, - size: ButtonSize::Sm, - on_click: { - let mut setter = date_range.clone(); - move |_| setter.set(None) - }, - "清除日期" - } - } } }, }