feat(admin): 代理密码可见性 + 复制代理 URL 功能

- 新增 AdminProxy / AdminProxyWithAccountCount DTO,遵循项目 Admin DTO 分层模式
- Proxy.Password 恢复 json:"-" 隐藏,ProxyFromService 不再赋值密码(纵深防御)
- 管理员接口使用 ProxyFromServiceAdmin / ProxyWithAccountCountFromServiceAdmin
- 前端代理列表新增 Auth 列:显示用户名 + 掩码密码 + 眼睛图标切换可见性
- Address 列新增复制按钮:左键复制完整 URL,右键选择格式
- 编辑模态框密码预填充 + 脏标记,避免误更新
This commit is contained in:
QTom
2026-03-01 21:06:53 +08:00
parent dd8df483cd
commit 8fb7d476b8
6 changed files with 230 additions and 28 deletions

View File

@@ -124,7 +124,54 @@
</template>
<template #cell-address="{ row }">
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
<div class="flex items-center gap-1.5">
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
<div class="relative">
<button
type="button"
class="rounded p-0.5 text-gray-400 hover:text-primary-600 dark:hover:text-primary-400"
:title="t('admin.proxies.copyProxyUrl')"
@click.stop="copyProxyUrl(row)"
@contextmenu.prevent="toggleCopyMenu(row.id)"
>
<Icon name="copy" size="sm" />
</button>
<!-- 右键展开格式选择菜单 -->
<div
v-if="copyMenuProxyId === row.id"
class="absolute left-0 top-full z-50 mt-1 w-auto min-w-[180px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-500 dark:bg-dark-700"
>
<button
v-for="fmt in getCopyFormats(row)"
:key="fmt.label"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-100 dark:hover:bg-dark-600"
@click.stop="copyFormat(fmt.value)"
>
<span class="truncate font-mono text-gray-600 dark:text-gray-300">{{ fmt.label }}</span>
</button>
</div>
</div>
</div>
</template>
<template #cell-auth="{ row }">
<div v-if="row.username || row.password" class="flex items-center gap-1.5">
<div class="flex flex-col text-xs">
<span v-if="row.username" class="text-gray-700 dark:text-gray-200">{{ row.username }}</span>
<span v-if="row.password" class="font-mono text-gray-500 dark:text-gray-400">
{{ visiblePasswordIds.has(row.id) ? row.password : '' }}
</span>
</div>
<button
v-if="row.password"
type="button"
class="ml-1 rounded p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@click.stop="visiblePasswordIds.has(row.id) ? visiblePasswordIds.delete(row.id) : visiblePasswordIds.add(row.id)"
>
<Icon :name="visiblePasswordIds.has(row.id) ? 'eyeOff' : 'eye'" size="sm" />
</button>
</div>
<span v-else class="text-sm text-gray-400">-</span>
</template>
<template #cell-location="{ row }">
@@ -397,12 +444,21 @@
</div>
<div>
<label class="input-label">{{ t('admin.proxies.password') }}</label>
<input
v-model="createForm.password"
type="password"
class="input"
:placeholder="t('admin.proxies.optionalAuth')"
/>
<div class="relative">
<input
v-model="createForm.password"
:type="createPasswordVisible ? 'text' : 'password'"
class="input pr-10"
:placeholder="t('admin.proxies.optionalAuth')"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@click="createPasswordVisible = !createPasswordVisible"
>
<Icon :name="createPasswordVisible ? 'eyeOff' : 'eye'" size="md" />
</button>
</div>
</div>
</form>
@@ -581,12 +637,22 @@
</div>
<div>
<label class="input-label">{{ t('admin.proxies.password') }}</label>
<input
v-model="editForm.password"
type="password"
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
class="input"
/>
<div class="relative">
<input
v-model="editForm.password"
:type="editPasswordVisible ? 'text' : 'password'"
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
class="input pr-10"
@input="editPasswordDirty = true"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@click="editPasswordVisible = !editPasswordVisible"
>
<Icon :name="editPasswordVisible ? 'eyeOff' : 'eye'" size="md" />
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.status') }}</label>
@@ -813,15 +879,18 @@ import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { useClipboard } from '@/composables/useClipboard'
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
const columns = computed<Column[]>(() => [
{ key: 'select', label: '', sortable: false },
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
{ key: 'auth', label: t('admin.proxies.columns.auth'), sortable: false },
{ key: 'location', label: t('admin.proxies.columns.location'), sortable: false },
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
@@ -858,6 +927,8 @@ const editStatusOptions = computed(() => [
])
const proxies = ref<Proxy[]>([])
const visiblePasswordIds = reactive(new Set<number>())
const copyMenuProxyId = ref<number | null>(null)
const loading = ref(false)
const searchQuery = ref('')
const filters = reactive({
@@ -872,7 +943,10 @@ const pagination = reactive({
})
const showCreateModal = ref(false)
const createPasswordVisible = ref(false)
const showEditModal = ref(false)
const editPasswordVisible = ref(false)
const editPasswordDirty = ref(false)
const showImportData = ref(false)
const showDeleteDialog = ref(false)
const showBatchDeleteDialog = ref(false)
@@ -1030,6 +1104,7 @@ const closeCreateModal = () => {
createForm.port = 8080
createForm.username = ''
createForm.password = ''
createPasswordVisible.value = false
batchInput.value = ''
batchParseResult.total = 0
batchParseResult.valid = 0
@@ -1173,14 +1248,18 @@ const handleEdit = (proxy: Proxy) => {
editForm.host = proxy.host
editForm.port = proxy.port
editForm.username = proxy.username || ''
editForm.password = ''
editForm.password = proxy.password || ''
editForm.status = proxy.status
editPasswordVisible.value = false
editPasswordDirty.value = false
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingProxy.value = null
editPasswordVisible.value = false
editPasswordDirty.value = false
}
const handleUpdateProxy = async () => {
@@ -1209,10 +1288,9 @@ const handleUpdateProxy = async () => {
status: editForm.status
}
// Only include password if it was changed
const trimmedPassword = editForm.password.trim()
if (trimmedPassword) {
updateData.password = trimmedPassword
// Only include password if user actually modified the field
if (editPasswordDirty.value) {
updateData.password = editForm.password.trim() || null
}
await adminAPI.proxies.update(editingProxy.value.id, updateData)
@@ -1715,12 +1793,60 @@ const closeAccountsModal = () => {
proxyAccounts.value = []
}
// ── Proxy URL copy ──
function buildAuthPart(row: any): string {
const user = row.username ? encodeURIComponent(row.username) : ''
const pass = row.password ? encodeURIComponent(row.password) : ''
if (user && pass) return `${user}:${pass}@`
if (user) return `${user}@`
if (pass) return `:${pass}@`
return ''
}
function buildProxyUrl(row: any): string {
return `${row.protocol}://${buildAuthPart(row)}${row.host}:${row.port}`
}
function getCopyFormats(row: any) {
const hasAuth = row.username || row.password
const fullUrl = buildProxyUrl(row)
const formats = [
{ label: fullUrl, value: fullUrl },
]
if (hasAuth) {
const withoutProtocol = fullUrl.replace(/^[^:]+:\/\//, '')
formats.push({ label: withoutProtocol, value: withoutProtocol })
}
formats.push({ label: `${row.host}:${row.port}`, value: `${row.host}:${row.port}` })
return formats
}
function copyProxyUrl(row: any) {
copyToClipboard(buildProxyUrl(row), t('admin.proxies.urlCopied'))
copyMenuProxyId.value = null
}
function toggleCopyMenu(id: number) {
copyMenuProxyId.value = copyMenuProxyId.value === id ? null : id
}
function copyFormat(value: string) {
copyToClipboard(value, t('admin.proxies.urlCopied'))
copyMenuProxyId.value = null
}
function closeCopyMenu() {
copyMenuProxyId.value = null
}
onMounted(() => {
loadProxies()
document.addEventListener('click', closeCopyMenu)
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
document.removeEventListener('click', closeCopyMenu)
})
</script>