ui: Web3 shop rename, merchant info header, shopping cart with floating button and two-column checkout

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ZHU MINCHAOandClaude Sonnet 5.5 committed 2026-11-04 11:18:45 +08:00
1 parent 9d24840561
commit 2d40df81bd
5 files changed
+346 -38

No files matched your search

+287 -22
View File
@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Decentralized.Domain;
namespace Decentralized
@@ -69,6 +70,7 @@ namespace Decentralized
b.End();
var win = b.Show();
Log.Guard("交易终端外观", () => PledgerSkin.Apply(win, screen, bezel, paper, _picked, _clear, _rows));
Log.Guard("购物车按钮", () => CartFab(win));
}
private static void Go(Page p) { _page = p; _message = null; _edit = null; Open(); }
@@ -1179,10 +1181,11 @@ namespace Decentralized
{
var tab = t;
string name = L.T("black.sub." + t.ToString().ToLowerInvariant());
PledgerSkin.Pick(b, name, tab == _blackTab, () => { _blackTab = tab; _message = null; _edit = null; _pay = null; Open(); }, _picked);
PledgerSkin.Pick(b, name, tab == _blackTab, () => { _blackTab = tab; _message = null; _edit = null; _pay = null; _checkout = false; Open(); }, _picked);
}
b.End();
if (d.Black.Closed(day)) { b.AddLabel(L.T("black.rugged", d.Black.ClosedUntil - day + 1), "black_rugged_note"); return; }
if (_checkout) { CheckoutPage(b, d); return; }
if (_pay != null && _blackTab != BlackTab.Buy) { PayPanel(b); return; }
if (_edit != null) { EditPage(b); return; }
if (_blackTab == BlackTab.Orders) { OrdersPage(b, day); return; }
@@ -1209,13 +1212,13 @@ namespace Decentralized
for (int i = 0; i < names.Count; i++)
{
int idx = i - 1;
TableRow(b, SellerW, new[] { SellerW - 8f }, new[] { TextAnchor.MiddleLeft }, new[] { names[i] }, idx == _merchant, () => { _merchant = idx; _pay = null; _message = null; Open(); });
TableRow(b, SellerW, new[] { SellerW - 8f }, new[] { TextAnchor.MiddleLeft }, new[] { names[i] }, idx == _merchant, () => { _merchant = idx; _message = null; Open(); });
}
b.End();
b.BeginPanel(ShelfW, -1f, TextAnchor.UpperLeft, 0f, 4f);
_clear.Add(b.Last.image);
if (_pay != null) PayPanel(b);
else if (platform) PlatformShelf(b, d);
ShopInfo(b, d, platform ? null : sellers[_merchant]);
if (platform) PlatformShelf(b, d);
else MerchantShelf(b, d);
b.End();
b.End();
@@ -1230,7 +1233,6 @@ namespace Decentralized
private static void PlatformShelf(CustomUIBuilder b, SaveData d)
{
b.AddLabel(L.T("black.platform.line"), "platform_line_dim");
if (d.Black.Offers.Count == 0) { b.AddLabel(L.T("black.empty"), "platform_empty_dim"); return; }
Shelf(b, d.Black.Offers.Cast<BmGood>().ToList(), (c, g) =>
{
@@ -1238,12 +1240,7 @@ namespace Decentralized
GoodHead(c, g, L.T("black.stock", offer.Qty));
c.AddLabel(L.T("black.price", offer.Price));
c.AddFlexibleSpace();
c.AddButton(L.T("black.buy"), () => OpenPay(new PayDraft
{
Title = ItemName(offer.Item), Address = d.Address("platform"), Accepted = Merchants.PlatformAccepts, MaxQty = offer.Qty,
Credits = (q, m) => offer.Price * q,
Execute = (asset, q, m, pw) => BuyOffer(offer, q, asset, pw),
}));
c.AddButton(L.T("black.buy"), () => AddToCart(-1, offer));
}, 3);
}
@@ -1458,6 +1455,273 @@ namespace Decentralized
Open();
}
// ——— 购物车 ———
private sealed class CartLine { public int Seller; public BmGood Good; public int Qty; }
private static readonly List<CartLine> _cart = new List<CartLine>();
private static bool _checkout, _ckEach;
private static string _ckCoin;
private static readonly Dictionary<int, PayMode> _ckMode = new Dictionary<int, PayMode>();
private static readonly Dictionary<int, string> _ckGroupCoin = new Dictionary<int, string>();
/// <summary>读档或换局时清空购物车(货架对象已经不是同一批了)。</summary>
public static void ClearCart() { _cart.Clear(); _checkout = false; _ckMode.Clear(); _ckGroupCoin.Clear(); }
private static int StockOf(BmGood g) => g is BmOffer o ? o.Qty : ((BmListing)g).Qty;
/// <summary>丢掉已经下架的货,数量压到库存以内。</summary>
private static void CartValidate(SaveData d)
{
_cart.RemoveAll(l => l.Good is BmOffer o ? !d.Black.Offers.Contains(o) : !d.Black.Catalog.Contains((BmListing)l.Good) || l.Seller >= d.Black.Sellers.Count);
foreach (var l in _cart) l.Qty = Math.Max(1, Math.Min(l.Qty, StockOf(l.Good)));
}
private static int CartCount() => _cart.Sum(l => l.Qty);
private static void AddToCart(int seller, BmGood g)
{
var line = _cart.Find(l => l.Good == g);
if (line == null) _cart.Add(new CartLine { Seller = seller, Good = g, Qty = 1 });
else line.Qty = Math.Min(StockOf(g), line.Qty + 1);
_message = L.T("cart.added", ItemName(g.Item));
Open();
}
private static string[] AcceptsOf(SaveData d, int seller) => seller < 0 ? Merchants.PlatformAccepts : Merchants.Accepts(d.Black.Sellers[seller].Type);
private static string AddressOf(SaveData d, int seller) => seller < 0 ? d.Address("platform") : d.Black.Sellers[seller].Name;
private static string ShopName(SaveData d, int seller) => seller < 0 ? L.T("merchant.Platform") : MerchantLabel(d.Black.Sellers[seller], true);
private static PayMode ModeOf(SaveData d, int seller) => seller >= 0 && d.Black.Sellers[seller].Advance && _ckMode.TryGetValue(seller, out var m) ? m : PayMode.Escrow;
private static double LineCredits(SaveData d, CartLine l) =>
l.Good is BmOffer o ? o.Price * l.Qty : BlackMarket.Pay(BlackMarket.OrderTotal(((BmListing)l.Good).Price, l.Qty), ModeOf(d, l.Seller));
private static double GroupCredits(SaveData d, int seller) => _cart.Where(l => l.Seller == seller).Sum(l => LineCredits(d, l));
/// <summary>悬浮购物车:商店页右下角一个常驻的小按钮,显示件数,点开进结算页。</summary>
private static void CartFab(CustomUIWindow win)
{
if (win?.root == null) return;
var old = win.root.transform.Find("pd_cart_fab");
bool show = _page == Page.Web3 && _web3Tab == Web3Tab.Black && !_checkout && WalletWindow.Pending == null && Unlock.BlackUnlocked;
if (!show) { if (old != null) UnityEngine.Object.Destroy(old.gameObject); return; }
if (old != null) UnityEngine.Object.DestroyImmediate(old.gameObject);
var go = new GameObject("pd_cart_fab", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button), typeof(LayoutElement));
go.transform.SetParent(win.root.transform, false);
go.GetComponent<LayoutElement>().ignoreLayout = true;
var rt = (RectTransform)go.transform;
rt.anchorMin = rt.anchorMax = new Vector2(1f, 0f);
rt.pivot = new Vector2(1f, 0f);
rt.sizeDelta = new Vector2(132f, 36f);
rt.anchoredPosition = new Vector2(-34f, 34f);
var img = go.GetComponent<Image>();
PledgerSkin.KeyShape(img, CartCount() > 0 ? PledgerSkin.Ink : PledgerSkin.Key);
var btn = go.GetComponent<Button>();
btn.transition = Selectable.Transition.None;
btn.onClick.AddListener(() => { _checkout = true; _message = null; Open(); });
var tgo = new GameObject("label", typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
tgo.transform.SetParent(go.transform, false);
var trt = (RectTransform)tgo.transform;
trt.anchorMin = Vector2.zero; trt.anchorMax = Vector2.one; trt.offsetMin = trt.offsetMax = Vector2.zero;
var tmp = tgo.GetComponent<TextMeshProUGUI>();
if (win.frame?.titleText != null) { tmp.font = win.frame.titleText.font; tmp.fontSharedMaterial = win.frame.titleText.fontSharedMaterial; }
tmp.text = L.T("cart.fab", CartCount());
tmp.fontSize = 15f;
tmp.fontStyle = FontStyles.Bold;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = PledgerSkin.KeyText;
tmp.raycastTarget = false;
go.transform.SetAsLastSibling();
}
/// <summary>右栏最前面:商家信息(成交、好评、到货天数、收的币、是否接受提前放款)。</summary>
private static void ShopInfo(CustomUIBuilder b, SaveData d, BmSeller seller)
{
if (seller == null)
{
b.AddHeader(L.T("merchant.Platform"));
b.AddLabel(L.T("black.platform.line"), "shop_info_dim");
b.AddLabel(L.T("black.info.accepts", string.Join(" / ", Merchants.PlatformAccepts)), "shop_accepts_dim");
}
else
{
b.AddHeader(MerchantLabel(seller));
b.AddLabel(L.T("black.seller", "", seller.Trades, seller.Rating) + " " + L.T("black.days", seller.Days), "shop_info_dim");
b.AddLabel(L.T("black.info.accepts", string.Join(" / ", Merchants.Accepts(seller.Type))) + " " + L.T(seller.Advance ? "black.info.advance.yes" : "black.info.advance.no"), "shop_accepts_dim");
}
b.AddSeparator();
}
/// <summary>结算页:左栏是购物车详情(按商家分组,可增减数量、删除),右栏是付款选项(统一结算,或逐家生成账单)。</summary>
private static void CheckoutPage(CustomUIBuilder b, SaveData d)
{
CartValidate(d);
var w = d.Hot;
var groups = _cart.GroupBy(l => l.Seller).OrderBy(g => g.Key).ToList();
Columns(b, l =>
{
l.AddHeader(L.T("cart.h"));
if (_cart.Count == 0) { l.AddLabel(L.T("cart.empty"), "cart_empty_dim"); return; }
foreach (var g in groups)
{
int seller = g.Key;
l.AddLabel("<b>" + ShopName(d, seller) + "</b>", "cart_shop");
if (seller >= 0 && d.Black.Sellers[seller].Advance)
{
l.BeginRow(4f);
foreach (PayMode m in Enum.GetValues(typeof(PayMode)))
{
var mode = m;
PledgerSkin.Pick(l, L.T("black.mode." + m), ModeOf(d, seller) == mode, () => { _ckMode[seller] = mode; Open(); }, _picked);
}
l.End();
}
foreach (var line in g.ToList())
{
var ln = line;
l.BeginRow(4f, TextAnchor.MiddleLeft);
Cell(l, 150f, ItemName(ln.Good.Item), false, TextAnchor.MiddleLeft);
RowKey(l, "-", () => { ln.Qty--; if (ln.Qty < 1) _cart.Remove(ln); Open(); });
Cell(l, 26f, ln.Qty.ToString(), false, TextAnchor.MiddleCenter);
RowKey(l, "+", () => { ln.Qty = Math.Min(StockOf(ln.Good), ln.Qty + 1); Open(); });
Cell(l, 58f, Fmt.Credits(LineCredits(d, ln)), false, TextAnchor.MiddleRight);
RowKey(l, "×", () => { _cart.Remove(ln); Open(); });
l.End();
}
}
l.AddSeparator();
l.AddLabel(L.T("bpay.total", Fmt.Credits(_cart.Sum(x => LineCredits(d, x)))), "cart_total");
}, r =>
{
r.AddHeader(L.T("bpay.h"));
if (_cart.Count == 0) { r.AddButton(L.T("cart.back"), () => { _checkout = false; Open(); }); return; }
r.BeginRow(4f);
PledgerSkin.Pick(r, L.T("cart.all"), !_ckEach, () => { _ckEach = false; Open(); }, _picked);
PledgerSkin.Pick(r, L.T("cart.each"), _ckEach, () => { _ckEach = true; Open(); }, _picked);
r.End();
if (!_ckEach) UnifiedPay(r, d, w, groups.Select(g => g.Key).ToList());
else foreach (var g in groups) GroupPay(r, d, w, g.Key);
r.AddSpace(4f);
r.AddButton(L.T("cart.back"), () => { _checkout = false; _message = null; Open(); });
});
}
/// <summary>统一结算:选一种币,一次合约签名付清全部商家;商家不收这种币的部分按 8% 滑点自动换。</summary>
private static void UnifiedPay(CustomUIBuilder r, SaveData d, Wallet w, List<int> sellers)
{
var coins = new[] { Assets.PSC, Assets.CRT, Assets.CRA, Assets.CRC, Assets.SPARK, Assets.Ponero }.Where(a => w.Avail(a) > 1e-9 && Session.Price(a) > 0).ToList();
if (coins.Count == 0) { r.AddLabel(L.T("cart.nocoin"), "cart_nocoin_dim"); return; }
if (_ckCoin == null || !coins.Contains(_ckCoin)) _ckCoin = coins[0];
r.AddLabel(L.T("bpay.coin2"), "bpay_coin_dim");
r.BeginRow(4f);
foreach (var a in coins) { string asset = a; PledgerSkin.Pick(r, asset, asset == _ckCoin, () => { _ckCoin = asset; Open(); }, _picked); }
r.End();
double credits = 0, swapped = 0;
foreach (int s in sellers)
{
double c = GroupCredits(d, s);
bool direct = AcceptsOf(d, s).Contains(_ckCoin);
credits += direct ? c : c * (1 + Tuning.BmAutoSwapSlip);
if (!direct) swapped += c;
}
double need = WalletWindow.RoundUp(credits / Session.Price(_ckCoin));
r.AddLabel(L.T("bpay.need", WalletWindow.Amt(need), _ckCoin, Fmt.Num(w.Avail(_ckCoin))), "bpay_need_dim");
if (swapped > 0) r.AddLabel(L.T("cart.swapped", Fmt.Credits(swapped), (Tuning.BmAutoSwapSlip * 100).ToString("0", CultureInfo.InvariantCulture)), "cart_swapped_dim");
r.AddButton(L.T("cart.pay"), () => CheckoutAll(_ckCoin, need));
}
/// <summary>逐家付款:每家商家单独选它收的币,生成账单,在钱包里转账。</summary>
private static void GroupPay(CustomUIBuilder r, SaveData d, Wallet w, int seller)
{
var acc = AcceptsOf(d, seller);
if (!_ckGroupCoin.TryGetValue(seller, out var coin) || !acc.Contains(coin)) coin = _ckGroupCoin[seller] = acc[0];
double credits = GroupCredits(d, seller);
r.AddSeparator();
r.AddLabel(ShopName(d, seller) + " " + Fmt.Credits(credits), "cart_shop");
r.BeginRow(4f);
foreach (var a in acc) { string asset = a; PledgerSkin.Pick(r, asset, asset == coin, () => { _ckGroupCoin[seller] = asset; Open(); }, _picked); }
r.End();
r.AddLabel(L.T("bpay.need", WalletWindow.Amt(WalletWindow.RoundUp(credits / Session.Price(coin))), coin, Fmt.Num(w.Avail(coin))), "bpay_need_dim");
r.AddButton(L.T("bpay.invoice"), () =>
{
var lines = _cart.Where(l => l.Seller == seller).ToList();
var mode = ModeOf(d, seller);
string summary = ShopName(d, seller) + " ×" + lines.Sum(l => l.Qty);
_checkout = false;
BeginPay(AddressOf(d, seller), summary, coin, credits, pw => { var r2 = ExecGroup(seller, lines, mode, coin, pw); Finish(CartMessage(r2.items, r2.failed)); });
});
}
private static void Finish(string message)
{
_message = message;
Session.Save();
Open();
}
/// <summary>结算一家商家的购物车行:平台现货直接买、商家下订单,成功的行从购物车里拿掉。返回结果消息。</summary>
private static (int items, int failed) ExecGroup(int seller, List<CartLine> lines, PayMode mode, string asset, Wallet pw)
{
var d = Session.Data;
int items = 0, failed = 0;
foreach (var line in lines)
{
if (line.Good is BmOffer o)
{
int n = BuyOfferCore(o, line.Qty, asset, pw);
items += n;
if (n < line.Qty) failed += line.Qty - n;
}
else
{
var ord = d.Black.Place((BmListing)line.Good, line.Qty, mode, pw, asset, StoreStation.GetDayCounter(), Session.Price);
if (ord == null) failed += line.Qty; else items += ord.Count;
}
_cart.Remove(line);
}
return (items, failed);
}
private static string CartMessage(int items, int failed) => failed == 0 ? L.T("cart.done", items) : L.T("cart.partial", items, failed);
/// <summary>统一结算:一次合约签名;每家商家不收所选币时,先按滑点换成它收的币再付。</summary>
private static void CheckoutAll(string coin, double need)
{
var d = Session.Data;
var lines = _cart.ToList();
_checkout = false;
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = coin, Amount = need, Spend = need,
Summary = L.T("cart.sum", lines.Sum(l => l.Qty), coin, WalletWindow.Amt(need)),
Execute = pw =>
{
var msgs = new List<string>(); int doneItems = 0, doneFailed = 0;
foreach (var g in lines.GroupBy(l => l.Seller))
{
int seller = g.Key;
var acc = AcceptsOf(d, seller);
double credits = g.Sum(l => LineCredits(d, l));
string asset = acc.Contains(coin) ? coin : acc[0];
if (asset != coin)
{
double src = credits * (1 + Tuning.BmAutoSwapSlip) / Session.Price(coin);
if (pw.Avail(coin) + 1e-9 < src) { msgs.Add(L.T("cart.short", ShopName(d, seller))); doneFailed += g.Sum(l => l.Qty); continue; }
double got = credits / Session.Price(asset) * 1.002;
pw.Add(coin, -src); pw.Add(asset, got); pw.RecordBuy(asset, got, credits);
CandleChart.Trade(coin, credits); CandleChart.Trade(asset, credits);
Log.Info($"自动换币:{src:0.####} {coin} → {got:0.####} {asset}({credits:0} 信用点等值)");
}
var r3 = ExecGroup(seller, g.ToList(), ModeOf(d, seller), asset, pw);
doneItems += r3.items; doneFailed += r3.failed;
}
msgs.Insert(0, CartMessage(doneItems, doneFailed));
Finish(string.Join(" ", msgs));
},
});
}
private static string MerchantLabel(BmSeller s, bool shortName = false) => L.T("merchant." + s.Type) + " " + (shortName ? ChainAddress.Short(s.Name) : s.Name);
private static int _merchant = -1;
@@ -1469,7 +1733,6 @@ namespace Decentralized
var sellers = d.Black.Sellers;
var seller = sellers[_merchant];
int merchant = _merchant;
b.AddLabel(L.T("black.seller", MerchantLabel(seller), seller.Trades, seller.Rating) + " " + L.T("black.days", seller.Days), "black_seller_dim");
var goods = d.Black.Catalog.Where(l => l.Seller == merchant).Cast<BmGood>().ToList();
if (goods.Count == 0) { b.AddLabel(L.T("black.empty"), "shelf_empty_dim"); return; }
Shelf(b, goods, (c, g) =>
@@ -1478,12 +1741,7 @@ namespace Decentralized
GoodHead(c, g, L.T("black.stock", listing.Qty));
c.AddLabel(L.T("black.price", BlackMarket.Pay(BlackMarket.OrderTotal(listing.Price, 1), PayMode.Escrow)));
c.AddFlexibleSpace();
c.AddButton(L.T("black.buy"), () => OpenPay(new PayDraft
{
Title = ItemName(listing.Item), Address = seller.Name, Accepted = Merchants.Accepts(seller.Type), MaxQty = listing.Qty, Modes = seller.Advance,
Credits = (q, m) => BlackMarket.Pay(BlackMarket.OrderTotal(listing.Price, q), m),
Execute = (asset, q, m, pw) => PlaceOrder(listing, q, asset, m, pw),
}));
c.AddButton(L.T("black.buy"), () => AddToCart(merchant, listing));
}, 3);
}
@@ -1553,6 +1811,16 @@ namespace Decentralized
}
private static void BuyOffer(BmOffer o, int count, string asset, Wallet pw)
{
int bought = BuyOfferCore(o, count, asset, pw);
if (bought == 0) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("black.bought", ItemName(o.Item) + (bought > 1 ? " ×" + bought : ""));
Session.Save();
Open();
}
/// <summary>平台现货买 count 件:每件从钱包扣币,货进储藏区。返回实际买到的件数。</summary>
private static int BuyOfferCore(BmOffer o, int count, string asset, Wallet pw)
{
var d = Session.Data;
string item = o.Item, model = o.Model, fake = o.FakeOf;
@@ -1564,10 +1832,7 @@ namespace Decentralized
Items.Give(BlackDelivery.Make(item, model, fake, health));
bought++;
}
if (bought == 0) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("black.bought", ItemName(item) + (bought > 1 ? " ×" + bought : ""));
Session.Save();
Open();
return bought;
}
private static void PlaceOrder(BmListing l, int count, string asset, PayMode mode, Wallet pw)