feat(update): 重构灰度发布系统并新增标签管理功能
- 灰度模式简化为 PERCENT 和 MEMBERS 两种 - 新增成员标签系统,支持标签 CRUD 和按标签选择发版 - 成员同步保留已有标签,手动成员不受同步影响 - 支持标签 + 额外成员组合选择:groupNames + extraMemberIds - 发布时回调集成方获取成员列表,支持 AppSecret 签名验证 - 从 IM 服务导入成员功能 - 修复 isInGrayRelease() 中的 String.contains() 误匹配 bug - 移除 IM_PUSH_USERS、CUSTOMER_SYNC、CUSTOMER_CALLBACK 模式 - 更新前端界面,优化灰度成员选择体验 - 添加发布配置和操作日志等相关数据库表结构
这个提交包含在:
父节点
cbc29cf255
当前提交
f116b63369
@ -83,7 +83,6 @@ export type StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE
|
||||
export type StoreReviewState = 'PENDING' | 'SUBMITTING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
|
||||
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
|
||||
export type GrayMode = 'PERCENT' | 'MEMBERS'
|
||||
export type GraySelectionSource = 'LOCAL' | 'CALLBACK'
|
||||
|
||||
export interface PublishConfig {
|
||||
id: string
|
||||
@ -107,15 +106,15 @@ export interface OperationLog {
|
||||
|
||||
export interface GrayMember {
|
||||
userId: string
|
||||
name?: string
|
||||
groupName?: string
|
||||
extraJson?: string
|
||||
updatedAt?: string
|
||||
name: string
|
||||
source: 'SYNC' | 'MANUAL'
|
||||
active: boolean
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface GrayMemberGroup {
|
||||
groupName: string
|
||||
members: GrayMember[]
|
||||
export interface GrayTag {
|
||||
tagName: string
|
||||
memberCount: number
|
||||
}
|
||||
|
||||
export interface StoreConfig {
|
||||
@ -264,8 +263,8 @@ export const updateAdminApi = {
|
||||
enabled: boolean
|
||||
grayMode: GrayMode
|
||||
percent?: number
|
||||
memberIds?: string[]
|
||||
selectionSource?: GraySelectionSource
|
||||
groupNames?: string[]
|
||||
extraMemberIds?: string[]
|
||||
}) {
|
||||
return updateClient.post(`/api/v1/updates/app/${id}/gray`, body)
|
||||
},
|
||||
@ -298,8 +297,8 @@ export const updateAdminApi = {
|
||||
enabled: boolean
|
||||
grayMode: GrayMode
|
||||
percent?: number
|
||||
memberIds?: string[]
|
||||
selectionSource?: GraySelectionSource
|
||||
groupNames?: string[]
|
||||
extraMemberIds?: string[]
|
||||
}) {
|
||||
return updateClient.post(`/api/v1/rn/${id}/gray`, body)
|
||||
},
|
||||
@ -404,15 +403,47 @@ export const updateAdminApi = {
|
||||
})
|
||||
},
|
||||
|
||||
listGrayMembers(appKey: string, keyword?: string, groupName?: string) {
|
||||
return updateClient.get<{ data: GrayMemberGroup[] }>('/api/v1/updates/gray/members', {
|
||||
params: { appKey, ...(keyword && { keyword }), ...(groupName && { groupName }) },
|
||||
})
|
||||
},
|
||||
|
||||
syncGrayMembers(appKey: string) {
|
||||
return updateClient.post<{ data: GrayMemberGroup[] }>('/api/v1/updates/gray/members/sync', null, {
|
||||
listGrayMembers(appKey: string) {
|
||||
return updateClient.get<{ data: GrayMember[] }>('/api/v1/updates/gray/members', {
|
||||
params: { appKey },
|
||||
})
|
||||
},
|
||||
|
||||
addGrayMembers(appKey: string, userIds: string[], name?: string) {
|
||||
return updateClient.post('/api/v1/updates/gray/members', { appKey, userIds, name })
|
||||
},
|
||||
|
||||
deleteGrayMember(appKey: string, userId: string) {
|
||||
return updateClient.delete(`/api/v1/updates/gray/members/${userId}`, { params: { appKey } })
|
||||
},
|
||||
|
||||
syncGrayMembers(appKey: string, members: { userId: string; name?: string }[]) {
|
||||
return updateClient.post<{ data: { added: number; updated: number; removed: number } }>(
|
||||
'/api/v1/updates/gray/members/sync', { appKey, members })
|
||||
},
|
||||
|
||||
importGrayMembersFromIm(appKey: string) {
|
||||
return updateClient.post<{ data: { added: number; updated: number; removed: number } }>(
|
||||
'/api/v1/updates/gray/members/import-im', null, { params: { appKey } })
|
||||
},
|
||||
|
||||
listGrayTags(appKey: string) {
|
||||
return updateClient.get<{ data: GrayTag[] }>('/api/v1/updates/gray/tags', { params: { appKey } })
|
||||
},
|
||||
|
||||
createGrayTag(appKey: string, tagName: string, userIds: string[]) {
|
||||
return updateClient.post('/api/v1/updates/gray/tags', { appKey, tagName, userIds })
|
||||
},
|
||||
|
||||
deleteGrayTag(appKey: string, tagName: string) {
|
||||
return updateClient.delete(`/api/v1/updates/gray/tags/${tagName}`, { params: { appKey } })
|
||||
},
|
||||
|
||||
addMembersToTag(appKey: string, tagName: string, userIds: string[]) {
|
||||
return updateClient.post(`/api/v1/updates/gray/tags/${tagName}/members`, { appKey, userIds })
|
||||
},
|
||||
|
||||
removeMembersFromTag(appKey: string, tagName: string, userIds: string[]) {
|
||||
return updateClient.delete(`/api/v1/updates/gray/tags/${tagName}/members`, { data: { appKey, userIds } })
|
||||
},
|
||||
}
|
||||
|
||||
@ -335,26 +335,11 @@
|
||||
<el-slider v-model="publishConfigForm.defaultGrayPercent" :min="1" :max="100" show-input :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||
</el-form-item>
|
||||
<template v-if="publishConfigForm.grayMode === 'MEMBERS'">
|
||||
<el-form-item label="成员选择回调">
|
||||
<el-input v-model="publishConfigForm.graySelectCallbackUrl" placeholder="选择成员时调用的回调地址" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||
<el-form-item label="成员同步地址">
|
||||
<el-input v-model="publishConfigForm.graySyncUrl" placeholder="集成方成员列表接口地址(留空则使用 AppSecret 签名)" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成员选择密钥">
|
||||
<el-input v-model="publishConfigForm.graySelectCallbackSecret" type="password" show-password placeholder="可选,用于成员选择回调验签" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成员目录同步回调">
|
||||
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackUrl" placeholder="同步所有成员时调用的回调地址" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成员目录密钥">
|
||||
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackSecret" type="password" show-password placeholder="可选,用于成员同步回调验签" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成员选择方式">
|
||||
<el-radio-group v-model="publishConfigForm.graySelectionSource" :disabled="publishConfigForm.allowAnonymousUpdateCheck">
|
||||
<el-radio-button value="LOCAL" :disabled="!hasGrayDirectorySyncCallback">同步后本地选择</el-radio-button>
|
||||
<el-radio-button value="CALLBACK" :disabled="!hasGraySelectCallback">回调直接返回成员</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="syncGrayMembers" :loading="loadingGrayMembers" :disabled="!hasGrayDirectorySyncCallback || publishConfigForm.allowAnonymousUpdateCheck">同步成员</el-button>
|
||||
<el-form-item label="发布时回调地址">
|
||||
<el-input v-model="publishConfigForm.publishCallbackUrl" placeholder="发版灰度时回调集成方获取成员列表(可选)" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
@ -417,7 +402,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- Gray Release Dialog -->
|
||||
<el-dialog v-model="showGray" title="灰度发布配置" :width="dialogWidth">
|
||||
<el-dialog v-model="showGray" title="灰度发布配置" width="600px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item>
|
||||
<el-form-item label="灰度方式" v-if="grayForm.enabled">
|
||||
@ -429,51 +414,47 @@
|
||||
<el-form-item label="灰度比例" v-if="grayForm.enabled && grayForm.grayMode === 'PERCENT'">
|
||||
<el-slider v-model="grayForm.percent" :min="1" :max="100" show-input />
|
||||
</el-form-item>
|
||||
<template v-else-if="grayForm.enabled && grayForm.grayMode === 'MEMBERS'">
|
||||
<el-form-item label="成员来源">
|
||||
<el-radio-group v-model="grayForm.selectionSource">
|
||||
<el-radio-button value="LOCAL">同步后本地选择</el-radio-button>
|
||||
<el-radio-button value="CALLBACK">回调直接返回成员</el-radio-button>
|
||||
</el-radio-group>
|
||||
<template v-if="grayForm.enabled && grayForm.grayMode === 'MEMBERS'">
|
||||
<!-- 按标签选择 -->
|
||||
<el-form-item label="按标签选择">
|
||||
<div style="width:100%">
|
||||
<el-checkbox-group v-model="grayForm.groupNames">
|
||||
<div v-for="tag in grayTags" :key="tag.tagName" class="gray-tag-item">
|
||||
<el-checkbox :value="tag.tagName">
|
||||
{{ tag.tagName }} <el-tag size="small" type="info">{{ tag.memberCount }}人</el-tag>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
<el-empty v-if="!grayTags.length" description="暂无标签,请先在成员管理中创建" :image-size="40" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="成员搜索" v-if="grayForm.selectionSource === 'LOCAL'">
|
||||
<!-- 额外指定成员 -->
|
||||
<el-form-item label="追加指定成员">
|
||||
<el-input
|
||||
v-model="grayMemberKeyword"
|
||||
placeholder="搜索 userId / name / 分组"
|
||||
v-model="grayExtraInput"
|
||||
placeholder="输入 userId,多个用逗号分隔"
|
||||
clearable
|
||||
style="width: 320px"
|
||||
@change="loadGrayMembers"
|
||||
@blur="addExtraMembers"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-button style="margin-left: 8px" @click="syncGrayMembers" :loading="loadingGrayMembers">同步成员</el-button>
|
||||
<div v-if="grayForm.extraMemberIds.length" class="gray-extra-tags">
|
||||
<el-tag
|
||||
v-for="uid in grayForm.extraMemberIds"
|
||||
:key="uid"
|
||||
closable
|
||||
@close="removeExtraMember(uid)"
|
||||
size="small"
|
||||
>{{ uid }}</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 汇总 -->
|
||||
<el-form-item label="灰度人数">
|
||||
<span class="gray-summary">
|
||||
标签 {{ grayTagMemberCount }} 人 + 指定 {{ grayForm.extraMemberIds.length }} 人
|
||||
<template v-if="grayOverlapCount > 0">(去重 {{ grayOverlapCount }} 人)</template>
|
||||
= <strong>{{ grayTotalCount }}</strong> 人
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-if="grayForm.selectionSource === 'CALLBACK'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="选择成员时将调用发布配置里的成员选择回调,由你的系统直接返回可参与灰度的成员列表。"
|
||||
/>
|
||||
<div v-if="grayForm.selectionSource === 'LOCAL'" class="gray-member-groups">
|
||||
<el-empty v-if="!grayMembers.length" description="暂无成员数据,请先同步" />
|
||||
<el-collapse v-else>
|
||||
<el-collapse-item v-for="group in grayMembers" :key="group.groupName" :name="group.groupName">
|
||||
<template #title>
|
||||
<div class="gray-group-title">
|
||||
<span>{{ group.groupName }}</span>
|
||||
<el-tag size="small">{{ group.members.length }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-checkbox-group v-model="grayMemberIds">
|
||||
<div v-for="member in group.members" :key="member.userId" class="gray-member-row">
|
||||
<el-checkbox :value="member.userId">
|
||||
<span class="gray-member-id">{{ member.userId }}</span>
|
||||
<span class="gray-member-name">{{ member.name || '未命名' }}</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@ -1076,18 +1057,11 @@ const publishConfigForm = ref({
|
||||
allowAnonymousUpdateCheck: false,
|
||||
defaultGrayPercent: 0,
|
||||
grayMode: 'PERCENT' as GrayMode,
|
||||
graySelectionSource: 'LOCAL' as GraySelectionSource,
|
||||
graySelectCallbackUrl: '',
|
||||
graySelectCallbackSecret: '',
|
||||
grayDirectorySyncCallbackUrl: '',
|
||||
grayDirectorySyncCallbackSecret: '',
|
||||
graySyncUrl: '',
|
||||
publishCallbackUrl: '',
|
||||
enableRealtimeNotification: false,
|
||||
parallelStoreUpload: true,
|
||||
})
|
||||
const grayMembers = ref<GrayMemberGroup[]>([])
|
||||
const loadingGrayMembers = ref(false)
|
||||
const grayMemberKeyword = ref('')
|
||||
const grayMemberGroupFilter = ref('')
|
||||
const grayMemberIds = ref<string[]>([])
|
||||
const appPackageInspecting = ref(false)
|
||||
const appPackageUploadProgress = ref(0)
|
||||
const appVersionUploadProgress = ref(0)
|
||||
@ -1110,9 +1084,6 @@ let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let storeReviewAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let storeReviewDialogPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
|
||||
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
|
||||
const hasAnyGrayCallback = computed(() => hasGraySelectCallback.value || hasGrayDirectorySyncCallback.value)
|
||||
const allowAnonymousUpdateCheck = computed(() => Boolean(publishConfigForm.value.allowAnonymousUpdateCheck))
|
||||
|
||||
type FieldDef = {
|
||||
@ -1774,13 +1745,48 @@ async function confirmSubmitToStores() {
|
||||
const showGray = ref(false)
|
||||
const submittingGray = ref(false)
|
||||
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
|
||||
const grayTags = ref<GrayTag[]>([])
|
||||
const grayExtraInput = ref('')
|
||||
const grayForm = ref({
|
||||
enabled: true,
|
||||
grayMode: 'PERCENT' as GrayMode,
|
||||
percent: 10,
|
||||
selectionSource: 'LOCAL' as GraySelectionSource,
|
||||
groupNames: [] as string[],
|
||||
extraMemberIds: [] as string[],
|
||||
})
|
||||
|
||||
const grayTagMemberCount = computed(() => {
|
||||
const selected = new Set(grayForm.value.groupNames)
|
||||
return grayTags.value
|
||||
.filter(t => selected.has(t.tagName))
|
||||
.reduce((sum, t) => sum + t.memberCount, 0)
|
||||
})
|
||||
|
||||
const grayOverlapCount = computed(() => {
|
||||
// 简化:无法精确计算标签与额外成员的重叠,显示为 0
|
||||
return 0
|
||||
})
|
||||
|
||||
const grayTotalCount = computed(() => {
|
||||
return grayTagMemberCount.value + grayForm.value.extraMemberIds.length - grayOverlapCount.value
|
||||
})
|
||||
|
||||
function addExtraMembers() {
|
||||
const input = grayExtraInput.value.trim()
|
||||
if (!input) return
|
||||
const ids = input.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean)
|
||||
for (const id of ids) {
|
||||
if (!grayForm.value.extraMemberIds.includes(id)) {
|
||||
grayForm.value.extraMemberIds.push(id)
|
||||
}
|
||||
}
|
||||
grayExtraInput.value = ''
|
||||
}
|
||||
|
||||
function removeExtraMember(uid: string) {
|
||||
grayForm.value.extraMemberIds = grayForm.value.extraMemberIds.filter(id => id !== uid)
|
||||
}
|
||||
|
||||
function openGrayDialog(row: { id: string }, type: 'app' | 'rn') {
|
||||
if (allowAnonymousUpdateCheck.value) {
|
||||
ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用')
|
||||
@ -1789,49 +1795,22 @@ function openGrayDialog(row: { id: string }, type: 'app' | 'rn') {
|
||||
grayTarget.value = { id: row.id, type }
|
||||
grayForm.value = {
|
||||
enabled: true,
|
||||
grayMode: hasAnyGrayCallback.value ? publishConfigForm.value.grayMode : 'PERCENT',
|
||||
grayMode: 'PERCENT',
|
||||
percent: publishConfigForm.value.defaultGrayPercent || 10,
|
||||
selectionSource: hasGrayDirectorySyncCallback.value
|
||||
? publishConfigForm.value.graySelectionSource
|
||||
: (hasGraySelectCallback.value ? 'CALLBACK' : 'LOCAL'),
|
||||
groupNames: [],
|
||||
extraMemberIds: [],
|
||||
}
|
||||
grayMemberIds.value = []
|
||||
grayMemberKeyword.value = ''
|
||||
grayMemberGroupFilter.value = ''
|
||||
grayMembers.value = []
|
||||
grayExtraInput.value = ''
|
||||
showGray.value = true
|
||||
if (grayForm.value.selectionSource === 'LOCAL' && hasGrayDirectorySyncCallback.value) {
|
||||
loadGrayMembers()
|
||||
}
|
||||
loadGrayTags()
|
||||
}
|
||||
|
||||
async function loadGrayMembers() {
|
||||
if (!showGray.value || !hasGrayDirectorySyncCallback.value) return
|
||||
loadingGrayMembers.value = true
|
||||
async function loadGrayTags() {
|
||||
try {
|
||||
const res = await updateAdminApi.listGrayMembers(appKey.value, grayMemberKeyword.value || undefined, grayMemberGroupFilter.value || undefined)
|
||||
grayMembers.value = res.data.data
|
||||
const res = await updateAdminApi.listGrayTags(appKey.value)
|
||||
grayTags.value = res.data.data
|
||||
} catch {
|
||||
grayMembers.value = []
|
||||
} finally {
|
||||
loadingGrayMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGrayMembers() {
|
||||
if (!hasGrayDirectorySyncCallback.value) {
|
||||
ElMessage.warning('未配置成员目录同步回调,无法同步成员')
|
||||
return
|
||||
}
|
||||
loadingGrayMembers.value = true
|
||||
try {
|
||||
const res = await updateAdminApi.syncGrayMembers(appKey.value)
|
||||
grayMembers.value = res.data.data
|
||||
ElMessage.success('成员已同步')
|
||||
} catch {
|
||||
ElMessage.error('同步失败')
|
||||
} finally {
|
||||
loadingGrayMembers.value = false
|
||||
grayTags.value = []
|
||||
}
|
||||
}
|
||||
|
||||
@ -1841,16 +1820,9 @@ async function submitGray() {
|
||||
ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用')
|
||||
return
|
||||
}
|
||||
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL' && !hasGrayDirectorySyncCallback.value) {
|
||||
ElMessage.warning('未配置成员目录同步回调,无法选择本地成员')
|
||||
return
|
||||
}
|
||||
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'CALLBACK' && !hasGraySelectCallback.value) {
|
||||
ElMessage.warning('未配置成员选择回调,无法使用回调成员选择')
|
||||
return
|
||||
}
|
||||
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL' && !grayMemberIds.value.length) {
|
||||
ElMessage.warning('请选择至少一个灰度成员')
|
||||
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS'
|
||||
&& !grayForm.value.groupNames.length && !grayForm.value.extraMemberIds.length) {
|
||||
ElMessage.warning('请至少选择一个标签或指定一个成员')
|
||||
return
|
||||
}
|
||||
submittingGray.value = true
|
||||
@ -1860,17 +1832,17 @@ async function submitGray() {
|
||||
enabled: boolean
|
||||
grayMode: GrayMode
|
||||
percent?: number
|
||||
memberIds?: string[]
|
||||
selectionSource?: GraySelectionSource
|
||||
groupNames?: string[]
|
||||
extraMemberIds?: string[]
|
||||
} = {
|
||||
enabled: grayForm.value.enabled,
|
||||
grayMode: grayForm.value.grayMode,
|
||||
selectionSource: grayForm.value.selectionSource,
|
||||
}
|
||||
if (grayForm.value.grayMode === 'PERCENT') {
|
||||
payload.percent = grayForm.value.percent
|
||||
} else {
|
||||
payload.memberIds = grayMemberIds.value
|
||||
payload.groupNames = grayForm.value.groupNames
|
||||
payload.extraMemberIds = grayForm.value.extraMemberIds
|
||||
}
|
||||
if (type === 'app') {
|
||||
await updateAdminApi.grayAppVersion(id, payload)
|
||||
@ -2791,27 +2763,28 @@ onBeforeUnmount(() => {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.gray-member-groups {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gray-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gray-member-row {
|
||||
.gray-tag-item {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.gray-tag-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gray-member-id {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
.gray-extra-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gray-member-name {
|
||||
.gray-summary {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.gray-summary strong {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upload-progress-block {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户