From 2ff320c6ac2ac3754dfa3a062a5c206cf72b1ef5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 26 May 2026 11:27:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloudtentacles/source-config-service.ts | 8 +- apps/frontend/src/components.d.ts | 3 - .../AdminCloudtentaclesConfigCard.vue | 424 ++++++++++++------ .../AdminCloudtentaclesDebugCard.vue | 278 ++++++++---- .../AdminCloudtentaclesInfoCard.vue | 263 ++++++++--- .../AdminPlatformCloudtentaclesSection.vue | 1 + .../useAdminCloudtentaclesPlatform.ts | 152 ++++--- docs/cloudflared_tunnel.txt | 63 +++ 8 files changed, 839 insertions(+), 353 deletions(-) create mode 100644 docs/cloudflared_tunnel.txt diff --git a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts index c37fad9c..7a660a4f 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts @@ -86,7 +86,7 @@ export function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObje } /** - * Delete a single source by key. Throws if key is 'default' (cannot delete default source). + * Delete a single source by key. */ export function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { const key = String(sourceKey || '').trim() @@ -96,12 +96,6 @@ export function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { errorCode: 'cloudtentacles_source_key_required', }) } - if (key === 'default') { - throw createHttpError('不能删除默认 cloudtentacles 账号', { - statusCode: 409, - errorCode: 'cloudtentacles_default_source_forbidden', - }) - } const config = loadCloudtentaclesSourcesConfigFromFile() const existingIndex = config.sources.findIndex(s => s.key === key) diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index 3ecc6fcf..d04129c3 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -15,7 +15,6 @@ declare module 'vue' { AdminResultCard: typeof import('./components/admin/AdminResultCard.vue')['default'] AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] - ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] @@ -49,8 +48,6 @@ declare module 'vue' { ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesConfigCard.vue b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesConfigCard.vue index f55d4ac3..e30f8422 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesConfigCard.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesConfigCard.vue @@ -3,10 +3,10 @@ import type { AdminCloudtentaclesLoginTestResult, AdminCloudtentaclesPersistedSession, AdminCloudtentaclesSendSmsResult, + AdminCloudtentaclesSessionsMap, AdminCloudtentaclesSourceItem, AdminCloudtentaclesValidateSessionResult, } from '@/types/admin' -import AdminResultCard from '@/components/admin/AdminResultCard.vue' import { formatAdminDateTime } from '@/utils/admin-time' type CloudtentaclesForm = { @@ -37,6 +37,7 @@ type Props = { selectedSourceKey: string currentSourceEnabled: boolean sources: AdminCloudtentaclesSourceItem[] + sessions: AdminCloudtentaclesSessionsMap currentSource: AdminCloudtentaclesSourceItem | null currentSession: AdminCloudtentaclesPersistedSession | null handleCloudtentaclesSaveSource: () => void | Promise @@ -52,29 +53,31 @@ defineProps() diff --git a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesDebugCard.vue b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesDebugCard.vue index f0d942a5..c6567273 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesDebugCard.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesDebugCard.vue @@ -22,96 +22,202 @@ defineProps() diff --git a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesInfoCard.vue b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesInfoCard.vue index 0d882418..89fcd93b 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesInfoCard.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminCloudtentaclesInfoCard.vue @@ -23,84 +23,215 @@ function resolveOrphanSessionEntries( ) { return Object.entries(sessions).filter(([key]) => !sources.some((source) => source.key === key)) } + +function countSavedTokens(sessions: AdminCloudtentaclesSessionsMap) { + return Object.values(sessions).filter((session) => session?.hasToken).length +} diff --git a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue index 562942a1..eb385475 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue @@ -106,6 +106,7 @@ defineProps() :selected-source-key="selectedSourceKey" :current-source-enabled="currentSourceEnabled" :sources="sources" + :sessions="sessions" :current-source="currentSource" :current-session="currentSession" :handle-cloudtentacles-save-source="handleCloudtentaclesSaveSource" diff --git a/apps/frontend/src/views/admin/platform-shops/composables/useAdminCloudtentaclesPlatform.ts b/apps/frontend/src/views/admin/platform-shops/composables/useAdminCloudtentaclesPlatform.ts index 05bc5ad6..06ab61f9 100644 --- a/apps/frontend/src/views/admin/platform-shops/composables/useAdminCloudtentaclesPlatform.ts +++ b/apps/frontend/src/views/admin/platform-shops/composables/useAdminCloudtentaclesPlatform.ts @@ -25,7 +25,7 @@ import { useCloudtentaclesDebugActions } from './useCloudtentaclesDebugActions' export function useAdminCloudtentaclesPlatform() { const cloudtentaclesFilePath = ref('') const cloudtentaclesSessionFilePath = ref('') - const selectedSourceKey = ref('default') + const selectedSourceKey = ref('') const sources = ref([]) const sessions = ref({}) @@ -88,33 +88,70 @@ export function useAdminCloudtentaclesPlatform() { sourceCount: sources.value.length, })) + function createSourceLabel(index: number) { + return `履约账号 ${index}` + } + + function generateSourceKey() { + let key = '' + do { + key = `account_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + } while (sources.value.some((source) => source.key === key)) + return key + } + + function createEmptySource(label = createSourceLabel(sources.value.length + 1)) { + return { + key: generateSourceKey(), + label, + enabled: true, + baseUrl: 'https://123.207.217.176', + username: '', + password: '', + phone: '', + deviceId: '-', + deviceType: 0, + } satisfies AdminCloudtentaclesSourceItem + } + + function resetCloudtentaclesTransientState() { + cloudtentaclesSmsResult.value = null + cloudtentaclesLoginResult.value = null + cloudtentaclesValidateResult.value = null + cloudtentaclesResultError.value = '' + } + + function persistCurrentFormToSelectedSource() { + const source = sources.value.find((item) => item.key === selectedSourceKey.value) + if (!source) return + + source.baseUrl = cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176' + source.username = cloudtentaclesForm.value.username.trim() + source.password = cloudtentaclesForm.value.password.trim() + source.phone = cloudtentaclesForm.value.phone.trim() + source.deviceId = cloudtentaclesForm.value.deviceId.trim() || '-' + source.deviceType = Number(cloudtentaclesForm.value.deviceType || 0) + } + + function selectFallbackSource(preferredKey = '') { + const preferred = sources.value.find((source) => source.key === preferredKey) + selectedSourceKey.value = preferred?.key || sources.value[0]?.key || '' + } + function hydrateCloudtentaclesConfig(data: AdminCloudtentaclesSourceConfigResponse) { cloudtentaclesFilePath.value = data.filePath cloudtentaclesSessionFilePath.value = data.sessionFilePath sources.value = data.sources || [] sessions.value = data.sessions || {} - // Ensure 'default' always exists if (!sources.value.length) { - sources.value.push({ - key: 'default', - label: '默认账号', - enabled: true, - baseUrl: 'https://123.207.217.176', - username: '', - password: '', - phone: '', - deviceId: '-', - deviceType: 0, - }) + sources.value.push(createEmptySource()) } - // Default selectedSourceKey to 'default' or first source if (!sources.value.find((s) => s.key === selectedSourceKey.value)) { - selectedSourceKey.value = sources.value[0].key + selectFallbackSource() } - // Hydrate form from the currently selected source hydrateFormFromSource(selectedSourceKey.value) } @@ -141,19 +178,19 @@ export function useAdminCloudtentaclesPlatform() { } function switchSource(sourceKey: string) { + if (sourceKey === selectedSourceKey.value) return + if (!sources.value.some((source) => source.key === sourceKey)) return + + persistCurrentFormToSelectedSource() selectedSourceKey.value = sourceKey - // Clear transient results when switching - cloudtentaclesSmsResult.value = null - cloudtentaclesLoginResult.value = null - cloudtentaclesValidateResult.value = null - cloudtentaclesResultError.value = '' + resetCloudtentaclesTransientState() hydrateFormFromSource(sourceKey) } async function addSource() { try { const result = await showPrompt( - '请输入新账号名称(例如:运营号、客服号)', + '请输入账号显示名称(例如:生产履约号、夜班履约号)', '新增 cloudtentacles 账号', { inputPattern: /\S/, @@ -165,27 +202,12 @@ export function useAdminCloudtentaclesPlatform() { showError('账号名称不能为空') return } - // Generate a URL-safe key from the label, ensure uniqueness - const baseKey = trimmed.replace(/[^a-zA-Z0-9\u4e00-\u9fff_]/g, '_').toLowerCase() - let newKey = baseKey - let counter = 2 - while (sources.value.some((s) => s.key === newKey)) { - newKey = `${baseKey}_${counter++}` - } - const newSource: AdminCloudtentaclesSourceItem = { - key: newKey, - label: trimmed, - enabled: true, - baseUrl: 'https://123.207.217.176', - username: '', - password: '', - phone: '', - deviceId: '-', - deviceType: 0, - } + persistCurrentFormToSelectedSource() + const newSource = createEmptySource(trimmed) sources.value.push(newSource) - selectedSourceKey.value = newKey - hydrateFormFromSource(newKey) + selectedSourceKey.value = newSource.key + resetCloudtentaclesTransientState() + hydrateFormFromSource(newSource.key) } catch (error) { if (isFeedbackDismissed(error)) return // user cancelled / closed showError(error instanceof Error ? error.message : '新增账号失败') @@ -193,20 +215,39 @@ export function useAdminCloudtentaclesPlatform() { } async function removeSource(sourceKey: string) { - if (sourceKey === 'default') { - showError('默认账号不可删除') + const sourceIndex = sources.value.findIndex((source) => source.key === sourceKey) + if (sourceIndex < 0) { + showError('账号不存在') return } try { cloudtentaclesSaving.value = true - await deleteAdminCloudtentaclesSource(sourceKey) - sources.value = sources.value.filter((s) => s.key !== sourceKey) - delete sessions.value[sourceKey] - if (selectedSourceKey.value === sourceKey) { - selectedSourceKey.value = 'default' - hydrateFormFromSource('default') + persistCurrentFormToSelectedSource() + + if (sources.value.length === 1) { + const replacement = createEmptySource() + const response = await saveAdminCloudtentaclesSourceConfig({ + enabled: true, + sources: [replacement], + }) + cloudtentaclesFilePath.value = response.data.filePath + cloudtentaclesSessionFilePath.value = response.data.sessionFilePath + sources.value = response.data.sources + sessions.value = response.data.sessions + selectFallbackSource(replacement.key) + } else { + await deleteAdminCloudtentaclesSource(sourceKey) + sources.value = sources.value.filter((s) => s.key !== sourceKey) + delete sessions.value[sourceKey] + + if (selectedSourceKey.value === sourceKey) { + const nextSource = sources.value[sourceIndex] || sources.value[sourceIndex - 1] || sources.value[0] + selectFallbackSource(nextSource?.key) + } } + resetCloudtentaclesTransientState() + hydrateFormFromSource(selectedSourceKey.value) showSuccess('账号已删除') } catch (error) { showError(error instanceof Error ? error.message : '删除账号失败') @@ -216,16 +257,7 @@ export function useAdminCloudtentaclesPlatform() { } function buildCloudtentaclesConfigPayload(): AdminCloudtentaclesSourcesConfig { - // Update current source from form - const current = sources.value.find((s) => s.key === selectedSourceKey.value) - if (current) { - current.baseUrl = cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176' - current.username = cloudtentaclesForm.value.username.trim() - current.password = cloudtentaclesForm.value.password.trim() - current.phone = cloudtentaclesForm.value.phone.trim() - current.deviceId = cloudtentaclesForm.value.deviceId.trim() || '-' - current.deviceType = Number(cloudtentaclesForm.value.deviceType || 0) - } + persistCurrentFormToSelectedSource() return { enabled: true, sources: sources.value.map((s) => ({ diff --git a/docs/cloudflared_tunnel.txt b/docs/cloudflared_tunnel.txt new file mode 100644 index 00000000..348134b4 --- /dev/null +++ b/docs/cloudflared_tunnel.txt @@ -0,0 +1,63 @@ + +最快解决(强制使用 HTTP/2 协议)防止clash干扰 +cloudflared tunnel --protocol http2 run --url http://localhost:80 mac-mini-tunnel + + + +cloudflared tunnel create mac-mini-tunnel + +yml@ymlMacBook-Air ~ % cloudflared tunnel create mac-mini-tunnel +Tunnel credentials written to /Users/yml/.cloudflared/562f9639-71f2-44e0-a5ef-b34bd4e215e5.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel. + +Created tunnel mac-mini-tunnel with id 562f9639-71f2-44e0-a5ef-b34bd4e215e5 +yml@ymlMacBook-Air ~ % + + +221329.cc.cd + +4. # 例子:把 web.yourdomain.com 指向刚才创建的隧道 +cloudflared tunnel route dns mac-mini-tunnel 221329.cc.cd + + +5. 启动隧道(临时/测试) +如果你只是临时跑一个服务(比如你 OrbStack 里的容器映射到了本地 80 端口): + +cloudflared tunnel run --url http://localhost:80 mac-mini-tunnel + +----- +如果两台电脑**不会同时运行**(例如一台是公司的 iMac,一台是家里的 MacBook),那么处理起来就简单得多了。 + +在这种情况下,你可以直接采用 **“共用同一个 Tunnel ID”** 的方案。这在 Cloudflare 的术语中叫做多副本(Replicas),虽然你不是为了负载均衡,但它能完美解决你的需求。 + +### 实现方案:共用 Tunnel +你只需要确保两台电脑拥有完全一致的“身份令牌”和“配置文件”。 + +1. **迁移证书与密钥:** + * 在第一台电脑(已配置好)上,找到这两个文件: + * `~/.cloudflared/cert.pem`(登录证书) + * `~/.cloudflared/.json`(隧道的身份密钥) + * 将它们拷贝到第二台电脑的 `~/.cloudflared/` 目录下。 + +2. **同步配置文件:** + * 将 `config.yml` 也拷贝过去。 + * **注意:** 确保两台电脑上运行的本地服务端口是一致的(比如都是 `localhost:8080`)。 + +3. **直接运行:** + * 在任意一台电脑上运行 `cloudflared tunnel run <隧道名>` 即可。 + +--- + +### 这样做的优势 +* **域名无缝切换:** 你不需要在 Cloudflare 后台修改任何 DNS 记录。只要其中一台电脑开启了隧道,`example.yourdomain.com` 就会立即指向那台电脑。 +* **无需重复配置:** 所有的路由规则(Ingress rules)都是一致的,维护起来非常省心。 + +### 注意事项 +* **如果真的“不小心”同时运行了:** + Cloudflare 会把它们当成一个隧道的两个节点。流量会以类似“轮询”的方式随机分配到两台电脑上。如果这时你的两台电脑跑的服务内容不一样,访问者可能会看到一会儿是 A 电脑的内容,一会儿是 B 电脑的内容。 +* **路径一致性:** + 在 `config.yml` 中,`credentials-file` 的路径如果写的是绝对路径(如 `/Users/jack/...`),请确保两台电脑的用户名一致;如果不一致,记得修改配置文件里的路径。 + +--- + +**总结:** +既然不同时运行,那就把第二台电脑伪装成第一台电脑的“克隆体”。只需拷贝那几个关键文件,你就能实现在家、在公司用同一个域名访问不同的机器了。 \ No newline at end of file