XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

STHApplication

【MAUI】寄当家——安卓端交易管理系统

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Text.Json;
using JiDangJia.Core;
using JiDangJia.Views;

namespace JiDangJia;

public partial class MainPage : ContentPage
{
    private readonly BusinessService service;
    private readonly HybridWebView feed;
    private readonly IDispatcherTimer timer;
    private int tab;
    private int businessTab;
    private int stockTab;
    private sealed class BrowseState
    {
        public string Query="",Kind="全部",Pawn="ALL",Status="IN_STOCK",Category="ALL";
    }
    private readonly BrowseState[] businessStates=Enumerable.Range(0,6).Select(_=>new BrowseState()).ToArray();
    private readonly BrowseState[] stockStates=Enumerable.Range(0,5).Select(_=>new BrowseState()).ToArray();
    private readonly List<Action> stockQuoteUpdates=new();
    private DateTime nextContentRefresh;
    private bool refreshingContent;
    private FinanceFilter financeFilter=new(new DateTime(DateTime.Today.Year,DateTime.Today.Month,1),DateTime.Today);
    private readonly Dictionary<string,(Label bid,Label ask,Label change)> quoteLabels=new();
    private readonly Dictionary<string,DbRow> quotes=new();
    private Label? quoteFoot;
    private string quoteState="connecting";
    private string quoteMessage="正在连接融通金行情…";
    private DateTime quoteTime;
    private CarouselView? ads;
    private int ticks;
    private int bellTicks;
    private Label? bellCount;
    private Border? bellBadge;
    private bool suspended;
    private bool checkingLicense;
    private DateTime nextLicenseCheck=DateTime.UtcNow.AddMinutes(2);
    private DateTime lastQuoteSave;
    private DateTime nextUpdateProbe=DateTime.UtcNow.AddMinutes(30);
    public MainPage(BusinessService service)
    {
        InitializeComponent(); this.service=service;
        NavigationPage.SetHasNavigationBar(this,false);
        feed=new HybridWebView { HybridRoot="quote",DefaultFile="index.html",WidthRequest=1,HeightRequest=1,Opacity=.01,InputTransparent=true,VerticalOptions=LayoutOptions.Start,HorizontalOptions=LayoutOptions.Start };
        feed.RawMessageReceived+=OnQuote;
        LoadQuoteCache();
        timer=Dispatcher.CreateTimer();timer.Interval=TimeSpan.FromSeconds(1);timer.Tick+=(_,_)=>
        {
            UpdateQuoteFoot();
            if(!suspended&&++bellTicks%5==0)UpdateBell();
            if(!suspended && !refreshingContent && DateTime.UtcNow>=nextContentRefresh)_=RefreshContent();
            if(!suspended&&!checkingLicense&&DateTime.UtcNow>=nextLicenseCheck)_=CheckLicense();
            if(!suspended&&DateTime.UtcNow>=nextUpdateProbe){nextUpdateProbe=DateTime.UtcNow.AddMinutes(30);if(Window?.Page is NavigationPage navigation)_=Services.AppUpdates.Check(navigation.CurrentPage);}
            if(!suspended && tab==0 && ads is not null && ++ticks%5==0 && ads.ItemsSource is IReadOnlyList<DbRow> items && items.Count>1) ads.Position=(ads.Position+1)%items.Count;
        };
        timer.Start();Render();
        Services.AppNotifications.Start(service.Db,Dispatcher);
        _=Task.Run(()=> {try{var day=DateTime.Today.ToString("yyyyMMdd");var path=Path.Combine(Path.GetDirectoryName(service.Db.FilePath)!,"backups",day+".db");if(!File.Exists(path))service.Db.Snapshot(path);}catch(Exception ex)when(ex is IOException or InvalidOperationException){}});
    }
    protected override void OnAppearing() {base.OnAppearing();Render();if(DateTime.UtcNow>=nextContentRefresh)_=RefreshContent();_ = Services.AppUpdates.Check(this);}
    public void Suspend() {suspended=true;timer.Stop();feed.SendRawMessage("stop");Services.AppNotifications.Pause();}
    public void Resume() {suspended=false;timer.Start();feed.SendRawMessage("start");Services.AppNotifications.Resume();nextLicenseCheck=DateTime.UtcNow;Render();}
    private async Task CheckLicense()
    {
        if(suspended||Services.AuthClient.Current.Locked||(Window?.Page as NavigationPage)?.RootPage!=this)return;
        checkingLicense=true;nextLicenseCheck=DateTime.UtcNow.AddMinutes(2);
        var owner=Services.AuthClient.Current.AccountId;
        (Navigation.NavigationStack.LastOrDefault() as FormPage)?.PreserveDraft();
        try{await Services.AuthClient.Current.RefreshLicense();if(!suspended&&!Services.AuthClient.Current.Locked&&Services.AuthClient.Current.AccountId==owner&&(Window?.Page as NavigationPage)?.RootPage==this&&!Services.AuthClient.Current.Licensed){Suspend();var account=Services.AuthClient.Current.Username;await Services.AuthClient.Current.Logout();LoginPage.ShowLogin(true,account);}}
        catch{if(!suspended&&!Services.AuthClient.Current.Locked&&(Window?.Page as NavigationPage)?.RootPage==this&&!Services.AuthClient.Current.Licensed&&(Services.AuthClient.Current.AccountId==owner||Services.AuthClient.Current.AccountId.Length==0)){Suspend();LoginPage.ShowLogin();}}
        finally{checkingLicense=false;}
    }
    private Task Switch(int index) {if(index==1&&tab!=1)businessTab=0;tab=index;Render();return Task.CompletedTask;}
    private Task Push(ContentPage page) => Navigation.PushAsync(page);
    internal Task RestoreOrderDraft()=>Services.OrderCameraRecovery.Restore(this,service);
    private Task Settings() => Push(new SettingsPage(service));
    private Task Customers() => Push(new RecordsPage(service,"Customer"));
    private Task NewCustomer() => Push(FormPage.Customer(service));
    private Task NewOrder(int index) => index==4?Switch(2):Push(FormPage.Order(service,index switch {0=>"RECYCLE",1=>"PAWN",2=>"CONSIGN",3=>"RENT",_=>"OTHER"}));
    private void Render()
    {
        foreach(var child in Root.Children.Where(child=>!ReferenceEquals(child,feed)).ToList())Root.Children.Remove(child);
        quoteLabels.Clear();stockQuoteUpdates.Clear();ads=null;quoteFoot=null;bellCount=null;bellBadge=null;
        View body=tab switch {0=>Dashboard(),1=>Business(),2=>Stock(),_=>Finance()};
        Root.Add(tab is 0 or 3?Scrollable(body):body); Root.Add(NavigationBar(),0,1); if(!Root.Children.Contains(feed))Root.Add(feed);
    }
    private View Scrollable(View body,Action? release=null)
    {
        var refresh=new PageScroller(release){Content=new ScrollView{Content=body},RefreshColor=Ui.Bronze};
        refresh.Refreshing+=async(_,_)=>{try{Render();await Task.CompletedTask;feed.SendRawMessage("refresh");}finally{refresh.IsRefreshing=false;}};return refresh;
    }
    private View Paged(View hero,LoopPager pager)
    {
        var grid=new Grid{RowDefinitions=[new(GridLength.Auto),new(GridLength.Star)]};grid.Add(hero);grid.Add(pager,0,1);return grid;
    }
    private View NavigationBar()
    {
        var grid=new Grid { ColumnDefinitions=[new(GridLength.Star),new(GridLength.Star),new(GridLength.Star),new(GridLength.Star)],Padding=new Thickness(10,9,10,7),BackgroundColor=Colors.White,HeightRequest=69 };
        string[] names=["工作台","业务","库存","财务"],icons=["home","grid","archive","finance"];
        for(int i=0;i<names.Length;i++)
        {
            int index=i; bool active=i==tab; var label=Ui.Text(names[i],10,active?Ui.Bronze:Ui.Muted,active);label.HorizontalTextAlignment=TextAlignment.Center;
            var icon=Ui.Icon(icons[i],active?Ui.Bronze:Ui.Muted,22);var s=Ui.Stack(icon,label);s.Spacing=4;s.HorizontalOptions=LayoutOptions.Fill;s.VerticalOptions=LayoutOptions.Center;
            grid.Add(Ui.Tap(s,()=>Switch(index),names[i]),i);
        }
        return new Border { Content=grid,Stroke=Ui.Line,StrokeThickness=.8,BackgroundColor=Colors.White };
    }
    private View Hero(string title,string? subtitle=null,bool dashboard=false)
    {
        var content=Ui.Stack(); content.Padding=new Thickness(22,18,22,26); content.Spacing=16;
        var brand=Ui.Row(Ui.Icon("home",Ui.Gold,20),Ui.Text("寄当家",14,Ui.Gold,true));
        View Action(string icon,Func<Task> action,string name)=>Ui.Tap(new Grid{WidthRequest=44,HeightRequest=44,Children={Ui.Icon(icon,Ui.Gold,22)}},action,name);
        var settings=Action("settings",Settings,"设置");
        var help=Action("support",()=>Push(new SupportPage()),"客服与反馈");
        var bell=new Grid{WidthRequest=44,HeightRequest=44};bell.Add(Ui.Icon("bell",Ui.Gold,22));
        bellCount=Ui.Text("",10,Colors.White,true);bellCount.AutomationId="reminder-unread-count";bellCount.HorizontalTextAlignment=TextAlignment.Center;
        bellBadge=Ui.Card(bellCount,9,new Thickness(4,0),Ui.Red);bellBadge.MinimumWidthRequest=18;bellBadge.HeightRequest=18;bellBadge.HorizontalOptions=LayoutOptions.End;bellBadge.VerticalOptions=LayoutOptions.Start;bellBadge.InputTransparent=true;bell.Add(bellBadge);UpdateBell();
        var notify=Ui.Tap(bell,()=>Push(new ReminderInboxPage(service)),"消息提醒");
        var contacts=Ui.Row(help,notify);contacts.Spacing=14;
        var h=Ui.Text(title,dashboard?24:26,Ui.Paper,true);h.LineBreakMode=LineBreakMode.TailTruncation;
        var date=Ui.Text(subtitle??(DateTime.Now.ToString("yyyy-MM-dd")+"  "+new[]{"周日","周一","周二","周三","周四","周五","周六"}[(int)DateTime.Now.DayOfWeek]),12,Color.FromArgb("#C8BBA3"));
        if(dashboard)
        {
            h.FontSize=20;h.MaxLines=2;h.HorizontalTextAlignment=TextAlignment.Center;
            content.Add(h);date.HorizontalTextAlignment=TextAlignment.Center;content.Add(date);
            content.Add(Ui.Spread(Ui.Row(settings,brand),contacts));
            var account=Ui.Text(Services.AuthClient.Current.Name,11,Ui.Gold);account.HorizontalTextAlignment=TextAlignment.Center;content.Add(account);
        }
        else{var actions=Ui.Row(settings,help,notify);actions.Spacing=14;content.Add(Ui.Spread(brand,actions));content.Add(h);content.Add(date);}
        content.Padding=new Thickness(22,12,22,16);content.Spacing=8;
        var grid=new Grid { Background=new LinearGradientBrush(new GradientStopCollection {new(Ui.Ink,0),new(Color.FromArgb("#433521"),1)},new Point(0,0),new Point(1,1)) };
        grid.Add(new GraphicsView { Drawable=new RibbonDrawable(),InputTransparent=true });grid.Add(content);return grid;
    }
    private void UpdateBell()
    {
        if(bellCount is null||bellBadge is null)return;var count=ReminderInbox.Read(service.Db,DateTime.Today).Count(n=>n.Unread);bellBadge.IsVisible=count>0;bellCount.Text=count>99?"99+":count.ToString();SemanticProperties.SetDescription(bellCount,count+" 条未读提醒");
    }
    private View Dashboard()
    {
        var month=new DateTime(DateTime.Today.Year,DateTime.Today.Month,1);
        var statistics=new (string label,string value,string icon,int target)[] {
            ("当月利润",Display.Money(service.Profit(month,month.AddMonths(1))),"finance",3),
            ("库存金额",Display.Money(service.Db.Query("SELECT COALESCE(SUM(amount),0) total FROM RecycleItem WHERE deletedAt IS NULL AND status IN ('IN_STOCK','LISTED','RENTED')").First().Money("total")),"archive",2),
            ("活当总额",Display.Money(service.Db.Query("SELECT COALESCE(SUM(principal),0) total FROM PawnTicket WHERE status='ACTIVE' AND deletedAt IS NULL").First().Money("total")),"gold",1),
            ("客户总数",service.Db.Query("SELECT COUNT(*) total FROM Customer WHERE deletedAt IS NULL").First().Text("total"),"people",1),
            ("今日新开",service.Db.Query("SELECT COUNT(*) total FROM PawnTicket WHERE pawnDate=@p0 AND deletedAt IS NULL",Display.Day(DateTime.Today)).First().Text("total"),"document",1)
        };
        var metrics=new HorizontalStackLayout {Spacing=10,Padding=new Thickness(18,0)};
        foreach(var stat in statistics)
        {
            var label=Ui.Text(stat.label,12,Ui.Muted);var amount=Ui.Text(stat.value,21,Ui.Bronze,true);amount.FontFamily="OpenSansSemibold";
            amount.FontSize=stat.value.Length>11?16:21;amount.MaxLines=1;amount.LineBreakMode=LineBreakMode.TailTruncation;
            var s=Ui.Stack(Ui.Spread(label,Ui.Icon(stat.icon,Ui.Gold,15)),amount);s.Spacing=13;
            var card=Ui.Card(s,16,new Thickness(15,15),shadow:true);card.WidthRequest=142;card.HeightRequest=97;
            int dest=stat.target;metrics.Add(Ui.Tap(card,stat.label=="客户总数"?Customers:()=>Switch(dest),stat.label));
        }
        var metricScroll=new ScrollView { Orientation=ScrollOrientation.Horizontal,HorizontalScrollBarVisibility=ScrollBarVisibility.Never,Content=metrics,Margin=new Thickness(0,-8,0,0) };
        var content=Ui.Stack();content.Padding=new Thickness(18,0,18,24);content.Spacing=16;
        content.Add(Advertisement());content.Add(Market());
        content.Add(Ui.Card(Ui.Stack(Ui.Section("客户档案","查看全部 ›",Customers),Ui.Spread(Ui.Button("客户管理",Customers,false),Ui.Button("+ 新建客户",NewCustomer))),shadow:true));
        var tools=Ui.Stack(Ui.Section("经营工具","查看全部 ›",()=>Push(new BusinessToolsPage())),Ui.Spread(Ui.Button("特惠发件",()=>Push(new BusinessToolsPage()),false),Ui.Button("货运保险",()=>Push(new BusinessToolsPage()),false)));
        content.Add(Ui.Card(tools,shadow:true));
        var incomes=new List<(string label,decimal value)>();
        for(int i=5;i>=0;i--) {var m=month.AddMonths(-i);var value=FinanceAnalytics.Read(service.Db,new(m,m.AddMonths(1).AddDays(-1))).Income; incomes.Add((m.Month+"月",value));}
        content.Add(Ui.Card(Ui.Stack(Ui.Section("近 6 个月收入","经营分析 ›",()=>Push(new ReportsPage(service))),new GraphicsView {Drawable=new IncomeDrawable(incomes),HeightRequest=168}),shadow:true));
        var distribution=DashboardAnalytics.PawnTypes(service.Db);
        var dist=Ui.Stack(Ui.Section("当物类型分布","按当单数"));
        if(distribution.Count==0) dist.Add(Ui.Empty("暂无当物数据","开当后自动生成经营分布"));
        else foreach(var row in distribution) {var p=Ui.Stack(Ui.Spread(Ui.Text(row.Name,13),Ui.Text($"{row.Count} 笔 · {Display.Money(row.Principal)}",12,Ui.Muted)),new ProgressBar { Progress=(double)row.Count/distribution.Sum(r=>r.Count),ProgressColor=Ui.Gold,BackgroundColor=Ui.Paper });dist.Add(p);}
        content.Add(Ui.Card(dist,shadow:true));
        content.Add(Ui.Card(Ui.Stack(Ui.Section("到期提醒","查看全部 ›",()=>Push(new RecordsPage(service,"Reminder"))),Ui.Text("7 天内到期 "+service.Db.Query("SELECT COUNT(*) total FROM PawnTicket WHERE status='ACTIVE' AND deletedAt IS NULL AND dueDate<=@p0",Display.Day(DateTime.Today.AddDays(7))).First().Text("total")+" 笔",14,Ui.Bronze)),shadow:true));
        var page=Ui.Stack(Hero(service.Shop.Text("name"),dashboard:true),metricScroll,content);page.Spacing=20;return page;
    }
    private View Advertisement()
    {
        var rows=AdvertisementRows();
        ads=new CarouselView { ItemsSource=rows,HeightRequest=155,Loop=true,HorizontalScrollBarVisibility=ScrollBarVisibility.Never,IsSwipeEnabled=true,PeekAreaInsets=0 };
        ads.ItemTemplate=new DataTemplate(()=>
        {
            var title=Ui.Text("",23,Ui.Ink,true);title.SetBinding(Label.TextProperty,"[title]");
            var subtitle=Ui.Text("",11,Ui.Bronze);subtitle.SetBinding(Label.TextProperty,"[subtitle]");
            var text=Ui.Stack(Ui.Spread(Ui.Pill("门店专属",Ui.Bronze,Color.FromArgb("#70FFFFFF")),Ui.Text("广告位",10,Ui.Bronze)),title,subtitle);text.Spacing=9;text.Padding=new Thickness(20,14);
            var g=new Grid { Background=new LinearGradientBrush(new GradientStopCollection {new(Color.FromArgb("#F3E0B4"),0),new(Color.FromArgb("#E5C68E"),1)},new Point(0,0),new Point(1,1)) };
            var background=new GraphicsView {Drawable=new AdDrawable(),InputTransparent=true};g.Add(background);g.Add(text);
            var border=Ui.Card(g,20,new Thickness(0));
            Image? adImage=null; border.BindingContextChanged+=(_,_)=> {if(adImage is not null){g.Children.Remove(adImage);adImage=null;} if(border.BindingContext is DbRow r && r.Text("image").Length>0) {adImage=new Image {Source=MediaFiles.Source(r.Text("image")),Aspect=Aspect.AspectFill,InputTransparent=true};g.Insert(1,adImage);} };
            Ui.Tap(border,async()=> {if(border.BindingContext is DbRow r && Uri.TryCreate(r.Text("link"),UriKind.Absolute,out var url)&&url.Scheme=="https")await Browser.OpenAsync(url,BrowserLaunchMode.SystemPreferred);});
            return border;
        });
        var dots=new IndicatorView {IndicatorColor=Color.FromArgb("#D7C5A6"),SelectedIndicatorColor=Ui.Bronze,IndicatorSize=5,HorizontalOptions=LayoutOptions.Center};ads.IndicatorView=dots;
        var s=Ui.Stack(ads,dots);s.Spacing=9;s.IsVisible=rows.Count>0;return s;
    }
    private List<DbRow> AdvertisementRows()
    {
        if(Services.ContentClient.Current is { } content)return content.Ads.Where(a=>a.Enabled).OrderBy(a=>a.SortOrder).Select(a=>new DbRow{["title"]=a.Title,["subtitle"]=a.Subtitle,["image"]=a.Image,["link"]=a.Link}).ToList();
        return [new(){["title"]="让每一笔生意\n清楚,有据",["subtitle"]="寄当家 · 您的门店经营助手"},new(){["title"]="安心管理资产\n从容经营每一天",["subtitle"]="客户 · 业务 · 库存 · 财务"}];
    }
    private async Task RefreshContent()
    {
        refreshingContent=true;nextContentRefresh=DateTime.UtcNow.AddMinutes(1);
        try{bool changed=await Services.ContentClient.Refresh();if(changed&&!suspended && tab==0)Render();}
        catch(Exception ex)when(ex is InvalidOperationException or OperationCanceledException){/* 断网时显示最近一次服务器发布的内容。 */}
        finally{refreshingContent=false;}
    }
    private View Market()
    {
        var rows=Ui.Stack(Ui.Section("贵金属实时行情","↻ 刷新",()=> {feed.SendRawMessage("refresh");return Task.CompletedTask;}));rows.Spacing=0;
        var headings=new Grid {ColumnDefinitions=[new(new GridLength(.9,GridUnitType.Star)),new(GridLength.Star),new(GridLength.Star),new(new GridLength(1.1,GridUnitType.Star))],Padding=new Thickness(0,16,0,11)};
        string[] labels=["品种 / 元·克","回收价","销售价","涨跌幅"];for(int i=0;i<4;i++){var label=Ui.Text(labels[i],10,Ui.Muted);if(i>0)label.HorizontalTextAlignment=TextAlignment.End;headings.Add(label,i);}rows.Add(headings);
        foreach(var pair in new[]{("sge_gold","黄金 SGE",2),("gold","黄金",2),("silver","白银",3),("platinum","铂金",2)})
        {
            var grid=new Grid {ColumnDefinitions=[new(new GridLength(.9,GridUnitType.Star)),new(GridLength.Star),new(GridLength.Star),new(new GridLength(1.1,GridUnitType.Star))],Padding=new Thickness(0,13),ColumnSpacing=6};
            grid.Add(Ui.Text(pair.Item2,12,bold:true));var bid=Ui.Text("—",15,Ui.Bronze,true);var ask=Ui.Text("—",15,Ui.Ink,true);var change=Ui.Text("—",11,Ui.Muted);
            foreach(var l in new[]{bid,ask,change}){l.HorizontalTextAlignment=TextAlignment.End;l.FontFamily="OpenSansSemibold";}
            grid.Add(bid,1);grid.Add(ask,2);grid.Add(change,3);quoteLabels[pair.Item1]=(bid,ask,change);rows.Add(Ui.Divider());rows.Add(grid);
        }
        quoteFoot=Ui.Text("",10,Ui.Muted);rows.Add(Ui.Divider());rows.Add(Ui.Row(new BoxView {Color=Ui.Green,WidthRequest=5,HeightRequest=5,CornerRadius=3},quoteFoot));
        UpdateQuoteLabels();UpdateQuoteFoot();return Ui.Card(rows,20,shadow:true);
    }
    private View Search(BrowseState browse,Func<Task> add,string addText="新建客户")
    {
        var entry=new Entry {Placeholder="姓名 / 电话 / 单号 / 物品",Text=browse.Query,ReturnType=ReturnType.Search,ClearButtonVisibility=ClearButtonVisibility.WhileEditing};
        var g=new Grid {ColumnDefinitions=[new(GridLength.Auto),new(GridLength.Star)],ColumnSpacing=9};g.Add(Ui.Icon("search",Ui.Muted,17));g.Add(entry,1);
        var b=Ui.Card(g,14,new Thickness(13,0),Ui.Paper);entry.Completed+=(_,_)=> {browse.Query=entry.Text??"";Render();};
        var grid=new Grid {ColumnDefinitions=[new(GridLength.Star),new(GridLength.Auto)],ColumnSpacing=10};grid.Add(b);grid.Add(Ui.Button("+ "+addText,add),1);
        return grid;
    }
    private View Business()=>Paged(Hero("业务","每一笔往来,都有据可查"),new LoopPager(["回收","典当","寄卖","租赁","销售","其他"],businessTab,i=>Scrollable(BusinessContent(i)),i=>businessTab=i));
    private View BusinessContent(int index)
    {
        var browse=businessStates[index];Task Add()=>NewOrder(index);
        var content=Ui.Stack();content.Padding=new Thickness(18,4,18,25);content.Spacing=15;
        content.Add(QuickActions(Add));content.Add(Search(browse,Add,"新建业务"));
        var filter=Ui.Tap(Ui.Pill("类型  "+browse.Kind+"  ﹀"),async()=> {var picked=await DisplayActionSheetAsync("物品类型","取消",null,new[]{"全部"}.Concat(ItemCatalog.Categories.Values).ToArray());if(picked is null||picked=="取消")return;browse.Kind=picked;Render();});
        content.Add(filter);
        if(index==1)
        {
            var states=new Dictionary<string,string>{["ALL"]="全部当单",["ACTIVE"]="活当",["DUE_SOON"]="即将到期",["OVERDUE"]="逾期",["DEAD"]="绝当",["REDEEMED"]="已赎当"};
            var picker=new Picker{Title="当单状态筛选",ItemsSource=states.Values.ToList(),SelectedIndex=states.Keys.ToList().IndexOf(browse.Pawn)};
            picker.SelectedIndexChanged+=(_,_)=>{if(picker.SelectedIndex>=0){browse.Pawn=states.Keys.ElementAt(picker.SelectedIndex);Render();}};
            content.Add(Ui.Card(Ui.Stack(Ui.Text("到期与状态筛选",11,Ui.Muted),picker),13,new Thickness(14,4),Ui.Cream));
        }
        if(index==4) {content.Add(Ui.Button("前往库存选择物品销售",()=>Switch(2)));foreach(var sale in service.Db.Query("SELECT * FROM SaleRecord WHERE deletedAt IS NULL ORDER BY createdAt DESC").Where(r=>Matches(r,browse.Query)))content.Add(RecordCard("SaleRecord",sale));return content;}
        string table=index switch{0=>"RecycleOrder",1=>"PawnTicket",_=>"BusinessOrder"};
        string where=table=="BusinessOrder"?" AND kind=@p0":table=="RecycleOrder"?" AND source='RECYCLE'":"";object?[] args=table=="BusinessOrder"?[index==2?"CONSIGN":index==3?"RENT":"OTHER"]:[];
        var records=service.Db.Query($"SELECT * FROM {table} WHERE deletedAt IS NULL{where} ORDER BY createdAt DESC",args);
        records=records.Where(r=>Matches(r,browse.Query)&& (browse.Kind=="全部" || ItemCatalog.Categories.GetValueOrDefault(ItemCatalog.Category(r.Text("itemType")),service.ItemType(r.Text("itemType")))==browse.Kind) && (table!="PawnTicket"||PawnState.Matches(r,browse.Pawn,DateTime.Today,service.Shop.Int("nearExpireDays")))).ToList();
        content.Add(Ui.Spread(Ui.Text(new[]{"回收单","当单","寄卖单","租赁单","销售单","其他业务"}[index],16,bold:true),Ui.Text(records.Count+" 笔",12,Ui.Muted)));
        if(records.Count==0) content.Add(Ui.Card(Ui.Empty(browse.Query.Length>0?"没有匹配的记录":"尚无业务记录",index==0?"直接填写物品信息即可回收入库":"开始记录第一笔业务",Add,"+ 新建业务")));
        foreach(var r in records) content.Add(RecordCard(table,r));
        if(records.Count>0)content.Add(Ui.Button("+ 新建"+new[]{"回收单","当单","寄卖单","租赁单","销售单","其他业务"}[index],Add));return content;
    }
    private static bool Matches(DbRow r,string query) => query.Length==0 || new[]{"name","itemName","customerName","phone","customerPhone","ticketNo","orderNo","saleNo","code","itemCode","serialNo","extraAttributes"}.Any(k=>r.Text(k).Contains(query,StringComparison.OrdinalIgnoreCase));
    private View QuickActions(Func<Task> add)
    {
        var grid=new Grid {ColumnDefinitions=[new(GridLength.Star),new(GridLength.Star),new(GridLength.Star),new(GridLength.Star)],Padding=new Thickness(0,8,0,12)};
        var actions=new(string text,string icon,Func<Task> action)[]{("新建业务","plus",add),("销售记录","archive",()=>Push(new RecordsPage(service,"SaleRecord"))),("到期提醒","clock",()=>Push(new RecordsPage(service,"Reminder"))),("经营报表","document",()=>Push(new ReportsPage(service)))};
        for(int i=0;i<4;i++) {var a=actions[i];var circle=Ui.Card(Ui.Icon(a.icon,Ui.Bronze,22),15,new Thickness(12),Ui.Cream);circle.HorizontalOptions=LayoutOptions.Center;var l=Ui.Text(a.text,11,Ui.Muted);l.HorizontalTextAlignment=TextAlignment.Center;grid.Add(Ui.Tap(Ui.Stack(circle,l),a.action),i);}return grid;
    }
    private View RecordCard(string table,DbRow row)
    {
        var no=row.Text("ticketNo",row.Text("orderNo",row.Text("saleNo",row.Text("id"))));
        var state=table=="PawnTicket"?PawnState.State(row,DateTime.Today,service.Shop.Int("nearExpireDays")):row.Text("status");
        var color=state=="OVERDUE"?Ui.Red:state=="DEAD"?Ui.Ink:Ui.Bronze;
        var head=Ui.Spread(Ui.Text(no,11,Ui.Muted),Ui.Pill(Display.Status(state),color));
        var title=Ui.Text(row.Text("itemName",row.Text("name")),18,bold:true);
        var subtitle=Ui.Text(row.Text("customerName")+"  "+row.Text("customerPhone"),12,Ui.Muted);
        var amount=Ui.Text(Display.Money(row.Money(table=="PawnTicket"?"principal":"amount")),21,Ui.Bronze,true);
        var foot=Ui.Spread(amount,Ui.Text(row.Text("dueDate",row.Text("dealDate",row.Text("startDate"))),11,Ui.Muted));
        var card=Ui.Card(Ui.Stack(head,title,subtitle,Ui.Divider(),foot));Ui.PawnOutline(card,state);
        return Ui.Tap(card,()=>Push(new DetailPage(service,table,row.Id)),"查看"+no);
    }
    private View Stock()
    {
        View Pane(int index)
        {
            var updates=new List<Action>();void Register(Action update){updates.Add(update);stockQuoteUpdates.Add(update);}
            return Scrollable(StockContent(index,Register),release:()=>{foreach(var update in updates)stockQuoteUpdates.Remove(update);});
        }
        return Paged(Hero("库存","资产流转,一目了然"),new LoopPager(["回收库存","铺货库存","租赁库存","典当在库","其他"],stockTab,Pane,i=>stockTab=i));
    }
    private View StockContent(int index,Action<Action> registerQuote)
    {
        if(index==3)return PawnStockContent(registerQuote);
        if(index==4)index=3;
        var browse=stockStates[index];var content=Ui.Stack();content.Padding=new Thickness(18,4,18,25);
        var categories=new Dictionary<string,string>{["ALL"]="全部物品",["GOLD"]="黄金",["SILVER"]="白银",["PLATINUM"]="铂金",["PALLADIUM"]="钯金(历史库存)",["METAL_OTHER"]="其他贵金属"};
        foreach(var category in ItemCatalog.Categories.Where(p=>p.Key!="METAL"))categories[category.Key]=category.Value;
        var categoryPicker=new Picker{Title="切换库存物品大类",ItemsSource=categories.Values.ToList(),SelectedIndex=categories.Keys.ToList().IndexOf(browse.Category)};
        categoryPicker.SelectedIndexChanged+=(_,_)=>{if(categoryPicker.SelectedIndex<0)return;browse.Category=categories.Keys.ElementAt(categoryPicker.SelectedIndex);Render();};
        content.Add(Ui.Card(Ui.Stack(Ui.Text("库存物品大类",11,Ui.Muted),categoryPicker),13,new Thickness(14,4),Ui.Cream));
        var row=new HorizontalStackLayout {Spacing=9};
        var statuses=index==2?new[]{("IN_STOCK","在库"),("RENTED","租赁中"),("OVERDUE_RENT","逾期滞还"),("SOLD","已售")}:new[]{("IN_STOCK","在库"),("LISTED","在售"),("SOLD","已售")};
        foreach(var p in statuses){string status=p.Item1;row.Add(Ui.Tap(Ui.Pill(p.Item2,browse.Status==status?Ui.Ink:Ui.Muted,browse.Status==status?Ui.Cream:Colors.White),()=> {browse.Status=status;Render();return Task.CompletedTask;}));}
        content.Add(row);content.Add(Search(browse,()=>Push(FormPage.Order(service,index switch{1=>"PURCHASE",2=>"RENTAL",3=>"STOCK_OTHER",_=>"RECYCLE"})),index switch{1=>"铺货入库",2=>"租赁入库",3=>"入库",_=>"回收入库"}));
        if(index==1)content.Add(Ui.Button("购货入库单记录",()=>Push(new RecordsPage(service,"RecycleOrder")),false));
        var all=service.Db.Query("SELECT i.*,COALESCE(r.customerName,o.customerName,p.customerName,'') customerName,COALESCE(r.customerPhone,o.customerPhone,p.customerPhone,'') customerPhone,COALESCE(r.orderNo,o.orderNo,p.ticketNo,'') orderNo,r.dueDate rentalDueDate,r.status rentalStatus FROM RecycleItem i LEFT JOIN RecycleOrder o ON o.id=i.orderId LEFT JOIN PawnTicket p ON p.id=i.ticketId LEFT JOIN BusinessOrder r ON r.id=i.rentalOrderId WHERE i.deletedAt IS NULL AND ((i.source='DEAD' AND p.id IS NOT NULL AND p.deletedAt IS NULL) OR (i.source IN ('RECYCLE','PURCHASE','OTHER','RENTAL') AND o.id IS NOT NULL AND o.deletedAt IS NULL AND o.status='DONE') OR (i.source='RENTAL' AND i.orderId='')) ORDER BY i.createdAt DESC");
        foreach(var item in all)if(item.Text("source")=="RENTAL")item["status"]=RentalInventory.Status(item,new DbRow{["dueDate"]=item.Text("rentalDueDate")},DateTime.Today);
        var items=all.Where(r=>(r.Text("status")==browse.Status || index==2&&browse.Status=="IN_STOCK"&&r.Text("status")=="LISTED") && Matches(r,browse.Query) && ItemCatalog.StockMatches(r,browse.Category) && (index switch{0=>r.Text("source") is "RECYCLE" or "DEAD",1=>r.Text("source")=="PURCHASE",2=>r.Text("source")=="RENTAL",_=>r.Text("source")=="OTHER" || r.Text("type")=="OTHER"&&r.Text("source")!="RENTAL"})).ToList();
        IReadOnlyDictionary<string,decimal> Bids()=>quotes.ToDictionary(p=>p.Key,p=>p.Value.Money("bid"));
        string QuoteStatus()=>quoteTime==default?"等待行情报价":(quoteState=="live"&&DateTime.Now-quoteTime<TimeSpan.FromSeconds(30)?"实时行情估值 · ":"最近行情估值 · ")+quoteTime.ToString("MM-dd HH:mm:ss");
        if(browse.Category is "GOLD" or "SILVER" or "PLATINUM" or "PALLADIUM" or "METAL_OTHER")content.Add(InventoryViews.MetalSummary(categories[browse.Category],items,Bids,QuoteStatus,registerQuote,browse.Status!="SOLD"));
        content.Add(Ui.Spread(Ui.Text(items.Count+" 件物品",15,bold:true),Ui.Text("成本 "+Display.Money(items.Sum(r=>r.Money("amount"))),12,Ui.Bronze)));
        if(items.Count==0)content.Add(Ui.Card(Ui.Empty("暂无"+Display.Status(browse.Status)+"物品",index==2?"租赁入库后可出租或销售,出租后按到期日显示状态":index==3?"从入库按钮添加其他库存":index==1?"新货从铺货入库录入,与二手回收库存分开管理":"回收与绝当物品会自动进入二手库存")));
        foreach(var item in items)
        {
            if(ItemCatalog.Category(item.Text("type"))=="METAL")
            {
                content.Add(Ui.Tap(InventoryViews.MetalItem(item,Bids,registerQuote,browse.Status!="SOLD"),()=>Push(new DetailPage(service,"RecycleItem",item.Id))));continue;
            }
            var head=Ui.Spread(Ui.Row(Ui.Icon("archive",Ui.Gold,28),Ui.Stack(Ui.Text(item.Text("name"),16,bold:true),Ui.Text(ItemCatalog.ItemName(item)+" · "+InventoryViews.Source(item),11,Ui.Muted))),Ui.Pill(Display.Status(item.Text("status"))));
            content.Add(Ui.Tap(Ui.Card(Ui.Stack(head,Ui.Divider(),Ui.Spread(Ui.Text(Display.Money(item.Money(browse.Status=="LISTED"?"listedPrice":"amount")),20,Ui.Bronze,true),Ui.Text("详情 ›",12,Ui.Muted)))),()=>Push(new DetailPage(service,"RecycleItem",item.Id))));
        }
        return content;
    }
    private View PawnStockContent(Action<Action> registerQuote)
    {
        var browse=stockStates[4];var content=Ui.Stack();content.Padding=new Thickness(18,4,18,25);
        var categories=new Dictionary<string,string>{{"ALL","全部物品"}}.Concat(FinanceAnalytics.Details("PAWN")).ToDictionary();
        var picker=new Picker{Title="典当物品大类",ItemsSource=categories.Values.ToList(),SelectedIndex=categories.Keys.ToList().IndexOf(browse.Category)};
        picker.SelectedIndexChanged+=(_,_)=>{if(picker.SelectedIndex>=0){browse.Category=categories.Keys.ElementAt(picker.SelectedIndex);Render();}};
        content.Add(Ui.Card(picker,12,new Thickness(12,0),Ui.Cream));
        var filters=new HorizontalStackLayout{Spacing=8};foreach(var p in new[]{("IN_STOCK","在库"),("DUE_SOON","即将到期"),("OVERDUE","逾期在库")}){var key=p.Item1;filters.Add(Ui.Tap(Ui.Pill(p.Item2,browse.Status==key?Ui.Ink:Ui.Muted),()=>{browse.Status=key;Render();return Task.CompletedTask;}));}content.Add(filters);
        var search=new Entry{Placeholder="姓名 / 电话 / 单号 / 物品",Text=browse.Query};search.Completed+=(_,_)=>{browse.Query=search.Text?.Trim()??"";Render();};content.Add(search);
        var items=PawnInventory.Read(service.Db).Where(i=>Matches(i,browse.Query)&&ItemCatalog.StockMatches(i,browse.Category)&&(browse.Status=="IN_STOCK"||i.Text("pawnState")==browse.Status)).ToList();
        content.Add(Ui.Spread(Ui.Text(items.Count+" 件典当物品",15,bold:true),Ui.Text("对应当金 "+Display.Money(items.Sum(i=>i.Money("amount"))),12,Ui.Bronze)));
        content.Add(Ui.Text("保管中的活当物品;赎当后退出,绝当后转入回收库存。",11,Ui.Muted));
        if(items.Count==0)content.Add(Ui.Card(Ui.Empty("暂无典当在库物品","开当后自动显示保管中的物品")));
        foreach(var item in items)
        {
            var card=Ui.Card(Ui.Stack(Ui.Spread(Ui.Text(item.Text("name"),16,bold:true),Ui.Pill(Display.Status(item.Text("pawnState")))),Ui.Text(item.Text("orderNo")+" · "+item.Text("customerName"),12,Ui.Muted),Ui.Spread(Ui.Text("当金 "+Display.Money(item.Money("amount")),18,Ui.Bronze,true),Ui.Text("到期 "+item.Text("dueDate"),11,Ui.Muted))));
            Ui.PawnOutline(card,item.Text("pawnState"));content.Add(Ui.Tap(card,()=>Push(new DetailPage(service,"PawnTicket",item.Text("ticketId"))),"查看典当物品 "+item.Text("name")));
        }
        return content;
    }
    private View Finance()
    {
        var content=new FinancePanel(service,financeFilter,f=>financeFilter=f).View;content.Padding=new Thickness(18,18,18,25);
        var root=Ui.Stack(Hero("财务","收支有序,经营有底"),content);root.Spacing=0;return root;
    }

    private void OnQuote(object? sender,HybridWebViewRawMessageReceivedEventArgs e)
    {
        try
        {
            if(e.Message is null)return;using var doc=JsonDocument.Parse(e.Message);var kind=doc.RootElement.GetProperty("kind").GetString();var value=doc.RootElement.GetProperty("value");
            if(kind=="status") {quoteState=value.GetProperty("state").GetString()??"";quoteMessage=value.GetProperty("message").GetString()??"";}
            else if(kind=="quotes") {foreach(var q in value.GetProperty("rows").EnumerateArray()) {var r=DbRow.FromJson(q);quotes[r.Text("key")]=r;}quoteTime=DateTimeOffset.FromUnixTimeMilliseconds(value.GetProperty("fetchedAt").GetInt64()).LocalDateTime;if(DateTime.Now-lastQuoteSave>TimeSpan.FromSeconds(15)){lastQuoteSave=DateTime.Now;var cache=JsonSerializer.Serialize(new {time=quoteTime,rows=quotes.Values});File.WriteAllText(Path.Combine(FileSystem.AppDataDirectory,"quote-cache.json"),cache);}}
            MainThread.BeginInvokeOnMainThread(()=>{UpdateQuoteLabels();UpdateQuoteFoot();});
        }
        catch(JsonException) {quoteMessage="行情消息格式异常";}
        catch(IOException) {quoteMessage="行情缓存未保存";}
    }
    private void LoadQuoteCache()
    {
        try {var path=Path.Combine(FileSystem.AppDataDirectory,"quote-cache.json");if(!File.Exists(path))return;using var d=JsonDocument.Parse(File.ReadAllText(path));quoteTime=d.RootElement.GetProperty("time").GetDateTime();foreach(var q in d.RootElement.GetProperty("rows").EnumerateArray()){var r=DbRow.FromJson(q);quotes[r.Text("key")]=r;}}catch { /* 缓存损坏时重新获取行情。 */ }
    }
    private void UpdateQuoteLabels()
    {
        foreach(var p in quoteLabels) {if(!quotes.TryGetValue(p.Key,out var r))continue;int digits=r.Int("digits");p.Value.bid.Text=r.Money("bid")>0?r.Money("bid").ToString("F"+digits):"—";p.Value.ask.Text=r.Money("ask")>0?r.Money("ask").ToString("F"+digits):"—";p.Value.change.Text=r.Money("rate").ToString("+0.00;-0.00;0.00")+"%";p.Value.change.TextColor=r.Money("rate")>=0?Ui.Red:Ui.Green;}
        foreach(var update in stockQuoteUpdates)update();
    }
    private void UpdateQuoteFoot()
    {
        if(quoteFoot is null)return;
        bool fresh=quoteState=="live" && quoteTime!=default && DateTime.Now-quoteTime<TimeSpan.FromSeconds(30);
        quoteFoot.Text=fresh?"实时推送 · "+quoteTime.ToString("HH:mm:ss"):quoteTime!=default?"最近报价 "+quoteTime.ToString("MM-dd HH:mm:ss")+" · "+(quoteState=="live"?"等待更新":quoteMessage):quoteMessage;
    }
}