feat: add app transfer, parallel upload toggle, webhook type config, fix Chinese encoding in JWT
- ops-platform: add 一键转移 button and dialog to AppListView; add transferApp API call - tenant-platform/update: parallel upload toggle in 凭据配置 tab; REVIEW_WEBHOOK notifyType select (钉钉/企微/飞书/自定义); select field support in store config dialog with visibleWhen - tenant-platform/jwt: fix Chinese garble in right-corner nickname by decoding JWT payload as UTF-8 via TextDecoder instead of raw atob() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
6b891bee92
当前提交
61dd09763a
@ -251,6 +251,9 @@ export const opsApi = {
|
||||
listAppServices: (id: string) =>
|
||||
client.get<{ data: FeatureServiceItem[] }>(`/ops/apps/${id}/services`),
|
||||
|
||||
transferApp: (appKey: string, targetTenantId: string) =>
|
||||
client.post(`/ops/apps/${appKey}/transfer`, { targetTenantId }),
|
||||
|
||||
listOperationLogs: (page = 0, size = 20) =>
|
||||
client.get<{ data: OpsLogPage }>('/ops/operation-logs', { params: { page, size } }),
|
||||
|
||||
|
||||
@ -37,15 +37,53 @@
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="$router.push(`/apps/${row.appKey}`)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button link type="warning" @click="openTransfer(row)">
|
||||
转移
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- Transfer Dialog -->
|
||||
<el-dialog v-model="showTransfer" title="转移应用到其他租户" width="480px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="应用">
|
||||
<span>{{ transferTarget?.name }} ({{ transferTarget?.appKey }})</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前租户">
|
||||
<span>{{ transferTarget?.tenantName || transferTarget?.tenantId }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标租户">
|
||||
<el-select
|
||||
v-model="transferTenantId"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="搜索目标租户"
|
||||
:remote-method="searchTransferTenants"
|
||||
:loading="transferTenantLoading"
|
||||
style="width:100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="t in transferTenantOptions"
|
||||
:key="t.id"
|
||||
:label="`${t.nickname || t.username} (${t.username})`"
|
||||
:value="t.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showTransfer = false">取消</el-button>
|
||||
<el-button type="primary" :loading="transferring" :disabled="!transferTenantId" @click="doTransfer">确认转移</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-pagination style="margin-top:16px"
|
||||
:current-page="page + 1" :page-size="size" :total="total"
|
||||
layout="prev, pager, next" @current-change="(p: number) => { page = p - 1; loadApps() }" />
|
||||
@ -54,6 +92,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { opsApi, type AppItem, type TenantItem } from '@/api/ops'
|
||||
|
||||
@ -99,5 +138,59 @@ async function loadApps() {
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer
|
||||
const showTransfer = ref(false)
|
||||
const transferTarget = ref<AppItem | null>(null)
|
||||
const transferTenantId = ref('')
|
||||
const transferTenantOptions = ref<TenantItem[]>([])
|
||||
const transferTenantLoading = ref(false)
|
||||
const transferring = ref(false)
|
||||
|
||||
function openTransfer(row: AppItem) {
|
||||
transferTarget.value = row
|
||||
transferTenantId.value = ''
|
||||
transferTenantOptions.value = []
|
||||
showTransfer.value = true
|
||||
}
|
||||
|
||||
async function searchTransferTenants(keyword: string) {
|
||||
if (!keyword) { transferTenantOptions.value = []; return }
|
||||
transferTenantLoading.value = true
|
||||
try {
|
||||
const res = await opsApi.listTenants(keyword, 0, 20)
|
||||
transferTenantOptions.value = res.data.data.content
|
||||
} catch {
|
||||
transferTenantOptions.value = []
|
||||
} finally {
|
||||
transferTenantLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doTransfer() {
|
||||
if (!transferTarget.value || !transferTenantId.value) return
|
||||
const target = transferTenantOptions.value.find(t => t.id === transferTenantId.value)
|
||||
const targetLabel = target ? `${target.nickname || target.username} (${target.username})` : transferTenantId.value
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认将应用「${transferTarget.value.name}」转移到「${targetLabel}」?此操作立即生效。`,
|
||||
'确认转移',
|
||||
{ type: 'warning', confirmButtonText: '确认转移', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
transferring.value = true
|
||||
try {
|
||||
await opsApi.transferApp(transferTarget.value.appKey, transferTenantId.value)
|
||||
ElMessage.success('转移成功')
|
||||
showTransfer.value = false
|
||||
await loadApps()
|
||||
} catch {
|
||||
ElMessage.error('转移失败')
|
||||
} finally {
|
||||
transferring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadApps)
|
||||
</script>
|
||||
|
||||
@ -4,7 +4,11 @@ export function decodeJwtPayload(token: string): Record<string, unknown> | null
|
||||
if (!payload) return null
|
||||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
return JSON.parse(atob(padded)) as Record<string, unknown>
|
||||
const binary = atob(padded)
|
||||
// Decode as UTF-8 to handle multi-byte characters (e.g. Chinese)
|
||||
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0))
|
||||
const json = new TextDecoder('utf-8').decode(bytes)
|
||||
return JSON.parse(json) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -205,6 +205,15 @@
|
||||
/>
|
||||
<el-tabs v-model="storeTab">
|
||||
<el-tab-pane label="凭据配置" name="configs">
|
||||
<div class="store-upload-options">
|
||||
<span class="store-option-label">并行上传</span>
|
||||
<el-switch
|
||||
v-model="publishConfigForm.parallelStoreUpload"
|
||||
:loading="savingPublishConfig"
|
||||
@change="savePublishConfig"
|
||||
/>
|
||||
<span class="store-option-hint">开启后同时向所有商店提交包,关闭后逐个排队上传</span>
|
||||
</div>
|
||||
<div class="store-grid">
|
||||
<el-card
|
||||
v-for="store in STORE_DEFS"
|
||||
@ -658,9 +667,26 @@
|
||||
<el-switch v-model="storeConfigForm.enabled" />
|
||||
</el-form-item>
|
||||
<template v-for="field in currentStoreDef.fields" :key="field.key">
|
||||
<el-form-item :label="field.label">
|
||||
<el-form-item
|
||||
v-if="!field.visibleWhen || field.visibleWhen(storeConfigForm.values)"
|
||||
:label="field.label"
|
||||
>
|
||||
<el-select
|
||||
v-if="field.type === 'select'"
|
||||
v-model="storeConfigForm.values[field.key]"
|
||||
style="width:100%"
|
||||
clearable
|
||||
placeholder="请选择"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="field.type === 'password'"
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="storeConfigForm.values[field.key]"
|
||||
type="password"
|
||||
show-password
|
||||
@ -985,6 +1011,7 @@ const publishConfigForm = ref({
|
||||
graySelectCallbackSecret: '',
|
||||
grayDirectorySyncCallbackUrl: '',
|
||||
grayDirectorySyncCallbackSecret: '',
|
||||
parallelStoreUpload: true,
|
||||
})
|
||||
const grayMembers = ref<GrayMemberGroup[]>([])
|
||||
const loadingGrayMembers = ref(false)
|
||||
@ -1015,7 +1042,14 @@ const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.va
|
||||
const hasAnyGrayCallback = computed(() => hasGraySelectCallback.value || hasGrayDirectorySyncCallback.value)
|
||||
const allowAnonymousUpdateCheck = computed(() => Boolean(publishConfigForm.value.allowAnonymousUpdateCheck))
|
||||
|
||||
type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string }
|
||||
type FieldDef = {
|
||||
key: string
|
||||
label: string
|
||||
type?: 'password' | 'textarea' | 'select'
|
||||
placeholder?: string
|
||||
options?: { label: string; value: string }[]
|
||||
visibleWhen?: (values: Record<string, string>) => boolean
|
||||
}
|
||||
type GuideStep = { title: string; description: string }
|
||||
function marketUrlField(placeholder: string): FieldDef {
|
||||
return { key: 'marketUrl', label: '应用市场跳转页面(可选)', placeholder }
|
||||
@ -1192,8 +1226,15 @@ const STORE_DEFS: StoreDef[] = [
|
||||
label: '审核通知',
|
||||
shortLabel: '审核通知',
|
||||
fields: [
|
||||
{ key: 'webhookUrl', label: '通知地址', placeholder: 'https://your.service/store/webhook' },
|
||||
{ key: 'secret', label: '签名密钥', type: 'password', placeholder: '可选,用于回调验签' },
|
||||
{ key: 'notifyType', label: '通知类型', type: 'select', options: [
|
||||
{ label: '自定义(XuqmGroup 格式)', value: 'CUSTOM' },
|
||||
{ label: '钉钉机器人', value: 'DINGTALK' },
|
||||
{ label: '企业微信机器人', value: 'WECOM' },
|
||||
{ label: '飞书机器人', value: 'FEISHU' },
|
||||
]},
|
||||
{ key: 'webhookUrl', label: 'Webhook 地址', placeholder: 'https://your.service/webhook 或机器人 Webhook 地址' },
|
||||
{ key: 'secret', label: '签名密钥', type: 'password', placeholder: '可选,仅自定义格式使用',
|
||||
visibleWhen: (v) => !v.notifyType || v.notifyType === 'CUSTOM' },
|
||||
],
|
||||
guideSubtitle: '所有市场审核状态共用这一套通知配置',
|
||||
guideUrl: 'https://cloud.tencent.com/document/product/269/32431',
|
||||
@ -1329,6 +1370,7 @@ function normalizePublishConfig(raw: Record<string, unknown> | null | undefined)
|
||||
graySelectCallbackSecret: String((raw as Record<string, unknown>)?.graySelectCallbackSecret ?? ''),
|
||||
grayDirectorySyncCallbackUrl: normalizeCallbackUrl((raw as Record<string, unknown>)?.grayDirectorySyncCallbackUrl),
|
||||
grayDirectorySyncCallbackSecret: String((raw as Record<string, unknown>)?.grayDirectorySyncCallbackSecret ?? ''),
|
||||
parallelStoreUpload: raw?.parallelStoreUpload !== false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -2306,6 +2348,18 @@ onBeforeUnmount(() => {
|
||||
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-left: 8px; }
|
||||
|
||||
/* Store config grid */
|
||||
.store-upload-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.store-option-label { font-weight: 600; font-size: 14px; white-space: nowrap; }
|
||||
.store-option-hint { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
|
||||
.store-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户