modal needed for edit

This commit is contained in:
Sense T 2024-04-08 13:32:01 +08:00
parent 8c0b79066f
commit 69613f9b6e
14 changed files with 381 additions and 315 deletions

49
web/package-lock.json generated
View File

@ -11,6 +11,7 @@
"axios": "^1.6.8",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-i18n": "^9.11.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
@ -878,6 +879,38 @@
"node": ">=12"
}
},
"node_modules/@intlify/core-base": {
"version": "9.11.0",
"resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.11.0.tgz",
"integrity": "sha512-cveOqAstjLZIiyatcP/HrzrQ87cZI8ScPQna3yvoM8zjcjcIRK1MRvmxUNlPdg0rTNJMZw7rixPVM58O5aHVPA==",
"dependencies": {
"@intlify/message-compiler": "9.11.0",
"@intlify/shared": "9.11.0"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.11.0",
"resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.11.0.tgz",
"integrity": "sha512-x31Gl7cscnoI4UUY1yaIy8e7vVMVW1VVlTXZz4SIHKqoSEUkfmgqK8NAx1e7RcoHEbICR7uyCbud0ZL1s4OGXQ==",
"dependencies": {
"@intlify/shared": "9.11.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/@intlify/shared": {
"version": "9.11.0",
"resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.11.0.tgz",
"integrity": "sha512-KHSNgi7sRjmSm7aD8QH8WFt9VfKaekJuJ473opbJlkGY3EDnDUU8ikIhG8PbasQbgNvbY3m3tWNGqk2omIdwMA==",
"engines": {
"node": ">= 16"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@ -2500,6 +2533,22 @@
}
}
},
"node_modules/vue-i18n": {
"version": "9.11.0",
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.11.0.tgz",
"integrity": "sha512-vU4gY6lu8Pdfs9BgKGiDAJmFDf88cceR47KcSB0VW4xJzUrXR/7qwqM7A8dQ2nedhoIDxoOm5Ro4pFd2KvJqbA==",
"dependencies": {
"@intlify/core-base": "9.11.0",
"@intlify/shared": "9.11.0",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.0.tgz",

View File

@ -14,6 +14,7 @@
"axios": "^1.6.8",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-i18n": "^9.11.0",
"vue-router": "^4.3.0"
},
"devDependencies": {

View File

@ -8,20 +8,27 @@ import {
lightTheme,
type GlobalTheme
} from "naive-ui";
import { zhCN, dateZhCN, enUS, dateEnUS, type NLocale, type NDateLocale } from 'naive-ui'
import { RouterView } from "vue-router";
import { onMounted } from "vue";
const osThemeRef = useOsTheme()
const theme = defineModel<GlobalTheme>()
const theme = defineModel<GlobalTheme>('theme')
theme.value = osThemeRef.value === 'dark' ? darkTheme : lightTheme
const locale = defineModel<NLocale>('locale')
locale.value = navigator.language === "zh-CN" ? zhCN : enUS
const dateLocale = defineModel<NDateLocale>('dateLocale')
dateLocale.value = navigator.language === "zh-CN" ? dateZhCN : dateEnUS
onMounted(() => {
document.title = 'reCoreD-UI'
})
</script>
<template>
<NConfigProvider :theme="theme">
<NConfigProvider :theme="theme" :locale="locale" :date-locale="dateLocale">
<NGlobalStyle />
<NNotificationProvider :max="3">
<RouterView />

View File

@ -2,6 +2,8 @@ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse,
import { type Record } from '@/stores/records';
import { type Domain } from "@/stores/domains";
import i18n from "@/locale/i18n";
type Result<T> = {
success: boolean
message: string
@ -13,31 +15,30 @@ const notificationDuration = 5000
const messages = new Map<number, {
title: string, content: string, duration: number
}>(
// TODO: i18n
[
[400, {
title: "请求错误 (400)",
content: "参数提交错误",
title: i18n.global.t("api.error400.title"),
content: i18n.global.t("api.error400.content"),
duration: notificationDuration
}],
[401, {
title: "未授权 (401)",
content: "请刷新页面重新登录",
title: i18n.global.t("api.error401.title"),
content: i18n.global.t("api.error401.content"),
duration: notificationDuration
}],
[403, {
title: "拒绝访问 (403)",
content: "你没有权限!",
title: i18n.global.t("api.error403.title"),
content: i18n.global.t("api.error403.content"),
duration: notificationDuration
}],
[404, {
title: "查无此项 (404)",
content: "没有该项内容",
title: i18n.global.t("api.error404.title"),
content: i18n.global.t("api.error404.content"),
duration: notificationDuration
}],
[500, {
title: "服务器错误 (500)",
content: "请检查系统日志",
title: i18n.global.t("api.error500.title"),
content: i18n.global.t("api.error500.content"),
duration: notificationDuration
}]
]
@ -46,8 +47,8 @@ const messages = new Map<number, {
export function getErrorInfo(err: any) {
const msg = messages.get(err.response.status)
return msg? msg: {
title: "未知错误",
content: "请打开控制台了解详情",
title: i18n.global.t("api.errorUnknown.title"),
content: i18n.global.t("api.errorUnknown.content"),
duration: notificationDuration
}
}

View File

@ -5,6 +5,7 @@
margin: 0 auto;
padding: 2rem;
font-weight: normal;
display: block;
}
a,
@ -29,7 +30,6 @@ a,
#app {
display: flex;
grid-template-columns: 1fr;
padding: 2rem 2rem;
}
}
}

View File

@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@ -1,88 +0,0 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank" rel="noopener"
>Cypress Component Testing</a
>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@ -0,0 +1,37 @@
<template>
<NFlex justify="end">
<NButtonGroup>
<NTooltip>
<template #trigger>
<NButton size="tiny">
<template #icon>
<NIcon :component="EditRegular" />
</template>
</NButton>
</template>
{{ $t("common.edit") }}
</NTooltip>
<NPopconfirm>
<template #trigger>
<NButton type="error" size="tiny">
<template #icon>
<NIcon :component="TrashAlt" />
</template>
</NButton>
</template>
{{ $t("common.deleteConfirm") }}
</NPopconfirm>
</NButtonGroup>
</NFlex>
</template>
<script setup lang="ts">
import { NButton, NButtonGroup, NTooltip, NIcon, NPopconfirm, NFlex } from 'naive-ui'
import { TrashAlt, EditRegular } from '@vicons/fa'
import { useI18n } from 'vue-i18n';
import type { Record } from '@/stores/records';
const { t } = useI18n()
const props = defineProps<{
record: Record
}>();
</script>

51
web/src/locale/en-US.ts Normal file
View File

@ -0,0 +1,51 @@
export default {
common: {
delete: 'Remove',
remove: 'Remove',
deleteConfirm: 'Are you sure?',
removeConfirm: 'Are you sure?',
edit: 'Edit',
add: 'New',
new: 'New',
},
api: {
error400: {
title: 'Bad Request (400)',
content: 'Bad Parameters'
},
error401: {
title: 'Unauthorized (401)',
content: 'Refresh page and relogin'
},
error403: {
title: 'Forbbiden (403)',
content: 'Permission denied'
},
error404: {
title: 'Not Found (404)',
content: 'No such content'
},
error500: {
title: "Internal Server Error (500)",
content: "Check server log, please"
},
errorUnknown: {
title: "Unknown Error",
content: "Open console for details",
}
},
domains: {
dnsRecord: 'DNS Record'
},
records: {
name: 'Record Name',
recordType: 'Type',
content: 'Record',
search: 'Search...',
refresh: 'Refresh Interval',
retry: 'Retry Interval',
expire: 'Expiry Period',
ttl: 'Negative TTL',
}
}

23
web/src/locale/i18n.ts Normal file
View File

@ -0,0 +1,23 @@
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
}
}
})

51
web/src/locale/zh-CN.ts Normal file
View File

@ -0,0 +1,51 @@
export default {
common: {
delete: '删除',
remove: '删除',
deleteConfirm: '确定要删除吗?',
removeConfirm: '确定要删除吗?',
edit: '修改',
add: '新增',
new: '新增',
},
api: {
error400: {
title: '请求错误 (400)',
content: '参数提交错误'
},
error401: {
title: '未授权 (401)',
content: '请刷新页面重新登录'
},
error403: {
title: '拒绝访问 (403)',
content: '你没有权限!'
},
error404: {
title: '查无此项 (404)',
content: '没有该项内容'
},
error500: {
title: "服务器错误 (500)",
content: "请检查系统日志"
},
errorUnknown: {
title: "未知错误",
content: "请打开控制台了解详情",
}
},
domains: {
dnsRecord: 'DNS 记录'
},
records: {
name: '记录名',
recordType: '类型',
content: '记录值',
search: '搜索...',
refresh: '刷新时间',
retry: '重试间隔',
expire: '超期时间',
ttl: '缓存时间',
}
}

View File

@ -5,10 +5,12 @@ import { createPinia } from 'pinia'
import App from './App.vue'
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')

View File

@ -1,18 +1,58 @@
<script setup lang="ts">
<script setup lang="tsx">
import { NSpin, NPageHeader, useNotification, NFlex, NButton, NIcon, NGrid, NGi, NStatistic, NDataTable, NInput } from 'naive-ui'
import { onMounted } from 'vue'
import type { DataTableColumns, DataTableFilterState } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { useRecordStore, type Record, type SOARecord, RecordTypes } 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.vue'
import { useI18n } from 'vue-i18n';
const { t } = useI18n()
const props = defineProps<{
domain: string
}>()
const loading = defineModel<boolean>('loading', { default: true });
const records = defineModel<Record[]>('records');
const search = defineModel<string>('search', { default: '' })
const soa = defineModel<SOARecord | undefined>('soa')
const columns = defineModel<DataTableColumns<Record>>('columns')
const table = ref<any>(null)
columns.value = [
{
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 } />
}
}
]
const recordStore = useRecordStore()
const notification = useNotification()
@ -27,95 +67,107 @@ onMounted(() => {
function refreshRecords() {
recordStore.loadRecords(props.domain)
records.value = recordStore.records
soa.value = records.value?.find(e => e.record_type === RecordTypes.RecordTypeSOA)?.content as SOARecord
records.value = recordStore.records?.filter(e => e.record_type !== RecordTypes.RecordTypeSOA)
soa.value = recordStore.records?.find(e => e.record_type === RecordTypes.RecordTypeSOA)?.content as SOARecord
loading.value = false;
}
function goBack() {
router.push('/domains')
}
function searchRecord(value: string) {
if (value.length > 0) {
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)
}
}
</script>
<template>
<div id="records">
<NSpin size="large" v-if="loading" />
<NPageHeader v-else title="DNS 记录" :subtitle="domain" @back="goBack">
<template #extra>
<NFlex :wrap="false" justify="end" inline>
<NButton type="primary">
<template #icon>
<NIcon>
<PlusSquare />
</NIcon>
</template>
新增
</NButton>
<NInput v-model:value="search" placeholder="搜索...">
<template #prefix>
<NIcon :component="Search" />
</template>
</NInput>
</NFlex>
</template>
<NGrid :cols="4">
<NGi>
<NStatistic :value="soa?.refresh">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<RedoAlt />
</NIcon>
刷新时间
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic :value="soa?.retry">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<CheckCircle />
</NIcon>
重试间隔
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic :value="soa?.expire">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<Clock />
</NIcon>
超期时间
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic :value="soa?.minttl">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<Cogs />
</NIcon>
缓存时间
</template>
</NStatistic>
</NGi>
</NGrid>
</NPageHeader>
<NDataTable :data="records">
</NDataTable>
<div v-else class="content">
<NPageHeader :title="t('domains.dnsRecord')" :subtitle="domain" @back="goBack">
<template #extra>
<NFlex :wrap="false" justify="end" inline>
<NButton type="primary">
<template #icon>
<NIcon>
<PlusSquare />
</NIcon>
</template>
{{ t('common.add') }}
</NButton>
<NInput :placeholder="t('records.search')" @update:value="searchRecord" clearable>
<template #prefix>
<NIcon :component="Search" />
</template>
</NInput>
</NFlex>
</template>
<NGrid :cols="4">
<NGi>
<NStatistic :value="soa?.refresh">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<RedoAlt />
</NIcon>
{{ t('records.refresh') }}
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic :value="soa?.retry">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<CheckCircle />
</NIcon>
{{ t('records.retry') }}
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic :value="soa?.expire">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<Clock />
</NIcon>
{{ t('records.expire') }}
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic :value="soa?.minttl">
<template #suffix>
s
</template>
<template #label>
<NIcon class="icon">
<Cogs />
</NIcon>
{{ t('records.ttl') }}
</template>
</NStatistic>
</NGi>
</NGrid>
</NPageHeader>
<br />
<NDataTable :data="records" :columns="columns" ref="table" :pagination="{ pageSize: 20 }" />
</div>
</div>
</template>
@ -123,4 +175,12 @@ function goBack() {
.icon {
transform: translateY(2px);
}
</style>
div#records {
position: absolute;
top: 0;
left: 0;
width: 100vw;
padding: 1.5rem;
}
</style>