feat: support redeem code operation (#90)

This commit is contained in:
Zhang Minghan 2024-03-11 21:24:14 +08:00
parent 296c1f8781
commit 6f5dae5ca9
18 changed files with 193 additions and 99 deletions

View File

@ -133,27 +133,27 @@ func UserTypeAnalysisAPI(c *gin.Context) {
func RedeemListAPI(c *gin.Context) { func RedeemListAPI(c *gin.Context) {
db := utils.GetDBFromContext(c) 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) { func DeleteRedeemAPI(c *gin.Context) {
quota := utils.ParseFloat32(c.Query("quota"))
onlyUnused := utils.ParseBool(c.Query("unused"))
db := utils.GetDBFromContext(c) db := utils.GetDBFromContext(c)
data, err := GetRedeemSegment(db, quota, onlyUnused) var form DeleteInvitationForm
if err != nil { if err := c.ShouldBindJSON(&form); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": false, "status": false,
"error": err.Error(), "error": err.Error(),
}) })
return return
} }
err := DeleteRedeemCode(db, form.Code)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": true, "status": err == nil,
"data": data, "error": err,
}) })
} }

View File

@ -5,60 +5,64 @@ import (
"chat/utils" "chat/utils"
"database/sql" "database/sql"
"fmt" "fmt"
"math"
"strings" "strings"
) )
func GetRedeemData(db *sql.DB) []RedeemData { func GetRedeemData(db *sql.DB, page int64) PaginationForm {
var data []RedeemData 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, ` 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 FROM redeem
GROUP BY quota ORDER BY id DESC LIMIT ? OFFSET ?
`) `, pagination, page*pagination)
if err != nil { if err != nil {
return data return PaginationForm{
Status: false,
Message: err.Error(),
}
} }
for rows.Next() { for rows.Next() {
var d RedeemData var redeem RedeemData
if err := rows.Scan(&d.Quota, &d.Total, &d.Used); err != nil { var createdAt []uint8
return data 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)
} }
return data 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 PaginationForm{
Status: true,
Total: int(math.Ceil(float64(total) / float64(pagination))),
Data: data,
}
} }
func GetRedeemSegment(db *sql.DB, quota float32, onlyUnused bool) ([]string, error) { func DeleteRedeemCode(db *sql.DB, code string) error {
var codes []string _, err := globals.ExecDb(db, `
var rows *sql.Rows DELETE FROM redeem WHERE code = ?
var err error `, code)
if onlyUnused { return err
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
} }
func GenerateRedeemCodes(db *sql.DB, num int, quota float32) RedeemGenerateResponse { func GenerateRedeemCodes(db *sql.DB, num int, quota float32) RedeemGenerateResponse {

View File

@ -20,8 +20,8 @@ func Register(app *gin.RouterGroup) {
app.POST("/admin/invitation/delete", DeleteInvitationAPI) app.POST("/admin/invitation/delete", DeleteInvitationAPI)
app.GET("/admin/redeem/list", RedeemListAPI) app.GET("/admin/redeem/list", RedeemListAPI)
app.GET("/admin/redeem/segment", RedeemSegmentAPI)
app.POST("/admin/redeem/generate", GenerateRedeemAPI) app.POST("/admin/redeem/generate", GenerateRedeemAPI)
app.POST("/admin/redeem/delete", DeleteRedeemAPI)
app.GET("/admin/user/list", UserPaginationAPI) app.GET("/admin/user/list", UserPaginationAPI)
app.POST("/admin/user/quota", UserQuotaAPI) app.POST("/admin/user/quota", UserQuotaAPI)

View File

@ -51,9 +51,11 @@ type InvitationData struct {
} }
type RedeemData struct { type RedeemData struct {
Code string `json:"code"`
Quota float32 `json:"quota"` Quota float32 `json:"quota"`
Used float32 `json:"used"` Used bool `json:"used"`
Total float32 `json:"total"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
} }
type InvitationGenerateResponse struct { type InvitationGenerateResponse struct {

View File

@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "chatnio", "productName": "chatnio",
"version": "3.10.5" "version": "3.10.6"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {

View File

@ -128,25 +128,22 @@ export async function generateInvitation(
} }
} }
export async function getRedeemList(): Promise<RedeemResponse> { export async function getRedeemList(page: number): Promise<RedeemResponse> {
try { try {
const response = await axios.get("/admin/redeem/list"); const response = await axios.get(`/admin/redeem/list?page=${page}`);
return response.data as RedeemResponse; return response.data as RedeemResponse;
} catch (e) { } catch (e) {
console.warn(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<CommonResponse> {
try { try {
const response = await axios.get( const response = await axios.post("/admin/redeem/delete", { code });
`/admin/redeem/segment?quota=${quota}&unused=${only_unused}`, return response.data as CommonResponse;
);
return response.data as RedeemResponse;
} catch (e) { } catch (e) {
console.warn(e); return { status: false, message: getErrorMessage(e) };
return [];
} }
} }

View File

@ -65,14 +65,21 @@ export type InvitationResponse = {
}; };
export type Redeem = { export type Redeem = {
code: string;
quota: number; quota: number;
used: boolean; used: boolean;
created_at: string;
updated_at: string;
};
export type RedeemForm = {
data: Redeem[];
total: number; total: number;
}; };
export type RedeemResponse = Redeem[]; export type RedeemResponse = CommonResponse & {
export type RedeemSegmentResponse = CommonResponse & { data: Redeem[];
data: string[]; total: number;
}; };
export type InvitationGenerateResponse = { export type InvitationGenerateResponse = {

View File

@ -35,6 +35,12 @@
--destructive: 0 67.22% 50.59%; --destructive: 0 67.22% 50.59%;
--destructive-foreground: 0 0% 98%; --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: 240 5.9% 90%;
--border-hover: 240 5.9% 85%; --border-hover: 240 5.9% 85%;
--border-active: 240 5.9% 80%; --border-active: 240 5.9% 80%;

View File

@ -34,6 +34,7 @@ import { PaginationAction } from "@/components/ui/pagination.tsx";
import { Badge } from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge.tsx";
import OperationAction from "@/components/OperationAction.tsx"; import OperationAction from "@/components/OperationAction.tsx";
import { toastState } from "@/api/common.ts"; import { toastState } from "@/api/common.ts";
import StateBadge from "@/components/admin/common/StateBadge.tsx";
function GenerateDialog({ update }: { update: () => void }) { function GenerateDialog({ update }: { update: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -191,7 +192,9 @@ function InvitationTable() {
<TableCell> <TableCell>
<Badge>{invitation.type}</Badge> <Badge>{invitation.type}</Badge>
</TableCell> </TableCell>
<TableCell>{t(`admin.used-${invitation.used}`)}</TableCell> <TableCell>
<StateBadge state={invitation.used} />
</TableCell>
<TableCell>{invitation.username || "-"}</TableCell> <TableCell>{invitation.username || "-"}</TableCell>
<TableCell>{invitation.created_at}</TableCell> <TableCell>{invitation.created_at}</TableCell>
<TableCell>{invitation.updated_at}</TableCell> <TableCell>{invitation.updated_at}</TableCell>

View File

@ -17,18 +17,26 @@ import {
} from "@/components/ui/dialog.tsx"; } from "@/components/ui/dialog.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import { RedeemResponse } from "@/admin/types.ts"; import { RedeemForm, RedeemResponse } from "@/admin/types.ts";
import { Button } from "@/components/ui/button.tsx"; import { Button, TemporaryButton } from "@/components/ui/button.tsx";
import { Download, Loader2, RotateCw } from "lucide-react"; import { Copy, Download, Loader2, RotateCw, Trash } from "lucide-react";
import { generateRedeem, getRedeemList } from "@/admin/api/chart.ts"; import {
deleteRedeem,
generateRedeem,
getRedeemList,
} from "@/admin/api/chart.ts";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { useToast } from "@/components/ui/use-toast.ts"; import { useToast } from "@/components/ui/use-toast.ts";
import { Textarea } from "@/components/ui/textarea.tsx"; 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 { useEffectAsync } from "@/utils/hook.ts";
import { Badge } from "@/components/ui/badge.tsx"; 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 { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
@ -44,7 +52,7 @@ function GenerateDialog({ sync }: { sync: () => void }) {
const data = await generateRedeem(Number(quota), Number(number)); const data = await generateRedeem(Number(quota), Number(number));
if (data.status) { if (data.status) {
setData(data.data.join("\n")); setData(data.data.join("\n"));
sync(); update();
} else { } else {
toast({ toast({
title: t("admin.error"), title: t("admin.error"),
@ -75,14 +83,14 @@ function GenerateDialog({ sync }: { sync: () => void }) {
<DialogHeader> <DialogHeader>
<DialogTitle>{t("admin.generate")}</DialogTitle> <DialogTitle>{t("admin.generate")}</DialogTitle>
<DialogDescription className={`pt-2`}> <DialogDescription className={`pt-2`}>
<div className={`invitation-row`}> <div className={`redeem-row`}>
<p className={`mr-4`}>{t("admin.quota")}</p> <p className={`mr-4`}>{t("admin.quota")}</p>
<Input <Input
value={quota} value={quota}
onChange={(e) => setQuota(getNumber(e.target.value))} onChange={(e) => setQuota(getNumber(e.target.value))}
/> />
</div> </div>
<div className={`invitation-row`}> <div className={`redeem-row`}>
<p className={`mr-4`}>{t("admin.number")}</p> <p className={`mr-4`}>{t("admin.number")}</p>
<Input <Input
value={number} value={number}
@ -131,42 +139,86 @@ function GenerateDialog({ sync }: { sync: () => void }) {
function RedeemTable() { function RedeemTable() {
const { t } = useTranslation(); const { t } = useTranslation();
const [data, setData] = useState<RedeemResponse>([]); const { toast } = useToast();
const [data, setData] = useState<RedeemForm>({
total: 0,
data: [],
});
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [page, setPage] = useState<number>(0);
const sync = async () => { async function update() {
setLoading(true); setLoading(true);
const resp = await getRedeemList(); const resp = await getRedeemList(page);
setLoading(false); setLoading(false);
setData(resp ?? []); if (resp.status) setData(resp as RedeemResponse);
}; else
toast({
useEffectAsync(sync, []); title: t("admin.error"),
description: resp.message,
});
}
useEffectAsync(update, [page]);
return ( return (
<div className={`redeem-table`}> <div className={`redeem-table`}>
{data.length > 0 ? ( {(data.data && data.data.length > 0) || page > 0 ? (
<> <>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className={`select-none whitespace-nowrap`}> <TableRow className={`select-none whitespace-nowrap`}>
<TableHead>{t("admin.redeem.code")}</TableHead>
<TableHead>{t("admin.redeem.quota")}</TableHead> <TableHead>{t("admin.redeem.quota")}</TableHead>
<TableHead>{t("admin.redeem.total")}</TableHead> <TableHead>{t("admin.used")}</TableHead>
<TableHead>{t("admin.redeem.used")}</TableHead> <TableHead>{t("admin.created-at")}</TableHead>
<TableHead>{t("admin.used-at")}</TableHead>
<TableHead>{t("admin.action")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((redeem, idx) => ( {(data.data || []).map((redeem, idx) => (
<TableRow key={idx} className={`whitespace-nowrap`}> <TableRow key={idx} className={`whitespace-nowrap`}>
<TableCell>{redeem.code}</TableCell>
<TableCell> <TableCell>
<Badge variant={`outline`}>{redeem.quota}</Badge> <Badge variant={`outline`}>{redeem.quota}</Badge>
</TableCell> </TableCell>
<TableCell>{redeem.total}</TableCell> <TableCell>
<TableCell>{redeem.used}</TableCell> <StateBadge state={redeem.used} />
</TableCell>
<TableCell>{redeem.created_at}</TableCell>
<TableCell>{redeem.updated_at}</TableCell>
<TableCell className={`flex gap-2`}>
<TemporaryButton
size={`icon`}
variant={`outline`}
onClick={() => copyClipboard(redeem.code)}
>
<Copy className={`h-4 w-4`} />
</TemporaryButton>
<OperationAction
native
tooltip={t("delete")}
variant={`destructive`}
onClick={async () => {
const resp = await deleteRedeem(redeem.code);
toastState(toast, t, resp, true);
resp.status && (await update());
}}
>
<Trash className={`h-4 w-4`} />
</OperationAction>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<PaginationAction
current={page}
total={data.total}
onPageChange={setPage}
offset
/>
</> </>
) : loading ? ( ) : loading ? (
<div className={`flex flex-col my-4 items-center`}> <div className={`flex flex-col my-4 items-center`}>
@ -177,10 +229,10 @@ function RedeemTable() {
)} )}
<div className={`redeem-action`}> <div className={`redeem-action`}>
<div className={`grow`} /> <div className={`grow`} />
<Button variant={`outline`} size={`icon`} onClick={sync}> <Button variant={`outline`} size={`icon`} onClick={update}>
<RotateCw className={`h-4 w-4`} /> <RotateCw className={`h-4 w-4`} />
</Button> </Button>
<GenerateDialog sync={sync} /> <GenerateDialog update={update} />
</div> </div>
</div> </div>
); );

View File

@ -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 <Badge variant="outline">{t(`admin.used-${state}`)}</Badge>;
}

View File

@ -7,7 +7,7 @@ import {
import { syncSiteInfo } from "@/admin/api/info.ts"; import { syncSiteInfo } from "@/admin/api/info.ts";
import { setAxiosConfig } from "@/conf/api.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 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 deploy: boolean = true; // is production environment (for api endpoint)
export const tokenField = getTokenField(deploy); // token field name for storing token export const tokenField = getTokenField(deploy); // token field name for storing token

View File

@ -547,7 +547,8 @@
"redeem": { "redeem": {
"quota": "点数", "quota": "点数",
"used": "已用个数", "used": "已用个数",
"total": "总个数" "total": "总个数",
"code": "兑换码"
}, },
"plan": { "plan": {
"enable": "启用订阅", "enable": "启用订阅",

View File

@ -571,7 +571,8 @@
"redeem": { "redeem": {
"quota": "Number of points", "quota": "Number of points",
"used": "Used Count", "used": "Used Count",
"total": "Total" "total": "Total",
"code": "Code"
}, },
"market": { "market": {
"title": "Marketplace", "title": "Marketplace",

View File

@ -571,7 +571,8 @@
"redeem": { "redeem": {
"quota": "Whirlies", "quota": "Whirlies",
"used": "使用数", "used": "使用数",
"total": "合計" "total": "合計",
"code": "コードを利用する"
}, },
"market": { "market": {
"title": "モデルマーケット", "title": "モデルマーケット",

View File

@ -571,7 +571,8 @@
"redeem": { "redeem": {
"quota": "Вихри", "quota": "Вихри",
"used": "Количество использованных", "used": "Количество использованных",
"total": "Итого" "total": "Итого",
"code": "Промокод"
}, },
"market": { "market": {
"title": "Модельный рынок", "title": "Модельный рынок",

View File

@ -46,6 +46,14 @@ module.exports = {
DEFAULT: "hsl(var(--destructive))", DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))", 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: { muted: {
DEFAULT: "hsl(var(--muted))", DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))", foreground: "hsl(var(--muted-foreground))",

View File

@ -22,8 +22,8 @@ func (l *Limiter) RateLimit(client *redis.Client, ip string, path string) (bool,
var limits = map[string]Limiter{ var limits = map[string]Limiter{
"/login": {Duration: 10, Count: 20}, "/login": {Duration: 10, Count: 20},
"/register": {Duration: 600, Count: 5}, "/register": {Duration: 120, Count: 10},
"/verify": {Duration: 120, Count: 5}, "/verify": {Duration: 120, Count: 10},
"/reset": {Duration: 120, Count: 10}, "/reset": {Duration: 120, Count: 10},
"/apikey": {Duration: 1, Count: 2}, "/apikey": {Duration: 1, Count: 2},
"/resetkey": {Duration: 3600, Count: 3}, "/resetkey": {Duration: 3600, Count: 3},
@ -36,7 +36,6 @@ var limits = map[string]Limiter{
"/conversation": {Duration: 1, Count: 5}, "/conversation": {Duration: 1, Count: 5},
"/invite": {Duration: 7200, Count: 20}, "/invite": {Duration: 7200, Count: 20},
"/redeem": {Duration: 1200, Count: 60}, "/redeem": {Duration: 1200, Count: 60},
"/v1": {Duration: 1, Count: 600},
"/dashboard": {Duration: 1, Count: 5}, "/dashboard": {Duration: 1, Count: 5},
"/card": {Duration: 1, Count: 5}, "/card": {Duration: 1, Count: 5},
"/generation": {Duration: 1, Count: 5}, "/generation": {Duration: 1, Count: 5},