Squashed commit of the following:

commit 1e92328a0fc570fe9419ad5dbaaef77f7dc9ad2e
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 15 21:52:44 2024 +0800

    yes, react it!

commit 09fffff6139b4cecb81cb1444139f225e95e8917
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 15 17:33:26 2024 +0800

    actions to be done

commit 1611b0b338cfd965d15f43fb10308bc56015895f
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 15 15:22:08 2024 +0800

    modal needed.

commit 88453e7382618fb6774ff1cc4c0f7045d4dfcf46
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 15 10:52:13 2024 +0800

    Domain View done

commit 8cedca27c79ca2ba69c8777dfcb6019799875e31
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 14 21:24:14 2024 +0800

    domain delete modal done

commit 60cd00c0cad0774bae5b57bcfc4723a29d28d221
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 14 07:55:11 2024 +0800

    1

commit 285853e988db6e6a6371135869da0129fd73afd7
Author: Sense T <me@sense-t.eu.org>
Date:   Sat Apr 13 17:29:43 2024 +0800

    eslint

commit 8f0ffbf744fd85a612daacd7bd6cbc45d58907d3
Author: Sense T <me@sense-t.eu.org>
Date:   Sat Apr 13 17:20:50 2024 +0800

    f

commit 9762b632225f185d83388e58d93ed49f62fe6b3f
Author: Sense T <me@sense-t.eu.org>
Date:   Sat Apr 13 17:08:37 2024 +0800

    views, components to be done

commit 321e5255f2b1e705844179dd910d5f5a1ae58298
Author: Sense T <me@sense-t.eu.org>
Date:   Sat Apr 13 14:29:04 2024 +0800

    prepare for react
This commit is contained in:
Sense T 2024-04-15 21:53:09 +08:00
parent 3305d8d618
commit b583720223
42 changed files with 4051 additions and 3359 deletions

12
web/.eslintrc Normal file
View File

@ -0,0 +1,12 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
]
}

View File

@ -1,33 +1,30 @@
# reCoreD-UI # React + TypeScript + Vite
This template should help get you started developing with Vue 3 in Vite. This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Recommended IDE Setup Currently, two official plugins are available:
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Type Support for `.vue` Imports in TS ## Expanding the ESLint configuration
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
## Customize configuration - Configure the top-level `parserOptions` property like this:
See [Vite Configuration Reference](https://vitejs.dev/config/). ```js
export default {
## Project Setup // other rules...
parserOptions: {
```sh ecmaVersion: 'latest',
npm install sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
``` ```
### Compile and Hot-Reload for Development - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
```sh - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

View File

@ -1,13 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico"> <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>Vite App</title> <title>Vite + React + TS</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="root"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

4264
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,36 @@
{ {
"name": "recored-ui", "name": "web",
"version": "0.0.0",
"private": true, "private": true,
"version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "NODE_ENV=dev vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "tsc && vite build",
"preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-only": "vite build", "preview": "vite preview"
"type-check": "vue-tsc --build --force"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.3.6",
"antd": "^5.16.1",
"axios": "^1.6.8", "axios": "^1.6.8",
"pinia": "^2.1.7", "i18next": "^23.11.1",
"vue": "^3.4.21", "i18next-browser-languagedetector": "^7.2.1",
"vue-i18n": "^9.11.0", "react": "^18.2.0",
"vue-router": "^4.3.0" "react-dom": "^18.2.0",
"react-i18next": "^14.1.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.3"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.2", "@types/react": "^18.2.66",
"@types/node": "^20.11.28", "@types/react-dom": "^18.2.22",
"@vicons/fa": "^0.12.0", "@typescript-eslint/eslint-plugin": "^7.6.0",
"@vitejs/plugin-vue": "^5.0.4", "@typescript-eslint/parser": "^7.6.0",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-react-swc": "^3.5.0",
"@vue/tsconfig": "^0.5.1", "eslint": "^8.57.0",
"naive-ui": "^2.38.1", "eslint-plugin-react-hooks": "^4.6.0",
"npm-run-all2": "^6.1.2", "eslint-plugin-react-refresh": "^0.4.6",
"typescript": "~5.4.0", "typescript": "^5.4.5",
"vfonts": "^0.0.3", "vite": "^5.2.0"
"vite": "^5.1.6",
"vue-tsc": "^2.0.6"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

42
web/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,32 +1,34 @@
import { import { RouterProvider } from 'react-router-dom'
NNotificationProvider, import router from './router'
NConfigProvider, import { App, ConfigProvider, Spin, theme } from 'antd'
NGlobalStyle, import zhCN from 'antd/locale/zh_CN'
useOsTheme, import enUS from 'antd/locale/en_US'
darkTheme,
lightTheme,
} from "naive-ui";
import { zhCN, dateZhCN, enUS, dateEnUS } from 'naive-ui' import './App.css'
import { RouterView } from "vue-router"; import isBrowserDarkTheme from './isBrowserDarkTheme'
import i18n from './locale'
const osThemeRef = useOsTheme() function detectLanguage() {
const theme = osThemeRef.value === 'dark' ? darkTheme : lightTheme switch (i18n.language) {
const locale = navigator.language === "zh-CN" ? zhCN : enUS case 'zh-CN':
const dateLocale = navigator.language === "zh-CN" ? dateZhCN : dateEnUS case 'zh':
return zhCN
default:
return enUS
}
}
function App() { function ReactApp() {
document.title = 'reCoreD-UI' document.title = 'reCoreD-UI'
const themeUsed = isBrowserDarkTheme() ? theme.darkAlgorithm : theme.defaultAlgorithm
return ( return (
<NConfigProvider theme={theme} locale={locale} date-locale={dateLocale}> <ConfigProvider theme={{ algorithm: themeUsed }} locale={detectLanguage()}>
<NGlobalStyle /> <App>
<NNotificationProvider max={3}> <RouterProvider router={router} fallbackElement={<Spin size='large' />} />
<RouterView /> </App>
</NNotificationProvider> </ConfigProvider>
</NConfigProvider>
) )
} }
App.displayName = 'App' export default ReactApp
export default App

View File

@ -1,8 +1,7 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from "axios"; import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"
import { type Record } from '@/stores/records'; import { type Record } from '../stores/records'
import { type Domain } from "@/stores/domains"; import { type Domain } from "../stores/domains"
import i18n from "../locale"
import i18n from "@/locale/i18n";
type Result<T> = { type Result<T> = {
success: boolean success: boolean
@ -10,45 +9,52 @@ type Result<T> = {
data: T data: T
} }
const t = i18n.t
// 5 second. // 5 second.
const notificationDuration = 5000 const notificationDuration = 5000
const messages = new Map<number, { const messages = new Map<number, {
title: string, content: string, duration: number message: string, description: string, duration: number
}>( }>(
[ [
[400, { [400, {
title: i18n.global.t("api.error400.title"), message: t("api.error400.title"),
content: i18n.global.t("api.error400.content"), description: t("api.error400.content"),
duration: notificationDuration duration: notificationDuration
}], }],
[401, { [401, {
title: i18n.global.t("api.error401.title"), message: t("api.error401.title"),
content: i18n.global.t("api.error401.content"), description: t("api.error401.content"),
duration: notificationDuration duration: notificationDuration
}], }],
[403, { [403, {
title: i18n.global.t("api.error403.title"), message: t("api.error403.title"),
content: i18n.global.t("api.error403.content"), description: t("api.error403.content"),
duration: notificationDuration duration: notificationDuration
}], }],
[404, { [404, {
title: i18n.global.t("api.error404.title"), message: t("api.error404.title"),
content: i18n.global.t("api.error404.content"), description: t("api.error404.content"),
duration: notificationDuration duration: notificationDuration
}], }],
[500, { [500, {
title: i18n.global.t("api.error500.title"), message: t("api.error500.title"),
content: i18n.global.t("api.error500.content"), description: t("api.error500.content"),
duration: notificationDuration duration: notificationDuration
}] }]
] ]
) )
export function getErrorInfo(err: any) { export interface ResponseError {
response: {
status: number
}
}
export function getErrorInfo(err: ResponseError) {
const msg = messages.get(err.response.status) const msg = messages.get(err.response.status)
return msg ? msg : { return msg ? msg : {
title: i18n.global.t("api.errorUnknown.title"), message: t("api.errorUnknown.title"),
content: i18n.global.t("api.errorUnknown.content"), description: t("api.errorUnknown.content"),
duration: notificationDuration duration: notificationDuration
} }
} }

View File

@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -1,35 +0,0 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
display: block;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: flex;
padding: 2rem 2rem;
}
}

View File

@ -0,0 +1,32 @@
import { Card, Tooltip } from "antd"
import { Domain } from "../../stores/domains"
import { BookOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons"
import DomainInfo from "./DomainInfo"
import i18n from '../../locale'
const { t } = i18n
type DomainCardProps = {
domain: Domain
onRecordClick(): void
onEditClick(): void
onDeleteClick(): void
}
export default function DomainCard({ domain, onRecordClick, onEditClick, onDeleteClick }: DomainCardProps) {
return (
<Card className='domain-info' title={domain.domain_name} actions={[
<Tooltip title={t('domains.dnsRecord')}>
<BookOutlined key='records' className='icon' onClick={onRecordClick} />
</Tooltip>,
<Tooltip title={t('common.edit')} >
<EditOutlined key='edit' className='icon' onClick={onEditClick} />
</Tooltip>,
<Tooltip title={t('common.delete')}>
<DeleteOutlined key='delete' className='icon' onClick={onDeleteClick} />
</Tooltip>
]} key={domain.id}>
<DomainInfo domain={domain} />
</Card>
)
}

View File

@ -0,0 +1,59 @@
import { App, Input, Modal } from "antd"
import { Domain } from "../../stores/domains"
import { useState } from "react"
import i18n from '../../locale'
import { CloseOutlined, DeleteOutlined } from "@ant-design/icons"
import { ResponseError, getErrorInfo } from "../../api"
const { t } = i18n
type Props = {
open: boolean
domain: Domain
removeDomain(domain: Domain): Promise<void>
onOk(): void
onCancel(): void
}
export default function DomainDeleteModal({ open, domain, removeDomain, onOk, onCancel }: Props) {
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const { notification } = App.useApp()
function confirm() {
setLoading(true)
removeDomain(domain).then(onOk).finally(() => setLoading(false)).catch(e => {
const msg = getErrorInfo(e as ResponseError)
notification.error(msg)
console.error(e)
})
}
return (
<Modal onOk={confirm} onCancel={onCancel}
title={`${t('domains.delete')} - ${domain.domain_name}`}
confirmLoading={loading}
okButtonProps={{
disabled: input !== domain.domain_name,
icon: <DeleteOutlined />,
danger: true
}}
cancelButtonProps={{
icon: <CloseOutlined />
}}
open={open}
closeIcon={false}
maskClosable={false}
centered
destroyOnClose
>
<p>{t('common.deleteConfirm')}</p>
<p>{t('domains.deleteHint')}</p>
<p>{t('domains.confirm1')} <b id="boldit">{domain.domain_name}</b> {t('domains.confirm2')}</p>
<p />
<p>
<Input placeholder={domain.domain_name} onChange={e => setInput(e.target.value)} />
</p>
</Modal>
)
}

View File

@ -1,220 +1,131 @@
import { import { App, Form, FormInstance, Input, InputNumber, Modal, Space } from "antd"
NModal, import { Domain } from "../../stores/domains"
NCard, import i18n from '../../locale'
NForm, import { useEffect, useState } from "react"
NFormItem, import { CheckOutlined, CloseOutlined } from "@ant-design/icons"
NFlex, import { ResponseError, getErrorInfo } from "../../api"
NIcon, const { t } = i18n
NButton,
NInput,
NInputNumber,
type FormRules,
type FormItemRule,
createDiscreteApi
} from 'naive-ui'
import { getErrorInfo } from '@/apis/api';
import { useDomainStore, type Domain } from '@/stores/domains';
import { Check, Times } from '@vicons/fa';
import i18n from '@/locale/i18n'
import { ref, type SetupContext } from 'vue';
const { t } = i18n.global
const domainStore = useDomainStore()
const { notification } = createDiscreteApi(['notification'])
const enum validFlags {
domainNameValid = 1,
mainNsValid = domainNameValid << 1,
adminEmailValid = mainNsValid << 1
}
const allFlags = validFlags.adminEmailValid | validFlags.mainNsValid | validFlags.domainNameValid
const rules = {
domain_name: [{
required: true,
trigger: 'blur',
validator: (_rule: FormItemRule, value: string) => {
return validate(
value,
/^([\w-]+\.)+[\w-]+$/,
'domains.errors.domainName',
validFlags.domainNameValid
)
}
}],
main_dns: [{
required: true,
trigger: 'blur',
validator: (_rule: FormItemRule, value: string) => {
return validate(
value,
/^([\w-]+\.)+[\w-]+$/,
'domains.errors.domainName',
validFlags.mainNsValid,
)
}
}],
admin_email: [{
required: true,
trigger: 'blur',
validator: (_rule: FormItemRule, value: string) => {
return validate(
value,
/^[\w-.]+@([\w-]+\.)+[\w-]+$/,
'domains.errors.mail',
validFlags.adminEmailValid
)
}
}],
refresh_interval: [{
required: true,
trigger: 'blur',
type: 'number',
}],
retry_interval: [{
required: true,
trigger: 'blur',
type: 'number',
}],
expiry_period: [{
required: true,
trigger: 'blur',
type: 'number',
}],
negative_ttl: [{
required: true,
trigger: 'blur',
type: 'number'
}]
} as FormRules
type Props = { type Props = {
open: boolean
domain: Domain domain: Domain
show: boolean editDomain(domain: Domain): Promise<void>
'onUpdate:show': (v: boolean) => void createDomain(domain: Domain): Promise<void>
onCancel(): void
onOk(): void
} }
type Events = { export default function DomainEditModal({ open, domain, editDomain, createDomain, onCancel, onOk }: Props) {
'update:show': (v: boolean) => void const [loading, setLoading] = useState(false)
'update:value': (v: string | number | null) => void const [form] = Form.useForm<Domain>()
} const { notification } = App.useApp()
const loading = ref(false) useEffect(() => {
const invalidData = ref(0) form.setFieldsValue(domain)
}, [open])
function validate(value: string, reg: RegExp, msg: string, flag: validFlags): Promise<void> { async function confirm() {
return new Promise<void>((resolve, reject) => { const commitFunction = (!domain.id || domain.id < 1) ? createDomain : editDomain
if (!value) { setLoading(true)
invalidData.value &= ~flag
reject(Error(t('common.mandatory')))
} else if (!reg.test(value)) {
invalidData.value &= ~flag
reject(Error(t(msg)))
} else {
invalidData.value |= flag
resolve()
}
})
}
async function confirm(domain: Domain) {
loading.value = true;
try { try {
if (!domain.id || domain.id < 1) { domain = await form.validateFields()
await domainStore.addDomain(domain) await commitFunction(domain)
} else { onOk()
await domainStore.updateDomain(domain) } catch (error) {
} const msg = getErrorInfo(error as ResponseError)
} catch (e) {
const msg = getErrorInfo(e)
notification.error(msg) notification.error(msg)
console.error(e) console.error(error)
} finally { } finally {
loading.value = false setLoading(false)
} }
} }
function easyInput(domain_name: string, domain: Domain) { function easyInput(form: FormInstance<Domain>, domain_name: string) {
domain.admin_email = `admin@${domain_name}` form.setFieldValue('admin_email', `admin@${domain_name}`)
domain.main_dns = `ns1.${domain_name}` form.setFieldValue('main_dns', `ns1.${domain_name}`)
} }
function modalHeader({ domain }: Props) {
return ( return (
<> <Modal
{(!domain || !domain.id || domain.id < 1) ? <span>{t('common.new')}</span> : <span>{t('common.edit')}</span>} onCancel={onCancel} onOk={confirm}
<span>{t('domains._')}</span> title={
</> <span>
) {
(!domain || !domain.id || domain.id < 1) ? t('common.new') : t('common.edit')
} }
{
function modalInputNumbers({ value, label, path }: { value: number, label: string, path: string }, { emit }: SetupContext<Events>) { t('domains._')
return ( }
<NFormItem label={t(label)} path={path}> </span>
<NInputNumber value={value} onUpdate:value={v => emit('update:value', v)} showButton={false}> }
{{ confirmLoading={loading}
suffix: () => t('common.unitForSecond') cancelButtonProps={{
icon: <CloseOutlined />,
}} }}
</NInputNumber> okButtonProps={{
</NFormItem> icon: <CheckOutlined />,
) htmlType: 'submit'
}
function modalBody({ domain }: Props) {
return (
<>
<NForm model={domain} rules={rules}>
<NFormItem label={t('domains._')} path='domain_name'>
<NInput placeholder='example.com' value={domain.domain_name} onUpdate:value={v => domain.domain_name = v} onInput={v => easyInput(v, domain)} />
</NFormItem>
<NFormItem label={t('domains.form.mainDNS')} path='main_dns'>
<NInput placeholder="ns1.example.com" value={domain.main_dns} onUpdate:value={v => domain.main_dns = v} />
</NFormItem>
<NFormItem label={t('domains.form.adminMail')} path='admin_email'>
<NInput placeholder="admin@example.com" value={domain.admin_email} onUpdate:value={v => domain.admin_email = v} inputProps={{ type: 'email' }} />
</NFormItem>
</NForm>
<NForm model={domain} rules={rules} inline>
<modalInputNumbers value={domain.refresh_interval} onUpdate:value={(v: number) => domain.refresh_interval = v} path='refresh_interval' label='records.refresh' />
<modalInputNumbers value={domain.retry_interval} onUpdate:value={(v: number) => domain.retry_interval = v} path='retry_interval' label='records.retry' />
<modalInputNumbers value={domain.expiry_period} onUpdate:value={(v: number) => domain.expiry_period = v} path='expiry_period' label='records.expire' />
<modalInputNumbers value={domain.negative_ttl} onUpdate:value={(v: number) => domain.negative_ttl = v} path='negative_ttl' label='records.ttl' />
</NForm>
</>
)
}
function modalActions({ domain }: Props, { emit }: SetupContext<Events>) {
return (
<NFlex justify='end'>
<NButton size='small' onClick={() => emit("update:show", false)} >
{{
default: () => t('common.cancel'),
icon: () => <NIcon><Times /></NIcon>
}} }}
</NButton>
<NButton size='small' type='primary' disabled={invalidData.value !== allFlags} loading={loading.value} onClick={() => confirm(domain).then(() => emit('update:show', false))} attrType='submit'> open={open}
{{ closeIcon={false}
default: () => t('common.confirm'), maskClosable={false}
icon: () => <NIcon><Check /></NIcon>
}} centered
</NButton> destroyOnClose
</NFlex> forceRender
>
<Form<Domain> name="domain" form={form}
scrollToFirstError
autoComplete="off"
>
<Form.Item<Domain>
label={t('domains._')}
name='domain_name'
rules={[
{ required: true, message: t('common.mandatory') },
{ pattern: /^([\w-]+\.)+[\w-]+$/, message: t('domains.errors.domainName') },
]}
>
<Input placeholder='example.com' onChange={v => easyInput(form, v.target.value)} />
</Form.Item>
<Form.Item<Domain>
label={t('domains.form.mainDNS')}
name='main_dns'
rules={[
{ required: true, message: t('common.mandatory') },
{ pattern: /^([\w-]+\.)+[\w-]+$/, message: t('domains.errors.domainName') },
]}
>
<Input placeholder="ns1.example.com" />
</Form.Item>
<Form.Item<Domain>
label={t('domains.form.adminMail')}
name='admin_email'
rules={[
{ required: true, message: t('common.mandatory') },
{ pattern: /^[\w-.]+@([\w-]+\.)+[\w-]+$/, message: t('domains.errors.mail') }
]}
>
<Input placeholder="admin@example.com" />
</Form.Item>
<Space>
<Form.Item<Domain> name='refresh_interval' label={t('records.refresh')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
<Form.Item<Domain> name='retry_interval' label={t('records.retry')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
</Space>
<Space>
<Form.Item<Domain> name='expiry_period' label={t('records.expire')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
<Form.Item<Domain> name='negative_ttl' label={t('records.ttl')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
</Space>
</Form>
</Modal>
) )
} }
function DomainEditModal({ domain, show, }: Props, { emit }: SetupContext<Events>) {
return (
<NModal maskClosable={false} show={show}>
<NCard style={{ width: '640px' }} role='dialog'>
{{
headler: () => <modalHeader domain={domain} />,
default: () => <modalBody domain={domain} />,
action: () => <modalActions domain={domain} onUpdate:show={(v: boolean) => { emit("update:show", v) }} />
}}
</NCard>
</NModal>
)
}
export default DomainEditModal

View File

@ -1,11 +1,7 @@
div { .icon-info {
display: block; transform: translateY(1px);
} }
span { span.info {
padding-left: 0.5em; padding-left: 0.5em;
} }
.icon {
transform: translateY(2px);
}

View File

@ -1,27 +1,22 @@
import { GlobalOutlined, MailOutlined } from "@ant-design/icons"
import { Domain } from "../../stores/domains"
import './DomainInfo.css' import './DomainInfo.css'
import { type Domain } from "../../stores/domains";
import { NIcon } from "naive-ui";
import { AddressCard, Server } from "@vicons/fa";
import { defineComponent, defineProps } from "vue";
type Props = { type Props = {
domain: Domain domain: Domain
} }
function DomainInfo({domain}: Props) { export default function DomainInfo({ domain }: Props) {
return ( return (
<div> <>
<p> <p>
<NIcon class="icon" component={AddressCard} /> <MailOutlined className="icon-info" />
<span> {domain.admin_email}</span> <span className="info">{domain.admin_email}</span>
</p> </p>
<p> <p>
<NIcon class="icon" component={Server} /> <GlobalOutlined className="icon-info" />
<span> {domain.main_dns}</span> <span className="info">{domain.domain_name}</span>
</p> </p>
</div> </>
) )
} }
export default DomainInfo

View File

@ -1,92 +0,0 @@
import { NSpace, NButton, NIcon, NTooltip, NFlex } from 'naive-ui'
import { TrashAlt, EditRegular, Book } from '@vicons/fa'
import { type Domain } from "../../stores/domains"
import router from '@/router';
import i18n from '@/locale/i18n'
import type { SetupContext } from 'vue';
const { t } = i18n.global
type Props = {
domain: Domain
onRemoveDomain: (d: Domain) => void
onEditDomain: (d: Domain) => void
}
type Events = {
removeDomain(domain: Domain): void
editDomain(domain: Domain): void
}
function loadRecord({ domain }: Props) {
return (
<NTooltip trigger="hover">
{{
trigger: () =>
<NButton size="tiny" type="primary" onClick={() => { router.push(`/records/${domain.domain_name}`) }}>
{{ icon: () => <NIcon component={Book} /> }}
</NButton>,
default: () => t('domains.dnsRecord')
}}
</NTooltip>
)
}
function editDomain({ domain }: Props, { emit }: SetupContext<Events>) {
return (
<NTooltip trigger="hover">
{{
default: () => t('common.edit'),
trigger: () =>
<NButton size="tiny" onClick={() => emit("editDomain", domain)}>
{{
icon: () =>
<NIcon component={EditRegular} />
}}
</NButton>
}}
</NTooltip>
)
}
function deleteDomain({ domain }: Props, { emit }: SetupContext<Events>) {
return (
<NTooltip trigger="hover">
{{
default: () => t('common.delete'),
trigger: () =>
<NButton type="error" size="tiny" onClick={() => emit("removeDomain", domain)}>
{{
icon: () =>
<NIcon component={TrashAlt} />
}}
</NButton>
}}
</NTooltip>
)
}
function DomainOps({ domain }: Props, { emit }: SetupContext<Events>) {
return (
<div>
<NFlex justify='end'>
<loadRecord domain={domain} />
<editDomain domain={domain} onEditDomain={(d: Domain) => emit("editDomain", d)} />
<deleteDomain domain={domain} onRemoveDomain={(d: Domain) => emit("removeDomain", d)} />
</NFlex>
</div>
)
}
DomainOps.props = {
domain: {
required: true
}
}
DomainOps.emits = {
removeDomain: (d: Domain) => d,
editDomain: (d: Domain) => d
} as Events
export default DomainOps

View File

@ -1,7 +0,0 @@
.icon-down {
transform: translateY(2px);
}
b#boldit {
font-weight: bold;
}

View File

@ -1,91 +0,0 @@
import './DomainRemoveModal.css'
import { useDomainStore, type Domain } from '@/stores/domains';
import { NModal, NCard, NFlex, NButton, NIcon, NInput, createDiscreteApi } from 'naive-ui'
import { Times, TrashAlt, QuestionCircle } from '@vicons/fa';
import { getErrorInfo } from '@/apis/api';
import i18n from '@/locale/i18n';
import { ref, type EmitsOptions, type ObjectEmitsOptions, type SetupContext } from 'vue';
const t = i18n.global.t
const domainStore = useDomainStore()
const { notification } = createDiscreteApi(['notification'])
type Props = {
domain: Domain
show: boolean
'onUpdate:show': (value: boolean) => void
}
type Events = {
'update:show': (value: boolean) => void
}
const domain_name = ref('')
const loading = ref(false)
async function confirm(domain: Domain) {
domain_name.value = ''
loading.value = true
try {
if (domain)
await domainStore.removeDomain(domain)
} catch (e) {
const msg = getErrorInfo(e)
notification.error(msg)
console.error(e)
} finally {
loading.value = false
}
}
function modalBody({ domain }: Props) {
return (
<>
<p>{t('common.deleteConfirm')}</p>
<p>{t('domains.deleteHint')}</p>
<p>{t('domains.confirm1')} <b id="boldit">{domain.domain_name}</b> {t('domains.confirm2')}</p>
<br />
<p>
<NInput onUpdate:value={(v) => domain_name.value = v} placeholder={domain.domain_name} />
</p>
</>
)
}
function modalActions({ domain }: Props, { emit }: SetupContext<Events>) {
return <>
<NFlex justify='end'>
<NButton size='small' onClick={() => { emit('update:show', false) }}>
{{
icon: () => <NIcon><Times /></NIcon>,
default: () => t('common.cancel')
}}
</NButton>
<NButton size='small' type='error' disabled={domain_name.value !== domain.domain_name} attrType='submit' loading={loading.value} onClick={() => confirm(domain).then(() => emit('update:show', false))}>
{{
icon: () => <NIcon><TrashAlt /></NIcon>,
default: () => t('common.confirm')
}}
</NButton>
</NFlex>
</>
}
function DomainRemoveModal({ domain, show }: Props, { emit }: SetupContext<Events>) {
return (
<NModal maskClosable={false} show={show}>
<NCard role='dialog' style={{ width: '600px' }}>
{{
header: () => <><NIcon class="icon-down" color='red' />{t('domains.delete')} - {domain.domain_name}</>,
default: () => <modalBody domain={domain} />,
action: () => <modalActions domain={domain} onUpdate:show={(v: boolean) => emit('update:show', v)} />
}}
</NCard>
</NModal>
)
}
export default DomainRemoveModal

View File

@ -1,461 +1,259 @@
import { import { App, Form, Input, InputNumber, Modal, Select } from 'antd'
NModal, import i18n from '../../locale'
NCard, import { AAAARecord, ARecord, CAARecord, CNAMERecord, MXRecord, NSRecord, Record, RecordTypes, SRVRecord, TXTRecord } from '../../stores/records'
NForm, import { useEffect, useState } from 'react'
NFormItem, import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
NFlex, import { ResponseError, getErrorInfo } from '../../api'
NButton, import { FormInstance } from 'antd/lib/form/Form'
NInput, const { t } = i18n
NInputNumber,
NInputGroup,
NSelect,
NIcon,
type FormRules,
type SelectOption,
createDiscreteApi,
type FormItemRule,
} from 'naive-ui'
import { getErrorInfo } from '@/apis/api';
import {
useRecordStore,
RecordTypes,
type Record,
type ARecord,
type AAAARecord,
type CAARecord,
type CNAMERecord,
type NSRecord,
type SRVRecord,
type TXTRecord,
type MXRecord,
type RecordT,
} from '@/stores/records';
import { Check, Times } from '@vicons/fa';
import { ref, type SetupContext } from 'vue';
import i18n from '@/locale/i18n';
const { t } = i18n.global
const enum validationFlags {
name = 1,
content = name << 1
}
type Props = { type Props = {
open: boolean
record: Record record: Record
domain: string //domain: string
show: boolean onCancel(): void
'onReloadRecords': () => void onOk(): void
'onUpdate:show': (v: boolean) => void
editRecord(record: Record): Promise<void>
createRecord(record: Record): Promise<void>
} }
type Events = { const recordTypeOptions = Object.entries(RecordTypes).filter(e => e[1] !== RecordTypes.RecordTypeSOA).map(e => {
reloadRecords(): void
'update:show': (v: boolean) => void
}
const invalidData = ref(validationFlags.content)
const loading = ref(false)
const recordStore = useRecordStore()
const { notification } = createDiscreteApi(['notification'])
const recordTypeOptions = Object.entries(RecordTypes).filter(
e => e[1] !== RecordTypes.RecordTypeSOA
).map(e => {
return { return {
label: e[1], value: e[1],
value: e[1] label: e[1]
} as SelectOption }
}) })
function validateName(_rule: FormItemRule, value: string): boolean | Error { export default function RecordEditModal({ open, record, onOk, onCancel, editRecord, createRecord }: Props) {
invalidData.value |= validationFlags.name const [loading, setLoading] = useState(false)
if (!value || value === '') { const [form] = Form.useForm<Record>()
invalidData.value &= ~validationFlags.name const { notification } = App.useApp()
return new Error(t('common.mandatory'))
}
if (value.includes(' ')) { useEffect(() => { form.setFieldsValue(record) }, [open])
invalidData.value &= ~validationFlags.name
return new Error(t('records.errors.hasSpace'))
}
if (value.startsWith('.') || value.endsWith('.')) { async function confirm() {
invalidData.value &= ~validationFlags.name const commitFunction = (!record.id || record.id < 1) ? createRecord : editRecord
return new Error(t('records.errors.badName.dotAndMinus')) setLoading(true)
}
if (value.startsWith('-') || value.endsWith('-')) {
invalidData.value &= ~validationFlags.name
return new Error(t('records.errors.badName.dotAndMinus'))
}
if (value.includes('..')) {
invalidData.value &= ~validationFlags.name
return new Error(t('records.errors.badName.doubleDots'))
}
if (value.split('.').filter(e => e.length > 63).length > 0) {
invalidData.value &= ~validationFlags.name
return new Error(t('records.errors.badName.longerThan63'))
}
return true
}
function validateTXTRecord(record: Record) {
return () => {
invalidData.value |= validationFlags.content
if (record.record_type !== RecordTypes.RecordTypeTXT) return true
const r = (record.content as TXTRecord)
if (!r || !r.text || r.text === '') {
invalidData.value &= ~validationFlags.content
return new Error(t('common.mandatory'))
}
return true
}
}
function validateHostRecord(record: Record) {
return () => {
invalidData.value |= validationFlags.content
if ([RecordTypes.RecordTypeCNAME, RecordTypes.RecordTypeNS].indexOf(record.record_type) === -1) return true
const r = (record.content as CNAMERecord | NSRecord)
if (!r || !r.host || r.host === '') {
invalidData.value &= ~validationFlags.content
return new Error(t('common.mandatory'))
}
if (r.host.includes(' ')) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.hasSpace'))
}
if (!r.host.endsWith('.')) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.endWithDot'))
}
return true
}
}
function validateIPRecord(record: Record) {
return () => {
invalidData.value |= validationFlags.content
if ([RecordTypes.RecordTypeA, RecordTypes.RecordTypeAAAA].indexOf(record.record_type) === -1) return true
const r = (record.content as AAAARecord | ARecord)
if (!r || !r.ip || r.ip === '') {
invalidData.value &= ~validationFlags.content
return new Error(t('common.mandatory'))
}
switch (record.record_type) {
case RecordTypes.RecordTypeA:
if (!/^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$/.test(r.ip)) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.badIPv4'))
}
break
case RecordTypes.RecordTypeAAAA:
if (!/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(r.ip)) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.badIPv6'))
}
break
}
return true
}
}
function validateMXRecord(record: Record) {
return () => {
invalidData.value |= validationFlags.content
if (record.record_type !== RecordTypes.RecordTypeMX) return true
const r = (record.content as MXRecord)
if (!r || !r.host || !r.preference || r.host === '') {
invalidData.value &= ~validationFlags.content
return new Error(t('common.mandatory'))
}
if (r.host.includes(' ')) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.hasSpace'))
}
if (!r.host.endsWith('.')) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.endWithDot'))
}
return true
}
}
function validateSRVRecord(record: Record) {
return () => {
invalidData.value |= validationFlags.content
if (record.record_type !== RecordTypes.RecordTypeSRV) return true
const r = (record.content as SRVRecord)
if (!r || !r.port || !r.priority || !r.weight || !r.target || r.target === '') {
invalidData.value &= ~validationFlags.content
return new Error(t('common.mandatory'))
}
if (r.target.includes(' ')) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.hasSpace'))
}
if (!r.target.endsWith('.')) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.endWithDot'))
}
return true
}
}
function validateCAARecord(record: Record) {
return () => {
invalidData.value |= validationFlags.content
if (record.record_type !== RecordTypes.RecordTypeCAA) return true
const r = (record.content as CAARecord)
if (!r || !r.flag || !r.tag || r.tag === '' || !r.value || r.value === '') {
invalidData.value &= ~validationFlags.content
return new Error(t('common.mandatory'))
}
if (r.tag.includes(' ')) {
invalidData.value &= ~validationFlags.content
return new Error(t('records.errors.hasSpace'))
}
return true
}
}
function buildRules(record: Record): FormRules {
return {
name: {
trigger: 'blur',
validator: validateName
},
txt: {
trigger: 'blur',
validator: validateTXTRecord(record)
},
host: {
trigger: 'blur',
validator: validateHostRecord(record)
},
ip: {
trigger: 'blur',
validator: validateIPRecord(record)
},
mx: {
trigger: 'blur',
validator: validateMXRecord(record)
},
srv: {
trigger: 'blur',
validator: validateSRVRecord(record)
},
caa: {
trigger: 'blur',
validator: validateCAARecord(record)
}
}
}
async function confirm(record: Record, domain: string) {
loading.value = true;
try { try {
if (!record.id || record.id < 1) { record = await form.validateFields()
await recordStore.addRecord(domain, record) await commitFunction(record)
} else { onOk()
await recordStore.updateRecord(domain, record) } catch (error) {
} const msg = getErrorInfo(error as ResponseError)
} catch (e) {
const msg = getErrorInfo(e)
notification.error(msg) notification.error(msg)
console.error(e) console.error(error)
} finally {
setLoading(false)
} }
loading.value = false;
} }
function modalHeader({ record }: Props) { const controls = new Map<RecordTypes, (f: FormInstance<Record>) => JSX.Element>([
return ( [
RecordTypes.RecordTypeA, (
({ getFieldValue }) =>
<Form.Item<Record<ARecord>> label='IP' name={['content', 'ip']} required rules={[{
validator() {
const result = ARecord.validate(getFieldValue('content') as ARecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeAAAA, (
({ getFieldValue }) =>
<Form.Item<Record<AAAARecord>> label='IP' name={['content', 'ip']} required rules={[{
validator() {
const result = AAAARecord.validate(getFieldValue('content') as AAAARecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeCNAME, (
({ getFieldValue }) =>
<Form.Item<Record<CNAMERecord>> label={t('records.form.host')} required name={['content', 'host']}
rules={[{
validator() {
const result = CNAMERecord.validate(getFieldValue('content') as CNAMERecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeNS, (
({ getFieldValue }) =>
<Form.Item<Record<NSRecord>> label={t('records.form.host')} name={['content', 'host']} required
rules={[{
validator() {
const result = NSRecord.validate(getFieldValue('content') as NSRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeTXT, (
({ getFieldValue }) =>
<Form.Item<Record<TXTRecord>> label={t('records.form.text')} name={['content', 'text']} required
rules={[{
validator() {
const result = TXTRecord.validate(getFieldValue('content') as TXTRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeMX, (
({ getFieldValue }) =>
<> <>
{(!record || !record.id || record.id < 1) ? <span>{t('common.new')}</span> : <span> {t('common.edit')}</span>} <Form.Item<Record<MXRecord>> label={t('records.form.host')} name={['content', 'host']} required
<span>{t('records._')}</span> rules={[{
validator() {
const result = MXRecord.validate(getFieldValue('content') as MXRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
<Form.Item<Record<MXRecord>> label={t('records.form.preference')} name={['content', 'preference']} required>
<InputNumber controls={false} />
</Form.Item>
</> </>
) )
} ],
[
function modalActions({ record, domain }: Props, { emit }: SetupContext<Events>) { RecordTypes.RecordTypeCAA, (
return ( ({ getFieldValue }) =>
<NFlex justify='end'>
<NButton size='small' onClick={() => emit('update:show', false)}>
{{
icon: () => <NIcon component={Times} />,
default: () => t('common.cancel')
}}
</NButton>
<NButton size='small' type='primary' loading={loading.value} attrType='submit'
disabled={invalidData.value !== (validationFlags.content | validationFlags.name)}
onClick={() => confirm(record, domain).then(() => { emit('reloadRecords'); emit('update:show', false) })}>
{{
icon: () => <NIcon component={Check} />,
default: () => t('common.confirm')
}}
</NButton>
</NFlex>
)
}
function modalBody({ record }: Props) {
const rules = buildRules(record)
return (
<> <>
<NForm model={record} rules={rules} inline> <Form.Item<Record<CAARecord>> label={t('records.form.flag')} name={['content', 'flag']} required>
<NFormItem label={t('records.recordType')}> <InputNumber controls={false} />
<NSelect value={record.record_type} </Form.Item>
onUpdate:value={(v) => { record.record_type = v; record.content = {} as RecordT }} <Form.Item<Record<CAARecord>> label={t('records.form.tag')} name={['content', 'tag']} required rules={[{
options={recordTypeOptions} style={{ width: '8vw' }} /> validator() {
</NFormItem> const result = CAARecord.validate(getFieldValue('content') as CAARecord)
<NFormItem label={t('records.name')} path='name'> return (result === true) ? Promise.resolve() : Promise.reject(result)
<NInput value={record.name} onUpdate:value={v => record.name = v} /> }
</NFormItem> }]}>
<NFormItem label='TTL' path='ttl'> <Input />
<NInputNumber value={record.ttl} onUpdate:value={v => v ? record.ttl = v : null} showButton={false} > </Form.Item>
{{ <Form.Item<Record<CAARecord>> label={t('records.form.value')} name={['content', 'value']} required rules={[
suffix: () => t('common.unitForSecond') { required: true, message: t('common.mandatory') }
}} ]} >
</NInputNumber> <Input />
</NFormItem> </Form.Item>
</NForm>
<NForm model={record} rules={rules}>
<modalBodyContent type={record.record_type} record={record} />
</NForm>
</> </>
) )
],
[
RecordTypes.RecordTypeSRV, (
({ getFieldValue }) =>
<>
<Form.Item<Record<SRVRecord>> label={t('records.form.priority')} name={['content', 'priority']} required>
<InputNumber controls={false} />
</Form.Item>
<Form.Item<Record<SRVRecord>> label={t('records.form.weight')} name={['content', 'weight']} required>
<InputNumber controls={false} />
</Form.Item>
<Form.Item<Record<SRVRecord>> label={t('records.form.port')} name={['content', 'port']} required>
<InputNumber controls={false} />
</Form.Item>
<Form.Item<Record<SRVRecord>> label={t('records.form.target')} name={['content', 'target']} required rules={[{
validator() {
const result = SRVRecord.validate(getFieldValue('content') as SRVRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
} }
}]}>
const IPRecordE = ({ record }: Props) => ( <Input />
<NFormItem label={t('records.content')} path='ip'> </Form.Item>
<NInput value={(record.content as ARecord | AAAARecord).ip} onUpdate:value={v => (record.content as ARecord | AAAARecord).ip = v} placeholder='IP' /> </>
</NFormItem>
) )
]
])
const HostRecordE = ({ record }: Props) => (
<NFormItem label={t('records.content')} path='host'>
<NInput value={(record.content as CNAMERecord | NSRecord).host} onUpdate:value={v => (record.content as CNAMERecord | NSRecord).host = v} placeholder={t('records.form.host')} />
</NFormItem>
)
const TXTRecordE = ({ record }: Props) => (
<NFormItem label={t('records.content')} path='txt'>
<NInput value={(record.content as TXTRecord).text} onUpdateValue={v => (record.content as TXTRecord).text = v} placeholder={t('records.form.text')} />
</NFormItem>
)
const MXRecordE = ({ record }: Props) => (
<NFormItem label={t('records.content')} path='mx'>
<NInputGroup>
<NInput placeholder={t('records.form.host')}
value={(record.content as MXRecord).host}
onUpdate:value={v => (record.content as MXRecord).host = v}
style={{ width: '75%' }} />
<NInputNumber placeholder={t('records.form.preference')}
value={(record.content as MXRecord).preference}
onUpdate:value={v => v ? (record.content as MXRecord).preference = v : null}
style={{ width: '25%' }} show-button={false} />
</NInputGroup>
</NFormItem>
)
const CAARecordE = ({ record }: Props) => (
<NFormItem label={t('records.content')} path='caa'>
<NInputGroup>
<NInputNumber placeholder={t('records.form.flag')}
value={(record.content as CAARecord).flag} style={{ width: '20%' }}
onUpdate:value={v => v ? (record.content as CAARecord).flag = v : null}
show-button={false} />
<NInput placeholder={t('records.form.tag')}
value={(record.content as CAARecord).tag}
onUpdate:value={v => v ? (record.content as CAARecord).tag = v : null}
style={{ width: '40%' }} />
<NInput placeholder={t('records.form.value')}
value={(record.content as CAARecord).value}
onUpdate:value={v => v ? (record.content as CAARecord).value = v : null}
style={{ width: '40%' }} />
</NInputGroup>
</NFormItem>
)
const SRVRecordE = ({ record }: Props) => (
<NFormItem label={t('records.content')} path='srv'>
<NInputGroup>
<NInputNumber placeholder={t('records.form.priority')}
value={(record.content as SRVRecord).priority} style={{ width: '15%' }}
onUpdateValue={v => v ? (record.content as SRVRecord).priority = v : null}
show-button={false} />
<NInputNumber placeholder={t('records.form.weight')}
value={(record.content as SRVRecord).weight} style={{ width: '15%' }}
onUpdate:value={v => v ? (record.content as SRVRecord).weight = v : null}
show-button={false} />
<NInputNumber placeholder={t('records.form.port')}
value={(record.content as SRVRecord).port} style={{ width: '15%' }} min={0} max={65535}
onUpdate:value={v => v ? (record.content as SRVRecord).port = v : null}
show-button={false} />
<NInput placeholder={t('records.form.target')}
value={(record.content as SRVRecord).target} style={{ width: '55%' }}
onUpdate:value={v => (record.content as SRVRecord).target = v}
/>
</NInputGroup>
</NFormItem>
)
const modalBodyContent = ({ type, record }: { type: RecordTypes, record: Record }) => {
const e = {
'A': IPRecordE,
'AAAA': IPRecordE,
'CNAME': HostRecordE,
'NS': HostRecordE,
'TXT': TXTRecordE,
'MX': MXRecordE,
'SRV': SRVRecordE,
'CAA': CAARecordE,
'SOA': ({ }: Props) => <></>
}[type]
return <e record={record} />
}
function RecordEditModal(
{ domain, show, record }: Props,
{ emit }: SetupContext<Events>) {
return ( return (
<NModal maskClosable={false} show={show}> <Modal
<NCard style={{ width: '640px' }} role='dialog'> onCancel={onCancel} onOk={confirm}
{{ title={
header: () => <modalHeader record={record} />, <span>
default: () => <modalBody record={record} />, {
action: () => <modalActions record={record} domain={domain} (!record || !record.id || record.id < 1) ? t('common.new') : t('common.edit')
onUpdate:show={(v: boolean) => emit('update:show', v)} }
onReloadRecords={() => emit('reloadRecords')} /> {
t('records._')
}
</span>
}
confirmLoading={loading}
cancelButtonProps={{
icon: <CloseOutlined />,
}} }}
</NCard> okButtonProps={{
</NModal> icon: <CheckOutlined />,
htmlType: 'submit'
}}
open={open}
closeIcon={false}
maskClosable={false}
centered
destroyOnClose
forceRender
>
<Form<Record> name='record' form={form}
scrollToFirstError
autoComplete='off'
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
validateTrigger='onBlur'
>
<Form.Item<Record> label={t('records.recordType')} required name='record_type'>
<Select allowClear={false} options={recordTypeOptions} />
</Form.Item>
<Form.Item<Record> label={t('records.name')} required name='name' rules={[
{
validator(_rule, value) {
const result = Record.validateName(value)
return (result === true) ? Promise.resolve() : Promise.reject(result)
},
}
]}>
<Input />
</Form.Item>
<Form.Item<Record> label='TTL' required name='ttl'>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
<Form.Item<Record> noStyle shouldUpdate={(p, c) => p.record_type !== c.record_type}>
{
({ getFieldValue }: FormInstance<Record>) => {
const e = controls.get(getFieldValue('record_type'))
if (!e) {
return <></>
}
return e({ getFieldValue } as FormInstance<Record>)
}
}
</Form.Item>
</Form>
</Modal>
) )
} }
export default RecordEditModal

View File

@ -1,49 +1,26 @@
import { NButton, NButtonGroup, NTooltip, NIcon, NPopconfirm, NFlex } from 'naive-ui' import { Button, Flex, Popconfirm, Tooltip } from "antd"
import { TrashAlt, EditRegular } from '@vicons/fa' import { DeleteOutlined, EditFilled } from "@ant-design/icons"
import type { Record } from '@/stores/records' import i18n from '../../locale'
import i18n from '@/locale/i18n'
import type { SetupContext } from 'vue' const { t } = i18n
const { t } = i18n.global
type Props = { type Props = {
record: Record onEdit(): void
domain: string onDelete(): void
onRecordDelete: (domain: string, record: Record) => void
onEditRecord: (domain: string, record: Record) => void
} }
type Events = { export default function RecordOps({ onEdit, onDelete }: Props) {
recordDelete(domain: string, record: Record): void
editRecord(domain: string, record: Record): void
}
function RecordOps({ record, domain }: Props, { emit }: SetupContext<Events>) {
return ( return (
<NFlex justify='end'> <Flex justify="end" gap='small'>
<NButtonGroup> <Tooltip title={t("common.edit")}>
<NTooltip> <Button icon={<EditFilled />} size="small" onClick={onEdit}/>
{{ </Tooltip>
trigger: () => <NButton size='tiny' onClick={() => emit('editRecord', domain, record)}>
{{ <Popconfirm onConfirm={onDelete} title={t("common.deleteConfirm")}>
icon: () => <NIcon component={EditRegular} /> <Tooltip title={t("common.delete")}>
}} <Button danger type="primary" icon={<DeleteOutlined />} size="small" />
</NButton>, </Tooltip>
default: () => t("common.edit") </Popconfirm>
}} </Flex>
</NTooltip>
<NPopconfirm onPositiveClick={() => emit('recordDelete', domain, record)}>
{{
trigger: () => <NButton type='error' size='tiny'>
{{
icon: () => <NIcon component={TrashAlt} />
}}
</NButton>,
default: () => t("common.deleteConfirm")
}}
</NPopconfirm>
</NButtonGroup>
</NFlex>
) )
} }
export default RecordOps

78
web/src/index.css Normal file
View File

@ -0,0 +1,78 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
--records-layout-header-bgcolor: #eee;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
.records-layout-header{
background-color: #eee;
}
}
@media (prefers-color-scheme: dark) {
:root {
--records-layout-header-bgcolor: #333;
}
}

View File

@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
const isBrowserDarkTheme = () => {
const [isDarkTheme, setIsDarkTheme] = useState(false);
useEffect(() => {
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: { matches: boolean | ((prevState: boolean) => boolean); }) => setIsDarkTheme(e.matches);
mediaQueryList.addEventListener('change', handleChange);
setIsDarkTheme(mediaQueryList.matches);
return () => mediaQueryList.removeEventListener('change', handleChange);
}, []);
return isDarkTheme
};
export default isBrowserDarkTheme;

View File

@ -86,6 +86,7 @@ export default {
hasSpace: 'shoule have no space', hasSpace: 'shoule have no space',
badIPv4: 'invalid IPv4 address', badIPv4: 'invalid IPv4 address',
badIPv6: 'invalid IPv6 address', badIPv6: 'invalid IPv6 address',
badEmail: 'no @ for this email address',
badName: { badName: {
dotAndMinus: 'should not start or end with "." "-"', dotAndMinus: 'should not start or end with "." "-"',
doubleDots: 'should have no contianus "."', doubleDots: 'should have no contianus "."',

View File

@ -1,23 +0,0 @@
import { createI18n } from "vue-i18n";
import zhCN from "./zh-CN";
import enUS from "./en-US";
export default createI18n({
locale: navigator.language,
legacy: false,
messages: {
zh: {
...zhCN
},
'zh-CN': {
...zhCN
},
en: {
...enUS
},
'en-US': {
...enUS
}
}
})

24
web/src/locale/index.ts Normal file
View File

@ -0,0 +1,24 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import zh from './zh'
import en from './en'
i18n.use(LanguageDetector).use(initReactI18next)
.init({
debug: import.meta.env.DEV,
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
resources: {
en: {
translation: en
},
zh: {
translation: zh
}
}
})
export default i18n

View File

@ -86,6 +86,7 @@ export default {
hasSpace: '不能有空格', hasSpace: '不能有空格',
badIPv4: '不是有效的 IPv4 地址', badIPv4: '不是有效的 IPv4 地址',
badIPv6: '不是有效的 IPv6 地址', badIPv6: '不是有效的 IPv6 地址',
badEmail: '这里的邮箱不能有 @ 符号',
badName: { badName: {
dotAndMinus: '资源记录不能以 "."、"-" 开头或结尾', dotAndMinus: '资源记录不能以 "."、"-" 开头或结尾',
doubleDots: '资源记录不能有连续的 "."', doubleDots: '资源记录不能有连续的 "."',

View File

@ -1,16 +0,0 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App'
import router from './router'
import i18n from './locale/i18n'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.mount('#app')

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -1,30 +0,0 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
redirect: '/domains'
},
{
path: '/domains',
name: 'domains',
meta: {
type: 'domains'
},
component: () => import('@/views/DomainsView')
},
{
path: '/records/:domain',
name: 'records',
meta: {
type: 'records'
},
component: () => import('@/views/RecordsView'),
props: true
}
]
})
export default router

28
web/src/router/index.tsx Normal file
View File

@ -0,0 +1,28 @@
import { createHashRouter, redirect } from 'react-router-dom'
import { Suspense, lazy } from 'react'
import { Spin } from 'antd'
const DomainsView = lazy(() => import('../views/DomainsView'))
const RecordsView = lazy(() => import('../views/RecordsView'))
const router = createHashRouter([
{
path: '/',
loader: async () => {
return redirect('/domains')
},
},
{
path: '/domains',
id: 'domains',
element: <Suspense fallback={<Spin size='large' />}><DomainsView /></Suspense>
},
{
path: '/records/:domain',
id: 'records',
loader: args => args.params,
element: <Suspense fallback={<Spin size='large' />}><RecordsView /></Suspense>
}
])
export default router

View File

@ -1,17 +1,16 @@
import { defineStore } from 'pinia' import { useState } from 'react'
import { ref, computed } from 'vue' import api from '../api'
import api from '@/apis/api'
export type Domain = { export class Domain {
id: number; id?: number
domain_name: string; domain_name?: string
main_dns: string; main_dns?: string
admin_email: string; admin_email?: string
serial_number: number; serial_number?: number
refresh_interval: number; refresh_interval?: number
retry_interval: number; retry_interval?: number
expiry_period: number; expiry_period?: number
negative_ttl: number; negative_ttl?: number
} }
const domainDevData: Domain[] = [ const domainDevData: Domain[] = [
@ -39,43 +38,37 @@ const domainDevData: Domain[] = [
}, },
] ]
export const useDomainStore = defineStore('domains', () => { export const useDomainStore = () => {
const domains = ref<Domain[]>([]) const [domains, setDomains] = useState<Domain[]>([])
const domainsGetter = computed(() => domains.value)
async function loadDomains() { async function loadDomains() {
// TODO: load from api setDomains(import.meta.env.DEV ? domainDevData : (await api.get<Domain[]>('/domains')).data.data)
domains.value = import.meta.env.DEV ?
domainDevData :
(await api.get<Domain[]>('/domains')).data.data
} }
async function addDomain(domain: Domain) { async function addDomain(domain: Domain) {
// TODO: load from api
if (!import.meta.env.DEV) { if (!import.meta.env.DEV) {
domain = (await api.post("/domains", domain)).data.data domain = (await api.post("/domains", domain)).data.data
} else if (!domain.id) {
domain.id = Math.floor(1000 + Math.random() * 9000)
} }
domains.value.push(domain) setDomains(domains.concat(domain))
} }
async function updateDomain(domain: Domain) { async function updateDomain(domain: Domain) {
// TODO: load from api
if (!import.meta.env.DEV) { if (!import.meta.env.DEV) {
await api.put("/domains", domain) await api.put("/domains", domain)
} }
domains.value = domains.value.map(e => (e.id === domain.id || e.domain_name === domain.domain_name) ? domain : e) setDomains(domains.map((e: Domain) => (e.id === domain.id || e.domain_name === domain.domain_name) ? domain : e))
} }
async function removeDomain(domain: Domain) { async function removeDomain(domain: Domain) {
// TODO: load from api
if (!import.meta.env.DEV) { if (!import.meta.env.DEV) {
await api.delete(`/domains/${domain.id}`) await api.delete(`/domains/${domain.id}`)
} }
setDomains(domains.filter(e => e.id !== domain.id))
domains.value = domains.value.filter(e => e.id !== domain.id)
} }
return { domains, domainsGetter, loadDomains, addDomain, updateDomain, removeDomain }
})
return { domains, loadDomains, addDomain, updateDomain, removeDomain }
}

View File

@ -1,52 +1,147 @@
import api from '@/apis/api'; import { useState } from 'react'
import { defineStore } from 'pinia' import i18n from '../locale'
import { ref, computed } from 'vue' import api from '../api';
const { t } = i18n
export class ARecord {
ip?: string
export type ARecord = { static validate(v: ARecord): true | Error {
ip: string; if (!v.ip || v.ip === '') return new Error(t('common.mandatory'))
if (!/^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$/.test(v.ip)) return new Error(t('records.errors.badIPv4'))
return true
} }
export type AAAARecord = { toString(): string | undefined {
ip: string; return this.ip
}
} }
export type TXTRecord = { export class AAAARecord {
text: string; ip?: string
static validate(v: AAAARecord): true | Error {
if (!v.ip || v.ip === '') return new Error(t('common.mandatory'))
if (!/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(v.ip)) return new Error(t('records.errors.badIPv4'))
return true
} }
export type CNAMERecord = { toString(): string | undefined {
host: string; return this.ip
}
} }
export type NSRecord = { export class TXTRecord {
host: string; text?: string
static validate(v: TXTRecord): true | Error {
if (!v.text || v.text === '') return new Error(t('common.mandatory'))
return true
} }
export type MXRecord = { toString(): string | undefined {
host: string; return this.text
preference: number; }
} }
export type SRVRecord = { export class CNAMERecord {
priority: number; host?: string
weight: number;
port: number; static validate(v: CNAMERecord): true | Error {
target: string; if (!v.host || v.host === '') return new Error(t('common.mandatory'))
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
} }
export type SOARecord = { toString(): string | undefined {
ns: string; return this.host
MBox: string; }
refresh: number;
retry: number;
expire: number;
minttl: number;
} }
export type CAARecord = { export class NSRecord {
flag: number; host?: string
tag: string;
value: string; static validate(v: NSRecord): true | Error {
if (!v.host || v.host === '') return new Error(t('common.mandatory'))
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
toString(): string | undefined {
return this.host
}
}
export class MXRecord {
host?: string
preference?: number
static validate(v: MXRecord): true | Error {
if (!v.host || v.host === '' || !v.preference) return new Error(t('common.mandatory'))
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
}
export class SRVRecord {
priority?: number;
weight?: number;
port?: number;
target?: string;
static validate(v: SRVRecord): true | Error {
if (!v.port || !v.priority || !v.weight || !v.target || v.target === '') return new Error(t('common.mandatory'))
if (v.target?.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.target?.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
}
export class SOARecord {
ns?: string;
MBox?: string;
refresh?: number;
retry?: number;
expire?: number;
minttl?: number;
static validate(v: SOARecord): true | Error {
if (!v.refresh || !v.retry || !v.expire! || !v.minttl || !v.MBox || v.MBox === '' || !v.ns || v.ns === '') return new Error(t('common.mandatory'))
if (v.ns?.includes(' ') || v.MBox?.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.ns?.endsWith('.') || !v.MBox?.endsWith('.')) return new Error(t('records.errors.endWithDot'))
if (v.MBox?.includes('@')) return new Error(t('records.errors.badEmail'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
}
export class CAARecord {
flag?: number
tag?: string
value?: string
static validate(v: CAARecord): true | Error {
if (!v.flag || !v.tag || v.tag === '' || !v.value || v.value === '') return new Error(t('common.mandatory'))
if (v.tag?.includes(' ')) return new Error(t('records.errors.hasSpace'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
} }
export enum RecordTypes { export enum RecordTypes {
@ -63,18 +158,42 @@ export enum RecordTypes {
export type RecordT = ARecord | AAAARecord | TXTRecord | CNAMERecord | NSRecord | MXRecord | SRVRecord | SOARecord | CAARecord export type RecordT = ARecord | AAAARecord | TXTRecord | CNAMERecord | NSRecord | MXRecord | SRVRecord | SOARecord | CAARecord
export type Record = { export class Record<T = RecordT> {
id: number; id?: number
zone: string; zone?: string
name: string; name?: string
ttl: number; ttl?: number
content: RecordT; content?: T
record_type: RecordTypes; record_type?: RecordTypes
validate(): true | Error {
const zone = Record.validateZone(this.zone!)
if (zone !== true) return zone
const name = Record.validateName(this.name!)
if (name !== true) return name
return true
}
static validateZone(zone: string): true | Error {
if (zone === '') return new Error(t('common.mandatory'))
if (zone.includes(' ')) new Error(t('records.errors.hasSpace'))
if (zone.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
static validateName(name: string): true | Error {
if (name === '') return new Error(t('common.mandatory'))
if (name.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (name.startsWith('.') || name.endsWith('.')) return new Error(t('records.errors.badName.dotAndMinus'))
if (name.startsWith('-') || name.endsWith('.')) return new Error(t('records.errors.badName.dotAndMinus'))
if (name.includes('..')) new Error(t('records.errors.badName.doubleDots'))
if (name.split('.').filter(e => e.length > 63).length > 0) return new Error(t('records.errors.badName.longerThan63'))
return true
}
} }
const recordDevData = new Map<string, Record[]>([ const recordDevData = new Map<string, Record[]>([
[ ['example.com', [
'example.com', [
{ {
id: 1, id: 1,
zone: "example.com", zone: "example.com",
@ -130,10 +249,8 @@ const recordDevData = new Map<string, Record[]>([
host: "www.example.com." host: "www.example.com."
} }
} }
] ] as Record[]],
], ['example.org', [
[
'example.org', [
{ {
id: 1, id: 1,
zone: "example.org", zone: "example.org",
@ -189,20 +306,17 @@ const recordDevData = new Map<string, Record[]>([
host: "www.example.org." host: "www.example.org."
} }
} }
] ] as Record[]]
]
]) ])
export const useRecordStore = () => {
export const useRecordStore = defineStore('records', () => { const [records, setRecords] = useState<Record[]>([])
const records = ref<Record[] | undefined>([])
const recordsGetter = computed(() => records.value)
async function loadRecords(domain: string) { async function loadRecords(domain: string) {
// TODO: load from api // TODO: load from api
records.value = import.meta.env.DEV ? setRecords(import.meta.env.DEV ?
recordDevData.get(domain) : recordDevData.get(domain)! :
(await api.get<Record[]>(`/records/${domain}`)).data.data (await api.get<Record[]>(`/records/${domain}`)).data.data)
} }
async function addRecord(domain: string, record: Record) { async function addRecord(domain: string, record: Record) {
@ -211,7 +325,7 @@ export const useRecordStore = defineStore('records', () => {
record = (await api.post(`/records/${domain}`, record)).data.data record = (await api.post(`/records/${domain}`, record)).data.data
} }
records.value?.push(record) setRecords(records.concat(record))
} }
async function updateRecord(domain: string, record: Record) { async function updateRecord(domain: string, record: Record) {
@ -220,7 +334,7 @@ export const useRecordStore = defineStore('records', () => {
await api.put(`/records/${domain}`, record) await api.put(`/records/${domain}`, record)
} }
records.value = records.value?.map(e => e.id === record.id ? record : e) setRecords(records.map(e => e.id === record.id ? record : e))
} }
async function removeRecord(domain: string, record: Record) { async function removeRecord(domain: string, record: Record) {
@ -229,8 +343,8 @@ export const useRecordStore = defineStore('records', () => {
await api.delete(`/records/${domain}/${record.id}`) await api.delete(`/records/${domain}/${record.id}`)
} }
records.value = records.value?.filter(e => e.id !== record.id) setRecords(records.filter(e => e.id !== record.id))
} }
return { records, recordsGetter, loadRecords, addRecord, updateRecord, removeRecord } return { records, loadRecords, addRecord, updateRecord, removeRecord }
}) }

View File

@ -1,3 +1,4 @@
.n-card { .domain-info {
width: 32vw; width: 32vw;
text-align: left;
} }

View File

@ -1,87 +1,102 @@
import './DomainsView.css' import './DomainsView.css'
import { Domain, useDomainStore } from '../stores/domains'
import { useEffect, useState } from 'react'
import { App, Button, Card, Space, Spin } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { NSpin, NFlex, NCard, NButton, NIcon, NModalProvider, createDiscreteApi } from 'naive-ui' import { useNavigate } from 'react-router-dom'
import { PlusSquare } from "@vicons/fa" import DomainDeleteModal from '../components/domains/DomainDeleteModal'
import { type Domain, useDomainStore } from '@/stores/domains' import DomainCard from '../components/domains/DomainCard'
import { getErrorInfo } from '@/apis/api' import DomainEditModal from '../components/domains/DomainEditModal'
import DomainInfo from '@/components/domains/DomainInfo' import { ResponseError, getErrorInfo } from '../api'
import DomainOps from '@/components/domains/DomainOps'
import DomainRemoveModal from '@/components/domains/DomainRemoveModal'
import DomainEditModal from '@/components/domains/DomainEditModal'
import { ref } from 'vue'
const emptyDomain: Domain = { domain_name: '' }
export default function DomainsView() {
const [loading, setLoading] = useState(true)
const [deleteModalShow, setDeleteModalShow] = useState(false)
const [editModalShow, setEditModalShow] = useState(false)
const [currentDomain, setCurrentDomain] = useState(emptyDomain)
const { notification } = App.useApp()
const domainStore = useDomainStore() const domainStore = useDomainStore()
const { notification } = createDiscreteApi(['notification']) const go = useNavigate()
const loading = ref(true); function openDeleteModal(domain: Domain) {
const removeModalShow = ref(false); setCurrentDomain(domain)
const editModalShow = ref(false); setDeleteModalShow(true)
const operationDomain = ref({} as Domain)
function showRemoveModal(domain: Domain) {
operationDomain.value = domain
removeModalShow.value = true
} }
function showEditModal(domain: Domain) { function closeDeleteModdal() {
operationDomain.value = domain setDeleteModalShow(false)
editModalShow.value = true
} }
function addDomain() { function openEditModal(domain: Domain) {
const domain = { setCurrentDomain(domain)
setEditModalShow(true)
}
function closeEditModal() {
setEditModalShow(false)
}
function newDomain() {
openEditModal({
domain_name: '',
admin_email: '',
main_dns: '',
refresh_interval: 86400, refresh_interval: 86400,
retry_interval: 7200, retry_interval: 7200,
expiry_period: 3600000, expiry_period: 3600000,
negative_ttl: 86400, negative_ttl: 86400,
serial_number: 1, serial_number: 1,
} as Domain })
showEditModal(domain)
} }
function DomainsView() { // called once only.
try { useEffect(() => {
domainStore.loadDomains() domainStore.loadDomains().then(() => setLoading(false)).catch(e => {
loading.value = false const msg = getErrorInfo(e as ResponseError)
} catch (e) {
const msg = getErrorInfo(e)
notification.error(msg) notification.error(msg)
console.error(e) console.error(e)
} })
}, [])
return ( return (
<> <>
{ {
loading.value ? <NSpin size="large" /> : loading ? <Spin size='large' /> :
<NModalProvider> <>
<NFlex vertical> <Space direction="vertical" >
{ {
domainStore.domains.map((domain: Domain) => ( domainStore.domains.map(e => (
<NCard title={domain.domain_name} key={domain.id} size='large' hoverable> <DomainCard domain={e}
{{ onDeleteClick={() => openDeleteModal(e)}
default: () => <DomainInfo domain={domain} />, onRecordClick={() => go(`/records/${e.domain_name}`)}
action: () => <DomainOps domain={domain} onRemoveDomain={showRemoveModal} onEditDomain={showEditModal} /> onEditClick={() => openEditModal(e)}
}} key={e.id}
/>
</NCard>
)) ))
} }
<Card>
<NCard hoverable> <Button icon={<PlusOutlined className='icon' />}
<NButton block quaternary size="large" onClick={addDomain}> block type="text" onClick={newDomain} />
{{ </Card>
icon: () => <NIcon component={PlusSquare} depth={5} /> </Space>
}} <DomainDeleteModal open={deleteModalShow}
</NButton> onCancel={closeDeleteModdal}
</NCard> onOk={closeDeleteModdal}
</NFlex> domain={currentDomain}
<DomainRemoveModal show={removeModalShow.value} domain={operationDomain.value} onUpdate:show={(v: boolean) => removeModalShow.value = v} /> removeDomain={domainStore.removeDomain}
<DomainEditModal show={editModalShow.value} domain={operationDomain.value} onUpdate:show={(v: boolean) => editModalShow.value = v} /> />
</NModalProvider> <DomainEditModal open={editModalShow}
onCancel={closeEditModal}
onOk={closeEditModal}
domain={currentDomain}
editDomain={domainStore.updateDomain}
createDomain={domainStore.addDomain}
/>
</>
} }
</> </>
) )
} }
DomainsView.displayName = 'DomainsView'
export default DomainsView

View File

@ -1,7 +1,38 @@
div#records { .records-layout {
position: absolute; position: fixed;
display: block;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100vw;
padding: 1.5rem; height: 100vh;
text-align: left;
}
.toolbar {
padding-top: 1em;
}
.right {
position:absolute ;
right: 64px;
}
.records-layout-header {
background-color: var(--records-layout-header-bgcolor);
align-items: center;
}
.display-as-inline {
display:inline-block;
margin: 0 !important;
}
h1.title {
font-weight:normal;
font-size: 14pt;
}
h2.subtitle {
font-weight:normal;
font-size: 12pt;
} }

View File

@ -1,250 +1,119 @@
import { LeftOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons"
import { App, Button, Flex, Input, Layout, Spin, Table, Typography, theme } from "antd"
import { Params, useLoaderData, useNavigate } from "react-router-dom"
import './RecordsView.css' import './RecordsView.css'
import i18n from '../locale'
import { useEffect, useState } from "react"
import { type Record, RecordT, useRecordStore, RecordTypes } from "../stores/records"
import { ResponseError, getErrorInfo } from "../api"
import RecordOps from "../components/records/RecordOps"
import RecordEditModal from "../components/records/RecordEditModal"
import { const { t } = i18n
NSpin, NPageHeader, const emptyRecord: Record<RecordT> = {} as Record
NFlex, NButton, NIcon, NGrid, NGi,
NStatistic, NDataTable, NInput,
NModalProvider,
createDiscreteApi
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { useRecordStore, type Record, type SOARecord, RecordTypes, type ARecord } from '@/stores/records'
import { getErrorInfo } from '@/apis/api'
import { PlusSquare, RedoAlt, CheckCircle, Clock, Cogs, Search } from '@vicons/fa'
import router from '@/router';
import RecordOps from '@/components/records/RecordOps'
import RecordEditModal from '@/components/records/RecordEditModal'
import i18n from '@/locale/i18n'
import { ref } from 'vue'
const { t } = i18n.global
type Props = {
domain: string
}
export default function RecordsView() {
const { domain } = useLoaderData() as Params<string>
const [loading, setLoading] = useState(true)
const [searchText, setSearchText] = useState<string>('')
const [editModalShow, setEditModalShow] = useState(false)
const [currentRecord, setCurrentRecord] = useState<Record>(emptyRecord)
const { notification } = App.useApp()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const go = useNavigate()
const recordStore = useRecordStore() const recordStore = useRecordStore()
const { notification } = createDiscreteApi(['notification'])
const editModalShow = ref(false)
const editingRecord = ref<Record>({} as Record)
const loading = ref(true);
const records = ref<Record[] | undefined>([] as Record[]);
const soa = ref<SOARecord>({} as SOARecord)
const reloadRecords = () => records.value = recordStore.records?.filter(e => e.record_type !== RecordTypes.RecordTypeSOA)
async function refreshRecords(domain: string) { useEffect(() => {
try { if (domain)
await recordStore.loadRecords(domain) recordStore.loadRecords(domain).then(() => setLoading(false)).catch(e => {
reloadRecords() const msg = getErrorInfo(e as ResponseError)
soa.value = recordStore.records?.find(e => e.record_type === RecordTypes.RecordTypeSOA)?.content as SOARecord
} catch (err) {
const msg = getErrorInfo(err)
notification.error(msg) notification.error(msg)
console.error(err) console.error(e)
} finally { })
loading.value = false; }, [domain])
}
function closeEditModal() {
setCurrentRecord(emptyRecord)
setEditModalShow(false)
} }
function goBack() { function openEditModal(record: Record) {
router.push('/domains') setCurrentRecord(record)
setEditModalShow(true)
} }
function searchRecord(value: string) { function newRecord() {
if (value.length > 0) { openEditModal({
records.value = recordStore.records?.
filter(e => e.record_type !== RecordTypes.RecordTypeSOA).
filter(e => !!~e.name.indexOf(value))
} else {
records.value = recordStore.records?.
filter(e => e.record_type !== RecordTypes.RecordTypeSOA)
}
}
async function deleteRecord(domain: string, record: Record) {
try {
await recordStore.removeRecord(domain, record)
reloadRecords()
} catch (err) {
const msg = getErrorInfo(err)
notification.error(msg)
console.error(err)
}
}
function showEditing(domain: string, record: Record) {
editModalShow.value = true
editingRecord.value = record
}
function newRecord(domain: string) {
showEditing(domain, {
zone: `${domain}.`, zone: `${domain}.`,
ttl: 500, name: '',
record_type: RecordTypes.RecordTypeA, record_type: RecordTypes.RecordTypeA,
content: { ttl: 600
ip: ''
} as ARecord
} as Record) } as Record)
} }
const generateColumns = (domain: string) => [
{
key: 'no',
title: '#',
render(_, index) {
return index + 1
}
},
{
key: 'name',
title: t("records.name"),
},
{
key: 'record_type',
title: t('records.recordType')
},
{
key: 'content',
title: t('records.content'),
render(row: Record) {
return Object.entries(row.content).map(i => i[1]).join(" ")
}
},
{
key: 'ttl',
title: 'TTL (s)'
},
{
key: '',
render(row: Record) {
return <RecordOps record={row} domain={domain} onRecordDelete={deleteRecord} onEditRecord={showEditing} />
}
}
] as DataTableColumns<Record>
const statRefresh = () => (
<NGi>
<NStatistic value={soa.value.refresh}>
{{
suffix: () => 's',
label: () => (
<>
<NIcon component={RedoAlt} style={{ transform: 'translateY(2px)' }} />
<span>{t('records.refresh')}</span>
</>
)
}}
</NStatistic>
</NGi>
)
const statRetry = () => (
<NGi>
<NStatistic value={soa.value.retry}>
{{
suffix: () => 's',
label: () => (
<>
<NIcon component={CheckCircle} style={{ transform: 'translateY(2px)' }} />
<span>{t('records.retry')}</span>
</>
)
}}
</NStatistic>
</NGi>
)
const statExpire = () => (
<NGi>
<NStatistic value={soa.value.expire}>
{{
suffix: () => 's',
label: () => (
<>
<NIcon component={Clock} style={{ transform: 'translateY(2px)' }} />
<span>{t('records.expire')}</span>
</>
)
}}
</NStatistic>
</NGi>
)
const statTTL = () => (
<NGi>
<NStatistic value={soa.value.minttl}>
{{
suffix: () => 's',
label: () => (
<>
<NIcon component={Cogs} style={{ transform: 'translateY(2px)' }} />
<span>{t('records.ttl')}</span>
</>
)
}}
</NStatistic>
</NGi>
)
function recordsViewBodyHeaderExtra() {
return ( return (
<NGrid cols={4} > <>
<statRefresh /> {
<statRetry /> loading ? <Spin size='large' /> :
<statExpire /> <>
<statTTL /> <Layout className="records-layout">
</NGrid> <Layout.Header className="records-layout-header">
) <Flex align='center' className="toolbar">
<Flex align="center" gap='small'>
<Button onClick={() => go('/domains')} type="text" icon={<LeftOutlined />} />
<Typography.Title level={1} className="display-as-inline title">{t('domains.dnsRecord')}</Typography.Title>
<Typography.Title level={2} type='secondary' className="display-as-inline subtitle">{domain}</Typography.Title>
</Flex>
<Flex align="center" gap='small' className="right">
<Button type="primary" icon={<PlusOutlined />} onClick={newRecord}>
{t('common.add')}
</Button>
<Input prefix={<SearchOutlined />} placeholder={t('records.search')} onChange={v => setSearchText(v.target.value)} />
</Flex>
</Flex>
</Layout.Header>
<Layout.Content style={{
margin: 24,
borderRadius: borderRadiusLG,
minHeight: 480,
background: colorBgContainer,
}}>
<Table<Record<RecordT>>
dataSource={recordStore.records
.filter(i => i.record_type !== RecordTypes.RecordTypeSOA)
.filter(i => i.name?.includes(searchText) || Object.entries(i.content as RecordT).map(i => i[1]).join(" ").includes(searchText))
} }
pagination={{ defaultPageSize: 20 }}
function recordsViewBody({ domain }: Props) { rowKey={e => `${e.id}`}
const columns = generateColumns(domain) >
return ( <Table.Column<Record<RecordT>> title='#' render={(_v, _r, index) => index + 1} />
<NModalProvider> <Table.Column<Record<RecordT>> title={t("records.name")} dataIndex='name' key='name' />
<NPageHeader title={t('domains.dnsRecord')} subtitle={domain} onBack={goBack}> <Table.Column<Record<RecordT>> title={t('records.recordType')} dataIndex='record_type' key='record_type' />
{{ <Table.Column<Record<RecordT>> title={t('records.content')} render={(v) => Object.entries(v.content).map(i => i[1]).join(" ")} />
extra: () => ( <Table.Column<Record<RecordT>> title='TTL' key='ttl' dataIndex='ttl' />
<NFlex wrap={false} justify="end" inline> <Table.Column<Record<RecordT>> key='op' render={(v: Record) =>
<NButton type="primary" onClick={() => newRecord(domain)}> <RecordOps
{{ onDelete={() => {
icon: () => <NIcon component={PlusSquare} />, if (domain)
default: () => t('common.add') recordStore.removeRecord(domain, v).catch(e => {
}} const msg = getErrorInfo(e as ResponseError)
</NButton>
<NInput placeholder={t('records.search')} onUpdate:value={searchRecord} clearable>
{{
prefix: () => <NIcon component={Search} />
}}
</NInput>
</NFlex>
),
default: () => <recordsViewBodyHeaderExtra />
}}
</NPageHeader>
<br />
<NDataTable data={records.value} columns={columns} pagination={{ pageSize: 20 }} />
</NModalProvider>
)
}
function RecordsView({ domain }: Props) {
try {
refreshRecords(domain)
} catch (err) {
const msg = getErrorInfo(err)
notification.error(msg) notification.error(msg)
console.error(err) console.error(e)
})
}} onEdit={() => openEditModal(v)}
/>
} />
</Table>
</Layout.Content>
</Layout>
<RecordEditModal open={editModalShow} onCancel={closeEditModal}
onOk={closeEditModal} record={currentRecord}
editRecord={v => recordStore.updateRecord(domain!, v)}
createRecord={v => recordStore.addRecord(domain!, v)} />
</>
} }
return ( </>
<div id='records'>
<RecordEditModal show={editModalShow.value} domain={domain} record={editingRecord.value} onReloadRecords={reloadRecords} onUpdate:show={(v: boolean) => editModalShow.value = v} />
{
loading.value ? <NSpin size='large' /> : <recordsViewBody domain={domain} />
}
</div>
) )
} }
RecordsView.displayName = 'RecordsView'
export default RecordsView

View File

@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -1,16 +1,25 @@
{ {
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
],
"compilerOptions": { "compilerOptions": {
"jsx": "preserve", "target": "ES2020",
"jsxFactory": "h", "useDefineForClassFields": true,
"jsxFragmentFactory": "Fragment", "lib": ["ES2020", "DOM", "DOM.Iterable"],
} "module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@ -1,19 +1,11 @@
{ {
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"noEmit": true, "skipLibCheck": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "bundler",
"types": ["node"] "allowSyntheticDefaultImports": true,
} "strict": true
},
"include": ["vite.config.ts"]
} }

View File

@ -1,18 +1,7 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import react from '@vitejs/plugin-react-swc'
import vueJsx from '@vitejs/plugin-vue-jsx'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react()],
vue(),
vueJsx(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
}) })