feat: add admin panel
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// پکیج admin یک پنل ادمینِ سرورسایدِ سبک (HTML) برای مدیریت فروشگاه و کاربران است.
|
||||
// همهی صفحات پشت Basic Auth هستند و در همان باینری Go سرو میشوند (بدون پروسهی جدا).
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"hakemsho/internal/economy"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var tplFS embed.FS
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
eco *economy.Service
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
func New(db *sql.DB, eco *economy.Service) *Handler {
|
||||
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
||||
return &Handler{db: db, eco: eco, tpl: tpl}
|
||||
}
|
||||
|
||||
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
||||
func (h *Handler) Routes(user, pass string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.BasicAuth("hakemsho-admin", map[string]string{user: pass}))
|
||||
r.Get("/", h.dashboard)
|
||||
r.Get("/shop", h.shop)
|
||||
r.Post("/shop/{kind}", h.updateItem)
|
||||
r.Get("/users", h.users)
|
||||
r.Post("/users/coins", h.adjustCoins)
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) render(w http.ResponseWriter, page string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.tpl.ExecuteTemplate(w, page, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// --- داشبورد ---
|
||||
|
||||
func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
count := func(q string) int64 {
|
||||
var n int64
|
||||
_ = h.db.QueryRowContext(ctx, q).Scan(&n)
|
||||
return n
|
||||
}
|
||||
data := map[string]any{
|
||||
"Users": count(`SELECT COUNT(*) FROM users`),
|
||||
"Purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`),
|
||||
"Games": count(`SELECT COUNT(*) FROM game_history`),
|
||||
"Coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`),
|
||||
"Nav": "dashboard",
|
||||
}
|
||||
h.render(w, "dashboard.html", data)
|
||||
}
|
||||
|
||||
// --- فروشگاه (ویرایش کاتالوگ) ---
|
||||
|
||||
func (h *Handler) shop(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
data := map[string]any{
|
||||
"Nav": "shop",
|
||||
"Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled FROM coin_packages ORDER BY sort`,
|
||||
"id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sort", "enabled"),
|
||||
"Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sort,enabled FROM ticket_packages ORDER BY sort`,
|
||||
"id", "title", "tickets", "price_toman", "sort", "enabled"),
|
||||
"Cards": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`,
|
||||
"id", "title", "price_coins", "sort", "enabled"),
|
||||
"Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sort,enabled FROM boosters ORDER BY sort`,
|
||||
"id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"),
|
||||
"Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,sort,enabled FROM table_tiers ORDER BY sort`,
|
||||
"id", "title", "hands", "entry", "prize", "xp", "trophy", "sort", "enabled"),
|
||||
"Saved": r.URL.Query().Get("saved") == "1",
|
||||
}
|
||||
h.render(w, "shop.html", data)
|
||||
}
|
||||
|
||||
// updateItem یک ردیفِ کاتالوگ را بر اساس kind بهروزرسانی و کش را تازه میکند.
|
||||
func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
kind := chi.URLParam(r, "kind")
|
||||
id := r.FormValue("id")
|
||||
f := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v }
|
||||
en := 0
|
||||
if r.FormValue("enabled") == "on" {
|
||||
en = 1
|
||||
}
|
||||
|
||||
var q string
|
||||
var args []any
|
||||
switch kind {
|
||||
case "coin":
|
||||
q = `UPDATE coin_packages SET title=?,coins=?,vip_days=?,price_toman=?,bonus_pct=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en, id}
|
||||
case "ticket":
|
||||
q = `UPDATE ticket_packages SET title=?,tickets=?,price_toman=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en, id}
|
||||
case "card":
|
||||
q = `UPDATE card_skins SET title=?,price_coins=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("price_coins"), f("sort"), en, id}
|
||||
case "booster":
|
||||
q = `UPDATE boosters SET title=?,multiplier=?,hours=?,price_toman=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en, id}
|
||||
case "tier":
|
||||
q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en, id}
|
||||
default:
|
||||
http.Error(w, "unknown kind", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.db.ExecContext(r.Context(), q, args...); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = h.eco.LoadCatalog(r.Context()) // تازهسازی کشِ کاتالوگ
|
||||
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- کاربران ---
|
||||
|
||||
func (h *Handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
q := r.URL.Query().Get("q")
|
||||
query := `SELECT id, mobile, coins, tickets, xp, trophies, is_admin FROM users`
|
||||
var args []any
|
||||
if q != "" {
|
||||
query += ` WHERE mobile LIKE ?`
|
||||
args = append(args, "%"+q+"%")
|
||||
}
|
||||
query += ` ORDER BY id DESC LIMIT 100`
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type userRow struct {
|
||||
ID int64
|
||||
Mobile string
|
||||
Coins, Tickets, XP, Trophy int64
|
||||
Level int
|
||||
IsAdmin bool
|
||||
}
|
||||
var list []userRow
|
||||
for rows.Next() {
|
||||
var u userRow
|
||||
var admin int
|
||||
if err := rows.Scan(&u.ID, &u.Mobile, &u.Coins, &u.Tickets, &u.XP, &u.Trophy, &admin); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
u.IsAdmin = admin == 1
|
||||
u.Level, _, _ = economy.LevelInfo(u.XP)
|
||||
list = append(list, u)
|
||||
}
|
||||
h.render(w, "users.html", map[string]any{
|
||||
"Nav": "users",
|
||||
"Users": list,
|
||||
"Q": q,
|
||||
"Saved": r.URL.Query().Get("saved") == "1",
|
||||
})
|
||||
}
|
||||
|
||||
// adjustCoins سکهی یک کاربر را افزایش/کاهش میدهد (لاگ در wallet_tx).
|
||||
func (h *Handler) adjustCoins(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userID, _ := strconv.ParseInt(r.FormValue("user_id"), 10, 64)
|
||||
amount, _ := strconv.ParseInt(r.FormValue("amount"), 10, 64)
|
||||
if userID > 0 && amount != 0 {
|
||||
_ = h.eco.Adjust(r.Context(), userID, economy.CurrencyCoin, amount, "admin_adjust", "")
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// rows یک کوئری را به []map[string]any تبدیل میکند (برای رندرِ عمومیِ جدولها).
|
||||
func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any {
|
||||
rs, err := h.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rs.Close()
|
||||
var out []map[string]any
|
||||
for rs.Next() {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rs.Scan(ptrs...); err != nil {
|
||||
return out
|
||||
}
|
||||
m := make(map[string]any, len(cols))
|
||||
for i, c := range cols {
|
||||
m[c] = vals[i]
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html dir="rtl" lang="fa">
|
||||
{{template "head" .}}
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
<div class="wrap">
|
||||
<h1>داشبورد</h1>
|
||||
<div class="stats">
|
||||
<div class="stat">کاربران<b>{{.Users}}</b></div>
|
||||
<div class="stat">خریدهای موفق<b>{{.Purchases}}</b></div>
|
||||
<div class="stat">بازیهای ثبتشده<b>{{.Games}}</b></div>
|
||||
<div class="stat">مجموع سکهی کاربران<b>{{.Coins}}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "head"}}
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>حکمشو — پنل ادمین</title>
|
||||
<style>
|
||||
:root { --bg:#240108; --panel:#3a0a12; --gold:#e9b949; --text:#f5e9d0; --accent:#b81d2a; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font-family: Tahoma, sans-serif; }
|
||||
nav { background:#1a0106; padding:12px 18px; display:flex; gap:16px; border-bottom:2px solid var(--gold); }
|
||||
nav a { color:#caa; text-decoration:none; font-weight:bold; padding:6px 10px; border-radius:8px; }
|
||||
nav a.active, nav a:hover { color:var(--gold); background:rgba(233,185,73,.12); }
|
||||
.wrap { padding:20px; max-width:1100px; margin:0 auto; }
|
||||
h1 { color:var(--gold); }
|
||||
h2 { color:var(--gold); border-bottom:1px solid #5a0e1a; padding-bottom:6px; margin-top:28px; }
|
||||
.stats { display:flex; gap:16px; flex-wrap:wrap; }
|
||||
.stat { background:var(--panel); border:1px solid #5a0e1a; border-radius:14px; padding:18px 26px; text-align:center; }
|
||||
.stat b { color:var(--gold); font-size:26px; display:block; margin-top:6px; }
|
||||
table { width:100%; border-collapse:collapse; background:var(--panel); border-radius:12px; overflow:hidden; }
|
||||
th, td { padding:8px 10px; text-align:right; border-bottom:1px solid #5a0e1a; font-size:13px; }
|
||||
th { color:var(--gold); }
|
||||
input { background:#1a0106; color:var(--text); border:1px solid #6b4; border-radius:6px; padding:5px 7px; width:90px; }
|
||||
input[type=text] { width:130px; }
|
||||
button { background:var(--accent); color:var(--text); border:1px solid var(--gold); border-radius:8px; padding:6px 14px; font-weight:bold; cursor:pointer; }
|
||||
button:hover { filter:brightness(1.15); }
|
||||
.saved { background:#2e7d32; color:#fff; padding:8px 14px; border-radius:8px; display:inline-block; margin:10px 0; }
|
||||
.search { margin:12px 0; }
|
||||
</style>
|
||||
</head>
|
||||
{{end}}
|
||||
|
||||
{{define "nav"}}
|
||||
<nav>
|
||||
<a href="/admin" class="{{if eq .Nav "dashboard"}}active{{end}}">داشبورد</a>
|
||||
<a href="/admin/shop" class="{{if eq .Nav "shop"}}active{{end}}">فروشگاه</a>
|
||||
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html dir="rtl" lang="fa">
|
||||
{{template "head" .}}
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
<div class="wrap">
|
||||
<h1>فروشگاه</h1>
|
||||
{{if .Saved}}<div class="saved">تغییرات ذخیره شد ✓</div>{{end}}
|
||||
|
||||
<h2>بستههای سکه</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>سکه</th><th>VIP (روز)</th><th>قیمت (تومان)</th><th>بونوس٪</th><th>ترتیب</th><th>فعال</th><th></th></tr>
|
||||
{{range .Coins}}
|
||||
<tr><form method="post" action="/admin/shop/coin">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||
<td><input name="coins" value="{{.coins}}"></td>
|
||||
<td><input name="vip_days" value="{{.vip_days}}"></td>
|
||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||
<td><input name="bonus_pct" value="{{.bonus_pct}}"></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
</form></tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<h2>بستههای بلیط</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>بلیط</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
|
||||
{{range .Tickets}}
|
||||
<tr><form method="post" action="/admin/shop/ticket">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||
<td><input name="tickets" value="{{.tickets}}"></td>
|
||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
</form></tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<h2>اسکین کارتها</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
|
||||
{{range .Cards}}
|
||||
<tr><form method="post" action="/admin/shop/card">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||
<td><input name="price_coins" value="{{.price_coins}}"></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
</form></tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<h2>بوسترها</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>ضریب</th><th>ساعت</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
|
||||
{{range .Boosters}}
|
||||
<tr><form method="post" action="/admin/shop/booster">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||
<td><input name="multiplier" value="{{.multiplier}}"></td>
|
||||
<td><input name="hours" value="{{.hours}}"></td>
|
||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
</form></tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<h2>انواع میز</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>ترتیب</th><th>فعال</th><th></th></tr>
|
||||
{{range .Tiers}}
|
||||
<tr><form method="post" action="/admin/shop/tier">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||
<td><input name="hands" value="{{.hands}}"></td>
|
||||
<td><input name="entry" value="{{.entry}}"></td>
|
||||
<td><input name="prize" value="{{.prize}}"></td>
|
||||
<td><input name="xp" value="{{.xp}}"></td>
|
||||
<td><input name="trophy" value="{{.trophy}}"></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
</form></tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html dir="rtl" lang="fa">
|
||||
{{template "head" .}}
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
<div class="wrap">
|
||||
<h1>کاربران</h1>
|
||||
{{if .Saved}}<div class="saved">انجام شد ✓</div>{{end}}
|
||||
|
||||
<form class="search" method="get" action="/admin/users">
|
||||
<input type="text" name="q" value="{{.Q}}" placeholder="جستجوی شماره موبایل">
|
||||
<button>جستجو</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<tr><th>#</th><th>موبایل</th><th>سکه</th><th>بلیط</th><th>سطح</th><th>جام</th><th>ادمین</th><th>تغییر سکه (+/-)</th></tr>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Mobile}}</td>
|
||||
<td>{{.Coins}}</td>
|
||||
<td>{{.Tickets}}</td>
|
||||
<td>{{.Level}}</td>
|
||||
<td>{{.Trophy}}</td>
|
||||
<td>{{if .IsAdmin}}✓{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users/coins" style="display:flex;gap:6px">
|
||||
<input type="hidden" name="user_id" value="{{.ID}}">
|
||||
<input name="amount" placeholder="مثلاً 500 یا -200">
|
||||
<button>اعمال</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user