优化多账号

This commit is contained in:
yml2213
2026-05-26 11:27:19 +08:00
parent d126b35579
commit 2ff320c6ac
8 changed files with 839 additions and 353 deletions
@@ -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)
-3
View File
@@ -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']
@@ -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<void>
@@ -52,29 +53,31 @@ defineProps<Props>()
</script>
<template>
<el-card class="section-card">
<el-card class="cloud-config-card">
<template #header>
<div class="card-header">
<div class="config-header">
<div>
<span class="card-title">cloudtentacles 履约平台配置</span>
<p class="card-desc">
先维护账号清单再编辑当前账号的核心信息短信登录token 校验和调试参数已经拆开避免常用 CRUD 被大表单干扰
</p>
<p class="card-desc">维护账号凭据与登录会话调试字段默认折叠</p>
</div>
<div class="card-actions">
<el-button :loading="cloudtentaclesSaving" @click="handleCloudtentaclesSaveSource"
>保存履约配置</el-button
<el-button
type="primary"
:loading="cloudtentaclesSaving"
@click="handleCloudtentaclesSaveSource"
>
保存配置
</el-button>
</div>
</div>
</template>
<div class="crud-layout">
<div class="account-column">
<aside class="account-column">
<div class="account-column-head">
<div>
<strong>账号列表</strong>
<p>先选账号再编辑右侧信息</p>
<p>{{ sources.length }} 个账号</p>
</div>
<el-button type="primary" plain size="small" @click="addSource">新增账号</el-button>
</div>
@@ -95,18 +98,24 @@ defineProps<Props>()
</el-tag>
</div>
<div class="account-item-meta">
<span>{{ source.username || '未填写账号' }}</span>
<span>{{ source.phone || '未填写手机号' }}</span>
<span>{{ source.username || '账号未填' }}</span>
<span>{{ source.phone || '手机号未填' }}</span>
</div>
<div class="account-item-foot">
<span>{{ source.key }}</span>
<span>{{ currentSession && source.key === selectedSourceKey ? (currentSession.hasToken ? '已登录' : '未登录') : '-' }}</span>
<span>{{
sessions[source.key]
? sessions[source.key].hasToken
? '已登录'
: '未登录'
: '-'
}}</span>
</div>
</button>
</div>
</div>
</aside>
<div class="editor-column">
<section class="editor-column">
<div class="editor-head">
<div>
<strong>{{ currentSource?.label || currentSource?.key || '未选择账号' }}</strong>
@@ -134,7 +143,6 @@ defineProps<Props>()
"
/>
<el-button
v-if="selectedSourceKey !== 'default'"
type="danger"
plain
size="small"
@@ -145,13 +153,13 @@ defineProps<Props>()
</div>
</div>
<div class="editor-panel">
<el-form label-width="auto" label-position="top" class="editor-form">
<el-row :gutter="16">
<el-col :span="12">
<div class="form-grid">
<el-form-item label="账号名称">
<el-input
:model-value="currentSource?.label || ''"
placeholder="例如:主号 / 备用号 / 夜班号"
placeholder="例如:生产履约号 / 夜班履约号 / 华东履约号"
@update:model-value="
(value) => {
if (currentSource) currentSource.label = String(value)
@@ -159,32 +167,22 @@ defineProps<Props>()
"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账号 Key">
<el-form-item label="系统 ID">
<el-input :model-value="currentSource?.key || ''" readonly />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="登录账号">
<el-input
v-model="cloudtentaclesForm.username"
placeholder="cloudtentacles 登录账号"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号">
<el-input v-model="cloudtentaclesForm.phone" placeholder="接收短信验证码的手机号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="登录密码">
<el-input
v-model="cloudtentaclesForm.password"
@@ -193,30 +191,28 @@ defineProps<Props>()
placeholder="cloudtentacles 登录密码"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Base URL">
<el-input
v-model="cloudtentaclesForm.baseUrl"
placeholder="https://123.207.217.176"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<el-card shadow="never" class="session-card">
<div class="session-panel">
<div class="session-card-head">
<div>
<strong>登录与会话</strong>
<p>短信验证码登录测试token 校验都放在这里和账号 CRUD 分开</p>
<p>发送验证码后测试登录已有 token 可直接校验</p>
</div>
<div class="session-card-actions">
<el-button
:loading="cloudtentaclesSendingSms"
@click="handleCloudtentaclesSendSmsCode"
>
发送短信验证码
发送验证码
</el-button>
<el-button
:loading="cloudtentaclesTesting"
@@ -234,33 +230,28 @@ defineProps<Props>()
</div>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form label-position="top" class="session-form">
<div class="form-grid">
<el-form-item label="短信验证码">
<el-input v-model="cloudtentaclesForm.code" placeholder="收到验证码后填写" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手动 token">
<el-input
v-model="cloudtentaclesForm.token"
placeholder="仅在需要手动校验时填写"
/>
</el-form-item>
</el-col>
</el-row>
</el-card>
</div>
</el-form>
</div>
<el-collapse class="advanced-collapse">
<el-collapse-item name="advanced" title="高级参数与调试字段">
<el-form label-width="auto" label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<div class="form-grid">
<el-form-item label="deviceId">
<el-input v-model="cloudtentaclesForm.deviceId" placeholder="默认 -" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="deviceType">
<el-input-number
v-model="cloudtentaclesForm.deviceType"
@@ -269,16 +260,12 @@ defineProps<Props>()
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="16">
<el-col :span="6">
<div class="debug-field-grid">
<el-form-item label="VN Key">
<el-input v-model="cloudtentaclesForm.vnKey" placeholder="例如 1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="VN ID">
<el-input-number
v-model="cloudtentaclesForm.vnId"
@@ -287,8 +274,6 @@ defineProps<Props>()
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="SKU ID">
<el-input-number
v-model="cloudtentaclesForm.skuId"
@@ -297,8 +282,6 @@ defineProps<Props>()
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="购买数量">
<el-input-number
v-model="cloudtentaclesForm.skuCount"
@@ -307,12 +290,12 @@ defineProps<Props>()
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
</el-collapse-item>
</el-collapse>
</div>
</section>
</div>
<el-alert
@@ -324,39 +307,56 @@ defineProps<Props>()
class="mt-4"
/>
<div class="result-grid mt-4">
<AdminResultCard label="短信发送" :value="cloudtentaclesSmsResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesSmsResult">
{{ cloudtentaclesSmsResult.phoneMasked }}{{
formatAdminDateTime(cloudtentaclesSmsResult.sentAt)
<div class="status-strip mt-4">
<div class="status-step" :class="{ 'status-step--done': cloudtentaclesSmsResult }">
<span>短信</span>
<strong>{{ cloudtentaclesSmsResult ? '已发送' : '未执行' }}</strong>
<em>
{{
cloudtentaclesSmsResult
? `${cloudtentaclesSmsResult.phoneMasked} · ${formatAdminDateTime(cloudtentaclesSmsResult.sentAt)}`
: '先确认手机号可用'
}}
</p>
<p v-else>先发送短信验证码确认手机号可用</p>
</AdminResultCard>
<AdminResultCard label="登录结果" :value="cloudtentaclesLoginResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesLoginResult">
{{ cloudtentaclesLoginResult.phoneMasked }}{{
formatAdminDateTime(cloudtentaclesLoginResult.loggedInAt)
</em>
</div>
<div class="status-step" :class="{ 'status-step--done': cloudtentaclesLoginResult }">
<span>登录</span>
<strong>{{ cloudtentaclesLoginResult ? '成功' : '未执行' }}</strong>
<em>
{{
cloudtentaclesLoginResult
? `${cloudtentaclesLoginResult.phoneMasked} · ${formatAdminDateTime(cloudtentaclesLoginResult.loggedInAt)}`
: '填写验证码后测试登录'
}}
</p>
<p v-else>填写短信验证码后可执行测试登录</p>
</AdminResultCard>
<AdminResultCard label="会话校验" :value="cloudtentaclesValidateResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesValidateResult">
权限 {{ cloudtentaclesValidateResult.session.permissionCount }} 余额
{{ cloudtentaclesValidateResult.asset }}
</p>
<p v-else>登录成功后可继续校验 token 会话</p>
</AdminResultCard>
</em>
</div>
<div class="status-step" :class="{ 'status-step--done': cloudtentaclesValidateResult }">
<span>会话</span>
<strong>{{ cloudtentaclesValidateResult ? '已校验' : '未执行' }}</strong>
<em>
{{
cloudtentaclesValidateResult
? `权限 ${cloudtentaclesValidateResult.session.permissionCount} 项 · 余额 ${cloudtentaclesValidateResult.asset}`
: '登录成功后校验 token'
}}
</em>
</div>
</div>
</el-card>
</template>
<style scoped>
.section-card {
.cloud-config-card {
border-radius: var(--radius-lg);
}
.config-header {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: center;
}
.card-actions {
display: flex;
gap: var(--space-2);
@@ -366,13 +366,14 @@ defineProps<Props>()
.crud-layout {
display: grid;
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr);
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
gap: var(--space-4);
}
.account-column,
.editor-column {
display: grid;
align-content: start;
gap: var(--space-3);
}
@@ -388,7 +389,7 @@ defineProps<Props>()
.account-column-head p,
.editor-head p,
.session-card-head p {
margin: 6px 0 0;
margin: 4px 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
@@ -401,27 +402,28 @@ defineProps<Props>()
.account-item {
width: 100%;
text-align: left;
border: 1px solid var(--el-border-color);
border-radius: var(--radius-lg);
background: var(--el-bg-color);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius-sm);
background: #f8fafc;
padding: var(--space-3);
display: grid;
gap: 10px;
gap: 8px;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
background 0.2s ease;
}
.account-item:hover {
border-color: var(--el-color-primary-light-5);
transform: translateY(-1px);
background: #ffffff;
}
.account-item--active {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--el-color-primary) 35%, transparent);
background: #ffffff;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
}
.account-item-head,
@@ -437,6 +439,16 @@ defineProps<Props>()
.account-item-head {
justify-content: space-between;
min-width: 0;
}
.account-item-head strong,
.account-item-meta span,
.account-item-foot span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-item-meta,
@@ -445,25 +457,153 @@ defineProps<Props>()
font-size: var(--text-sm);
}
.account-item-meta {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
}
.account-item-foot {
justify-content: space-between;
font-size: var(--text-xs);
}
.editor-form,
.session-card,
.advanced-collapse {
margin-top: 0;
.editor-head {
padding-bottom: var(--space-2);
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.result-grid {
.editor-head > div:first-child {
min-width: 0;
}
.editor-head > div:first-child strong {
display: block;
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-lg);
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-panel {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: var(--radius-md);
background: #fbfdff;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px var(--space-4);
}
.session-panel {
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid rgba(59, 130, 246, 0.14);
border-radius: var(--radius-md);
background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.92));
}
.session-card-actions {
justify-content: flex-end;
}
.session-form :deep(.el-form-item) {
margin-bottom: 0;
}
.advanced-collapse {
border-top: 1px solid rgba(148, 163, 184, 0.14);
border-bottom: 0;
}
.advanced-collapse :deep(.el-collapse-item__header) {
height: 38px;
background: transparent;
font-weight: 700;
}
.advanced-collapse :deep(.el-collapse-item__wrap) {
background: transparent;
}
.debug-field-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 2px var(--space-3);
}
.status-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
@media (max-width: 960px) {
.status-step {
min-width: 0;
display: grid;
gap: 4px;
padding: var(--space-3);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius-sm);
background: #f8fafc;
}
.status-step span {
color: var(--text-muted);
font-size: var(--text-xs);
font-weight: 800;
}
.status-step strong {
color: var(--text-primary);
font-size: var(--text-lg);
line-height: 1.15;
}
.status-step em {
overflow: hidden;
color: var(--text-secondary);
font-size: var(--text-xs);
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-step--done {
border-color: rgba(34, 197, 94, 0.2);
background: var(--color-success-light);
}
@media (max-width: 1180px) {
.crud-layout {
grid-template-columns: 1fr;
}
.account-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 820px) {
.config-header,
.editor-head,
.session-card-head {
align-items: stretch;
flex-direction: column;
}
.form-grid,
.debug-field-grid,
.status-strip,
.account-list {
grid-template-columns: 1fr;
}
}
</style>
@@ -22,96 +22,202 @@ defineProps<Props>()
</script>
<template>
<el-card class="section-card">
<template #header>
<div>
<span class="card-title">cloudtentacles 开发调试工具</span>
<p class="card-desc">
仅开发模式展示用于调试分类SKU购买发货虚拟号兑换链接和结束退还链路
</p>
<el-card class="debug-card">
<el-collapse>
<el-collapse-item name="debug">
<template #title>
<div class="debug-title">
<strong>开发调试工具</strong>
<span>分类SKU虚拟号和完整流程调试</span>
</div>
</template>
<div class="debug-panel">
<div class="debug-group">
<span>基础</span>
<div class="debug-buttons">
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchAsset"
>余额</el-button
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchAsset"
>
余额
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchCategories"
>分类</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchSkuList"
>SKU</el-button
分类
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchSkuList"
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesBuySku"
>购买</el-button
SKU
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchKnapsack"
>
背包
</el-button>
</div>
</div>
<div class="debug-group">
<span>履约</span>
<div class="debug-buttons">
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesBuySku">
购买
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
type="primary"
@click="handleCloudtentaclesUseSku"
>发货</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchKnapsack"
>背包</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnList"
>虚拟号列表</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesAppointVn"
>申请虚拟号</el-button
>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesGenerateVnLoginCode"
>生成登录码</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnCode"
>获取验证码</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesVerifyVnCode"
>校验验证码</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchBindUrl"
>兑换链接</el-button
>
<el-button
:loading="cloudtentaclesDebugLoading"
type="warning"
@click="handleCloudtentaclesBackVn"
>退还号码</el-button
>
发货
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
type="success"
@click="handleCloudtentaclesRunFullFlow"
>完整流程</el-button
>
完整流程
</el-button>
</div>
</div>
<div class="debug-group">
<span>虚拟号</span>
<div class="debug-buttons">
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchVnList"
>
列表
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesAppointVn"
>
申请
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesGenerateVnLoginCode"
>
登录码
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchVnCode"
>
验证码
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesVerifyVnCode"
>
校验
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchBindUrl"
>
兑换链接
</el-button>
<el-button
:loading="cloudtentaclesDebugLoading"
type="warning"
@click="handleCloudtentaclesBackVn"
>
退还
</el-button>
</div>
</div>
</div>
<pre v-if="cloudtentaclesDebugResult" class="debug-pre mt-4">{{
cloudtentaclesDebugResult
}}</pre>
<el-empty
v-else
description="执行一次调试动作后,这里会显示原始返回数据。"
:image-size="60"
/>
<div v-else class="debug-empty">执行调试动作后显示原始返回数据</div>
</el-collapse-item>
</el-collapse>
</el-card>
</template>
<style scoped>
.section-card {
.debug-card {
border-radius: var(--radius-lg);
}
.debug-card :deep(.el-card__body) {
padding: 0 var(--space-4);
}
.debug-card :deep(.el-collapse) {
border: 0;
}
.debug-card :deep(.el-collapse-item__header) {
height: 54px;
background: transparent;
}
.debug-card :deep(.el-collapse-item__wrap) {
border-bottom: 0;
background: transparent;
}
.debug-title {
display: flex;
gap: var(--space-2);
align-items: baseline;
min-width: 0;
}
.debug-title strong {
color: var(--text-primary);
font-size: var(--text-base);
}
.debug-title span {
overflow: hidden;
color: var(--text-secondary);
font-size: var(--text-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.debug-panel {
display: grid;
gap: var(--space-3);
padding-bottom: var(--space-4);
}
.debug-group {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.debug-group > span {
color: var(--text-muted);
font-size: var(--text-xs);
font-weight: 800;
line-height: 32px;
}
.debug-buttons {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.debug-buttons :deep(.el-button) {
min-width: 72px;
}
.debug-pre {
margin: 0;
padding: var(--space-4);
@@ -125,4 +231,26 @@ defineProps<Props>()
line-height: 1.6;
font-family: var(--font-mono);
}
.debug-empty {
margin-bottom: var(--space-4);
padding: var(--space-4);
border: 1px dashed rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
color: var(--text-muted);
text-align: center;
font-size: var(--text-sm);
}
@media (max-width: 720px) {
.debug-title {
display: grid;
gap: 2px;
}
.debug-group {
grid-template-columns: 1fr;
gap: var(--space-1);
}
}
</style>
@@ -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
}
</script>
<template>
<el-card class="section-card">
<template #header>
<span class="card-title">配置信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="配置文件">
<code>{{ cloudtentaclesFilePath || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="会话文件">
<code>{{ cloudtentaclesSessionFilePath || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="账号数量">
{{ sources.length }}
</el-descriptions-item>
<el-descriptions-item label="当前选中账号">
{{ selectedSourceKey }}
</el-descriptions-item>
<el-descriptions-item label="职责">
管理 cloudtentacles 外部履约平台的账号短信验证码登录与 token
探活该区域不处理订单来源同步
</el-descriptions-item>
<el-descriptions-item label="当前账号已保存 token">
<section class="cloudtentacles-overview">
<div class="overview-copy">
<span class="eyebrow">履约账号</span>
<strong>cloudtentacles</strong>
<p>管理外部履约账号短信登录和 token 会话订单来源同步不在这里处理</p>
</div>
<div class="overview-metrics">
<div class="metric-item">
<span>账号</span>
<strong>{{ sources.length }}</strong>
</div>
<div class="metric-item">
<span>已保存 token</span>
<strong>{{ countSavedTokens(sessions) }}</strong>
</div>
<div class="metric-item metric-item--wide">
<span>当前账号</span>
<strong>{{ selectedSourceKey || '-' }}</strong>
</div>
<div class="metric-item metric-item--wide">
<span>当前 token</span>
<strong>
{{
cloudtentaclesPersistedSession?.hasToken
? `${cloudtentaclesPersistedSession.tokenMasked} / ${formatAdminDateTime(cloudtentaclesPersistedSession.loggedInAt)}`
? cloudtentaclesPersistedSession.tokenMasked
: '未保存'
}}
</el-descriptions-item>
<el-descriptions-item
v-if="sources.length > 0 || Object.keys(sessions).length > 0"
label="各账号会话状态"
>
<div class="session-list">
<div v-for="source in sources" :key="source.key" class="session-item">
<el-tag :type="source.enabled !== false ? 'success' : 'info'" size="small">
{{ source.key }}
</el-tag>
<span>{{ source.label || source.key }}</span>
<span>
{{
sessions[source.key]?.hasToken
? sessions[source.key]?.tokenMasked
: '未登录'
}}
</span>
</strong>
<small v-if="cloudtentaclesPersistedSession?.hasToken">
{{ formatAdminDateTime(cloudtentaclesPersistedSession.loggedInAt) }}
</small>
</div>
<div
</div>
<div class="file-strip">
<span>配置 {{ cloudtentaclesFilePath || '-' }}</span>
<span>会话 {{ cloudtentaclesSessionFilePath || '-' }}</span>
</div>
<div v-if="sources.length > 0 || Object.keys(sessions).length > 0" class="session-strip">
<span v-for="source in sources" :key="source.key" class="session-chip">
<strong>{{ source.label || source.key }}</strong>
<em>{{ sessions[source.key]?.hasToken ? sessions[source.key]?.tokenMasked : '未登录' }}</em>
</span>
<span
v-for="[key, session] in resolveOrphanSessionEntries(sources, sessions)"
:key="`orphan-${key}`"
class="session-item"
class="session-chip session-chip--orphan"
>
<el-tag :type="session.hasToken ? 'success' : 'info'" size="small">{{ key }}</el-tag>
<span>孤立会话</span>
<span>{{ session.hasToken ? session.tokenMasked : '未登录' }}</span>
<strong>{{ key }}</strong>
<em>{{ session.hasToken ? session.tokenMasked : '孤立会话' }}</em>
</span>
</div>
</div>
</el-descriptions-item>
</el-descriptions>
</el-card>
</section>
</template>
<style scoped>
.section-card {
.cloudtentacles-overview {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(420px, 1.5fr);
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-sm);
}
.session-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
.overview-copy {
min-width: 0;
}
.session-item {
display: flex;
align-items: center;
.eyebrow {
display: block;
color: var(--color-primary);
font-size: var(--text-xs);
font-weight: 800;
}
.overview-copy strong {
display: block;
margin-top: 4px;
color: var(--text-primary);
font-size: var(--text-xl);
line-height: 1.25;
}
.overview-copy p {
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.overview-metrics {
display: grid;
grid-template-columns: 92px 128px minmax(120px, 1fr) minmax(180px, 1.2fr);
gap: var(--space-2);
}
.metric-item {
min-width: 0;
display: grid;
gap: 3px;
align-content: center;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius-sm);
background: #f8fafc;
}
.metric-item span {
color: var(--text-muted);
font-size: var(--text-xs);
font-weight: 700;
}
.metric-item strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-lg);
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-item small {
overflow: hidden;
color: var(--text-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-strip,
.session-strip {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.file-strip span {
min-width: 0;
max-width: 100%;
padding: 5px 9px;
border-radius: var(--radius-full);
background: #eef3f8;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
word-break: break-all;
}
.session-strip {
padding-top: var(--space-1);
}
.session-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
padding: 4px 9px;
border: 1px solid rgba(34, 197, 94, 0.2);
border-radius: var(--radius-full);
background: var(--color-success-light);
color: var(--color-success-dark);
font-size: var(--text-xs);
}
.session-chip strong,
.session-chip em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-chip em {
color: inherit;
font-style: normal;
opacity: 0.78;
}
.session-chip--orphan {
border-color: rgba(245, 158, 11, 0.24);
background: var(--color-warning-light);
color: var(--color-warning-dark);
}
@media (max-width: 1180px) {
.cloudtentacles-overview,
.overview-metrics {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.cloudtentacles-overview,
.overview-metrics {
grid-template-columns: 1fr;
}
}
</style>
@@ -106,6 +106,7 @@ defineProps<Props>()
:selected-source-key="selectedSourceKey"
:current-source-enabled="currentSourceEnabled"
:sources="sources"
:sessions="sessions"
:current-source="currentSource"
:current-session="currentSession"
:handle-cloudtentacles-save-source="handleCloudtentaclesSaveSource"
@@ -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<AdminCloudtentaclesSourceItem[]>([])
const sessions = ref<AdminCloudtentaclesSessionsMap>({})
@@ -88,17 +88,22 @@ export function useAdminCloudtentaclesPlatform() {
sourceCount: sources.value.length,
}))
function hydrateCloudtentaclesConfig(data: AdminCloudtentaclesSourceConfigResponse) {
cloudtentaclesFilePath.value = data.filePath
cloudtentaclesSessionFilePath.value = data.sessionFilePath
sources.value = data.sources || []
sessions.value = data.sessions || {}
function createSourceLabel(index: number) {
return `履约账号 ${index}`
}
// Ensure 'default' always exists
if (!sources.value.length) {
sources.value.push({
key: 'default',
label: '默认账号',
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: '',
@@ -106,15 +111,47 @@ export function useAdminCloudtentaclesPlatform() {
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 || {}
if (!sources.value.length) {
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
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) {
selectedSourceKey.value = 'default'
hydrateFormFromSource('default')
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) => ({
+63
View File
@@ -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/<Tunnel-ID>.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/...`),请确保两台电脑的用户名一致;如果不一致,记得修改配置文件里的路径。
---
**总结:**
既然不同时运行,那就把第二台电脑伪装成第一台电脑的“克隆体”。只需拷贝那几个关键文件,你就能实现在家、在公司用同一个域名访问不同的机器了。