chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label configured-secret-label">
|
||||
<span>{{ label }}</span>
|
||||
<n-tag v-if="configured" size="small" type="success" :bordered="false">{{ CONFIGURED_SECRET_TAG }}</n-tag>
|
||||
</div>
|
||||
<n-input
|
||||
:value="modelValue"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="configured ? CONFIGURED_SECRET_PLACEHOLDER : placeholder"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NInput, NTag } from 'naive-ui'
|
||||
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: string
|
||||
configured?: boolean
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
configured: false,
|
||||
placeholder: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.configured-secret-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">{{ icon }}</div>
|
||||
<h3 class="empty-state__title">{{ title }}</h3>
|
||||
<p class="empty-state__desc">{{ description }}</p>
|
||||
<div v-if="$slots.action" class="empty-state__action">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
}>(),
|
||||
{ icon: '○' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
padding: var(--fonu-space-6) var(--fonu-space-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto var(--fonu-space-4);
|
||||
border-radius: 50%;
|
||||
background: var(--fonu-brand-soft);
|
||||
color: var(--fonu-brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
margin: 0 0 var(--fonu-space-2);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.empty-state__desc {
|
||||
margin: 0 auto;
|
||||
max-width: 360px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state__action {
|
||||
margin-top: var(--fonu-space-5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="fonu-card" :class="{ 'fonu-card--flush': flush }">
|
||||
<div v-if="title || $slots.title || $slots.header" class="fonu-card__header">
|
||||
<div>
|
||||
<h3 v-if="title" class="fonu-card__title">{{ title }}</h3>
|
||||
<div v-else-if="$slots.title" class="fonu-card__title fonu-card__title-row">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<p v-if="subtitle" class="fonu-card__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.header" class="fonu-card__header-extra">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fonu-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
flush?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fonu-card {
|
||||
background: var(--fonu-surface);
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius);
|
||||
box-shadow: var(--fonu-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fonu-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-4);
|
||||
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
|
||||
}
|
||||
|
||||
.fonu-card__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.fonu-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-2);
|
||||
}
|
||||
|
||||
.fonu-card__subtitle {
|
||||
margin: var(--fonu-space-1) 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.fonu-card__body {
|
||||
padding: var(--fonu-space-5);
|
||||
}
|
||||
|
||||
.fonu-card--flush .fonu-card__body {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="load-error">
|
||||
<n-result status="error" title="加载失败" :description="message">
|
||||
<template #footer>
|
||||
<n-button type="primary" @click="$emit('retry')">重新加载</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NButton, NResult } from 'naive-ui'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>(),
|
||||
{ message: '请检查 Fonu 服务是否正常运行' },
|
||||
)
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.load-error {
|
||||
padding: var(--fonu-space-6) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<VChart class="chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { BarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
values: number[]
|
||||
labels?: string[]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const option = computed(() => {
|
||||
const labels = props.labels ?? ['00', '04', '08', '12', '16', '20']
|
||||
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
|
||||
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
|
||||
|
||||
return {
|
||||
grid: { left: 8, right: 8, top: 12, bottom: 28, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
return `${p.name}<br/>请求数 ${p.value}`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: textColor,
|
||||
fontSize: 10,
|
||||
interval: 0,
|
||||
hideOverlap: false,
|
||||
showMinLabel: true,
|
||||
showMaxLabel: true,
|
||||
rotate: labels.length > 8 ? 30 : 0,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: props.values,
|
||||
barMaxWidth: 28,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#6ee7b7' },
|
||||
{ offset: 1, color: '#10b981' },
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#a7f3d0' },
|
||||
{ offset: 1, color: '#059669' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<VChart class="chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
use([CanvasRenderer, PieChart, LegendComponent, TitleComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
segments: { label: string; value: number; color: string }[]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const option = computed(() => {
|
||||
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
|
||||
const titleColor = isDark.value ? '#f1f5f9' : '#111827'
|
||||
const total = props.segments.reduce((sum, s) => sum + s.value, 0)
|
||||
|
||||
return {
|
||||
title: [
|
||||
{
|
||||
text: String(total),
|
||||
left: '31%',
|
||||
top: '38%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 22, fontWeight: 700, color: titleColor },
|
||||
},
|
||||
{
|
||||
text: '总请求',
|
||||
left: '31%',
|
||||
top: '52%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 12, color: textColor },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 0,
|
||||
top: 'center',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 12,
|
||||
textStyle: { color: textColor, fontSize: 13 },
|
||||
formatter: (name: string) => {
|
||||
const seg = props.segments.find((s) => s.label === name)
|
||||
const total = props.segments.reduce((sum, s) => sum + s.value, 0) || 1
|
||||
const pct = seg ? Math.round((seg.value / total) * 100) : 0
|
||||
return `${name} ${pct}% (${seg?.value ?? 0})`
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['52%', '72%'],
|
||||
center: ['32%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
scale: true,
|
||||
scaleSize: 4,
|
||||
},
|
||||
data: props.segments.map((s) => ({
|
||||
name: s.label,
|
||||
value: s.value,
|
||||
itemStyle: { color: s.color },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<VChart class="chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { formatRate } from '../utils/format'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[]
|
||||
upload: number[]
|
||||
download: number[]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
function axisRate(value: number): string {
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(0)} MB/s`
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(0)} KB/s`
|
||||
return `${Math.round(value)} B/s`
|
||||
}
|
||||
|
||||
const option = computed(() => {
|
||||
const textColor = isDark.value ? '#94a3b8' : '#9ca3af'
|
||||
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
|
||||
|
||||
return {
|
||||
grid: { left: 4, right: 8, top: 8, bottom: 4, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
formatter: (params: { seriesName: string; value: number; axisValue: string }[]) => {
|
||||
const time = params[0]?.axisValue ?? ''
|
||||
const lines = params.map((p) => `${p.seriesName} ${formatRate(p.value)}`)
|
||||
return `${time}<br/>${lines.join('<br/>')}`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
splitNumber: 3,
|
||||
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
|
||||
axisLabel: {
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => axisRate(v),
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '上传',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
data: props.upload,
|
||||
lineStyle: { width: 2, color: '#10b981' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(16, 185, 129, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(16, 185, 129, 0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '下载',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
data: props.download,
|
||||
lineStyle: { width: 2, color: '#3b82f6' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div
|
||||
class="nginx-code-editor"
|
||||
:class="{
|
||||
'nginx-code-editor--readonly': readonly,
|
||||
'nginx-code-editor--embedded': embedded,
|
||||
}"
|
||||
>
|
||||
<Codemirror
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:style="editorStyle"
|
||||
:autofocus="false"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="4"
|
||||
:extensions="extensions"
|
||||
@update:model-value="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Codemirror } from 'vue-codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
|
||||
import { StreamLanguage, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
embedded?: boolean
|
||||
placeholder?: string
|
||||
minHeight?: string
|
||||
}>(),
|
||||
{
|
||||
readonly: false,
|
||||
embedded: false,
|
||||
placeholder: '',
|
||||
minHeight: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const nginxLanguage = StreamLanguage.define(nginx)
|
||||
|
||||
const lightTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
backgroundColor: 'var(--fonu-surface)',
|
||||
color: 'var(--fonu-text)',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--fonu-brand)',
|
||||
fontFamily: 'var(--fonu-mono)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.7',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--fonu-bg-muted)',
|
||||
color: 'var(--fonu-text-muted)',
|
||||
borderRight: '1px solid var(--fonu-border)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--fonu-bg)',
|
||||
color: 'var(--fonu-text-secondary)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--fonu-brand-soft)',
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--fonu-brand) 22%, transparent) !important',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--fonu-brand)',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
{ dark: false },
|
||||
)
|
||||
|
||||
const editorStyle = computed(() => {
|
||||
if (props.embedded) {
|
||||
return { height: '100%' }
|
||||
}
|
||||
if (props.minHeight) {
|
||||
return { height: '100%', minHeight: props.minHeight }
|
||||
}
|
||||
return { height: '100%', minHeight: '240px' }
|
||||
})
|
||||
|
||||
const extensions = computed(() => {
|
||||
const base = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
nginxLanguage,
|
||||
EditorView.lineWrapping,
|
||||
]
|
||||
if (isDark.value) {
|
||||
base.push(oneDark)
|
||||
} else {
|
||||
base.push(lightTheme, syntaxHighlighting(defaultHighlightStyle, { fallback: true }))
|
||||
}
|
||||
if (props.readonly) {
|
||||
base.push(EditorState.readOnly.of(true), EditorView.editable.of(false))
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
function onUpdate(value: string) {
|
||||
if (!props.readonly) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nginx-code-editor {
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--fonu-surface);
|
||||
}
|
||||
|
||||
.nginx-code-editor--embedded {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 35%, transparent) transparent;
|
||||
}
|
||||
|
||||
.nginx-code-editor:hover :deep(.cm-scroller) {
|
||||
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 65%, transparent) var(--fonu-bg-muted);
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--fonu-text-muted) 30%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nginx-code-editor:hover :deep(.cm-scroller)::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--fonu-text-muted) 55%, transparent);
|
||||
}
|
||||
|
||||
.nginx-code-editor--readonly :deep(.cm-cursor) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nginx-code-editor--readonly :deep(.cm-content) {
|
||||
caret-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-header__main">
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
<p v-if="description" class="page-header__desc">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-header__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-4);
|
||||
margin-bottom: var(--fonu-space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--fonu-text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-header__desc {
|
||||
margin: var(--fonu-space-2) 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--fonu-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="proxy-log-box"
|
||||
:class="{
|
||||
'proxy-log-box--embedded': embedded,
|
||||
'proxy-log-box--fullscreen': fullscreen,
|
||||
'is-empty': parsedLines.length === 0,
|
||||
}"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<template v-for="(item, i) in parsedLines" :key="i">
|
||||
<div
|
||||
v-if="item.parsed"
|
||||
class="proxy-log-entry"
|
||||
:class="logEntryStatusClass(item.parsed.status)"
|
||||
>
|
||||
<time class="proxy-log-entry__time" :title="item.parsed.time">{{ formatLogTime(item.parsed.time) }}</time>
|
||||
<span class="proxy-log-entry__method" :class="`is-${item.parsed.method.toLowerCase()}`">
|
||||
{{ item.parsed.method }}
|
||||
</span>
|
||||
<span class="proxy-log-entry__path" :title="pathTitle(item.parsed)">{{ item.parsed.path }}</span>
|
||||
<span class="proxy-log-entry__status">{{ item.parsed.status }}</span>
|
||||
<span class="proxy-log-entry__ms">{{ item.parsed.ms }}ms</span>
|
||||
<span class="proxy-log-entry__client mono">{{ item.parsed.client }}</span>
|
||||
<span v-if="!hideUpstream" class="proxy-log-entry__upstream mono" :title="item.parsed.upstream">
|
||||
→ {{ formatUpstream(item.parsed.upstream) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="proxy-log-line proxy-log-line--raw">{{ item.raw }}</div>
|
||||
</template>
|
||||
<div v-if="parsedLines.length === 0" class="proxy-log-empty">暂无记录,通过反代域名访问后会显示在这里。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type AccessLogEntry = {
|
||||
time: string
|
||||
host: string
|
||||
method: string
|
||||
path: string
|
||||
status: number
|
||||
ms: string
|
||||
client: string
|
||||
upstream: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
lines: string[]
|
||||
embedded?: boolean
|
||||
fullscreen?: boolean
|
||||
hideUpstream?: boolean
|
||||
}>(),
|
||||
{
|
||||
hideUpstream: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
scroll: [el: HTMLElement]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const accessLineReWithPort =
|
||||
/^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)(?:\s+(\d+)\s+(\d+))?$/
|
||||
const accessLineReLegacy =
|
||||
/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)(?:\s+(\d+)\s+(\d+))?$/
|
||||
|
||||
function parseAccessLog(line: string): AccessLogEntry | null {
|
||||
let m = line.match(accessLineReWithPort)
|
||||
if (m) {
|
||||
const [, time, host, , method, path, status, rt, client, upstream] = m
|
||||
return buildEntry(time, host, method, path, status, rt, client, upstream)
|
||||
}
|
||||
m = line.match(accessLineReLegacy)
|
||||
if (!m) return null
|
||||
const [, time, host, method, path, status, rt, client, upstream] = m
|
||||
return buildEntry(time, host, method, path, status, rt, client, upstream)
|
||||
}
|
||||
|
||||
function buildEntry(
|
||||
time: string,
|
||||
host: string,
|
||||
method: string,
|
||||
path: string,
|
||||
status: string,
|
||||
rt: string,
|
||||
client: string,
|
||||
upstream: string,
|
||||
): AccessLogEntry {
|
||||
return {
|
||||
time: time.replace('T', ' ').replace(/([+-]\d{2}:\d{2}|Z)$/, ''),
|
||||
host,
|
||||
method,
|
||||
path,
|
||||
status: Number(status),
|
||||
ms: (parseFloat(rt) * 1000).toFixed(1),
|
||||
client,
|
||||
upstream,
|
||||
}
|
||||
}
|
||||
|
||||
const parsedLines = computed(() =>
|
||||
props.lines.map((raw) => ({ raw, parsed: parseAccessLog(raw) })),
|
||||
)
|
||||
|
||||
function formatLogTime(raw: string): string {
|
||||
const normalized = raw.replace('T', ' ')
|
||||
const space = normalized.indexOf(' ')
|
||||
if (space >= 0 && space < normalized.length - 1) {
|
||||
return normalized.slice(space + 1).replace(/([+-]\d{2}:\d{2}|Z)$/, '')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function formatUpstream(upstream: string): string {
|
||||
if (!upstream || upstream === '-') return '-'
|
||||
return upstream
|
||||
}
|
||||
|
||||
function pathTitle(entry: AccessLogEntry): string {
|
||||
if (entry.host) return `${entry.host}${entry.path}`
|
||||
return entry.path
|
||||
}
|
||||
|
||||
function logEntryStatusClass(status: number): string {
|
||||
if (status >= 500) return 'is-error'
|
||||
if (status >= 400) return 'is-warn'
|
||||
return ''
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (root.value) emit('scroll', root.value)
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (root.value) root.value.scrollTop = root.value.scrollHeight
|
||||
}
|
||||
|
||||
defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxy-log-box {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: var(--fonu-space-3);
|
||||
font-family: var(--fonu-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
border-radius: var(--fonu-radius-sm);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.2) transparent;
|
||||
}
|
||||
|
||||
.proxy-log-box:hover {
|
||||
scrollbar-color: rgba(148, 163, 184, 0.55) rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.proxy-log-box::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.proxy-log-box::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.proxy-log-box::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.proxy-log-box:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
|
||||
.proxy-log-box--embedded {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.proxy-log-box--embedded.is-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.proxy-log-box--fullscreen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: var(--fonu-radius);
|
||||
}
|
||||
|
||||
.proxy-log-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.proxy-log-entry + .proxy-log-entry {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.proxy-log-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.proxy-log-entry__time {
|
||||
flex: 0 0 auto;
|
||||
color: #94a3b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.proxy-log-entry__method {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.proxy-log-entry__method.is-get { background: rgba(59, 130, 246, 0.18); color: #93c5fd; }
|
||||
.proxy-log-entry__method.is-post { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
|
||||
.proxy-log-entry__method.is-put,
|
||||
.proxy-log-entry__method.is-patch { background: rgba(245, 158, 11, 0.18); color: #fcd34d; }
|
||||
.proxy-log-entry__method.is-delete { background: rgba(239, 68, 68, 0.18); color: #fca5a5; }
|
||||
|
||||
.proxy-log-entry__path {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.proxy-log-entry__status {
|
||||
flex: 0 0 auto;
|
||||
min-width: 28px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.proxy-log-entry.is-warn .proxy-log-entry__status { color: #fbbf24; }
|
||||
.proxy-log-entry.is-error .proxy-log-entry__status { color: #f87171; }
|
||||
|
||||
.proxy-log-entry__ms {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
color: #94a3b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.proxy-log-entry__client {
|
||||
flex: 0 0 auto;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.proxy-log-entry__upstream {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.proxy-log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 6px 10px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.proxy-log-line--raw + .proxy-log-entry,
|
||||
.proxy-log-entry + .proxy-log-line--raw {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.proxy-log-empty {
|
||||
padding: var(--fonu-space-5);
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--fonu-mono); }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__header">
|
||||
<div class="stat-card__title-row">
|
||||
<div v-if="$slots.icon" class="stat-card__icon" :class="`stat-card__icon--${tone}`">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<span class="stat-card__label">{{ label }}</span>
|
||||
</div>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
<div v-if="$slots.value" class="stat-card__value-slot">
|
||||
<slot name="value" />
|
||||
</div>
|
||||
<div v-else-if="value !== undefined && value !== ''" class="stat-card__value" :class="{ 'stat-card__value--sm': valueSmall }">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div v-if="sub" class="stat-card__sub">{{ sub }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value?: string | number
|
||||
sub?: string
|
||||
tone?: 'brand' | 'blue' | 'green' | 'teal'
|
||||
valueSmall?: boolean
|
||||
}>(),
|
||||
{ tone: 'brand', valueSmall: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: var(--fonu-surface);
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius);
|
||||
box-shadow: var(--fonu-shadow);
|
||||
padding: var(--fonu-space-5);
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--fonu-shadow-md);
|
||||
}
|
||||
|
||||
.stat-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-2);
|
||||
margin-bottom: var(--fonu-space-3);
|
||||
}
|
||||
|
||||
.stat-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-3);
|
||||
}
|
||||
|
||||
.stat-card__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-card__icon--brand {
|
||||
background: var(--fonu-brand-soft);
|
||||
color: var(--fonu-brand);
|
||||
}
|
||||
|
||||
.stat-card__icon--blue {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-card__icon--green {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-card__icon--teal {
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.stat-card__value,
|
||||
.stat-card__value-slot {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: var(--fonu-text);
|
||||
word-break: break-all;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-card__value--sm {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stat-card__sub {
|
||||
margin-top: var(--fonu-space-2);
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<span class="status-badge" :class="[kind, { pill }]">
|
||||
<span class="dot" />
|
||||
<span class="label">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { statusKind, statusLabel, type StatusKind } from '../utils/status'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value?: string
|
||||
text?: string
|
||||
kind?: StatusKind
|
||||
pill?: boolean
|
||||
}>(),
|
||||
{ pill: true },
|
||||
)
|
||||
|
||||
const kind = computed(() => props.kind ?? statusKind(props.value))
|
||||
const label = computed(() => props.text ?? statusLabel(props.value))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-badge.pill {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--fonu-bg);
|
||||
}
|
||||
|
||||
.status-badge.pill.success {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--fonu-success);
|
||||
}
|
||||
|
||||
.status-badge.pill.warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: var(--fonu-warning);
|
||||
}
|
||||
|
||||
.status-badge.pill.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--fonu-error);
|
||||
}
|
||||
|
||||
.status-badge.pill.disabled,
|
||||
.status-badge.pill.unknown {
|
||||
background: var(--fonu-bg-muted);
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success .dot { background: var(--fonu-success); }
|
||||
.warning .dot { background: var(--fonu-warning); }
|
||||
.error .dot { background: var(--fonu-error); }
|
||||
.disabled .dot { background: var(--fonu-disabled); }
|
||||
.unknown .dot { background: var(--fonu-text-muted); }
|
||||
|
||||
.success .label { color: inherit; }
|
||||
.warning .label { color: inherit; }
|
||||
.error .label { color: inherit; }
|
||||
.disabled .label { color: inherit; }
|
||||
.unknown .label { color: inherit; }
|
||||
|
||||
.status-badge:not(.pill) .success .label { color: var(--fonu-success); }
|
||||
.status-badge:not(.pill) .warning .label { color: var(--fonu-warning); }
|
||||
.status-badge:not(.pill) .error .label { color: var(--fonu-error); }
|
||||
.status-badge:not(.pill) .disabled .label,
|
||||
.status-badge:not(.pill) .unknown .label { color: var(--fonu-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="provider-form">
|
||||
<div class="provider-form__head">
|
||||
<span class="provider-form__title">{{ editing ? '编辑服务商' : '添加服务商' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="provider-form__field">
|
||||
<label class="provider-form__label">DNS 服务商</label>
|
||||
<div class="provider-select">
|
||||
<span class="provider-logo provider-logo--md">
|
||||
<img :src="providerIcon(form.provider)" :alt="providerLabel(form.provider)" />
|
||||
</span>
|
||||
<n-select
|
||||
v-model:value="form.provider"
|
||||
:options="providerOptions"
|
||||
:disabled="!!editing"
|
||||
size="small"
|
||||
class="provider-select__input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider === 'dnspod'" class="provider-form__field">
|
||||
<label class="provider-form__label">Token ID</label>
|
||||
<n-input v-model:value="form.api_token_id" placeholder="DNSPod ID" size="small" />
|
||||
</div>
|
||||
|
||||
<div class="provider-form__field">
|
||||
<label class="provider-form__label provider-secret-label">
|
||||
<span>{{ credentialLabel }}</span>
|
||||
<n-tag v-if="editing?.has_token" size="small" type="success" :bordered="false">
|
||||
{{ CONFIGURED_SECRET_TAG }}
|
||||
</n-tag>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="form.api_token"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="credentialPlaceholder"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider === 'alidns' || form.provider === 'tencentcloud' || form.provider === 'volcengine'" class="provider-form__field">
|
||||
<label class="provider-form__label provider-secret-label">
|
||||
<span>{{ secretLabel }}</span>
|
||||
<n-tag v-if="editing?.has_token" size="small" type="success" :bordered="false">
|
||||
{{ CONFIGURED_SECRET_TAG }}
|
||||
</n-tag>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="form.api_secret"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="editing?.has_token ? CONFIGURED_SECRET_PLACEHOLDER : secretPlaceholder"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="provider-form__switches">
|
||||
<div class="settings-switch">
|
||||
<span>IPv4</span>
|
||||
<n-switch v-model:value="form.ipv4_enabled" size="small" />
|
||||
</div>
|
||||
<div class="settings-switch">
|
||||
<span>IPv6</span>
|
||||
<n-switch v-model:value="form.ipv6_enabled" size="small" />
|
||||
</div>
|
||||
<div class="settings-switch">
|
||||
<span>启用</span>
|
||||
<n-switch v-model:value="form.enabled" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-form__actions">
|
||||
<n-button size="small" quaternary @click="emit('cancel')">取消</n-button>
|
||||
<n-button size="small" :loading="testing" @click="emit('test')">测试连接</n-button>
|
||||
<n-button size="small" type="primary" :loading="saving" @click="emit('save')">保存</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NInput, NSelect, NSwitch, NTag } from 'naive-ui'
|
||||
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
|
||||
import aliyunIcon from '../assets/brand/dns/aliyun.png'
|
||||
import cloudflareIcon from '../assets/brand/dns/cloudflare.png'
|
||||
import dnspodIcon from '../assets/brand/dns/dnspod.png'
|
||||
import tencentcloudIcon from '../assets/brand/dns/tencentcloud.png'
|
||||
import volcengineIcon from '../assets/brand/dns/volcengine.png'
|
||||
import type { DDNSConfig } from '../api/types'
|
||||
|
||||
export type ProviderForm = {
|
||||
provider: string
|
||||
api_token: string
|
||||
api_token_id: string
|
||||
api_secret: string
|
||||
ipv4_enabled: boolean
|
||||
ipv6_enabled: boolean
|
||||
enabled: boolean
|
||||
custom_ipv4: string
|
||||
custom_ipv6: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
form: ProviderForm
|
||||
editing?: DDNSConfig | null
|
||||
saving?: boolean
|
||||
testing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
test: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const providerOptions = [
|
||||
{ label: 'Cloudflare', value: 'cloudflare' },
|
||||
{ label: 'DNSPod', value: 'dnspod' },
|
||||
{ label: '阿里云 DNS', value: 'alidns' },
|
||||
{ label: '腾讯云 DNS', value: 'tencentcloud' },
|
||||
{ label: '火山引擎 DNS', value: 'volcengine' },
|
||||
]
|
||||
|
||||
const providerMap: Record<string, { label: string; icon: string }> = {
|
||||
cloudflare: { label: 'Cloudflare', icon: cloudflareIcon },
|
||||
dnspod: { label: 'DNSPod', icon: dnspodIcon },
|
||||
alidns: { label: '阿里云 DNS', icon: aliyunIcon },
|
||||
tencentcloud: { label: '腾讯云 DNS', icon: tencentcloudIcon },
|
||||
volcengine: { label: '火山引擎 DNS', icon: volcengineIcon },
|
||||
}
|
||||
|
||||
const credentialLabel = computed(() => {
|
||||
if (props.form.provider === 'alidns' || props.form.provider === 'volcengine') return 'AccessKey ID'
|
||||
if (props.form.provider === 'tencentcloud') return 'SecretId'
|
||||
if (props.form.provider === 'dnspod') return 'Token'
|
||||
return 'API Token'
|
||||
})
|
||||
|
||||
const secretLabel = computed(() => {
|
||||
if (props.form.provider === 'tencentcloud') return 'SecretKey'
|
||||
return 'AccessKey Secret'
|
||||
})
|
||||
|
||||
const secretPlaceholder = computed(() => {
|
||||
if (props.form.provider === 'tencentcloud') return '腾讯云 SecretKey'
|
||||
return 'AccessKey Secret'
|
||||
})
|
||||
|
||||
const credentialPlaceholder = computed(() => {
|
||||
if (props.editing?.has_token) return CONFIGURED_SECRET_PLACEHOLDER
|
||||
if (props.form.provider === 'alidns' || props.form.provider === 'volcengine') return 'AccessKey ID'
|
||||
if (props.form.provider === 'tencentcloud') return '腾讯云 SecretId'
|
||||
if (props.form.provider === 'dnspod') return 'DNSPod Token'
|
||||
return 'Cloudflare API Token'
|
||||
})
|
||||
|
||||
function providerLabel(v: string) {
|
||||
return providerMap[v]?.label ?? v
|
||||
}
|
||||
|
||||
function providerIcon(v: string) {
|
||||
return providerMap[v]?.icon ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-form__head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.provider-form__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.provider-form__field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.provider-form__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.provider-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-select__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--fonu-border);
|
||||
}
|
||||
|
||||
.provider-logo--md {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.provider-logo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.provider-form__switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.settings-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.provider-form__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-secret-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user