Merge pull request #697 from DaydreamCoding/feat/proxy-password-visibility
feat(admin): 代理密码可见性 + 复制代理 URL 功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '搜索代理...',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user