feat: better admin user subscription management ui

This commit is contained in:
XiaomaiTX 2024-07-06 15:32:41 +08:00
parent 140bed53f9
commit 6e8c33f1a2
23 changed files with 5405 additions and 3992 deletions

View File

@ -2,10 +2,12 @@ package admin
import ( import (
"chat/utils" "chat/utils"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin"
) )
type GenerateInvitationForm struct { type GenerateInvitationForm struct {
@ -44,27 +46,26 @@ type BanForm struct {
} }
type QuotaOperationForm struct { type QuotaOperationForm struct {
Id int64 `json:"id" binding:"required"` Id int64 `json:"id" binding:"required"`
Quota float32 `json:"quota" binding:"required"` Quota *float32 `json:"quota" binding:"required"`
Override bool `json:"override"` Override bool `json:"override"`}
}
type SubscriptionOperationForm struct { type SubscriptionOperationForm struct {
Id int64 `json:"id"` Id int64 `json:"id" binding:"required"`
Month int64 `json:"month"` Expired string `json:"expired" binding:"required"`
} }
type SubscriptionLevelForm struct { type SubscriptionLevelForm struct {
Id int64 `json:"id"` Id int64 `json:"id" binding:"required"`
Level int64 `json:"level"` Level *int64 `json:"level" binding:"required"`
} }
type ReleaseUsageForm struct { type ReleaseUsageForm struct {
Id int64 `json:"id"` Id int64 `json:"id" binding:"required"`
} }
type UpdateRootPasswordForm struct { type UpdateRootPasswordForm struct {
Password string `json:"password"` Password string `json:"password" binding:"required"`
} }
func UpdateMarketAPI(c *gin.Context) { func UpdateMarketAPI(c *gin.Context) {
@ -337,7 +338,7 @@ func UserQuotaAPI(c *gin.Context) {
return return
} }
err := quotaMigration(db, form.Id, form.Quota, form.Override) err := quotaMigration(db, form.Id, *form.Quota, form.Override)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": false, "status": false,
@ -363,18 +364,26 @@ func UserSubscriptionAPI(c *gin.Context) {
return return
} }
err := subscriptionMigration(db, form.Id, form.Month) // convert to time
if err != nil { if _, err := time.Parse("2006-01-02 15:04:05", form.Expired); err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": true, "status": false,
"message": err.Error(),
}) })
return
}
if err := subscriptionMigration(db, form.Id, form.Expired); err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": true,
})
} }
func SubscriptionLevelAPI(c *gin.Context) { func SubscriptionLevelAPI(c *gin.Context) {
@ -389,7 +398,7 @@ func SubscriptionLevelAPI(c *gin.Context) {
return return
} }
err := subscriptionLevelMigration(db, form.Id, form.Level) err := subscriptionLevelMigration(db, form.Id, *form.Level)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": false, "status": false,

View File

@ -3,6 +3,7 @@ package admin
import ( import (
"chat/globals" "chat/globals"
"fmt" "fmt"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -12,7 +13,7 @@ type MarketModel struct {
Name string `json:"name" mapstructure:"name" required:"true"` Name string `json:"name" mapstructure:"name" required:"true"`
Description string `json:"description" mapstructure:"description"` Description string `json:"description" mapstructure:"description"`
Default bool `json:"default" mapstructure:"default"` Default bool `json:"default" mapstructure:"default"`
HighContext bool `json:"high_context" mapstructure:"high_context"` HighContext bool `json:"high_context" mapstructure:"highcontext"`
Avatar string `json:"avatar" mapstructure:"avatar"` Avatar string `json:"avatar" mapstructure:"avatar"`
Tag ModelTag `json:"tag" mapstructure:"tag"` Tag ModelTag `json:"tag" mapstructure:"tag"`
} }

View File

@ -77,6 +77,7 @@ type UserData struct {
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Quota float32 `json:"quota"` Quota float32 `json:"quota"`
UsedQuota float32 `json:"used_quota"` UsedQuota float32 `json:"used_quota"`
ExpiredAt string `json:"expired_at"`
IsSubscribed bool `json:"is_subscribed"` IsSubscribed bool `json:"is_subscribed"`
TotalMonth int64 `json:"total_month"` TotalMonth int64 `json:"total_month"`
Enterprise bool `json:"enterprise"` Enterprise bool `json:"enterprise"`

View File

@ -7,10 +7,11 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"github.com/go-redis/redis/v8"
"math" "math"
"strings" "strings"
"time" "time"
"github.com/go-redis/redis/v8"
) )
// AuthLike is to solve the problem of import cycle // AuthLike is to solve the problem of import cycle
@ -97,6 +98,7 @@ func getUsersForm(db *sql.DB, page int64, search string) PaginationForm {
stamp := utils.ConvertTime(expired) stamp := utils.ConvertTime(expired)
if stamp != nil { if stamp != nil {
user.IsSubscribed = stamp.After(time.Now()) user.IsSubscribed = stamp.After(time.Now())
user.ExpiredAt = stamp.Format("2006-01-02 15:04:05")
} }
user.Enterprise = isEnterprise.Valid && isEnterprise.Bool user.Enterprise = isEnterprise.Valid && isEnterprise.Bool
user.IsBanned = isBanned.Valid && isBanned.Bool user.IsBanned = isBanned.Valid && isBanned.Bool
@ -171,17 +173,11 @@ func quotaMigration(db *sql.DB, id int64, quota float32, override bool) error {
return err return err
} }
func subscriptionMigration(db *sql.DB, id int64, month int64) error { func subscriptionMigration(db *sql.DB, id int64, expired string) error {
// if month is negative, then decrease month
// if month is positive, then increase month
expireAt := time.Now().AddDate(0, int(month), 0)
_, err := globals.ExecDb(db, ` _, err := globals.ExecDb(db, `
INSERT INTO subscription (user_id, total_month, expired_at) VALUES (?, ?, ?) INSERT INTO subscription (user_id, expired_at) VALUES (?, ?)
ON DUPLICATE KEY UPDATE total_month = total_month + ?, expired_at = DATE_ADD(expired_at, INTERVAL ? MONTH) ON DUPLICATE KEY UPDATE expired_at = ?
`, id, month, expireAt, month, month) `, id, expired, expired)
return err return err
} }

View File

@ -52,6 +52,7 @@
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-day-picker": "^8.10.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^13.2.2", "react-i18next": "^13.2.2",
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",

8701
app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -230,12 +230,12 @@ export async function quotaOperation(
export async function subscriptionOperation( export async function subscriptionOperation(
id: number, id: number,
month: number, expired: string,
): Promise<CommonResponse> { ): Promise<CommonResponse> {
try { try {
const response = await axios.post("/admin/user/subscription", { const response = await axios.post("/admin/user/subscription", {
id, id,
month, expired,
}); });
return response.data as CommonResponse; return response.data as CommonResponse;
} catch (e) { } catch (e) {

View File

@ -104,6 +104,7 @@ export type UserData = {
used_quota: number; used_quota: number;
is_subscribed: boolean; is_subscribed: boolean;
total_month: number; total_month: number;
expired_at: string;
level: number; level: number;
enterprise: boolean; enterprise: boolean;
}; };

View File

@ -9,7 +9,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { NumberInput } from "@/components/ui/number-input.tsx"; import { NumberInput } from "@/components/ui/number-input.tsx";
import { Switch } from "@/components/ui/switch.tsx"; import { Switch } from "@/components/ui/switch.tsx";
import { Alert, AlertDescription } from "./ui/alert"; import { Alert, AlertDescription } from "./ui/alert";
@ -23,13 +23,22 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Calendar } from "@/components/ui/calendar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Combobox } from "@/components/ui/combo-box.tsx";
export enum popupTypes { export enum popupTypes {
Text = "text", Text = "text",
Number = "number", Number = "number",
Switch = "switch", Switch = "switch",
Clock = "clock",
List = "list",
Empty = "empty", Empty = "empty",
} }
type ParamProps = {
dataList?: string[];
dataListTranslated?: string;
};
export type PopupDialogProps = { export type PopupDialogProps = {
title: string; title: string;
@ -38,6 +47,7 @@ export type PopupDialogProps = {
placeholder?: string; placeholder?: string;
defaultValue?: string; defaultValue?: string;
onValueChange?: (value: string) => string; onValueChange?: (value: string) => string;
params?: ParamProps;
onSubmit?: (value: string) => Promise<boolean>; onSubmit?: (value: string) => Promise<boolean>;
destructive?: boolean; destructive?: boolean;
disabled?: boolean; disabled?: boolean;
@ -63,6 +73,7 @@ function PopupField({
type, type,
setValue, setValue,
onValueChange, onValueChange,
params,
value, value,
placeholder, placeholder,
componentProps, componentProps,
@ -81,7 +92,18 @@ function PopupField({
{...componentProps} {...componentProps}
/> />
); );
case popupTypes.Clock:
return <CalendarComp value={value} onValueChange={(v) => setValue(v)} />;
case popupTypes.List:
return (
<Combobox
value={value}
onChange={(v) => setValue(v)}
list={params?.dataList || []}
listTranslated={params?.dataListTranslated || ""}
/>
);
case popupTypes.Number: case popupTypes.Number:
return ( return (
<NumberInput <NumberInput
@ -111,7 +133,150 @@ function PopupField({
return null; return null;
} }
} }
function fixedZero(val: number) {
return val < 10 ? `0${val}` : val.toString();
}
function CalendarComp(props: {
value: string;
onValueChange: (v: string) => void;
}) {
const { value, onValueChange } = props;
const { t } = useTranslation();
const convertedDate = useMemo(() => {
const date = new Date(value.split(" ")[0] || "1970-01-01");
console.log(`[calendar] converted date:`, date);
return date;
}, [value]);
const onDateChange = (date: Date, overrideTime?: boolean) => {
const v = `${date.getFullYear()}-${fixedZero(
date.getMonth() + 1,
)}-${fixedZero(date.getDate())}`;
const t = !overrideTime
? value.split(" ")[1] || "00:00:00"
: `${fixedZero(date.getHours())}:${fixedZero(
date.getMinutes(),
)}:${fixedZero(date.getSeconds())}`;
console.log(`[calendar] clicked date: [${v} ${t}]`);
onValueChange(`${v} ${t}`);
};
const [month, setMonth] = useState(convertedDate);
useEffect(() => {
setMonth(convertedDate);
}, [convertedDate]);
return (
<div
className={`flex flex-col gap-2 items-center justify-center px-2 w-full h-fit`}
>
<Calendar
className={`scale-90 md:scale-100`}
mode="single"
month={month}
onMonthChange={(date) => date && setMonth(date)}
selected={convertedDate}
onSelect={(date) => date && onDateChange(date)}
/>
<Input
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder={t("date.pick")}
className={`w-full text-center`}
/>
<Separator />
<div className={`flex flex-row w-full flex-wrap`}>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() => onDateChange(new Date("1970-01-01 00:00:00"), true)}
>
{t("date.clean")}
</Button>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() => onDateChange(new Date(), true)}
>
{t("date.today")}
</Button>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() =>
onDateChange(
new Date(convertedDate.setDate(convertedDate.getDate() + 1)),
)
}
>
{t("date.add-day")}
</Button>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() =>
onDateChange(
new Date(convertedDate.setDate(convertedDate.getDate() - 1)),
)
}
>
{t("date.sub-day")}
</Button>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() =>
onDateChange(
new Date(convertedDate.setMonth(convertedDate.getMonth() + 1)),
)
}
>
{t("date.add-month")}
</Button>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() =>
onDateChange(
new Date(convertedDate.setMonth(convertedDate.getMonth() - 1)),
)
}
>
{t("date.sub-month")}
</Button>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() =>
onDateChange(
new Date(
convertedDate.setFullYear(convertedDate.getFullYear() + 1),
),
)
}
>
{t("date.add-year")}
</Button>
<Button
variant={`outline`}
className={`m-0.5 shrink-0`}
onClick={() =>
onDateChange(
new Date(
convertedDate.setFullYear(convertedDate.getFullYear() - 1),
),
)
}
>
{t("date.sub-year")}
</Button>
</div>
</div>
);
}
function PopupDialog(props: PopupDialogProps) { function PopupDialog(props: PopupDialogProps) {
const { const {
title, title,

View File

@ -57,7 +57,7 @@ import { getNumber, isEnter, parseNumber } from "@/utils/base.ts";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { selectUsername } from "@/store/auth.ts"; import { selectUsername } from "@/store/auth.ts";
import { PaginationAction } from "@/components/ui/pagination.tsx"; import { PaginationAction } from "@/components/ui/pagination.tsx";
import Tips from "@/components/Tips.tsx";
type OperationMenuProps = { type OperationMenuProps = {
user: UserData; user: UserData;
onRefresh?: () => void; onRefresh?: () => void;
@ -187,17 +187,16 @@ function OperationMenu({ user, onRefresh }: OperationMenuProps) {
}} }}
/> />
<PopupDialog <PopupDialog
type={popupTypes.Number} type={popupTypes.Clock}
title={t("admin.subscription-action")} title={t("admin.subscription-action")}
name={t("admin.month")} description={t("admin.subscription-action-desc", {
description={t("admin.subscription-action-desc")} username: user.username,
defaultValue={"0"} })}
onValueChange={getNumber} defaultValue={user.expired_at || "1970-01-01 00:00:00"}
open={subscriptionOpen} open={subscriptionOpen}
setOpen={setSubscriptionOpen} setOpen={setSubscriptionOpen}
onSubmit={async (value) => { onSubmit={async (value) => {
const month = parseNumber(value); const resp = await subscriptionOperation(user.id, value);
const resp = await subscriptionOperation(user.id, month);
doToast(t, toast, resp); doToast(t, toast, resp);
if (resp.status) onRefresh?.(); if (resp.status) onRefresh?.();
@ -205,16 +204,20 @@ function OperationMenu({ user, onRefresh }: OperationMenuProps) {
}} }}
/> />
<PopupDialog <PopupDialog
type={popupTypes.Number} type={popupTypes.List}
title={t("admin.subscription-level")} title={t("admin.subscription-level")}
name={t("admin.level")} name={t("admin.level")}
description={t("admin.subscription-level-desc")} description={t("admin.subscription-level-desc")}
defaultValue={user.level.toString()} defaultValue={userTypeArray[user.level]}
onValueChange={getNumber} params={{
dataList: userTypeArray,
dataListTranslated: "admin.identity",
}}
open={subscriptionLevelOpen} open={subscriptionLevelOpen}
setOpen={setSubscriptionLevelOpen} setOpen={setSubscriptionLevelOpen}
onSubmit={async (value) => { onSubmit={async (value) => {
const level = parseNumber(value); const level = userTypeArray.indexOf(value as UserType);
console.log(level);
const resp = await subscriptionLevelOperation(user.id, level); const resp = await subscriptionLevelOperation(user.id, level);
doToast(t, toast, resp); doToast(t, toast, resp);
@ -331,14 +334,14 @@ function OperationMenu({ user, onRefresh }: OperationMenuProps) {
<CalendarClock className={`h-4 w-4 mr-2`} /> <CalendarClock className={`h-4 w-4 mr-2`} />
{t("admin.subscription-action")} {t("admin.subscription-action")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setReleaseOpen(true)}>
<CalendarOff className={`h-4 w-4 mr-2`} />
{t("admin.release-subscription-action")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSubscriptionLevelOpen(true)}> <DropdownMenuItem onClick={() => setSubscriptionLevelOpen(true)}>
<CalendarCheck2 className={`h-4 w-4 mr-2`} /> <CalendarCheck2 className={`h-4 w-4 mr-2`} />
{t("admin.subscription-level")} {t("admin.subscription-level")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setReleaseOpen(true)}>
<CalendarOff className={`h-4 w-4 mr-2`} />
{t("admin.release-subscription-action")}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>
@ -398,6 +401,7 @@ function UserTable() {
<TableHead>{t("admin.is-subscribed")}</TableHead> <TableHead>{t("admin.is-subscribed")}</TableHead>
<TableHead>{t("admin.level")}</TableHead> <TableHead>{t("admin.level")}</TableHead>
<TableHead>{t("admin.total-month")}</TableHead> <TableHead>{t("admin.total-month")}</TableHead>
<TableHead>{t("admin.expired-at")}</TableHead>
<TableHead>{t("admin.is-banned")}</TableHead> <TableHead>{t("admin.is-banned")}</TableHead>
<TableHead>{t("admin.is-admin")}</TableHead> <TableHead>{t("admin.is-admin")}</TableHead>
<TableHead>{t("admin.action")}</TableHead> <TableHead>{t("admin.action")}</TableHead>
@ -415,11 +419,20 @@ function UserTable() {
</TableCell> </TableCell>
<TableCell>{user.quota}</TableCell> <TableCell>{user.quota}</TableCell>
<TableCell>{user.used_quota}</TableCell> <TableCell>{user.used_quota}</TableCell>
<TableCell>{t(user.is_subscribed.toString())}</TableCell> <TableCell>
{t(user.is_subscribed.toString())}
<Tips
className={`inline-block`}
content={t("admin.is-subscribed-tips")}
/>
</TableCell>
<TableCell className={`whitespace-nowrap`}> <TableCell className={`whitespace-nowrap`}>
{t(`admin.identity.${userTypeArray[user.level]}`)} {t(`admin.identity.${userTypeArray[user.level]}`)}
</TableCell> </TableCell>
<TableCell>{user.total_month}</TableCell> <TableCell>{user.total_month}</TableCell>
<TableCell className={`whitespace-nowrap`}>
{user.expired_at || "-"}
</TableCell>
<TableCell>{t(user.is_banned.toString())}</TableCell> <TableCell>{t(user.is_banned.toString())}</TableCell>
<TableCell>{t(user.is_admin.toString())}</TableCell> <TableCell>{t(user.is_admin.toString())}</TableCell>
<TableCell> <TableCell>

View File

@ -0,0 +1,73 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/components/ui/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-6 w-6 bg-transparent p-1 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => (
<ChevronLeft className="h-4 w-4" {...props} />
),
IconRight: ({ ...props }) => (
<ChevronRight className="h-4 w-4" {...props} />
),
}}
formatters={{
formatCaption: (date) => `${date.getFullYear()}/${date.getMonth() + 1}`,
formatWeekdayName: (date) =>
date.toLocaleDateString(undefined, { weekday: "short" }),
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

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.9"; // version of the current build export const version = "3.11.0"; // 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

@ -49,7 +49,9 @@ function ShareContent({ data }: ShareTableProps) {
const time = useMemo(() => { const time = useMemo(() => {
return data.map((row) => { return data.map((row) => {
const date = new Date(row.time); const date = new Date(row.time);
return `${date.getMonth()}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`; return `${
date.getMonth() + 1
}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
}); });
}, [data]); }, [data]);

View File

@ -462,7 +462,9 @@
"is-banned": "封禁", "is-banned": "封禁",
"used-quota": "已用点数", "used-quota": "已用点数",
"is-subscribed": "是否订阅", "is-subscribed": "是否订阅",
"is-subscribed-tips": "是否订阅评判逻辑: 有订阅等级且订阅时间未过期",
"total-month": "总计订阅月数", "total-month": "总计订阅月数",
"expired-at": "订阅到期时间",
"enterprise": "企业版", "enterprise": "企业版",
"action": "操作", "action": "操作",
"search-username": "搜索用户名", "search-username": "搜索用户名",
@ -482,12 +484,12 @@
"quota-action-desc": "请输入点数变更值(正数为增加,负数为减少)", "quota-action-desc": "请输入点数变更值(正数为增加,负数为减少)",
"quota-set-action": "点数设置", "quota-set-action": "点数设置",
"quota-set-action-desc": "设置用户的点数", "quota-set-action-desc": "设置用户的点数",
"subscription-action": "订阅管理", "subscription-action": "订阅时间管理",
"subscription-action-desc": "请输入赠送的订阅月数", "subscription-action-desc": "请设置用户 {{username}} 的订阅到期时间",
"release-subscription-action": "释放订阅用量", "release-subscription-action": "释放订阅用量",
"release-subscription-action-desc": "是否释放用户的订阅用量?", "release-subscription-action-desc": "是否释放用户的订阅用量?",
"subscription-level": "设置订阅等级", "subscription-level": "设置订阅等级",
"subscription-level-desc": "设置用户的订阅等级 (0 为普通用户, 1 为基础版订阅, 2 为标准版订阅, 3 为专业版订阅)", "subscription-level-desc": "设置用户的订阅等级",
"operate-success": "操作成功", "operate-success": "操作成功",
"operate-success-prompt": "您的操作已成功执行。", "operate-success-prompt": "您的操作已成功执行。",
"operate-failed": "操作失败", "operate-failed": "操作失败",
@ -640,8 +642,8 @@
"proxy-endpoint-placeholder": "请输入正向代理地址socks5://example.com:1080", "proxy-endpoint-placeholder": "请输入正向代理地址socks5://example.com:1080",
"proxy-username": "代理用户名", "proxy-username": "代理用户名",
"proxy-username-placeholder": "请输入代理的鉴权用户名 (可选)", "proxy-username-placeholder": "请输入代理的鉴权用户名 (可选)",
"proxy-password": "代理密码", "proxy-password": "代理密码",
"proxy-password-placeholder": "请输入代理的鉴权密码 (可选)", "proxy-password-placeholder": "请输入代理的鉴权密码 (可选)",
"proxy-desc": "正向代理,支持 HTTP/HTTPS/SOCKS5 代理 (反向代理请填写接入点, 非特殊情况无需设置正向代理)" "proxy-desc": "正向代理,支持 HTTP/HTTPS/SOCKS5 代理 (反向代理请填写接入点, 非特殊情况无需设置正向代理)"
}, },
"charge": { "charge": {
@ -800,5 +802,16 @@
"edit": "编辑预设", "edit": "编辑预设",
"delete": "删除预设" "delete": "删除预设"
} }
},
"date": {
"pick": "选择一个日期",
"today": "今天",
"clean": "归零",
"add-day": "增加一天",
"sub-day": "减少一天",
"add-month": "增加一个月",
"sub-month": "减少一个月",
"add-year": "增加一年",
"sub-year": "减少一年"
} }
} }

View File

@ -721,7 +721,9 @@
"broadcast-tip": "Notifications will only show the most recent one and will only be notified once. Site announcements can be set in the system settings. The pop-up window will be displayed on the homepage for the first time and subsequent viewing will be supported.", "broadcast-tip": "Notifications will only show the most recent one and will only be notified once. Site announcements can be set in the system settings. The pop-up window will be displayed on the homepage for the first time and subsequent viewing will be supported.",
"created-at": "Creation Time", "created-at": "Creation Time",
"used-at": "Collection time", "used-at": "Collection time",
"used-username": "Claim User" "used-username": "Claim User",
"is-subscribed-tips": "Subscription judgment logic: There is a subscription tier and the subscription period has not expired",
"expired-at": "Subscription Expiration Time"
}, },
"mask": { "mask": {
"title": "Mask Settings", "title": "Mask Settings",
@ -801,5 +803,16 @@
"min-quota": "Minimum Balance", "min-quota": "Minimum Balance",
"your-quota": "Your balance", "your-quota": "Your balance",
"title": "Title", "title": "Title",
"my-account": "My Account" "my-account": "My Account",
"date": {
"pick": "Select date",
"today": "Today",
"clean": "Return to zero",
"add-day": "Add one day",
"sub-day": "Decrease by one day",
"add-month": "Add one month",
"sub-month": "Decrease by one month",
"add-year": "Add one year",
"sub-year": "Decrease by one year"
}
} }

View File

@ -722,7 +722,9 @@
"broadcast-tip": "通知には最新の通知のみが表示され、一度だけ通知されます。サイトのお知らせは、システム設定で設定できます。ポップアップウィンドウがホームページに初めて表示され、その後の表示がサポートされます。", "broadcast-tip": "通知には最新の通知のみが表示され、一度だけ通知されます。サイトのお知らせは、システム設定で設定できます。ポップアップウィンドウがホームページに初めて表示され、その後の表示がサポートされます。",
"created-at": "作成日時", "created-at": "作成日時",
"used-at": "乗車時間", "used-at": "乗車時間",
"used-username": "ユーザーを請求する" "used-username": "ユーザーを請求する",
"is-subscribed-tips": "サブスクリプション判断ロジック:サブスクリプションティアがあり、サブスクリプション期間が満了していません",
"expired-at": "サブスクリプションの有効期限"
}, },
"mask": { "mask": {
"title": "プリセット設定", "title": "プリセット設定",
@ -802,5 +804,16 @@
"min-quota": "最低残高", "min-quota": "最低残高",
"your-quota": "残高", "your-quota": "残高",
"title": "タイトル", "title": "タイトル",
"my-account": "マイアカウント" "my-account": "マイアカウント",
"date": {
"pick": "日付を選択",
"today": "今日",
"clean": "ゼロに戻る",
"add-day": "1日追加",
"sub-day": "1日短縮",
"add-month": "1か月追加",
"sub-month": "1か月短縮",
"add-year": "1年追加",
"sub-year": "1年減"
}
} }

View File

@ -722,7 +722,9 @@
"broadcast-tip": "Уведомления будут отображаться только самые последние и будут уведомлены только один раз. Объявления сайта можно задать в системных настройках. Всплывающее окно будет отображаться на главной странице в первый раз и будет поддерживаться последующий просмотр.", "broadcast-tip": "Уведомления будут отображаться только самые последние и будут уведомлены только один раз. Объявления сайта можно задать в системных настройках. Всплывающее окно будет отображаться на главной странице в первый раз и будет поддерживаться последующий просмотр.",
"created-at": "Время создания", "created-at": "Время создания",
"used-at": "Время награждения", "used-at": "Время награждения",
"used-username": "Получить пользователя" "used-username": "Получить пользователя",
"is-subscribed-tips": "Логика суждения о подписке: существует уровень подписки, и период подписки не истек",
"expired-at": "Срок действия подписки"
}, },
"mask": { "mask": {
"title": "Настройки маски", "title": "Настройки маски",
@ -802,5 +804,16 @@
"min-quota": "Минимальный баланс", "min-quota": "Минимальный баланс",
"your-quota": "Ваш баланс", "your-quota": "Ваш баланс",
"title": "заглавие", "title": "заглавие",
"my-account": "Моя учетная запись" "my-account": "Моя учетная запись",
"date": {
"pick": "Выберите дату",
"today": "сегодня",
"clean": "Вернуться к нулю",
"add-day": "Добавить один день",
"sub-day": "Уменьшить на один день",
"add-month": "Добавить один месяц",
"sub-month": "Уменьшить на один месяц",
"add-year": "Добавить один год",
"sub-year": "Уменьшение на один год"
}
} }

View File

@ -354,7 +354,11 @@ function Mail({ data, dispatch, onChange }: CompProps<MailState>) {
> >
<SelectTrigger className={`select`}> <SelectTrigger className={`select`}>
<SelectValue <SelectValue
placeholder={data.protocol ? t("admin.system.mailProtocolTLS") : t("admin.system.mailProtocolSSL")} placeholder={
data.protocol
? t("admin.system.mailProtocolTLS")
: t("admin.system.mailProtocolSSL")
}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -389,9 +393,7 @@ function Mail({ data, dispatch, onChange }: CompProps<MailState>) {
value: e.target.value, value: e.target.value,
}) })
} }
className={cn( className={cn("transition-all duration-300")}
"transition-all duration-300",
)}
placeholder={t("admin.system.mailUser")} placeholder={t("admin.system.mailUser")}
/> />
</ParagraphItem> </ParagraphItem>
@ -423,9 +425,7 @@ function Mail({ data, dispatch, onChange }: CompProps<MailState>) {
}) })
} }
placeholder={`${data.username}@${location.hostname}`} placeholder={`${data.username}@${location.hostname}`}
className={cn( className={cn("transition-all duration-300")}
"transition-all duration-300",
)}
/> />
</ParagraphItem> </ParagraphItem>
<ParagraphSpace /> <ParagraphSpace />
@ -863,7 +863,7 @@ function Search({ data, dispatch, onChange }: CompProps<SearchState>) {
/> />
</ParagraphItem> </ParagraphItem>
<ParagraphItem> <ParagraphItem>
<Label>{t("admin.system.searchEngines")}</Label> <Label>{t("admin.system.searchEngines")}</Label>
<MultiCombobox <MultiCombobox
value={data.engines} value={data.engines}
onChange={(value) => { onChange={(value) => {

View File

@ -8,7 +8,9 @@ import {
import { RootState } from "@/store/index.ts"; import { RootState } from "@/store/index.ts";
import { isMobile } from "@/utils/device"; import { isMobile } from "@/utils/device";
export const sendKeys = isMobile() ? ["Ctrl + Enter", "Enter"] : ["Enter", "Ctrl + Enter"]; export const sendKeys = isMobile()
? ["Ctrl + Enter", "Enter"]
: ["Enter", "Ctrl + Enter"];
export const initialSettings = { export const initialSettings = {
context: true, context: true,
align: false, align: false,

File diff suppressed because one or more lines are too long