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>
这个提交包含在:
XuqmGroup 2026-05-16 15:34:42 +08:00
父节点 6b891bee92
当前提交 61dd09763a
共有 4 个文件被更改,包括 161 次插入7 次删除

查看文件

@ -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));