mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 04:22:33 +08:00
953 lines
34 KiB
Vue
953 lines
34 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: []
|
||
}
|
||
})
|
||
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_certificate = cert.cert
|
||
setting.value.ssl_certificate_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)
|
||
}
|
||
|
||
// ========== 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({
|
||
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({
|
||
location: '/',
|
||
pass: 'http://127.0.0.1:8080',
|
||
host: '$host',
|
||
sni: '',
|
||
cache: false,
|
||
buffering: true,
|
||
resolver: [],
|
||
resolver_timeout: 5 * 1000000000, // 5秒,以纳秒为单位
|
||
replaces: {}
|
||
})
|
||
}
|
||
|
||
// 删除代理
|
||
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)
|
||
}
|
||
</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="name"
|
||
handle=".drag-handle"
|
||
:animation="200"
|
||
ghost-class="ghost-card"
|
||
>
|
||
<template #item="{ element: upstream, index }">
|
||
<n-card closable @close="removeUpstream(index)" style="margin-bottom: 16px">
|
||
<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="[
|
||
{ 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' }
|
||
]"
|
||
/>
|
||
</n-form-item-gi>
|
||
<n-form-item-gi :span="12" :label="$gettext('Keepalive Connections')">
|
||
<n-input-number
|
||
v-model:value="upstream.keepalive"
|
||
:min="0"
|
||
:max="1000"
|
||
style="width: 100%"
|
||
/>
|
||
</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"
|
||
style="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" style="width: 100%">
|
||
<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')"
|
||
style="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')"
|
||
style="flex: 1"
|
||
@update:value="(v: string) => (upstream.servers[String(address)] = v)"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
style="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="location"
|
||
handle=".drag-handle"
|
||
:animation="200"
|
||
ghost-class="ghost-card"
|
||
>
|
||
<template #item="{ element: proxy, index }">
|
||
<n-card closable @close="removeProxy(index)" style="margin-bottom: 16px">
|
||
<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: $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 v-model:value="proxy.cache" />
|
||
</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"
|
||
style="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-divider>{{ $gettext('Response Content Replacement') }}</n-divider>
|
||
<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')"
|
||
style="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 style="flex-shrink: 0">=></span>
|
||
<n-input
|
||
:value="String(toValue)"
|
||
:placeholder="$gettext('Replacement content')"
|
||
style="flex: 1"
|
||
@update:value="(v: string) => (proxy.replaces[String(fromValue)] = v)"
|
||
/>
|
||
<n-button
|
||
type="error"
|
||
secondary
|
||
size="small"
|
||
style="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-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 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="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-popconfirm @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-flex>
|
||
<realtime-log :path="setting.access_log" />
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
<n-tab-pane 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" />
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
</n-tabs>
|
||
<n-button v-if="current !== 'log'" type="primary" @click="handleSave">
|
||
{{ $gettext('Save') }}
|
||
</n-button>
|
||
<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>
|