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) {
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,
})
}

View File

@ -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 {

View File

@ -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)

View File

@ -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 {

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "chatnio",
"version": "3.10.5"
"version": "3.10.6"
},
"tauri": {
"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 {
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<CommonResponse> {
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) };
}
}

View File

@ -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 = {

View File

@ -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%;

View File

@ -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() {
<TableCell>
<Badge>{invitation.type}</Badge>
</TableCell>
<TableCell>{t(`admin.used-${invitation.used}`)}</TableCell>
<TableCell>
<StateBadge state={invitation.used} />
</TableCell>
<TableCell>{invitation.username || "-"}</TableCell>
<TableCell>{invitation.created_at}</TableCell>
<TableCell>{invitation.updated_at}</TableCell>

View File

@ -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<boolean>(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 }) {
<DialogHeader>
<DialogTitle>{t("admin.generate")}</DialogTitle>
<DialogDescription className={`pt-2`}>
<div className={`invitation-row`}>
<div className={`redeem-row`}>
<p className={`mr-4`}>{t("admin.quota")}</p>
<Input
value={quota}
onChange={(e) => setQuota(getNumber(e.target.value))}
/>
</div>
<div className={`invitation-row`}>
<div className={`redeem-row`}>
<p className={`mr-4`}>{t("admin.number")}</p>
<Input
value={number}
@ -131,42 +139,86 @@ function GenerateDialog({ sync }: { sync: () => void }) {
function RedeemTable() {
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 [page, setPage] = useState<number>(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 (
<div className={`redeem-table`}>
{data.length > 0 ? (
{(data.data && data.data.length > 0) || page > 0 ? (
<>
<Table>
<TableHeader>
<TableRow className={`select-none whitespace-nowrap`}>
<TableHead>{t("admin.redeem.code")}</TableHead>
<TableHead>{t("admin.redeem.quota")}</TableHead>
<TableHead>{t("admin.redeem.total")}</TableHead>
<TableHead>{t("admin.redeem.used")}</TableHead>
<TableHead>{t("admin.used")}</TableHead>
<TableHead>{t("admin.created-at")}</TableHead>
<TableHead>{t("admin.used-at")}</TableHead>
<TableHead>{t("admin.action")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((redeem, idx) => (
{(data.data || []).map((redeem, idx) => (
<TableRow key={idx} className={`whitespace-nowrap`}>
<TableCell>{redeem.code}</TableCell>
<TableCell>
<Badge variant={`outline`}>{redeem.quota}</Badge>
</TableCell>
<TableCell>{redeem.total}</TableCell>
<TableCell>{redeem.used}</TableCell>
<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>
))}
</TableBody>
</Table>
<PaginationAction
current={page}
total={data.total}
onPageChange={setPage}
offset
/>
</>
) : loading ? (
<div className={`flex flex-col my-4 items-center`}>
@ -177,10 +229,10 @@ function RedeemTable() {
)}
<div className={`redeem-action`}>
<div className={`grow`} />
<Button variant={`outline`} size={`icon`} onClick={sync}>
<Button variant={`outline`} size={`icon`} onClick={update}>
<RotateCw className={`h-4 w-4`} />
</Button>
<GenerateDialog sync={sync} />
<GenerateDialog update={update} />
</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 { 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

View File

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

View File

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

View File

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

View File

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

View File

@ -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))",

View File

@ -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},