diff --git a/admin/controller.go b/admin/controller.go index 7d77f50f..d0c21d35 100644 --- a/admin/controller.go +++ b/admin/controller.go @@ -14,6 +14,16 @@ type GenerateInvitationForm struct { Number int `json:"number"` } +type QuotaOperationForm struct { + Id int64 `json:"id"` + Quota float32 `json:"quota"` +} + +type SubscriptionOperationForm struct { + Id int64 `json:"id"` + Month int64 `json:"month"` +} + func InfoAPI(c *gin.Context) { db := utils.GetDBFromContext(c) cache := utils.GetCacheFromContext(c) @@ -74,3 +84,55 @@ func UserPaginationAPI(c *gin.Context) { search := strings.TrimSpace(c.Query("search")) c.JSON(http.StatusOK, GetUserPagination(db, int64(page), search)) } + +func UserQuotaAPI(c *gin.Context) { + db := utils.GetDBFromContext(c) + + var form QuotaOperationForm + if err := c.ShouldBindJSON(&form); err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": err.Error(), + }) + return + } + + err := QuotaOperation(db, form.Id, form.Quota) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": true, + }) +} + +func UserSubscriptionAPI(c *gin.Context) { + db := utils.GetDBFromContext(c) + + var form SubscriptionOperationForm + if err := c.ShouldBindJSON(&form); err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": err.Error(), + }) + return + } + + err := SubscriptionOperation(db, form.Id, form.Month) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": true, + }) +} diff --git a/admin/router.go b/admin/router.go index ec38058f..0d090be1 100644 --- a/admin/router.go +++ b/admin/router.go @@ -13,4 +13,6 @@ func Register(app *gin.Engine) { app.POST("/admin/invitation/generate", GenerateInvitationAPI) app.GET("/admin/user/list", UserPaginationAPI) + app.POST("/admin/user/quota", UserQuotaAPI) + app.POST("/admin/user/subscription", UserSubscriptionAPI) } diff --git a/admin/statistic.go b/admin/statistic.go index 7794e247..af0b3f0b 100644 --- a/admin/statistic.go +++ b/admin/statistic.go @@ -21,7 +21,6 @@ func IncrRequest(cache *redis.Client) { } func IncrModelRequest(cache *redis.Client, model string, tokens int64) { - IncrRequest(cache) utils.IncrWithExpire(cache, getModelFormat(getDay(), model), tokens, time.Hour*24*7*2) } diff --git a/admin/user.go b/admin/user.go index 564391ef..670e209c 100644 --- a/admin/user.go +++ b/admin/user.go @@ -32,7 +32,7 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm { LEFT JOIN quota ON quota.user_id = auth.id LEFT JOIN subscription ON subscription.user_id = auth.id WHERE auth.username LIKE ? - ORDER BY auth.id DESC LIMIT ? OFFSET ? + ORDER BY auth.id LIMIT ? OFFSET ? `, "%"+search+"%", pagination, page*pagination) if err != nil { return PaginationForm{ @@ -79,3 +79,29 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm { Data: users, } } + +func QuotaOperation(db *sql.DB, id int64, quota float32) error { + // if quota is negative, then decrease quota + // if quota is positive, then increase quota + + _, err := db.Exec(` + INSERT INTO quota (user_id, quota, used) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE quota = quota + ? + `, id, quota, 0., quota) + + return err +} + +func SubscriptionOperation(db *sql.DB, id int64, month int64) error { + // if month is negative, then decrease month + // if month is positive, then increase month + + expireAt := time.Now().AddDate(0, int(month), 0) + + _, err := db.Exec(` + INSERT INTO subscription (user_id, total_month, expired_at) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE total_month = total_month + ?, expired_at = DATE_ADD(expired_at, INTERVAL ? MONTH) + `, id, month, expireAt, month, month) + + return err +} diff --git a/app/src/admin/api.ts b/app/src/admin/api.ts index 6e7d2459..ba76324d 100644 --- a/app/src/admin/api.ts +++ b/app/src/admin/api.ts @@ -1,5 +1,6 @@ import { BillingChartResponse, + CommonResponse, ErrorChartResponse, InfoResponse, InvitationGenerateResponse, @@ -111,3 +112,27 @@ export async function getUserList( return response.data as UserResponse; } + +export async function quotaOperation( + id: number, + quota: number, +): Promise { + const response = await axios.post("/admin/user/quota", { id, quota }); + if (response.status !== 200) { + return { status: false, message: "" }; + } + + return response.data as CommonResponse; +} + +export async function subscriptionOperation( + id: number, + month: number, +): Promise { + const response = await axios.post("/admin/user/subscription", { id, month }); + if (response.status !== 200) { + return { status: false, message: "" }; + } + + return response.data as CommonResponse; +} diff --git a/app/src/admin/types.ts b/app/src/admin/types.ts index fc44d9bb..58c95246 100644 --- a/app/src/admin/types.ts +++ b/app/src/admin/types.ts @@ -1,3 +1,8 @@ +export type CommonResponse = { + status: boolean; + message: string; +}; + export type InfoResponse = { billing_today: number; billing_month: number; @@ -54,7 +59,7 @@ export type InvitationGenerateResponse = { }; export type UserData = { - id: string; + id: number; username: string; is_admin: boolean; quota: number; diff --git a/app/src/components/PopupDialog.tsx b/app/src/components/PopupDialog.tsx new file mode 100644 index 00000000..961665ef --- /dev/null +++ b/app/src/components/PopupDialog.tsx @@ -0,0 +1,79 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { useState } from "react"; + +export type PopupDialogProps = { + title: string; + description?: string; + name: string; + defaultValue?: string; + onValueChange?: (value: string) => string; + onSubmit?: (value: string) => Promise; + + open: boolean; + setOpen: (open: boolean) => void; +}; + +function PopupDialog({ + title, + description, + name, + defaultValue, + onValueChange, + onSubmit, + open, + setOpen, +}: PopupDialogProps) { + const { t } = useTranslation(); + const [value, setValue] = useState(defaultValue || ""); + + return ( + + + + {title} + {description} + +
+ {name} + { + setValue( + onValueChange ? onValueChange(e.target.value) : e.target.value, + ); + }} + value={value} + /> +
+ + + + +
+
+ ); +} + +export default PopupDialog; diff --git a/app/src/components/admin/InvitationTable.tsx b/app/src/components/admin/InvitationTable.tsx index 275f91dd..0ef43ad2 100644 --- a/app/src/components/admin/InvitationTable.tsx +++ b/app/src/components/admin/InvitationTable.tsx @@ -155,8 +155,8 @@ function InvitationTable() { - {data.data.map((invitation, idx) => ( - + {(data.data || []).map((invitation, idx) => ( + {invitation.code} {invitation.quota} {invitation.type} diff --git a/app/src/components/admin/UserTable.tsx b/app/src/components/admin/UserTable.tsx index b20f5f7e..4ccbd693 100644 --- a/app/src/components/admin/UserTable.tsx +++ b/app/src/components/admin/UserTable.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; import { useToast } from "@/components/ui/use-toast.ts"; import { useState } from "react"; -import { UserForm, UserResponse } from "@/admin/types.ts"; -import { getUserList } from "@/admin/api.ts"; +import {CommonResponse, UserForm, UserResponse} from "@/admin/types.ts"; +import {getUserList, quotaOperation, subscriptionOperation} from "@/admin/api.ts"; import { useEffectAsync } from "@/utils/hook.ts"; import { Table, @@ -12,15 +12,93 @@ import { TableHeader, TableRow, } from "@/components/ui/table.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.tsx"; import { Button } from "@/components/ui/button.tsx"; import { + CalendarClock, ChevronLeft, ChevronRight, + CloudCog, MoreHorizontal, RotateCw, Search, } from "lucide-react"; import { Input } from "@/components/ui/input.tsx"; +import PopupDialog from "@/components/PopupDialog.tsx"; +import {getNumber, parseNumber} from "@/utils/base.ts"; + +type OperationMenuProps = { + id: number; +}; + +function doToast(t: any, toast: any, resp: CommonResponse) { + if (!resp.status) toast({ + title: t("admin.operate-failed"), + description: t("admin.operate-failed-prompt", { reason: resp.message }), + }); + else toast({ + title: t("admin.operate-success"), + description: t("admin.operate-success-prompt"), + }); +} + +function OperationMenu({ id }: OperationMenuProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [quotaOpen, setQuotaOpen] = useState(false); + const [subscriptionOpen, setSubscriptionOpen] = useState(false); + + return ( + <> + { + const quota = parseNumber(value); + const resp = await quotaOperation(id, quota); + doToast(t, toast, resp); + return resp.status; + }} + /> + { + const month = parseNumber(value); + const resp = await subscriptionOperation(id, month); + doToast(t, toast, resp); + return resp.status; + }} + /> + + + + + + setQuotaOpen(true)}> + + {t("admin.quota-action")} + + setSubscriptionOpen(true)}> + + {t("admin.subscription-action")} + + + + + ); +} function UserTable() { const { t } = useTranslation(); @@ -73,7 +151,7 @@ function UserTable() { - {data.data.map((user, idx) => ( + {(data.data || []).map((user, idx) => ( {user.id} {user.username} @@ -84,9 +162,7 @@ function UserTable() { {t(user.enterprise.toString())} {t(user.is_admin.toString())} - + ))} diff --git a/app/src/components/home/ChatSpace.tsx b/app/src/components/home/ChatSpace.tsx index 174d147b..92ceed7d 100644 --- a/app/src/components/home/ChatSpace.tsx +++ b/app/src/components/home/ChatSpace.tsx @@ -9,6 +9,7 @@ import { FolderKanban, Link, Newspaper, + Shield, Users2, } from "lucide-react"; import router from "@/router.tsx"; @@ -20,8 +21,10 @@ import { DialogTitle, } from "@/components/ui/dialog.tsx"; import { getLanguage } from "@/i18n.ts"; +import { selectAdmin } from "@/store/auth.ts"; function ChatSpace() { + const admin = useSelector(selectAdmin); const [open, setOpen] = useState(false); const { t } = useTranslation(); const subscription = useSelector(isSubscribedSelector); @@ -82,6 +85,12 @@ function ChatSpace() {
+ {admin && ( +

+ + router.navigate("/admin")}>{t("admin.users")} +

+ )}

): boolean { if (!this.state || !this.connection) { + if (this.connection === undefined) this.init(); console.debug("[connection] connection not ready, retrying in 500ms..."); return false; } diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 112fd9ae..f485d483 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -267,6 +267,7 @@ const resources = { used: "状态", number: "数量", username: "用户名", + month: "月数", "is-admin": "管理员", "used-quota": "已用点数", "is-subscribed": "是否订阅", @@ -276,7 +277,7 @@ const resources = { "search-username": "搜索用户名", "quota-action": "点数变更", "quota-action-desc": "请输入点数变更值(正数为增加,负数为减少)", - "subscription-action": "赠送订阅", + "subscription-action": "订阅管理", "subscription-action-desc": "请输入赠送的订阅月数", "operate-success": "操作成功", "operate-success-prompt": "您的操作已成功执行。", @@ -567,6 +568,7 @@ const resources = { used: "Status", number: "Number", username: "Username", + month: "Month", "is-admin": "Admin", "used-quota": "Used Quota", "is-subscribed": "Subscribed", @@ -577,7 +579,7 @@ const resources = { "quota-action": "Quota Change", "quota-action-desc": "Please enter the quota change value (positive for increase, negative for decrease)", - "subscription-action": "Subscription Gift", + "subscription-action": "Subscription Management", "subscription-action-desc": "Please enter the gift subscription months", "operate-success": "Operate Success", "operate-success-prompt": @@ -870,6 +872,7 @@ const resources = { used: "Статус", number: "Количество", username: "Имя пользователя", + month: "Месяц", "is-admin": "Админ", "used-quota": "Использовано", "is-subscribed": "Подписан", @@ -880,7 +883,7 @@ const resources = { "quota-action": "Изменение квоты", "quota-action-desc": "Пожалуйста, введите значение изменения квоты (положительное для увеличения, отрицательное для уменьшения)", - "subscription-action": "Подарок подписки", + "subscription-action": "Управление подпиской", "subscription-action-desc": "Пожалуйста, введите количество месяцев подарочной подписки", "operate-success": "Успешно", diff --git a/app/src/utils/base.ts b/app/src/utils/base.ts index 79f39d52..fbe10654 100644 --- a/app/src/utils/base.ts +++ b/app/src/utils/base.ts @@ -26,3 +26,14 @@ export function asyncCaller(fn: (...args: any[]) => Promise) { return promise; }; } + +export function getNumber(value: string, supportNegative = true): string { + return value.replace( + supportNegative ? /[^-0-9.]/g : /[^0-9.]/g, + "" + ); +} + +export function parseNumber(value: string): number { + return parseFloat(getNumber(value)); +} diff --git a/manager/manager.go b/manager/manager.go index 6403773b..1c722f0d 100644 --- a/manager/manager.go +++ b/manager/manager.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/gin-gonic/gin" "strconv" + "strings" ) type WebsocketAuthForm struct { @@ -15,6 +16,23 @@ type WebsocketAuthForm struct { Ref string `json:"ref"` } +func ParseAuth(c *gin.Context, token string) *auth.User { + token = strings.TrimSpace(token) + if token == "" { + return nil + } + + if strings.HasPrefix(token, "Bearer ") { + token = token[7:] + } + + if strings.HasPrefix(token, "sk-") { + return auth.ParseApiKey(c, token) + } + + return auth.ParseToken(c, token) +} + func ChatAPI(c *gin.Context) { var conn *utils.WebSocket if conn = utils.NewWebsocket(c, false); conn == nil { @@ -28,7 +46,7 @@ func ChatAPI(c *gin.Context) { return } - user := auth.ParseToken(c, form.Token) + user := ParseAuth(c, form.Token) authenticated := user != nil id := auth.GetId(db, user) diff --git a/middleware/throttle.go b/middleware/throttle.go index bf24b777..ba98bbff 100644 --- a/middleware/throttle.go +++ b/middleware/throttle.go @@ -1,6 +1,7 @@ package middleware import ( + "chat/admin" "chat/utils" "fmt" "github.com/gin-gonic/gin" @@ -58,6 +59,7 @@ func ThrottleMiddleware() gin.HandlerFunc { ip := c.ClientIP() path := c.Request.URL.Path cache := utils.GetCacheFromContext(c) + admin.IncrRequest(cache) limiter := GetPrefixMap[Limiter](path, limits) if limiter != nil && limiter.RateLimit(c, cache, ip, path) { c.JSON(200, gin.H{"status": false, "reason": "You have sent too many requests. Please try again later."})