translate new element to English

This commit is contained in:
yoan
2024-09-28 07:46:55 +08:00
48 changed files with 1532 additions and 806 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
ui/dist/assets/index-I--T0qY3.css vendored Normal file

File diff suppressed because one or more lines are too long

322
ui/dist/assets/index-TzNEc_kS.js vendored Normal file

File diff suppressed because one or more lines are too long

4
ui/dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Certimate - Your Trusted SSL Automation Partner</title> <title>Certimate - Your Trusted SSL Automation Partner</title>
<script type="module" crossorigin src="/assets/index-CQVPrK_Y.js"></script> <script type="module" crossorigin src="/assets/index-TzNEc_kS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Djc_JtNf.css"> <link rel="stylesheet" crossorigin href="/assets/index-I--T0qY3.css">
</head> </head>
<body class="bg-background"> <body class="bg-background">
<div id="root"></div> <div id="root"></div>

141
ui/package-lock.json generated
View File

@@ -27,6 +27,9 @@
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.417.0", "lucide-react": "^0.417.0",
"moment": "^2.30.1", "moment": "^2.30.1",
@@ -34,6 +37,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-i18next": "^15.0.2",
"react-router-dom": "^6.25.1", "react-router-dom": "^6.25.1",
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -382,6 +386,17 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.25.6",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.25.6.tgz",
"integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.24.7", "version": "7.24.7",
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.24.7.tgz", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.24.7.tgz",
@@ -2959,6 +2974,14 @@
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
}, },
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -3697,6 +3720,52 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "23.15.1",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-23.15.1.tgz",
"integrity": "sha512-wB4abZ3uK7EWodYisHl/asf8UYEhrI/vj/8aoSsrj/ZDxj4/UXPOa1KvFt1Fq5hkUHquNqwFlDprmjZ8iySgYA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz",
"integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "2.6.1",
"resolved": "https://registry.npmmirror.com/i18next-http-backend/-/i18next-http-backend-2.6.1.tgz",
"integrity": "sha512-rCilMAnlEQNeKOZY1+x8wLM5IpYOj10guGvEpeC59tNjj6MMreLIjIW8D1RclhD3ifLwn6d/Y9HEM1RUE6DSog==",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.1.tgz", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.1.tgz",
@@ -4112,6 +4181,25 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "dev": true
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.18", "version": "2.0.18",
"resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.18.tgz", "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.18.tgz",
@@ -4553,6 +4641,27 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-i18next": {
"version": "15.0.2",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-15.0.2.tgz",
"integrity": "sha512-z0W3/RES9Idv3MmJUcf0mDNeeMOUXe+xoL0kPfQPbDoZHmni/XsIoq5zgT2MCFUiau283GuBUK578uD/mkAbLQ==",
"dependencies": {
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -4692,6 +4801,11 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.8", "version": "1.22.8",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz",
@@ -5140,6 +5254,11 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
@@ -5361,6 +5480,28 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",

View File

@@ -40,7 +40,11 @@
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.1", "vaul": "^0.9.1",
"zod": "^3.23.8" "zod": "^3.23.8",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
"react-i18next": "^15.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",

View File

@@ -0,0 +1,33 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function LocaleToggle() {
const { i18n } = useTranslation()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Languages className="h-[1.2rem] w-[1.2rem] dark:text-white" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.keys(i18n.store.data).map(key => (
<DropdownMenuItem onClick={() => i18n.changeLanguage(key)}>
{i18n.store.data[key].name as string}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,4 +1,5 @@
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTranslation } from 'react-i18next'
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -11,6 +12,8 @@ import { useTheme } from "./ThemeProvider";
export function ThemeToggle() { export function ThemeToggle() {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const { t } = useTranslation();
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -23,13 +26,13 @@ export function ThemeToggle() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}> <DropdownMenuItem onClick={() => setTheme("light")}>
{t('theme.light')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}> <DropdownMenuItem onClick={() => setTheme("dark")}>
{t('theme.dark')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}> <DropdownMenuItem onClick={() => setTheme("system")}>
{t('theme.system')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -29,12 +30,13 @@ const AccessAliyunForm = ({
onAfterReq: () => void; onAfterReq: () => void;
}) => { }) => {
const { addAccess, updateAccess } = useConfig(); const { addAccess, updateAccess } = useConfig();
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
accessKeyId: z.string().min(1).max(64), accessKeyId: z.string().min(1, 'access.form.access.key.id.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
accessSecretId: z.string().min(1).max(64), accessSecretId: z.string().min(1, 'access.form.access.key.secret.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
}); });
let config: AliyunConfig = { let config: AliyunConfig = {
@@ -47,7 +49,7 @@ const AccessAliyunForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "aliyun", configType: "aliyun",
accessKeyId: config.accessKeyId, accessKeyId: config.accessKeyId,
accessSecretId: config.accessKeySecret, accessSecretId: config.accessKeySecret,
@@ -111,9 +113,9 @@ const AccessAliyunForm = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -126,7 +128,7 @@ const AccessAliyunForm = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -141,7 +143,7 @@ const AccessAliyunForm = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -156,9 +158,9 @@ const AccessAliyunForm = ({
name="accessKeyId" name="accessKeyId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AccessKeyId</FormLabel> <FormLabel>{t('access.form.access.key.id')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入AccessKeyId" {...field} /> <Input placeholder={t('access.form.access.key.id.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -171,9 +173,9 @@ const AccessAliyunForm = ({
name="accessSecretId" name="accessSecretId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AccessKeySecret</FormLabel> <FormLabel>{t('access.form.access.key.secret')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入AccessKeySecret" {...field} /> <Input placeholder={t('access.form.access.key.secret.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -184,7 +186,7 @@ const AccessAliyunForm = ({
<FormMessage /> <FormMessage />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -28,11 +29,12 @@ const AccessCloudflareForm = ({
onAfterReq: () => void; onAfterReq: () => void;
}) => { }) => {
const { addAccess, updateAccess } = useConfig(); const { addAccess, updateAccess } = useConfig();
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
dnsApiToken: z.string().min(1).max(64), dnsApiToken: z.string().min(1, 'access.form.cloud.dns.api.token.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
}); });
let config: CloudflareConfig = { let config: CloudflareConfig = {
@@ -44,7 +46,7 @@ const AccessCloudflareForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "cloudflare", configType: "cloudflare",
dnsApiToken: config.dnsApiToken, dnsApiToken: config.dnsApiToken,
}, },
@@ -106,9 +108,9 @@ const AccessCloudflareForm = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -121,7 +123,7 @@ const AccessCloudflareForm = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -136,7 +138,7 @@ const AccessCloudflareForm = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -151,9 +153,9 @@ const AccessCloudflareForm = ({
name="dnsApiToken" name="dnsApiToken"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>CLOUD_DNS_API_TOKEN</FormLabel> <FormLabel>{t('access.form.cloud.dns.api.token')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入CLOUD_DNS_API_TOKEN" {...field} /> <Input placeholder={t('access.form.cloud.dns.api.token.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -162,7 +164,7 @@ const AccessCloudflareForm = ({
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -8,6 +8,7 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import AccessTencentForm from "./AccessTencentForm"; import AccessTencentForm from "./AccessTencentForm";
@@ -47,6 +48,7 @@ export function AccessEdit({
className, className,
}: TargetConfigEditProps) { }: TargetConfigEditProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation();
const typeKeys = Array.from(accessTypeMap.keys()); const typeKeys = Array.from(accessTypeMap.keys());
@@ -157,25 +159,24 @@ export function AccessEdit({
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200"> <DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogHeader> <DialogHeader>
<DialogTitle>{op == "add" ? "添加" : "编辑"}</DialogTitle> <DialogTitle>{op == "add" ? t('access.add') : t('access.edit')}</DialogTitle>
</DialogHeader> </DialogHeader>
<ScrollArea className="max-h-[80vh]"> <ScrollArea className="max-h-[80vh]">
<div className="container py-3"> <div className="container py-3">
<Label></Label> <Label>{t('access.type')}</Label>
<Select <Select
onValueChange={(val) => { onValueChange={(val) => {
console.log(val);
setConfigType(val); setConfigType(val);
}} }}
defaultValue={configType} defaultValue={configType}
> >
<SelectTrigger className="mt-3"> <SelectTrigger className="mt-3">
<SelectValue placeholder="请选择服务商" /> <SelectValue placeholder={t('access.type.not.empty')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel></SelectLabel> <SelectLabel>{t('access.type')}</SelectLabel>
{typeKeys.map((key) => ( {typeKeys.map((key) => (
<SelectItem value={key} key={key}> <SelectItem value={key} key={key}>
<div <div
@@ -188,7 +189,7 @@ export function AccessEdit({
src={accessTypeMap.get(key)?.[1]} src={accessTypeMap.get(key)?.[1]}
className="h-6 w-6" className="h-6 w-6"
/> />
<div>{accessTypeMap.get(key)?.[0]}</div> <div>{t(accessTypeMap.get(key)?.[0] || '')}</div>
</div> </div>
</SelectItem> </SelectItem>
))} ))}

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -33,12 +34,13 @@ const AccessGodaddyFrom = ({
onAfterReq: () => void; onAfterReq: () => void;
}) => { }) => {
const { addAccess, updateAccess } = useConfig(); const { addAccess, updateAccess } = useConfig();
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
apiKey: z.string().min(1).max(64), apiKey: z.string().min(1, 'access.form.go.daddy.api.key.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
apiSecret: z.string().min(1).max(64), apiSecret: z.string().min(1, 'access.form.go.daddy.api.secret.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
}); });
let config: GodaddyConfig = { let config: GodaddyConfig = {
@@ -51,7 +53,7 @@ const AccessGodaddyFrom = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "godaddy", configType: "godaddy",
apiKey: config.apiKey, apiKey: config.apiKey,
apiSecret: config.apiSecret, apiSecret: config.apiSecret,
@@ -115,9 +117,9 @@ const AccessGodaddyFrom = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -130,7 +132,7 @@ const AccessGodaddyFrom = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -145,7 +147,7 @@ const AccessGodaddyFrom = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -160,9 +162,9 @@ const AccessGodaddyFrom = ({
name="apiKey" name="apiKey"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>GODADDY_API_KEY</FormLabel> <FormLabel>{t('access.form.go.daddy.api.key')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入GODADDY_API_KEY" {...field} /> <Input placeholder={t('access.form.go.daddy.api.key.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -175,9 +177,9 @@ const AccessGodaddyFrom = ({
name="apiSecret" name="apiSecret"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>GODADDY_API_SECRET</FormLabel> <FormLabel>{t('access.form.go.daddy.api.secret')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入GODADDY_API_SECRET" {...field} /> <Input placeholder={t('access.form.go.daddy.api.secret.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -186,7 +188,7 @@ const AccessGodaddyFrom = ({
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -25,6 +25,7 @@ import { update } from "@/repository/access_group";
import { ClientResponseError } from "pocketbase"; import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base"; import { PbErrorData } from "@/domain/base";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
type AccessGroupEditProps = { type AccessGroupEditProps = {
className?: string; className?: string;
@@ -35,9 +36,10 @@ const AccessGroupEdit = ({ className, trigger }: AccessGroupEditProps) => {
const { reloadAccessGroups } = useConfig(); const { reloadAccessGroups } = useConfig();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(1).max(64), name: z.string().min(1, 'access.group.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@@ -78,14 +80,13 @@ const AccessGroupEdit = ({ className, trigger }: AccessGroupEditProps) => {
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200"> <DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t('access.group.add')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="container py-3"> <div className="container py-3">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
console.log(e);
e.stopPropagation(); e.stopPropagation();
form.handleSubmit(onSubmit)(e); form.handleSubmit(onSubmit)(e);
}} }}
@@ -96,9 +97,9 @@ const AccessGroupEdit = ({ className, trigger }: AccessGroupEditProps) => {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('access.group.name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入组名" {...field} type="text" /> <Input placeholder={t('access.group.name.not.empty')} {...field} type="text" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -107,7 +108,7 @@ const AccessGroupEdit = ({ className, trigger }: AccessGroupEditProps) => {
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -29,6 +29,7 @@ import { Group } from "lucide-react";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
const AccessGroupList = () => { const AccessGroupList = () => {
const { const {
@@ -39,8 +40,7 @@ const AccessGroupList = () => {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const handleRemoveClick = async (id: string) => { const handleRemoveClick = async (id: string) => {
try { try {
@@ -48,7 +48,7 @@ const AccessGroupList = () => {
reloadAccessGroups(); reloadAccessGroups();
} catch (e) { } catch (e) {
toast({ toast({
title: "删除失败", title: t('delete.failed'),
description: getErrMessage(e), description: getErrMessage(e),
variant: "destructive", variant: "destructive",
}); });
@@ -69,10 +69,10 @@ const AccessGroupList = () => {
</span> </span>
<div className="text-center text-sm text-muted-foreground mt-3"> <div className="text-center text-sm text-muted-foreground mt-3">
{t('access.group.domain.empty')}
</div> </div>
<AccessGroupEdit <AccessGroupEdit
trigger={<Button></Button>} trigger={<Button>{t('access.group.add')}</Button>}
className="mt-3" className="mt-3"
/> />
</div> </div>
@@ -86,9 +86,7 @@ const AccessGroupList = () => {
<CardHeader> <CardHeader>
<CardTitle>{accessGroup.name}</CardTitle> <CardTitle>{accessGroup.name}</CardTitle>
<CardDescription> <CardDescription>
{t('access.group.total', { total: accessGroup.expand ? accessGroup.expand.access.length : 0 })}
{accessGroup.expand ? accessGroup.expand.access.length : 0}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="min-h-[180px]"> <CardContent className="min-h-[180px]">
@@ -123,7 +121,7 @@ const AccessGroupList = () => {
<Group size={40} /> <Group size={40} />
</div> </div>
<div className="ml-2"> <div className="ml-2">
使 {t('access.group.empty')}
</div> </div>
</div> </div>
</> </>
@@ -151,7 +149,7 @@ const AccessGroupList = () => {
); );
}} }}
> >
{t('access.all')}
</Button> </Button>
</div> </div>
</Show> </Show>
@@ -159,14 +157,14 @@ const AccessGroupList = () => {
<Show <Show
when={ when={
!accessGroup.expand || !accessGroup.expand ||
accessGroup.expand.access.length == 0 accessGroup.expand.access.length == 0
? true ? true
: false : false
} }
> >
<div> <div>
<Button size="sm" onClick={handleAddAccess}> <Button size="sm" onClick={handleAddAccess}>
{t('access.add')}
</Button> </Button>
</div> </div>
</Show> </Show>
@@ -175,21 +173,21 @@ const AccessGroupList = () => {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant={"destructive"} size={"sm"}> <Button variant={"destructive"} size={"sm"}>
{t('delete')}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="dark:text-gray-200"> <AlertDialogTitle className="dark:text-gray-200">
{t('access.group.delete')}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t('access.group.delete.confirm')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel className="dark:text-gray-200"> <AlertDialogCancel className="dark:text-gray-200">
{t('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={() => {
@@ -198,7 +196,7 @@ const AccessGroupList = () => {
); );
}} }}
> >
{t('confirm')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -28,11 +29,12 @@ const AccessNamesiloForm = ({
onAfterReq: () => void; onAfterReq: () => void;
}) => { }) => {
const { addAccess, updateAccess } = useConfig(); const { addAccess, updateAccess } = useConfig();
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
apiKey: z.string().min(1).max(64), apiKey: z.string().min(1, 'access.form.namesilo.api.key.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
}); });
let config: NamesiloConfig = { let config: NamesiloConfig = {
@@ -44,14 +46,13 @@ const AccessNamesiloForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "namesilo", configType: "namesilo",
apiKey: config.apiKey, apiKey: config.apiKey,
}, },
}); });
const onSubmit = async (data: z.infer<typeof formSchema>) => { const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data);
const req: Access = { const req: Access = {
id: data.id as string, id: data.id as string,
name: data.name, name: data.name,
@@ -106,9 +107,9 @@ const AccessNamesiloForm = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -121,7 +122,7 @@ const AccessNamesiloForm = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -136,7 +137,7 @@ const AccessNamesiloForm = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -151,9 +152,9 @@ const AccessNamesiloForm = ({
name="apiKey" name="apiKey"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>NAMESILO_API_KEY</FormLabel> <FormLabel>{t('access.form.namesilo.api.key')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入NAMESILO_API_KEY" {...field} /> <Input placeholder={t('access.form.namesilo.api.key.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -162,7 +163,7 @@ const AccessNamesiloForm = ({
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -29,12 +30,13 @@ const AccessQiniuForm = ({
onAfterReq: () => void; onAfterReq: () => void;
}) => { }) => {
const { addAccess, updateAccess } = useConfig(); const { addAccess, updateAccess } = useConfig();
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
accessKey: z.string().min(1).max(64), accessKey: z.string().min(1, 'access.form.access.key.not.empty').max(64),
secretKey: z.string().min(1).max(64), secretKey: z.string().min(1, 'access.form.secret.key.not.empty').max(64),
}); });
let config: QiniuConfig = { let config: QiniuConfig = {
@@ -47,7 +49,7 @@ const AccessQiniuForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "qiniu", configType: "qiniu",
accessKey: config.accessKey, accessKey: config.accessKey,
secretKey: config.secretKey, secretKey: config.secretKey,
@@ -111,9 +113,9 @@ const AccessQiniuForm = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -126,7 +128,7 @@ const AccessQiniuForm = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -141,7 +143,7 @@ const AccessQiniuForm = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -156,9 +158,9 @@ const AccessQiniuForm = ({
name="accessKey" name="accessKey"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AccessKey</FormLabel> <FormLabel>{t('access.form.access.key')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入AccessKey" {...field} /> <Input placeholder={t('access.form.access.key.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -171,9 +173,9 @@ const AccessQiniuForm = ({
name="secretKey" name="secretKey"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>SecretKey</FormLabel> <FormLabel>{t('access.form.secret.key')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入SecretKey" {...field} /> <Input placeholder={t('access.form.secret.key.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -184,7 +186,7 @@ const AccessQiniuForm = ({
<FormMessage /> <FormMessage />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -24,6 +24,7 @@ import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base"; import { PbErrorData } from "@/domain/base";
import { readFileContent } from "@/lib/file"; import { readFileContent } from "@/lib/file";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -53,6 +54,7 @@ const AccessSSHForm = ({
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState(""); const [fileName, setFileName] = useState("");
const { t } = useTranslation();
const originGroup = data ? (data.group ? data.group : "") : ""; const originGroup = data ? (data.group ? data.group : "") : "";
@@ -62,26 +64,27 @@ const AccessSSHForm = ({
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
host: z.string().refine( host: z.string().refine(
(str) => { (str) => {
return ipReg.test(str) || domainReg.test(str); return ipReg.test(str) || domainReg.test(str);
}, },
{ {
message: "请输入正确的域名或IP", message: "zod.rule.ssh.host",
} }
), ),
group: z.string().optional(), group: z.string().optional(),
port: z.string().min(1).max(5), port: z.string().min(1, 'access.form.ssh.port.not.empty').max(5, t('zod.rule.string.max', { max: 5 })),
username: z.string().min(1).max(64), username: z.string().min(1, 'username.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
password: z.string().min(0).max(64), password: z.string().min(0, 'password.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
key: z.string().min(0).max(20480), key: z.string().min(0, 'access.form.ssh.key.not.empty').max(20480, t('zod.rule.string.max', { max: 20480 })),
keyFile: z.any().optional(), keyFile: z.any().optional(),
command: z.string().min(1).max(2048),
preCommand: z.string().min(0).max(2048).optional(), preCommand: z.string().min(0).max(2048).optional(),
certPath: z.string().min(0).max(2048), command: z.string().min(1, 'access.form.ssh.command.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
keyPath: z.string().min(0).max(2048), certPath: z.string().min(0, 'access.form.ssh.cert.path.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
keyPath: z.string().min(0, 'access.form.ssh.key.path.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
}); });
let config: SSHConfig = { let config: SSHConfig = {
@@ -102,7 +105,7 @@ const AccessSSHForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "ssh", configType: "ssh",
group: data?.group, group: data?.group,
host: config.host, host: config.host,
@@ -223,9 +226,9 @@ const AccessSSHForm = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -239,12 +242,12 @@ const AccessSSHForm = ({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="w-full flex justify-between"> <FormLabel className="w-full flex justify-between">
<div>( ssh )</div> <div>{t('access.form.ssh.group.label')}</div>
<AccessGroupEdit <AccessGroupEdit
trigger={ trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center"> <div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} /> <Plus size={14} />
{t('add')}
</div> </div>
} }
/> />
@@ -259,7 +262,7 @@ const AccessSSHForm = ({
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择分组" /> <SelectValue placeholder={t('access.group.not.empty')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="emptyId"> <SelectItem value="emptyId">
@@ -299,7 +302,7 @@ const AccessSSHForm = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -314,7 +317,7 @@ const AccessSSHForm = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -329,9 +332,9 @@ const AccessSSHForm = ({
name="host" name="host"
render={({ field }) => ( render={({ field }) => (
<FormItem className="grow"> <FormItem className="grow">
<FormLabel>HOST</FormLabel> <FormLabel>{t('access.form.ssh.host')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入Host" {...field} /> <Input placeholder={t('access.form.ssh.host.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -344,10 +347,10 @@ const AccessSSHForm = ({
name="port" name="port"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>SSH端口</FormLabel> <FormLabel>{t('access.form.ssh.port')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="请输入Port" placeholder={t('access.form.ssh.port.not.empty')}
{...field} {...field}
type="number" type="number"
/> />
@@ -364,9 +367,9 @@ const AccessSSHForm = ({
name="username" name="username"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('username')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入用户名" {...field} /> <Input placeholder={t('username.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -379,10 +382,10 @@ const AccessSSHForm = ({
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('password')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="请输入密码" placeholder={t('password.not.empty')}
{...field} {...field}
type="password" type="password"
/> />
@@ -398,9 +401,9 @@ const AccessSSHForm = ({
name="key" name="key"
render={({ field }) => ( render={({ field }) => (
<FormItem hidden> <FormItem hidden>
<FormLabel>Key使</FormLabel> <FormLabel>{t('access.form.ssh.key')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入Key" {...field} /> <Input placeholder={t('access.form.ssh.key.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -413,7 +416,7 @@ const AccessSSHForm = ({
name="keyFile" name="keyFile"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Key使</FormLabel> <FormLabel>{t('access.form.ssh.key')}</FormLabel>
<FormControl> <FormControl>
<div> <div>
<Button <Button
@@ -423,10 +426,10 @@ const AccessSSHForm = ({
className="w-48" className="w-48"
onClick={handleSelectFileClick} onClick={handleSelectFileClick}
> >
{fileName ? fileName : "请选择文件"} {fileName ? fileName : t('access.form.ssh.key.file.not.empty')}
</Button> </Button>
<Input <Input
placeholder="请输入Key" placeholder={t('access.form.ssh.key.not.empty')}
{...field} {...field}
ref={fileInputRef} ref={fileInputRef}
className="hidden" className="hidden"
@@ -447,9 +450,9 @@ const AccessSSHForm = ({
name="certPath" name="certPath"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('access.form.ssh.cert.path')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入证书上传路径" {...field} /> <Input placeholder={t('access.form.ssh.cert.path.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -462,9 +465,9 @@ const AccessSSHForm = ({
name="keyPath" name="keyPath"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('access.form.ssh.key.path')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入私钥上传路径" {...field} /> <Input placeholder={t('access.form.ssh.key.path.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -492,9 +495,9 @@ const AccessSSHForm = ({
name="command" name="command"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Command</FormLabel> <FormLabel>{t('access.form.ssh.command')}</FormLabel>
<FormControl> <FormControl>
<Textarea placeholder="请输入要执行的命令" {...field} /> <Textarea placeholder={t('access.form.ssh.command.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -505,7 +508,7 @@ const AccessSSHForm = ({
<FormMessage /> <FormMessage />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -28,12 +29,13 @@ const AccessTencentForm = ({
onAfterReq: () => void; onAfterReq: () => void;
}) => { }) => {
const { addAccess, updateAccess } = useConfig(); const { addAccess, updateAccess } = useConfig();
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
secretId: z.string().min(1).max(64), secretId: z.string().min(1, 'access.form.secret.id.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
secretKey: z.string().min(1).max(64), secretKey: z.string().min(1, 'access.form.secret.key.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
}); });
let config: TencentConfig = { let config: TencentConfig = {
@@ -46,7 +48,7 @@ const AccessTencentForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "tencent", configType: "tencent",
secretId: config.secretId, secretId: config.secretId,
secretKey: config.secretKey, secretKey: config.secretKey,
@@ -108,9 +110,9 @@ const AccessTencentForm = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -123,7 +125,7 @@ const AccessTencentForm = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -138,7 +140,7 @@ const AccessTencentForm = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -153,9 +155,9 @@ const AccessTencentForm = ({
name="secretId" name="secretId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>SecretId</FormLabel> <FormLabel>{t('access.form.secret.id')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入SecretId" {...field} /> <Input placeholder={t('access.form.secret.id.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -168,9 +170,9 @@ const AccessTencentForm = ({
name="secretKey" name="secretKey"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>SecretKey</FormLabel> <FormLabel>{t('access.form.secret.key')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入SecretKey" {...field} /> <Input placeholder={t('access.form.secret.key.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -179,7 +181,7 @@ const AccessTencentForm = ({
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -28,11 +29,12 @@ const WebhookForm = ({
onAfterReq: () => void; onAfterReq: () => void;
}) => { }) => {
const { addAccess, updateAccess } = useConfig(); const { addAccess, updateAccess } = useConfig();
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().min(1).max(64), name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
configType: accessFormType, configType: accessFormType,
url: z.string().url(), url: z.string().url('zod.rule.url'),
}); });
let config: WebhookConfig = { let config: WebhookConfig = {
@@ -44,14 +46,13 @@ const WebhookForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: data?.id, id: data?.id,
name: data?.name, name: data?.name || '',
configType: "webhook", configType: "webhook",
url: config.url, url: config.url,
}, },
}); });
const onSubmit = async (data: z.infer<typeof formSchema>) => { const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data);
const req: Access = { const req: Access = {
id: data.id as string, id: data.id as string,
name: data.name, name: data.name,
@@ -106,9 +107,9 @@ const WebhookForm = ({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('name')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入授权名称" {...field} /> <Input placeholder={t('access.form.name.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -121,7 +122,7 @@ const WebhookForm = ({
name="id" name="id"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -136,7 +137,7 @@ const WebhookForm = ({
name="configType" name="configType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="hidden"> <FormItem className="hidden">
<FormLabel></FormLabel> <FormLabel>{t('access.form.config.field')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -151,9 +152,9 @@ const WebhookForm = ({
name="url" name="url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Webhook Url</FormLabel> <FormLabel>{t('access.form.webhook.url')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入Webhook Url" {...field} /> <Input placeholder={t('access.form.webhook.url.not.empty')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -162,7 +163,7 @@ const WebhookForm = ({
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,3 +1,8 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
type DeployProgressProps = { type DeployProgressProps = {
@@ -6,80 +11,63 @@ type DeployProgressProps = {
}; };
const DeployProgress = ({ phase, phaseSuccess }: DeployProgressProps) => { const DeployProgress = ({ phase, phaseSuccess }: DeployProgressProps) => {
let rs = <> </>; const { t } = useTranslation();
let step = 0;
if (phase === "check") { if (phase === "check") {
if (phaseSuccess) { step = 1
rs = ( } else if (phase === "apply") {
<div className="flex items-center"> step = 2
<div className="text-xs text-nowrap text-green-600"> </div> } else if (phase === "deploy") {
<Separator className="h-1 grow" /> step = 3
<div className="text-xs text-nowrap text-muted-foreground"></div>
<Separator className="h-1 grow" />
<div className="text-xs text-nowrap text-muted-foreground"></div>
</div>
);
} else {
rs = (
<div className="flex items-center">
<div className="text-xs text-nowrap text-red-600"> </div>
<Separator className="h-1 grow" />
<div className="text-xs text-nowrap text-muted-foreground"></div>
<Separator className="h-1 grow" />
<div className="text-xs text-nowrap text-muted-foreground"></div>
</div>
);
}
} }
if (phase === "apply") { return (
if (phaseSuccess) { <div className="flex items-center">
rs = ( <div className={
<div className="flex items-center"> cn(
<div className="text-xs text-nowrap text-green-600"> </div> "text-xs text-nowrap",
<Separator className="h-1 grow bg-green-600" /> step === 1 ? phaseSuccess ? "text-green-600" : "text-red-600" : "",
<div className="text-xs text-nowrap text-green-600"></div> step > 1 ? "text-green-600" : "",
<Separator className="h-1 grow" /> )
<div className="text-xs text-nowrap text-muted-foreground"></div> }>
</div> {t('deploy.progress.check')}
); </div>
} else { <Separator className={
rs = ( cn(
<div className="flex items-center"> "h-1 grow max-w-[60px]",
<div className="text-xs text-nowrap text-green-600"> </div> step > 1 ? "bg-green-600" : "",
<Separator className="h-1 grow bg-green-600" /> )
<div className="text-xs text-nowrap text-red-600"></div> } />
<Separator className="h-1 grow" /> <div className={
<div className="text-xs text-nowrap text-muted-foreground"></div> cn(
</div> "text-xs text-nowrap",
); step < 2 ? "text-muted-foreground" : "",
} step === 2 ? phaseSuccess ? "text-green-600" : "text-red-600" : "",
} step > 2 ? "text-green-600" : "",
)
if (phase === "deploy") { }>
if (phaseSuccess) { {t('deploy.progress.apply')}
rs = ( </div>
<div className="flex items-center"> <Separator className={
<div className="text-xs text-nowrap text-green-600"> </div> cn(
<Separator className="h-1 grow bg-green-600" /> "h-1 grow max-w-[60px]",
<div className="text-xs text-nowrap text-green-600"></div> step > 2 ? "bg-green-600" : "",
<Separator className="h-1 grow bg-green-600" /> )
<div className="text-xs text-nowrap text-green-600"></div> } />
</div> <div className={
); cn(
} else { "text-xs text-nowrap",
rs = ( step < 3 ? "text-muted-foreground" : "",
<div className="flex items-center"> step === 3 ? phaseSuccess ? "text-green-600" : "text-red-600" : "",
<div className="text-xs text-nowrap text-green-600"> </div> step > 3 ? "text-green-600" : "",
<Separator className="h-1 grow bg-green-600" /> )
<div className="text-xs text-nowrap text-green-600"></div> }>
<Separator className="h-1 grow bg-green-600" /> {t('deploy.progress.deploy')}
<div className="text-xs text-nowrap text-red-600"></div> </div>
</div> </div>
); )
}
}
return rs;
}; };
export default DeployProgress; export default DeployProgress;

View File

@@ -10,6 +10,7 @@ import {
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { import {
Form, Form,
FormControl, FormControl,
@@ -39,9 +40,10 @@ const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
} = useConfig(); } = useConfig();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation();
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email(), email: z.string().email("email.valid.message"),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@@ -54,7 +56,7 @@ const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
const onSubmit = async (data: z.infer<typeof formSchema>) => { const onSubmit = async (data: z.infer<typeof formSchema>) => {
if ((emails.content as EmailsSetting).emails.includes(data.email)) { if ((emails.content as EmailsSetting).emails.includes(data.email)) {
form.setError("email", { form.setError("email", {
message: "邮箱已存在", message: "email.already.exist",
}); });
return; return;
} }
@@ -100,7 +102,7 @@ const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200"> <DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t('email.add')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="container py-3"> <div className="container py-3">
@@ -118,9 +120,9 @@ const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('email')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入邮箱" {...field} type="email" /> <Input placeholder={t('email.not.empty.message')} {...field} type="email" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -129,7 +131,7 @@ const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,9 +1,12 @@
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { version } from "@/domain/version"; import { version } from "@/domain/version";
const Version = () => { const Version = () => {
const { t } = useTranslation()
return ( return (
<div className="fixed right-0 bottom-0 w-full flex justify-between p-5"> <div className="fixed right-0 bottom-0 w-full flex justify-between p-5">
<div className=""></div> <div className=""></div>
@@ -14,7 +17,7 @@ const Version = () => {
className="flex items-center" className="flex items-center"
> >
<BookOpen size={16} /> <BookOpen size={16} />
<div className="ml-1"></div> <div className="ml-1">{t('document')}</div>
</a> </a>
<Separator orientation="vertical" className="mx-2" /> <Separator orientation="vertical" className="mx-2" />
<a <a

View File

@@ -8,6 +8,7 @@ import { useEffect, useState } from "react";
import { update } from "@/repository/settings"; import { update } from "@/repository/settings";
import { getErrMessage } from "@/lib/error"; import { getErrMessage } from "@/lib/error";
import { useToast } from "../ui/use-toast"; import { useToast } from "../ui/use-toast";
import { useTranslation } from 'react-i18next'
type DingTalkSetting = { type DingTalkSetting = {
id: string; id: string;
@@ -17,6 +18,7 @@ type DingTalkSetting = {
const DingTalk = () => { const DingTalk = () => {
const { config, setChannels } = useNotify(); const { config, setChannels } = useNotify();
const { t } = useTranslation();
const [dingtalk, setDingtalk] = useState<DingTalkSetting>({ const [dingtalk, setDingtalk] = useState<DingTalkSetting>({
id: config.id ?? "", id: config.id ?? "",
@@ -70,15 +72,15 @@ const DingTalk = () => {
setChannels(resp); setChannels(resp);
toast({ toast({
title: "保存成功", title: t('save.succeed'),
description: "配置保存成功", description: t('setting.notify.config.save.succeed'),
}); });
} catch (e) { } catch (e) {
const msg = getErrMessage(e); const msg = getErrMessage(e);
toast({ toast({
title: "保存失败", title: t('save.failed'),
description: "配置保存失败:" + msg, description: `${t('setting.notify.config.save.failed')}: ${msg}`,
variant: "destructive", variant: "destructive",
}); });
} }
@@ -100,7 +102,7 @@ const DingTalk = () => {
}} }}
/> />
<Input <Input
placeholder="加签的签名" placeholder={t('access.form.ding.access.token.placeholder')}
className="mt-2" className="mt-2"
value={dingtalk.data.secret} value={dingtalk.data.secret}
onChange={(e) => { onChange={(e) => {
@@ -127,7 +129,7 @@ const DingTalk = () => {
}); });
}} }}
/> />
<Label htmlFor="airplane-mode"></Label> <Label htmlFor="airplane-mode">{t('setting.notify.config.enable')}</Label>
</div> </div>
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-2">
@@ -136,7 +138,7 @@ const DingTalk = () => {
handleSaveClick(); handleSaveClick();
}} }}
> >
{t('save')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import {
} from "@/domain/settings"; } from "@/domain/settings";
import { getSetting, update } from "@/repository/settings"; import { getSetting, update } from "@/repository/settings";
import { useToast } from "../ui/use-toast"; import { useToast } from "../ui/use-toast";
import { useTranslation } from 'react-i18next'
const NotifyTemplate = () => { const NotifyTemplate = () => {
const [id, setId] = useState(""); const [id, setId] = useState("");
@@ -17,6 +18,7 @@ const NotifyTemplate = () => {
]); ]);
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const featchData = async () => { const featchData = async () => {
@@ -66,8 +68,8 @@ const NotifyTemplate = () => {
} }
toast({ toast({
title: "保存成功", title: t('save.succeed'),
description: "通知模板保存成功", description: t('setting.notify.template.save.succeed'),
}); });
}; };
@@ -81,7 +83,7 @@ const NotifyTemplate = () => {
/> />
<div className="text-muted-foreground text-sm mt-1"> <div className="text-muted-foreground text-sm mt-1">
, COUNT:即将过期张数 {t('setting.notify.template.variables.tips.title')}
</div> </div>
<Textarea <Textarea
@@ -92,10 +94,10 @@ const NotifyTemplate = () => {
}} }}
></Textarea> ></Textarea>
<div className="text-muted-foreground text-sm mt-1"> <div className="text-muted-foreground text-sm mt-1">
, COUNT:即将过期张数DOMAINS:域名列表 {t('setting.notify.template.variables.tips.content')}
</div> </div>
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-2">
<Button onClick={handleSaveClick}></Button> <Button onClick={handleSaveClick}>{t('save')}</Button>
</div> </div>
</div> </div>
); );

View File

@@ -8,6 +8,7 @@ import { useEffect, useState } from "react";
import { update } from "@/repository/settings"; import { update } from "@/repository/settings";
import { getErrMessage } from "@/lib/error"; import { getErrMessage } from "@/lib/error";
import { useToast } from "../ui/use-toast"; import { useToast } from "../ui/use-toast";
import { useTranslation } from "react-i18next";
type TelegramSetting = { type TelegramSetting = {
id: string; id: string;
@@ -17,6 +18,7 @@ type TelegramSetting = {
const Telegram = () => { const Telegram = () => {
const { config, setChannels } = useNotify(); const { config, setChannels } = useNotify();
const { t } = useTranslation();
const [telegram, setTelegram] = useState<TelegramSetting>({ const [telegram, setTelegram] = useState<TelegramSetting>({
id: config.id ?? "", id: config.id ?? "",
@@ -70,15 +72,15 @@ const Telegram = () => {
setChannels(resp); setChannels(resp);
toast({ toast({
title: "保存成功", title: t('save.succeed'),
description: "配置保存成功", description: t('setting.notify.config.save.succeed'),
}); });
} catch (e) { } catch (e) {
const msg = getErrMessage(e); const msg = getErrMessage(e);
toast({ toast({
title: "保存失败", title: t('save.failed'),
description: "配置保存失败:" + msg, description: `${t('setting.notify.config.save.failed')}: ${msg}`,
variant: "destructive", variant: "destructive",
}); });
} }
@@ -128,7 +130,7 @@ const Telegram = () => {
}); });
}} }}
/> />
<Label htmlFor="airplane-mode"></Label> <Label htmlFor="airplane-mode">{t('setting.notify.config.enable')}</Label>
</div> </div>
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-2">
@@ -137,7 +139,7 @@ const Telegram = () => {
handleSaveClick(); handleSaveClick();
}} }}
> >
{t('save')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import { update } from "@/repository/settings";
import { getErrMessage } from "@/lib/error"; import { getErrMessage } from "@/lib/error";
import { useToast } from "../ui/use-toast"; import { useToast } from "../ui/use-toast";
import { isValidURL } from "@/lib/url"; import { isValidURL } from "@/lib/url";
import { useTranslation } from 'react-i18next'
type WebhookSetting = { type WebhookSetting = {
id: string; id: string;
@@ -18,6 +19,7 @@ type WebhookSetting = {
const Webhook = () => { const Webhook = () => {
const { config, setChannels } = useNotify(); const { config, setChannels } = useNotify();
const { t } = useTranslation();
const [webhook, setWebhook] = useState<WebhookSetting>({ const [webhook, setWebhook] = useState<WebhookSetting>({
id: config.id ?? "", id: config.id ?? "",
@@ -59,8 +61,8 @@ const Webhook = () => {
webhook.data.url = webhook.data.url.trim(); webhook.data.url = webhook.data.url.trim();
if (!isValidURL(webhook.data.url)) { if (!isValidURL(webhook.data.url)) {
toast({ toast({
title: "保存失败", title: t('save.failed'),
description: "Url格式不正确", description: t('setting.notify.config.save.failed.url.not.valid'),
variant: "destructive", variant: "destructive",
}); });
return; return;
@@ -79,15 +81,15 @@ const Webhook = () => {
setChannels(resp); setChannels(resp);
toast({ toast({
title: "保存成功", title: t('save.succeed'),
description: "配置保存成功", description: t('setting.notify.config.save.succeed'),
}); });
} catch (e) { } catch (e) {
const msg = getErrMessage(e); const msg = getErrMessage(e);
toast({ toast({
title: "保存失败", title: t('save.failed'),
description: "配置保存失败:" + msg, description: `${t('setting.notify.config.save.failed')}: ${msg}`,
variant: "destructive", variant: "destructive",
}); });
} }
@@ -123,7 +125,7 @@ const Webhook = () => {
}); });
}} }}
/> />
<Label htmlFor="airplane-mode"></Label> <Label htmlFor="airplane-mode">{t('setting.notify.config.enable')}</Label>
</div> </div>
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-2">
@@ -132,7 +134,7 @@ const Webhook = () => {
handleSaveClick(); handleSaveClick();
}} }}
> >
{t('save')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useTranslation } from "react-i18next"
const Form = FormProvider const Form = FormProvider
@@ -145,7 +146,9 @@ const FormMessage = React.forwardRef<
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children const { t } = useTranslation()
const body = error ? t(String(error?.message)) : children
if (!body) { if (!body) {
return null return null

View File

@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button"; import { ButtonProps, buttonVariants } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav <nav
@@ -62,33 +63,41 @@ PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ const PaginationPrevious = ({
className, className,
...props ...props
}: React.ComponentProps<typeof PaginationLink>) => ( }: React.ComponentProps<typeof PaginationLink>) => {
<PaginationLink const { t } = useTranslation()
aria-label="Go to previous page"
size="default" return (
className={cn("gap-1 pl-2.5", className)} <PaginationLink
{...props} aria-label="Go to previous page"
> size="default"
<ChevronLeft className="h-4 w-4" /> className={cn("gap-1 pl-2.5", className)}
<span></span> {...props}
</PaginationLink> >
); <ChevronLeft className="h-4 w-4" />
<span>{t('pagination.prev')}</span>
</PaginationLink>
)
};
PaginationPrevious.displayName = "PaginationPrevious"; PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ const PaginationNext = ({
className, className,
...props ...props
}: React.ComponentProps<typeof PaginationLink>) => ( }: React.ComponentProps<typeof PaginationLink>) => {
<PaginationLink const { t } = useTranslation()
aria-label="Go to next page"
size="default" return (
className={cn("gap-1 pr-2.5", className)} <PaginationLink
{...props} aria-label="Go to next page"
> size="default"
<span></span> className={cn("gap-1 pr-2.5", className)}
<ChevronRight className="h-4 w-4" /> {...props}
</PaginationLink> >
); <span>{t('pagination.next')}</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
};
PaginationNext.displayName = "PaginationNext"; PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ const PaginationEllipsis = ({

View File

@@ -1,15 +1,15 @@
import { z } from "zod"; import { z } from "zod";
export const accessTypeMap: Map<string, [string, string]> = new Map([ export const accessTypeMap: Map<string, [string, string]> = new Map([
["tencent", ["腾讯云", "/imgs/providers/tencent.svg"]], ["tencent", ["tencent", "/imgs/providers/tencent.svg"]],
["aliyun", ["阿里云", "/imgs/providers/aliyun.svg"]], ["aliyun", ["aliyun", "/imgs/providers/aliyun.svg"]],
["cloudflare", ["Cloudflare", "/imgs/providers/cloudflare.svg"]], ["cloudflare", ["cloudflare", "/imgs/providers/cloudflare.svg"]],
["namesilo", ["Namesilo", "/imgs/providers/namesilo.svg"]], ["namesilo", ["namesilo", "/imgs/providers/namesilo.svg"]],
["godaddy", ["GoDaddy", "/imgs/providers/godaddy.svg"]], ["godaddy", ["go.daddy", "/imgs/providers/godaddy.svg"]],
["qiniu", ["七牛云", "/imgs/providers/qiniu.svg"]], ["qiniu", ["qiniu", "/imgs/providers/qiniu.svg"]],
["ssh", ["SSH部署", "/imgs/providers/ssh.svg"]], ["ssh", ["ssh", "/imgs/providers/ssh.svg"]],
["webhook", ["Webhook", "/imgs/providers/webhook.svg"]], ["webhook", ["webhook", "/imgs/providers/webhook.svg"]],
["local", ["本地部署", "/imgs/providers/local.svg"]], ["local", ["local", "/imgs/providers/local.svg"]],
]); ]);
export const getProviderInfo = (t: string) => { export const getProviderInfo = (t: string) => {
@@ -28,7 +28,7 @@ export const accessFormType = z.union(
z.literal("godaddy"), z.literal("godaddy"),
z.literal("local"), z.literal("local"),
], ],
{ message: "请选择云服务商" } { message: "access.not.empty" }
); );
type AccessUsage = "apply" | "deploy" | "all"; type AccessUsage = "apply" | "deploy" | "all";

View File

@@ -40,14 +40,14 @@ export const getLastDeployment = (domain: Domain): Deployment | undefined => {
}; };
export const targetTypeMap: Map<string, [string, string]> = new Map([ export const targetTypeMap: Map<string, [string, string]> = new Map([
["aliyun-cdn", ["阿里云-CDN", "/imgs/providers/aliyun.svg"]], ["aliyun-cdn", ["aliyun.cdn", "/imgs/providers/aliyun.svg"]],
["aliyun-oss", ["阿里云-OSS", "/imgs/providers/aliyun.svg"]], ["aliyun-oss", ["aliyun.oss", "/imgs/providers/aliyun.svg"]],
["aliyun-dcdn", ["阿里云-DCDN", "/imgs/providers/aliyun.svg"]], ["aliyun-dcdn", ["aliyun.dcdn", "/imgs/providers/aliyun.svg"]],
["tencent-cdn", ["腾讯云-CDN", "/imgs/providers/tencent.svg"]], ["tencent-cdn", ["tencent.cdn", "/imgs/providers/tencent.svg"]],
["ssh", ["SSH部署", "/imgs/providers/ssh.svg"]], ["ssh", ["ssh", "/imgs/providers/ssh.svg"]],
["qiniu-cdn", ["七牛云-CDN", "/imgs/providers/qiniu.svg"]], ["qiniu-cdn", ["qiniu.cdn", "/imgs/providers/qiniu.svg"]],
["webhook", ["Webhook", "/imgs/providers/webhook.svg"]], ["webhook", ["webhook", "/imgs/providers/webhook.svg"]],
["local", ["本地部署", "/imgs/providers/local.svg"]], ["local", ["local", "/imgs/providers/local.svg"]],
]); ]);
export const targetTypeKeys = Array.from(targetTypeMap.keys()); export const targetTypeKeys = Array.from(targetTypeMap.keys());

22
ui/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import resources from './locales'
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'zh',
debug: true,
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}.json',
}
});
export default i18n;

210
ui/src/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,210 @@
{
"ca": "Certificate Authority",
"username": "Username",
"username.not.empty": "Please enter username",
"password": "Password",
"password.not.empty": "Please enter password",
"email": "Email",
"logout": "Logout",
"setting": "Settings",
"account": "Account",
"template": "Template",
"save": "Save",
"no.data": "No data available",
"status": "Status",
"operation": "Operation",
"enable": "Enable",
"disable": "Disable",
"deploy": "Deploy",
"download": "Download",
"delete": "Delete",
"cancel": "Cancel",
"confirm": "Confirm",
"edit": "Edit",
"succeed": "Successful",
"add": "Add",
"document": "Document",
"variables": "Variables",
"dns": "Domain Name Server",
"name": "Name",
"create.time": "CreateTime",
"update.time": "UpdateTime",
"created.in": "Created in",
"updated.in": "Updated in",
"basic.setting": "Basic Settings",
"advanced.setting": "Advanced Settings",
"operation.succeed": "Operation Successful",
"save.succeed": "Save Successful",
"save.failed": "Save Failed",
"update.succeed": "Update Successful",
"update.failed": "Update Failed",
"delete.failed": "Delete Failed",
"ding.talk": "Ding Talk",
"telegram": "Telegram",
"webhook": "Webhook",
"local": "Local Deployment",
"tencent": "Tencent",
"tencent.cdn": "Tencent-CDN",
"aliyun": "Alibaba Cloud",
"aliyun.cdn": "Alibaba Cloud-CDN",
"aliyun.oss": "Alibaba Cloud-OSS",
"aliyun.dcdn": "Alibaba Cloud-DCDN",
"qiniu": "Qiniu",
"qiniu.cdn": "Qiniu-CDN",
"cloudflare": "Cloudflare",
"namesilo": "Namesilo",
"go.daddy": "GoDaddy",
"ssh": "SSH Deployment",
"zod.rule.string.max": "Please enter no more than {{max}} characters",
"zod.rule.url": "Please enter a valid URL",
"zod.rule.ssh.host": "Please enter the correct domain name or IP",
"login.submit": "Log In",
"login.username.no.empty.message": "Please enter a valid email address",
"login.password.length.message": "Password should be at least 10 characters",
"menu.auth.management": "Authorization Management",
"theme.light": "Light",
"theme.dark": "Dark",
"theme.system": "System",
"dashboard": "Dashboard",
"dashboard.all": "All",
"dashboard.near.expired": "About to Expire",
"dashboard.enabled": "Enabled",
"dashboard.not.enabled": "Not Enabled",
"dashboard.unit": "Unit",
"deployment.log.name": "Deployment History",
"deployment.log.empty": "You have not created any deployments yet, please add a domain to start deployment!",
"deployment.log.status": "Status",
"deployment.log.stage": "Stage",
"deployment.log.last.execution.time": "Last Execution Time",
"deployment.log.detail.button.text": "Log",
"deployment.log.detail": "Deployment Details",
"pagination.next": "Next",
"pagination.prev": "Previous",
"domain": "Domain",
"domain.add": "Add Domain",
"domain.delete": "Delete Domain",
"domain.not.empty.verify.message": "Please enter domain",
"domain.management.name": "Domain List",
"domain.management.start.deploy.succeed.tips": "Deployment initiated, please check the deployment log later.",
"domain.management.execution.failed": "Execution Failed",
"domain.management.execution.failed.tips": "Execution failed, please check the details in <1>Deployment History</1>.",
"domain.management.empty": "Please add a domain to start deploying the certificate.",
"domain.management.expiry.date": "Validity Period",
"domain.management.expiry.date1": "Valid for {{date}} days",
"domain.management.expiry.date2": "Expiry on {{date}}",
"domain.management.last.execution.time": "Last Execution Time",
"domain.management.last.execution.status": "Last Execution Status",
"domain.management.last.execution.stage": "Last Execution Stage",
"domain.management.enable": "Enable",
"domain.management.start.deploying": "Deploy Now",
"domain.management.forced.deployment": "Force Deployment",
"domain.management.delete.confirm": "Are you sure you want to delete this domain?",
"domain.management.edit.title": "Edit Domain",
"domain.management.edit.dns.access.label": "DNS Provider Authorization Configuration",
"domain.management.edit.dns.access.not.empty.message": "Please select DNS provider authorization configuration",
"domain.management.edit.access.label": "Provider Authorization Configuration",
"domain.management.edit.access.not.empty.message": "Please select authorization configuration",
"domain.management.edit.target.type": "Deployment Service Type",
"domain.management.edit.target.type.not.empty.message": "Please select deployment service type",
"domain.management.edit.succeed.tips": "Successful domain editing",
"domain.management.edit.target.access": "Deployment Service Provider Authorization Configuration",
"domain.management.edit.target.access.content.label": "Provider Authorization Configuration",
"domain.management.edit.target.access.not.empty.message": "Please select authorization configuration",
"domain.management.edit.target.access.verify.msg": "At least one of the deployment authorization and deployment authorization group must be selected",
"domain.management.edit.group.label": "Deployment Configuration Group (used to deploy a domain certificate to multiple ssh hosts)",
"domain.management.edit.group.not.empty.message": "Please select group",
"domain.management.edit.email.not.empty.message": "Please select email",
"domain.management.edit.email.description": "(A email is required to apply for a certificate)",
"domain.management.edit.variables.placeholder": "It can be used in SSH deployment, like:\nkey=val;\nkey2=val2;",
"domain.management.edit.dns.placeholder": "Custom domain name server, separates multiple entries with semicolon, like:\n8.8.8.8;\n8.8.4.4;",
"domain.management.add.succeed.tips": "Domain added successfully",
"email.add": "Add Email",
"email.list": "Email List",
"email.valid.message": "Please enter a valid email address",
"email.already.exist": "Email already exists",
"email.not.empty.message": "Please enter email",
"setting.notify.menu": "Notification Push",
"setting.submit": "Confirm Changes",
"setting.account.email.valid.message": "Please enter a valid email address",
"setting.account.email.placeholder": "Please enter email",
"setting.account.email.change.succeed": "Account email altered successfully",
"setting.account.email.change.failed": "Account email alteration failed",
"setting.account.log.back.in": "Please login again",
"setting.password.length.message": "Password should be at least 10 characters",
"setting.password.not.match": "Passwords do not match",
"setting.password.change.succeed": "Password changed successfully",
"setting.password.change.failed": "Password change failed",
"setting.password.current.password": "Current Password",
"setting.password.new.password": "New Password",
"setting.password.confirm.password": "Confirm Password",
"setting.notify.template.save.succeed": "Notification template saved successfully",
"setting.notify.template.variables.tips.title": "Optional variables, COUNT: number of expiring soon",
"setting.notify.template.variables.tips.content": "Optional variables, COUNT: number of expiring soon, DOMAINS: Domain list",
"setting.notify.config.enable": "Enable",
"setting.notify.config.save.succeed": "Configuration saved successfully",
"setting.notify.config.save.failed": "Configuration save failed",
"setting.notify.config.save.failed.url.not.valid": "Invalid Url format",
"setting.ca.not.empty": "Please select a Certificate Authority",
"setting.ca.eab_kid.not.empty": "Please enter EAB_KID",
"setting.ca.eab_hmac_key.not.empty": "Please enter EAB_HMAC_KEY.",
"setting.ca.eab_kid_hmac_key.not.empty": "Please enter EAB_KID and EAB_HMAC_KEY",
"deploy.progress.check": "Check",
"deploy.progress.apply": "Apply",
"deploy.progress.deploy": "Deploy",
"access.management": "Authorization Management",
"access.add": "Add Authorization",
"access.edit": "Edit Authorization",
"access.all": "All Authorizations",
"access.list": "Authorization List",
"access.type": "Provider",
"access.type.not.empty": "Please select a provider",
"access.not.empty": "Please select a cloud provider",
"access.empty": "Please add authorization to start deploying certificate.",
"access.group.management": "Authorization Group Management",
"access.group.add": "Add Authorization Group",
"access.group.not.empty": "Please select a group",
"access.group.name": "Group Name",
"access.group.name.not.empty": "Please enter group name",
"access.group.delete": "Delete Group",
"access.group.delete.confirm": "Are you sure you want to delete the deployment authorization group?",
"access.group.domain.empty": "Please add a domain to start deploying the certificate.",
"access.group.empty": "No deployment authorization configuration yet, please add after starting use.",
"access.group.total": "Totally {{total}} deployment authorization configuration",
"access.form.name.not.empty": "Please enter authorization name",
"access.form.config.field": "Configuration Type",
"access.form.access.key.id": "AccessKeyId",
"access.form.access.key.id.not.empty": "Please enter AccessKeyId",
"access.form.access.key.secret": "AccessKeySecret",
"access.form.access.key.secret.not.empty": "Please enter AccessKeySecret",
"access.form.cloud.dns.api.token": "CLOUD_DNS_API_TOKEN",
"access.form.cloud.dns.api.token.not.empty": "Please enter CLOUD_DNS_API_TOKEN",
"access.form.go.daddy.api.key": "GO_DADDY_API_KEY",
"access.form.go.daddy.api.key.not.empty": "Please enter GO_DADDY_API_KEY",
"access.form.go.daddy.api.secret": "GO_DADDY_API_SECRET",
"access.form.go.daddy.api.secret.not.empty": "Please enter GO_DADDY_API_SECRET",
"access.form.namesilo.api.key": "NAMESILO_API_KEY",
"access.form.namesilo.api.key.not.empty": "Please enter NAMESILO_API_KEY",
"access.form.secret.id": "SecretId",
"access.form.secret.id.not.empty": "Please enter SecretId",
"access.form.secret.key": "SecretKey",
"access.form.secret.key.not.empty": "Please enter SecretKey",
"access.form.access.key": "AccessKey",
"access.form.access.key.not.empty": "Please enter AccessKey",
"access.form.webhook.url": "Webhook URL",
"access.form.webhook.url.not.empty": "Please enter Webhook URL",
"access.form.ssh.group.label": "Authorization Configuration Group (used to deploy a single domain certificate to multiple SSH hosts)",
"access.form.ssh.host": "Server Host",
"access.form.ssh.host.not.empty": "Please enter Host",
"access.form.ssh.port": "SSH Port",
"access.form.ssh.port.not.empty": "Please enter Port",
"access.form.ssh.key": "Key (Log in using certificate)",
"access.form.ssh.key.not.empty": "Please enter Key",
"access.form.ssh.key.file.not.empty": "Please select file",
"access.form.ssh.cert.path": "Certificate Upload Path",
"access.form.ssh.cert.path.not.empty": "Please enter certificate upload path",
"access.form.ssh.key.path": "Private Key Upload Path",
"access.form.ssh.key.path.not.empty": "Please enter private key upload path",
"access.form.ssh.command": "Command",
"access.form.ssh.command.not.empty": "Please enter command",
"access.form.ding.access.token.placeholder": "Signature for signed addition"
}

View File

@@ -0,0 +1,17 @@
import { Resource } from 'i18next'
import zh from './zh.json'
import en from './en.json'
const resources: Resource = {
zh: {
name: '简体中文',
translation: zh
},
en: {
name: 'English',
translation: en
}
}
export default resources;

210
ui/src/i18n/locales/zh.json Normal file
View File

@@ -0,0 +1,210 @@
{
"ca": "证书颁发机构",
"username": "用户名",
"username.not.empty": "请输入用户名",
"password": "密码",
"password.not.empty": "请输入密码",
"email": "邮箱",
"logout": "退出登录",
"setting": "设置",
"account": "账户",
"template": "模版",
"save": "保存",
"no.data": "暂无数据",
"status": "状态",
"operation": "操作",
"enable": "启用",
"disable": "禁用",
"deploy": "部署",
"download": "下载",
"delete": "删除",
"cancel": "取消",
"confirm": "确认",
"edit": "编辑",
"succeed": "成功",
"add": "新增",
"document": "文档",
"variables": "变量",
"dns": "域名服务器",
"name": "名称",
"create.time": "创建时间",
"update.time": "更新时间",
"created.in": "创建于",
"updated.in": "更新于",
"basic.setting": "基础设置",
"advanced.setting": "高级设置",
"operation.succeed": "操作成功",
"save.succeed": "保存成功",
"save.failed": "保存失败",
"update.succeed": "修改成功",
"update.failed": "修改失败",
"delete.failed": "删除失败",
"ding.talk": "钉钉",
"telegram": "Telegram",
"webhook": "Webhook",
"local": "本地部署",
"tencent": "腾讯云",
"tencent.cdn": "腾讯云-CDN",
"aliyun": "阿里云",
"aliyun.cdn": "阿里云-CDN",
"aliyun.oss": "阿里云-OSS",
"aliyun.dcdn": "阿里云-DCDN",
"qiniu": "七牛云",
"qiniu.cdn": "七牛云-CDN",
"cloudflare": "Cloudflare",
"namesilo": "Namesilo",
"go.daddy": "GoDaddy",
"ssh": "SSH 部署",
"zod.rule.string.max": "请输入不超过 {{max}} 个字符",
"zod.rule.url": "请输入有效的 url 地址",
"zod.rule.ssh.host": "请输入正确的域名或IP",
"login.submit": "登录",
"login.username.no.empty.message": "请输入正确的邮箱地址",
"login.password.length.message": "密码至少10个字符",
"menu.auth.management": "授权管理",
"theme.light": "浅色",
"theme.dark": "暗黑",
"theme.system": "系统",
"dashboard": "控制面板",
"dashboard.all": "所有",
"dashboard.near.expired": "即将过期",
"dashboard.enabled": "启用中",
"dashboard.not.enabled": "未启用",
"dashboard.unit": "个",
"deployment.log.name": "部署历史",
"deployment.log.empty": "你暂未创建任何部署,请先添加域名进行部署吧!",
"deployment.log.status": "状态",
"deployment.log.stage": "阶段",
"deployment.log.last.execution.time": "最近执行时间",
"deployment.log.detail.button.text": "日志",
"deployment.log.detail": "部署详情",
"pagination.next": "下一页",
"pagination.prev": "上一页",
"domain": "域名",
"domain.add": "新增域名",
"domain.delete": "删除域名",
"domain.not.empty.verify.message": "请输入域名",
"domain.management.name": "域名列表",
"domain.management.start.deploy.succeed.tips": "已发起部署,请稍后查看部署日志。",
"domain.management.execution.failed": "执行失败",
"domain.management.execution.failed.tips": "执行失败,请在 <1>部署历史</1> 查看详情。",
"domain.management.empty": "请添加域名开始部署证书吧。",
"domain.management.expiry.date": "有效期限",
"domain.management.expiry.date1": "有效期 {{date}} 天",
"domain.management.expiry.date2": "{{date}} 到期",
"domain.management.last.execution.time": "最近执行时间",
"domain.management.last.execution.status": "最近执行状态",
"domain.management.last.execution.stage": "最近执行阶段",
"domain.management.enable": "是否启用",
"domain.management.start.deploying": "立即部署",
"domain.management.forced.deployment": "强行部署",
"domain.management.delete.confirm": "确定要删除域名吗?",
"domain.management.edit.title": "编辑域名",
"domain.management.edit.dns.access.label": "DNS 服务商授权配置",
"domain.management.edit.dns.access.not.empty.message": "请选择DNS服务商授权配置",
"domain.management.edit.access.label": "服务商授权配置",
"domain.management.edit.access.not.empty.message": "请选择授权配置",
"domain.management.edit.target.type": "部署服务类型",
"domain.management.edit.target.type.not.empty.message": "请选择部署服务类型",
"domain.management.edit.succeed.tips": "域名编辑成功",
"domain.management.edit.target.access": "部署服务商授权配置",
"domain.management.edit.target.access.content.label": "服务商授权配置",
"domain.management.edit.target.access.not.empty.message": "请选择授权配置",
"domain.management.edit.target.access.verify.msg": "部署授权和部署授权组至少选一个",
"domain.management.edit.group.label": "部署配置组(用于将一个域名证书部署到多个 ssh 主机)",
"domain.management.edit.group.not.empty.message": "请选择分组",
"domain.management.edit.email.not.empty.message": "请选择邮箱",
"domain.management.edit.email.description": "(申请证书需要提供邮箱)",
"domain.management.edit.variables.placeholder": "可在SSH部署中使用,形如:\nkey=val;\nkey2=val2;",
"domain.management.edit.dns.placeholder": "自定义域名服务器,多个用分号隔开,如:\n8.8.8.8;\n8.8.4.4;",
"domain.management.add.succeed.tips": "域名添加成功",
"email.add": "添加邮箱",
"email.list": "邮箱列表",
"email.valid.message": "请输入正确的邮箱地址",
"email.already.exist": "邮箱已存在",
"email.not.empty.message": "请输入邮箱",
"setting.notify.menu": "消息推送",
"setting.submit": "确认修改",
"setting.account.email.valid.message": "请输入正确的邮箱地址",
"setting.account.email.placeholder": "请输入邮箱",
"setting.account.email.change.succeed": "修改账户邮箱成功",
"setting.account.email.change.failed": "修改账户邮箱失败",
"setting.account.log.back.in": "请重新登录",
"setting.password.length.message": "密码至少10个字符",
"setting.password.not.match": "两次密码不一致",
"setting.password.change.succeed": "修改密码成功",
"setting.password.change.failed": "修改密码失败",
"setting.password.current.password": "当前密码",
"setting.password.new.password": "新密码",
"setting.password.confirm.password": "确认密码",
"setting.notify.template.save.succeed": "通知模板保存成功",
"setting.notify.template.variables.tips.title": "可选的变量, COUNT:即将过期张数",
"setting.notify.template.variables.tips.content": "可选的变量, COUNT:即将过期张数DOMAINS:域名列表",
"setting.notify.config.enable": "是否启用",
"setting.notify.config.save.succeed": "配置保存成功",
"setting.notify.config.save.failed": "配置保存失败",
"setting.notify.config.save.failed.url.not.valid": "Url格式不正确",
"setting.ca.not.empty": "请选择证书分发机构",
"setting.ca.eab_kid.not.empty": "请输入EAB_KID",
"setting.ca.eab_hmac_key.not.empty": "请输入EAB_HMAC_KEY",
"setting.ca.eab_kid_hmac_key.not.empty": "请输入EAB_KID和EAB_HMAC_KEY",
"deploy.progress.check": "检查",
"deploy.progress.apply": "获取",
"deploy.progress.deploy": "部署",
"access.management": "授权管理",
"access.add": "添加授权",
"access.edit": "编辑授权",
"access.all": "所有授权",
"access.list": "授权列表",
"access.type": "服务商",
"access.type.not.empty": "请选择服务商",
"access.not.empty": "请选择云服务商",
"access.empty": "请添加授权开始部署证书吧。",
"access.group.management": "授权组管理",
"access.group.add": "添加授权组",
"access.group.not.empty": "请选择分组",
"access.group.name": "组名",
"access.group.name.not.empty": "请输入组名",
"access.group.delete": "删除组",
"access.group.delete.confirm": "确定要删除部署授权组吗?",
"access.group.domain.empty": "请添加域名开始部署证书吧。",
"access.group.empty": "暂无部署授权配置,请添加后开始使用吧",
"access.group.total": "共有 {{total}} 个部署授权配置",
"access.form.name.not.empty": "请输入授权名称",
"access.form.config.field": "配置类型",
"access.form.access.key.id": "AccessKeyId",
"access.form.access.key.id.not.empty": "请输入 AccessKeyId",
"access.form.access.key.secret": "AccessKeySecret",
"access.form.access.key.secret.not.empty": "请输入 AccessKeySecret",
"access.form.cloud.dns.api.token": "CLOUD_DNS_API_TOKEN",
"access.form.cloud.dns.api.token.not.empty": "请输入 CLOUD_DNS_API_TOKEN",
"access.form.go.daddy.api.key": "GO_DADDY_API_KEY",
"access.form.go.daddy.api.key.not.empty": "请输入 GO_DADDY_API_KEY",
"access.form.go.daddy.api.secret": "GO_DADDY_API_SECRET",
"access.form.go.daddy.api.secret.not.empty": "请输入 GO_DADDY_API_SECRET",
"access.form.namesilo.api.key": "NAMESILO_API_KEY",
"access.form.namesilo.api.key.not.empty": "请输入 NAMESILO_API_KEY",
"access.form.secret.id": "SecretId",
"access.form.secret.id.not.empty": "请输入 SecretId",
"access.form.secret.key": "SecretKey",
"access.form.secret.key.not.empty": "请输入 SecretKey",
"access.form.access.key": "AccessKey",
"access.form.access.key.not.empty": "请输入 AccessKey",
"access.form.webhook.url": "Webhook URL",
"access.form.webhook.url.not.empty": "请输入 Webhook URL",
"access.form.ssh.group.label": "授权配置组(用于将一个域名证书部署到多个 ssh 主机)",
"access.form.ssh.host": "服务器 Host",
"access.form.ssh.host.not.empty": "请输入 Host",
"access.form.ssh.port": "SSH 端口",
"access.form.ssh.port.not.empty": "请输入 Port",
"access.form.ssh.key": "Key使用证书登录",
"access.form.ssh.key.not.empty": "请输入 Key",
"access.form.ssh.key.file.not.empty": "请选择文件",
"access.form.ssh.cert.path": "证书上传路径",
"access.form.ssh.cert.path.not.empty": "请输入证书上传路径",
"access.form.ssh.key.path": "私钥上传路径",
"access.form.ssh.key.path.not.empty": "请输入私钥上传路径",
"access.form.ssh.command": "Command",
"access.form.ssh.command.not.empty": "请输入要执行的命令",
"access.form.ding.access.token.placeholder": "加签的签名"
}

View File

@@ -4,6 +4,7 @@ import "./global.css";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { router } from "./router.tsx"; import { router } from "./router.tsx";
import { ThemeProvider } from "./components/ThemeProvider.tsx"; import { ThemeProvider } from "./components/ThemeProvider.tsx";
import "@/i18n";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -6,6 +6,7 @@ import {
useNavigate, useNavigate,
} from "react-router-dom"; } from "react-router-dom";
import { CircleUser, Earth, History, Home, Menu, Server } from "lucide-react"; import { CircleUser, Earth, History, Home, Menu, Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -22,12 +23,14 @@ import { cn } from "@/lib/utils";
import { ConfigProvider } from "@/providers/config"; import { ConfigProvider } from "@/providers/config";
import { getPb } from "@/repository/api"; import { getPb } from "@/repository/api";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import LocaleToggle from "@/components/LocaleToggle";
import Version from "@/components/certimate/Version"; import Version from "@/components/certimate/Version";
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { t } = useTranslation()
if (!getPb().authStore.isValid || !getPb().authStore.isAdmin) { if (!getPb().authStore.isValid || !getPb().authStore.isAdmin) {
return <Navigate to="/login" />; return <Navigate to="/login" />;
@@ -70,7 +73,7 @@ export default function Dashboard() {
)} )}
> >
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
{t('dashboard')}
</Link> </Link>
<Link <Link
to="/domains" to="/domains"
@@ -80,7 +83,7 @@ export default function Dashboard() {
)} )}
> >
<Earth className="h-4 w-4" /> <Earth className="h-4 w-4" />
{t('domain.management.name')}
</Link> </Link>
<Link <Link
to="/access" to="/access"
@@ -90,7 +93,7 @@ export default function Dashboard() {
)} )}
> >
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
{t('menu.auth.management')}
</Link> </Link>
<Link <Link
@@ -101,7 +104,7 @@ export default function Dashboard() {
)} )}
> >
<History className="h-4 w-4" /> <History className="h-4 w-4" />
{t('deployment.log.name')}
</Link> </Link>
</nav> </nav>
</div> </div>
@@ -138,7 +141,7 @@ export default function Dashboard() {
)} )}
> >
<Home className="h-5 w-5" /> <Home className="h-5 w-5" />
{t('dashboard')}
</Link> </Link>
<Link <Link
to="/domains" to="/domains"
@@ -148,7 +151,7 @@ export default function Dashboard() {
)} )}
> >
<Earth className="h-5 w-5" /> <Earth className="h-5 w-5" />
{t('domain.management.name')}
</Link> </Link>
<Link <Link
to="/access" to="/access"
@@ -158,7 +161,7 @@ export default function Dashboard() {
)} )}
> >
<Server className="h-5 w-5" /> <Server className="h-5 w-5" />
{t('menu.auth.management')}
</Link> </Link>
<Link <Link
@@ -169,13 +172,14 @@ export default function Dashboard() {
)} )}
> >
<History className="h-5 w-5" /> <History className="h-5 w-5" />
{t('deployment.log.name')}
</Link> </Link>
</nav> </nav>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<div className="w-full flex-1"></div> <div className="w-full flex-1"></div>
<ThemeToggle /> <ThemeToggle />
<LocaleToggle />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -188,15 +192,15 @@ export default function Dashboard() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel>{t('account')}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSettingClick}> <DropdownMenuItem onClick={handleSettingClick}>
{t('setting')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleLogoutClick}> <DropdownMenuItem onClick={handleLogoutClick}>
退 {t('logout')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -4,11 +4,13 @@ import { KeyRound, Megaphone, ShieldCheck, UserRound } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
const SettingLayout = () => { const SettingLayout = () => {
const location = useLocation(); const location = useLocation();
const [tabValue, setTabValue] = useState("account"); const [tabValue, setTabValue] = useState("account");
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const pathname = location.pathname; const pathname = location.pathname;
@@ -20,7 +22,7 @@ const SettingLayout = () => {
<div> <div>
<Toaster /> <Toaster />
<div className="text-muted-foreground border-b dark:border-stone-500 py-5"> <div className="text-muted-foreground border-b dark:border-stone-500 py-5">
{t("setting")}
</div> </div>
<div className="w-full mt-5 p-0 md:p-3 flex justify-center"> <div className="w-full mt-5 p-0 md:p-3 flex justify-center">
<Tabs defaultValue="account" className="w-full" value={tabValue}> <Tabs defaultValue="account" className="w-full" value={tabValue}>
@@ -33,7 +35,7 @@ const SettingLayout = () => {
className="px-5" className="px-5"
> >
<UserRound size={14} /> <UserRound size={14} />
<div className="ml-1"></div> <div className="ml-1">{t("account")}</div>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="password" value="password"
@@ -43,7 +45,7 @@ const SettingLayout = () => {
className="px-5" className="px-5"
> >
<KeyRound size={14} /> <KeyRound size={14} />
<div className="ml-1"></div> <div className="ml-1">{t("password")}</div>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
@@ -54,7 +56,7 @@ const SettingLayout = () => {
className="px-5" className="px-5"
> >
<Megaphone size={14} /> <Megaphone size={14} />
<div className="ml-1"></div> <div className="ml-1">{t("setting.notify.menu")}</div>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="ssl-provider" value="ssl-provider"
@@ -64,7 +66,7 @@ const SettingLayout = () => {
className="px-5" className="px-5"
> >
<ShieldCheck size={14} /> <ShieldCheck size={14} />
<div className="ml-1"></div> <div className="ml-1">{t("ca")}</div>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value={tabValue}> <TabsContent value={tabValue}>

View File

@@ -9,6 +9,7 @@ import { Access as AccessType, accessTypeMap } from "@/domain/access";
import { convertZulu2Beijing } from "@/lib/time"; import { convertZulu2Beijing } from "@/lib/time";
import { useConfig } from "@/providers/config"; import { useConfig } from "@/providers/config";
import { remove } from "@/repository/access"; import { remove } from "@/repository/access";
import { t } from "i18next";
import { Key } from "lucide-react"; import { Key } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -46,11 +47,11 @@ const Access = () => {
return ( return (
<div className=""> <div className="">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t("access.management")}</div>
{tab != "access_group" ? ( {tab != "access_group" ? (
<AccessEdit trigger={<Button></Button>} op="add" /> <AccessEdit trigger={<Button>{t("access.add")}</Button>} op="add" />
) : ( ) : (
<AccessGroupEdit trigger={<Button></Button>} /> <AccessGroupEdit trigger={<Button>{t("access.group.add")}</Button>} />
)} )}
</div> </div>
@@ -66,7 +67,7 @@ const Access = () => {
handleTabItemClick("access"); handleTabItemClick("access");
}} }}
> >
{t("access.management")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="access_group" value="access_group"
@@ -74,7 +75,7 @@ const Access = () => {
handleTabItemClick("access_group"); handleTabItemClick("access_group");
}} }}
> >
{t("access.group.management")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="access"> <TabsContent value="access">
@@ -85,10 +86,10 @@ const Access = () => {
</span> </span>
<div className="text-center text-sm text-muted-foreground mt-3"> <div className="text-center text-sm text-muted-foreground mt-3">
{t("access.empty")}
</div> </div>
<AccessEdit <AccessEdit
trigger={<Button></Button>} trigger={<Button>{t("access.add")}</Button>}
op="add" op="add"
className="mt-3" className="mt-3"
/> />
@@ -96,15 +97,15 @@ const Access = () => {
) : ( ) : (
<> <>
<div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5"> <div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5">
<div className="w-48"></div> <div className="w-48">{t("name")}</div>
<div className="w-48"></div> <div className="w-48">{t("access.type")}</div>
<div className="w-52"></div> <div className="w-60">{t("create.time")}</div>
<div className="w-52"></div> <div className="w-60">{t("update.time")}</div>
<div className="grow"></div> <div className="grow">{t("operation")}</div>
</div> </div>
<div className="sm:hidden flex text-sm text-muted-foreground"> <div className="sm:hidden flex text-sm text-muted-foreground">
{t("access.list")}
</div> </div>
{accesses {accesses
.filter((item) => { .filter((item) => {
@@ -124,22 +125,24 @@ const Access = () => {
src={accessTypeMap.get(access.configType)?.[1]} src={accessTypeMap.get(access.configType)?.[1]}
className="w-6" className="w-6"
/> />
<div>{accessTypeMap.get(access.configType)?.[0]}</div> <div>
{t(accessTypeMap.get(access.configType)?.[0] || "")}
</div>
</div> </div>
<div className="sm:w-52 w-full pt-1 sm:pt-0 flex items-center"> <div className="sm:w-60 w-full pt-1 sm:pt-0 flex items-center">
{" "} {t("created.in")}{" "}
{access.created && convertZulu2Beijing(access.created)} {access.created && convertZulu2Beijing(access.created)}
</div> </div>
<div className="sm:w-52 w-full pt-1 sm:pt-0 flex items-center"> <div className="sm:w-60 w-full pt-1 sm:pt-0 flex items-center">
{" "} {t("updated.in")}{" "}
{access.updated && convertZulu2Beijing(access.updated)} {access.updated && convertZulu2Beijing(access.updated)}
</div> </div>
<div className="flex items-center grow justify-start pt-1 sm:pt-0"> <div className="flex items-center grow justify-start pt-1 sm:pt-0">
<AccessEdit <AccessEdit
trigger={ trigger={
<Button variant={"link"} className="p-0"> <Button variant={"link"} className="p-0">
{t("edit")}
</Button> </Button>
} }
op="edit" op="edit"
@@ -153,7 +156,7 @@ const Access = () => {
handleDelete(access); handleDelete(access);
}} }}
> >
{t("delete")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -24,12 +24,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
const Dashboard = () => { const Dashboard = () => {
const [statistic, setStatistic] = useState<Statistic>(); const [statistic, setStatistic] = useState<Statistic>();
const [deployments, setDeployments] = useState<Deployment[]>(); const [deployments, setDeployments] = useState<Deployment[]>();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const fetchStatistic = async () => { const fetchStatistic = async () => {
@@ -55,7 +57,7 @@ const Dashboard = () => {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t('dashboard')}</div>
</div> </div>
<div className="flex mt-10 gap-5 flex-col flex-wrap md:flex-row"> <div className="flex mt-10 gap-5 flex-col flex-wrap md:flex-row">
<div className="w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg border"> <div className="w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
@@ -63,7 +65,9 @@ const Dashboard = () => {
<SquareSigma size={48} strokeWidth={1} className="text-blue-400" /> <SquareSigma size={48} strokeWidth={1} className="text-blue-400" />
</div> </div>
<div> <div>
<div className="text-muted-foreground font-semibold"></div> <div className="text-muted-foreground font-semibold">
{t('dashboard.all')}
</div>
<div className="flex items-baseline"> <div className="flex items-baseline">
<div className="text-3xl text-stone-700 dark:text-stone-200"> <div className="text-3xl text-stone-700 dark:text-stone-200">
{statistic?.total ? ( {statistic?.total ? (
@@ -74,7 +78,9 @@ const Dashboard = () => {
0 0
)} )}
</div> </div>
<div className="ml-1 text-stone-700 dark:text-stone-200"></div> <div className="ml-1 text-stone-700 dark:text-stone-200">
{t("dashboard.unit")}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -84,7 +90,9 @@ const Dashboard = () => {
<CalendarX2 size={48} strokeWidth={1} className="text-red-400" /> <CalendarX2 size={48} strokeWidth={1} className="text-red-400" />
</div> </div>
<div> <div>
<div className="text-muted-foreground font-semibold"></div> <div className="text-muted-foreground font-semibold">
{t('dashboard.near.expired')}
</div>
<div className="flex items-baseline"> <div className="flex items-baseline">
<div className="text-3xl text-stone-700 dark:text-stone-200"> <div className="text-3xl text-stone-700 dark:text-stone-200">
{statistic?.expired ? ( {statistic?.expired ? (
@@ -95,7 +103,9 @@ const Dashboard = () => {
0 0
)} )}
</div> </div>
<div className="ml-1 text-stone-700 dark:text-stone-200"></div> <div className="ml-1 text-stone-700 dark:text-stone-200">
{t("dashboard.unit")}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -109,7 +119,9 @@ const Dashboard = () => {
/> />
</div> </div>
<div> <div>
<div className="text-muted-foreground font-semibold"></div> <div className="text-muted-foreground font-semibold">
{t('dashboard.enabled')}
</div>
<div className="flex items-baseline"> <div className="flex items-baseline">
<div className="text-3xl text-stone-700 dark:text-stone-200"> <div className="text-3xl text-stone-700 dark:text-stone-200">
{statistic?.enabled ? ( {statistic?.enabled ? (
@@ -120,7 +132,9 @@ const Dashboard = () => {
0 0
)} )}
</div> </div>
<div className="ml-1 text-stone-700 dark:text-stone-200"></div> <div className="ml-1 text-stone-700 dark:text-stone-200">
{t("dashboard.unit")}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -130,7 +144,7 @@ const Dashboard = () => {
<Ban size={48} strokeWidth={1} className="text-gray-400" /> <Ban size={48} strokeWidth={1} className="text-gray-400" />
</div> </div>
<div> <div>
<div className="text-muted-foreground font-semibold"></div> <div className="text-muted-foreground font-semibold">{t('dashboard.not.enabled')}</div>
<div className="flex items-baseline"> <div className="flex items-baseline">
<div className="text-3xl text-stone-700 dark:text-stone-200"> <div className="text-3xl text-stone-700 dark:text-stone-200">
{statistic?.disabled ? ( {statistic?.disabled ? (
@@ -144,19 +158,23 @@ const Dashboard = () => {
0 0
)} )}
</div> </div>
<div className="ml-1 text-stone-700 dark:text-stone-200"></div> <div className="ml-1 text-stone-700 dark:text-stone-200">
{t("dashboard.unit")}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<div className="text-muted-foreground mt-5 text-sm"></div> <div className="text-muted-foreground mt-5 text-sm">
{t('deployment.log.name')}
</div>
{deployments?.length == 0 ? ( {deployments?.length == 0 ? (
<> <>
<Alert className="max-w-[40em] mt-10"> <Alert className="max-w-[40em] mt-10">
<AlertTitle></AlertTitle> <AlertTitle>{t('no.data')}</AlertTitle>
<AlertDescription> <AlertDescription>
<div className="flex items-center mt-5"> <div className="flex items-center mt-5">
<div> <div>
@@ -164,7 +182,7 @@ const Dashboard = () => {
</div> </div>
<div className="ml-2"> <div className="ml-2">
{" "} {" "}
{t('deployment.log.empty')}
</div> </div>
</div> </div>
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
@@ -173,7 +191,7 @@ const Dashboard = () => {
navigate("/edit"); navigate("/edit");
}} }}
> >
{t('domain.add')}
</Button> </Button>
</div> </div>
</AlertDescription> </AlertDescription>
@@ -182,16 +200,16 @@ const Dashboard = () => {
) : ( ) : (
<> <>
<div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5"> <div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5">
<div className="w-48"></div> <div className="w-48">{t('domain')}</div>
<div className="w-24"></div> <div className="w-24">{t('deployment.log.status')}</div>
<div className="w-56"></div> <div className="w-56">{t('deployment.log.stage')}</div>
<div className="w-56 sm:ml-2 text-center"></div> <div className="w-56 sm:ml-2 text-center">{t('deployment.log.last.execution.time')}</div>
<div className="grow"></div> <div className="grow">{t('operation')}</div>
</div> </div>
<div className="sm:hidden flex text-sm text-muted-foreground"> <div className="sm:hidden flex text-sm text-muted-foreground">
{t('deployment.log.name')}
</div> </div>
{deployments?.map((deployment) => ( {deployments?.map((deployment) => (
@@ -218,14 +236,14 @@ const Dashboard = () => {
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant={"link"} className="p-0"> <Button variant={"link"} className="p-0">
{t('deployment.log.detail.button.text')}
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="sm:max-w-5xl"> <SheetContent className="sm:max-w-5xl">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>
{deployment.expand.domain?.domain}-{deployment.id} {deployment.expand.domain?.domain}-{deployment.id}
{t('deployment.log.detail')}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]"> <div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]">

View File

@@ -38,6 +38,7 @@ import EmailsEdit from "@/components/certimate/EmailsEdit";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EmailsSetting } from "@/domain/settings"; import { EmailsSetting } from "@/domain/settings";
import { useTranslation } from "react-i18next";
const Edit = () => { const Edit = () => {
const { const {
@@ -47,6 +48,7 @@ const Edit = () => {
const [domain, setDomain] = useState<Domain>(); const [domain, setDomain] = useState<Domain>();
const location = useLocation(); const location = useLocation();
const { t } = useTranslation();
const [tab, setTab] = useState<"base" | "advance">("base"); const [tab, setTab] = useState<"base" | "advance">("base");
@@ -69,15 +71,15 @@ const Edit = () => {
const formSchema = z.object({ const formSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, { domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: "请输入正确的域名", message: 'domain.not.empty.verify.message',
}), }),
email: z.string().email().optional(), email: z.string().email('email.valid.message').optional(),
access: z.string().regex(/^[a-zA-Z0-9]+$/, { access: z.string().regex(/^[a-zA-Z0-9]+$/, {
message: "请选择DNS服务商授权配置", message: 'domain.management.edit.dns.access.not.empty.message',
}), }),
targetAccess: z.string().optional(), targetAccess: z.string().optional(),
targetType: z.string().regex(/^[a-zA-Z0-9-]+$/, { targetType: z.string().regex(/^[a-zA-Z0-9-]+$/, {
message: "请选择部署服务类型", message: 'domain.management.edit.target.type.not.empty.message',
}), }),
variables: z.string().optional(), variables: z.string().optional(),
group: z.string().optional(), group: z.string().optional(),
@@ -138,11 +140,11 @@ const Edit = () => {
if (group == "" && targetAccess == "") { if (group == "" && targetAccess == "") {
form.setError("group", { form.setError("group", {
type: "manual", type: "manual",
message: "部署授权和部署授权组至少选一个", message: 'domain.management.edit.target.access.verify.msg',
}); });
form.setError("targetAccess", { form.setError("targetAccess", {
type: "manual", type: "manual",
message: "部署授权和部署授权组至少选一个", message: 'domain.management.edit.target.access.verify.msg',
}); });
return; return;
} }
@@ -162,13 +164,13 @@ const Edit = () => {
try { try {
await save(req); await save(req);
let description = "域名编辑成功"; let description = t('domain.management.edit.succeed.tips');
if (req.id == "") { if (req.id == "") {
description = "域名添加成功"; description = t('domain.management.add.succeed.tips');
} }
toast({ toast({
title: "成功", title: t('succeed'),
description, description,
}); });
navigate("/domains"); navigate("/domains");
@@ -193,7 +195,7 @@ const Edit = () => {
<div className=""> <div className="">
<Toaster /> <Toaster />
<div className=" h-5 text-muted-foreground"> <div className=" h-5 text-muted-foreground">
{domain?.id ? "编辑" : "新增"} {domain?.id ? t('domain.edit') : t('domain.add')}
</div> </div>
<div className="mt-5 flex w-full justify-center md:space-x-10 flex-col md:flex-row"> <div className="mt-5 flex w-full justify-center md:space-x-10 flex-col md:flex-row">
<div className="w-full md:w-[200px] text-muted-foreground space-x-3 md:space-y-3 flex-row md:flex-col flex"> <div className="w-full md:w-[200px] text-muted-foreground space-x-3 md:space-y-3 flex-row md:flex-col flex">
@@ -206,7 +208,7 @@ const Edit = () => {
setTab("base"); setTab("base");
}} }}
> >
{t('basic.setting')}
</div> </div>
<div <div
className={cn( className={cn(
@@ -217,7 +219,7 @@ const Edit = () => {
setTab("advance"); setTab("advance");
}} }}
> >
{t('advanced.setting')}
</div> </div>
</div> </div>
@@ -232,9 +234,9 @@ const Edit = () => {
name="domain" name="domain"
render={({ field }) => ( render={({ field }) => (
<FormItem hidden={tab != "base"}> <FormItem hidden={tab != "base"}>
<FormLabel></FormLabel> <FormLabel>{t('domain')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入域名" {...field} /> <Input placeholder={t('domain.not.empty.verify.message')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -247,12 +249,12 @@ const Edit = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem hidden={tab != "base"}> <FormItem hidden={tab != "base"}>
<FormLabel className="flex w-full justify-between"> <FormLabel className="flex w-full justify-between">
<div>Email</div> <div>{t('email') + t('domain.management.edit.email.description')}</div>
<EmailsEdit <EmailsEdit
trigger={ trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center"> <div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} /> <Plus size={14} />
{t('add')}
</div> </div>
} }
/> />
@@ -266,11 +268,11 @@ const Edit = () => {
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择邮箱" /> <SelectValue placeholder={t('domain.management.edit.email.not.empty.message')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel></SelectLabel> <SelectLabel>{t('email.list')}</SelectLabel>
{(emails.content as EmailsSetting).emails.map( {(emails.content as EmailsSetting).emails.map(
(item) => ( (item) => (
<SelectItem key={item} value={item}> <SelectItem key={item} value={item}>
@@ -293,12 +295,12 @@ const Edit = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem hidden={tab != "base"}> <FormItem hidden={tab != "base"}>
<FormLabel className="flex w-full justify-between"> <FormLabel className="flex w-full justify-between">
<div>DNS </div> <div>{t('domain.management.edit.dns.access.label')}</div>
<AccessEdit <AccessEdit
trigger={ trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center"> <div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} /> <Plus size={14} />
{t('add')}
</div> </div>
} }
op="add" op="add"
@@ -313,11 +315,11 @@ const Edit = () => {
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择授权配置" /> <SelectValue placeholder={t('domain.management.edit.access.not.empty.message')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel></SelectLabel> <SelectLabel>{t('domain.management.edit.access.label')}</SelectLabel>
{accesses {accesses
.filter((item) => item.usage != "deploy") .filter((item) => item.usage != "deploy")
.map((item) => ( .map((item) => (
@@ -349,7 +351,7 @@ const Edit = () => {
name="targetType" name="targetType"
render={({ field }) => ( render={({ field }) => (
<FormItem hidden={tab != "base"}> <FormItem hidden={tab != "base"}>
<FormLabel></FormLabel> <FormLabel>{t('domain.management.edit.target.type')}</FormLabel>
<FormControl> <FormControl>
<Select <Select
{...field} {...field}
@@ -359,11 +361,11 @@ const Edit = () => {
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择部署服务类型" /> <SelectValue placeholder={t('domain.management.edit.target.type.not.empty.message')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel></SelectLabel> <SelectLabel>{t('domain.management.edit.target.type')}</SelectLabel>
{targetTypeKeys.map((key) => ( {targetTypeKeys.map((key) => (
<SelectItem key={key} value={key}> <SelectItem key={key} value={key}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -371,7 +373,7 @@ const Edit = () => {
className="w-6" className="w-6"
src={targetTypeMap.get(key)?.[1]} src={targetTypeMap.get(key)?.[1]}
/> />
<div>{targetTypeMap.get(key)?.[0]}</div> <div>{t(targetTypeMap.get(key)?.[0] || '')}</div>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -390,12 +392,12 @@ const Edit = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem hidden={tab != "base"}> <FormItem hidden={tab != "base"}>
<FormLabel className="w-full flex justify-between"> <FormLabel className="w-full flex justify-between">
<div></div> <div>{t('domain.management.edit.target.access')}</div>
<AccessEdit <AccessEdit
trigger={ trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center"> <div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} /> <Plus size={14} />
{t('add')}
</div> </div>
} }
op="add" op="add"
@@ -409,12 +411,12 @@ const Edit = () => {
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择授权配置" /> <SelectValue placeholder={t('domain.management.edit.target.access.not.empty.message')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel> <SelectLabel>
{form.getValues().targetAccess} {t('domain.management.edit.target.access.content.label')} {form.getValues().targetAccess}
</SelectLabel> </SelectLabel>
<SelectItem value="emptyId"> <SelectItem value="emptyId">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -451,7 +453,7 @@ const Edit = () => {
<FormItem hidden={tab != "advance" || targetType != "ssh"}> <FormItem hidden={tab != "advance" || targetType != "ssh"}>
<FormLabel className="w-full flex justify-between"> <FormLabel className="w-full flex justify-between">
<div> <div>
( ssh ) {t('domain.management.edit.group.label')}
</div> </div>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
@@ -464,7 +466,7 @@ const Edit = () => {
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择分组" /> <SelectValue placeholder={t('domain.management.edit.group.not.empty.message')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="emptyId"> <SelectItem value="emptyId">
@@ -509,10 +511,10 @@ const Edit = () => {
name="variables" name="variables"
render={({ field }) => ( render={({ field }) => (
<FormItem hidden={tab != "advance"}> <FormItem hidden={tab != "advance"}>
<FormLabel></FormLabel> <FormLabel>{t('variables')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder={`可在SSH部署中使用,形如:\nkey=val;\nkey2=val2;`} placeholder={t('domain.management.edit.variables.placeholder')}
{...field} {...field}
className="placeholder:whitespace-pre-wrap" className="placeholder:whitespace-pre-wrap"
/> />
@@ -528,10 +530,10 @@ const Edit = () => {
name="nameservers" name="nameservers"
render={({ field }) => ( render={({ field }) => (
<FormItem hidden={tab != "advance"}> <FormItem hidden={tab != "advance"}>
<FormLabel></FormLabel> <FormLabel>{t('dns')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder={`自定义域名服务器,多个用分号隔开,如:\n8.8.8.8;\n8.8.4.4;`} placeholder={t('domain.management.edit.dns.placeholder')}
{...field} {...field}
className="placeholder:whitespace-pre-wrap" className="placeholder:whitespace-pre-wrap"
/> />
@@ -543,7 +545,7 @@ const Edit = () => {
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('save')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -35,11 +35,13 @@ import { TooltipContent, TooltipProvider } from "@radix-ui/react-tooltip";
import { Earth } from "lucide-react"; import { Earth } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useTranslation, Trans } from "react-i18next";
const Home = () => { const Home = () => {
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation()
const location = useLocation(); const location = useLocation();
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
@@ -127,23 +129,22 @@ const Home = () => {
await save(domain); await save(domain);
toast.toast({ toast.toast({
title: "操作成功", title: t('operation.succeed'),
description: "已发起部署,请稍后查看部署日志。", description: t('domain.management.start.deploy.succeed.tips'),
}); });
} catch (e) { } catch (e) {
toast.toast({ toast.toast({
title: "执行失败", title: t('domain.management.execution.failed'),
description: ( description: (
<> // 这里的 text 只是占位作用,实际文案在 src/i18n/locales/[lang].json
<Trans i18nKey="domain.management.execution.failed.tips">
text1
<Link <Link
to={`/history?domain=${domain.id}`} to={`/history?domain=${domain.id}`}
className="underline text-blue-500" className="underline text-blue-500"
> >text2</Link>
text3
</Link> </Trans>
</>
), ),
variant: "destructive", variant: "destructive",
}); });
@@ -175,8 +176,10 @@ const Home = () => {
<div className=""> <div className="">
<Toaster /> <Toaster />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t('domain.management.name')}</div>
<Button onClick={handleCreateClick}></Button> <Button onClick={handleCreateClick}>
{t('domain.add')}
</Button>
</div> </div>
{!domains.length ? ( {!domains.length ? (
@@ -187,26 +190,26 @@ const Home = () => {
</span> </span>
<div className="text-center text-sm text-muted-foreground mt-3"> <div className="text-center text-sm text-muted-foreground mt-3">
{t('domain.management.empty')}
</div> </div>
<Button onClick={handleCreateClick} className="mt-3"> <Button onClick={handleCreateClick} className="mt-3">
{t('domain.add')}
</Button> </Button>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5"> <div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5">
<div className="w-36"></div> <div className="w-36">{t('domain')}</div>
<div className="w-40"></div> <div className="w-40">{t('domain.management.expiry.date')}</div>
<div className="w-32"></div> <div className="w-32">{t('domain.management.last.execution.status')}</div>
<div className="w-64"></div> <div className="w-64">{t('domain.management.last.execution.stage')}</div>
<div className="w-40 sm:ml-2"></div> <div className="w-40 sm:ml-2">{t('domain.management.last.execution.time')}</div>
<div className="w-24"></div> <div className="w-24">{t('domain.management.enable')}</div>
<div className="grow"></div> <div className="grow">{t('operation')}</div>
</div> </div>
<div className="sm:hidden flex text-sm text-muted-foreground"> <div className="sm:hidden flex text-sm text-muted-foreground">
{t('domain')}
</div> </div>
{domains.map((domain) => ( {domains.map((domain) => (
@@ -221,8 +224,8 @@ const Home = () => {
<div> <div>
{domain.expiredAt ? ( {domain.expiredAt ? (
<> <>
<div>90</div> <div>{t('domain.management.expiry.date1', { date: 90 })}</div>
<div>{getDate(domain.expiredAt)}</div> <div>{t('domain.management.expiry.date2', { date: getDate(domain.expiredAt) })}</div>
</> </>
) : ( ) : (
"---" "---"
@@ -266,7 +269,7 @@ const Home = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="border rounded-sm px-3 bg-background text-muted-foreground text-xs"> <div className="border rounded-sm px-3 bg-background text-muted-foreground text-xs">
{domain.enabled ? "禁用" : "启用"} {domain.enabled ? t('disable') : t('enable')}
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -278,7 +281,7 @@ const Home = () => {
className="p-0" className="p-0"
onClick={() => handleHistoryClick(domain.id)} onClick={() => handleHistoryClick(domain.id)}
> >
{t('deployment.log.name')}
</Button> </Button>
<Show when={domain.enabled ? true : false}> <Show when={domain.enabled ? true : false}>
<Separator orientation="vertical" className="h-4 mx-2" /> <Separator orientation="vertical" className="h-4 mx-2" />
@@ -287,7 +290,7 @@ const Home = () => {
className="p-0" className="p-0"
onClick={() => handleRightNowClick(domain)} onClick={() => handleRightNowClick(domain)}
> >
{t('domain.management.start.deploying')}
</Button> </Button>
</Show> </Show>
@@ -304,7 +307,7 @@ const Home = () => {
className="p-0" className="p-0"
onClick={() => handleForceClick(domain)} onClick={() => handleForceClick(domain)}
> >
{t('domain.management.forced.deployment')}
</Button> </Button>
</Show> </Show>
@@ -315,7 +318,7 @@ const Home = () => {
className="p-0" className="p-0"
onClick={() => handleDownloadClick(domain)} onClick={() => handleDownloadClick(domain)}
> >
{t('download')}
</Button> </Button>
</Show> </Show>
@@ -325,24 +328,24 @@ const Home = () => {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant={"link"} className="p-0"> <Button variant={"link"} className="p-0">
{t('delete')}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle>{t('domain.delete')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t('domain.management.delete.confirm')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={() => {
handleDeleteClick(domain.id); handleDeleteClick(domain.id);
}} }}
> >
{t('confirm')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -354,7 +357,7 @@ const Home = () => {
className="p-0" className="p-0"
onClick={() => handleEditClick(domain.id)} onClick={() => handleEditClick(domain.id)}
> >
{t('edit')}
</Button> </Button>
</> </>
)} )}

View File

@@ -17,11 +17,13 @@ import { list } from "@/repository/deployment";
import { Smile } from "lucide-react"; import { Smile } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
const History = () => { const History = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [deployments, setDeployments] = useState<Deployment[]>(); const [deployments, setDeployments] = useState<Deployment[]>();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { t } = useTranslation();
const domain = searchParams.get("domain"); const domain = searchParams.get("domain");
useEffect(() => { useEffect(() => {
@@ -38,11 +40,11 @@ const History = () => {
return ( return (
<ScrollArea className="h-[80vh] overflow-hidden"> <ScrollArea className="h-[80vh] overflow-hidden">
<div className="text-muted-foreground"></div> <div className="text-muted-foreground">{t('deployment.log.name')}</div>
{!deployments?.length ? ( {!deployments?.length ? (
<> <>
<Alert className="max-w-[40em] mx-auto mt-20"> <Alert className="max-w-[40em] mx-auto mt-20">
<AlertTitle></AlertTitle> <AlertTitle>{t('no.data')}</AlertTitle>
<AlertDescription> <AlertDescription>
<div className="flex items-center mt-5"> <div className="flex items-center mt-5">
<div> <div>
@@ -50,7 +52,7 @@ const History = () => {
</div> </div>
<div className="ml-2"> <div className="ml-2">
{" "} {" "}
{t('deployment.log.empty')}
</div> </div>
</div> </div>
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
@@ -59,7 +61,7 @@ const History = () => {
navigate("/"); navigate("/");
}} }}
> >
{t('domain.add')}
</Button> </Button>
</div> </div>
</AlertDescription> </AlertDescription>
@@ -68,16 +70,16 @@ const History = () => {
) : ( ) : (
<> <>
<div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5"> <div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5">
<div className="w-48"></div> <div className="w-48">{t('domain')}</div>
<div className="w-24"></div> <div className="w-24">{t('deployment.log.status')}</div>
<div className="w-56"></div> <div className="w-56">{t('deployment.log.stage')}</div>
<div className="w-56 sm:ml-2 text-center"></div> <div className="w-56 sm:ml-2 text-center">{t('deployment.log.last.execution.time')}</div>
<div className="grow"></div> <div className="grow">{t('operation')}</div>
</div> </div>
<div className="sm:hidden flex text-sm text-muted-foreground"> <div className="sm:hidden flex text-sm text-muted-foreground">
{t('deployment.log.name')}
</div> </div>
{deployments?.map((deployment) => ( {deployments?.map((deployment) => (
@@ -104,14 +106,14 @@ const History = () => {
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant={"link"} className="p-0"> <Button variant={"link"} className="p-0">
{t('deployment.log.detail.button.text')}
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="sm:max-w-5xl"> <SheetContent className="sm:max-w-5xl">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>
{deployment.expand.domain?.domain}-{deployment.id} {deployment.expand.domain?.domain}-{deployment.id}
{t('deployment.log.detail')}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]"> <div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]">

View File

@@ -1,3 +1,8 @@
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { useTranslation } from 'react-i18next'
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Form, Form,
@@ -11,20 +16,19 @@ import { Input } from "@/components/ui/input";
import { getErrMessage } from "@/lib/error"; import { getErrMessage } from "@/lib/error";
import { getPb } from "@/repository/api"; import { getPb } from "@/repository/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
const formSchema = z.object({ const formSchema = z.object({
username: z.string().email({ username: z.string().email({
message: "请输入正确的邮箱地址", message: "login.username.no.empty.message",
}), }),
password: z.string().min(10, { password: z.string().min(10, {
message: "密码至少10个字符", message: "login.password.length.message",
}), }),
}); });
const Login = () => { const Login = () => {
const { t } = useTranslation()
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -61,7 +65,7 @@ const Login = () => {
name="username" name="username"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('username')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="email" {...field} /> <Input placeholder="email" {...field} />
</FormControl> </FormControl>
@@ -76,7 +80,7 @@ const Login = () => {
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('password')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="password" {...field} type="password" /> <Input placeholder="password" {...field} type="password" />
</FormControl> </FormControl>
@@ -86,7 +90,7 @@ const Login = () => {
)} )}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('login.submit')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -15,16 +15,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email("请输入正确的邮箱"), email: z.string().email("setting.account.email.valid.message"),
}); });
const Account = () => { const Account = () => {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const [changed, setChanged] = useState(false); const [changed, setChanged] = useState(false);
@@ -43,8 +45,8 @@ const Account = () => {
getPb().authStore.clear(); getPb().authStore.clear();
toast({ toast({
title: "修改账户邮箱功", title: t("setting.account.email.change.succeed"),
description: "请重新登录", description: t("setting.account.log.back.in"),
}); });
setTimeout(() => { setTimeout(() => {
navigate("/login"); navigate("/login");
@@ -52,7 +54,7 @@ const Account = () => {
} catch (e) { } catch (e) {
const message = getErrMessage(e); const message = getErrMessage(e);
toast({ toast({
title: "修改账户邮箱失败", title: t("setting.account.email.change.failed"),
description: message, description: message,
variant: "destructive", variant: "destructive",
}); });
@@ -72,10 +74,10 @@ const Account = () => {
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('email')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="请输入邮箱" placeholder={t('setting.email.placeholder')}
{...field} {...field}
type="email" type="email"
onChange={(e) => { onChange={(e) => {
@@ -92,10 +94,10 @@ const Account = () => {
<div className="flex justify-end"> <div className="flex justify-end">
{changed ? ( {changed ? (
<Button type="submit"></Button> <Button type="submit">{t('setting.submit')}</Button>
) : ( ) : (
<Button type="submit" disabled variant={"secondary"}> <Button type="submit" disabled variant={"secondary"}>
{t('setting.submit')}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -9,15 +9,18 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { NotifyProvider } from "@/providers/notify"; import { NotifyProvider } from "@/providers/notify";
import { useTranslation } from "react-i18next";
const Notify = () => { const Notify = () => {
const { t } = useTranslation();
return ( return (
<> <>
<NotifyProvider> <NotifyProvider>
<div className="border rounded-sm p-5 shadow-lg"> <div className="border rounded-sm p-5 shadow-lg">
<Accordion type={"multiple"} className="dark:text-stone-200"> <Accordion type={"multiple"} className="dark:text-stone-200">
<AccordionItem value="item-1" className="dark:border-stone-200"> <AccordionItem value="item-1" className="dark:border-stone-200">
<AccordionTrigger></AccordionTrigger> <AccordionTrigger>{t('template')}</AccordionTrigger>
<AccordionContent> <AccordionContent>
<NotifyTemplate /> <NotifyTemplate />
</AccordionContent> </AccordionContent>
@@ -27,21 +30,21 @@ const Notify = () => {
<div className="border rounded-md p-5 mt-7 shadow-lg"> <div className="border rounded-md p-5 mt-7 shadow-lg">
<Accordion type={"single"} className="dark:text-stone-200"> <Accordion type={"single"} className="dark:text-stone-200">
<AccordionItem value="item-2" className="dark:border-stone-200"> <AccordionItem value="item-2" className="dark:border-stone-200">
<AccordionTrigger></AccordionTrigger> <AccordionTrigger>{t('ding.talk')}</AccordionTrigger>
<AccordionContent> <AccordionContent>
<DingTalk /> <DingTalk />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-4" className="dark:border-stone-200"> <AccordionItem value="item-4" className="dark:border-stone-200">
<AccordionTrigger>Telegram</AccordionTrigger> <AccordionTrigger>{t('telegram')}</AccordionTrigger>
<AccordionContent> <AccordionContent>
<Telegram /> <Telegram />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="item-5" className="dark:border-stone-200"> <AccordionItem value="item-5" className="dark:border-stone-200">
<AccordionTrigger>Webhook</AccordionTrigger> <AccordionTrigger>{t('webhook')}</AccordionTrigger>
<AccordionContent> <AccordionContent>
<Webhook /> <Webhook />
</AccordionContent> </AccordionContent>

View File

@@ -14,29 +14,31 @@ import { getPb } from "@/repository/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
const formSchema = z const formSchema = z
.object({ .object({
oldPassword: z.string().min(10, { oldPassword: z.string().min(10, {
message: "密码至少10个字符", message: "setting.password.length.message",
}), }),
newPassword: z.string().min(10, { newPassword: z.string().min(10, {
message: "密码至少10个字符", message: "setting.password.length.message",
}), }),
confirmPassword: z.string().min(10, { confirmPassword: z.string().min(10, {
message: "密码至少10个字符", message: "setting.password.length.message",
}), }),
}) })
.refine((data) => data.newPassword === data.confirmPassword, { .refine((data) => data.newPassword === data.confirmPassword, {
message: "两次密码不一致", message: "setting.password.not.match",
path: ["confirmPassword"], path: ["confirmPassword"],
}); });
const Password = () => { const Password = () => {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -66,8 +68,8 @@ const Password = () => {
getPb().authStore.clear(); getPb().authStore.clear();
toast({ toast({
title: "修改密码成功", title: t('setting.password.change.succeed'),
description: "请重新登录", description: t("setting.account.log.back.in"),
}); });
setTimeout(() => { setTimeout(() => {
navigate("/login"); navigate("/login");
@@ -75,7 +77,7 @@ const Password = () => {
} catch (e) { } catch (e) {
const message = getErrMessage(e); const message = getErrMessage(e);
toast({ toast({
title: "修改密码失败", title: t('setting.password.change.failed'),
description: message, description: message,
variant: "destructive", variant: "destructive",
}); });
@@ -95,9 +97,9 @@ const Password = () => {
name="oldPassword" name="oldPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('setting.password.current.password')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="当前密码" {...field} type="password" /> <Input placeholder={t('setting.password.current.password')} {...field} type="password" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -110,7 +112,7 @@ const Password = () => {
name="newPassword" name="newPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('setting.password.new.password')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="newPassword" placeholder="newPassword"
@@ -129,7 +131,7 @@ const Password = () => {
name="confirmPassword" name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t('setting.password.confirm.password')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="confirmPassword" placeholder="confirmPassword"
@@ -143,7 +145,7 @@ const Password = () => {
)} )}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t('setting.submit')}</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -26,18 +26,21 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
const formSchema = z.object({
provider: z.enum(["letsencrypt", "zerossl"], {
message: "请选择SSL提供商",
}),
eabKid: z.string().optional(),
eabHmacKey: z.string().optional(),
});
const SSLProvider = () => { const SSLProvider = () => {
const { t } = useTranslation();
const formSchema = z.object({
provider: z.enum(["letsencrypt", "zerossl"], {
message: t("setting.ca.not.empty"),
}),
eabKid: z.string().optional(),
eabHmacKey: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -86,12 +89,12 @@ const SSLProvider = () => {
if (values.provider === "zerossl") { if (values.provider === "zerossl") {
if (!values.eabKid) { if (!values.eabKid) {
form.setError("eabKid", { form.setError("eabKid", {
message: "请输入EAB_KID和EAB_HMAC_KEY", message: t("setting.ca.eab_kid_hmac_key.not.empty"),
}); });
} }
if (!values.eabHmacKey) { if (!values.eabHmacKey) {
form.setError("eabHmacKey", { form.setError("eabHmacKey", {
message: "请输入EAB_KID和EAB_HMAC_KEY", message: t("setting.ca.eab_kid_hmac_key.not.empty"),
}); });
} }
if (!values.eabKid || !values.eabHmacKey) { if (!values.eabKid || !values.eabHmacKey) {
@@ -117,13 +120,13 @@ const SSLProvider = () => {
try { try {
await update(setting); await update(setting);
toast({ toast({
title: "修改成功", title: t("update.succeed"),
description: "修改成功", description: t("update.succeed"),
}); });
} catch (e) { } catch (e) {
const message = getErrMessage(e); const message = getErrMessage(e);
toast({ toast({
title: "修改失败", title: t("update.failed"),
description: message, description: message,
variant: "destructive", variant: "destructive",
}); });
@@ -143,7 +146,7 @@ const SSLProvider = () => {
name="provider" name="provider"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel>{t("ca")}</FormLabel>
<FormControl> <FormControl>
<RadioGroup <RadioGroup
{...field} {...field}
@@ -199,7 +202,7 @@ const SSLProvider = () => {
<FormLabel>EAB_KID</FormLabel> <FormLabel>EAB_KID</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="请输入EAB_KID" placeholder={t("setting.ca.eab_kid.not.empty")}
{...field} {...field}
type="text" type="text"
/> />
@@ -218,7 +221,7 @@ const SSLProvider = () => {
<FormLabel>EAB_HMAC_KEY</FormLabel> <FormLabel>EAB_HMAC_KEY</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="请输入EAB_HMAC_KEY" placeholder={t("setting.ca.eab_hmac_key.not.empty")}
{...field} {...field}
type="text" type="text"
/> />
@@ -235,7 +238,7 @@ const SSLProvider = () => {
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit"></Button> <Button type="submit">{t("setting.submit")}</Button>
</div> </div>
</form> </form>
</Form> </Form>