mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 13:47:15 +08:00
2260 lines
86 KiB
Vue
2260 lines
86 KiB
Vue
<script setup lang="ts">
|
||
defineOptions({
|
||
name: 'website-edit'
|
||
})
|
||
|
||
import type { MessageReactive } from 'naive-ui'
|
||
import { NButton } from 'naive-ui'
|
||
import { useGettext } from 'vue3-gettext'
|
||
import draggable from 'vuedraggable'
|
||
|
||
import cert from '@/api/panel/cert'
|
||
import home from '@/api/panel/home'
|
||
import website from '@/api/panel/website'
|
||
|
||
const { $gettext } = useGettext()
|
||
let messageReactive: MessageReactive | null = null
|
||
|
||
const current = ref('listen')
|
||
const route = useRoute()
|
||
const { id } = route.params
|
||
const { data: setting, send: fetchSetting } = useRequest(website.config(Number(id)), {
|
||
initialData: {
|
||
id: 0,
|
||
name: '',
|
||
type: 'proxy',
|
||
listens: [],
|
||
domains: [],
|
||
path: '',
|
||
root: '',
|
||
index: [],
|
||
ssl: false,
|
||
ssl_cert: '',
|
||
ssl_key: '',
|
||
hsts: false,
|
||
ocsp: false,
|
||
http_redirect: false,
|
||
ssl_protocols: [],
|
||
ssl_ciphers: '',
|
||
ssl_not_before: '',
|
||
ssl_not_after: '',
|
||
ssl_dns_names: [],
|
||
ssl_issuer: '',
|
||
ssl_ocsp_server: [],
|
||
access_log: '',
|
||
error_log: '',
|
||
php: 0,
|
||
rewrite: '',
|
||
open_basedir: false,
|
||
upstreams: [],
|
||
proxies: [],
|
||
redirects: [],
|
||
rate_limit: null,
|
||
real_ip: null,
|
||
basic_auth: {},
|
||
custom_configs: []
|
||
}
|
||
})
|
||
const { data: installedEnvironment } = useRequest(home.installedEnvironment, {
|
||
initialData: {
|
||
webserver: 'nginx',
|
||
php: [
|
||
{
|
||
label: $gettext('Not used'),
|
||
value: 0
|
||
}
|
||
],
|
||
db: [
|
||
{
|
||
label: '',
|
||
value: ''
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
// 是否为 Nginx
|
||
const isNginx = computed(() => installedEnvironment.value.webserver === 'nginx')
|
||
const certs = ref<any>([])
|
||
useRequest(cert.certs(1, 10000)).onSuccess(({ data }) => {
|
||
certs.value = data.items
|
||
})
|
||
const { data: rewrites } = useRequest(website.rewrites, {
|
||
initialData: {}
|
||
})
|
||
const rewriteOptions = computed(() => {
|
||
return Object.keys(rewrites.value).map((key) => ({
|
||
label: key,
|
||
value: key
|
||
}))
|
||
})
|
||
const rewriteValue = ref(null)
|
||
const title = computed(() => {
|
||
if (setting.value) {
|
||
return $gettext('Edit Website - %{ name }', { name: setting.value.name })
|
||
}
|
||
return $gettext('Edit Website')
|
||
})
|
||
const certOptions = computed(() => {
|
||
return certs.value.map((item: any) => ({
|
||
label: item.domains.join(', '),
|
||
value: item.id
|
||
}))
|
||
})
|
||
const selectedCert = ref(null)
|
||
|
||
const handleSave = () => {
|
||
// 如果开启了ssl但没有任何监听地址设置了ssl,则自动添加443
|
||
if (setting.value.ssl && !setting.value.listens.some((item: any) => item.args?.includes('ssl'))) {
|
||
const args = ['ssl']
|
||
if (isNginx.value) {
|
||
args.push('quic')
|
||
}
|
||
setting.value.listens.push({
|
||
address: '443',
|
||
args
|
||
})
|
||
}
|
||
// 如果关闭了ssl,自动禁用所有ssl和quic
|
||
if (!setting.value.ssl) {
|
||
setting.value.listens = setting.value.listens.filter((item: any) => item.address !== '443') // 443直接删掉
|
||
setting.value.listens.forEach((item: any) => {
|
||
item.args = []
|
||
})
|
||
}
|
||
|
||
useRequest(website.saveConfig(Number(id), setting.value)).onSuccess(() => {
|
||
fetchSetting()
|
||
window.$message.success($gettext('Saved successfully'))
|
||
})
|
||
}
|
||
|
||
const handleReset = () => {
|
||
useRequest(website.resetConfig(Number(id))).onSuccess(() => {
|
||
fetchSetting()
|
||
window.$message.success($gettext('Reset successfully'))
|
||
})
|
||
}
|
||
|
||
const handleRewrite = (value: string) => {
|
||
setting.value.rewrite = rewrites.value[value] || ''
|
||
}
|
||
|
||
const isObtainCert = ref(false)
|
||
const handleObtainCert = () => {
|
||
isObtainCert.value = true
|
||
messageReactive = window.$message.loading($gettext('Please wait...'), {
|
||
duration: 0
|
||
})
|
||
useRequest(website.obtainCert(Number(id)))
|
||
.onSuccess(() => {
|
||
fetchSetting()
|
||
window.$message.success($gettext('Issued successfully'))
|
||
})
|
||
.onComplete(() => {
|
||
isObtainCert.value = false
|
||
messageReactive?.destroy()
|
||
})
|
||
}
|
||
|
||
const handleSelectCert = (value: number) => {
|
||
const cert = certs.value.find((item: any) => item.id === value)
|
||
if (cert && cert.cert !== '' && cert.key !== '') {
|
||
setting.value.ssl_cert = cert.cert
|
||
setting.value.ssl_key = cert.key
|
||
} else {
|
||
window.$message.error($gettext('The selected certificate is invalid'))
|
||
}
|
||
}
|
||
|
||
const clearLog = async () => {
|
||
useRequest(website.clearLog(Number(id))).onSuccess(() => {
|
||
fetchSetting()
|
||
window.$message.success($gettext('Cleared successfully'))
|
||
})
|
||
}
|
||
|
||
const onCreateListen = () => {
|
||
return {
|
||
address: '',
|
||
args: []
|
||
}
|
||
}
|
||
|
||
const toggleArg = (args: string[], arg: string, checked: boolean) => {
|
||
const index = args.indexOf(arg)
|
||
if (checked && index === -1) {
|
||
args.push(arg)
|
||
} else if (!checked && index !== -1) {
|
||
args.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
const hasArg = (args: string[], arg: string) => {
|
||
return args.includes(arg)
|
||
}
|
||
|
||
// ========== 唯一 ID 生成 ==========
|
||
let idCounter = 0
|
||
const generateId = () => `_${Date.now()}_${++idCounter}`
|
||
|
||
// 确保列表项有唯一 ID
|
||
const ensureItemIds = () => {
|
||
setting.value.upstreams?.forEach((item: any) => {
|
||
if (!item._id) item._id = generateId()
|
||
})
|
||
setting.value.proxies?.forEach((item: any) => {
|
||
if (!item._id) item._id = generateId()
|
||
})
|
||
setting.value.redirects?.forEach((item: any) => {
|
||
if (!item._id) item._id = generateId()
|
||
})
|
||
setting.value.custom_configs?.forEach((item: any) => {
|
||
if (!item._id) item._id = generateId()
|
||
})
|
||
}
|
||
|
||
// 监听 setting 变化,确保所有项都有 ID
|
||
watch(
|
||
() => setting.value,
|
||
() => {
|
||
ensureItemIds()
|
||
},
|
||
{ immediate: true, deep: false }
|
||
)
|
||
|
||
// ========== Upstreams 相关 ==========
|
||
// 添加新的上游
|
||
const addUpstream = () => {
|
||
const name = `${setting.value.name.replace(/-/g, '_')}_upstream_${(setting.value.upstreams?.length || 0) + 1}`
|
||
if (!setting.value.upstreams) {
|
||
setting.value.upstreams = []
|
||
}
|
||
setting.value.upstreams.push({
|
||
_id: generateId(),
|
||
name,
|
||
servers: {},
|
||
algo: '',
|
||
keepalive: 32,
|
||
resolver: [],
|
||
resolver_timeout: 5 * 1000000000 // 5秒,以纳秒为单位
|
||
})
|
||
}
|
||
|
||
// 删除上游
|
||
const removeUpstream = (index: number) => {
|
||
if (setting.value.upstreams) {
|
||
setting.value.upstreams.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
// 为上游添加服务器
|
||
const addServerToUpstream = (index: number) => {
|
||
const upstream = setting.value.upstreams[index]
|
||
if (!upstream.servers) {
|
||
upstream.servers = {}
|
||
}
|
||
upstream.servers[`127.0.0.1:${8080 + Object.keys(upstream.servers).length}`] = ''
|
||
}
|
||
|
||
// 更新上游超时时间值
|
||
const updateUpstreamTimeoutValue = (upstream: any, value: number) => {
|
||
const parsed = parseDuration(upstream.resolver_timeout)
|
||
upstream.resolver_timeout = buildDuration(value, parsed.unit)
|
||
}
|
||
|
||
// 更新上游超时时间单位
|
||
const updateUpstreamTimeoutUnit = (upstream: any, unit: string) => {
|
||
const parsed = parseDuration(upstream.resolver_timeout)
|
||
upstream.resolver_timeout = buildDuration(parsed.value, unit)
|
||
}
|
||
|
||
// ========== Proxies 相关 ==========
|
||
// Location 匹配类型选项
|
||
const locationMatchTypes = [
|
||
{ label: $gettext('Exact Match (=)'), value: '=' },
|
||
{ label: $gettext('Priority Prefix Match (^~)'), value: '^~' },
|
||
{ label: $gettext('Prefix Match'), value: '' },
|
||
{ label: $gettext('Case-sensitive Regex (~)'), value: '~' },
|
||
{ label: $gettext('Case-insensitive Regex (~*)'), value: '~*' }
|
||
]
|
||
|
||
// 解析 location 字符串,返回匹配类型和表达式
|
||
const parseLocation = (location: string): { type: string; expression: string } => {
|
||
if (!location) return { type: '', expression: '/' }
|
||
// 精确匹配 =
|
||
if (location.startsWith('= ')) {
|
||
return { type: '=', expression: location.slice(2) }
|
||
}
|
||
// 优先前缀匹配 ^~
|
||
if (location.startsWith('^~ ')) {
|
||
return { type: '^~', expression: location.slice(3) }
|
||
}
|
||
// 不区分大小写正则 ~*
|
||
if (location.startsWith('~* ')) {
|
||
return { type: '~*', expression: location.slice(3) }
|
||
}
|
||
// 区分大小写正则 ~
|
||
if (location.startsWith('~ ')) {
|
||
return { type: '~', expression: location.slice(2) }
|
||
}
|
||
// 普通前缀匹配
|
||
return { type: '', expression: location }
|
||
}
|
||
|
||
// 组合 location 字符串
|
||
const buildLocation = (type: string, expression: string): string => {
|
||
if (!expression) expression = '/'
|
||
if (type === '') return expression
|
||
return `${type} ${expression}`
|
||
}
|
||
|
||
// 从 URL 提取主机名
|
||
const extractHostFromUrl = (url: string): string => {
|
||
try {
|
||
const urlObj = new URL(url)
|
||
return urlObj.hostname
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// 添加新的代理
|
||
const addProxy = () => {
|
||
if (!setting.value.proxies) {
|
||
setting.value.proxies = []
|
||
}
|
||
setting.value.proxies.push({
|
||
_id: generateId(),
|
||
location: '/',
|
||
pass: 'http://127.0.0.1:8080',
|
||
host: '$host',
|
||
sni: '',
|
||
cache: null, // null 表示禁用缓存
|
||
buffering: true,
|
||
resolver: [],
|
||
resolver_timeout: 5 * 1000000000, // 5秒,以纳秒为单位
|
||
headers: {},
|
||
replaces: {},
|
||
http_version: '1.1',
|
||
timeout: null,
|
||
retry: null,
|
||
client_max_body_size: 0,
|
||
ssl_backend: null,
|
||
response_headers: null,
|
||
access_control: null
|
||
})
|
||
}
|
||
|
||
// ========== 缓存配置相关 ==========
|
||
// 创建默认缓存配置
|
||
const createDefaultCacheConfig = () => ({
|
||
valid: { '200 302': '10m', '404': '10s' },
|
||
no_cache_conditions: [],
|
||
use_stale: [],
|
||
background_update: false,
|
||
lock: false,
|
||
min_uses: 0,
|
||
methods: [],
|
||
key: ''
|
||
})
|
||
|
||
// 切换缓存启用状态
|
||
const toggleProxyCache = (proxy: any, enabled: boolean) => {
|
||
if (enabled) {
|
||
proxy.cache = createDefaultCacheConfig()
|
||
} else {
|
||
proxy.cache = null
|
||
}
|
||
}
|
||
|
||
// 判断缓存是否启用
|
||
const isCacheEnabled = (proxy: any) => {
|
||
return proxy.cache !== null && proxy.cache !== undefined
|
||
}
|
||
|
||
// 添加缓存有效期规则
|
||
const addCacheValidRule = (proxy: any) => {
|
||
if (!proxy.cache) return
|
||
if (!proxy.cache.valid) proxy.cache.valid = {}
|
||
proxy.cache.valid[`any`] = '5m'
|
||
}
|
||
|
||
// 删除缓存有效期规则
|
||
const removeCacheValidRule = (proxy: any, codes: string) => {
|
||
if (proxy.cache?.valid) {
|
||
delete proxy.cache.valid[codes]
|
||
}
|
||
}
|
||
|
||
// 不缓存条件选项
|
||
const noCacheConditionOptions = [
|
||
{ label: '$cookie_nocache', value: '$cookie_nocache' },
|
||
{ label: '$arg_nocache', value: '$arg_nocache' },
|
||
{ label: '$http_pragma', value: '$http_pragma' },
|
||
{ label: '$http_authorization', value: '$http_authorization' },
|
||
{ label: '$http_cache_control', value: '$http_cache_control' }
|
||
]
|
||
|
||
// 过期缓存使用策略选项
|
||
const useStaleOptions = [
|
||
{ label: 'error', value: 'error' },
|
||
{ label: 'timeout', value: 'timeout' },
|
||
{ label: 'updating', value: 'updating' },
|
||
{ label: 'http_500', value: 'http_500' },
|
||
{ label: 'http_502', value: 'http_502' },
|
||
{ label: 'http_503', value: 'http_503' },
|
||
{ label: 'http_504', value: 'http_504' }
|
||
]
|
||
|
||
// 缓存方法选项
|
||
const cacheMethodOptions = [
|
||
{ label: 'GET', value: 'GET' },
|
||
{ label: 'HEAD', value: 'HEAD' },
|
||
{ label: 'POST', value: 'POST' }
|
||
]
|
||
|
||
// HTTP 协议版本选项
|
||
const httpVersionOptions = [
|
||
{ label: 'HTTP/1.0', value: '1.0' },
|
||
{ label: 'HTTP/1.1', value: '1.1' },
|
||
{ label: 'HTTP/2', value: '2' }
|
||
]
|
||
|
||
// 大小单位选项
|
||
const sizeUnitOptions = [
|
||
{ label: 'KB', value: 'k' },
|
||
{ label: 'MB', value: 'm' },
|
||
{ label: 'GB', value: 'g' }
|
||
]
|
||
|
||
// 从字节解析为 {value, unit} 格式
|
||
const parseSize = (bytes: number): { value: number; unit: string } => {
|
||
if (!bytes || bytes <= 0) return { value: 0, unit: 'm' }
|
||
|
||
if (bytes >= 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024) === 0) {
|
||
return { value: bytes / (1024 * 1024 * 1024), unit: 'g' }
|
||
}
|
||
if (bytes >= 1024 * 1024 && bytes % (1024 * 1024) === 0) {
|
||
return { value: bytes / (1024 * 1024), unit: 'm' }
|
||
}
|
||
if (bytes >= 1024 && bytes % 1024 === 0) {
|
||
return { value: bytes / 1024, unit: 'k' }
|
||
}
|
||
return { value: bytes, unit: '' }
|
||
}
|
||
|
||
// 将 {value, unit} 转换为字节
|
||
const buildSize = (value: number, unit: string): number => {
|
||
if (!value || value <= 0) return 0
|
||
switch (unit) {
|
||
case 'g':
|
||
return value * 1024 * 1024 * 1024
|
||
case 'm':
|
||
return value * 1024 * 1024
|
||
case 'k':
|
||
return value * 1024
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
// 重试条件选项
|
||
const retryConditionOptions = [
|
||
{ label: 'error', value: 'error' },
|
||
{ label: 'timeout', value: 'timeout' },
|
||
{ label: 'invalid_header', value: 'invalid_header' },
|
||
{ label: 'http_500', value: 'http_500' },
|
||
{ label: 'http_502', value: 'http_502' },
|
||
{ label: 'http_503', value: 'http_503' },
|
||
{ label: 'http_504', value: 'http_504' },
|
||
{ label: 'http_429', value: 'http_429' },
|
||
{ label: 'non_idempotent', value: 'non_idempotent' },
|
||
{ label: 'off', value: 'off' }
|
||
]
|
||
|
||
// 常见隐藏响应头选项
|
||
const hideHeaderOptions = [
|
||
{ label: 'X-Powered-By', value: 'X-Powered-By' },
|
||
{ label: 'Server', value: 'Server' },
|
||
{ label: 'X-AspNet-Version', value: 'X-AspNet-Version' },
|
||
{ label: 'X-AspNetMvc-Version', value: 'X-AspNetMvc-Version' },
|
||
{ label: 'X-Runtime', value: 'X-Runtime' },
|
||
{ label: 'X-Version', value: 'X-Version' }
|
||
]
|
||
|
||
// 创建默认超时配置
|
||
const createDefaultTimeoutConfig = () => ({
|
||
connect: 60 * SECOND,
|
||
read: 60 * SECOND,
|
||
send: 60 * SECOND
|
||
})
|
||
|
||
// 创建默认重试配置
|
||
const createDefaultRetryConfig = () => ({
|
||
conditions: ['error', 'timeout'],
|
||
tries: 0,
|
||
timeout: 0
|
||
})
|
||
|
||
// 创建默认 SSL 后端配置
|
||
const createDefaultSSLBackendConfig = () => ({
|
||
verify: false,
|
||
trusted_certificate: '',
|
||
verify_depth: 1
|
||
})
|
||
|
||
// 创建默认响应头配置
|
||
const createDefaultResponseHeadersConfig = () => ({
|
||
hide: [],
|
||
add: {}
|
||
})
|
||
|
||
// 创建默认访问控制配置
|
||
const createDefaultAccessControlConfig = () => ({
|
||
allow: [],
|
||
deny: []
|
||
})
|
||
|
||
// 切换超时配置启用状态
|
||
const toggleProxyTimeout = (proxy: any, enabled: boolean) => {
|
||
if (enabled) {
|
||
proxy.timeout = createDefaultTimeoutConfig()
|
||
} else {
|
||
proxy.timeout = null
|
||
}
|
||
}
|
||
|
||
// 切换重试配置启用状态
|
||
const toggleProxyRetry = (proxy: any, enabled: boolean) => {
|
||
if (enabled) {
|
||
proxy.retry = createDefaultRetryConfig()
|
||
} else {
|
||
proxy.retry = null
|
||
}
|
||
}
|
||
|
||
// 切换 SSL 后端验证启用状态
|
||
const toggleProxySSLBackend = (proxy: any, enabled: boolean) => {
|
||
if (enabled) {
|
||
proxy.ssl_backend = createDefaultSSLBackendConfig()
|
||
} else {
|
||
proxy.ssl_backend = null
|
||
}
|
||
}
|
||
|
||
// 切换响应头配置启用状态
|
||
const toggleProxyResponseHeaders = (proxy: any, enabled: boolean) => {
|
||
if (enabled) {
|
||
proxy.response_headers = createDefaultResponseHeadersConfig()
|
||
} else {
|
||
proxy.response_headers = null
|
||
}
|
||
}
|
||
|
||
// 切换访问控制启用状态
|
||
const toggleProxyAccessControl = (proxy: any, enabled: boolean) => {
|
||
if (enabled) {
|
||
proxy.access_control = createDefaultAccessControlConfig()
|
||
} else {
|
||
proxy.access_control = null
|
||
}
|
||
}
|
||
|
||
// 更新超时时间值
|
||
const updateProxyTimeoutValue = (proxy: any, field: string, value: number) => {
|
||
if (!proxy.timeout) return
|
||
const parsed = parseDuration(proxy.timeout[field])
|
||
proxy.timeout[field] = buildDuration(value, parsed.unit)
|
||
}
|
||
|
||
// 更新超时时间单位
|
||
const updateProxyTimeoutUnit = (proxy: any, field: string, unit: string) => {
|
||
if (!proxy.timeout) return
|
||
const parsed = parseDuration(proxy.timeout[field])
|
||
proxy.timeout[field] = buildDuration(parsed.value, unit)
|
||
}
|
||
|
||
// 更新请求体大小值
|
||
const updateClientMaxBodySizeValue = (proxy: any, value: number) => {
|
||
const parsed = parseSize(proxy.client_max_body_size)
|
||
proxy.client_max_body_size = buildSize(value, parsed.unit || 'm')
|
||
}
|
||
|
||
// 更新请求体大小单位
|
||
const updateClientMaxBodySizeUnit = (proxy: any, unit: string) => {
|
||
const parsed = parseSize(proxy.client_max_body_size)
|
||
proxy.client_max_body_size = buildSize(parsed.value || 0, unit)
|
||
}
|
||
|
||
// 更新重试超时值
|
||
const updateRetryTimeoutValue = (proxy: any, value: number) => {
|
||
if (!proxy.retry) return
|
||
const parsed = parseDuration(proxy.retry.timeout)
|
||
proxy.retry.timeout = buildDuration(value, parsed.unit)
|
||
}
|
||
|
||
// 更新重试超时单位
|
||
const updateRetryTimeoutUnit = (proxy: any, unit: string) => {
|
||
if (!proxy.retry) return
|
||
const parsed = parseDuration(proxy.retry.timeout)
|
||
proxy.retry.timeout = buildDuration(parsed.value, unit)
|
||
}
|
||
|
||
// 添加响应头
|
||
const addResponseHeader = (proxy: any) => {
|
||
if (!proxy.response_headers) return
|
||
if (!proxy.response_headers.add) proxy.response_headers.add = {}
|
||
proxy.response_headers.add['X-Custom-Header'] = 'value'
|
||
}
|
||
|
||
// 删除代理
|
||
const removeProxy = (index: number) => {
|
||
if (setting.value.proxies) {
|
||
setting.value.proxies.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
// 处理 Proxy Pass 变化,自动更新 Host
|
||
const handleProxyPassChange = (proxy: any, value: string) => {
|
||
proxy.pass = value
|
||
const extracted = extractHostFromUrl(value)
|
||
if (extracted !== '') {
|
||
proxy.host = extracted
|
||
} else {
|
||
proxy.host = '$host'
|
||
}
|
||
}
|
||
|
||
// 更新 Location 匹配类型
|
||
const updateLocationType = (proxy: any, type: string) => {
|
||
const parsed = parseLocation(proxy.location)
|
||
proxy.location = buildLocation(type, parsed.expression)
|
||
}
|
||
|
||
// 更新 Location 表达式
|
||
const updateLocationExpression = (proxy: any, expression: string) => {
|
||
const parsed = parseLocation(proxy.location)
|
||
proxy.location = buildLocation(parsed.type, expression)
|
||
}
|
||
|
||
// ========== 时间单位相关 ==========
|
||
// Go time.Duration 在 JSON 中以纳秒表示
|
||
const NANOSECOND = 1
|
||
const SECOND = 1000000000 * NANOSECOND
|
||
const MINUTE = 60 * SECOND
|
||
const HOUR = 60 * MINUTE
|
||
|
||
// 时间单位选项
|
||
const timeUnitOptions = [
|
||
{ label: $gettext('Seconds'), value: 's' },
|
||
{ label: $gettext('Minutes'), value: 'm' },
|
||
{ label: $gettext('Hours'), value: 'h' }
|
||
]
|
||
|
||
// 从纳秒解析为 {value, unit} 格式
|
||
const parseDuration = (ns: number): { value: number; unit: string } => {
|
||
if (!ns || ns <= 0) return { value: 5, unit: 's' }
|
||
|
||
if (ns >= HOUR && ns % HOUR === 0) {
|
||
return { value: ns / HOUR, unit: 'h' }
|
||
}
|
||
if (ns >= MINUTE && ns % MINUTE === 0) {
|
||
return { value: ns / MINUTE, unit: 'm' }
|
||
}
|
||
return { value: Math.floor(ns / SECOND), unit: 's' }
|
||
}
|
||
|
||
// 将 {value, unit} 转换为纳秒
|
||
const buildDuration = (value: number, unit: string): number => {
|
||
if (!value || value <= 0) value = 5
|
||
switch (unit) {
|
||
case 'h':
|
||
return value * HOUR
|
||
case 'm':
|
||
return value * MINUTE
|
||
default:
|
||
return value * SECOND
|
||
}
|
||
}
|
||
|
||
// 更新超时时间值
|
||
const updateTimeoutValue = (proxy: any, value: number) => {
|
||
const parsed = parseDuration(proxy.resolver_timeout)
|
||
proxy.resolver_timeout = buildDuration(value, parsed.unit)
|
||
}
|
||
|
||
// 更新超时时间单位
|
||
const updateTimeoutUnit = (proxy: any, unit: string) => {
|
||
const parsed = parseDuration(proxy.resolver_timeout)
|
||
proxy.resolver_timeout = buildDuration(parsed.value, unit)
|
||
}
|
||
|
||
// ========== 重定向相关 ==========
|
||
// 重定向类型选项
|
||
const redirectTypeOptions = [
|
||
{ label: $gettext('URL Redirect'), value: 'url' },
|
||
{ label: $gettext('Host Redirect'), value: 'host' },
|
||
{ label: $gettext('404 Redirect'), value: '404' }
|
||
]
|
||
|
||
// 状态码选项
|
||
const redirectStatusCodeOptions = [
|
||
{ label: '301 - ' + $gettext('Moved Permanently'), value: 301 },
|
||
{ label: '302 - ' + $gettext('Found'), value: 302 },
|
||
{ label: '307 - ' + $gettext('Temporary Redirect'), value: 307 },
|
||
{ label: '308 - ' + $gettext('Permanent Redirect'), value: 308 }
|
||
]
|
||
|
||
// 添加重定向规则
|
||
const addRedirect = () => {
|
||
if (!setting.value.redirects) {
|
||
setting.value.redirects = []
|
||
}
|
||
setting.value.redirects.push({
|
||
_id: generateId(),
|
||
type: 'url',
|
||
from: '/',
|
||
to: '/new',
|
||
keep_uri: true,
|
||
status_code: 308
|
||
})
|
||
}
|
||
|
||
// 删除重定向规则
|
||
const removeRedirect = (index: number) => {
|
||
if (setting.value.redirects) {
|
||
setting.value.redirects.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
// 获取重定向类型的标签
|
||
const getRedirectTypeLabel = (type: string) => {
|
||
const option = redirectTypeOptions.find((opt) => opt.value === type)
|
||
return option ? option.label : type
|
||
}
|
||
|
||
// ========== 高级设置相关(日志设置、限流限速、真实 IP、基本认证)==========
|
||
// 默认日志路径
|
||
const defaultAccessLog = computed(() => `/opt/ace/sites/${setting.value.name}/log/access.log`)
|
||
const defaultErrorLog = computed(() => `/opt/ace/sites/${setting.value.name}/log/error.log`)
|
||
|
||
// 日志路径选项
|
||
const accessLogOptions = computed(() => [
|
||
{ label: $gettext('Disabled'), value: 'off' },
|
||
{ label: $gettext('Default Path'), value: defaultAccessLog.value }
|
||
])
|
||
const errorLogOptions = computed(() => [
|
||
{ label: $gettext('Disabled'), value: 'off' },
|
||
{ label: $gettext('Default Path'), value: defaultErrorLog.value }
|
||
])
|
||
|
||
// 限流限速是否启用
|
||
const rateLimitEnabled = computed({
|
||
get: () => setting.value.rate_limit !== null,
|
||
set: (value: boolean) => {
|
||
if (value) {
|
||
setting.value.rate_limit = {
|
||
per_server: 0,
|
||
per_ip: 0,
|
||
rate: 0
|
||
}
|
||
} else {
|
||
setting.value.rate_limit = null
|
||
}
|
||
}
|
||
})
|
||
|
||
// 真实 IP 是否启用
|
||
const realIPEnabled = computed({
|
||
get: () => setting.value.real_ip !== null,
|
||
set: (value: boolean) => {
|
||
if (value) {
|
||
setting.value.real_ip = {
|
||
from: [],
|
||
header: 'X-Real-IP',
|
||
recursive: false
|
||
}
|
||
} else {
|
||
setting.value.real_ip = null
|
||
}
|
||
}
|
||
})
|
||
|
||
// 真实 IP Header 选项
|
||
const realIPHeaderOptions = [
|
||
{ label: 'X-Real-IP', value: 'X-Real-IP' },
|
||
{ label: 'X-Forwarded-For', value: 'X-Forwarded-For' },
|
||
{ label: 'CF-Connecting-IP', value: 'CF-Connecting-IP' },
|
||
{ label: 'True-Client-IP', value: 'True-Client-IP' },
|
||
{ label: 'Ali-Cdn-Real-Ip', value: 'Ali-Cdn-Real-Ip' },
|
||
{ label: 'EO-Connecting-IP', value: 'EO-Connecting-IP' }
|
||
]
|
||
|
||
// 添加基本认证用户
|
||
const addBasicAuthUser = () => {
|
||
if (!setting.value.basic_auth) {
|
||
setting.value.basic_auth = {}
|
||
}
|
||
const index = Object.keys(setting.value.basic_auth).length + 1
|
||
setting.value.basic_auth[`user${index}`] = ''
|
||
}
|
||
|
||
// 删除基本认证用户
|
||
const removeBasicAuthUser = (username: string) => {
|
||
if (setting.value.basic_auth) {
|
||
delete setting.value.basic_auth[username]
|
||
}
|
||
}
|
||
|
||
// ========== 自定义配置相关 ==========
|
||
// 作用域选项
|
||
const scopeOptions = [
|
||
{ label: $gettext('This Website'), value: 'site' },
|
||
{ label: $gettext('Global'), value: 'shared' }
|
||
]
|
||
|
||
// 添加自定义配置
|
||
const addCustomConfig = () => {
|
||
if (!setting.value.custom_configs) {
|
||
setting.value.custom_configs = []
|
||
}
|
||
const index = setting.value.custom_configs.length + 1
|
||
setting.value.custom_configs.push({
|
||
_id: generateId(),
|
||
name: `custom_${index}`,
|
||
scope: 'site',
|
||
content: ''
|
||
})
|
||
}
|
||
|
||
// 删除自定义配置
|
||
const removeCustomConfig = (index: number) => {
|
||
if (setting.value.custom_configs) {
|
||
setting.value.custom_configs.splice(index, 1)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<common-page show-footer :title="title">
|
||
<n-tabs v-model:value="current" type="line" animated>
|
||
<n-tab-pane name="listen" :tab="$gettext('Domain & Listening')">
|
||
<n-form v-if="setting">
|
||
<n-form-item :label="$gettext('Domain')">
|
||
<n-dynamic-input
|
||
v-model:value="setting.domains"
|
||
placeholder="example.com"
|
||
:min="1"
|
||
show-sort-button
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Listening Address')">
|
||
<n-dynamic-input
|
||
v-model:value="setting.listens"
|
||
show-sort-button
|
||
:on-create="onCreateListen"
|
||
>
|
||
<template #default="{ value }">
|
||
<div flex w-full items-center>
|
||
<n-input v-model:value="value.address" clearable />
|
||
<n-checkbox
|
||
:checked="hasArg(value.args, 'ssl')"
|
||
@update:checked="(checked) => toggleArg(value.args, 'ssl', checked)"
|
||
ml-20
|
||
mr-20
|
||
w-120
|
||
>
|
||
HTTPS
|
||
</n-checkbox>
|
||
<n-checkbox
|
||
v-if="isNginx"
|
||
:checked="hasArg(value.args, 'quic')"
|
||
@update:checked="(checked) => toggleArg(value.args, 'quic', checked)"
|
||
w-200
|
||
>
|
||
QUIC(HTTP3)
|
||
</n-checkbox>
|
||
</div>
|
||
</template>
|
||
</n-dynamic-input>
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-skeleton v-else text :repeat="10" />
|
||
</n-tab-pane>
|
||
<n-tab-pane name="basic" :tab="$gettext('Basic Settings')">
|
||
<n-form v-if="setting">
|
||
<n-form-item :label="$gettext('Website Directory')">
|
||
<n-input
|
||
v-model:value="setting.path"
|
||
:placeholder="$gettext('Enter website directory (absolute path)')"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Running Directory')">
|
||
<n-input
|
||
v-model:value="setting.root"
|
||
:placeholder="
|
||
$gettext('Enter running directory (needed for Laravel etc.) (absolute path)')
|
||
"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Default Document')">
|
||
<n-dynamic-tags v-model:value="setting.index" />
|
||
</n-form-item>
|
||
<n-form-item v-if="setting.type == 'php'" :label="$gettext('PHP Version')">
|
||
<n-select
|
||
v-model:value="setting.php"
|
||
:default-value="0"
|
||
:options="installedEnvironment.php"
|
||
:placeholder="$gettext('Select PHP Version')"
|
||
@keydown.enter.prevent
|
||
>
|
||
</n-select>
|
||
</n-form-item>
|
||
<n-form-item v-if="setting.type == 'php'" :label="$gettext('Anti-cross-site Attack')">
|
||
<n-switch v-model:value="setting.open_basedir" />
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-skeleton v-else text :repeat="10" />
|
||
</n-tab-pane>
|
||
<n-tab-pane v-if="setting.type === 'proxy'" name="upstreams" :tab="$gettext('Upstreams')">
|
||
<n-flex vertical>
|
||
<!-- 上游卡片列表 -->
|
||
<draggable
|
||
v-model="setting.upstreams"
|
||
item-key="_id"
|
||
handle=".drag-handle"
|
||
:animation="200"
|
||
ghost-class="ghost-card"
|
||
>
|
||
<template #item="{ element: upstream, index }">
|
||
<n-card closable @close="removeUpstream(index)" mb-16>
|
||
<template #header>
|
||
<n-flex align="center" :size="8">
|
||
<!-- 拖拽手柄 -->
|
||
<div class="drag-handle" cursor-grab>
|
||
<the-icon icon="mdi:drag" :size="20" />
|
||
</div>
|
||
<span>{{ $gettext('Upstream') }}</span>
|
||
<n-input
|
||
v-model:value="upstream.name"
|
||
:placeholder="$gettext('Upstream name')"
|
||
size="small"
|
||
style="width: 200px"
|
||
/>
|
||
</n-flex>
|
||
</template>
|
||
<n-form label-placement="left" label-width="140px">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="12" :label="$gettext('Load Balancing Algorithm')">
|
||
<n-select
|
||
v-model:value="upstream.algo"
|
||
:options="
|
||
isNginx
|
||
? [
|
||
{ label: $gettext('Round Robin (default)'), value: '' },
|
||
{ label: 'least_conn', value: 'least_conn' },
|
||
{ label: 'ip_hash', value: 'ip_hash' },
|
||
{ label: 'hash', value: 'hash' },
|
||
{ label: 'random', value: 'random' }
|
||
]
|
||
: [
|
||
{ label: $gettext('Round Robin (default)'), value: '' },
|
||
{ label: $gettext('Least Busy'), value: 'bybusyness' },
|
||
{ label: $gettext('By Traffic'), value: 'bytraffic' }
|
||
]
|
||
"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Keepalive Connections')">
|
||
<n-input-number
|
||
:value="upstream.keepalive || null"
|
||
:min="0"
|
||
:max="1000"
|
||
w-full
|
||
:placeholder="$gettext('Disabled')"
|
||
@update:value="(v: number | null) => (upstream.keepalive = v ?? 0)"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi v-if="isNginx" :span="12" :label="$gettext('DNS Resolver')">
|
||
<n-dynamic-tags
|
||
v-model:value="upstream.resolver"
|
||
:placeholder="$gettext('e.g., 8.8.8.8')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi
|
||
v-if="isNginx && upstream.resolver?.length"
|
||
:span="12"
|
||
:label="$gettext('Resolver Timeout')"
|
||
>
|
||
<n-input-group>
|
||
<n-input-number
|
||
:value="parseDuration(upstream.resolver_timeout).value"
|
||
:min="1"
|
||
:max="3600"
|
||
flex-1
|
||
@update:value="
|
||
(v: number | null) => updateUpstreamTimeoutValue(upstream, v ?? 5)
|
||
"
|
||
/>
|
||
<n-select
|
||
:value="parseDuration(upstream.resolver_timeout).unit"
|
||
:options="timeUnitOptions"
|
||
style="width: 100px"
|
||
@update:value="(v: string) => updateUpstreamTimeoutUnit(upstream, v)"
|
||
/>
|
||
</n-input-group>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
<n-form-item :label="$gettext('Backend Servers')">
|
||
<n-flex vertical :size="8" w-full>
|
||
<n-flex
|
||
v-for="(options, address) in upstream.servers"
|
||
:key="String(address)"
|
||
:size="8"
|
||
align="center"
|
||
>
|
||
<n-input
|
||
:default-value="String(address)"
|
||
:placeholder="$gettext('Server address, e.g., 127.0.0.1:8080')"
|
||
flex-1
|
||
@change="
|
||
(newAddr: string) => {
|
||
const oldAddr = String(address)
|
||
if (newAddr && newAddr !== oldAddr) {
|
||
upstream.servers[newAddr] = upstream.servers[oldAddr]
|
||
delete upstream.servers[oldAddr]
|
||
}
|
||
}
|
||
"
|
||
/>
|
||
<n-input
|
||
:value="String(options)"
|
||
:placeholder="$gettext('Options, e.g., weight=5 backup')"
|
||
flex-1
|
||
@update:value="(v: string) => (upstream.servers[String(address)] = v)"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
flex-shrink-0
|
||
@click="delete upstream.servers[String(address)]"
|
||
>
|
||
{{ $gettext('Remove') }}
|
||
</n-button>
|
||
</n-flex>
|
||
<n-button dashed size="small" @click="addServerToUpstream(index)">
|
||
{{ $gettext('Add Server') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-card>
|
||
</template>
|
||
</draggable>
|
||
|
||
<!-- 空状态 -->
|
||
<n-empty v-if="!setting.upstreams || setting.upstreams.length === 0">
|
||
{{ $gettext('No upstreams configured') }}
|
||
</n-empty>
|
||
|
||
<!-- 添加按钮 -->
|
||
<n-button type="primary" dashed @click="addUpstream" mb-20>
|
||
{{ $gettext('Add Upstream') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane v-if="setting.type === 'proxy'" name="proxies" :tab="$gettext('Proxies')">
|
||
<n-flex vertical>
|
||
<!-- 代理卡片列表 -->
|
||
<draggable
|
||
v-model="setting.proxies"
|
||
item-key="_id"
|
||
handle=".drag-handle"
|
||
:animation="200"
|
||
ghost-class="ghost-card"
|
||
>
|
||
<template #item="{ element: proxy, index }">
|
||
<n-card closable @close="removeProxy(index)" mb-16>
|
||
<template #header>
|
||
<n-flex align="center" :size="8">
|
||
<!-- 拖拽手柄 -->
|
||
<div class="drag-handle" cursor-grab>
|
||
<the-icon icon="mdi:drag" :size="20" />
|
||
</div>
|
||
<span>{{ $gettext('Rule') }} #{{ index + 1 }}</span>
|
||
<n-tag size="small">{{ proxy.location }}</n-tag>
|
||
<the-icon icon="mdi:arrow-right-bold" :size="20" />
|
||
<n-tag size="small" type="success">{{ proxy.pass }}</n-tag>
|
||
</n-flex>
|
||
</template>
|
||
<n-form label-placement="left" label-width="140px">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi v-if="isNginx" :span="12" :label="$gettext('Match Type')">
|
||
<n-select
|
||
:value="parseLocation(proxy.location).type"
|
||
:options="locationMatchTypes"
|
||
@update:value="(v: string) => updateLocationType(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="isNginx ? 12 : 24" :label="$gettext('Match Expression')">
|
||
<n-input
|
||
:value="parseLocation(proxy.location).expression"
|
||
:placeholder="$gettext('e.g., /, /api, ^/api/v[0-9]+/')"
|
||
@update:value="(v: string) => updateLocationExpression(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Proxy Pass')">
|
||
<n-input
|
||
:value="proxy.pass"
|
||
:placeholder="
|
||
$gettext(
|
||
'Backend address, e.g., http://127.0.0.1:8080 or http://upstream_name'
|
||
)
|
||
"
|
||
@update:value="(v: string) => handleProxyPassChange(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Proxy Host')">
|
||
<n-input
|
||
v-model:value="proxy.host"
|
||
:placeholder="
|
||
$gettext('Default: $proxy_host, or extracted from Proxy Pass')
|
||
"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Proxy SNI')">
|
||
<n-input
|
||
v-model:value="proxy.sni"
|
||
:placeholder="$gettext('Optional, for HTTPS backends')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="6" :label="$gettext('Enable Cache')">
|
||
<n-switch
|
||
:value="isCacheEnabled(proxy)"
|
||
@update:value="(v: boolean) => toggleProxyCache(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="6" :label="$gettext('Enable Buffering')">
|
||
<n-switch v-model:value="proxy.buffering" />
|
||
</n-form-item-gi>
|
||
<n-form-item-gi v-if="isNginx" :span="12" :label="$gettext('DNS Resolver')">
|
||
<n-dynamic-tags
|
||
v-model:value="proxy.resolver"
|
||
:placeholder="$gettext('e.g., 8.8.8.8')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi
|
||
v-if="isNginx && proxy.resolver.length"
|
||
:span="12"
|
||
:label="$gettext('Resolver Timeout')"
|
||
>
|
||
<n-input-group>
|
||
<n-input-number
|
||
:value="parseDuration(proxy.resolver_timeout).value"
|
||
:min="1"
|
||
:max="3600"
|
||
flex-1
|
||
@update:value="(v: number | null) => updateTimeoutValue(proxy, v ?? 5)"
|
||
/>
|
||
<n-select
|
||
:value="parseDuration(proxy.resolver_timeout).unit"
|
||
:options="timeUnitOptions"
|
||
style="width: 100px"
|
||
@update:value="(v: string) => updateTimeoutUnit(proxy, v)"
|
||
/>
|
||
</n-input-group>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
<!-- 可折叠配置区域 -->
|
||
<n-collapse :default-expanded-names="[]" mt-16>
|
||
<!-- 缓存配置详情 -->
|
||
<n-collapse-item
|
||
v-if="isNginx && isCacheEnabled(proxy)"
|
||
:title="$gettext('Cache Settings')"
|
||
name="cache"
|
||
>
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<!-- 缓存有效期 -->
|
||
<n-form-item-gi :span="24" :label="$gettext('Cache Valid')">
|
||
<n-flex vertical :size="8" w-full>
|
||
<n-flex
|
||
v-for="(duration, codes) in proxy.cache?.valid"
|
||
:key="String(codes)"
|
||
:size="8"
|
||
align="center"
|
||
>
|
||
<n-input
|
||
:value="String(codes)"
|
||
:placeholder="$gettext('Status codes, e.g., 200 302 or any')"
|
||
flex-1
|
||
@blur="
|
||
(e: FocusEvent) => {
|
||
const newCodes = (e.target as HTMLInputElement).value
|
||
const oldCodes = String(codes)
|
||
if (newCodes && newCodes !== oldCodes && proxy.cache?.valid) {
|
||
proxy.cache.valid[newCodes] = proxy.cache.valid[oldCodes]
|
||
delete proxy.cache.valid[oldCodes]
|
||
}
|
||
}
|
||
"
|
||
/>
|
||
<span flex-shrink-0>=</span>
|
||
<n-input
|
||
:value="String(duration)"
|
||
:placeholder="$gettext('Duration, e.g., 10m, 1h, 1d')"
|
||
style="width: 120px"
|
||
@update:value="
|
||
(v: string) => {
|
||
if (proxy.cache?.valid) proxy.cache.valid[String(codes)] = v
|
||
}
|
||
"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
flex-shrink-0
|
||
@click="removeCacheValidRule(proxy, String(codes))"
|
||
>
|
||
{{ $gettext('Remove') }}
|
||
</n-button>
|
||
</n-flex>
|
||
<n-button dashed size="small" @click="addCacheValidRule(proxy)">
|
||
{{ $gettext('Add Cache Valid Rule') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-form-item-gi>
|
||
<!-- 不缓存条件 -->
|
||
<n-form-item-gi :span="12" :label="$gettext('No Cache Conditions')">
|
||
<n-select
|
||
v-model:value="proxy.cache.no_cache_conditions"
|
||
:options="noCacheConditionOptions"
|
||
multiple
|
||
filterable
|
||
tag
|
||
:placeholder="$gettext('Select or enter conditions')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<!-- 过期缓存使用策略 -->
|
||
<n-form-item-gi :span="12" :label="$gettext('Use Stale')">
|
||
<n-select
|
||
v-model:value="proxy.cache.use_stale"
|
||
:options="useStaleOptions"
|
||
multiple
|
||
:placeholder="$gettext('When to use stale cache')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<!-- 后台更新 -->
|
||
<n-form-item-gi :span="6" :label="$gettext('Background Update')">
|
||
<n-switch v-model:value="proxy.cache.background_update" />
|
||
</n-form-item-gi>
|
||
<!-- 缓存锁 -->
|
||
<n-form-item-gi :span="6" :label="$gettext('Cache Lock')">
|
||
<n-switch v-model:value="proxy.cache.lock" />
|
||
</n-form-item-gi>
|
||
<!-- 最小请求次数 -->
|
||
<n-form-item-gi :span="6" :label="$gettext('Min Uses')">
|
||
<n-input-number
|
||
:value="proxy.cache.min_uses || null"
|
||
:min="0"
|
||
:max="100"
|
||
w-full
|
||
:placeholder="$gettext('Default')"
|
||
@update:value="(v: number | null) => (proxy.cache.min_uses = v ?? 0)"
|
||
/>
|
||
</n-form-item-gi>
|
||
<!-- 缓存方法 -->
|
||
<n-form-item-gi :span="6" :label="$gettext('Cache Methods')">
|
||
<n-select
|
||
v-model:value="proxy.cache.methods"
|
||
:options="cacheMethodOptions"
|
||
multiple
|
||
:placeholder="$gettext('Default: GET HEAD')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<!-- 自定义缓存键 -->
|
||
<n-form-item-gi :span="24" :label="$gettext('Cache Key')">
|
||
<n-input
|
||
v-model:value="proxy.cache.key"
|
||
:placeholder="
|
||
$gettext('Custom cache key, e.g., $scheme$host$request_uri')
|
||
"
|
||
/>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</n-collapse-item>
|
||
|
||
<!-- 自定义请求头 -->
|
||
<n-collapse-item :title="$gettext('Custom Request Headers')" name="headers">
|
||
<n-flex vertical :size="8">
|
||
<n-flex
|
||
v-for="(headerValue, headerName) in proxy.headers"
|
||
:key="String(headerName)"
|
||
:size="8"
|
||
align="center"
|
||
>
|
||
<n-input
|
||
:value="String(headerName)"
|
||
:placeholder="$gettext('Header name')"
|
||
flex-1
|
||
@blur="
|
||
(e: FocusEvent) => {
|
||
const newName = (e.target as HTMLInputElement).value
|
||
const oldName = String(headerName)
|
||
if (newName && newName !== oldName) {
|
||
proxy.headers[newName] = proxy.headers[oldName]
|
||
delete proxy.headers[oldName]
|
||
}
|
||
}
|
||
"
|
||
/>
|
||
<span flex-shrink-0>=</span>
|
||
<n-input
|
||
:value="String(headerValue)"
|
||
:placeholder="$gettext('Value or variable like $host, $remote_addr')"
|
||
flex-1
|
||
@update:value="(v: string) => (proxy.headers[String(headerName)] = v)"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
flex-shrink-0
|
||
@click="delete proxy.headers[String(headerName)]"
|
||
>
|
||
{{ $gettext('Remove') }}
|
||
</n-button>
|
||
</n-flex>
|
||
<n-button
|
||
dashed
|
||
size="small"
|
||
@click="
|
||
() => {
|
||
if (!proxy.headers) proxy.headers = {}
|
||
proxy.headers[`X-Custom-${Object.keys(proxy.headers).length}`] = ''
|
||
}
|
||
"
|
||
>
|
||
{{ $gettext('Add Request Header') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-collapse-item>
|
||
|
||
<!-- 响应内容替换 -->
|
||
<n-collapse-item
|
||
:title="$gettext('Response Content Replacement')"
|
||
name="replaces"
|
||
>
|
||
<n-flex vertical :size="8">
|
||
<n-flex
|
||
v-for="(toValue, fromValue) in proxy.replaces"
|
||
:key="String(fromValue)"
|
||
:size="8"
|
||
align="center"
|
||
>
|
||
<n-input
|
||
:value="String(fromValue)"
|
||
:placeholder="$gettext('Original content')"
|
||
flex-1
|
||
@blur="
|
||
(e: FocusEvent) => {
|
||
const newFrom = (e.target as HTMLInputElement).value
|
||
const oldFrom = String(fromValue)
|
||
if (newFrom && newFrom !== oldFrom) {
|
||
proxy.replaces[newFrom] = proxy.replaces[oldFrom]
|
||
delete proxy.replaces[oldFrom]
|
||
}
|
||
}
|
||
"
|
||
/>
|
||
<span flex-shrink-0>=></span>
|
||
<n-input
|
||
:value="String(toValue)"
|
||
:placeholder="$gettext('Replacement content')"
|
||
flex-1
|
||
@update:value="(v: string) => (proxy.replaces[String(fromValue)] = v)"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
flex-shrink-0
|
||
@click="delete proxy.replaces[String(fromValue)]"
|
||
>
|
||
{{ $gettext('Remove') }}
|
||
</n-button>
|
||
</n-flex>
|
||
<n-button
|
||
dashed
|
||
size="small"
|
||
@click="
|
||
() => {
|
||
if (!proxy.replaces) proxy.replaces = {}
|
||
proxy.replaces[`/old_${Object.keys(proxy.replaces).length}`] = '/new'
|
||
}
|
||
"
|
||
>
|
||
{{ $gettext('Add Replacement Rule') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-collapse-item>
|
||
|
||
<!-- 高级配置(仅 Nginx) -->
|
||
<n-collapse-item
|
||
v-if="isNginx"
|
||
:title="$gettext('Advanced Settings')"
|
||
name="advanced"
|
||
>
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<!-- HTTP 协议版本 -->
|
||
<n-form-item-gi :span="8" :label="$gettext('HTTP Version')">
|
||
<n-select
|
||
v-model:value="proxy.http_version"
|
||
:options="httpVersionOptions"
|
||
:placeholder="$gettext('Select HTTP version')"
|
||
/>
|
||
</n-form-item-gi>
|
||
|
||
<!-- 请求体大小限制 -->
|
||
<n-form-item-gi :span="8" :label="$gettext('Max Body Size')">
|
||
<n-input-group>
|
||
<n-input-number
|
||
:value="
|
||
proxy.client_max_body_size
|
||
? parseSize(proxy.client_max_body_size).value
|
||
: null
|
||
"
|
||
:min="0"
|
||
flex-1
|
||
:placeholder="$gettext('Use global')"
|
||
@update:value="
|
||
(v: number | null) => updateClientMaxBodySizeValue(proxy, v ?? 0)
|
||
"
|
||
/>
|
||
<n-select
|
||
:value="
|
||
parseSize(proxy.client_max_body_size || 1024 * 1024).unit || 'm'
|
||
"
|
||
:options="sizeUnitOptions"
|
||
style="width: 80px"
|
||
@update:value="(v: string) => updateClientMaxBodySizeUnit(proxy, v)"
|
||
/>
|
||
</n-input-group>
|
||
</n-form-item-gi>
|
||
|
||
<!-- 超时设置开关 -->
|
||
<n-form-item-gi :span="8" :label="$gettext('Timeout Settings')">
|
||
<n-switch
|
||
:value="proxy.timeout !== null"
|
||
@update:value="(v: boolean) => toggleProxyTimeout(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
|
||
<!-- 超时配置详情 -->
|
||
<template v-if="proxy.timeout">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="8" :label="$gettext('Connect Timeout')">
|
||
<n-input-group>
|
||
<n-input-number
|
||
:value="parseDuration(proxy.timeout.connect).value"
|
||
:min="1"
|
||
flex-1
|
||
@update:value="
|
||
(v: number | null) =>
|
||
updateProxyTimeoutValue(proxy, 'connect', v ?? 1)
|
||
"
|
||
/>
|
||
<n-select
|
||
:value="parseDuration(proxy.timeout.connect).unit"
|
||
:options="timeUnitOptions"
|
||
style="width: 100px"
|
||
@update:value="
|
||
(v: string) => updateProxyTimeoutUnit(proxy, 'connect', v)
|
||
"
|
||
/>
|
||
</n-input-group>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="8" :label="$gettext('Read Timeout')">
|
||
<n-input-group>
|
||
<n-input-number
|
||
:value="parseDuration(proxy.timeout.read).value"
|
||
:min="1"
|
||
flex-1
|
||
@update:value="
|
||
(v: number | null) =>
|
||
updateProxyTimeoutValue(proxy, 'read', v ?? 1)
|
||
"
|
||
/>
|
||
<n-select
|
||
:value="parseDuration(proxy.timeout.read).unit"
|
||
:options="timeUnitOptions"
|
||
style="width: 100px"
|
||
@update:value="
|
||
(v: string) => updateProxyTimeoutUnit(proxy, 'read', v)
|
||
"
|
||
/>
|
||
</n-input-group>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="8" :label="$gettext('Send Timeout')">
|
||
<n-input-group>
|
||
<n-input-number
|
||
:value="parseDuration(proxy.timeout.send).value"
|
||
:min="1"
|
||
flex-1
|
||
@update:value="
|
||
(v: number | null) =>
|
||
updateProxyTimeoutValue(proxy, 'send', v ?? 1)
|
||
"
|
||
/>
|
||
<n-select
|
||
:value="parseDuration(proxy.timeout.send).unit"
|
||
:options="timeUnitOptions"
|
||
style="width: 100px"
|
||
@update:value="
|
||
(v: string) => updateProxyTimeoutUnit(proxy, 'send', v)
|
||
"
|
||
/>
|
||
</n-input-group>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</template>
|
||
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<!-- 重试配置开关 -->
|
||
<n-form-item-gi :span="8" :label="$gettext('Retry Settings')">
|
||
<n-switch
|
||
:value="proxy.retry !== null"
|
||
@update:value="(v: boolean) => toggleProxyRetry(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
|
||
<!-- SSL 后端验证开关(仅 https) -->
|
||
<n-form-item-gi
|
||
v-if="proxy.pass?.startsWith('https')"
|
||
:span="8"
|
||
:label="$gettext('SSL Backend Verify')"
|
||
>
|
||
<n-switch
|
||
:value="proxy.ssl_backend !== null"
|
||
@update:value="(v: boolean) => toggleProxySSLBackend(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
|
||
<!-- 响应头修改开关 -->
|
||
<n-form-item-gi :span="8" :label="$gettext('Response Headers')">
|
||
<n-switch
|
||
:value="proxy.response_headers !== null"
|
||
@update:value="(v: boolean) => toggleProxyResponseHeaders(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
|
||
<!-- 重试配置详情 -->
|
||
<template v-if="proxy.retry">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="12" :label="$gettext('Retry Conditions')">
|
||
<n-select
|
||
v-model:value="proxy.retry.conditions"
|
||
:options="retryConditionOptions"
|
||
multiple
|
||
:placeholder="$gettext('Select retry conditions')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="6" :label="$gettext('Max Tries')">
|
||
<n-input-number
|
||
:value="proxy.retry.tries || null"
|
||
:min="0"
|
||
:placeholder="$gettext('Unlimited')"
|
||
@update:value="(v: number | null) => (proxy.retry.tries = v ?? 0)"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="6" :label="$gettext('Retry Timeout')">
|
||
<n-input-group>
|
||
<n-input-number
|
||
:value="
|
||
proxy.retry.timeout
|
||
? parseDuration(proxy.retry.timeout).value
|
||
: null
|
||
"
|
||
:min="0"
|
||
flex-1
|
||
:placeholder="$gettext('Unlimited')"
|
||
@update:value="
|
||
(v: number | null) => updateRetryTimeoutValue(proxy, v ?? 0)
|
||
"
|
||
/>
|
||
<n-select
|
||
:value="parseDuration(proxy.retry.timeout).unit"
|
||
:options="timeUnitOptions"
|
||
style="width: 100px"
|
||
@update:value="(v: string) => updateRetryTimeoutUnit(proxy, v)"
|
||
/>
|
||
</n-input-group>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</template>
|
||
|
||
<!-- SSL 后端验证详情 -->
|
||
<template v-if="proxy.ssl_backend && proxy.pass?.startsWith('https')">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="6" :label="$gettext('Enable Verify')">
|
||
<n-switch v-model:value="proxy.ssl_backend.verify" />
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="6" :label="$gettext('Verify Depth')">
|
||
<n-input-number
|
||
v-model:value="proxy.ssl_backend.verify_depth"
|
||
:min="1"
|
||
:max="10"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Trusted Certificate')">
|
||
<n-input
|
||
v-model:value="proxy.ssl_backend.trusted_certificate"
|
||
:placeholder="
|
||
$gettext(
|
||
'CA certificate path, e.g. /etc/ssl/certs/ca-certificates.crt'
|
||
)
|
||
"
|
||
/>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</template>
|
||
|
||
<!-- 响应头修改详情 -->
|
||
<template v-if="proxy.response_headers">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="12" :label="$gettext('Hide Headers')">
|
||
<n-select
|
||
v-model:value="proxy.response_headers.hide"
|
||
:options="hideHeaderOptions"
|
||
multiple
|
||
filterable
|
||
tag
|
||
:placeholder="$gettext('Select or input headers to hide')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Add Headers')">
|
||
<n-flex vertical :size="8" w-full>
|
||
<n-flex
|
||
v-for="(headerValue, headerName) in proxy.response_headers.add"
|
||
:key="String(headerName)"
|
||
:size="8"
|
||
align="center"
|
||
>
|
||
<n-input
|
||
:value="String(headerName)"
|
||
:placeholder="$gettext('Header name')"
|
||
flex-1
|
||
@blur="
|
||
(e: FocusEvent) => {
|
||
const newName = (e.target as HTMLInputElement).value
|
||
const oldName = String(headerName)
|
||
if (newName && newName !== oldName) {
|
||
proxy.response_headers.add[newName] =
|
||
proxy.response_headers.add[oldName]
|
||
delete proxy.response_headers.add[oldName]
|
||
}
|
||
}
|
||
"
|
||
/>
|
||
<span flex-shrink-0>=</span>
|
||
<n-input
|
||
:value="String(headerValue)"
|
||
:placeholder="$gettext('Header value')"
|
||
flex-1
|
||
@update:value="
|
||
(v: string) =>
|
||
(proxy.response_headers.add[String(headerName)] = v)
|
||
"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
flex-shrink-0
|
||
@click="delete proxy.response_headers.add[String(headerName)]"
|
||
>
|
||
{{ $gettext('Remove') }}
|
||
</n-button>
|
||
</n-flex>
|
||
<n-button dashed size="small" @click="addResponseHeader(proxy)">
|
||
{{ $gettext('Add Response Header') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</template>
|
||
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<!-- IP 访问控制开关 -->
|
||
<n-form-item-gi :span="8" :label="$gettext('IP Access Control')">
|
||
<n-switch
|
||
:value="proxy.access_control !== null"
|
||
@update:value="(v: boolean) => toggleProxyAccessControl(proxy, v)"
|
||
/>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
|
||
<!-- IP 访问控制详情 -->
|
||
<template v-if="proxy.access_control">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="12" :label="$gettext('Allow IPs')">
|
||
<n-dynamic-tags
|
||
v-model:value="proxy.access_control.allow"
|
||
:placeholder="$gettext('IP or CIDR, e.g. 192.168.1.0/24')"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Deny IPs')">
|
||
<n-dynamic-tags
|
||
v-model:value="proxy.access_control.deny"
|
||
:placeholder="$gettext('IP or CIDR, e.g. all')"
|
||
/>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</template>
|
||
</n-collapse-item>
|
||
</n-collapse>
|
||
</n-form>
|
||
</n-card>
|
||
</template>
|
||
</draggable>
|
||
|
||
<!-- 空状态 -->
|
||
<n-empty v-if="!setting.proxies || setting.proxies.length === 0">
|
||
{{ $gettext('No proxy rules configured') }}
|
||
</n-empty>
|
||
|
||
<!-- 添加按钮 -->
|
||
<n-button type="primary" dashed @click="addProxy" mb-20>
|
||
{{ $gettext('Add Proxy Rule') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane name="https" tab="HTTPS">
|
||
<n-flex vertical v-if="setting">
|
||
<n-card v-if="setting.ssl && setting.ssl_issuer != ''">
|
||
<n-descriptions :title="$gettext('Certificate Information')" :column="2">
|
||
<n-descriptions-item>
|
||
<template #label>{{ $gettext('Certificate Validity') }}</template>
|
||
<n-flex>
|
||
<n-tag>{{ setting.ssl_not_before }}</n-tag>
|
||
-
|
||
<n-tag>{{ setting.ssl_not_after }}</n-tag>
|
||
</n-flex>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item>
|
||
<template #label>{{ $gettext('Issuer') }}</template>
|
||
<n-flex>
|
||
<n-tag>{{ setting.ssl_issuer }}</n-tag>
|
||
</n-flex>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item>
|
||
<template #label>{{ $gettext('Domains') }}</template>
|
||
<n-flex>
|
||
<n-tag v-for="item in setting.ssl_dns_names" :key="item">{{ item }}</n-tag>
|
||
</n-flex>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item>
|
||
<template #label>OCSP</template>
|
||
<n-flex>
|
||
<n-tag v-for="item in setting.ssl_ocsp_server" :key="item">{{ item }}</n-tag>
|
||
</n-flex>
|
||
</n-descriptions-item>
|
||
</n-descriptions>
|
||
</n-card>
|
||
<n-form>
|
||
<n-grid :cols="24" :x-gap="24">
|
||
<n-form-item-gi :span="12" :label="$gettext('Main Switch')">
|
||
<n-switch v-model:value="setting.ssl" />
|
||
</n-form-item-gi>
|
||
<n-form-item-gi
|
||
v-if="setting.ssl"
|
||
:span="12"
|
||
:label="$gettext('Use Existing Certificate')"
|
||
>
|
||
<n-select
|
||
v-model:value="selectedCert"
|
||
:options="certOptions"
|
||
@update-value="handleSelectCert"
|
||
/>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</n-form>
|
||
<n-form inline v-if="setting.ssl">
|
||
<n-form-item label="HSTS">
|
||
<n-switch v-model:value="setting.hsts" />
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('HTTP Redirect')">
|
||
<n-switch v-model:value="setting.http_redirect" />
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('OCSP Stapling')">
|
||
<n-switch v-model:value="setting.ocsp" />
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-form v-if="setting.ssl">
|
||
<n-form-item :label="$gettext('TLS Version')">
|
||
<n-select
|
||
v-model:value="setting.ssl_protocols"
|
||
:options="[
|
||
{ label: 'TLS 1.0', value: 'TLSv1.0' },
|
||
{ label: 'TLS 1.1', value: 'TLSv1.1' },
|
||
{ label: 'TLS 1.2', value: 'TLSv1.2' },
|
||
{ label: 'TLS 1.3', value: 'TLSv1.3' }
|
||
]"
|
||
multiple
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Cipher Suites')">
|
||
<n-input
|
||
type="textarea"
|
||
v-model:value="setting.ssl_ciphers"
|
||
:placeholder="$gettext('Enter the cipher suite, leave blank to reset to default')"
|
||
rows="4"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Certificate')">
|
||
<n-input
|
||
v-model:value="setting.ssl_cert"
|
||
type="textarea"
|
||
:placeholder="$gettext('Enter the content of the PEM certificate file')"
|
||
:autosize="{ minRows: 10, maxRows: 15 }"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Private Key')">
|
||
<n-input
|
||
v-model:value="setting.ssl_key"
|
||
type="textarea"
|
||
:placeholder="$gettext('Enter the content of the KEY private key file')"
|
||
:autosize="{ minRows: 10, maxRows: 15 }"
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-flex>
|
||
<n-skeleton v-else text :repeat="10" />
|
||
</n-tab-pane>
|
||
<n-tab-pane v-if="setting.type == 'php'" name="rewrite" :tab="$gettext('Rewrite')">
|
||
<n-flex vertical>
|
||
<n-form v-if="isNginx" label-placement="left" label-width="auto">
|
||
<n-form-item :label="$gettext('Presets')">
|
||
<n-select
|
||
v-model:value="rewriteValue"
|
||
clearable
|
||
:options="rewriteOptions"
|
||
@update-value="handleRewrite"
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
<common-editor v-if="setting" v-model:value="setting.rewrite" height="60vh" />
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane name="redirects" :tab="$gettext('Redirects')">
|
||
<n-flex vertical>
|
||
<!-- 重定向卡片列表 -->
|
||
<draggable
|
||
v-model="setting.redirects"
|
||
item-key="_id"
|
||
handle=".drag-handle"
|
||
:animation="200"
|
||
ghost-class="ghost-card"
|
||
>
|
||
<template #item="{ element: redirect, index }">
|
||
<n-card closable @close="removeRedirect(index)" mb-16>
|
||
<template #header>
|
||
<n-flex align="center" :size="8">
|
||
<!-- 拖拽手柄 -->
|
||
<div class="drag-handle" cursor-grab>
|
||
<the-icon icon="mdi:drag" :size="20" />
|
||
</div>
|
||
<span>{{ $gettext('Rule') }} #{{ index + 1 }}</span>
|
||
<n-tag size="small" :type="redirect.type === '404' ? 'warning' : 'default'">
|
||
{{ getRedirectTypeLabel(redirect.type) }}
|
||
</n-tag>
|
||
<template v-if="redirect.type !== '404'">
|
||
<n-tag size="small">{{ redirect.from }}</n-tag>
|
||
<the-icon icon="mdi:arrow-right-bold" :size="20" />
|
||
</template>
|
||
<n-tag size="small" type="success">{{ redirect.to }}</n-tag>
|
||
</n-flex>
|
||
</template>
|
||
<n-form label-placement="left" label-width="140px">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="12" :label="$gettext('Redirect Type')">
|
||
<n-select v-model:value="redirect.type" :options="redirectTypeOptions" />
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Status Code')">
|
||
<n-select
|
||
v-model:value="redirect.status_code"
|
||
:options="redirectStatusCodeOptions"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi
|
||
v-if="redirect.type !== '404'"
|
||
:span="12"
|
||
:label="$gettext('Source')"
|
||
>
|
||
<n-input
|
||
v-model:value="redirect.from"
|
||
:placeholder="
|
||
redirect.type === 'url'
|
||
? $gettext('Source path, e.g., /old')
|
||
: $gettext('Source host, e.g., example.com')
|
||
"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi
|
||
:span="redirect.type === '404' ? 24 : 12"
|
||
:label="$gettext('Target')"
|
||
>
|
||
<n-input
|
||
v-model:value="redirect.to"
|
||
:placeholder="
|
||
redirect.type === 'url'
|
||
? $gettext('Target path, e.g., /new')
|
||
: $gettext('Target URL, e.g., https://example.com')
|
||
"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Keep URI')">
|
||
<n-switch v-model:value="redirect.keep_uri" />
|
||
<n-text depth="3" class="ml-8">
|
||
{{ $gettext('Keep the original request path and query parameters') }}
|
||
</n-text>
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
</n-form>
|
||
</n-card>
|
||
</template>
|
||
</draggable>
|
||
|
||
<!-- 空状态 -->
|
||
<n-empty v-if="!setting.redirects || setting.redirects.length === 0">
|
||
{{ $gettext('No redirect rules configured') }}
|
||
</n-empty>
|
||
|
||
<!-- 添加按钮 -->
|
||
<n-button type="primary" dashed @click="addRedirect" mb-20>
|
||
{{ $gettext('Add Redirect Rule') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane name="advanced" :tab="$gettext('Advanced Settings')">
|
||
<n-flex vertical>
|
||
<!-- 日志设置 -->
|
||
<n-card :title="$gettext('Log Settings')" mb-16>
|
||
<n-form label-placement="left" label-width="140px">
|
||
<n-form-item :label="$gettext('Access Log')">
|
||
<n-select
|
||
v-model:value="setting.access_log"
|
||
:options="accessLogOptions"
|
||
:placeholder="defaultAccessLog"
|
||
filterable
|
||
tag
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Error Log')">
|
||
<n-select
|
||
v-model:value="setting.error_log"
|
||
:options="errorLogOptions"
|
||
:placeholder="defaultErrorLog"
|
||
filterable
|
||
tag
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-card>
|
||
|
||
<!-- 限流限速设置 -->
|
||
<n-card :title="$gettext('Rate Limiting')" mb-16>
|
||
<n-form label-placement="left" label-width="140px">
|
||
<n-form-item :label="$gettext('Enable Rate Limiting')">
|
||
<n-switch v-model:value="rateLimitEnabled" />
|
||
</n-form-item>
|
||
<template v-if="rateLimitEnabled && setting.rate_limit">
|
||
<n-form-item :label="$gettext('Concurrent Limit')">
|
||
<n-input-number
|
||
:value="setting.rate_limit.per_server || null"
|
||
:min="0"
|
||
:max="100000"
|
||
w-full
|
||
:placeholder="$gettext('Unlimited')"
|
||
@update:value="(v: number | null) => (setting.rate_limit.per_server = v ?? 0)"
|
||
/>
|
||
<template #feedback>
|
||
{{ $gettext('Limit the maximum concurrent connections for this site') }}
|
||
</template>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Per IP Limit')">
|
||
<n-input-number
|
||
:value="setting.rate_limit.per_ip || null"
|
||
:min="0"
|
||
:max="10000"
|
||
w-full
|
||
:placeholder="$gettext('Unlimited')"
|
||
@update:value="(v: number | null) => (setting.rate_limit.per_ip = v ?? 0)"
|
||
/>
|
||
<template #feedback>
|
||
{{ $gettext('Limit the maximum concurrent connections per IP') }}
|
||
</template>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Rate Limit')">
|
||
<n-input-number
|
||
:value="setting.rate_limit.rate || null"
|
||
:min="0"
|
||
:max="1000000"
|
||
w-full
|
||
:placeholder="$gettext('Unlimited')"
|
||
@update:value="(v: number | null) => (setting.rate_limit.rate = v ?? 0)"
|
||
/>
|
||
<template #feedback>
|
||
{{ $gettext('Limit the rate of each request (unit: KB)') }}
|
||
</template>
|
||
</n-form-item>
|
||
</template>
|
||
</n-form>
|
||
</n-card>
|
||
|
||
<!-- 真实 IP 设置 -->
|
||
<n-card :title="$gettext('Real IP')" mb-16>
|
||
<n-alert type="info" mb-16>
|
||
{{
|
||
$gettext(
|
||
'Configure trusted proxy IPs (e.g., CDN or Frp) to identify real visitor IPs.'
|
||
)
|
||
}}
|
||
</n-alert>
|
||
<n-alert type="warning" mb-16>
|
||
{{
|
||
$gettext(
|
||
'If using Frp, fill in the Frp IP address (e.g., 127.0.0.1). If using CDN, fill in the CDN IP ranges. If unsure, you can fill in 0.0.0.0/0 (ipv4) or ::/0 (ipv6) [insecure].'
|
||
)
|
||
}}
|
||
</n-alert>
|
||
<n-form label-placement="left" label-width="140px">
|
||
<n-form-item :label="$gettext('Enable')">
|
||
<n-switch v-model:value="realIPEnabled" />
|
||
</n-form-item>
|
||
<template v-if="realIPEnabled && setting.real_ip">
|
||
<n-form-item :label="$gettext('IP Sources')">
|
||
<n-dynamic-input
|
||
v-model:value="setting.real_ip.from"
|
||
:placeholder="$gettext('e.g., 127.0.0.1 or 10.0.0.0/8')"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('IP Header')">
|
||
<n-select
|
||
v-model:value="setting.real_ip.header"
|
||
:options="realIPHeaderOptions"
|
||
filterable
|
||
tag
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="$gettext('Recursive')">
|
||
<n-switch v-model:value="setting.real_ip.recursive" />
|
||
<template #feedback>
|
||
{{ $gettext('Recursively search for real IP in X-Forwarded-For header') }}
|
||
</template>
|
||
</n-form-item>
|
||
</template>
|
||
</n-form>
|
||
</n-card>
|
||
|
||
<!-- 基本认证设置 -->
|
||
<n-card :title="$gettext('Basic Authentication')" mb-16>
|
||
<n-form label-placement="left" label-width="140px">
|
||
<n-form-item :label="$gettext('User Credentials')">
|
||
<n-flex vertical :size="8" w-full>
|
||
<n-flex
|
||
v-for="(password, username) in setting.basic_auth"
|
||
:key="String(username)"
|
||
:size="8"
|
||
align="center"
|
||
>
|
||
<n-input
|
||
:default-value="String(username)"
|
||
:placeholder="$gettext('Username')"
|
||
flex-1
|
||
@change="
|
||
(newUsername: string) => {
|
||
const oldUsername = String(username)
|
||
if (newUsername && newUsername !== oldUsername) {
|
||
// 检查新用户名是否已存在
|
||
if (setting.basic_auth[newUsername] !== undefined) {
|
||
return
|
||
}
|
||
setting.basic_auth[newUsername] = setting.basic_auth[oldUsername]
|
||
delete setting.basic_auth[oldUsername]
|
||
}
|
||
}
|
||
"
|
||
/>
|
||
<n-input
|
||
:value="String(password)"
|
||
type="password"
|
||
show-password-on="click"
|
||
:placeholder="$gettext('Password')"
|
||
flex-1
|
||
@update:value="(v: string) => (setting.basic_auth[String(username)] = v)"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
flex-shrink-0
|
||
@click="removeBasicAuthUser(String(username))"
|
||
>
|
||
{{ $gettext('Remove') }}
|
||
</n-button>
|
||
</n-flex>
|
||
<n-button dashed size="small" @click="addBasicAuthUser">
|
||
{{ $gettext('Add User') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-alert v-if="Object.keys(setting.basic_auth || {}).length > 0" type="info">
|
||
{{
|
||
$gettext(
|
||
'Visitors will need to enter a username and password to access this website.'
|
||
)
|
||
}}
|
||
</n-alert>
|
||
</n-card>
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane name="custom_configs" :tab="$gettext('Custom Configs')">
|
||
<n-flex vertical>
|
||
<!-- 自定义配置列表 -->
|
||
<draggable
|
||
v-model="setting.custom_configs"
|
||
item-key="_id"
|
||
handle=".drag-handle"
|
||
:animation="200"
|
||
ghost-class="ghost-card"
|
||
>
|
||
<template #item="{ element: config, index }">
|
||
<n-card closable @close="removeCustomConfig(index)" mb-16>
|
||
<template #header>
|
||
<n-flex align="center" :size="8">
|
||
<!-- 拖拽手柄 -->
|
||
<div class="drag-handle" cursor-grab>
|
||
<the-icon icon="mdi:drag" :size="20" />
|
||
</div>
|
||
<span>{{ $gettext('Config') }} #{{ index + 1 }}</span>
|
||
</n-flex>
|
||
</template>
|
||
<n-form label-placement="left" label-width="100px">
|
||
<n-grid :cols="24" :x-gap="16">
|
||
<n-form-item-gi :span="12" :label="$gettext('Name')">
|
||
<n-input
|
||
v-model:value="config.name"
|
||
:placeholder="
|
||
$gettext('Config name (letters, numbers, underscore, hyphen)')
|
||
"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Scope')">
|
||
<n-select v-model:value="config.scope" :options="scopeOptions" />
|
||
</n-form-item-gi>
|
||
</n-grid>
|
||
<n-form-item :label="$gettext('Content')">
|
||
<common-editor
|
||
v-model:value="config.content"
|
||
height="30vh"
|
||
:lang="isNginx ? 'nginx' : 'apacheconf'"
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-card>
|
||
</template>
|
||
</draggable>
|
||
|
||
<!-- 空状态 -->
|
||
<n-empty v-if="!setting.custom_configs || setting.custom_configs.length === 0">
|
||
{{ $gettext('No custom configs') }}
|
||
</n-empty>
|
||
|
||
<!-- 添加按钮 -->
|
||
<n-button type="primary" dashed @click="addCustomConfig" mb-20>
|
||
{{ $gettext('Add Custom Config') }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane
|
||
v-if="setting.access_log && setting.access_log !== 'off'"
|
||
name="log"
|
||
:tab="$gettext('Access Log')"
|
||
>
|
||
<n-flex vertical>
|
||
<n-flex flex items-center>
|
||
<n-alert type="warning" w-full>
|
||
{{ $gettext('All logs can be viewed by downloading the file') }}
|
||
<n-tag>{{ setting.access_log }}</n-tag>
|
||
{{ $gettext('view') }}.
|
||
</n-alert>
|
||
</n-flex>
|
||
<realtime-log :path="setting.access_log" language="accesslog" pb-20 />
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane
|
||
v-if="setting.error_log && setting.error_log !== 'off'"
|
||
name="error_log"
|
||
:tab="$gettext('Error Log')"
|
||
>
|
||
<n-flex vertical>
|
||
<n-flex flex items-center>
|
||
<n-alert type="warning" w-full>
|
||
{{ $gettext('All logs can be viewed by downloading the file') }}
|
||
<n-tag>{{ setting.error_log }}</n-tag>
|
||
{{ $gettext('view') }}.
|
||
</n-alert>
|
||
</n-flex>
|
||
<realtime-log :path="setting.error_log" language="accesslog" />
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
</n-tabs>
|
||
<n-button
|
||
v-if="current !== 'log' && current !== 'error_log'"
|
||
type="primary"
|
||
@click="handleSave"
|
||
>
|
||
{{ $gettext('Save') }}
|
||
</n-button>
|
||
<n-popconfirm v-if="current == 'log'" @positive-click="clearLog">
|
||
<template #trigger>
|
||
<n-button type="primary">
|
||
{{ $gettext('Clear Logs') }}
|
||
</n-button>
|
||
</template>
|
||
{{ $gettext('Are you sure you want to clear?') }}
|
||
</n-popconfirm>
|
||
<n-button
|
||
v-if="current === 'https' && setting && setting.domains.length > 0"
|
||
:loading="isObtainCert"
|
||
:disabled="isObtainCert"
|
||
class="ml-16"
|
||
type="info"
|
||
@click="handleObtainCert"
|
||
>
|
||
{{ $gettext('One-click Certificate Issuance') }}
|
||
</n-button>
|
||
<n-popconfirm v-if="current === 'config'" @positive-click="handleReset">
|
||
<template #trigger>
|
||
<n-button type="warning" ml-16>
|
||
{{ $gettext('Reset Configuration') }}
|
||
</n-button>
|
||
</template>
|
||
{{ $gettext('Are you sure you want to reset the configuration?') }}
|
||
</n-popconfirm>
|
||
</common-page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 拖拽时的占位卡片 */
|
||
:deep(.ghost-card) {
|
||
opacity: 0.5;
|
||
}
|
||
</style>
|