mirror of
https://github.com/coaidev/coai.git
synced 2025-05-19 04:50:14 +09:00
feat: support redeem code operation (#90)
This commit is contained in:
parent
296c1f8781
commit
6f5dae5ca9
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "chatnio",
|
||||
"version": "3.10.5"
|
||||
"version": "3.10.6"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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%;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
12
app/src/components/admin/common/StateBadge.tsx
Normal file
12
app/src/components/admin/common/StateBadge.tsx
Normal 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>;
|
||||
}
|
@ -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
|
||||
|
@ -547,7 +547,8 @@
|
||||
"redeem": {
|
||||
"quota": "点数",
|
||||
"used": "已用个数",
|
||||
"total": "总个数"
|
||||
"total": "总个数",
|
||||
"code": "兑换码"
|
||||
},
|
||||
"plan": {
|
||||
"enable": "启用订阅",
|
||||
|
@ -571,7 +571,8 @@
|
||||
"redeem": {
|
||||
"quota": "Number of points",
|
||||
"used": "Used Count",
|
||||
"total": "Total"
|
||||
"total": "Total",
|
||||
"code": "Code"
|
||||
},
|
||||
"market": {
|
||||
"title": "Marketplace",
|
||||
|
@ -571,7 +571,8 @@
|
||||
"redeem": {
|
||||
"quota": "Whirlies",
|
||||
"used": "使用数",
|
||||
"total": "合計"
|
||||
"total": "合計",
|
||||
"code": "コードを利用する"
|
||||
},
|
||||
"market": {
|
||||
"title": "モデルマーケット",
|
||||
|
@ -571,7 +571,8 @@
|
||||
"redeem": {
|
||||
"quota": "Вихри",
|
||||
"used": "Количество использованных",
|
||||
"total": "Итого"
|
||||
"total": "Итого",
|
||||
"code": "Промокод"
|
||||
},
|
||||
"market": {
|
||||
"title": "Модельный рынок",
|
||||
|
@ -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))",
|
||||
|
@ -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},
|
||||
|
Loading…
Reference in New Issue
Block a user