ProxySettingsView.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. <template>
  2. <!-- eslint-disable vue/no-v-model-argument -->
  3. <div class="proxy-settings-container">
  4. <p class="proxy-subtitle">选择代理品牌后,在下方配置该品牌的代理信息。所有配置仅保存在本机,不上传服务器。</p>
  5. <!-- 品牌卡片:上方图片,下方品牌名称 + 网址 -->
  6. <div class="settings-section">
  7. <div class="section-header">
  8. <span class="section-title">选择代理品牌</span>
  9. </div>
  10. <div class="brand-cards">
  11. <div
  12. v-for="brand in brands"
  13. :key="brand.id"
  14. class="brand-card"
  15. :class="{ active: selectedBrandId === brand.id }"
  16. @click="selectBrand(brand.id)"
  17. >
  18. <div class="brand-card-cover">
  19. <img :src="brand.logo" :alt="brand.name" class="brand-logo" @error="onLogoError" />
  20. </div>
  21. <div class="brand-card-body">
  22. <div class="brand-name">{{ brand.name }}</div>
  23. <a :href="brand.url" target="_blank" rel="noopener noreferrer" class="brand-url" @click.stop>{{ brand.url }}</a>
  24. <a-tag v-if="effectiveBrandId === brand.id" color="green" class="effective-tag">
  25. {{ effectiveMode === 'api' ? 'API方式有效' : effectiveMode === 'account' ? '账号密码方式有效' : '有效' }}
  26. </a-tag>
  27. </div>
  28. </div>
  29. </div>
  30. </div>
  31. <!-- 配置区域:选择品牌后显示 -->
  32. <template v-if="selectedBrandId">
  33. <div class="settings-section">
  34. <div class="section-header">
  35. <span class="section-title">配置该品牌代理</span>
  36. </div>
  37. <a-radio-group v-model:value="form.mode" class="proxy-mode-group">
  38. <a-radio value="api">API 方式</a-radio>
  39. <a-radio value="account">账号密码方式</a-radio>
  40. </a-radio-group>
  41. </div>
  42. <!-- API 方式:HTTP/HTTPS 代理 API -->
  43. <div v-if="form.mode === 'api'" class="settings-section">
  44. <div class="form-group">
  45. <div class="label-with-action">
  46. <label class="form-label">API 地址(URL)- HTTP/HTTPS</label>
  47. <a-button
  48. size="small"
  49. :loading="testingKey === 'api-http'"
  50. @click="testApiProxy('http')"
  51. >
  52. 测试
  53. </a-button>
  54. </div>
  55. <a-input
  56. v-model:value="form.apiUrl"
  57. placeholder="请输入获取 HTTP/HTTPS 代理的 API 地址"
  58. allow-clear
  59. />
  60. </div>
  61. </div>
  62. <!-- 账号密码方式:HTTP/HTTPS 代理 + SOCKS5 代理 -->
  63. <div v-if="form.mode === 'account'" class="settings-section">
  64. <div class="proxy-type-header">
  65. <div class="proxy-type-title">HTTP/HTTPS 代理</div>
  66. <a-button
  67. size="small"
  68. :loading="testingKey === 'account-http'"
  69. @click="testAccountProxy('http')"
  70. >
  71. 测试
  72. </a-button>
  73. </div>
  74. <div class="form-row">
  75. <div class="form-group half">
  76. <label class="form-label">代理服务器</label>
  77. <a-input
  78. v-model:value="form.serverHost"
  79. placeholder="例如: proxy.example.com"
  80. allow-clear
  81. />
  82. </div>
  83. <div class="form-group half">
  84. <label class="form-label">端口</label>
  85. <a-input-number
  86. v-model:value="form.serverPort"
  87. placeholder="8080"
  88. :min="1"
  89. :max="65535"
  90. style="width: 100%"
  91. />
  92. </div>
  93. </div>
  94. <div class="form-row">
  95. <div class="form-group half">
  96. <label class="form-label">账号</label>
  97. <a-input
  98. v-model:value="form.serverUsername"
  99. placeholder="请输入账号"
  100. allow-clear
  101. />
  102. </div>
  103. <div class="form-group half">
  104. <label class="form-label">密码</label>
  105. <a-input-password
  106. v-model:value="form.serverPassword"
  107. placeholder="请输入密码"
  108. allow-clear
  109. />
  110. </div>
  111. </div>
  112. </div>
  113. <!-- 当前有效配置:全局唯一,同一品牌下也只能有一种方式(API 或 账号密码)为有效 -->
  114. <div class="settings-section">
  115. <div class="section-header">
  116. <span class="section-title">当前有效配置</span>
  117. </div>
  118. <div class="effective-option-hint">调用 Steam 接口时仅使用被设为「有效」的配置;全局只能有一个有效配置,同一品牌下也只能有一种方式(API 或 账号密码)为有效。</div>
  119. <a-radio-group v-model:value="effectiveOption" class="effective-radio-group" @change="onEffectiveOptionChange">
  120. <a-radio value="none">无(不启用任何代理配置)</a-radio>
  121. <a-radio value="api">API 方式 为当前有效配置</a-radio>
  122. <a-radio value="account">账号密码方式 为当前有效配置</a-radio>
  123. </a-radio-group>
  124. </div>
  125. <div class="proxy-actions">
  126. <a-button type="primary" :loading="saving" @click="save">保存配置</a-button>
  127. </div>
  128. </template>
  129. <a-modal
  130. v-model:open="testDialog.open"
  131. :title="testDialog.title"
  132. width="760px"
  133. :mask-closable="false"
  134. :footer="null"
  135. >
  136. <div class="test-status-line">
  137. <a-tag v-if="testDialog.running" color="processing">测试中</a-tag>
  138. <a-tag v-else-if="testDialog.success" color="success">测试通过</a-tag>
  139. <a-tag v-else color="error">测试失败</a-tag>
  140. </div>
  141. <pre class="test-log-panel">{{ testDialog.logs.join('\n') }}</pre>
  142. <div class="test-dialog-footer">
  143. <a-button type="primary" @click="testDialog.open = false">关闭</a-button>
  144. </div>
  145. </a-modal>
  146. </div>
  147. </template>
  148. <script setup lang="ts">
  149. import { ref, reactive, computed, onMounted, watch } from 'vue'
  150. import { invoke } from '@tauri-apps/api/tauri'
  151. import { message } from 'ant-design-vue'
  152. import { useAuthStore } from '../stores/authStore'
  153. const authStore = useAuthStore()
  154. interface Brand {
  155. id: string
  156. name: string
  157. url: string
  158. logo: string
  159. }
  160. const brands: Brand[] = [
  161. {
  162. id: 'haiwaiip',
  163. name: '海外代理IP',
  164. url: 'https://haiwaiip.net/cn/register?icode=481',
  165. logo: '/youdangdang/haiwaiip.png'
  166. }
  167. ]
  168. interface BrandConfig {
  169. mode: 'api' | 'account'
  170. // HTTP/HTTPS 代理 - API 方式
  171. apiUrl: string
  172. // SOCKS5 代理 - API 方式
  173. socks5ApiUrl: string
  174. // HTTP/HTTPS 代理 - 账号密码方式
  175. serverHost: string
  176. serverPort: number | null
  177. serverUsername: string
  178. serverPassword: string
  179. // SOCKS5 代理 - 账号密码方式
  180. socks5ServerHost: string
  181. socks5ServerPort: number | null
  182. socks5ServerUsername: string
  183. socks5ServerPassword: string
  184. // 是否跳过 TLS 证书校验(仅用于代理链路容错)
  185. insecureSkipTlsVerify?: boolean
  186. }
  187. const defaultBrandConfig = (): BrandConfig => ({
  188. mode: 'api',
  189. apiUrl: '',
  190. socks5ApiUrl: '',
  191. serverHost: '',
  192. serverPort: null,
  193. serverUsername: '',
  194. serverPassword: '',
  195. socks5ServerHost: '',
  196. socks5ServerPort: null,
  197. socks5ServerUsername: '',
  198. socks5ServerPassword: '',
  199. insecureSkipTlsVerify: false
  200. })
  201. type EffectiveMode = 'api' | 'account'
  202. interface StoredConfig {
  203. selectedBrandId: string
  204. effectiveBrandId: string | null
  205. effectiveMode: EffectiveMode | null
  206. configs: Record<string, BrandConfig>
  207. }
  208. interface ProxyTestResponse {
  209. success: boolean
  210. logs: string[]
  211. error?: string
  212. }
  213. const selectedBrandId = ref<string>(brands[0]?.id ?? '')
  214. const effectiveBrandId = ref<string | null>(null)
  215. const effectiveMode = ref<EffectiveMode | null>(null)
  216. const form = reactive<BrandConfig>(defaultBrandConfig())
  217. const configsByBrand = reactive<Record<string, BrandConfig>>({})
  218. const saving = ref(false)
  219. const testingKey = ref<string | null>(null)
  220. const testDialog = reactive({
  221. open: false,
  222. title: '代理测试',
  223. running: false,
  224. success: false,
  225. logs: [] as string[]
  226. })
  227. /** 当前选中的「有效」选项:无 / API 方式 / 账号密码方式(仅对当前品牌而言),默认选中 API 方式 */
  228. const effectiveOption = computed({
  229. get() {
  230. if (effectiveBrandId.value !== selectedBrandId.value) return 'api'
  231. return effectiveMode.value === 'api' ? 'api' : effectiveMode.value === 'account' ? 'account' : 'api'
  232. },
  233. set(v: 'none' | 'api' | 'account') {
  234. if (v === 'none') {
  235. if (effectiveBrandId.value === selectedBrandId.value) {
  236. effectiveBrandId.value = null
  237. effectiveMode.value = null
  238. }
  239. } else {
  240. effectiveBrandId.value = selectedBrandId.value
  241. effectiveMode.value = v
  242. }
  243. }
  244. })
  245. function onEffectiveOptionChange() {
  246. save()
  247. }
  248. function selectBrand(id: string) {
  249. // 当前表单写回当前品牌
  250. if (selectedBrandId.value) {
  251. configsByBrand[selectedBrandId.value] = { ...form }
  252. }
  253. selectedBrandId.value = id
  254. // 加载该品牌已保存的配置
  255. const saved = configsByBrand[id]
  256. if (saved) {
  257. form.mode = saved.mode
  258. form.apiUrl = saved.apiUrl ?? ''
  259. form.socks5ApiUrl = saved.socks5ApiUrl ?? ''
  260. form.serverHost = saved.serverHost ?? ''
  261. form.serverPort = saved.serverPort ?? null
  262. form.serverUsername = saved.serverUsername ?? ''
  263. form.serverPassword = saved.serverPassword ?? ''
  264. form.socks5ServerHost = saved.socks5ServerHost ?? ''
  265. form.socks5ServerPort = saved.socks5ServerPort ?? null
  266. form.socks5ServerUsername = saved.socks5ServerUsername ?? ''
  267. form.socks5ServerPassword = saved.socks5ServerPassword ?? ''
  268. } else {
  269. Object.assign(form, defaultBrandConfig())
  270. }
  271. }
  272. function loadFromLocal() {
  273. invoke<string>('read_proxy_config')
  274. .then((raw) => {
  275. if (!raw || raw === '{}') return
  276. try {
  277. const data = JSON.parse(raw) as StoredConfig & Partial<BrandConfig> & { proxyUrl?: string }
  278. if (data.selectedBrandId) selectedBrandId.value = data.selectedBrandId
  279. if (data.effectiveBrandId != null) effectiveBrandId.value = data.effectiveBrandId
  280. if (data.effectiveMode != null) effectiveMode.value = data.effectiveMode
  281. if (data.configs && typeof data.configs === 'object') {
  282. Object.keys(data.configs).forEach((id) => {
  283. configsByBrand[id] = { ...defaultBrandConfig(), ...data.configs![id] }
  284. })
  285. }
  286. // 兼容旧版单表单格式:无 configs 但有 mode/apiUrl 等时,写入当前品牌
  287. if (!data.configs && (data.mode || data.proxyUrl != null || data.apiUrl != null)) {
  288. const migrated: BrandConfig = {
  289. mode: (data.mode as BrandConfig['mode']) || 'api',
  290. apiUrl: data.apiUrl ?? data.proxyUrl ?? '',
  291. socks5ApiUrl: '',
  292. serverHost: data.serverHost ?? '',
  293. serverPort: data.serverPort ?? null,
  294. serverUsername: data.serverUsername ?? '',
  295. serverPassword: data.serverPassword ?? '',
  296. socks5ServerHost: '',
  297. socks5ServerPort: null,
  298. socks5ServerUsername: '',
  299. socks5ServerPassword: ''
  300. }
  301. configsByBrand[selectedBrandId.value] = migrated
  302. }
  303. const cur = configsByBrand[selectedBrandId.value]
  304. if (cur) Object.assign(form, cur)
  305. } catch (_) {
  306. // ignore
  307. }
  308. })
  309. .catch((err) => {
  310. console.warn('读取代理配置失败:', err)
  311. })
  312. }
  313. function save() {
  314. if (!selectedBrandId.value) return
  315. configsByBrand[selectedBrandId.value] = { ...form }
  316. saving.value = true
  317. // 如果没有设置有效配置,默认使用当前品牌的 API 方式
  318. if (!effectiveBrandId.value) {
  319. effectiveBrandId.value = selectedBrandId.value
  320. effectiveMode.value = 'api'
  321. }
  322. const persistedConfigs: Record<string, BrandConfig> = {}
  323. Object.keys(configsByBrand).forEach((brandId) => {
  324. const config = { ...configsByBrand[brandId] }
  325. // 按需默认:海外代理IP保存时强制开启跳过 TLS 校验
  326. if (brandId === 'haiwaiip') {
  327. config.insecureSkipTlsVerify = true
  328. }
  329. persistedConfigs[brandId] = config
  330. })
  331. const payload: StoredConfig = {
  332. selectedBrandId: selectedBrandId.value,
  333. effectiveBrandId: effectiveBrandId.value,
  334. effectiveMode: effectiveBrandId.value ? effectiveMode.value : null,
  335. configs: persistedConfigs
  336. }
  337. invoke('write_proxy_config', { json: JSON.stringify(payload) })
  338. .then(() => {
  339. message.success('配置已保存到本机')
  340. saving.value = false
  341. })
  342. .catch((err) => {
  343. message.error(err?.toString?.() || '保存失败')
  344. saving.value = false
  345. })
  346. }
  347. async function runProxyTest(
  348. title: string,
  349. key: string,
  350. payload: Record<string, unknown>
  351. ) {
  352. if (testingKey.value) return
  353. testingKey.value = key
  354. testDialog.open = true
  355. testDialog.title = title
  356. testDialog.running = true
  357. testDialog.success = false
  358. testDialog.logs = ['开始测试,请稍候...']
  359. try {
  360. const raw = await invoke<string>('test_proxy_configuration', { payload: JSON.stringify(payload) })
  361. const result = JSON.parse(raw) as ProxyTestResponse
  362. testDialog.logs = Array.isArray(result.logs) && result.logs.length > 0 ? result.logs : ['未返回测试日志']
  363. testDialog.success = !!result.success
  364. testDialog.running = false
  365. if (result.success) {
  366. message.success('代理测试通过')
  367. } else {
  368. message.error(result.error || '代理测试失败')
  369. }
  370. } catch (err: any) {
  371. testDialog.logs = [...testDialog.logs, `测试异常: ${err?.toString?.() || '未知错误'}`]
  372. testDialog.success = false
  373. testDialog.running = false
  374. message.error('代理测试异常,请重试')
  375. } finally {
  376. testingKey.value = null
  377. }
  378. }
  379. function testApiProxy(protocol: 'http' | 'socks5') {
  380. if (protocol === 'http') {
  381. runProxyTest('API 方式 - HTTP/HTTPS 测试', 'api-http', {
  382. mode: 'api',
  383. protocol: 'http',
  384. apiUrl: form.apiUrl
  385. })
  386. return
  387. }
  388. runProxyTest('API 方式 - SOCKS5 测试', 'api-socks5', {
  389. mode: 'api',
  390. protocol: 'socks5',
  391. apiUrl: form.socks5ApiUrl
  392. })
  393. }
  394. function testAccountProxy(protocol: 'http' | 'socks5') {
  395. if (protocol === 'http') {
  396. runProxyTest('账号密码方式 - HTTP/HTTPS 测试', 'account-http', {
  397. mode: 'account',
  398. protocol: 'http',
  399. serverHost: form.serverHost,
  400. serverPort: form.serverPort,
  401. serverUsername: form.serverUsername,
  402. serverPassword: form.serverPassword
  403. })
  404. return
  405. }
  406. runProxyTest('账号密码方式 - SOCKS5 测试', 'account-socks5', {
  407. mode: 'account',
  408. protocol: 'socks5',
  409. serverHost: form.socks5ServerHost,
  410. serverPort: form.socks5ServerPort,
  411. serverUsername: form.socks5ServerUsername,
  412. serverPassword: form.socks5ServerPassword
  413. })
  414. }
  415. function onLogoError(e: Event) {
  416. const img = e.target as HTMLImageElement
  417. if (img) img.style.display = 'none'
  418. }
  419. // 初次加载后若当前品牌没有缓存,用默认空表单
  420. watch(
  421. selectedBrandId,
  422. (id) => {
  423. if (!id) return
  424. if (!configsByBrand[id]) {
  425. configsByBrand[id] = defaultBrandConfig()
  426. Object.assign(form, defaultBrandConfig())
  427. }
  428. },
  429. { immediate: true }
  430. )
  431. onMounted(() => {
  432. loadFromLocal()
  433. })
  434. </script>
  435. <style scoped>
  436. .proxy-settings-container {
  437. min-height: 100%;
  438. min-width: 0;
  439. padding: 0;
  440. text-align: center;
  441. box-sizing: border-box;
  442. }
  443. .proxy-title {
  444. font-size: 22px;
  445. font-weight: 600;
  446. color: #1a1a1a;
  447. margin: 0 0 8px 0;
  448. }
  449. .proxy-subtitle {
  450. font-size: 13px;
  451. color: #666;
  452. margin: 0 0 16px 0;
  453. text-align: center;
  454. }
  455. .settings-section {
  456. margin-bottom: 24px;
  457. padding-bottom: 20px;
  458. border-bottom: 1px solid #f0f0f0;
  459. }
  460. .settings-section:last-of-type {
  461. border-bottom: none;
  462. }
  463. .section-header {
  464. margin-bottom: 16px;
  465. }
  466. .section-title {
  467. font-size: 15px;
  468. font-weight: 600;
  469. color: #333;
  470. text-align: left;
  471. }
  472. .brand-cards {
  473. display: flex;
  474. flex-wrap: nowrap;
  475. gap: 16px;
  476. overflow-x: auto;
  477. overflow-y: hidden;
  478. padding-bottom: 8px;
  479. }
  480. .brand-cards::-webkit-scrollbar {
  481. height: 6px;
  482. }
  483. .brand-cards::-webkit-scrollbar-thumb {
  484. background: #d9d9d9;
  485. border-radius: 3px;
  486. }
  487. .brand-card {
  488. flex-shrink: 0;
  489. width: 140px;
  490. border-radius: 12px;
  491. overflow: hidden;
  492. border: 2px solid #f0f0f0;
  493. cursor: pointer;
  494. transition: all 0.2s;
  495. background: #fff;
  496. }
  497. .brand-card:hover {
  498. border-color: #667eea;
  499. box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
  500. }
  501. .brand-card.active {
  502. border-color: #667eea;
  503. box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
  504. }
  505. .brand-card-cover {
  506. height: 56px;
  507. display: flex;
  508. align-items: center;
  509. justify-content: center;
  510. background: #fafafa;
  511. }
  512. .brand-logo {
  513. max-width: 72px;
  514. max-height: 40px;
  515. object-fit: contain;
  516. }
  517. .brand-card-body {
  518. padding: 8px 10px;
  519. text-align: center;
  520. }
  521. .brand-name {
  522. font-size: 13px;
  523. font-weight: 600;
  524. color: #333;
  525. margin-bottom: 4px;
  526. }
  527. .brand-url {
  528. font-size: 11px;
  529. color: #667eea;
  530. word-break: break-all;
  531. text-decoration: none;
  532. }
  533. .brand-url:hover {
  534. text-decoration: underline;
  535. }
  536. .effective-tag {
  537. display: block;
  538. margin-top: 8px;
  539. font-size: 11px;
  540. text-align: center;
  541. }
  542. .effective-option-hint {
  543. font-size: 12px;
  544. color: #999;
  545. margin-bottom: 12px;
  546. }
  547. .effective-radio-group {
  548. display: flex;
  549. flex-direction: row;
  550. flex-wrap: wrap;
  551. gap: 16px 24px;
  552. }
  553. .proxy-mode-group {
  554. display: flex;
  555. gap: 24px;
  556. }
  557. .form-row {
  558. display: flex;
  559. gap: 16px;
  560. margin-bottom: 16px;
  561. }
  562. .form-row .form-group.half {
  563. flex: 1;
  564. margin-bottom: 0;
  565. }
  566. .form-group {
  567. margin-bottom: 16px;
  568. }
  569. .form-group:last-child,
  570. .form-group.half {
  571. margin-bottom: 0;
  572. }
  573. .form-label {
  574. display: block;
  575. font-size: 14px;
  576. font-weight: 500;
  577. color: #333;
  578. margin-bottom: 8px;
  579. }
  580. .label-with-action {
  581. display: flex;
  582. align-items: center;
  583. justify-content: space-between;
  584. gap: 12px;
  585. }
  586. .proxy-type-title {
  587. font-size: 14px;
  588. font-weight: 600;
  589. color: #1a1a1a;
  590. }
  591. .proxy-type-header {
  592. display: flex;
  593. align-items: center;
  594. justify-content: space-between;
  595. gap: 12px;
  596. margin-bottom: 16px;
  597. padding-bottom: 8px;
  598. border-bottom: 1px solid #f0f0f0;
  599. }
  600. .proxy-actions {
  601. padding-top: 16px;
  602. border-top: 1px solid #f0f0f0;
  603. display: flex;
  604. justify-content: center;
  605. }
  606. .proxy-actions :deep(.ant-btn) {
  607. width: 200px;
  608. }
  609. .test-status-line {
  610. margin-bottom: 12px;
  611. }
  612. .test-log-panel {
  613. margin: 0;
  614. min-height: 260px;
  615. max-height: 420px;
  616. overflow: auto;
  617. background: #fafafa;
  618. border: 1px solid #f0f0f0;
  619. border-radius: 8px;
  620. padding: 12px;
  621. font-size: 12px;
  622. line-height: 1.55;
  623. white-space: pre-wrap;
  624. word-break: break-word;
  625. }
  626. .test-dialog-footer {
  627. margin-top: 16px;
  628. text-align: right;
  629. }
  630. </style>