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:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
@@ -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>
+61
View File
@@ -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>
+72
View File
@@ -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>
+28
View File
@@ -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>
+107
View File
@@ -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>
+93
View File
@@ -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>
+120
View File
@@ -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>
+181
View File
@@ -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>
+52
View File
@@ -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>
+307
View File
@@ -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>
+124
View File
@@ -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>
+87
View File
@@ -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>
+255
View File
@@ -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>