From 6f5dae5ca96160b6daea21dc4dfc97b23947f1d0 Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Mon, 11 Mar 2024 21:24:14 +0800 Subject: [PATCH] feat: support redeem code operation (#90) --- admin/controller.go | 20 ++-- admin/redeem.go | 80 +++++++------- admin/router.go | 2 +- admin/types.go | 8 +- app/src-tauri/tauri.conf.json | 2 +- app/src/admin/api/chart.ts | 17 ++- app/src/admin/types.ts | 13 ++- app/src/assets/globals.less | 6 ++ app/src/components/admin/InvitationTable.tsx | 5 +- app/src/components/admin/RedeemTable.tsx | 100 +++++++++++++----- .../components/admin/common/StateBadge.tsx | 12 +++ app/src/conf/bootstrap.ts | 2 +- app/src/resources/i18n/cn.json | 3 +- app/src/resources/i18n/en.json | 3 +- app/src/resources/i18n/ja.json | 3 +- app/src/resources/i18n/ru.json | 3 +- app/tailwind.config.js | 8 ++ middleware/throttle.go | 5 +- 18 files changed, 193 insertions(+), 99 deletions(-) create mode 100644 app/src/components/admin/common/StateBadge.tsx diff --git a/admin/controller.go b/admin/controller.go index 0029f94..126cb94 100644 --- a/admin/controller.go +++ b/admin/controller.go @@ -133,27 +133,27 @@ func UserTypeAnalysisAPI(c *gin.Context) { func RedeemListAPI(c *gin.Context) { db := utils.GetDBFromContext(c) - c.JSON(http.StatusOK, GetRedeemData(db)) + + page, _ := strconv.Atoi(c.Query("page")) + c.JSON(http.StatusOK, GetRedeemData(db, int64(page))) } -func RedeemSegmentAPI(c *gin.Context) { - quota := utils.ParseFloat32(c.Query("quota")) - onlyUnused := utils.ParseBool(c.Query("unused")) - +func DeleteRedeemAPI(c *gin.Context) { db := utils.GetDBFromContext(c) - data, err := GetRedeemSegment(db, quota, onlyUnused) - if err != nil { + var form DeleteInvitationForm + if err := c.ShouldBindJSON(&form); err != nil { c.JSON(http.StatusOK, gin.H{ "status": false, "error": err.Error(), }) return - } + + err := DeleteRedeemCode(db, form.Code) c.JSON(http.StatusOK, gin.H{ - "status": true, - "data": data, + "status": err == nil, + "error": err, }) } diff --git a/admin/redeem.go b/admin/redeem.go index 66b778b..7a47dc5 100644 --- a/admin/redeem.go +++ b/admin/redeem.go @@ -5,60 +5,64 @@ import ( "chat/utils" "database/sql" "fmt" + "math" "strings" ) -func GetRedeemData(db *sql.DB) []RedeemData { - var data []RedeemData +func GetRedeemData(db *sql.DB, page int64) PaginationForm { + var data []interface{} + var total int64 + if err := globals.QueryRowDb(db, ` + SELECT COUNT(*) FROM redeem + `).Scan(&total); err != nil { + return PaginationForm{ + Status: false, + Message: err.Error(), + } + } rows, err := globals.QueryDb(db, ` - SELECT quota, COUNT(*) AS total, SUM(IF(used = 0, 0, 1)) AS used + SELECT code, quota, used, created_at, updated_at FROM redeem - GROUP BY quota - `) + ORDER BY id DESC LIMIT ? OFFSET ? + `, pagination, page*pagination) + if err != nil { - return data + return PaginationForm{ + Status: false, + Message: err.Error(), + } } for rows.Next() { - var d RedeemData - if err := rows.Scan(&d.Quota, &d.Total, &d.Used); err != nil { - return data + var redeem RedeemData + var createdAt []uint8 + var updatedAt []uint8 + if err := rows.Scan(&redeem.Code, &redeem.Quota, &redeem.Used, &createdAt, &updatedAt); err != nil { + return PaginationForm{ + Status: false, + Message: err.Error(), + } } - data = append(data, d) + + redeem.CreatedAt = utils.ConvertTime(createdAt).Format("2006-01-02 15:04:05") + redeem.UpdatedAt = utils.ConvertTime(updatedAt).Format("2006-01-02 15:04:05") + data = append(data, redeem) } - return data + return PaginationForm{ + Status: true, + Total: int(math.Ceil(float64(total) / float64(pagination))), + Data: data, + } } -func GetRedeemSegment(db *sql.DB, quota float32, onlyUnused bool) ([]string, error) { - var codes []string - var rows *sql.Rows - var err error +func DeleteRedeemCode(db *sql.DB, code string) error { + _, err := globals.ExecDb(db, ` + DELETE FROM redeem WHERE code = ? + `, code) - if onlyUnused { - rows, err = globals.QueryDb(db, ` - SELECT code FROM redeem WHERE quota = ? AND used = 0 - `, quota) - } else { - rows, err = globals.QueryDb(db, ` - SELECT code FROM redeem WHERE quota = ? - `, quota) - } - - if err != nil { - return codes, err - } - - for rows.Next() { - var code string - if err := rows.Scan(&code); err != nil { - return codes, err - } - codes = append(codes, code) - } - - return codes, nil + return err } func GenerateRedeemCodes(db *sql.DB, num int, quota float32) RedeemGenerateResponse { diff --git a/admin/router.go b/admin/router.go index b545347..91e8d51 100644 --- a/admin/router.go +++ b/admin/router.go @@ -20,8 +20,8 @@ func Register(app *gin.RouterGroup) { app.POST("/admin/invitation/delete", DeleteInvitationAPI) app.GET("/admin/redeem/list", RedeemListAPI) - app.GET("/admin/redeem/segment", RedeemSegmentAPI) app.POST("/admin/redeem/generate", GenerateRedeemAPI) + app.POST("/admin/redeem/delete", DeleteRedeemAPI) app.GET("/admin/user/list", UserPaginationAPI) app.POST("/admin/user/quota", UserQuotaAPI) diff --git a/admin/types.go b/admin/types.go index 9354a40..1dc1aa4 100644 --- a/admin/types.go +++ b/admin/types.go @@ -51,9 +51,11 @@ type InvitationData struct { } type RedeemData struct { - Quota float32 `json:"quota"` - Used float32 `json:"used"` - Total float32 `json:"total"` + Code string `json:"code"` + Quota float32 `json:"quota"` + Used bool `json:"used"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type InvitationGenerateResponse struct { diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index bcf2004..0649eb1 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "chatnio", - "version": "3.10.5" + "version": "3.10.6" }, "tauri": { "allowlist": { diff --git a/app/src/admin/api/chart.ts b/app/src/admin/api/chart.ts index ee6bb6b..57c719b 100644 --- a/app/src/admin/api/chart.ts +++ b/app/src/admin/api/chart.ts @@ -128,25 +128,22 @@ export async function generateInvitation( } } -export async function getRedeemList(): Promise { +export async function getRedeemList(page: number): Promise { try { - const response = await axios.get("/admin/redeem/list"); + const response = await axios.get(`/admin/redeem/list?page=${page}`); return response.data as RedeemResponse; } catch (e) { console.warn(e); - return []; + return { status: false, message: getErrorMessage(e), data: [], total: 0 }; } } -export async function getRedeemSegment(quota: number, only_unused: boolean) { +export async function deleteRedeem(code: string): Promise { try { - const response = await axios.get( - `/admin/redeem/segment?quota=${quota}&unused=${only_unused}`, - ); - return response.data as RedeemResponse; + const response = await axios.post("/admin/redeem/delete", { code }); + return response.data as CommonResponse; } catch (e) { - console.warn(e); - return []; + return { status: false, message: getErrorMessage(e) }; } } diff --git a/app/src/admin/types.ts b/app/src/admin/types.ts index bc443ad..a1d18d2 100644 --- a/app/src/admin/types.ts +++ b/app/src/admin/types.ts @@ -65,14 +65,21 @@ export type InvitationResponse = { }; export type Redeem = { + code: string; quota: number; used: boolean; + created_at: string; + updated_at: string; +}; + +export type RedeemForm = { + data: Redeem[]; total: number; }; -export type RedeemResponse = Redeem[]; -export type RedeemSegmentResponse = CommonResponse & { - data: string[]; +export type RedeemResponse = CommonResponse & { + data: Redeem[]; + total: number; }; export type InvitationGenerateResponse = { diff --git a/app/src/assets/globals.less b/app/src/assets/globals.less index 46cd754..e001f1b 100644 --- a/app/src/assets/globals.less +++ b/app/src/assets/globals.less @@ -35,6 +35,12 @@ --destructive: 0 67.22% 50.59%; --destructive-foreground: 0 0% 98%; + --success: 120 100% 50%; + --success-foreground: 0 0% 98%; + + --failure: 0 67.22% 50.59%; + --failure-foreground: 0 0% 98%; + --border: 240 5.9% 90%; --border-hover: 240 5.9% 85%; --border-active: 240 5.9% 80%; diff --git a/app/src/components/admin/InvitationTable.tsx b/app/src/components/admin/InvitationTable.tsx index 9cef0a1..419f84c 100644 --- a/app/src/components/admin/InvitationTable.tsx +++ b/app/src/components/admin/InvitationTable.tsx @@ -34,6 +34,7 @@ import { PaginationAction } from "@/components/ui/pagination.tsx"; import { Badge } from "@/components/ui/badge.tsx"; import OperationAction from "@/components/OperationAction.tsx"; import { toastState } from "@/api/common.ts"; +import StateBadge from "@/components/admin/common/StateBadge.tsx"; function GenerateDialog({ update }: { update: () => void }) { const { t } = useTranslation(); @@ -191,7 +192,9 @@ function InvitationTable() { {invitation.type} - {t(`admin.used-${invitation.used}`)} + + + {invitation.username || "-"} {invitation.created_at} {invitation.updated_at} diff --git a/app/src/components/admin/RedeemTable.tsx b/app/src/components/admin/RedeemTable.tsx index d883195..6414a7c 100644 --- a/app/src/components/admin/RedeemTable.tsx +++ b/app/src/components/admin/RedeemTable.tsx @@ -17,18 +17,26 @@ import { } from "@/components/ui/dialog.tsx"; import { useTranslation } from "react-i18next"; import { useState } from "react"; -import { RedeemResponse } from "@/admin/types.ts"; -import { Button } from "@/components/ui/button.tsx"; -import { Download, Loader2, RotateCw } from "lucide-react"; -import { generateRedeem, getRedeemList } from "@/admin/api/chart.ts"; +import { RedeemForm, RedeemResponse } from "@/admin/types.ts"; +import { Button, TemporaryButton } from "@/components/ui/button.tsx"; +import { Copy, Download, Loader2, RotateCw, Trash } from "lucide-react"; +import { + deleteRedeem, + generateRedeem, + getRedeemList, +} from "@/admin/api/chart.ts"; import { Input } from "@/components/ui/input.tsx"; import { useToast } from "@/components/ui/use-toast.ts"; import { Textarea } from "@/components/ui/textarea.tsx"; -import { saveAsFile } from "@/utils/dom.ts"; +import { copyClipboard, saveAsFile } from "@/utils/dom.ts"; import { useEffectAsync } from "@/utils/hook.ts"; import { Badge } from "@/components/ui/badge.tsx"; +import { PaginationAction } from "@/components/ui/pagination.tsx"; +import OperationAction from "@/components/OperationAction.tsx"; +import { toastState } from "@/api/common.ts"; +import StateBadge from "@/components/admin/common/StateBadge.tsx"; -function GenerateDialog({ sync }: { sync: () => void }) { +function GenerateDialog({ update }: { update: () => void }) { const { t } = useTranslation(); const { toast } = useToast(); const [open, setOpen] = useState(false); @@ -44,7 +52,7 @@ function GenerateDialog({ sync }: { sync: () => void }) { const data = await generateRedeem(Number(quota), Number(number)); if (data.status) { setData(data.data.join("\n")); - sync(); + update(); } else { toast({ title: t("admin.error"), @@ -75,14 +83,14 @@ function GenerateDialog({ sync }: { sync: () => void }) { {t("admin.generate")} -
+

{t("admin.quota")}

setQuota(getNumber(e.target.value))} />
-
+

{t("admin.number")}

void }) { function RedeemTable() { const { t } = useTranslation(); - const [data, setData] = useState([]); + const { toast } = useToast(); + const [data, setData] = useState({ + total: 0, + data: [], + }); const [loading, setLoading] = useState(false); + const [page, setPage] = useState(0); - const sync = async () => { + async function update() { setLoading(true); - const resp = await getRedeemList(); + const resp = await getRedeemList(page); setLoading(false); - setData(resp ?? []); - }; - - useEffectAsync(sync, []); + if (resp.status) setData(resp as RedeemResponse); + else + toast({ + title: t("admin.error"), + description: resp.message, + }); + } + useEffectAsync(update, [page]); return (
- {data.length > 0 ? ( + {(data.data && data.data.length > 0) || page > 0 ? ( <> + {t("admin.redeem.code")} {t("admin.redeem.quota")} - {t("admin.redeem.total")} - {t("admin.redeem.used")} + {t("admin.used")} + {t("admin.created-at")} + {t("admin.used-at")} + {t("admin.action")} - {data.map((redeem, idx) => ( + {(data.data || []).map((redeem, idx) => ( + {redeem.code} {redeem.quota} - {redeem.total} - {redeem.used} + + + + {redeem.created_at} + {redeem.updated_at} + + copyClipboard(redeem.code)} + > + + + { + const resp = await deleteRedeem(redeem.code); + toastState(toast, t, resp, true); + + resp.status && (await update()); + }} + > + + + ))}
+ ) : loading ? (
@@ -177,10 +229,10 @@ function RedeemTable() { )}
- - +
); diff --git a/app/src/components/admin/common/StateBadge.tsx b/app/src/components/admin/common/StateBadge.tsx new file mode 100644 index 0000000..0e8482d --- /dev/null +++ b/app/src/components/admin/common/StateBadge.tsx @@ -0,0 +1,12 @@ +import { Badge } from "@/components/ui/badge.tsx"; +import { useTranslation } from "react-i18next"; + +export type StateBadgeProps = { + state: boolean; +}; + +export default function StateBadge({ state }: StateBadgeProps) { + const { t } = useTranslation(); + + return {t(`admin.used-${state}`)}; +} diff --git a/app/src/conf/bootstrap.ts b/app/src/conf/bootstrap.ts index b6f0cba..ec68607 100644 --- a/app/src/conf/bootstrap.ts +++ b/app/src/conf/bootstrap.ts @@ -7,7 +7,7 @@ import { import { syncSiteInfo } from "@/admin/api/info.ts"; import { setAxiosConfig } from "@/conf/api.ts"; -export const version = "3.10.5"; // version of the current build +export const version = "3.10.6"; // version of the current build export const dev: boolean = getDev(); // is in development mode (for debugging, in localhost origin) export const deploy: boolean = true; // is production environment (for api endpoint) export const tokenField = getTokenField(deploy); // token field name for storing token diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index 2256705..40fa80e 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -547,7 +547,8 @@ "redeem": { "quota": "点数", "used": "已用个数", - "total": "总个数" + "total": "总个数", + "code": "兑换码" }, "plan": { "enable": "启用订阅", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index 3a2fef2..279c090 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -571,7 +571,8 @@ "redeem": { "quota": "Number of points", "used": "Used Count", - "total": "Total" + "total": "Total", + "code": "Code" }, "market": { "title": "Marketplace", diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index 6f70f5d..0b42839 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -571,7 +571,8 @@ "redeem": { "quota": "Whirlies", "used": "使用数", - "total": "合計" + "total": "合計", + "code": "コードを利用する" }, "market": { "title": "モデルマーケット", diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index 242b8fe..d651635 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -571,7 +571,8 @@ "redeem": { "quota": "Вихри", "used": "Количество использованных", - "total": "Итого" + "total": "Итого", + "code": "Промокод" }, "market": { "title": "Модельный рынок", diff --git a/app/tailwind.config.js b/app/tailwind.config.js index f1cd66d..c272f9d 100644 --- a/app/tailwind.config.js +++ b/app/tailwind.config.js @@ -46,6 +46,14 @@ module.exports = { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, + success: { + DEFAULT: "hsl(var(--success))", + foreground: "hsl(var(--success-foreground))", + }, + failure: { + DEFAULT: "hsl(var(--failure))", + foreground: "hsl(var(--failure-foreground))", + }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", diff --git a/middleware/throttle.go b/middleware/throttle.go index 37c29c1..994a267 100644 --- a/middleware/throttle.go +++ b/middleware/throttle.go @@ -22,8 +22,8 @@ func (l *Limiter) RateLimit(client *redis.Client, ip string, path string) (bool, var limits = map[string]Limiter{ "/login": {Duration: 10, Count: 20}, - "/register": {Duration: 600, Count: 5}, - "/verify": {Duration: 120, Count: 5}, + "/register": {Duration: 120, Count: 10}, + "/verify": {Duration: 120, Count: 10}, "/reset": {Duration: 120, Count: 10}, "/apikey": {Duration: 1, Count: 2}, "/resetkey": {Duration: 3600, Count: 3}, @@ -36,7 +36,6 @@ var limits = map[string]Limiter{ "/conversation": {Duration: 1, Count: 5}, "/invite": {Duration: 7200, Count: 20}, "/redeem": {Duration: 1200, Count: 60}, - "/v1": {Duration: 1, Count: 600}, "/dashboard": {Duration: 1, Count: 5}, "/card": {Duration: 1, Count: 5}, "/generation": {Duration: 1, Count: 5},