Merge pull request #697 from DaydreamCoding/feat/proxy-password-visibility

feat(admin): 代理密码可见性 + 复制代理 URL 功能
This commit is contained in:
Wesley Liddick
2026-03-01 22:21:30 +08:00
committed by GitHub
6 changed files with 230 additions and 28 deletions

View File

@@ -64,9 +64,9 @@ func (h *ProxyHandler) List(c *gin.Context) {
return
}
out := make([]dto.ProxyWithAccountCount, 0, len(proxies))
out := make([]dto.AdminProxyWithAccountCount, 0, len(proxies))
for i := range proxies {
out = append(out, *dto.ProxyWithAccountCountFromService(&proxies[i]))
out = append(out, *dto.ProxyWithAccountCountFromServiceAdmin(&proxies[i]))
}
response.Paginated(c, out, total, page, pageSize)
}
@@ -83,9 +83,9 @@ func (h *ProxyHandler) GetAll(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
out := make([]dto.ProxyWithAccountCount, 0, len(proxies))
out := make([]dto.AdminProxyWithAccountCount, 0, len(proxies))
for i := range proxies {
out = append(out, *dto.ProxyWithAccountCountFromService(&proxies[i]))
out = append(out, *dto.ProxyWithAccountCountFromServiceAdmin(&proxies[i]))
}
response.Success(c, out)
return
@@ -97,9 +97,9 @@ func (h *ProxyHandler) GetAll(c *gin.Context) {
return
}
out := make([]dto.Proxy, 0, len(proxies))
out := make([]dto.AdminProxy, 0, len(proxies))
for i := range proxies {
out = append(out, *dto.ProxyFromService(&proxies[i]))
out = append(out, *dto.ProxyFromServiceAdmin(&proxies[i]))
}
response.Success(c, out)
}
@@ -119,7 +119,7 @@ func (h *ProxyHandler) GetByID(c *gin.Context) {
return
}
response.Success(c, dto.ProxyFromService(proxy))
response.Success(c, dto.ProxyFromServiceAdmin(proxy))
}
// Create handles creating a new proxy
@@ -143,7 +143,7 @@ func (h *ProxyHandler) Create(c *gin.Context) {
if err != nil {
return nil, err
}
return dto.ProxyFromService(proxy), nil
return dto.ProxyFromServiceAdmin(proxy), nil
})
}
@@ -176,7 +176,7 @@ func (h *ProxyHandler) Update(c *gin.Context) {
return
}
response.Success(c, dto.ProxyFromService(proxy))
response.Success(c, dto.ProxyFromServiceAdmin(proxy))
}
// Delete handles deleting a proxy

View File

@@ -293,7 +293,6 @@ func ProxyFromService(p *service.Proxy) *Proxy {
Host: p.Host,
Port: p.Port,
Username: p.Username,
Password: p.Password,
Status: p.Status,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
@@ -323,6 +322,51 @@ func ProxyWithAccountCountFromService(p *service.ProxyWithAccountCount) *ProxyWi
}
}
// ProxyFromServiceAdmin converts a service Proxy to AdminProxy DTO for admin users.
// It includes the password field - user-facing endpoints must not use this.
func ProxyFromServiceAdmin(p *service.Proxy) *AdminProxy {
if p == nil {
return nil
}
base := ProxyFromService(p)
if base == nil {
return nil
}
return &AdminProxy{
Proxy: *base,
Password: p.Password,
}
}
// ProxyWithAccountCountFromServiceAdmin converts a service ProxyWithAccountCount to AdminProxyWithAccountCount DTO.
// It includes the password field - user-facing endpoints must not use this.
func ProxyWithAccountCountFromServiceAdmin(p *service.ProxyWithAccountCount) *AdminProxyWithAccountCount {
if p == nil {
return nil
}
admin := ProxyFromServiceAdmin(&p.Proxy)
if admin == nil {
return nil
}
return &AdminProxyWithAccountCount{
AdminProxy: *admin,
AccountCount: p.AccountCount,
LatencyMs: p.LatencyMs,
LatencyStatus: p.LatencyStatus,
LatencyMessage: p.LatencyMessage,
IPAddress: p.IPAddress,
Country: p.Country,
CountryCode: p.CountryCode,
Region: p.Region,
City: p.City,
QualityStatus: p.QualityStatus,
QualityScore: p.QualityScore,
QualityGrade: p.QualityGrade,
QualitySummary: p.QualitySummary,
QualityChecked: p.QualityChecked,
}
}
func ProxyAccountSummaryFromService(a *service.ProxyAccountSummary) *ProxyAccountSummary {
if a == nil {
return nil

View File

@@ -221,6 +221,32 @@ type ProxyWithAccountCount struct {
QualityChecked *int64 `json:"quality_checked,omitempty"`
}
// AdminProxy 是管理员接口使用的 proxy DTO包含密码等敏感字段
// 注意:普通接口不得使用此 DTO。
type AdminProxy struct {
Proxy
Password string `json:"password,omitempty"`
}
// AdminProxyWithAccountCount 是管理员接口使用的带账号统计的 proxy DTO。
type AdminProxyWithAccountCount struct {
AdminProxy
AccountCount int64 `json:"account_count"`
LatencyMs *int64 `json:"latency_ms,omitempty"`
LatencyStatus string `json:"latency_status,omitempty"`
LatencyMessage string `json:"latency_message,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"country_code,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
QualityStatus string `json:"quality_status,omitempty"`
QualityScore *int `json:"quality_score,omitempty"`
QualityGrade string `json:"quality_grade,omitempty"`
QualitySummary string `json:"quality_summary,omitempty"`
QualityChecked *int64 `json:"quality_checked,omitempty"`
}
type ProxyAccountSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`

View File

@@ -2345,6 +2345,8 @@ export default {
dataExportConfirm: 'Confirm Export',
dataExported: 'Data exported successfully',
dataExportFailed: 'Failed to export data',
copyProxyUrl: 'Copy Proxy URL',
urlCopied: 'Proxy URL copied',
searchProxies: 'Search proxies...',
allProtocols: 'All Protocols',
allStatus: 'All Status',
@@ -2358,6 +2360,7 @@ export default {
name: 'Name',
protocol: 'Protocol',
address: 'Address',
auth: 'Auth',
location: 'Location',
status: 'Status',
accounts: 'Accounts',

View File

@@ -2459,6 +2459,7 @@ export default {
name: '名称',
protocol: '协议',
address: '地址',
auth: '认证',
location: '地理位置',
status: '状态',
accounts: '账号数',
@@ -2486,6 +2487,8 @@ export default {
allStatuses: '全部状态'
},
// Additional keys used in ProxiesView
copyProxyUrl: '复制代理 URL',
urlCopied: '代理 URL 已复制',
allProtocols: '全部协议',
allStatus: '全部状态',
searchProxies: '搜索代理...',

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>