go-via模拟ag-grid示例

// StockGrid demonstrates an ag-grid-like data table using Via framework.
// Features: sorting, filtering, pagination - all in pure Go with no JavaScript.
//
//	go run .
package main

import (
	"log"
	"math/rand"
	"net"
	"net/http"
	"sort"
	"strings"
	"syscall"
	"time"
	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
)

type Stock struct {
	Symbol        string
	Name          string
	Price         float64
	Change        float64
	ChangePercent float64
	Volume        int64
}

// allStocks contains sample stock data
var allStocks = []Stock{
	{"AAPL", "Apple Inc.", 178.52, 2.35, 1.34, 58234567},
	{"MSFT", "Microsoft Corporation", 378.91, -1.23, -0.32, 23456789},
	{"GOOGL", "Alphabet Inc.", 141.80, 3.45, 2.49, 45678901},
	{"AMZN", "Amazon.com Inc.", 178.25, 4.12, 2.37, 34567890},
	{"NVDA", "NVIDIA Corporation", 495.22, 12.45, 2.58, 78901234},
	{"META", "Meta Platforms Inc.", 505.95, -3.21, -0.63, 23456789},
	{"TSLA", "Tesla Inc.", 177.48, -5.67, -3.10, 89012345},
	{"BRK.B", "Berkshire Hathaway", 363.54, 1.23, 0.34, 12345678},
	{"JPM", "JPMorgan Chase & Co.", 196.54, 2.11, 1.09, 34567890},
	{"JNJ", "Johnson & Johnson", 156.74, -0.45, -0.29, 23456789},
	{"V", "Visa Inc.", 276.32, 1.89, 0.69, 18901234},
	{"PG", "Procter & Gamble", 158.90, 0.56, 0.35, 15678901},
	{"UNH", "UnitedHealth Group", 527.43, -2.34, -0.44, 12345678},
	{"HD", "Home Depot Inc.", 345.67, 4.23, 1.24, 8901234},
	{"MA", "Mastercard Inc.", 458.90, 3.45, 0.76, 8901234},
	{"DIS", "Walt Disney Company", 112.45, -1.56, -1.37, 23456789},
	{"PYPL", "PayPal Holdings", 63.78, 2.34, 3.81, 34567890},
	{"NFLX", "Netflix Inc.", 628.90, 15.67, 2.56, 8901234},
	{"ADBE", "Adobe Inc.", 524.67, 8.90, 1.73, 4567890},
	{"CRM", "Salesforce Inc.", 272.34, -3.45, -1.25, 12345678},
	{"INTC", "Intel Corporation", 31.45, 0.78, 2.54, 56789012},
	{"AMD", "Advanced Micro Devices", 158.90, 5.67, 3.70, 45678901},
	{"CSCO", "Cisco Systems", 48.90, 0.34, 0.70, 23456789},
	{"PEP", "PepsiCo Inc.", 172.56, 1.23, 0.72, 8901234},
	{"KO", "Coca-Cola Company", 60.45, 0.12, 0.20, 23456789},
	{"MRK", "Merck & Co.", 126.78, -0.89, -0.70, 15678901},
	{"ABBV", "AbbVie Inc.", 173.45, 2.34, 1.37, 12345678},
	{"TMO", "Thermo Fisher Scientific", 567.89, -4.56, -0.80, 3456789},
	{"COST", "Costco Wholesale", 728.90, 12.34, 1.72, 4567890},
	{"AVGO", "Broadcom Inc.", 1289.45, 45.67, 3.67, 5678901},
}

const pageSize = 10

type StockGrid struct {
	SearchText  via.SignalStr      `via:"search,init="`
	SortColumn  via.StateTabStr    `via:"sort,init=symbol"`
	SortDir     via.StateTabStr    `via:"dir,init=asc"`
	CurrentPage via.StateTab[int]  `via:"page,init=1"`
	TargetPage  via.SignalNum[int] `via:"targetPage"`
	AllStocks   via.StateTabSlice[Stock]
}

func (s *StockGrid) OnInit(ctx *via.Ctx) error {
	return s.AllStocks.Update(ctx, func(_ []Stock) ([]Stock, error) {
		return allStocks, nil
	})
}

func (s *StockGrid) OnConnect(ctx *via.Ctx) error {
	via.Stream(ctx, 5*time.Second, func(ctx *via.Ctx, t time.Time) {
		rng := rand.New(rand.NewSource(t.UnixNano()))

		s.AllStocks.Update(ctx, func(stocks []Stock) ([]Stock, error) {
			updated := make([]Stock, len(stocks))

			for i, stock := range stocks {
				rng.Seed(t.UnixNano() + int64(i))

				change := (rng.Float64() - 0.5) * 0.10
				newPrice := stock.Price * (1 + change)
				newChange := newPrice - stock.Price
				newChangePercent := change * 100
				updated[i] = Stock{
					Symbol:        stock.Symbol,
					Name:          stock.Name,
					Price:         newPrice,
					Change:        newChange,
					ChangePercent: newChangePercent,
					Volume:        stock.Volume + int64(rng.Intn(10000)),
				}
			}

			return updated, nil
		})
	})

	return nil
}

func (s *StockGrid) gotoPage(ctx *via.Ctx, page int) {
	search := strings.ToLower(strings.TrimSpace(s.SearchText.Read(ctx)))
	stocks := s.AllStocks.Read(ctx)
	filtered := s.filterStocks(stocks, search)
	totalPages := (len(filtered) + pageSize - 1) / pageSize

	if page >= 1 && page <= totalPages {
		s.CurrentPage.Write(ctx, page)
	}
}

func (s *StockGrid) GoToPage(ctx *via.Ctx) {
	page := s.TargetPage.Read(ctx)

	s.gotoPage(ctx, page)
}

func (s *StockGrid) Search(ctx *via.Ctx) {
	s.CurrentPage.Write(ctx, 1)
}

func (s *StockGrid) setSortCol(ctx *via.Ctx, col string) {
	currentCol := s.SortColumn.Read(ctx)
	currentDir := s.SortDir.Read(ctx)

	if currentCol == col {
		if currentDir == "asc" {
			s.SortDir.Write(ctx, "desc")
		} else {
			s.SortDir.Write(ctx, "asc")
		}
	} else {
		s.SortColumn.Write(ctx, col)
		s.SortDir.Write(ctx, "asc")
	}
}

func (s *StockGrid) ColSymbol(ctx *via.Ctx) { s.setSortCol(ctx, "symbol") }

func (s *StockGrid) ColName(ctx *via.Ctx) { s.setSortCol(ctx, "name") }

func (s *StockGrid) ColPrice(ctx *via.Ctx) { s.setSortCol(ctx, "price") }

func (s *StockGrid) ColChange(ctx *via.Ctx) { s.setSortCol(ctx, "change") }

func (s *StockGrid) ColChangePercent(ctx *via.Ctx) { s.setSortCol(ctx, "changePercent") }

func (s *StockGrid) ColVolume(ctx *via.Ctx) { s.setSortCol(ctx, "volume") }

func (s *StockGrid) PrevPage(ctx *via.Ctx) {
	page := s.CurrentPage.Read(ctx)

	if page > 1 {
		s.CurrentPage.Write(ctx, page-1)
	}
}

func (s *StockGrid) NextPage(ctx *via.Ctx) {
	page := s.CurrentPage.Read(ctx)
	search := strings.ToLower(strings.TrimSpace(s.SearchText.Read(ctx)))
	stocks := s.AllStocks.Read(ctx)
	filtered := s.filterStocks(stocks, search)
	totalPages := (len(filtered) + pageSize - 1) / pageSize

	if page < totalPages {
		s.CurrentPage.Write(ctx, page+1)
	}
}

func (s *StockGrid) filterStocks(stocks []Stock, search string) []Stock {
	if search == "" {
		return stocks
	}

	filtered := make([]Stock, 0)

	for _, stock := range stocks {
		if strings.Contains(strings.ToLower(stock.Symbol), search) ||
			strings.Contains(strings.ToLower(stock.Name), search) {
			filtered = append(filtered, stock)
		}
	}

	return filtered
}

func (s *StockGrid) GetFilteredStocks(ctx *via.CtxR) []Stock {
	search := strings.ToLower(strings.TrimSpace(s.SearchText.Read(ctx)))
	stocks := s.AllStocks.Read(ctx)
	filtered := s.filterStocks(stocks, search)
	col := s.SortColumn.Read(ctx)
	dir := s.SortDir.Read(ctx)

	sort.Slice(filtered, func(i, j int) bool {
		var less bool

		switch col {
		case "symbol":
			less = filtered[i].Symbol < filtered[j].Symbol
		case "name":
			less = filtered[i].Name < filtered[j].Name
		case "price":
			less = filtered[i].Price < filtered[j].Price
		case "change":
			less = filtered[i].Change < filtered[j].Change
		case "changePercent":
			less = filtered[i].ChangePercent < filtered[j].ChangePercent
		case "volume":
			less = filtered[i].Volume < filtered[j].Volume
		default:
			less = filtered[i].Symbol < filtered[j].Symbol
		}

		if dir == "desc" {
			return !less
		}

		return less
	})

	return filtered
}

func (s *StockGrid) GetPageStocks(ctx *via.CtxR) []Stock {
	all := s.GetFilteredStocks(ctx)
	page := s.CurrentPage.Read(ctx)

	if page < 1 {
		page = 1
	}

	start := (page - 1) * pageSize
	end := start + pageSize

	if start >= len(all) {
		return []Stock{}
	}

	if end > len(all) {
		end = len(all)
	}

	if start < 0 {
		start = 0
	}

	return all[start:end]
}

func minInt(a, b int) int {
	if a < b {
		return a
	}

	return b
}

func (s *StockGrid) sortTh(col, label string, ctx *via.CtxR) h.H {
	currentCol := s.SortColumn.Read(ctx)
	currentDir := s.SortDir.Read(ctx)
	isSorted := currentCol == col
	icon := "▲"

	if isSorted && currentDir == "desc" {
		icon = "▼"
	}

	if col == "symbol" {
		return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColSymbol), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
	}

	if col == "name" {
		return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColName), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
	}

	if col == "price" {
		return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColPrice), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
	}

	if col == "change" {
		return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColChange), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
	}

	if col == "changePercent" {
		return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColChangePercent), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
	}

	return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColVolume), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
}

func (s *StockGrid) View(ctx *via.CtxR) h.H {
	stocks := s.GetPageStocks(ctx)
	page := s.CurrentPage.Read(ctx)
	totalCount := len(s.GetFilteredStocks(ctx))
	totalPages := (totalCount + pageSize - 1) / pageSize

	if totalPages < 1 {
		totalPages = 1
	}

	pageNums := make([]int, totalPages)

	for i := range pageNums {
		pageNums[i] = i + 1
	}

	return h.Div(h.Class("stock-grid"),
		h.StyleEl(h.Raw(`
			.stock-grid {
				font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Roboto, sans-serif;
				max-width: 1280px;
				margin: 2rem auto;
				padding: 0;
				background: #ffffff;
				border-radius: 16px;
				box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
				overflow: hidden;
			}
			.stock-grid .grid-header {
				padding: 1.75rem 2rem;
				border-bottom: 1px solid #f0f0f0;
				background: linear-gradient(135deg, #fafbff 0%, #f5f7ff 100%);
			}
			.stock-grid .grid-title {
				font-size: 1.625rem;
				font-weight: 700;
				color: #1a1a2e;
				margin-bottom: 0.375rem;
				letter-spacing: -0.02em;
			}
			.stock-grid .grid-subtitle {
				font-size: 0.875rem;
				color: #6b7280;
			}
			.stock-grid .toolbar {
				display: flex;
				align-items: center;
				justify-content: space-between;
				padding: 1rem 2rem;
				background: #fafafa;
				border-bottom: 1px solid #f0f0f0;
			}
			.stock-grid .search-box {
				position: relative;
				flex: 0 0 360px;
			}
			.stock-grid .search-box input {
				width: 100%;
				padding: 0.625rem 1rem;
				border: 1.5px solid #e5e7eb;
				border-radius: 10px;
				font-size: 0.9375rem;
				background: #ffffff;
				transition: all 0.2s ease;
				color: #1f2937;
			}
			.stock-grid .search-box input:focus {
				outline: none;
				border-color: #667eea;
				box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
			}
			.stock-grid .search-box input::placeholder {
				color: #9ca3af;
			}
			.stock-grid .stats-bar {
				display: flex;
				gap: 1.5rem;
			}
			.stock-grid .stat-item {
				display: flex;
				align-items: center;
				gap: 0.5rem;
				font-size: 0.875rem;
				color: #6b7280;
			}
			.stock-grid .stat-value {
				font-weight: 600;
				color: #1f2937;
				font-size: 0.9375rem;
			}
			.stock-grid .table-container {
				overflow-x: auto;
			}
			.stock-grid table {
				width: 100%;
				border-collapse: collapse;
				font-size: 0.9375rem;
			}
			.stock-grid thead th {
				background: linear-gradient(180deg, #f8f9fc 0%, #f1f3f9 100%);
				padding: 0.875rem 1.25rem;
				text-align: left;
				font-weight: 600;
				color: #374151;
				border-bottom: 2px solid #e5e7eb;
				cursor: pointer;
				user-select: none;
				white-space: nowrap;
				transition: background 0.15s ease;
				position: relative;
			}
			.stock-grid thead th:hover {
				background: linear-gradient(180deg, #eef0f7 0%, #e5e8f0 100%);
			}
			.stock-grid thead th.sorted {
				color: #4f46e5;
				background: linear-gradient(180deg, #eef2ff 0%, #e0e7ff 100%);
			}
			.stock-grid thead th.sorted::after {
				content: '';
				position: absolute;
				left: 0;
				right: 0;
				bottom: -2px;
				height: 2px;
				background: #4f46e5;
			}
			.stock-grid .sort-icon {
				display: inline-flex;
				align-items: center;
				margin-left: 0.375rem;
				font-size: 0.6875rem;
				opacity: 0.35;
				transition: opacity 0.15s ease;
			}
			.stock-grid th.sorted .sort-icon {
				opacity: 1;
				color: #4f46e5;
			}
			.stock-grid tbody td {
				padding: 0.875rem 1.25rem;
				border-bottom: 1px solid #f3f4f6;
				color: #1f2937;
				transition: background 0.15s ease;
			}
			.stock-grid tbody tr:hover td {
				background: #f9fafb;
			}
			.stock-grid tbody tr:last-child td {
				border-bottom: none;
			}
			.stock-grid .symbol {
				font-weight: 600;
				color: #111827;
				font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
			}
			.stock-grid .name {
				color: #4b5563;
			}
			.stock-grid .price {
				text-align: right;
				font-weight: 600;
				font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
				color: #111827;
			}
			.stock-grid .change, .stock-grid .change-percent {
				text-align: right;
				font-weight: 500;
				font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
			}
			.stock-grid .volume {
				text-align: right;
				font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
				color: #6b7280;
			}
			.stock-grid .positive {
				color: #dc2626;
			}
			.stock-grid .negative {
				color: #16a34a;
			}
			.stock-grid .change-badge {
				display: inline-flex;
				align-items: center;
				padding: 0.25rem 0.625rem;
				border-radius: 6px;
				font-size: 0.875rem;
				font-weight: 600;
			}
			.stock-grid .change-badge.positive {
				background: #fef2f2;
				color: #dc2626;
			}
			.stock-grid .change-badge.negative {
				background: #f0fdf4;
				color: #16a34a;
			}
			.stock-grid .pagination {
				display: flex;
				align-items: center;
				justify-content: space-between;
				padding: 1rem 2rem;
				background: #fafafa;
				border-top: 1px solid #f0f0f0;
			}
			.stock-grid .pagination-info {
				color: #6b7280;
				font-size: 0.875rem;
			}
			.stock-grid .pagination-controls {
				display: flex;
				align-items: center;
				gap: 0.5rem;
			}
			.stock-grid .pagination-controls button {
				padding: 0.5rem 0.875rem;
				border: 1.5px solid #e5e7eb;
				background: #ffffff;
				border-radius: 8px;
				cursor: pointer;
				font-size: 0.875rem;
				font-weight: 500;
				color: #374151;
				transition: all 0.15s ease;
			}
			.stock-grid .pagination-controls button:hover:not(:disabled) {
				border-color: #667eea;
				color: #4f46e5;
				background: #f5f3ff;
			}
			.stock-grid .pagination-controls button:disabled {
				opacity: 0.4;
				cursor: not-allowed;
			}
			.stock-grid .page-numbers {
				display: flex;
				gap: 0.25rem;
			}
			.stock-grid .page-numbers button {
				min-width: 2.25rem;
				text-align: center;
				padding: 0.5rem 0.25rem;
			}
			.stock-grid .page-numbers button.active {
				background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
				color: #ffffff;
				border-color: transparent;
				box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
			}
			.stock-grid .page-numbers button.active:hover {
				background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
				color: #ffffff;
			}
		`)),
		h.Div(h.Class("grid-header"),
			h.H1(h.Class("grid-title"), h.Text("股票行情数据")),
			h.P(h.Class("grid-subtitle"), h.Text("实时行情 · 支持排序、筛选、分页")),
		),
		h.Div(h.Class("toolbar"),
			h.Div(h.Class("search-box"),
				h.Input(
					h.Type("text"),
					h.Placeholder("搜索股票代码或名称..."),
					s.SearchText.Bind(),
					on.Input(s.Search, on.Debounce("300ms")),
					on.Key("Enter", s.Search),
				),
			),
			h.Div(h.Class("stats-bar"),
				h.Div(h.Class("stat-item"),
					h.Span(h.Text("股票总数:")),
					h.Span(h.Class("stat-value"), h.Textf("%d", totalCount)),
				),
				h.Div(h.Class("stat-item"),
					h.Span(h.Text("当前页:")),
					h.Span(h.Class("stat-value"), h.Textf("%d / %d", page, totalPages)),
				),
			),
		),
		h.Div(h.Class("table-container"),
			h.Table(
				h.THead(
					h.Tr(
						s.sortTh("symbol", "代码", ctx),
						s.sortTh("name", "名称", ctx),
						s.sortTh("price", "现价", ctx),
						s.sortTh("change", "涨跌额", ctx),
						s.sortTh("changePercent", "涨跌幅", ctx),
						s.sortTh("volume", "成交量", ctx),
					),
				),
				h.TBody(
					h.Each(stocks, func(stock Stock) h.H {
						changeClass := "positive"
						changeSign := "+"

						if stock.Change < 0 {
							changeClass = "negative"
							changeSign = ""
						}

						return h.Tr(
							h.Td(h.Class("symbol"), h.Text(stock.Symbol)),
							h.Td(h.Class("name"), h.Text(stock.Name)),
							h.Td(h.Class("price"), h.Textf("$%.2f", stock.Price)),
							h.Td(h.Class("change"),
								h.Span(h.Class("change-badge "+changeClass),
									h.Textf("%s%.2f", changeSign, stock.Change),
								),
							),
							h.Td(h.Class("change-percent"),
								h.Span(h.Class("change-badge "+changeClass),
									h.Textf("%s%.2f%%", changeSign, stock.ChangePercent),
								),
							),
							h.Td(h.Class("volume"), h.Textf("%d", stock.Volume)),
						)
					}),
				),
			),
		),
		h.Div(h.Class("pagination"),
			h.Div(h.Class("pagination-info"),
				h.Textf("显示第 %d - %d 条,共 %d 条记录",
					minInt(totalCount, (page-1)*pageSize+1),
					minInt(page*pageSize, totalCount),
					totalCount,
				),
			),
			h.Div(h.Class("pagination-controls"),
				h.Button(
					h.Text("上一页"),
					on.Click(s.PrevPage),
					h.If(page <= 1, h.Disabled()),
				),
				h.Div(h.Class("page-numbers"),
					h.Each(pageNums, func(pageNum int) h.H {
						return h.Button(
							h.Textf("%d", pageNum),
							on.Click(s.GoToPage, on.SetSignal(&s.TargetPage.Signal, pageNum)),
							h.If(pageNum == page, h.Class("active")),
						)
					}),
				),
				h.Button(
					h.Text("下一页"),
					on.Click(s.NextPage),
					h.If(page >= totalPages, h.Disabled()),
				),
			),
		),
	)
}

func main() {
	port := ":8080"
	app := via.New(
		via.WithTitle("Stock Grid"),
	)

	via.Mount[StockGrid](app, "/")
	app.HandleFunc("GET /_datastar.js.map", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"version":3,"sources":[],"names":[],"mappings":""}`))
	})
	log.Printf("Starting server on port %s...", port)

	server := &http.Server{Addr: port, Handler: app}
	lc := &net.ListenConfig{
		Control: func(network, address string, c syscall.RawConn) error {
			return c.Control(func(fd uintptr) {
				syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
			})
		},
	}
	ln, err := lc.Listen(nil, "tcp", port)

	if err != nil {
		log.Fatalf("Port %s is already in use! Error: %v", port, err)
	}

	if err := server.Serve(ln); err != nil {
		log.Fatalf("Failed to start server: %v", err)
	}
}
posted @ 2026-06-27 16:47  卓能文  阅读(2)  评论(0)    收藏  举报