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 (
"chat/utils"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type GenerateInvitationForm struct {
@ -45,26 +47,25 @@ type BanForm struct {
type QuotaOperationForm struct {
Id int64 `json:"id" binding:"required"`
Quota float32 `json:"quota" binding:"required"`
Override bool `json:"override"`
}
Quota *float32 `json:"quota" binding:"required"`
Override bool `json:"override"`}
type SubscriptionOperationForm struct {
Id int64 `json:"id"`
Month int64 `json:"month"`
Id int64 `json:"id" binding:"required"`
Expired string `json:"expired" binding:"required"`
}
type SubscriptionLevelForm struct {
Id int64 `json:"id"`
Level int64 `json:"level"`
Id int64 `json:"id" binding:"required"`
Level *int64 `json:"level" binding:"required"`
}
type ReleaseUsageForm struct {
Id int64 `json:"id"`
Id int64 `json:"id" binding:"required"`
}
type UpdateRootPasswordForm struct {
Password string `json:"password"`
Password string `json:"password" binding:"required"`
}
func UpdateMarketAPI(c *gin.Context) {
@ -337,7 +338,7 @@ func UserQuotaAPI(c *gin.Context) {
return
}
err := quotaMigration(db, form.Id, form.Quota, form.Override)
err := quotaMigration(db, form.Id, *form.Quota, form.Override)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
@ -363,18 +364,26 @@ func UserSubscriptionAPI(c *gin.Context) {
return
}
err := subscriptionMigration(db, form.Id, form.Month)
if err != nil {
// convert to time
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
}
}
if err := subscriptionMigration(db, form.Id, form.Expired); err != nil {
c.JSON(http.StatusOK, gin.H{
"status": true,
"status": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": true,
})
}
func SubscriptionLevelAPI(c *gin.Context) {
@ -389,7 +398,7 @@ func SubscriptionLevelAPI(c *gin.Context) {
return
}
err := subscriptionLevelMigration(db, form.Id, form.Level)
err := subscriptionLevelMigration(db, form.Id, *form.Level)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,

View File

@ -3,6 +3,7 @@ package admin
import (
"chat/globals"
"fmt"
"github.com/spf13/viper"
)
@ -12,7 +13,7 @@ type MarketModel struct {
Name string `json:"name" mapstructure:"name" required:"true"`
Description string `json:"description" mapstructure:"description"`
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"`
Tag ModelTag `json:"tag" mapstructure:"tag"`
}

View File

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

View File

@ -7,10 +7,11 @@ import (
"context"
"database/sql"
"fmt"
"github.com/go-redis/redis/v8"
"math"
"strings"
"time"
"github.com/go-redis/redis/v8"
)
// 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)
if stamp != nil {
user.IsSubscribed = stamp.After(time.Now())
user.ExpiredAt = stamp.Format("2006-01-02 15:04:05")
}
user.Enterprise = isEnterprise.Valid && isEnterprise.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
}
func subscriptionMigration(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)
func subscriptionMigration(db *sql.DB, id int64, expired string) error {
_, err := globals.ExecDb(db, `
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)
INSERT INTO subscription (user_id, expired_at) VALUES (?, ?)
ON DUPLICATE KEY UPDATE expired_at = ?
`, id, expired, expired)
return err
}

View File

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

8295
app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button.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 { Switch } from "@/components/ui/switch.tsx";
import { Alert, AlertDescription } from "./ui/alert";
@ -23,13 +23,22 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} 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 {
Text = "text",
Number = "number",
Switch = "switch",
Clock = "clock",
List = "list",
Empty = "empty",
}
type ParamProps = {
dataList?: string[];
dataListTranslated?: string;
};
export type PopupDialogProps = {
title: string;
@ -38,6 +47,7 @@ export type PopupDialogProps = {
placeholder?: string;
defaultValue?: string;
onValueChange?: (value: string) => string;
params?: ParamProps;
onSubmit?: (value: string) => Promise<boolean>;
destructive?: boolean;
disabled?: boolean;
@ -63,6 +73,7 @@ function PopupField({
type,
setValue,
onValueChange,
params,
value,
placeholder,
componentProps,
@ -81,7 +92,18 @@ function PopupField({
{...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:
return (
<NumberInput
@ -111,7 +133,150 @@ function PopupField({
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) {
const {
title,

View File

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

View File

@ -49,7 +49,9 @@ function ShareContent({ data }: ShareTableProps) {
const time = useMemo(() => {
return data.map((row) => {
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]);

View File

@ -462,7 +462,9 @@
"is-banned": "封禁",
"used-quota": "已用点数",
"is-subscribed": "是否订阅",
"is-subscribed-tips": "是否订阅评判逻辑: 有订阅等级且订阅时间未过期",
"total-month": "总计订阅月数",
"expired-at": "订阅到期时间",
"enterprise": "企业版",
"action": "操作",
"search-username": "搜索用户名",
@ -482,12 +484,12 @@
"quota-action-desc": "请输入点数变更值(正数为增加,负数为减少)",
"quota-set-action": "点数设置",
"quota-set-action-desc": "设置用户的点数",
"subscription-action": "订阅管理",
"subscription-action-desc": "请输入赠送的订阅月数",
"subscription-action": "订阅时间管理",
"subscription-action-desc": "请设置用户 {{username}} 的订阅到期时间",
"release-subscription-action": "释放订阅用量",
"release-subscription-action-desc": "是否释放用户的订阅用量?",
"subscription-level": "设置订阅等级",
"subscription-level-desc": "设置用户的订阅等级 (0 为普通用户, 1 为基础版订阅, 2 为标准版订阅, 3 为专业版订阅)",
"subscription-level-desc": "设置用户的订阅等级",
"operate-success": "操作成功",
"operate-success-prompt": "您的操作已成功执行。",
"operate-failed": "操作失败",
@ -800,5 +802,16 @@
"edit": "编辑预设",
"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.",
"created-at": "Creation 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": {
"title": "Mask Settings",
@ -801,5 +803,16 @@
"min-quota": "Minimum Balance",
"your-quota": "Your balance",
"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": "通知には最新の通知のみが表示され、一度だけ通知されます。サイトのお知らせは、システム設定で設定できます。ポップアップウィンドウがホームページに初めて表示され、その後の表示がサポートされます。",
"created-at": "作成日時",
"used-at": "乗車時間",
"used-username": "ユーザーを請求する"
"used-username": "ユーザーを請求する",
"is-subscribed-tips": "サブスクリプション判断ロジック:サブスクリプションティアがあり、サブスクリプション期間が満了していません",
"expired-at": "サブスクリプションの有効期限"
},
"mask": {
"title": "プリセット設定",
@ -802,5 +804,16 @@
"min-quota": "最低残高",
"your-quota": "残高",
"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": "Уведомления будут отображаться только самые последние и будут уведомлены только один раз. Объявления сайта можно задать в системных настройках. Всплывающее окно будет отображаться на главной странице в первый раз и будет поддерживаться последующий просмотр.",
"created-at": "Время создания",
"used-at": "Время награждения",
"used-username": "Получить пользователя"
"used-username": "Получить пользователя",
"is-subscribed-tips": "Логика суждения о подписке: существует уровень подписки, и период подписки не истек",
"expired-at": "Срок действия подписки"
},
"mask": {
"title": "Настройки маски",
@ -802,5 +804,16 @@
"min-quota": "Минимальный баланс",
"your-quota": "Ваш баланс",
"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`}>
<SelectValue
placeholder={data.protocol ? t("admin.system.mailProtocolTLS") : t("admin.system.mailProtocolSSL")}
placeholder={
data.protocol
? t("admin.system.mailProtocolTLS")
: t("admin.system.mailProtocolSSL")
}
/>
</SelectTrigger>
<SelectContent>
@ -389,9 +393,7 @@ function Mail({ data, dispatch, onChange }: CompProps<MailState>) {
value: e.target.value,
})
}
className={cn(
"transition-all duration-300",
)}
className={cn("transition-all duration-300")}
placeholder={t("admin.system.mailUser")}
/>
</ParagraphItem>
@ -423,9 +425,7 @@ function Mail({ data, dispatch, onChange }: CompProps<MailState>) {
})
}
placeholder={`${data.username}@${location.hostname}`}
className={cn(
"transition-all duration-300",
)}
className={cn("transition-all duration-300")}
/>
</ParagraphItem>
<ParagraphSpace />

View File

@ -8,7 +8,9 @@ import {
import { RootState } from "@/store/index.ts";
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 = {
context: true,
align: false,

File diff suppressed because one or more lines are too long