diff --git a/README.md b/README.md index 98acf24..03db9e7 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ chatnio 版本更新: ```shell - docker-compose pull chatnio # pull latest image + docker-compose down + docker-compose pull # pull latest image + docker-compose up -d # start service in background ``` > - MySQL 数据库挂载目录项目 ~/**db** diff --git a/admin/controller.go b/admin/controller.go index d0c21d3..ab7cf23 100644 --- a/admin/controller.go +++ b/admin/controller.go @@ -14,6 +14,11 @@ type GenerateInvitationForm struct { Number int `json:"number"` } +type GenerateRedeemForm struct { + Quota float32 `json:"quota"` + Number int `json:"number"` +} + type QuotaOperationForm struct { Id int64 `json:"id"` Quota float32 `json:"quota"` @@ -55,6 +60,11 @@ func ErrorAnalysisAPI(c *gin.Context) { c.JSON(http.StatusOK, GetErrorData(cache)) } +func RedeemListAPI(c *gin.Context) { + db := utils.GetDBFromContext(c) + c.JSON(http.StatusOK, GetRedeemData(db)) +} + func InvitationPaginationAPI(c *gin.Context) { db := utils.GetDBFromContext(c) @@ -77,6 +87,21 @@ func GenerateInvitationAPI(c *gin.Context) { c.JSON(http.StatusOK, GenerateInvitations(db, form.Number, form.Quota, form.Type)) } +func GenerateRedeemAPI(c *gin.Context) { + db := utils.GetDBFromContext(c) + + var form GenerateRedeemForm + if err := c.ShouldBindJSON(&form); err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, GenerateRedeemCodes(db, form.Number, form.Quota)) +} + func UserPaginationAPI(c *gin.Context) { db := utils.GetDBFromContext(c) diff --git a/admin/redeem.go b/admin/redeem.go new file mode 100644 index 0000000..d2b3336 --- /dev/null +++ b/admin/redeem.go @@ -0,0 +1,67 @@ +package admin + +import ( + "chat/utils" + "database/sql" + "fmt" + "strings" +) + +func GetRedeemData(db *sql.DB) []RedeemData { + var data []RedeemData + + rows, err := db.Query(` + SELECT quota, COUNT(*) AS total, SUM(IF(used = 0, 0, 1)) AS used + FROM redeem + GROUP BY quota + `) + if err != nil { + return data + } + + for rows.Next() { + var d RedeemData + if err := rows.Scan(&d.Quota, &d.Total, &d.Used); err != nil { + return data + } + data = append(data, d) + } + + return data +} + +func GenerateRedeemCodes(db *sql.DB, num int, quota float32) RedeemGenerateResponse { + arr := make([]string, 0) + idx := 0 + for idx < num { + code, err := CreateRedeemCode(db, quota) + + if err != nil { + return RedeemGenerateResponse{ + Status: false, + Message: err.Error(), + } + } + arr = append(arr, code) + idx++ + } + + return RedeemGenerateResponse{ + Status: true, + Data: arr, + } +} + +func CreateRedeemCode(db *sql.DB, quota float32) (string, error) { + code := fmt.Sprintf("nio-%s", utils.GenerateChar(32)) + _, err := db.Exec(` + INSERT INTO redeem (code, quota) VALUES (?, ?) + `, code, quota) + + if err != nil && strings.Contains(err.Error(), "Duplicate entry") { + // code name is duplicate + return CreateRedeemCode(db, quota) + } + + return code, err +} diff --git a/admin/router.go b/admin/router.go index 3557ff2..f8a2b86 100644 --- a/admin/router.go +++ b/admin/router.go @@ -17,6 +17,9 @@ func Register(app *gin.RouterGroup) { app.GET("/admin/invitation/list", InvitationPaginationAPI) app.POST("/admin/invitation/generate", GenerateInvitationAPI) + app.GET("/admin/redeem/list", RedeemListAPI) + app.POST("/admin/redeem/generate", GenerateRedeemAPI) + app.GET("/admin/user/list", UserPaginationAPI) app.POST("/admin/user/quota", UserQuotaAPI) app.POST("/admin/user/subscription", UserSubscriptionAPI) diff --git a/admin/types.go b/admin/types.go index 4786f2a..057f820 100644 --- a/admin/types.go +++ b/admin/types.go @@ -48,12 +48,24 @@ type InvitationData struct { UpdatedAt string `json:"updated_at"` } +type RedeemData struct { + Quota float32 `json:"quota"` + Used float32 `json:"used"` + Total float32 `json:"total"` +} + type InvitationGenerateResponse struct { Status bool `json:"status"` Message string `json:"message"` Data []string `json:"data"` } +type RedeemGenerateResponse struct { + Status bool `json:"status"` + Message string `json:"message"` + Data []string `json:"data"` +} + type UserData struct { Id int64 `json:"id"` Username string `json:"username"` diff --git a/app/src/admin/api/chart.ts b/app/src/admin/api/chart.ts index ba76324..801ad82 100644 --- a/app/src/admin/api/chart.ts +++ b/app/src/admin/api/chart.ts @@ -6,6 +6,7 @@ import { InvitationGenerateResponse, InvitationResponse, ModelChartResponse, + RedeemResponse, RequestChartResponse, UserResponse, } from "@/admin/types.ts"; @@ -94,6 +95,30 @@ export async function generateInvitation( return response.data as InvitationGenerateResponse; } +export async function getRedeemList(): Promise { + const response = await axios.get("/admin/redeem/list"); + if (response.status !== 200) { + return []; + } + + return response.data as RedeemResponse; +} + +export async function generateRedeem( + quota: number, + number: number, +): Promise { + const response = await axios.post("/admin/redeem/generate", { + quota, + number, + }); + if (response.status !== 200) { + return { status: false, data: [], message: "" }; + } + + return response.data as InvitationGenerateResponse; +} + export async function getUserList( page: number, search: string, diff --git a/app/src/admin/channel.ts b/app/src/admin/channel.ts index 6772457..934707c 100644 --- a/app/src/admin/channel.ts +++ b/app/src/admin/channel.ts @@ -35,7 +35,7 @@ export const ChannelTypes: Record = { bing: "New Bing", palm: "Google PaLM2", midjourney: "Midjourney", - oneapi: "One API", + oneapi: "Nio API", }; export const ChannelInfos: Record = { diff --git a/app/src/admin/types.ts b/app/src/admin/types.ts index ea5dbb7..52643ab 100644 --- a/app/src/admin/types.ts +++ b/app/src/admin/types.ts @@ -52,12 +52,26 @@ export type InvitationResponse = { total: number; }; +export type Redeem = { + quota: number; + used: boolean; + total: number; +}; + +export type RedeemResponse = Redeem[]; + export type InvitationGenerateResponse = { status: boolean; data: string[]; message: string; }; +export type RedeemGenerateResponse = { + status: boolean; + data: string[]; + message: string; +}; + export type UserData = { id: number; username: string; diff --git a/app/src/api/redeem.ts b/app/src/api/redeem.ts new file mode 100644 index 0000000..fd09d7c --- /dev/null +++ b/app/src/api/redeem.ts @@ -0,0 +1,22 @@ +import axios from "axios"; +import { getErrorMessage } from "@/utils/base.ts"; + +export type RedeemResponse = { + status: boolean; + error: string; + quota: number; +}; + +export async function useRedeem(code: string): Promise { + try { + const resp = await axios.get(`/redeem?code=${code}`); + return resp.data as RedeemResponse; + } catch (e) { + console.debug(e); + return { + status: false, + error: `network error: ${getErrorMessage(e)}`, + quota: 0, + }; + } +} diff --git a/app/src/assets/admin/management.less b/app/src/assets/admin/management.less index 8d0b165..d58cf61 100644 --- a/app/src/assets/admin/management.less +++ b/app/src/assets/admin/management.less @@ -46,6 +46,7 @@ } .user-row, +.redeem-row, .invitation-row { display: flex; flex-direction: row; @@ -58,6 +59,7 @@ } .user-action, +.redeem-action, .invitation-action { display: flex; margin-top: 1rem; diff --git a/app/src/components/Tips.tsx b/app/src/components/Tips.tsx index d11ff18..3f6f2df 100644 --- a/app/src/components/Tips.tsx +++ b/app/src/components/Tips.tsx @@ -8,14 +8,15 @@ import { HelpCircle } from "lucide-react"; type TipsProps = { content: string; + className?: string; }; -function Tips({ content }: TipsProps) { +function Tips({ content, className }: TipsProps) { return ( - +

{content}

diff --git a/app/src/components/admin/InvitationTable.tsx b/app/src/components/admin/InvitationTable.tsx index c38f43d..9909130 100644 --- a/app/src/components/admin/InvitationTable.tsx +++ b/app/src/components/admin/InvitationTable.tsx @@ -158,7 +158,7 @@ function InvitationTable() { - {t("admin.code")} + {t("admin.invitation-code")} {t("admin.quota")} {t("admin.type")} {t("admin.used")} diff --git a/app/src/components/admin/MenuBar.tsx b/app/src/components/admin/MenuBar.tsx index f4f3083..4f43766 100644 --- a/app/src/components/admin/MenuBar.tsx +++ b/app/src/components/admin/MenuBar.tsx @@ -53,7 +53,7 @@ function MenuBar() { icon={} path={"/"} /> - } path={"/users"} /> + } path={"/users"} /> } diff --git a/app/src/components/admin/RedeemTable.tsx b/app/src/components/admin/RedeemTable.tsx new file mode 100644 index 0000000..f37d72f --- /dev/null +++ b/app/src/components/admin/RedeemTable.tsx @@ -0,0 +1,178 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} 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, RotateCw } from "lucide-react"; +import { 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 { useEffectAsync } from "@/utils/hook.ts"; + +function GenerateDialog() { + const { t } = useTranslation(); + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [quota, setQuota] = useState("5"); + const [number, setNumber] = useState("1"); + const [data, setData] = useState(""); + + function getNumber(value: string): string { + return value.replace(/[^\d.]/g, ""); + } + + async function generateCode() { + const data = await generateRedeem(Number(quota), Number(number)); + if (data.status) setData(data.data.join("\n")); + else + toast({ + title: t("admin.error"), + description: data.message, + }); + } + + function close() { + setQuota("5"); + setNumber("1"); + + setOpen(false); + setData(""); + } + + function downloadCode() { + return saveAsFile("code.txt", data); + } + + return ( + <> + + + + + + + {t("admin.generate")} + +
+

{t("admin.quota")}

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

{t("admin.number")}

+ setNumber(getNumber(e.target.value))} + /> +
+
+
+ + + + +
+
+ { + if (!state) close(); + }} + > + + + {t("admin.generate-result")} + +