Compare commits

..

7 Commits

33 changed files with 2847 additions and 2190 deletions
+442 -303
View File
File diff suppressed because it is too large Load Diff
+28 -9
View File
@@ -1,30 +1,36 @@
import { fetcher, FetcherMethod, swrFetcher } from './api' // 导入正确的 fetcher 函数和方法枚举
import type { Domain, BillingDataMod} from '@/types/api'
// 导入正确的 fetcher 函数和方法枚举
import type { BillingDataMod, Domain } from "@/types/domain"
import { FetcherMethod, fetcher } from "./api"
// --- GET 请求 (用于 SWR) ---
// 获取域名列表的函数,专门为 useSWR 设计
// swrFetcher 内部会调用 fetcher,这一部分是正确的
export const useDomainList = () => {
return swrFetcher<Domain[]>('/api/v1/domains')
export const useDomainList = (url: string) => {
return fetcher<Domain[]>(FetcherMethod.GET, url)
}
// --- POST, PUT, DELETE 请求 (使用 fetcher) ---
// 添加一个新的域名
export const addDomain = (domain: string) => {
return fetcher<Domain>(FetcherMethod.POST, '/api/v1/domains', { domain })
return fetcher<Domain>(FetcherMethod.POST, "/api/v1/domains", { domain })
}
// 触发域名验证
export const verifyDomain = (id: number) => {
return fetcher<{ success: boolean; message: string }>(FetcherMethod.POST, `/api/v1/domains/${id}/verify`)
return fetcher<{ success: boolean; message: string }>(
FetcherMethod.POST,
`/api/v1/domains/${id}/verify`,
)
}
// 更新域名的配置信息
export const updateDomainConfig = (id: number, billingData: BillingDataMod) => {
return fetcher<Domain>(FetcherMethod.PUT, `/api/v1/domains/${id}`, { billing_data: billingData })
return fetcher<Domain>(FetcherMethod.PUT, `/api/v1/domains/${id}`, {
billing_data: billingData,
})
}
// 删除一个域名
@@ -34,6 +40,19 @@ export const deleteDomain = (id: number) => {
}
// 更新一个域名(包括公开状态和配置信息)
export const updateDomain = (id: number, data: { is_public: boolean, billing_data: BillingDataMod }) => {
export const updateDomain = (
id: number,
data: { is_public: boolean; billing_data: BillingDataMod },
) => {
return fetcher<Domain>(FetcherMethod.PUT, `/api/v1/domains/${id}`, data)
}
// 同步 Whois 信息
export const syncDomainWHOIS = (id: number) => {
return fetcher<Domain>(FetcherMethod.POST, `/api/v1/domains/${id}/sync`)
}
// 同步所有域名
export const syncAllDomains = () => {
return fetcher<any>(FetcherMethod.POST, "/api/v1/domains/sync-all")
}
+14 -16
View File
@@ -1,4 +1,6 @@
import { ModeToggle } from "@/components/mode-toggle"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
@@ -9,12 +11,24 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
import { IconButton } from "@/components/xui/icon-button"
import { NzNavigationMenuLink } from "@/components/xui/navigation-menu"
import { useAuth } from "@/hooks/useAuth"
import { useMainStore } from "@/hooks/useMainStore"
import { useMediaQuery } from "@/hooks/useMediaQuery"
@@ -26,21 +40,6 @@ import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useLocation, useNavigate } from "react-router-dom"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { IconButton } from "@/components/xui/icon-button"
import { NzNavigationMenuLink } from "@/components/xui/navigation-menu"
// =======================================================
// vvvvvvvvvvv 1. 在这里为移动端菜单添加新页面 vvvvvvvvvvv
const pages = [
@@ -428,4 +427,3 @@ function Overview() {
</section>
)
}
+108 -38
View File
@@ -30,7 +30,6 @@ import {
SelectValue,
} from "@/components/ui/select"
import { IconButton } from "@/components/xui/icon-button"
import { asOptionalField } from "@/lib/utils"
import { ModelNotification } from "@/types"
import { nrequestMethods, nrequestTypes } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
@@ -50,14 +49,15 @@ interface NotifierCardProps {
const notificationFormSchema = z.object({
name: z.string().min(1),
url: z.string().url(),
url: z.string().min(1),
request_method: z.coerce.number().int().min(1).max(255),
request_type: z.coerce.number().int().min(1).max(255),
request_header: z.string(),
request_body: z.string(),
verify_tls: asOptionalField(z.boolean()),
skip_check: asOptionalField(z.boolean()),
format_metric_units: asOptionalField(z.boolean()),
verify_tls: z.boolean().default(false),
skip_check: z.boolean().default(false),
format_metric_units: z.boolean().default(false),
type: z.coerce.number().int().default(1),
})
export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
@@ -74,9 +74,10 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
request_type: data.request_type ?? 1,
request_header: data.request_header ?? "",
request_body: data.request_body ?? "",
verify_tls: (data as any).verify_tls ?? false,
skip_check: (data as any).skip_check ?? false,
format_metric_units: (data as any).format_metric_units ?? false,
verify_tls: data.verify_tls ?? false,
skip_check: data.skip_check ?? false,
format_metric_units: data.format_metric_units ?? false,
type: data.type ?? 1,
}
: {
name: "",
@@ -88,6 +89,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
verify_tls: false,
skip_check: false,
format_metric_units: false,
type: 1,
},
resetOptions: {
keepDefaultValues: false,
@@ -143,19 +145,59 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Notification Type</FormLabel>
<Select
onValueChange={field.onChange}
value={`${field.value}`}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1">Webhook</SelectItem>
<SelectItem value="2">SMTP (Email)</SelectItem>
<SelectItem value="3">Telegram</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormLabel>
{form.watch("type") == 2
? "SMTP Server (host:port)"
: form.watch("type") == 3
? "Bot Token"
: "URL"}
</FormLabel>
<FormControl>
<Input {...field} />
<Input
{...field}
placeholder={
form.watch("type") == 3
? "123456:ABC-DEF"
: ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("type") != 2 && form.watch("type") != 3 && (
<>
<FormField
control={form.control}
name="request_method"
@@ -201,27 +243,43 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(nrequestTypes).map(([k, v]) => (
{Object.entries(nrequestTypes).map(
([k, v]) => (
<SelectItem key={k} value={k}>
{v}
</SelectItem>
))}
),
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="request_header"
render={({ field }) => (
<FormItem>
<FormLabel>{t("RequestHeader")}</FormLabel>
<FormLabel>
{form.watch("type") == 2
? "SMTP User:Pass"
: form.watch("type") == 3
? "Chat ID"
: t("RequestHeader")}
</FormLabel>
<FormControl>
<Textarea
className="resize-y"
placeholder='{"User-Agent":"Nezha-Agent"}'
placeholder={
form.watch("type") == 2
? "user:pass"
: form.watch("type") == 3
? "123456789"
: '{"User-Agent":"Nezha-Agent"}'
}
{...field}
/>
</FormControl>
@@ -229,16 +287,29 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
{form.watch("type") != 3 && (
<FormField
control={form.control}
name="request_body"
render={({ field }) => (
<FormItem>
<FormLabel>{t("RequestBody")}</FormLabel>
<FormLabel>
{form.watch("type") == 2
? "Recipient Email"
: t("RequestBody")}
</FormLabel>
<FormControl>
<Textarea
className="resize-y h-[240px]"
placeholder='{&#13;&#10; "content":"#NEZHA#",&#13;&#10; "ServerName":"#SERVER.NAME#",&#13;&#10; "ServerIP":"#SERVER.IP#",&#13;&#10; "ServerIPV4":"#SERVER.IPV4#",&#13;&#10; "ServerIPV6":"#SERVER.IPV6#",&#13;&#10; "CPU":"#SERVER.CPU#",&#13;&#10; "MEM":"#SERVER.MEM#",&#13;&#10; "SWAP":"#SERVER.SWAP#",&#13;&#10; "DISK":"#SERVER.DISK#",&#13;&#10; "NetInSpeed":"#SERVER.NETINSPEED#",&#13;&#10; "NetOutSpeed":"#SERVER.NETOUTSPEED#",&#13;&#10; "TransferIn":"#SERVER.TRANSFERIN#",&#13;&#10; "TranferOut":"#SERVER.TRANSFEROUT#",&#13;&#10; "Load1":"#SERVER.LOAD1#",&#13;&#10; "Load5":"#SERVER.LOAD5#",&#13;&#10; "Load15":"#SERVER.LOAD15#",&#13;&#10; "TCP_CONN_COUNT":"#SERVER.TCPCONNCOUNT",&#13;&#10; "UDP_CONN_COUNT":"#SERVER.UDPCONNCOUNT"&#13;&#10;}'
className={
form.watch("type") == 2
? "resize-y"
: "resize-y h-[240px]"
}
placeholder={
form.watch("type") == 2
? "target@example.com"
: "..."
}
{...field}
/>
</FormControl>
@@ -246,23 +317,26 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
)}
<div className="pt-4 border-t space-y-3">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("AdvancedSettings")}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
<FormField
control={form.control}
name="verify_tls"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex items-center space-x-2 space-y-0 py-1">
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
{t("VerifyTLS")}
</Label>
</div>
</FormControl>
<FormMessage />
<FormLabel className="text-sm font-normal cursor-pointer">
{t("VerifyTLS")}
</FormLabel>
</FormItem>
)}
/>
@@ -270,19 +344,16 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
control={form.control}
name="skip_check"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex items-center space-x-2 space-y-0 py-1">
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
{t("DoNotSendTestMessage")}
</Label>
</div>
</FormControl>
<FormMessage />
<FormLabel className="text-sm font-normal cursor-pointer">
{t("DoNotSendTestMessage")}
</FormLabel>
</FormItem>
)}
/>
@@ -290,22 +361,21 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
control={form.control}
name="format_metric_units"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex items-center space-x-2 space-y-0 py-1">
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
{t("FormatMetricUnits")}
</Label>
</div>
</FormControl>
<FormMessage />
<FormLabel className="text-sm font-normal cursor-pointer">
{t("FormatMetricUnits")}
</FormLabel>
</FormItem>
)}
/>
</div>
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" className="my-2" variant="secondary">
+9 -5
View File
@@ -160,13 +160,17 @@ export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConf
const onSubmit = async (values: any) => {
let resp: ModelServerTaskResponse = {}
try {
values.nic_allowlist = values.nic_allowlist_raw
? JSON.parse(values.nic_allowlist_raw)
const submitValues = { ...values }
submitValues.nic_allowlist = submitValues.nic_allowlist_raw
? JSON.parse(submitValues.nic_allowlist_raw)
: undefined
values.hard_drive_partition_allowlist = values.hard_drive_partition_allowlist_raw
? JSON.parse(values.hard_drive_partition_allowlist_raw)
submitValues.hard_drive_partition_allowlist =
submitValues.hard_drive_partition_allowlist_raw
? JSON.parse(submitValues.hard_drive_partition_allowlist_raw)
: undefined
resp = await setServerConfig({ config: JSON.stringify(values), servers: [sid] })
delete submitValues.nic_allowlist_raw
delete submitValues.hard_drive_partition_allowlist_raw
resp = await setServerConfig({ config: JSON.stringify(submitValues), servers: [sid] })
} catch (e) {
console.error(e)
toast(t("Error"), {
+80 -16
View File
@@ -28,7 +28,7 @@ import { conv } from "@/lib/utils"
import { asOptionalField } from "@/lib/utils"
import { ModelServer } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState, useEffect } from "react"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -65,6 +65,13 @@ const serverFormSchema = z.object({
},
),
),
billing_data: z
.object({
registrar: asOptionalField(z.string()),
endDate: asOptionalField(z.string()),
notes: asOptionalField(z.string()),
})
.optional(),
})
export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
@@ -87,16 +94,16 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
useEffect(() => {
const handleMessage = (e: MessageEvent) => {
if (e.data?.type === 'NZCFG_JSON') {
if (e.data.target === 'public_note') {
form.setValue('public_note', e.data.payload);
toast(t("Success"), { description: "配置已通过可视化构建器自动填入" });
if (e.data?.type === "NZCFG_JSON") {
if (e.data.target === "public_note") {
form.setValue("public_note", e.data.payload)
toast(t("Success"), { description: "配置已通过可视化构建器自动填入" })
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [form, t]);
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [form, t])
const onSubmit = async (values: any) => {
try {
@@ -249,6 +256,53 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
<div className="p-3 border rounded-md border-dashed space-y-2">
<Label className="text-xs text-muted-foreground uppercase font-bold">
Billing & Expiry
</Label>
<FormField
control={form.control as any}
name="billing_data.registrar"
render={({ field }) => (
<FormItem>
<FormLabel>Registrar</FormLabel>
<FormControl>
<Input
placeholder="AWS / Azure /阿里云"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control as any}
name="billing_data.endDate"
render={({ field }) => (
<FormItem>
<FormLabel>Expiry Date</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value?.split("T")[0] || ""}
onChange={(e) =>
field.onChange(
e.target.value
? new Date(
e.target.value,
).toISOString()
: "",
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control as any}
name="public_note"
@@ -256,18 +310,28 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
<FormItem>
<FormLabel className="flex justify-between items-center w-full">
<span>{t("Public") + t("Note")}</span>
<a href="/dashboard/nzcfg.html" target="_blank" className="text-blue-500 hover:text-blue-700 text-xs flex items-center gap-1" onClick={(e) => {
e.preventDefault();
const popup = window.open('/dashboard/nzcfg.html', 'nzcfg', 'width=1000,height=800');
<a
href="/dashboard/nzcfg.html"
target="_blank"
className="text-blue-500 hover:text-blue-700 text-xs flex items-center gap-1"
onClick={(e) => {
e.preventDefault()
const popup = window.open(
"/dashboard/nzcfg.html",
"nzcfg",
"width=1000,height=800",
)
if (popup) {
const timer = setInterval(() => {
if (popup.closed) {
clearInterval(timer);
clearInterval(timer)
}
}, 500);
}, 500)
}
}}>
<i className="fa-solid fa-up-right-from-square"></i>
}}
>
{" "}
<i className="fa-solid fa-up-right-from-square"></i>
</a>
</FormLabel>
<FormControl>
+15 -8
View File
@@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useState } from "react"
import { DateTime } from "luxon"
export type Theme = "dark" | "light" | "system"
@@ -30,22 +31,28 @@ export function ThemeProvider({
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
)
const [hour, setHour] = useState(() => DateTime.now().hour)
useEffect(() => {
const timer = setInterval(() => {
setHour(DateTime.now().hour)
}, 60000)
return () => clearInterval(timer)
}, [])
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
let effectiveTheme = theme
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
const isNight = hour >= 18 || hour < 6
effectiveTheme = isNight ? "dark" : "light"
}
root.classList.add(theme)
}, [theme])
root.classList.add(effectiveTheme)
}, [theme, hour])
const value = {
theme,
+1 -2
View File
@@ -23,8 +23,7 @@ const badgeVariants = cva(
)
export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
+1 -2
View File
@@ -31,8 +31,7 @@ const buttonVariants = cva(
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
+2 -1
View File
@@ -67,7 +67,8 @@ const multiSelectVariants = cva(
* Props for MultiSelect component
*/
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
+2 -1
View File
@@ -37,7 +37,8 @@ const sheetVariants = cva(
)
interface SheetContentProps
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
extends
ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
setOpen: Dispatch<SetStateAction<boolean>>
}
+1 -1
View File
@@ -45,7 +45,7 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({
(async () => {
try {
const n = (await getNotification()) || []
const nData = n.map(({ id, name }) => ({ id, name }))
const nData = n.map(({ id, name }) => ({ id: id!, name }))
setNotifier(nData)
} catch (error: any) {
toast("NotificationProvider Error", {
+1 -1
View File
@@ -45,7 +45,7 @@ export const ServerProvider: React.FC<ServerProviderProps> = ({
(async () => {
try {
const s = (await getServers()) || []
const serverData = s.map(({ id, name }) => ({ id, name }))
const serverData = s.map(({ id, name }) => ({ id: id!, name }))
setServer(serverData)
} catch (error: any) {
toast("ServerProvider Error", {
+2
View File
@@ -151,6 +151,8 @@
"AgentRealIPHeader": "Agent real IP request header",
"UseDirectConnectingIP": "Use direct connection IP",
"IPChangeNotification": "IP Change Notification",
"IPChangeNotificationGroupID": "IP Change Notification Group ID",
"ExpiryNotificationGroupID": "Expiry Notification Group ID",
"FullIPNotification": "Show Full IP Address in Notification Messages",
"EditService": "Edit Service",
"CreateService": "Create Service",
+2
View File
@@ -158,6 +158,8 @@
"AgentRealIPHeader": "Agent真实IP请求头",
"UseDirectConnectingIP": "使用直连 IP",
"IPChangeNotification": "IP 变更通知",
"IPChangeNotificationGroupID": "IP 变更通知组 ID",
"ExpiryNotificationGroupID": "到期通知组 ID",
"FullIPNotification": "在通知消息中显示完整的 IP 地址",
"LoginFailed": "登录失败",
"BruteForceAttackingToken": "暴力攻击令牌",
+2 -2
View File
@@ -116,7 +116,7 @@ export default function AlertRulePage() {
className="flex gap-2"
delete={{
fn: deleteAlertRules,
id: s.id,
id: s.id!,
mutate: mutate,
}}
>
@@ -147,7 +147,7 @@ export default function AlertRulePage() {
className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0"
delete={{
fn: deleteAlertRules,
id: selectedRows.map((r) => r.original.id),
id: selectedRows.map((r) => r.original.id!),
mutate: mutate,
}}
>
+3 -3
View File
@@ -164,7 +164,7 @@ export default function CronPage() {
return (
<ActionButtonGroup
className="flex gap-2"
delete={{ fn: deleteCron, id: s.id, mutate: mutate }}
delete={{ fn: deleteCron, id: s.id!, mutate: mutate }}
>
<>
<IconButton
@@ -172,7 +172,7 @@ export default function CronPage() {
icon="play"
onClick={async () => {
try {
await runCron(s.id)
await runCron(s.id!)
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -215,7 +215,7 @@ export default function CronPage() {
className="flex gap-2 flex-wrap shrink-0"
delete={{
fn: deleteCron,
id: selectedRows.map((r) => r.original.id),
id: selectedRows.map((r) => r.original.id!),
mutate: mutate,
}}
>
+2 -2
View File
@@ -121,7 +121,7 @@ export default function DDNSPage() {
className="flex gap-2"
delete={{
fn: deleteDDNSProfiles,
id: s.id,
id: s.id!,
mutate: mutate,
}}
>
@@ -152,7 +152,7 @@ export default function DDNSPage() {
className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0"
delete={{
fn: deleteDDNSProfiles,
id: selectedRows.map((r) => r.original.id),
id: selectedRows.map((r) => r.original.id!),
mutate: mutate,
}}
>
+279 -72
View File
@@ -1,27 +1,58 @@
// src/routes/domain.tsx (最终 Bug 修复版)
import { useState, useEffect } from 'react'
import { PlusCircle, RefreshCw, MoreVertical, Trash2, Edit, CheckCircle } from 'lucide-react'
import {
addDomain,
deleteDomain,
syncAllDomains,
syncDomainWHOIS,
updateDomain,
useDomainList,
verifyDomain,
} from "@/api/domain"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
// 导入 shadcn/ui 组件
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { toast } from 'sonner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
// 导入 API 类型和函数
import type { Domain, BillingDataMod } from '@/types/api'
import { useDomainList, addDomain, verifyDomain, deleteDomain, updateDomain } from '@/api/domain'
import useSWR from 'swr'
import type { BillingDataMod, Domain } from "@/types/domain"
import {
CheckCircle,
Edit,
MoreVertical,
PlusCircle,
RefreshCcw,
RefreshCw,
Trash2,
} from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import useSWR from "swr"
export default function DomainPage() {
// --- React State Hooks ---
@@ -29,9 +60,9 @@ export default function DomainPage() {
const [isLoading, setIsLoading] = useState(true)
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [newDomainName, setNewDomainName] = useState('')
const [newDomainName, setNewDomainName] = useState("")
const [verificationToken, setVerificationToken] = useState('')
const [verificationToken, setVerificationToken] = useState("")
const [isVerificationInfoModalOpen, setIsVerificationInfoModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
@@ -39,7 +70,12 @@ export default function DomainPage() {
const [editFormData, setEditFormData] = useState<Partial<BillingDataMod>>({})
// --- 数据获取 (使用 SWR) ---
const { data: domainData, error, mutate } = useSWR('/api/v1/domains', useDomainList, { revalidateOnFocus: false })
const {
data: domainData,
error,
mutate,
isValidating,
} = useSWR("/api/v1/domains", useDomainList, { revalidateOnFocus: false })
useEffect(() => {
if (domainData) {
@@ -47,14 +83,24 @@ export default function DomainPage() {
setIsLoading(false)
}
if (error) {
toast.error('无法加载域名列表,请检查后端服务是否正常。')
toast.error("无法加载域名列表,请检查后端服务是否正常。")
setIsLoading(false)
}
}, [domainData, error])
const handleRefreshAll = async () => {
try {
await syncAllDomains()
toast.success("刷新成功", { description: "已触发所有域名的状态同步。" })
mutate()
} catch (err) {
toast.error("刷新失败", { description: (err as Error).message })
}
}
const handleAddDomain = async () => {
if (!newDomainName) {
toast.error('请输入域名')
toast.error("请输入域名")
return
}
try {
@@ -62,10 +108,10 @@ export default function DomainPage() {
setVerificationToken(response.VerifyToken)
setIsAddModalOpen(false)
setIsVerificationInfoModalOpen(true)
setNewDomainName('')
setNewDomainName("")
mutate()
} catch (err) {
toast.error('添加失败', { description: (err as Error).message })
toast.error("添加失败", { description: (err as Error).message })
}
}
@@ -73,13 +119,24 @@ export default function DomainPage() {
try {
const response = await verifyDomain(domainId)
if (response.success) {
toast.success('验证成功', { description: response.message })
toast.success("验证成功", { description: response.message })
} else {
toast.warning('验证失败', { description: response.message })
toast.warning("验证失败", { description: response.message })
}
setTimeout(() => mutate(), 2000)
} catch (err) {
toast.error('操作失败', { description: (err as Error).message })
toast.error("操作失败", { description: (err as Error).message })
}
}
const handleSyncWhois = async (domainId: number) => {
const loadingToast = toast.loading("正在同步 Whois 信息...")
try {
await syncDomainWHOIS(domainId)
toast.success("同步成功", { id: loadingToast, description: "域名 Whois 信息已更新。" })
mutate()
} catch (err) {
toast.error("同步失败", { id: loadingToast, description: (err as Error).message })
}
}
@@ -87,10 +144,10 @@ export default function DomainPage() {
if (window.confirm(`确定要删除域名 ${domainName} 吗?`)) {
try {
await deleteDomain(domainId)
toast.success('删除成功', { description: `域名 ${domainName} 已被删除。` })
toast.success("删除成功", { description: `域名 ${domainName} 已被删除。` })
mutate()
} catch (err) {
toast.error('删除失败', { description: (err as Error).message })
toast.error("删除失败", { description: (err as Error).message })
}
}
}
@@ -104,7 +161,7 @@ export default function DomainPage() {
toast.success(`域名 ${domain.Domain} 的可见状态已更新`)
mutate()
} catch (err) {
toast.error('更新失败', { description: (err as Error).message })
toast.error("更新失败", { description: (err as Error).message })
}
}
@@ -124,32 +181,40 @@ export default function DomainPage() {
const handleUpdateDomain = async () => {
if (!currentDomain) return
try {
const dataToSend = { ...editFormData };
const dataToSend = { ...editFormData }
if (dataToSend.registeredDate) {
dataToSend.registeredDate = new Date(dataToSend.registeredDate).toISOString();
dataToSend.registeredDate = new Date(dataToSend.registeredDate).toISOString()
}
if (dataToSend.endDate) {
dataToSend.endDate = new Date(dataToSend.endDate).toISOString();
dataToSend.endDate = new Date(dataToSend.endDate).toISOString()
}
await updateDomain(currentDomain.ID, {
is_public: currentDomain.IsPublic,
billing_data: dataToSend as BillingDataMod
billing_data: dataToSend as BillingDataMod,
})
toast.success("更新成功", {
description: `域名 ${currentDomain.Domain} 的配置已保存。`,
})
toast.success('更新成功', { description: `域名 ${currentDomain.Domain} 的配置已保存。` })
setIsEditModalOpen(false)
mutate()
} catch (err) {
toast.error('更新失败', { description: (err as Error).message })
toast.error("更新失败", { description: (err as Error).message })
}
}
const getStatusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
const getStatusVariant = (
status: string,
): "default" | "secondary" | "destructive" | "outline" => {
switch (status) {
case 'verified': return 'default'
case 'pending': return 'secondary'
case 'expired': return 'destructive'
default: return 'outline'
case "verified":
return "default"
case "pending":
return "secondary"
case "expired":
return "destructive"
default:
return "outline"
}
}
@@ -163,21 +228,43 @@ export default function DomainPage() {
<CardDescription></CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={() => mutate()} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
<Button
variant="outline"
size="icon"
onClick={handleRefreshAll}
disabled={isValidating}
>
<RefreshCw className={`h-4 w-4 ${isValidating ? "animate-spin" : ""}`} />
</Button>
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogTrigger asChild><Button><PlusCircle className="mr-2 h-4 w-4" /></Button></DialogTrigger>
<DialogTrigger asChild>
<Button>
<PlusCircle className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> "example.com"</DialogDescription>
<DialogDescription>
"example.com"
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input value={newDomainName} onChange={(e) => setNewDomainName(e.target.value)} placeholder="your-domain.com" onKeyUp={(e) => e.key === 'Enter' && handleAddDomain()} />
<Input
value={newDomainName}
onChange={(e) => setNewDomainName(e.target.value)}
placeholder="your-domain.com"
onKeyUp={(e) => e.key === "Enter" && handleAddDomain()}
/>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setIsAddModalOpen(false)}></Button>
<Button
variant="secondary"
onClick={() => setIsAddModalOpen(false)}
>
</Button>
<Button onClick={handleAddDomain}></Button>
</DialogFooter>
</DialogContent>
@@ -185,7 +272,9 @@ export default function DomainPage() {
</div>
</CardHeader>
<CardContent>
{isLoading ? ( <div className="text-center py-10 text-muted-foreground">...</div> ) : (
{isLoading ? (
<div className="text-center py-10 text-muted-foreground">...</div>
) : (
<Table>
<TableHeader>
<TableRow>
@@ -199,9 +288,15 @@ export default function DomainPage() {
<TableBody>
{domains.map((domain) => (
<TableRow key={domain.ID}>
<TableCell className="font-medium">{domain.Domain}</TableCell>
<TableCell><Badge variant={getStatusVariant(domain.Status)}>{domain.Status}</Badge></TableCell>
<TableCell>{domain.expires_in_days ?? 'N/A'}</TableCell>
<TableCell className="font-medium">
{domain.Domain}
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(domain.Status)}>
{domain.Status}
</Badge>
</TableCell>
<TableCell>{domain.expires_in_days ?? "N/A"}</TableCell>
<TableCell>
<Switch
checked={domain.IsPublic}
@@ -210,11 +305,43 @@ export default function DomainPage() {
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreVertical className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{domain.Status === 'pending' && (<DropdownMenuItem onClick={() => handleVerify(domain.ID)}><CheckCircle className="mr-2 h-4 w-4" /> </DropdownMenuItem>)}
<DropdownMenuItem onClick={() => handleEditClick(domain)}><Edit className="mr-2 h-4 w-4" /> </DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => handleDelete(domain.ID, domain.Domain)}><Trash2 className="mr-2 h-4 w-4" /> </DropdownMenuItem>
{domain.Status === "pending" && (
<DropdownMenuItem
onClick={() => handleVerify(domain.ID)}
>
<CheckCircle className="mr-2 h-4 w-4" />{" "}
</DropdownMenuItem>
)}
{domain.Status === "verified" && (
<DropdownMenuItem
onClick={() =>
handleSyncWhois(domain.ID)
}
>
<RefreshCcw className="mr-2 h-4 w-4" />{" "}
Whois
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleEditClick(domain)}
>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() =>
handleDelete(domain.ID, domain.Domain)
}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -227,23 +354,41 @@ export default function DomainPage() {
</Card>
{/* 验证信息弹窗 */}
<Dialog open={isVerificationInfoModalOpen} onOpenChange={setIsVerificationInfoModalOpen}>
<Dialog
open={isVerificationInfoModalOpen}
onOpenChange={setIsVerificationInfoModalOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> DNS TXT </DialogDescription>
<DialogDescription>
DNS TXT
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
<p> DNS </p>
<div className="p-2 bg-muted rounded-md text-sm">
<p><span className="font-semibold">:</span> TXT</p>
<p><span className="font-semibold">/:</span> @</p>
<p>
<span className="font-semibold">:</span> TXT
</p>
<p>
<span className="font-semibold">/:</span> @
</p>
<p className="font-semibold">:</p>
<p className="font-mono bg-background p-2 rounded">{verificationToken}</p>
<p className="font-mono bg-background p-2 rounded">
{verificationToken}
</p>
</div>
<p className="text-xs text-muted-foreground">DNS </p>
<p className="text-xs text-muted-foreground">
DNS
</p>
</div>
<DialogFooter><Button onClick={() => setIsVerificationInfoModalOpen(false)}></Button></DialogFooter>
<DialogFooter>
<Button onClick={() => setIsVerificationInfoModalOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -252,17 +397,79 @@ export default function DomainPage() {
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> <span className="font-mono">{currentDomain?.Domain}</span> </DialogDescription>
<DialogDescription>
<span className="font-mono">{currentDomain?.Domain}</span>{" "}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="registrar" className="text-right"></Label><Input id="registrar" name="registrar" value={editFormData.registrar || ''} onChange={handleEditFormChange} className="col-span-3" /></div>
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="registeredDate" className="text-right"></Label><Input id="registeredDate" name="registeredDate" type="date" value={editFormData.registeredDate?.split('T')[0] || ''} onChange={handleEditFormChange} className="col-span-3" /></div>
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="endDate" className="text-right"></Label><Input id="endDate" name="endDate" type="date" value={editFormData.endDate?.split('T')[0] || ''} onChange={handleEditFormChange} className="col-span-3" /></div>
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="renewalPrice" className="text-right"></Label><Input id="renewalPrice" name="renewalPrice" value={editFormData.renewalPrice || ''} onChange={handleEditFormChange} className="col-span-3" /></div>
<div className="grid grid-cols-4 items-center gap-4"><Label htmlFor="notes" className="text-right"></Label><Textarea id="notes" name="notes" value={editFormData.notes || ''} onChange={handleEditFormChange} className="col-span-3" /></div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="registrar" className="text-right">
</Label>
<Input
id="registrar"
name="registrar"
value={editFormData.registrar || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="registeredDate" className="text-right">
</Label>
<Input
id="registeredDate"
name="registeredDate"
type="date"
value={editFormData.registeredDate?.split("T")[0] || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="endDate" className="text-right">
</Label>
<Input
id="endDate"
name="endDate"
type="date"
value={editFormData.endDate?.split("T")[0] || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="renewalPrice" className="text-right">
</Label>
<Input
id="renewalPrice"
name="renewalPrice"
value={editFormData.renewalPrice || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="notes" className="text-right">
</Label>
<Textarea
id="notes"
name="notes"
value={editFormData.notes || ""}
onChange={handleEditFormChange}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setIsEditModalOpen(false)}></Button>
<Button variant="secondary" onClick={() => setIsEditModalOpen(false)}>
</Button>
<Button onClick={handleUpdateDomain}></Button>
</DialogFooter>
</DialogContent>
+2 -2
View File
@@ -106,7 +106,7 @@ export default function NATPage() {
return (
<ActionButtonGroup
className="flex gap-2"
delete={{ fn: deleteNAT, id: s.id, mutate: mutate }}
delete={{ fn: deleteNAT, id: s.id!, mutate: mutate }}
>
<NATCard mutate={mutate} data={s} />
</ActionButtonGroup>
@@ -135,7 +135,7 @@ export default function NATPage() {
className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0"
delete={{
fn: deleteNAT,
id: selectedRows.map((r) => r.original.id),
id: selectedRows.map((r) => r.original.id!),
mutate: mutate,
}}
>
+2 -2
View File
@@ -97,7 +97,7 @@ export default function NotificationGroupPage() {
className="flex gap-2"
delete={{
fn: deleteNotificationGroups,
id: s.group.id,
id: s.group.id!,
mutate: mutate,
}}
>
@@ -128,7 +128,7 @@ export default function NotificationGroupPage() {
className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0"
delete={{
fn: deleteNotificationGroups,
id: selectedRows.map((r) => r.original.group.id),
id: selectedRows.map((r) => r.original.group.id!),
mutate: mutate,
}}
>
+3 -3
View File
@@ -83,7 +83,7 @@ export default function NotificationPage() {
accessorFn: (row) => {
return (
notifierGroup
?.filter((ng) => ng.notifications?.includes(row.id))
?.filter((ng) => ng.notifications?.includes(row.id!))
.map((ng) => ng.group.id) || []
)
},
@@ -112,7 +112,7 @@ export default function NotificationPage() {
className="flex gap-2"
delete={{
fn: deleteNotification,
id: s.id,
id: s.id!,
mutate: mutate,
}}
>
@@ -143,7 +143,7 @@ export default function NotificationPage() {
className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0"
delete={{
fn: deleteNotification,
id: selectedRows.map((r) => r.original.id),
id: selectedRows.map((r) => r.original.id!),
mutate: mutate,
}}
>
+6 -6
View File
@@ -22,7 +22,7 @@ import {
TableRow,
} from "@/components/ui/table"
import { useAuth } from "@/hooks/useAuth"
import { ModelOnlineUser, ModelOnlineUserApi } from "@/types"
import { GithubComNezhahqNezhaModelPaginatedResponseArrayModelOnlineUserModelOnlineUser, ModelOnlineUser } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
@@ -40,7 +40,7 @@ export default function OnlineUserPage() {
// 计算 offset
const offset = (page - 1) * pageSize
const { data, mutate, error, isLoading } = useSWR<ModelOnlineUserApi, Error>(
const { data, mutate, error, isLoading } = useSWR<GithubComNezhahqNezhaModelPaginatedResponseArrayModelOnlineUserModelOnlineUser, Error>(
`/api/v1/online-user?offset=${offset}&limit=${pageSize}`,
swrFetcher,
)
@@ -94,7 +94,7 @@ export default function OnlineUserPage() {
accessorFn: (row) => row.connected_at,
cell: ({ row }) => {
const s = row.original
const date = new Date(s.connected_at)
const date = new Date(s.connected_at!)
return <span>{date.toISOString()}</span>
},
},
@@ -125,7 +125,7 @@ export default function OnlineUserPage() {
}
const dataCache = useMemo(() => {
return data?.value ?? []
return data?.data?.value ?? []
}, [data])
const table = useReactTable<ModelOnlineUser>({
@@ -137,9 +137,9 @@ export default function OnlineUserPage() {
const selectedRows = table.getSelectedRowModel().rows
const renderPagination = () => {
if (!data?.pagination) return null
if (!data?.data?.pagination) return null
const { total } = data.pagination
const { total = 0 } = data.data.pagination
const totalPages = Math.ceil(total / pageSize)
const handlePageChange = (newPage: number) => {
+4 -2
View File
@@ -36,8 +36,10 @@ export default function Root() {
return (
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<section className="text-sm mx-auto h-full flex flex-col justify-between">
<div>
<section
className="text-sm mx-auto h-full flex flex-col justify-between relative z-10 bg-background"
>
<div className="flex-1">
<Header />
<div className="max-w-5xl mx-auto">
<Outlet />
+2 -2
View File
@@ -97,7 +97,7 @@ export default function ServerGroupPage() {
className="flex gap-2"
delete={{
fn: deleteServerGroups,
id: s.group.id,
id: s.group.id!,
mutate: mutate,
}}
>
@@ -128,7 +128,7 @@ export default function ServerGroupPage() {
className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0"
delete={{
fn: deleteServerGroups,
id: selectedRows.map((r) => r.original.group.id),
id: selectedRows.map((r) => r.original.group.id!),
mutate: mutate,
}}
>
+3 -1
View File
@@ -212,7 +212,9 @@ export default function ServerPage() {
})
}}
/>
<BatchMoveServerIcon serverIds={selectedRows.map((r) => r.original.id) as number[]} />
<BatchMoveServerIcon
serverIds={selectedRows.map((r) => r.original.id) as number[]}
/>
<ServerConfigCardBatch
sid={selectedRows.map((r) => r.original.id) as number[]}
className="shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] bg-yellow-600 text-white hover:bg-yellow-500 dark:hover:bg-yellow-700 rounded-lg"
+2 -2
View File
@@ -152,7 +152,7 @@ export default function ServicePage() {
return (
<ActionButtonGroup
className="flex gap-2"
delete={{ fn: deleteService, id: s.id, mutate: mutate }}
delete={{ fn: deleteService, id: s.id!, mutate: mutate }}
>
<ServiceCard mutate={mutate} data={s} />
</ActionButtonGroup>
@@ -181,7 +181,7 @@ export default function ServicePage() {
className="flex gap-2 flex-wrap shrink-0"
delete={{
fn: deleteService,
id: selectedRows.map((r) => r.original.id),
id: selectedRows.map((r) => r.original.id!),
mutate: mutate,
}}
>
+76 -6
View File
@@ -3,6 +3,7 @@ import { SettingsTab } from "@/components/settings-tab"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Combobox } from "@/components/ui/combobox"
import {
Form,
FormControl,
@@ -21,9 +22,8 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { Combobox } from "@/components/ui/combobox"
import { useNotification } from "@/hooks/useNotfication"
import { useAuth } from "@/hooks/useAuth"
import { useNotification } from "@/hooks/useNotfication"
import useSetting from "@/hooks/useSetting"
import { asOptionalField } from "@/lib/utils"
import { nezhaLang, settingCoverageTypes } from "@/types"
@@ -57,6 +57,8 @@ const settingFormSchema = z.object({
custom_links: asOptionalField(z.string()),
background_image_day: asOptionalField(z.string()),
background_image_night: asOptionalField(z.string()),
telegram_bot_token: asOptionalField(z.string()),
telegram_admin_chat_id: asOptionalField(z.string()),
})
export default function SettingsPage() {
@@ -131,6 +133,36 @@ export default function SettingsPage() {
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
<FormField
control={form.control}
name="ip_change_notification_group_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("IPChangeNotificationGroupID")}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiry_notification_group_id"
render={({ field }) => (
<FormItem>
<FormLabel>Expiry Notification Group ID</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter Group ID"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="site_name"
@@ -151,7 +183,10 @@ export default function SettingsPage() {
<FormItem>
<FormLabel>Custom Logo URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/logo.png" {...field} />
<Input
placeholder="https://example.com/logo.png"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -177,7 +212,10 @@ export default function SettingsPage() {
<FormItem>
<FormLabel>Custom Links (JSON Array)</FormLabel>
<FormControl>
<Input placeholder='[{"link":"https://loohui.com/","name":"Blog","blank":false}]' {...field} />
<Input
placeholder='[{"link":"https://loohui.com/","name":"Blog","blank":false}]'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -190,7 +228,10 @@ export default function SettingsPage() {
<FormItem>
<FormLabel>Background Image (Day)</FormLabel>
<FormControl>
<Input placeholder="https://example.com/day.jpg" {...field} />
<Input
placeholder="https://example.com/day.jpg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -203,7 +244,36 @@ export default function SettingsPage() {
<FormItem>
<FormLabel>Background Image (Night)</FormLabel>
<FormControl>
<Input placeholder="https://example.com/night.jpg" {...field} />
<Input
placeholder="https://example.com/night.jpg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="telegram_bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram Bot Token</FormLabel>
<FormControl>
<Input placeholder="123456789:ABCDEF..." {...field} value={field.value as string || ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="telegram_admin_chat_id"
render={({ field }) => (
<FormItem>
<FormLabel>Telegram Admin Chat ID</FormLabel>
<FormControl>
<Input placeholder="12345678" {...field} value={field.value as string || ""} />
</FormControl>
<FormMessage />
</FormItem>
+2 -2
View File
@@ -90,7 +90,7 @@ export default function UserPage() {
className="flex gap-2"
delete={{
fn: deleteUser,
id: s.id,
id: s.id!,
mutate: mutate,
}}
>
@@ -121,7 +121,7 @@ export default function UserPage() {
className="flex-2 flex gap-2 ml-auto"
delete={{
fn: deleteUser,
id: selectedRows.map((r) => r.original.id),
id: selectedRows.map((r) => r.original.id!),
mutate: mutate,
}}
>
+3 -3
View File
@@ -98,13 +98,13 @@ export default function WAFPage() {
header: t("LastBlockReason"),
accessorKey: "lastBlockReason",
accessorFn: (row) => row.block_reason,
cell: ({ row }) => <span>{wafBlockReasons[row.original.block_reason] || ""}</span>,
cell: ({ row }) => <span>{wafBlockReasons[row.original.block_reason!] || ""}</span>,
},
{
header: t("BlockIdentifier"),
accessorKey: "BlockIdentifier",
accessorFn: (row) => {
return wafBlockIdentifiers[row.block_identifier] || row.block_identifier
return wafBlockIdentifiers[row.block_identifier!] || row.block_identifier
},
},
{
@@ -158,7 +158,7 @@ export default function WAFPage() {
const renderPagination = () => {
if (!data?.pagination) return null
const { total } = data.pagination
const { total = 0 } = data.pagination
const totalPages = Math.ceil(total / pageSize)
const handlePageChange = (newPage: number) => {
+456 -430
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
export interface BillingDataMod {
registrar?: string;
registeredDate?: string;
endDate?: string;
renewalPrice?: string;
autoRenewal?: string;
notes?: string;
cycle?: string;
amount?: string;
}
export interface Domain {
ID: number;
Domain: string;
Status: string;
VerifyToken: string;
IsPublic: boolean;
BillingData: BillingDataMod | null;
expires_in_days?: number;
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Config struct {
IPReportPeriod int `json:"ip_report_period"`
}
func main() {
jsonData := `{"ip_report_period":30, "unknown_field": 123}`
var c Config
dec := json.NewDecoder(strings.NewReader(jsonData))
dec.DisallowUnknownFields()
err := dec.Decode(&c)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Success")
}
}