feat(admin): 双邀请太阳码与自动拉取;企业/个人 Invite API;用户头像字段;题库工具与抖音测试页调整

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-30 11:31:48 +08:00
parent de3a3fd637
commit f589b5d50c
6 changed files with 541 additions and 359 deletions

View File

@@ -110,15 +110,24 @@
<div class="panel-head row">
<div>
<h2 class="panel-title">邀请小程序码</h2>
<p class="panel-desc">员工 / 客户扫码进入企业测评</p>
<p class="panel-desc">企业版进企业测评个人版进小程序首页</p>
</div>
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
{{ inviteQrcode ? '刷新' : '生成' }}
{{ inviteQrcodeEnterprise || inviteQrcodePersonal ? '刷新' : '生成' }}
</el-button>
</div>
<div class="invite-body">
<img v-if="inviteQrcode" :src="inviteQrcode" alt="邀请码" class="invite-img" />
<span v-else class="invite-placeholder">点击生成</span>
<template v-if="inviteQrcodeEnterprise || inviteQrcodePersonal">
<div v-if="inviteQrcodeEnterprise" class="invite-card">
<span class="invite-label">企业版</span>
<img :src="inviteQrcodeEnterprise" alt="企业版太阳码" class="invite-img" />
</div>
<div v-if="inviteQrcodePersonal" class="invite-card">
<span class="invite-label">个人版</span>
<img :src="inviteQrcodePersonal" alt="个人版太阳码" class="invite-img" />
</div>
</template>
<span v-else class="invite-placeholder">{{ inviteLoadError || '进入页面将自动加载,也可点击生成' }}</span>
</div>
</div>
</aside>
@@ -189,7 +198,9 @@ const faceSubtypeHints = ref<{
const loading = ref(false)
const inviteLoading = ref(false)
const inviteQrcode = ref<string>('')
const inviteQrcodeEnterprise = ref<string>('')
const inviteQrcodePersonal = ref<string>('')
const inviteLoadError = ref<string>('')
/** 侧栏表格最大高度:单屏内滚动,不撑开整页 */
const tableMaxH = 220
@@ -381,21 +392,32 @@ const loadData = async () => {
onMounted(() => {
loadData()
loadInviteQrcode()
})
const loadInviteQrcode = async () => {
if (inviteLoading.value) return
inviteLoading.value = true
inviteLoadError.value = ''
try {
const res: any = await request.get('/admin/invite/qrcode')
const qrcode = res?.data?.qrcode
if (qrcode && typeof qrcode === 'string') {
inviteQrcode.value = qrcode
} else {
ElMessage.error(res?.message || res?.msg || '生成失败,请确认企业绑定')
const d = res?.data
const ent = d?.enterprise?.qrcode ?? d?.qrcode
const per = d?.personal?.qrcode
if (typeof ent === 'string' && ent) inviteQrcodeEnterprise.value = ent
else inviteQrcodeEnterprise.value = ''
if (typeof per === 'string' && per) inviteQrcodePersonal.value = per
else inviteQrcodePersonal.value = ''
if (!inviteQrcodeEnterprise.value && !inviteQrcodePersonal.value) {
const msg = res?.message || res?.msg || '生成失败,请确认企业绑定'
inviteLoadError.value = msg
ElMessage.error(msg)
}
} catch (error: any) {
ElMessage.error(error?.message || '生成失败')
const msg = error?.message || '生成失败'
inviteLoadError.value = msg
ElMessage.error(msg)
} finally {
inviteLoading.value = false
}
@@ -807,11 +829,26 @@ const loadInviteQrcode = async () => {
.invite-body {
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
min-height: 112px;
}
.invite-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.invite-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.invite-img {
width: 112px;
height: 112px;

View File

@@ -35,7 +35,20 @@
<el-table-column label="用户信息" min-width="180">
<template #default="{ row }">
<div class="user-info-cell">
<div class="user-avatar">{{ (row.username || '?')[0] }}</div>
<img
v-if="displayAvatarUrl(row.avatar)"
:src="displayAvatarUrl(row.avatar)"
class="user-avatar user-avatar-img"
referrerpolicy="no-referrer"
alt=""
/>
<div
v-else
class="user-avatar user-avatar-letter"
:style="{ backgroundColor: avatarBgColor(row) }"
>
{{ avatarLetter(row) }}
</div>
<div class="user-details">
<div class="username">{{ row.username || '未设置昵称' }}</div>
<div class="openid-line">{{ row.openid || '—' }}</div>
@@ -685,6 +698,27 @@ async function loadUsers() {
}
}
/** 有效头像地址(接口字段 avatar过滤空白 */
function displayAvatarUrl(avatar: string | null | undefined) {
const u = avatar != null ? String(avatar).trim() : ''
return u || ''
}
/** 无头像时文字占位:昵称首字 */
function avatarLetter(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
const ch = name.charAt(0) || '?'
return /[a-zA-Z]/.test(ch) ? ch.toUpperCase() : ch
}
const AVATAR_PALETTE = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308']
function avatarBgColor(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
let hash = 0
for (let i = 0; i < name.length; i++) hash += name.charCodeAt(i)
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]
}
function formatPhone(phone: string) {
if (!phone) return '-'
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
@@ -1072,6 +1106,16 @@ onMounted(() => {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.user-avatar-img {
object-fit: cover;
border: 1px solid #e5e7eb;
background: #f3f4f6;
}
.user-avatar-letter {
background-color: #7c3aed;
color: #fff;
display: flex;
@@ -1079,7 +1123,6 @@ onMounted(() => {
justify-content: center;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
}
.user-details {