diff --git a/apps/admin/app/dashboard/auth-control/forms/apple-form.tsx b/apps/admin/app/dashboard/auth-control/forms/apple-form.tsx index 770d145..af4f071 100644 --- a/apps/admin/app/dashboard/auth-control/forms/apple-form.tsx +++ b/apps/admin/app/dashboard/auth-control/forms/apple-form.tsx @@ -61,6 +61,7 @@ export default function AppleForm() { return data.data; }, + enabled: open, }); const form = useForm({ diff --git a/apps/admin/app/dashboard/auth-control/forms/facebook-form.tsx b/apps/admin/app/dashboard/auth-control/forms/facebook-form.tsx index 9672d2f..382ff95 100644 --- a/apps/admin/app/dashboard/auth-control/forms/facebook-form.tsx +++ b/apps/admin/app/dashboard/auth-control/forms/facebook-form.tsx @@ -52,7 +52,7 @@ export default function FacebookForm() { }); return data.data; }, - // 移除 enabled: open,现在默认加载数据 + enabled: open, }); const form = useForm({ diff --git a/apps/admin/app/dashboard/auth-control/forms/github-form.tsx b/apps/admin/app/dashboard/auth-control/forms/github-form.tsx index ca3687c..d1eee8a 100644 --- a/apps/admin/app/dashboard/auth-control/forms/github-form.tsx +++ b/apps/admin/app/dashboard/auth-control/forms/github-form.tsx @@ -53,6 +53,7 @@ export default function GithubForm() { return data.data; }, + enabled: open, }); const form = useForm({ diff --git a/apps/admin/app/dashboard/auth-control/forms/telegram-form.tsx b/apps/admin/app/dashboard/auth-control/forms/telegram-form.tsx index 6d01917..06d4908 100644 --- a/apps/admin/app/dashboard/auth-control/forms/telegram-form.tsx +++ b/apps/admin/app/dashboard/auth-control/forms/telegram-form.tsx @@ -53,6 +53,7 @@ export default function TelegramForm() { return data.data; }, + enabled: open, }); const form = useForm({ diff --git a/apps/admin/app/dashboard/nodes/node-form.tsx b/apps/admin/app/dashboard/nodes/node-form.tsx new file mode 100644 index 0000000..47b3a54 --- /dev/null +++ b/apps/admin/app/dashboard/nodes/node-form.tsx @@ -0,0 +1,362 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '@workspace/ui/components/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@workspace/ui/components/form'; +import { ScrollArea } from '@workspace/ui/components/scroll-area'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@workspace/ui/components/sheet'; +import { Combobox } from '@workspace/ui/custom-components/combobox'; +import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; +import TagInput from '@workspace/ui/custom-components/tag-input'; +import { useTranslations } from 'next-intl'; +import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +export type ProtocolName = + | 'shadowsocks' + | 'vmess' + | 'vless' + | 'trojan' + | 'hysteria2' + | 'tuic' + | 'anytls'; + +type ServerProtocolItem = { + protocol: ProtocolName; + enabled: boolean; + config?: { port?: number } & Record; +}; + +type ServerRow = { + id: number; + name: string; + server_addr: string; + protocols: ServerProtocolItem[]; +}; + +export type NodeFormValues = { + name: string; + server_id?: number; + protocol: ProtocolName | ''; + server_addr: string; + port?: number; + tags: string[]; +}; + +async function getServerListMock(): Promise<{ data: { list: ServerRow[] } }> { + return { + data: { + list: [ + { + id: 101, + name: 'Tokyo-1', + server_addr: 'jp-1.example.com', + protocols: [ + { protocol: 'shadowsocks', enabled: true, config: { port: 443 } }, + { protocol: 'vless', enabled: true, config: { port: 8443 } }, + { protocol: 'trojan', enabled: false, config: { port: 443 } }, + ], + }, + { + id: 102, + name: 'HK-Edge', + server_addr: 'hk-edge.example.com', + protocols: [ + { protocol: 'vmess', enabled: true, config: { port: 443 } }, + { protocol: 'vless', enabled: true, config: { port: 443 } }, + { protocol: 'hysteria2', enabled: true, config: { port: 60000 } }, + ], + }, + { + id: 103, + name: 'AnyTLS Lab', + server_addr: 'lab.example.com', + protocols: [ + { protocol: 'anytls', enabled: true, config: { port: 443 } }, + { protocol: 'tuic', enabled: false, config: { port: 4443 } }, + ], + }, + ], + }, + }; +} + +const buildSchema = (t: ReturnType) => + z + .object({ + name: z.string().min(1, t('errors.nameRequired')), + server_id: z.number({ invalid_type_error: t('errors.serverRequired') }).optional(), + protocol: z.string().min(1, t('errors.protocolRequired')), + server_addr: z.string().min(1, t('errors.serverAddrRequired')), + port: z + .number() + .int() + .min(1, t('errors.portRange')) + .max(65535, t('errors.portRange')) + .optional(), + tags: z.array(z.string()), + }) + .refine((v) => !!v.server_id, { path: ['server_id'], message: t('errors.serverRequired') }); + +export default function NodeForm(props: { + trigger: string; + title: string; + loading?: boolean; + initialValues?: Partial; + onSubmit: (values: NodeFormValues) => Promise | boolean; +}) { + const { trigger, title, loading, initialValues, onSubmit } = props; + const t = useTranslations('nodes'); + const schema = useMemo(() => buildSchema(t), [t]); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + server_id: undefined, + protocol: '', + server_addr: '', + port: undefined, + tags: [], + ...initialValues, + }, + }); + + const serverId = form.watch('server_id'); + + const { data } = useQuery({ queryKey: ['getServerListMock'], queryFn: getServerListMock }); + // eslint-disable-next-line react-hooks/exhaustive-deps + const servers: ServerRow[] = data?.data?.list ?? []; + + const currentServer = useMemo(() => servers.find((s) => s.id === serverId), [servers, serverId]); + + const availableProtocols = useMemo( + () => + (currentServer?.protocols || []) + .filter((p) => p.enabled) + .map((p) => ({ + protocol: p.protocol, + port: p.config?.port, + })), + [currentServer], + ); + + useEffect(() => { + if (initialValues) { + form.reset({ + name: '', + server_id: undefined, + protocol: '', + server_addr: '', + port: undefined, + tags: [], + ...initialValues, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues]); + + function handleServerChange(nextId?: number | null) { + const id = nextId ?? undefined; + form.setValue('server_id', id); + + const sel = servers.find((s) => s.id === id); + if (!form.getValues('server_addr') && sel?.server_addr) { + form.setValue('server_addr', sel.server_addr); + } + const allowed = (sel?.protocols || []).filter((p) => p.enabled).map((p) => p.protocol); + if (!allowed.includes(form.getValues('protocol') as ProtocolName)) { + form.setValue('protocol', '' as any); + } + } + + function handleProtocolChange(nextProto?: ProtocolName | null) { + const p = (nextProto || '') as ProtocolName | ''; + form.setValue('protocol', p); + if (!p || !currentServer) return; + const curPort = Number(form.getValues('port') || 0); + if (!curPort) { + const hit = currentServer.protocols.find((x) => x.protocol === p); + const port = hit?.config?.port; + if (typeof port === 'number' && port > 0) { + form.setValue('port', port); + } + } + } + + async function submit(values: NodeFormValues) { + const ok = await onSubmit(values); + if (ok) form.reset(); + return ok; + } + + return ( + + + + + + + + {title} + + +
+ + ( + + {t('name')} + + form.setValue(field.name, v as string)} + /> + + + + )} + /> + ( + + {t('tags')} + + form.setValue(field.name, v)} + /> + + + + )} + /> + + ( + + {t('server')} + + + placeholder={t('select_server')} + value={field.value} + options={servers.map((s) => ({ + value: s.id, + label: `${s.name} (${s.server_addr})`, + }))} + onChange={(v) => handleServerChange(v)} + /> + + + + )} + /> + ( + + {t('protocol')} + + + placeholder={t('select_protocol')} + value={field.value} + options={availableProtocols.map((p) => ({ + value: p.protocol, + label: `${p.protocol} (${p.port})`, + }))} + onChange={(v) => handleProtocolChange((v as ProtocolName) || null)} + /> + + + + )} + /> + + ( + + {t('server_addr')} + + form.setValue(field.name, v as string)} + /> + + + + )} + /> + + ( + + {t('port')} + + form.setValue(field.name, Number(v))} + /> + + + + )} + /> + + +
+ + + + + +
+
+ ); +} diff --git a/apps/admin/app/dashboard/nodes/page.tsx b/apps/admin/app/dashboard/nodes/page.tsx new file mode 100644 index 0000000..a6d6dbb --- /dev/null +++ b/apps/admin/app/dashboard/nodes/page.tsx @@ -0,0 +1,309 @@ +'use client'; + +import { ProTable, ProTableActions } from '@/components/pro-table'; +import { useQuery } from '@tanstack/react-query'; +import { Badge } from '@workspace/ui/components/badge'; +import { Button } from '@workspace/ui/components/button'; +import { Switch } from '@workspace/ui/components/switch'; +import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button'; +import { useTranslations } from 'next-intl'; +import { useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import NodeForm, { type NodeFormValues } from './node-form'; + +type NodeItem = NodeFormValues & { id: number; enabled: boolean; sort: number }; + +let mock: NodeItem[] = [ + { + id: 1, + enabled: false, + name: 'Node A', + server_id: 101, + protocol: 'shadowsocks', + server_addr: 'jp-1.example.com', + port: 443, + tags: ['hk', 'premium'], + sort: 1, + }, + { + id: 2, + enabled: true, + name: 'Node B', + server_id: 102, + protocol: 'vless', + server_addr: 'hk-edge.example.com', + port: 8443, + tags: ['jp'], + sort: 2, + }, +]; + +const list = async () => ({ list: mock, total: mock.length }); +const create = async (v: NodeFormValues) => { + mock.push({ + id: Date.now(), + enabled: false, + sort: 0, + ...v, + }); + return true; +}; +const update = async (id: number, v: NodeFormValues) => { + mock = mock.map((x) => (x.id === id ? { ...x, ...v } : x)); + return true; +}; +const remove = async (id: number) => { + mock = mock.filter((x) => x.id !== id); + return true; +}; +const setState = async (id: number, en: boolean) => { + mock = mock.map((x) => (x.id === id ? { ...x, enabled: en } : x)); + return true; +}; + +type ProtocolName = 'shadowsocks' | 'vmess' | 'vless' | 'trojan' | 'hysteria2' | 'tuic' | 'anytls'; +type ServerProtocolItem = { protocol: ProtocolName; enabled: boolean; config?: { port?: number } }; +type ServerRow = { id: number; name: string; server_addr: string; protocols: ServerProtocolItem[] }; + +async function getServerListMock(): Promise<{ data: { list: ServerRow[] } }> { + return { + data: { + list: [ + { + id: 101, + name: 'Tokyo-1', + server_addr: 'jp-1.example.com', + protocols: [ + { protocol: 'shadowsocks', enabled: true, config: { port: 443 } }, + { protocol: 'vless', enabled: true, config: { port: 8443 } }, + ], + }, + { + id: 102, + name: 'HK-Edge', + server_addr: 'hk-edge.example.com', + protocols: [ + { protocol: 'vmess', enabled: true, config: { port: 443 } }, + { protocol: 'vless', enabled: true, config: { port: 443 } }, + ], + }, + ], + }, + }; +} + +export default function NodesPage() { + const t = useTranslations('nodes'); + const ref = useRef(null); + const [loading, setLoading] = useState(false); + + const { data: serversResp } = useQuery({ + queryKey: ['getServerListMock'], + queryFn: getServerListMock, + }); + const servers: ServerRow[] = serversResp?.data?.list ?? []; + const serverMap = useMemo(() => { + const m = new Map(); + servers.forEach((s) => m.set(s.id, s)); + return m; + }, [servers]); + + const getServerName = (id?: number) => (id ? (serverMap.get(id)?.name ?? `#${id}`) : '—'); + const getServerOriginAddr = (id?: number) => (id ? (serverMap.get(id)?.server_addr ?? '—') : '—'); + const getProtocolOriginPort = (id?: number, proto?: string) => { + if (!id || !proto) return '—'; + const hit = serverMap.get(id)?.protocols?.find((p) => p.protocol === proto); + const p = hit?.config?.port; + return typeof p === 'number' ? String(p) : '—'; + }; + + return ( + + action={ref} + header={{ + title: t('pageTitle'), + toolbar: ( + { + setLoading(true); + await create(values); + toast.success(t('created')); + ref.current?.refresh(); + setLoading(false); + return true; + }} + /> + ), + }} + columns={[ + { + id: 'enabled', + header: t('enabled'), + cell: ({ row }) => ( + { + await setState(row.original.id, v); + toast.success(v ? t('enabled_on') : t('enabled_off')); + ref.current?.refresh(); + }} + /> + ), + }, + { accessorKey: 'name', header: t('name') }, + + { + id: 'server_addr_port', + header: t('server_addr_port'), + cell: ({ row }) => ( + + {(row.original.server_addr || '—') + ':' + (row.original.port ?? '—')} + + ), + }, + + { + id: 'server_combined', + header: t('server'), + cell: ({ row }) => ( +
+ + {getServerName(row.original.server_id)} ·{' '} + {getServerOriginAddr(row.original.server_id)} + + + {row.original.protocol || '—'} ·{' '} + {getProtocolOriginPort(row.original.server_id, row.original.protocol)} + +
+ ), + }, + { + accessorKey: 'tags', + header: t('tags'), + cell: ({ row }) => ( +
+ {(row.original.tags || []).length === 0 + ? '—' + : row.original.tags.map((tg) => ( + + {tg} + + ))} +
+ ), + }, + ]} + params={[{ key: 'search' }]} + request={async (_pagination, filter) => { + const { list: items } = await list(); + const kw = (filter?.search || '').toLowerCase().trim(); + const filtered = kw + ? items.filter((i) => + [ + i.name, + getServerName(i.server_id), + getServerOriginAddr(i.server_id), + `${i.server_addr}:${i.port ?? ''}`, + `${i.protocol}:${getProtocolOriginPort(i.server_id, i.protocol)}`, + ...(i.tags || []), + ] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(kw)), + ) + : items; + return { list: filtered, total: filtered.length }; + }} + actions={{ + render: (row) => [ + { + setLoading(true); + await update(row.id, values); + toast.success(t('updated')); + ref.current?.refresh(); + setLoading(false); + return true; + }} + />, + {t('delete')}} + title={t('confirmDeleteTitle')} + description={t('confirmDeleteDesc')} + onConfirm={async () => { + await remove(row.id); + toast.success(t('deleted')); + ref.current?.refresh(); + }} + cancelText={t('cancel')} + confirmText={t('confirm')} + />, + , + ], + batchRender(rows) { + return [ + {t('delete')}} + title={t('confirmDeleteTitle')} + description={t('confirmDeleteDesc')} + onConfirm={async () => { + toast.success(t('deleted')); + ref.current?.refresh(); + }} + cancelText={t('cancel')} + confirmText={t('confirm')} + />, + ]; + }, + }} + onSort={async (source, target, items) => { + const sourceIndex = items.findIndex((item) => String(item.id) === source); + const targetIndex = items.findIndex((item) => String(item.id) === target); + + const originalSorts = items.map((item) => item.sort); + + const [movedItem] = items.splice(sourceIndex, 1); + items.splice(targetIndex, 0, movedItem!); + + const updatedItems = items.map((item, index) => { + const originalSort = originalSorts[index]; + const newSort = originalSort !== undefined ? originalSort : item.sort; + return { ...item, sort: newSort }; + }); + + const changedItems = updatedItems.filter((item, index) => { + return item.sort !== items[index]?.sort; + }); + + if (changedItems.length > 0) { + // nodeSort({ + // sort: changedItems.map((item) => ({ id: item.id, sort: item.sort })), + // }); + } + + return updatedItems; + }} + /> + ); +} diff --git a/apps/admin/app/dashboard/servers/form-scheme.ts b/apps/admin/app/dashboard/servers/form-scheme.ts new file mode 100644 index 0000000..5631d2f --- /dev/null +++ b/apps/admin/app/dashboard/servers/form-scheme.ts @@ -0,0 +1,189 @@ +import { z } from 'zod'; + +export const protocols = [ + 'shadowsocks', + 'vmess', + 'vless', + 'trojan', + 'hysteria2', + 'tuic', + 'anytls', +] as const; + +const nullableString = z.string().nullish(); +const portScheme = z.number().max(65535).nullish(); + +const securityConfigScheme = z + .object({ + sni: nullableString, + allow_insecure: z.boolean().nullable().default(false), + fingerprint: nullableString, + reality_private_key: nullableString, + reality_public_key: nullableString, + reality_short_id: nullableString, + reality_server_addr: nullableString, + reality_server_port: portScheme, + }) + .nullish(); + +const transportConfigScheme = z + .object({ + path: nullableString, + host: nullableString, + service_name: nullableString, + }) + .nullish(); + +const shadowsocksScheme = z.object({ + method: z.string(), + port: portScheme, + server_key: nullableString, +}); + +const vmessScheme = z.object({ + port: portScheme, + transport: z.string(), + transport_config: transportConfigScheme, + security: z.string(), + security_config: securityConfigScheme, +}); + +const vlessScheme = z.object({ + port: portScheme, + transport: z.string(), + transport_config: transportConfigScheme, + security: z.string(), + security_config: securityConfigScheme, + flow: nullableString, +}); + +const trojanScheme = z.object({ + port: portScheme, + transport: z.string(), + transport_config: transportConfigScheme, + security: z.string(), + security_config: securityConfigScheme, +}); + +const hysteria2Scheme = z.object({ + port: portScheme, + hop_ports: nullableString, + hop_interval: z.number().nullish(), + obfs_password: nullableString, + security: z.string(), + security_config: securityConfigScheme, +}); + +const tuicScheme = z.object({ + port: portScheme, + disable_sni: z.boolean().default(false), + reduce_rtt: z.boolean().default(false), + udp_relay_mode: z.string().default('native'), + congestion_controller: z.string().default('bbr'), + security_config: securityConfigScheme, +}); + +const anytlsScheme = z.object({ + port: portScheme, + security_config: securityConfigScheme, +}); + +export const protocolConfigScheme = z.discriminatedUnion('protocol', [ + z.object({ + protocol: z.literal('shadowsocks'), + enabled: z.boolean().default(false), + config: shadowsocksScheme, + }), + z.object({ + protocol: z.literal('vmess'), + enabled: z.boolean().default(false), + config: vmessScheme, + }), + z.object({ + protocol: z.literal('vless'), + enabled: z.boolean().default(false), + config: vlessScheme, + }), + z.object({ + protocol: z.literal('trojan'), + enabled: z.boolean().default(false), + config: trojanScheme, + }), + z.object({ + protocol: z.literal('hysteria2'), + enabled: z.boolean().default(false), + config: hysteria2Scheme, + }), + z.object({ + protocol: z.literal('tuic'), + enabled: z.boolean().default(false), + config: tuicScheme, + }), + z.object({ + protocol: z.literal('anytls'), + enabled: z.boolean().default(false), + config: anytlsScheme, + }), +]); + +export const formScheme = z.object({ + name: z.string(), + server_addr: z.string(), + country: z.string().optional(), + city: z.string().optional(), + protocols: z.array(protocolConfigScheme).min(1), +}); + +export function getProtocolDefaultConfig(proto: (typeof protocols)[number]) { + switch (proto) { + case 'shadowsocks': + return { method: 'chacha20-ietf-poly1305', port: null, server_key: null }; + case 'vmess': + return { + port: null, + transport: 'tcp', + transport_config: null, + security: 'none', + security_config: null, + }; + case 'vless': + return { + port: null, + transport: 'tcp', + transport_config: null, + security: 'none', + security_config: null, + flow: null, + }; + case 'trojan': + return { + port: null, + transport: 'tcp', + transport_config: null, + security: 'tls', + security_config: {}, + }; + case 'hysteria2': + return { + port: null, + hop_ports: null, + hop_interval: null, + obfs_password: null, + security: 'tls', + security_config: {}, + }; + case 'tuic': + return { + port: null, + disable_sni: false, + reduce_rtt: false, + udp_relay_mode: 'native', + congestion_controller: 'bbr', + security_config: {}, + }; + case 'anytls': + return { port: null, security_config: {} }; + default: + return {} as any; + } +} diff --git a/apps/admin/app/dashboard/servers/page.tsx b/apps/admin/app/dashboard/servers/page.tsx new file mode 100644 index 0000000..8dbceaa --- /dev/null +++ b/apps/admin/app/dashboard/servers/page.tsx @@ -0,0 +1,579 @@ +'use client'; +import { UserDetail } from '@/app/dashboard/user/user-detail'; +import { IpLink } from '@/components/ip-link'; +import { ProTable, ProTableActions } from '@/components/pro-table'; +import { getUserSubscribeById } from '@/services/admin/user'; +import { useQuery } from '@tanstack/react-query'; +import { Badge } from '@workspace/ui/components/badge'; +import { Button } from '@workspace/ui/components/button'; +import { Card, CardContent } from '@workspace/ui/components/card'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@workspace/ui/components/sheet'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@workspace/ui/components/tooltip'; +import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button'; +import { cn } from '@workspace/ui/lib/utils'; +import { useTranslations } from 'next-intl'; +import { useRef, useState } from 'react'; +import { toast } from 'sonner'; +import ServerConfig from './server-config'; +import ServerForm from './server-form'; + +type ProtocolName = 'shadowsocks' | 'vmess' | 'vless' | 'trojan' | 'hysteria2' | 'tuic' | 'anytls'; +type ProtocolEntry = { protocol: ProtocolName; enabled: boolean; config: Record }; + +interface ServerFormFields { + name: string; + server_addr: string; + country?: string; + city?: string; + protocols: ProtocolEntry[]; +} + +type ServerStatus = { + online?: unknown; + cpu?: number; + mem?: number; + disk?: number; + updated_at?: number; +}; + +type ServerItem = ServerFormFields & { id: number; status?: ServerStatus; [key: string]: unknown }; + +const mockList: ServerItem[] = [ + { + id: 1, + name: 'Server A', + server_addr: '1.1.1.1', + country: 'US', + city: 'SFO', + protocols: [ + { + protocol: 'shadowsocks', + enabled: true, + config: { method: 'aes-128-gcm', port: 443, server_key: null }, + }, + { + protocol: 'trojan', + enabled: true, + config: { port: 8443, transport: 'tcp', security: 'tls' }, + }, + { + protocol: 'vmess', + enabled: false, + config: { + port: 1443, + transport: 'websocket', + transport_config: { path: '/ws', host: 'example.com' }, + security: 'tls', + }, + }, + ], + status: { + online: { 1001: ['1.2.3.4'], 1002: ['5.6.7.8', '9.9.9.9'] }, + cpu: 34, + mem: 62, + disk: 48, + updated_at: Date.now() / 1000, + }, + }, + { + id: 2, + name: 'Server B', + server_addr: '2.2.2.2', + country: 'JP', + city: 'Tokyo', + protocols: [ + { + protocol: 'vmess', + enabled: true, + config: { port: 2443, transport: 'tcp', security: 'none' }, + }, + { + protocol: 'hysteria2', + enabled: true, + config: { port: 3443, hop_ports: '443,8443,10443', hop_interval: 15, security: 'tls' }, + }, + { protocol: 'tuic', enabled: false, config: { port: 4443 } }, + ], + status: { + online: { 2001: ['10.0.0.1'] }, + cpu: 72, + mem: 81, + disk: 67, + updated_at: Date.now() / 1000, + }, + }, + { + id: 3, + name: 'Server C', + server_addr: '3.3.3.3', + country: 'DE', + city: 'FRA', + protocols: [ + { protocol: 'anytls', enabled: true, config: { port: 80 } }, + { + protocol: 'shadowsocks', + enabled: false, + config: { method: 'chacha20-ietf-poly1305', port: 8080 }, + }, + ], + status: { online: {}, cpu: 0, mem: 0, disk: 0, updated_at: 0 }, + }, +]; + +let mockData: ServerItem[] = [...mockList]; +const getServerList = async () => ({ list: mockData, total: mockData.length }); +const createServer = async (values: Omit) => { + mockData.push({ + id: Date.now(), + name: '', + server_addr: '', + protocols: [], + ...values, + }); + return true; +}; +const updateServer = async (id: number, values: Omit) => { + mockData = mockData.map((i) => (i.id === id ? { ...i, ...values } : i)); + return true; +}; +const deleteServer = async (id: number) => { + mockData = mockData.filter((i) => i.id !== id); + return true; +}; + +const PROTOCOL_COLORS: Record = { + shadowsocks: 'bg-green-500', + vmess: 'bg-rose-500', + vless: 'bg-blue-500', + trojan: 'bg-yellow-500', + hysteria2: 'bg-purple-500', + tuic: 'bg-cyan-500', + anytls: 'bg-gray-500', +}; + +function getEnabledProtocols(p: ServerItem['protocols']) { + return Array.isArray(p) ? p.filter((x) => x.enabled) : []; +} + +function ProtocolBadge({ + item, + t, +}: { + item: ServerItem['protocols'][number]; + t: (key: string) => string; +}) { + const color = PROTOCOL_COLORS[item.protocol]; + const port = (item?.config as any)?.port as number | undefined; + const extra: string[] = []; + if ((item.config as any)?.transport) extra.push(String((item.config as any).transport)); + if ((item.config as any)?.security && (item.config as any).security !== 'none') + extra.push(String((item.config as any).security)); + const label = `${item.protocol}${port ? ` (${port})` : ''}`; + const tipParts = [label, extra.length ? `· ${extra.join(' / ')}` : ''].filter(Boolean); + const tooltip = tipParts.join(' '); + return ( + + + + + {label} + + + {tooltip || t('notAvailable')} + + + ); +} + +function PctBar({ value }: { value: number }) { + const v = Math.max(0, Math.min(100, Math.round(value))); + return ( +
+
{v}%
+
+
+
+
+ ); +} + +function RegionIpCell({ + country, + city, + ip, + t, +}: { + country?: string; + city?: string; + ip: string; + t: (key: string) => string; +}) { + const region = [country, city].filter(Boolean).join(' / ') || t('notAvailable'); + return ( +
+ {region} + {ip} +
+ ); +} + +function UserSubscribeInfo({ + userId, + type, + t, +}: { + userId: number; + type: 'account' | 'subscribeName' | 'subscribeId' | 'traffic' | 'expireTime'; + t: (key: string) => string; +}) { + const { data } = useQuery({ + enabled: userId !== 0, + queryKey: ['getUserSubscribeById', userId], + queryFn: async () => { + const { data } = await getUserSubscribeById({ id: userId }); + return data.data; + }, + }); + if (!data) return --; + if (type === 'account') + return data.user_id ? ( + + ) : ( + -- + ); + if (type === 'subscribeName') + return data.subscribe?.name ? ( + {data.subscribe.name} + ) : ( + -- + ); + if (type === 'subscribeId') + return data.id ? ( + {data.id} + ) : ( + -- + ); + if (type === 'traffic') { + const used = (data.upload || 0) + (data.download || 0); + const total = data.traffic || 0; + return ( +
{`${(used / 1024 ** 3).toFixed(2)} GB / ${total > 0 ? (total / 1024 ** 3).toFixed(2) + ' GB' : t('unlimited')}`}
+ ); + } + if (type === 'expireTime') { + if (!data.expire_time) return --; + const expired = data.expire_time < Date.now() / 1000; + return ( +
+ {new Date((data.expire_time || 0) * 1000).toLocaleString()} + {expired && ( + + {t('expired')} + + )} +
+ ); + } + return --; +} + +function normalizeOnlineMap(online: unknown): { uid: string; ips: string[] }[] { + if (!online || typeof online !== 'object' || Array.isArray(online)) return []; + const m = online as Record; + const rows = Object.entries(m).map(([uid, ips]) => { + if (Array.isArray(ips)) return { uid, ips: (ips as unknown[]).map(String) }; + if (typeof ips === 'string') return { uid, ips: [ips] }; + const o = ips as Record; + if (Array.isArray(o?.ips)) return { uid, ips: (o.ips as unknown[]).map(String) }; + return { uid, ips: [] }; + }); + return rows.filter((r) => r.ips.length > 0); +} + +function OnlineUsersCell({ status, t }: { status?: ServerStatus; t: (key: string) => string }) { + const [open, setOpen] = useState(false); + const rows = normalizeOnlineMap(status?.online); + const count = rows.length; + return ( + + + + + + + {t('onlineUsers')} + +
+ + > + header={{ hidden: true }} + columns={[ + { + accessorKey: 'ips', + header: t('ipAddresses'), + cell: ({ row }) => { + const ips = row.original.ips; + return ( +
+ {ips.map((ip, i) => ( +
+ {i === 0 ? ( + + ) : ( + + )} +
+ ))} +
+ ); + }, + }, + { + accessorKey: 'user', + header: t('user'), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'subscription', + header: t('subscription'), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'subscribeId', + header: t('subscribeId'), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'traffic', + header: t('traffic'), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'expireTime', + header: t('expireTime'), + cell: ({ row }) => ( + + ), + }, + ]} + request={async () => ({ list: rows, total: rows.length })} + /> +
+
+
+ ); +} + +export default function ServersPage() { + const t = useTranslations('servers'); + + const [loading, setLoading] = useState(false); + const ref = useRef(null); + + return ( +
+ + + + + + + action={ref} + header={{ + title: t('pageTitle'), + toolbar: ( + { + setLoading(true); + await createServer(values as any); + toast.success(t('created')); + ref.current?.refresh(); + setLoading(false); + return true; + }} + /> + ), + }} + columns={[ + { + accessorKey: 'id', + header: t('id'), + cell: ({ row }) => {row.getValue('id')}, + }, + { accessorKey: 'name', header: t('name') }, + { + id: 'region_ip', + header: t('serverAddress'), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'protocols', + header: t('protocols'), + cell: ({ row }) => { + const enabled = getEnabledProtocols(row.original.protocols); + if (!enabled.length) return t('noData'); + return ( +
+ {enabled.map((p, idx) => ( + + ))} +
+ ); + }, + }, + { + id: 'status', + header: t('status'), + cell: ({ row }) => { + const s = (row.original.status ?? {}) as ServerStatus; + const on = !!( + s.online && + typeof s.online === 'object' && + !Array.isArray(s.online) && + Object.keys(s.online as Record).length + ); + return ( +
+ + {on ? t('online') : t('offline')} +
+ ); + }, + }, + { + id: 'cpu', + header: t('cpu'), + cell: ({ row }) => , + }, + { + id: 'mem', + header: t('memory'), + cell: ({ row }) => , + }, + { + id: 'disk', + header: t('disk'), + cell: ({ row }) => , + }, + { + id: 'online_users', + header: t('onlineUsers'), + cell: ({ row }) => ( + + ), + }, + ]} + params={[{ key: 'search' }]} + request={async (_pagination, filter) => { + const { list } = await getServerList(); + const keyword = (filter?.search || '').toLowerCase().trim(); + const filtered = keyword + ? list.filter((item) => + [item.name, item.server_addr, item.country, item.city] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(keyword)), + ) + : list; + return { list: filtered, total: filtered.length }; + }} + actions={{ + render: (row) => [ + { + setLoading(true); + await updateServer(row.id as number, values as any); + toast.success(t('updated')); + ref.current?.refresh(); + setLoading(false); + return true; + }} + />, + {t('delete')}} + title={t('confirmDeleteTitle')} + description={t('confirmDeleteDesc')} + onConfirm={async () => { + await deleteServer(row.id as number); + toast.success(t('deleted')); + ref.current?.refresh(); + }} + cancelText={t('cancel')} + confirmText={t('confirm')} + />, + , + ], + }} + /> +
+ ); +} diff --git a/apps/admin/app/dashboard/servers/server-config.tsx b/apps/admin/app/dashboard/servers/server-config.tsx new file mode 100644 index 0000000..4439d24 --- /dev/null +++ b/apps/admin/app/dashboard/servers/server-config.tsx @@ -0,0 +1,399 @@ +'use client'; + +import { + getNodeConfig, + getNodeMultiplier, + setNodeMultiplier, + updateNodeConfig, +} from '@/services/admin/system'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '@workspace/ui/components/button'; +import { ChartContainer, ChartTooltip } from '@workspace/ui/components/chart'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@workspace/ui/components/form'; +import { Label } from '@workspace/ui/components/label'; +import { ScrollArea } from '@workspace/ui/components/scroll-area'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@workspace/ui/components/sheet'; +import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs'; +import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; +import { Icon } from '@workspace/ui/custom-components/icon'; +import { DicesIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { uid } from 'radash'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Cell, Legend, Pie, PieChart } from 'recharts'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const COLORS = [ + 'hsl(var(--chart-1))', + 'hsl(var(--chart-2))', + 'hsl(var(--chart-3))', + 'hsl(var(--chart-4))', + 'hsl(var(--chart-5))', +]; + +const MINUTES_IN_DAY = 1440; + +function getTimeRangeData(slots: API.TimePeriod[]) { + const timePoints = slots + .filter((slot) => slot.start_time && slot.end_time) + .flatMap((slot) => { + const [startH = 0, startM = 0] = slot.start_time.split(':').map(Number); + const [endH = 0, endM = 0] = slot.end_time.split(':').map(Number); + const start = startH * 60 + startM; + let end = endH * 60 + endM; + if (end < start) end += MINUTES_IN_DAY; + return { start, end, multiplier: slot.multiplier }; + }) + .sort((a, b) => a.start - b.start); + + const result: { name: string; value: number; multiplier: number }[] = []; + let currentMinute = 0; + + timePoints.forEach((point) => { + if (point.start > currentMinute) { + result.push({ + name: `${Math.floor(currentMinute / 60)}:${String(currentMinute % 60).padStart(2, '0')} - ${Math.floor(point.start / 60)}:${String(point.start % 60).padStart(2, '0')}`, + value: point.start - currentMinute, + multiplier: 1, + }); + } + result.push({ + name: `${Math.floor(point.start / 60)}:${String(point.start % 60).padStart(2, '0')} - ${Math.floor((point.end / 60) % 24)}:${String(point.end % 60).padStart(2, '0')}`, + value: point.end - point.start, + multiplier: point.multiplier, + }); + currentMinute = point.end % MINUTES_IN_DAY; + }); + + if (currentMinute < MINUTES_IN_DAY) { + result.push({ + name: `${Math.floor(currentMinute / 60)}:${String(currentMinute % 60).padStart(2, '0')} - 24:00`, + value: MINUTES_IN_DAY - currentMinute, + multiplier: 1, + }); + } + + return result; +} + +const nodeConfigSchema = z.object({ + node_secret: z.string().optional(), + node_pull_interval: z.number().or(z.string().pipe(z.coerce.number())).optional(), + node_push_interval: z.number().or(z.string().pipe(z.coerce.number())).optional(), +}); +type NodeConfigFormData = z.infer; + +export default function ServerConfig() { + const t = useTranslations('servers'); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [timeSlots, setTimeSlots] = useState([]); + + const { data: cfgResp, refetch: refetchCfg } = useQuery({ + queryKey: ['getNodeConfig'], + queryFn: async () => { + const { data } = await getNodeConfig(); + return data.data as API.NodeConfig | undefined; + }, + enabled: open, + }); + + const { data: periodsResp, refetch: refetchPeriods } = useQuery({ + queryKey: ['getNodeMultiplier'], + queryFn: async () => { + const { data } = await getNodeMultiplier(); + return (data.data?.periods || []) as API.TimePeriod[]; + }, + enabled: open, + }); + + const form = useForm({ + resolver: zodResolver(nodeConfigSchema), + defaultValues: { + node_secret: '', + node_pull_interval: undefined, + node_push_interval: undefined, + }, + }); + + useEffect(() => { + if (cfgResp) { + form.reset({ + node_secret: cfgResp.node_secret ?? '', + node_pull_interval: cfgResp.node_pull_interval as number | undefined, + node_push_interval: cfgResp.node_push_interval as number | undefined, + }); + } + }, [cfgResp, form]); + + useEffect(() => { + if (periodsResp) { + setTimeSlots(periodsResp); + } + }, [periodsResp]); + + const chartTimeSlots = useMemo(() => getTimeRangeData(timeSlots), [timeSlots]); + const chartConfig = useMemo(() => { + return chartTimeSlots.reduce( + (acc, item, index) => { + acc[item.name] = { + label: item.name, + color: COLORS[index % COLORS.length] || 'hsl(var(--default-chart-color))', + }; + return acc; + }, + {} as Record, + ); + }, [chartTimeSlots]); + + async function onSubmit(values: NodeConfigFormData) { + setSaving(true); + try { + await updateNodeConfig(values as API.NodeConfig); + toast.success(t('config.saveSuccess')); + await refetchCfg(); + setOpen(false); + } finally { + setSaving(false); + } + } + + async function savePeriods() { + await setNodeMultiplier({ periods: timeSlots }); + await refetchPeriods(); + toast.success(t('config.saveSuccess')); + } + + return ( + + +
+
+
+ +
+
+

{t('config.title')}

+

{t('config.description')}

+
+
+ +
+
+ + + + {t('config.title')} + + + +
+ + ( + + {t('config.communicationKey')} + + + { + const id = uid(32).toLowerCase(); + const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`; + form.setValue('node_secret', formatted); + }} + className='cursor-pointer' + /> +
+ } + /> + + {t('config.communicationKeyDescription')} + + + )} + /> + + ( + + {t('config.nodePullInterval')} + + + + {t('config.nodePullIntervalDescription')} + + + )} + /> + + ( + + {t('config.nodePushInterval')} + + + + {t('config.nodePushIntervalDescription')} + + + )} + /> + +
+ +

+ {t('config.dynamicMultiplierDescription')} +

+ +
+
+ + fields={[ + { name: 'start_time', prefix: t('config.startTime'), type: 'time' }, + { name: 'end_time', prefix: t('config.endTime'), type: 'time' }, + { + name: 'multiplier', + prefix: t('config.multiplier'), + type: 'number', + placeholder: '0', + }, + ]} + value={timeSlots} + onChange={setTimeSlots} + /> +
+ + +
+
+ +
+ + + + `${(multiplier || 0)?.toFixed(2)}x (${(percent * 100).toFixed(0)}%)` + } + > + {chartTimeSlots.map((entry, index) => ( + + ))} + + { + if (payload && payload.length) { + const d = payload[0]?.payload as any; + return ( +
+
+
+ + {t('config.timeSlot')} + + + {d.name || '—'} + +
+
+ + {t('config.multiplier')} + + + {Number(d.multiplier).toFixed(2)}x + +
+
+
+ ); + } + return null; + }} + /> + +
+
+
+
+
+ + + + + + + + + + + ); +} diff --git a/apps/admin/app/dashboard/servers/server-form.tsx b/apps/admin/app/dashboard/servers/server-form.tsx new file mode 100644 index 0000000..1ebe331 --- /dev/null +++ b/apps/admin/app/dashboard/servers/server-form.tsx @@ -0,0 +1,858 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@workspace/ui/components/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@workspace/ui/components/form'; +import { ScrollArea } from '@workspace/ui/components/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@workspace/ui/components/select'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@workspace/ui/components/sheet'; +import { Switch } from '@workspace/ui/components/switch'; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; +import { Icon } from '@workspace/ui/custom-components/icon'; +import { cn } from '@workspace/ui/lib/utils'; +import { useTranslations } from 'next-intl'; +import { useEffect, useMemo, useState } from 'react'; +import { useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { formScheme, getProtocolDefaultConfig, protocols as PROTOCOLS } from './form-scheme'; + +interface ServerFormProps { + onSubmit: (data: T) => Promise | boolean; + initialValues?: T | any; + loading?: boolean; + trigger: string; + title: string; +} + +function titleCase(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function normalizeValues(raw: any) { + const map = new Map(); + const given = Array.isArray(raw?.protocols) ? raw.protocols : []; + for (const it of given) map.set(it?.protocol, it); + + const normalized = { + name: raw?.name ?? '', + server_addr: raw?.server_addr ?? '', + country: raw?.country ?? '', + city: raw?.city ?? '', + protocols: PROTOCOLS.map((p) => { + const incoming = map.get(p); + const def = getProtocolDefaultConfig(p as any); + if (incoming) { + return { + protocol: p, + enabled: !!incoming.enabled, + config: { ...def, ...(incoming.config ?? {}) }, + }; + } + return { protocol: p, enabled: false, config: def }; + }), + }; + return normalized; +} + +export default function ServerForm({ + onSubmit, + initialValues, + loading, + trigger, + title, +}: Readonly>) { + const t = useTranslations('servers'); + const [open, setOpen] = useState(false); + + const defaultValues = useMemo( + () => + normalizeValues({ + name: '', + server_addr: '', + country: '', + city: '', + protocols: [], + }), + [], + ); + + const form = useForm({ + resolver: zodResolver(formScheme), + defaultValues, + }); + const { control } = form; + useFieldArray({ control, name: 'protocols' }); + + const [activeProto, setActiveProto] = useState(PROTOCOLS[0]); + const activeIndex = useMemo(() => PROTOCOLS.findIndex((p) => p === activeProto), [activeProto]); + + useEffect(() => { + if (initialValues) { + const normalized = normalizeValues(initialValues); + form.reset(normalized); + const enabledFirst = normalized.protocols.find((p: any) => p.enabled)?.protocol; + setActiveProto((enabledFirst as any) || PROTOCOLS[0]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues]); + + async function handleSubmit(data: { [x: string]: any }) { + const ok = await onSubmit(data as unknown as T); + if (ok) setOpen(false); + } + + function ProtocolEditor({ idx, proto }: { idx: number; proto: string }) { + const transport = useWatch({ control, name: `protocols.${idx}.config.transport` as const }); + const security = useWatch({ control, name: `protocols.${idx}.config.security` as const }); + const method = useWatch({ control, name: `protocols.${idx}.config.method` as const }); + const enabled = useWatch({ control, name: `protocols.${idx}.enabled` as const }); + + return ( +
+ ( +
+ {t('enabled')} + { + field.onChange(checked); + if (checked) { + form.setValue( + `protocols.${idx}.config` as const, + getProtocolDefaultConfig(proto as any), + ); + if (['trojan', 'hysteria2'].includes(proto)) { + form.setValue(`protocols.${idx}.config.security` as const, 'tls'); + } + } + }} + /> +
+ )} + /> + + {enabled && ( + <> + {['shadowsocks'].includes(proto) && ( +
+ ( + + {t('encryption_method')} + + + + + + )} + /> + ( + + {t('port')} + + field.onChange(v)} + /> + + + + )} + /> + {[ + '2022-blake3-aes-128-gcm', + '2022-blake3-aes-256-gcm', + '2022-blake3-chacha20-poly1305', + ].includes(method as any) && ( + ( + + {t('server_key')} + + field.onChange(v)} /> + + + + )} + /> + )} +
+ )} + + {['vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'anytls'].includes(proto) && ( +
+ ( + + {t('port')} + + field.onChange(v)} + /> + + + + )} + /> + + {proto === 'vless' && ( + ( + + {t('flow')} + + + + + + )} + /> + )} + + {proto === 'hysteria2' && ( + <> + ( + + {t('obfs_password')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('hop_ports')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('hop_interval')} + + field.onChange(v)} + suffix={t('unitSecondsShort')} + /> + + + + )} + /> + + )} + + {proto === 'tuic' && ( + <> + ( + + {t('udp_relay_mode')} + + + + + + )} + /> + ( + + {t('congestion_controller')} + + + + + + )} + /> +
+ ( + + {t('disable_sni')} + +
+ field.onChange(checked)} + /> +
+
+ +
+ )} + /> + ( + + {t('reduce_rtt')} + +
+ field.onChange(checked)} + /> +
+
+ +
+ )} + /> +
+ + )} +
+ )} + + {['vmess', 'vless', 'trojan'].includes(proto) && ( + + + {t('transport_title')} + ( + + + + + + + )} + /> + + {transport !== 'tcp' && ( + + {['websocket', 'http2', 'httpupgrade'].includes(transport as any) && ( + <> + ( + + {t('path')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('host')} + + field.onChange(v)} + /> + + + + )} + /> + + )} + {transport === 'grpc' && ( + ( + + {t('service_name')} + + field.onChange(v)} /> + + + + )} + /> + )} + + )} + + )} + + {['vmess', 'vless', 'trojan', 'anytls', 'tuic', 'hysteria2'].includes(proto) && ( + + + {t('security_title')} + {['vmess', 'vless', 'trojan'].includes(proto) && ( + ( + + + + + )} + /> + )} + + + {(['anytls', 'tuic', 'hysteria2'].includes(proto) || + (['vmess', 'vless', 'trojan'].includes(proto) && security !== 'none')) && ( + + ( + + {t('security_sni')} + + field.onChange(v)} /> + + + + )} + /> + + {proto === 'vless' && security === 'reality' && ( + <> + ( + + {t('security_server_address')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_server_port')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_private_key')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_public_key')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_short_id')} + + field.onChange(v)} + /> + + + + )} + /> + + )} + + {proto === 'vless' && ( + ( + + {t('security_fingerprint')} + + + + )} + /> + )} + + ( + + {t('security_allow_insecure')} + +
+ field.onChange(checked)} + /> +
+
+ +
+ )} + /> +
+ )} +
+ )} + + )} +
+ ); + } + + return ( + + + + + + + {title} + + +
+ +
+ ( + + {t('name')} + + field.onChange(v)} /> + + + + )} + /> + ( + + {t('server_addr')} + + field.onChange(v)} /> + + + + )} + /> + ( + + {t('country')} + + field.onChange(v)} /> + + + + )} + /> + ( + + {t('city')} + + field.onChange(v)} /> + + + + )} + /> +
+ + setActiveProto(v as any)} + className='w-full pt-3' + > + + {PROTOCOLS.map((p) => ( + +
{titleCase(p)}
+
+ ))} +
+
+ + + +
+ + + + +
+
+ ); +} diff --git a/apps/admin/components/sidebar-left.tsx b/apps/admin/components/sidebar-left.tsx index 8eafebd..47c32e2 100644 --- a/apps/admin/components/sidebar-left.tsx +++ b/apps/admin/components/sidebar-left.tsx @@ -1,28 +1,137 @@ 'use client'; import { navs } from '@/config/navs'; import useGlobalStore from '@/config/use-global'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@workspace/ui/components/hover-card'; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, - SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, + useSidebar, } from '@workspace/ui/components/sidebar'; import { Icon } from '@workspace/ui/custom-components/icon'; +import { cn } from '@workspace/ui/lib/utils'; import { useTranslations } from 'next-intl'; import Image from 'next/legacy/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import React, { useState } from 'react'; + +type Nav = (typeof navs)[number]; + +function hasChildren(obj: any): obj is { items: any[] } { + return obj && Array.isArray((obj as any).items) && (obj as any).items.length > 0; +} export function SidebarLeft({ ...props }: React.ComponentProps) { const { common } = useGlobalStore(); const { site } = common; const t = useTranslations('menu'); const pathname = usePathname(); + const { state, isMobile } = useSidebar(); + + const firstGroupTitle = (navs as typeof navs).find((n) => hasChildren(n))?.title ?? ''; + + const [openGroups, setOpenGroups] = useState>(() => { + const groups: Record = {}; + (navs as typeof navs).forEach((nav) => { + if (hasChildren(nav)) groups[nav.title] = nav.title === firstGroupTitle; + }); + return groups; + }); + + const handleToggleGroup = (title: string) => { + setOpenGroups((prev) => { + const currentlyOpen = !!prev[title]; + const next: Record = {}; + (navs as typeof navs).forEach((nav) => { + if (hasChildren(nav)) next[nav.title] = false; + }); + next[title] = !currentlyOpen; + return next; + }); + }; + + const isActiveUrl = (url: string) => + url === '/dashboard' ? pathname === url : pathname.startsWith(url); + + const isGroupActive = (nav: Nav) => + (hasChildren(nav) && nav.items.some((i: any) => isActiveUrl(i.url))) || + ('url' in nav && nav.url ? isActiveUrl(nav.url as string) : false); + + const renderCollapsedFlyout = (nav: Nav) => { + const ParentButton = ( + + {'url' in nav && nav.url ? ( + + {'icon' in nav && (nav as any).icon ? ( + + ) : null} + + ) : ( + <> + {'icon' in nav && (nav as any).icon ? ( + + ) : null} + + )} + + ); + + if (!hasChildren(nav)) return ParentButton; + + return ( + + {ParentButton} + +
+ {'icon' in nav && (nav as any).icon ? ( + + ) : null} + + {t(nav.title)} + +
+ +
    + {nav.items.map((item: any) => ( +
  • + + {item.icon && } + {t(item.title)} + +
  • + ))} +
+
+
+ ); + }; + return ( @@ -49,39 +158,105 @@ export function SidebarLeft({ ...props }: React.ComponentProps) + - {navs.map((nav) => ( - - {nav.items && ( - {t(nav.title)} - )} - - - {(nav.items || [nav]).map((item) => ( - + {!isMobile && state === 'collapsed' + ? (navs as typeof navs).map((nav) => ( + + {renderCollapsedFlyout(nav)} + + )) + : (navs as typeof navs).map((nav) => { + if (hasChildren(nav)) { + const isOpen = openGroups[nav.title] ?? false; + return ( + handleToggleGroup(nav.title)} + tabIndex={0} + style={{ fontWeight: 500 }} + // isActive={isGroupActive(nav)} > - - {item.icon && } - {t(item.title)} - + + {'icon' in nav && (nav as any).icon ? ( + + ) : null} + {t(nav.title)} + + - - ))} - - - - ))} + {isOpen && ( + + + {nav.items.map((item: any) => ( + + + + {item.icon && } + {t(item.title)} + + + + ))} + + + )} + + ); + } + + return ( + + + + + + {'url' in nav && (nav as any).url ? ( + + {'icon' in nav && (nav as any).icon ? ( + + ) : null} + {t(nav.title)} + + ) : ( + <> + {'icon' in nav && (nav as any).icon ? ( + + ) : null} + {t(nav.title)} + + )} + + + + + + ); + })} diff --git a/apps/admin/config/navs.ts b/apps/admin/config/navs.ts index e7048a8..b5df415 100644 --- a/apps/admin/config/navs.ts +++ b/apps/admin/config/navs.ts @@ -4,86 +4,33 @@ export const navs = [ url: '/dashboard', icon: 'flat-color-icons:globe', }, - { - title: 'System Management', - items: [ - { - title: 'System Config', - url: '/dashboard/system', - icon: 'flat-color-icons:services', - }, - { - title: 'Auth Control', - url: '/dashboard/auth-control', - icon: 'flat-color-icons:lock-portrait', - }, - { - title: 'Payment Config', - url: '/dashboard/payment', - icon: 'flat-color-icons:currency-exchange', - }, - { - title: 'ADS Config', - url: '/dashboard/ads', - icon: 'flat-color-icons:electrical-sensor', - }, - { - title: 'System Tool', - url: '/dashboard/tool', - icon: 'flat-color-icons:info', - }, - ], - }, { - title: 'Server', + title: 'Maintenance', + icon: 'flat-color-icons:data-protection', items: [ - { - title: 'Subscribe Config', - url: '/dashboard/subscribe', - icon: 'flat-color-icons:ruler', - }, { title: 'Server Management', url: '/dashboard/server', icon: 'flat-color-icons:data-protection', }, { - title: 'Product Management', - url: '/dashboard/product', - icon: 'flat-color-icons:shop', + title: 'Server Management', + url: '/dashboard/servers', + icon: 'flat-color-icons:data-protection', }, + { title: 'Node Management', url: '/dashboard/nodes', icon: 'flat-color-icons:mind-map' }, + { title: 'Subscribe Config', url: '/dashboard/subscribe', icon: 'flat-color-icons:ruler' }, + { title: 'Product Management', url: '/dashboard/product', icon: 'flat-color-icons:shop' }, ], }, + { - title: 'Finance', + title: 'Commerce', + icon: 'flat-color-icons:sales-performance', items: [ - { - title: 'Order Management', - url: '/dashboard/order', - icon: 'flat-color-icons:todo-list', - }, - { - title: 'Coupon Management', - url: '/dashboard/coupon', - icon: 'flat-color-icons:bookmark', - }, - ], - }, - { - title: 'User', - items: [ - { - title: 'User Management', - url: '/dashboard/user', - icon: 'flat-color-icons:conference-call', - items: [ - { - title: 'User Detail', - url: '/dashboard/user/:id', - }, - ], - }, + { title: 'Order Management', url: '/dashboard/order', icon: 'flat-color-icons:todo-list' }, + { title: 'Coupon Management', url: '/dashboard/coupon', icon: 'flat-color-icons:bookmark' }, { title: 'Marketing Management', url: '/dashboard/marketing', @@ -94,6 +41,19 @@ export const navs = [ url: '/dashboard/announcement', icon: 'flat-color-icons:advertising', }, + ], + }, + + { + title: 'Users & Support', + icon: 'flat-color-icons:collaboration', + items: [ + { + title: 'User Management', + url: '/dashboard/user', + icon: 'flat-color-icons:conference-call', + items: [{ title: 'User Detail', url: '/dashboard/user/:id' }], + }, { title: 'Ticket Management', url: '/dashboard/ticket', @@ -106,6 +66,61 @@ export const navs = [ }, ], }, + + { + title: 'System', + icon: 'flat-color-icons:services', + items: [ + { title: 'System Config', url: '/dashboard/system', icon: 'flat-color-icons:services' }, + { + title: 'Auth Control', + url: '/dashboard/auth-control', + icon: 'flat-color-icons:lock-portrait', + }, + { + title: 'Payment Config', + url: '/dashboard/payment', + icon: 'flat-color-icons:currency-exchange', + }, + { title: 'ADS Config', url: '/dashboard/ads', icon: 'flat-color-icons:electrical-sensor' }, + { title: 'System Tool', url: '/dashboard/tool', icon: 'flat-color-icons:info' }, + ], + }, + + // 日志与分析 + { + title: 'Logs & Analytics', + icon: 'flat-color-icons:statistics', + items: [ + { title: 'Login', url: '/dashboard/log/login', icon: 'flat-color-icons:unlock' }, + { title: 'Register', url: '/dashboard/log/register', icon: 'flat-color-icons:contacts' }, + { title: 'Email', url: '/dashboard/log/email', icon: 'flat-color-icons:feedback' }, + { title: 'SMS', url: '/dashboard/log/sms', icon: 'flat-color-icons:sms' }, + { title: 'Subscribe', url: '/dashboard/log/subscribe', icon: 'flat-color-icons:workflow' }, + { + title: 'Reset Subscribe', + url: '/dashboard/log/reset-subscribe', + icon: 'flat-color-icons:refresh', + }, + { + title: 'Subscribe Traffic', + url: '/dashboard/log/subscribe-traffic', + icon: 'flat-color-icons:statistics', + }, + { + title: 'Server Traffic', + url: '/dashboard/log/server-traffic', + icon: 'flat-color-icons:statistics', + }, + { + title: 'Balance', + url: '/dashboard/log/balance', + icon: 'flat-color-icons:sales-performance', + }, + { title: 'Commission', url: '/dashboard/log/commission', icon: 'flat-color-icons:debt' }, + { title: 'Gift', url: '/dashboard/log/gift', icon: 'flat-color-icons:donate' }, + ], + }, ]; export function findNavByUrl(url: string) { @@ -114,7 +129,6 @@ export function findNavByUrl(url: string) { const regex = new RegExp(`^${regexPattern}$`); return regex.test(path); } - function findNav(items: any[], url: string, path: any[] = []): any[] { for (const item of items) { if (item.url === url || (item.url && matchDynamicRoute(item.url, url))) { @@ -122,13 +136,10 @@ export function findNavByUrl(url: string) { } if (item.items) { const result = findNav(item.items, url, [...path, item]); - if (result.length) { - return result; - } + if (result.length) return result; } } return []; } - return findNav(navs, url); } diff --git a/apps/admin/locales/en-US/menu.json b/apps/admin/locales/en-US/menu.json index 78eb51c..14ee889 100644 --- a/apps/admin/locales/en-US/menu.json +++ b/apps/admin/locales/en-US/menu.json @@ -1,27 +1,41 @@ { "ADS Config": "ADS Config", "Announcement Management": "Announcement Management", - "Application Management": "Application Management", + "Auth Control": "Auth Control", + "Balance": "Balance", + "Commerce": "Commerce", + "Commission": "Commission", "Coupon Management": "Coupon Management", "Dashboard": "Dashboard", + "Document Management": "Document Management", - "Finance": "Finance", + + "Email": "Email", + "Gift": "Gift", + "Login": "Login", + "Logs & Analytics": "Logs & Analytics", + "Maintenance": "Maintenance", "Marketing Management": "Marketing Management", + "Node Management": "Node Management", "Order Management": "Order Management", "Payment Config": "Payment Config", "Product Management": "Product Management", - "Protocol Management": "Protocol Management", - "Rule Management": "Rule Management", - "Server": "Server", + + "Register": "Register", + "Reset Subscribe": "Reset Subscribe", + "SMS": "SMS", "Server Management": "Server Management", - "Settings": "Settings", + "Server Traffic": "Server Traffic", + "Subscribe": "Subscribe", "Subscribe Config": "Subscribe Config", + "Subscribe Traffic": "Subscribe Traffic", + "System": "System", "System Config": "System Config", - "System Management": "System Management", "System Tool": "System Tool", + "Ticket Management": "Ticket Management", - "User": "User", "User Detail": "User Detail", - "User Management": "User Management" + "User Management": "User Management", + "Users & Support": "Users & Support" } diff --git a/apps/admin/locales/en-US/nodes.json b/apps/admin/locales/en-US/nodes.json new file mode 100644 index 0000000..0f09e2f --- /dev/null +++ b/apps/admin/locales/en-US/nodes.json @@ -0,0 +1,37 @@ +{ + "cancel": "Cancel", + "confirm": "Confirm", + "confirmDeleteDesc": "This action cannot be undone.", + "confirmDeleteTitle": "Delete this node?", + "copied": "Copied successfully", + "copy": "Copy", + "create": "Create", + "created": "Created successfully", + "delete": "Delete", + "deleted": "Deleted successfully", + "drawerCreateTitle": "Create Node", + "drawerEditTitle": "Edit Node", + "edit": "Edit", + "enabled": "Enabled", + "enabled_off": "Disabled", + "enabled_on": "Enabled", + "errors": { + "nameRequired": "Name is required", + "serverRequired": "Please select a server", + "serverAddrRequired": "Entry address is required", + "protocolRequired": "Please select a protocol", + "portRange": "Port must be 1–65535" + }, + "name": "Name", + "pageTitle": "Nodes", + "port": "Port", + "protocol": "Protocol", + "select_protocol": "Search protocol…", + "select_server": "Search server…", + "server": "Server", + "server_addr": "Entry address", + "server_addr_port": "Address:Port", + "tags": "Tags", + "tags_placeholder": "Type and press Enter", + "updated": "Updated successfully" +} diff --git a/apps/admin/locales/en-US/servers.json b/apps/admin/locales/en-US/servers.json new file mode 100644 index 0000000..ddcc111 --- /dev/null +++ b/apps/admin/locales/en-US/servers.json @@ -0,0 +1,102 @@ +{ + "cancel": "Cancel", + "city": "City", + "config": { + "title": "Node configuration", + "description": "Manage node communication keys, pull/push intervals, and dynamic multipliers.", + "saveSuccess": "Saved successfully", + "communicationKey": "Communication key", + "inputPlaceholder": "Please enter", + "communicationKeyDescription": "Used for node authentication.", + "nodePullInterval": "Node pull interval", + "nodePullIntervalDescription": "How often the node pulls configuration (seconds).", + "nodePushInterval": "Node push interval", + "nodePushIntervalDescription": "How often the node pushes stats (seconds).", + "dynamicMultiplier": "Dynamic multiplier", + "dynamicMultiplierDescription": "Define time slots and multipliers to adjust traffic accounting.", + "startTime": "Start time", + "endTime": "End time", + "multiplier": "Multiplier", + "reset": "Reset", + "save": "Save", + "timeSlot": "Time slot", + "actions": { + "cancel": "Cancel", + "save": "Save" + } + }, + "confirm": "Confirm", + "confirmDeleteDesc": "This action cannot be undone.", + "confirmDeleteTitle": "Delete this server?", + "congestion_controller": "Congestion controller", + "copied": "Copied", + "copy": "Copy", + "country": "Country", + "cpu": "CPU", + "create": "Create", + "created": "Created successfully", + "delete": "Delete", + "deleted": "Deleted successfully", + "disable_sni": "Disable SNI", + "disk": "Disk", + "drawerCreateTitle": "Create Server", + "drawerEditTitle": "Edit Server", + "edit": "Edit", + "enabled": "Enabled", + "encryption_method": "Encryption method", + "expireTime": "Expire time", + "expired": "Expired", + "flow": "Flow", + "hop_interval": "Hop interval", + "hop_ports": "Hop ports", + "hop_ports_placeholder": "e.g. 443,8443,10443", + "host": "Host", + "id": "ID", + "ipAddresses": "IP addresses", + "memory": "Memory", + "name": "Name", + "noData": "No data", + "notAvailable": "N/A", + "obfs_password": "Obfuscation password", + "obfs_password_placeholder": "Enter obfuscation password", + "offline": "Offline", + "online": "Online", + "onlineUsers": "Online users", + "pageTitle": "Servers", + "path": "Path", + "please_select": "Please select", + "port": "Port", + "port_placeholder": "Enter port", + "protocols": "Protocols", + "reduce_rtt": "Reduce RTT", + "security_allow_insecure": "Allow insecure", + "security_fingerprint": "Fingerprint", + "security_private_key": "Reality private key", + "security_private_key_placeholder": "Enter private key", + "security_public_key": "Reality public key", + "security_public_key_placeholder": "Enter public key", + "security_server_address": "Reality server address", + "security_server_address_placeholder": "e.g. 1.2.3.4 or domain", + "security_server_port": "Reality server port", + "security_server_port_placeholder": "Enter port", + "security_short_id": "Reality short ID", + "security_short_id_placeholder": "Hex string (up to 16 chars)", + "security_sni": "SNI", + "security_title": "Security", + "select_encryption_method": "Select encryption method", + "serverAddress": "Server", + "server_addr": "Server address", + "server_key": "Server key", + "service_name": "Service name", + "status": "Status", + "subscribeId": "Subscription ID", + "subscription": "Subscription", + "traffic": "Traffic", + "transport_title": "Transport", + "udp_relay_mode": "UDP relay mode", + "unitSecondsShort": "s", + "unlimited": "Unlimited", + "updated": "Updated successfully", + "user": "User", + "validation_failed": "Validation failed. Please check the form." +} diff --git a/apps/admin/locales/request.ts b/apps/admin/locales/request.ts index f2ebcb2..488cb0d 100644 --- a/apps/admin/locales/request.ts +++ b/apps/admin/locales/request.ts @@ -18,6 +18,8 @@ export default getRequestConfig(async () => { 'ads': (await import(`./${locale}/ads.json`)).default, 'payment': (await import(`./${locale}/payment.json`)).default, 'server': (await import(`./${locale}/server.json`)).default, + 'servers': (await import(`./${locale}/servers.json`)).default, + 'nodes': (await import(`./${locale}/nodes.json`)).default, 'product': (await import(`./${locale}/product.json`)).default, 'order': (await import(`./${locale}/order.json`)).default, 'coupon': (await import(`./${locale}/coupon.json`)).default, diff --git a/apps/admin/locales/zh-CN/menu.json b/apps/admin/locales/zh-CN/menu.json index b5478b4..b8ed1bc 100644 --- a/apps/admin/locales/zh-CN/menu.json +++ b/apps/admin/locales/zh-CN/menu.json @@ -1,27 +1,41 @@ { - "ADS Config": "ADS配置", + "ADS Config": "广告配置", "Announcement Management": "公告管理", - "Application Management": "应用管理", + "Auth Control": "认证控制", + "Balance": "余额变动", + "Commerce": "商务", + "Commission": "佣金记录", "Coupon Management": "优惠券管理", "Dashboard": "仪表盘", + "Document Management": "文档管理", - "Finance": "财务", + + "Email": "邮件日志", + "Gift": "赠送记录", + "Login": "登录日志", + "Logs & Analytics": "日志与分析", + "Maintenance": "运维", "Marketing Management": "营销管理", + "Node Management": "节点管理", "Order Management": "订单管理", "Payment Config": "支付配置", - "Product Management": "产品管理", - "Protocol Management": "协议管理", - "Rule Management": "规则管理", - "Server": "服务", + "Product Management": "商品管理", + + "Register": "注册日志", + "Reset Subscribe": "重置订阅", + "SMS": "短信日志", "Server Management": "服务管理", - "Settings": "设置", + "Server Traffic": "服务流量", + "Subscribe": "订阅日志", "Subscribe Config": "订阅配置", + "Subscribe Traffic": "订阅流量", + "System": "系统", "System Config": "系统配置", - "System Management": "系统管理", "System Tool": "系统工具", + "Ticket Management": "工单管理", - "User": "用户", "User Detail": "用户详情", - "User Management": "用户管理" + "User Management": "用户管理", + "Users & Support": "用户与支持" } diff --git a/apps/admin/locales/zh-CN/nodes.json b/apps/admin/locales/zh-CN/nodes.json new file mode 100644 index 0000000..19c4c6a --- /dev/null +++ b/apps/admin/locales/zh-CN/nodes.json @@ -0,0 +1,38 @@ +{ + "cancel": "取消", + "confirm": "确定", + "confirmDeleteDesc": "此操作不可撤销。", + "confirmDeleteTitle": "删除该节点?", + "copied": "复制成功", + "copy": "复制", + "create": "新建", + "created": "创建成功", + "delete": "删除", + "deleted": "删除成功", + "drawerCreateTitle": "新建节点", + "drawerEditTitle": "编辑节点", + "edit": "编辑", + "enabled": "是否启用", + "enabled_off": "未启用", + "enabled_on": "已启用", + "errors": { + "nameRequired": "请输入名称", + "serverRequired": "请选择服务器", + "serverAddrRequired": "请输入入口地址", + "protocolRequired": "请选择协议", + "portRange": "端口范围 1–65535" + }, + "name": "名称", + "pageTitle": "节点管理", + "port": "端口", + "protocol": "协议类型", + "select_protocol": "搜索协议…", + "select_server": "搜索服务器…", + "server": "服务器", + "server_addr": "入口地址", + "server_addr_port": "服务器地址:端口", + "tags": "标签", + + "tags_placeholder": "输入后回车添加", + "updated": "更新成功" +} diff --git a/apps/admin/locales/zh-CN/servers.json b/apps/admin/locales/zh-CN/servers.json new file mode 100644 index 0000000..deb3fa1 --- /dev/null +++ b/apps/admin/locales/zh-CN/servers.json @@ -0,0 +1,102 @@ +{ + "cancel": "取消", + "city": "城市", + "config": { + "title": "节点配置", + "description": "管理节点通信密钥、拉取/推送间隔与动态倍率。", + "saveSuccess": "保存成功", + "communicationKey": "通信密钥", + "inputPlaceholder": "请输入", + "communicationKeyDescription": "用于节点鉴权。", + "nodePullInterval": "节点拉取间隔", + "nodePullIntervalDescription": "节点拉取配置的频率(秒)。", + "nodePushInterval": "节点推送间隔", + "nodePushIntervalDescription": "节点上报状态的频率(秒)。", + "dynamicMultiplier": "动态倍率", + "dynamicMultiplierDescription": "按时间段设置倍率,用于调节流量或计费。", + "startTime": "开始时间", + "endTime": "结束时间", + "multiplier": "倍率", + "reset": "重置", + "save": "保存", + "timeSlot": "时间段", + "actions": { + "cancel": "取消", + "save": "保存" + } + }, + "confirm": "确认", + "confirmDeleteDesc": "该操作不可撤销。", + "confirmDeleteTitle": "确认删除该服务器?", + "congestion_controller": "拥塞控制", + "copied": "已复制", + "copy": "复制", + "country": "国家", + "cpu": "CPU", + "create": "新建", + "created": "创建成功", + "delete": "删除", + "deleted": "删除成功", + "disable_sni": "禁用 SNI", + "disk": "磁盘", + "drawerCreateTitle": "新建服务器", + "drawerEditTitle": "编辑服务器", + "edit": "编辑", + "enabled": "启用", + "encryption_method": "加密方式", + "expireTime": "到期时间", + "expired": "已过期", + "flow": "流控", + "hop_interval": "跳端口间隔", + "hop_ports": "跳端口", + "hop_ports_placeholder": "例如 443,8443,10443", + "host": "Host", + "id": "编号", + "ipAddresses": "IP 地址", + "memory": "内存", + "name": "名称", + "noData": "暂无数据", + "notAvailable": "—", + "obfs_password": "混淆密码", + "obfs_password_placeholder": "输入混淆密码", + "offline": "离线", + "online": "在线", + "onlineUsers": "在线人数", + "pageTitle": "服务器", + "path": "路径", + "please_select": "请选择", + "port": "端口", + "port_placeholder": "输入端口", + "protocols": "协议", + "reduce_rtt": "降低 RTT", + "security_allow_insecure": "允许不安全", + "security_fingerprint": "指纹", + "security_private_key": "Reality 私钥", + "security_private_key_placeholder": "输入私钥", + "security_public_key": "Reality 公钥", + "security_public_key_placeholder": "输入公钥", + "security_server_address": "Reality 回源地址", + "security_server_address_placeholder": "如 1.2.3.4 或域名", + "security_server_port": "Reality 回源端口", + "security_server_port_placeholder": "输入端口", + "security_short_id": "Reality Short ID", + "security_short_id_placeholder": "16 位内十六进制", + "security_sni": "SNI", + "security_title": "安全", + "select_encryption_method": "选择加密方式", + "serverAddress": "服务器", + "server_addr": "服务器地址", + "server_key": "服务器密钥", + "service_name": "服务名", + "status": "状态", + "subscribeId": "订阅 ID", + "subscription": "订阅", + "traffic": "流量", + "transport_title": "传输方式", + "udp_relay_mode": "UDP 转发模式", + "unitSecondsShort": "秒", + "unlimited": "不限", + "updated": "更新成功", + "user": "用户", + "validation_failed": "校验失败,请检查表单。" +} diff --git a/apps/admin/services/admin/typings.d.ts b/apps/admin/services/admin/typings.d.ts index 8a76881..1a96a3e 100644 --- a/apps/admin/services/admin/typings.d.ts +++ b/apps/admin/services/admin/typings.d.ts @@ -652,23 +652,15 @@ declare namespace API { type GetMessageLogListParams = { page: number; size: number; - type: string; - platform?: string; - to?: string; - subject?: string; - content?: string; - status?: number; + type: number; + search?: string; }; type GetMessageLogListRequest = { page: number; size: number; - type: string; - platform?: string; - to?: string; - subject?: string; - content?: string; - status?: number; + type: number; + search?: string; }; type GetMessageLogListResponse = { @@ -968,6 +960,23 @@ declare namespace API { user_id: number; }; + type GetUserSubscribeResetTrafficLogsParams = { + page: number; + size: number; + user_subscribe_id: number; + }; + + type GetUserSubscribeResetTrafficLogsRequest = { + page: number; + size: number; + user_subscribe_id: number; + }; + + type GetUserSubscribeResetTrafficLogsResponse = { + list: ResetSubscribeTrafficLog[]; + total: number; + }; + type GetUserSubscribeTrafficLogsParams = { page: number; size: number; @@ -1015,14 +1024,13 @@ declare namespace API { type MessageLog = { id: number; - type: string; + type: number; platform: string; to: string; subject: string; - content: string; + content: Record; status: number; created_at: number; - updated_at: number; }; type MobileAuthenticateConfig = { @@ -1308,6 +1316,14 @@ declare namespace API { order_no: string; }; + type ResetSubscribeTrafficLog = { + id: number; + type: number; + user_subscribe_id: number; + order_no?: string; + reset_at: number; + }; + type ResetTrafficOrderRequest = { user_subscribe_id: number; payment: number; diff --git a/apps/admin/services/admin/user.ts b/apps/admin/services/admin/user.ts index 2025c8e..acad2c7 100644 --- a/apps/admin/services/admin/user.ts +++ b/apps/admin/services/admin/user.ts @@ -345,6 +345,24 @@ export async function getUserSubscribeLogs( ); } +/** Get user subcribe reset traffic logs GET /v1/admin/user/subscribe/reset/logs */ +export async function getUserSubscribeResetTrafficLogs( + // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) + params: API.GetUserSubscribeResetTrafficLogsParams, + options?: { [key: string]: any }, +) { + return request( + '/v1/admin/user/subscribe/reset/logs', + { + method: 'GET', + params: { + ...params, + }, + ...(options || {}), + }, + ); +} + /** Get user subcribe traffic logs GET /v1/admin/user/subscribe/traffic_logs */ export async function getUserSubscribeTrafficLogs( // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) diff --git a/apps/admin/services/common/typings.d.ts b/apps/admin/services/common/typings.d.ts index 1e25c05..b07f89d 100644 --- a/apps/admin/services/common/typings.d.ts +++ b/apps/admin/services/common/typings.d.ts @@ -327,14 +327,13 @@ declare namespace API { type MessageLog = { id: number; - type: string; + type: number; platform: string; to: string; subject: string; - content: string; + content: Record; status: number; created_at: number; - updated_at: number; }; type MobileAuthenticateConfig = { @@ -618,6 +617,14 @@ declare namespace API { cf_token?: string; }; + type ResetSubscribeTrafficLog = { + id: number; + type: number; + user_subscribe_id: number; + order_no?: string; + reset_at: number; + }; + type ResetTrafficOrderRequest = { user_subscribe_id: number; payment: number; diff --git a/apps/user/services/common/common.ts b/apps/user/services/common/common.ts index 0c3920b..cc262d0 100644 --- a/apps/user/services/common/common.ts +++ b/apps/user/services/common/common.ts @@ -1,5 +1,5 @@ // @ts-ignore - +/* eslint-disable */ import request from '@/utils/request'; /** Get Ads GET /v1/common/ads */ diff --git a/apps/user/services/common/typings.d.ts b/apps/user/services/common/typings.d.ts index 1e25c05..b07f89d 100644 --- a/apps/user/services/common/typings.d.ts +++ b/apps/user/services/common/typings.d.ts @@ -327,14 +327,13 @@ declare namespace API { type MessageLog = { id: number; - type: string; + type: number; platform: string; to: string; subject: string; - content: string; + content: Record; status: number; created_at: number; - updated_at: number; }; type MobileAuthenticateConfig = { @@ -618,6 +617,14 @@ declare namespace API { cf_token?: string; }; + type ResetSubscribeTrafficLog = { + id: number; + type: number; + user_subscribe_id: number; + order_no?: string; + reset_at: number; + }; + type ResetTrafficOrderRequest = { user_subscribe_id: number; payment: number; diff --git a/apps/user/services/user/typings.d.ts b/apps/user/services/user/typings.d.ts index 85de663..0a60f8f 100644 --- a/apps/user/services/user/typings.d.ts +++ b/apps/user/services/user/typings.d.ts @@ -342,14 +342,13 @@ declare namespace API { type MessageLog = { id: number; - type: string; + type: number; platform: string; to: string; subject: string; - content: string; + content: Record; status: number; created_at: number; - updated_at: number; }; type MobileAuthenticateConfig = { @@ -728,6 +727,14 @@ declare namespace API { order_no: string; }; + type ResetSubscribeTrafficLog = { + id: number; + type: number; + user_subscribe_id: number; + order_no?: string; + reset_at: number; + }; + type ResetTrafficOrderRequest = { user_subscribe_id: number; payment: number; diff --git a/packages/ui/src/custom-components/pro-table/pro-table.tsx b/packages/ui/src/custom-components/pro-table/pro-table.tsx index 981ea3a..6b6843f 100644 --- a/packages/ui/src/custom-components/pro-table/pro-table.tsx +++ b/packages/ui/src/custom-components/pro-table/pro-table.tsx @@ -30,6 +30,7 @@ import { ColumnToggle } from '@workspace/ui/custom-components/pro-table/column-t import { Pagination } from '@workspace/ui/custom-components/pro-table/pagination'; import { SortableRow } from '@workspace/ui/custom-components/pro-table/sortable-row'; import { ProTableWrapper } from '@workspace/ui/custom-components/pro-table/wrapper'; +import { cn } from '@workspace/ui/lib/utils.js'; import { useSize } from 'ahooks'; import { GripVertical, ListRestart, Loader, RefreshCcw } from 'lucide-react'; import React, { Fragment, useEffect, useImperativeHandle, useRef, useState } from 'react'; @@ -258,7 +259,10 @@ export function ProTable< {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - +