feat: 企业权限上限与迁移脚本;管理端/超管概览与财务;双端小程序 tab/订单/历史/测评选择等同步
Made-with: Cursor
This commit is contained in:
@@ -2,9 +2,7 @@
|
||||
<div class="dashboard-viewport" v-loading="loading">
|
||||
<header class="dash-head">
|
||||
<h1 class="dash-title">数据概览</h1>
|
||||
<p class="dash-tagline">
|
||||
企业侧测评闭环:小程序面相与 MBTI / DISC / PDP 答题 → MySQL 落库 → 本页看用户与测试趋势、拉新邀请码(全链路见开发文档《小程序全链路功能与接口》)。
|
||||
</p>
|
||||
<p class="dash-tagline"></p>
|
||||
</header>
|
||||
|
||||
<div class="dash-kpis">
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<div class="finance-page" :class="{ 'is-embedded': embedded }">
|
||||
<!-- 嵌入系统设置「企业余额」时单独展示操作区(独立页仍有完整 page-header) -->
|
||||
<div v-if="embedded" class="embedded-toolbar">
|
||||
<div class="embedded-toolbar-actions">
|
||||
<el-button @click="loadAll" :loading="loading">刷新</el-button>
|
||||
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!embedded" class="page-header">
|
||||
<div>
|
||||
<h2>企业余额</h2>
|
||||
@@ -383,4 +391,17 @@ onMounted(() => {
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.embedded-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.embedded-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,31 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card" :class="{ 'flat-embed': activeTab === 'finance' }">
|
||||
<div v-if="activeTab === 'features'" class="tab-content" v-loading="permLoading">
|
||||
<div class="content-header">
|
||||
<h3>终端功能开关</h3>
|
||||
<p class="content-description">
|
||||
仅可在超管为贵司授权的范围内调整。若超管未开放某项(如人脸分析),此处不显示对应开关。
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<template v-if="visibleAdminPermItems.length === 0">
|
||||
<p class="hint-muted">当前无超管授权项可配置,请联系平台管理员。</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="perm-admin-row" v-for="p in visibleAdminPermItems" :key="p.key">
|
||||
<span class="perm-admin-label">{{ p.label }}</span>
|
||||
<el-switch v-model="adminPerms[p.key]" />
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAdminPermissions" :loading="permSaving">
|
||||
保存功能开关
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'account'" class="tab-content">
|
||||
<div class="content-header">
|
||||
<h3>管理员账号设置</h3>
|
||||
@@ -82,14 +107,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { getAdminRole } from '@/utils/authStorage'
|
||||
import Finance from './Finance.vue'
|
||||
|
||||
const TAB_IDS = ['account', 'finance'] as const
|
||||
const TAB_IDS = ['account', 'features', 'finance'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
@@ -101,15 +127,25 @@ const router = useRouter()
|
||||
const activeTab = ref<TabId>('account')
|
||||
const loading = ref(false)
|
||||
|
||||
const tabs: { label: string; value: TabId }[] = [
|
||||
{ label: '账号设置', value: 'account' },
|
||||
{ label: '企业余额', value: 'finance' }
|
||||
]
|
||||
const isEnterpriseAdmin = () => getAdminRole() === 'enterprise_admin'
|
||||
|
||||
const tabs = computed(() => {
|
||||
const rows: { label: string; value: TabId }[] = [{ label: '账号设置', value: 'account' }]
|
||||
if (isEnterpriseAdmin()) {
|
||||
rows.push({ label: '功能开关', value: 'features' })
|
||||
}
|
||||
rows.push({ label: '企业余额', value: 'finance' })
|
||||
return rows
|
||||
})
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
if (t === 'features' && !isEnterpriseAdmin()) {
|
||||
activeTab.value = 'account'
|
||||
} else {
|
||||
activeTab.value = t
|
||||
}
|
||||
} else {
|
||||
activeTab.value = 'account'
|
||||
}
|
||||
@@ -136,6 +172,65 @@ const accountConfig = reactive({
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const permItems = [
|
||||
{ key: 'face', label: '人脸分析' },
|
||||
{ key: 'mbti', label: 'MBTI' },
|
||||
{ key: 'pdp', label: 'PDP' },
|
||||
{ key: 'disc', label: 'DISC' },
|
||||
{ key: 'distribution', label: '分销推广' }
|
||||
] as const
|
||||
|
||||
const defaultAdminPermissions = () =>
|
||||
({ face: true, mbti: true, pdp: true, disc: true, distribution: true }) as Record<string, boolean>
|
||||
|
||||
const permLoading = ref(false)
|
||||
const permSaving = ref(false)
|
||||
const adminPermsCeiling = ref<Record<string, boolean>>(defaultAdminPermissions())
|
||||
const adminPerms = reactive<Record<string, boolean>>(defaultAdminPermissions())
|
||||
|
||||
const visibleAdminPermItems = computed(() =>
|
||||
permItems.filter((p) => adminPermsCeiling.value[p.key] !== false)
|
||||
)
|
||||
|
||||
const loadAdminPermissions = async () => {
|
||||
if (!isEnterpriseAdmin()) return
|
||||
permLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/enterprise/permissions')
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(adminPerms, defaultAdminPermissions(), res.data.permissions || {})
|
||||
adminPermsCeiling.value = { ...defaultAdminPermissions(), ...(res.data.permissionsCeiling || {}) }
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
ElMessage.error(e?.message || '加载功能开关失败')
|
||||
} finally {
|
||||
permLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveAdminPermissions = async () => {
|
||||
if (!isEnterpriseAdmin()) return
|
||||
permSaving.value = true
|
||||
try {
|
||||
const body: Record<string, boolean> = { ...defaultAdminPermissions() }
|
||||
for (const p of permItems) {
|
||||
body[p.key] = adminPermsCeiling.value[p.key] === false ? false : !!adminPerms[p.key]
|
||||
}
|
||||
const res: any = await request.put('/admin/enterprise/permissions', { permissions: body })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('功能开关已保存')
|
||||
await loadAdminPermissions()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
permSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -186,9 +281,21 @@ watch(
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
watch(
|
||||
() => activeTab.value,
|
||||
(tab) => {
|
||||
if (tab === 'features' && isEnterpriseAdmin()) {
|
||||
loadAdminPermissions()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
loadSettings()
|
||||
if (activeTab.value === 'features' && isEnterpriseAdmin()) {
|
||||
loadAdminPermissions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -217,6 +324,26 @@ onMounted(() => {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hint-muted {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.perm-admin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 360px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.perm-admin-label {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
|
||||
@@ -106,9 +106,26 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<el-table-column label="超管授权" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="perm-tags">
|
||||
<el-tag v-for="p in permItems" :key="p.key" size="small"
|
||||
:type="permCeilingVal(row, p.key) !== false ? 'success' : 'info'"
|
||||
:effect="permCeilingVal(row, p.key) !== false ? 'light' : 'plain'"
|
||||
class="perm-tag"
|
||||
>{{ p.label }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="176" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-tooltip content="邀请小程序码" placement="top">
|
||||
<el-button link class="action-invite" @click="openInviteQrcodeDialog(row)">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button link @click="handleView(row)"><el-icon><View /></el-icon></el-button>
|
||||
<el-button link @click="handleEdit(row)"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)"><el-icon><Delete /></el-icon></el-button>
|
||||
@@ -222,6 +239,16 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="功能授权(超管)" class="mt-12">
|
||||
<p class="perm-form-hint">决定企业管理员能否在后台为终端用户打开该项;关闭后管理端不会出现对应开关。</p>
|
||||
<div class="perm-switch-group">
|
||||
<div class="perm-switch-item" v-for="p in permItems" :key="p.key">
|
||||
<span class="perm-label">{{ p.label }}</span>
|
||||
<el-switch v-model="newEnterprise.permissions[p.key]" />
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@@ -294,6 +321,16 @@
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="功能授权(超管)">
|
||||
<p class="perm-form-hint">保存后将收紧企业管理员可调范围;对已关闭项,终端与企业后台同步关闭。</p>
|
||||
<div class="perm-switch-group">
|
||||
<div class="perm-switch-item" v-for="p in permItems" :key="p.key">
|
||||
<span class="perm-label">{{ p.label }}</span>
|
||||
<el-switch v-model="editEnterprise.permissions[p.key]" />
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@@ -660,6 +697,54 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 邀请小程序码(按当前行企业 ID) -->
|
||||
<el-dialog
|
||||
v-model="showInviteQrcodeDialog"
|
||||
width="440px"
|
||||
class="custom-dialog invite-qrcode-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
@closed="resetInviteQrcodeDialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<h3 class="dialog-title">邀请小程序码</h3>
|
||||
<p class="dialog-subtitle">
|
||||
{{ inviteDialogEnterprise?.name || '企业' }} · 企业版进企业测评,个人版进小程序首页
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="inviteQrcodeLoading" class="invite-qrcode-dialog-body">
|
||||
<div
|
||||
v-if="inviteQrcodeEnterpriseB64 || inviteQrcodePersonalB64"
|
||||
class="invite-qrcode-pair"
|
||||
>
|
||||
<div v-if="inviteQrcodeEnterpriseB64" class="invite-qrcode-card">
|
||||
<span class="invite-qrcode-label">企业版</span>
|
||||
<img :src="inviteQrcodeEnterpriseB64" alt="企业版太阳码" class="invite-qrcode-img" />
|
||||
</div>
|
||||
<div v-if="inviteQrcodePersonalB64" class="invite-qrcode-card">
|
||||
<span class="invite-qrcode-label">个人版</span>
|
||||
<img :src="inviteQrcodePersonalB64" alt="个人版太阳码" class="invite-qrcode-img" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="invite-qrcode-placeholder">{{ inviteQrcodeError || '加载中…' }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="showInviteQrcodeDialog = false" class="cancel-btn">关闭</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
color="#3b82f6"
|
||||
:loading="inviteQrcodeLoading"
|
||||
@click="loadInviteQrcodeForDialog"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -680,7 +765,8 @@ import {
|
||||
DataAnalysis,
|
||||
Document,
|
||||
Wallet,
|
||||
Postcard
|
||||
Postcard,
|
||||
Picture
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
@@ -715,6 +801,61 @@ const detailOrderPageSize = ref(10)
|
||||
const enterpriseDetailTab = ref('overview')
|
||||
const creating = ref(false)
|
||||
|
||||
/** 邀请小程序码(企业列表操作列) */
|
||||
const showInviteQrcodeDialog = ref(false)
|
||||
const inviteDialogEnterprise = ref<{ id: number; name: string } | null>(null)
|
||||
const inviteQrcodeLoading = ref(false)
|
||||
const inviteQrcodeEnterpriseB64 = ref('')
|
||||
const inviteQrcodePersonalB64 = ref('')
|
||||
const inviteQrcodeError = ref('')
|
||||
|
||||
const resetInviteQrcodeDialog = () => {
|
||||
inviteDialogEnterprise.value = null
|
||||
inviteQrcodeEnterpriseB64.value = ''
|
||||
inviteQrcodePersonalB64.value = ''
|
||||
inviteQrcodeError.value = ''
|
||||
}
|
||||
|
||||
const loadInviteQrcodeForDialog = async () => {
|
||||
const ent = inviteDialogEnterprise.value
|
||||
if (!ent?.id) return
|
||||
inviteQrcodeLoading.value = true
|
||||
inviteQrcodeError.value = ''
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/invite/qrcode', {
|
||||
params: { enterpriseId: ent.id }
|
||||
})
|
||||
const d = res?.data
|
||||
const qEnt = d?.enterprise?.qrcode ?? d?.qrcode
|
||||
const qPer = d?.personal?.qrcode
|
||||
inviteQrcodeEnterpriseB64.value = typeof qEnt === 'string' && qEnt ? qEnt : ''
|
||||
inviteQrcodePersonalB64.value = typeof qPer === 'string' && qPer ? qPer : ''
|
||||
if (!inviteQrcodeEnterpriseB64.value && !inviteQrcodePersonalB64.value) {
|
||||
inviteQrcodeError.value = res?.message || res?.msg || '生成失败,请检查小程序配置'
|
||||
ElMessage.error(inviteQrcodeError.value)
|
||||
}
|
||||
} catch (e: any) {
|
||||
inviteQrcodeError.value = e?.message || '加载失败'
|
||||
ElMessage.error(inviteQrcodeError.value)
|
||||
} finally {
|
||||
inviteQrcodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openInviteQrcodeDialog = (row: any) => {
|
||||
inviteDialogEnterprise.value = {
|
||||
id: Number(row.id),
|
||||
name: row.name ? String(row.name) : `企业#${row.id}`
|
||||
}
|
||||
inviteQrcodeEnterpriseB64.value = ''
|
||||
inviteQrcodePersonalB64.value = ''
|
||||
inviteQrcodeError.value = ''
|
||||
showInviteQrcodeDialog.value = true
|
||||
nextTick(() => {
|
||||
loadInviteQrcodeForDialog()
|
||||
})
|
||||
}
|
||||
|
||||
/** 企业侧栏头像首字 */
|
||||
const enterpriseAvatarLetter = computed(() => {
|
||||
const n = viewEnterpriseData.value?.name
|
||||
@@ -773,6 +914,25 @@ const statusOptions = [
|
||||
{ label: '已停用', value: 'disabled' }
|
||||
]
|
||||
|
||||
const permItems = [
|
||||
{ key: 'face', label: '人脸分析' },
|
||||
{ key: 'mbti', label: 'MBTI' },
|
||||
{ key: 'pdp', label: 'PDP' },
|
||||
{ key: 'disc', label: 'DISC' },
|
||||
{ key: 'distribution', label: '分销' },
|
||||
]
|
||||
|
||||
const defaultPermissions = () => ({ face: true, mbti: true, pdp: true, disc: true, distribution: true })
|
||||
|
||||
/** 列表/展示:超管授权上限(兼容未返回 permissionsCeiling 的旧接口) */
|
||||
const permCeilingVal = (row: Record<string, any>, key: string) => {
|
||||
const c = row.permissionsCeiling
|
||||
if (c && typeof c === 'object' && key in c) return c[key]
|
||||
const p = row.permissions
|
||||
if (p && typeof p === 'object' && key in p) return p[key]
|
||||
return true
|
||||
}
|
||||
|
||||
const enterprises = ref<any[]>([])
|
||||
|
||||
const newEnterprise = reactive({
|
||||
@@ -785,7 +945,8 @@ const newEnterprise = reactive({
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
status: 'operating',
|
||||
trialExpireAt: '' as string
|
||||
trialExpireAt: '' as string,
|
||||
permissions: defaultPermissions() as Record<string, boolean>
|
||||
})
|
||||
|
||||
const editEnterprise = reactive({
|
||||
@@ -795,7 +956,8 @@ const editEnterprise = reactive({
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
status: 'operating',
|
||||
trialExpireAt: ''
|
||||
trialExpireAt: '',
|
||||
permissions: defaultPermissions() as Record<string, boolean>
|
||||
})
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
@@ -925,7 +1087,8 @@ const handleCreateEnterprise = async () => {
|
||||
contactName: newEnterprise.contactName,
|
||||
contactPhone: newEnterprise.contactPhone,
|
||||
contactEmail: newEnterprise.contactEmail,
|
||||
status: newEnterprise.status
|
||||
status: newEnterprise.status,
|
||||
permissions: { ...newEnterprise.permissions }
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
@@ -941,7 +1104,8 @@ const handleCreateEnterprise = async () => {
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
status: 'operating',
|
||||
trialExpireAt: ''
|
||||
trialExpireAt: '',
|
||||
permissions: defaultPermissions()
|
||||
})
|
||||
loadEnterprises()
|
||||
}
|
||||
@@ -1059,11 +1223,18 @@ const handleEditDialogOpen = async () => {
|
||||
editEnterprise.contactEmail = ''
|
||||
editEnterprise.status = 'operating'
|
||||
editEnterprise.trialExpireAt = ''
|
||||
editEnterprise.permissions = defaultPermissions()
|
||||
|
||||
// 等待一个tick确保清空完成
|
||||
await nextTick()
|
||||
|
||||
// 使用 Object.assign 一次性更新所有字段
|
||||
const ceilingFromApi =
|
||||
data.permissionsCeiling && typeof data.permissionsCeiling === 'object'
|
||||
? { ...defaultPermissions(), ...data.permissionsCeiling }
|
||||
: data.permissions && typeof data.permissions === 'object'
|
||||
? { ...defaultPermissions(), ...data.permissions }
|
||||
: defaultPermissions()
|
||||
Object.assign(editEnterprise, {
|
||||
name: data.name || '',
|
||||
code: data.code || '',
|
||||
@@ -1071,7 +1242,8 @@ const handleEditDialogOpen = async () => {
|
||||
contactPhone: data.contactPhone || '',
|
||||
contactEmail: data.contactEmail || '',
|
||||
status: data.status || 'operating',
|
||||
trialExpireAt: trialExpireAtStr
|
||||
trialExpireAt: trialExpireAtStr,
|
||||
permissions: ceilingFromApi
|
||||
})
|
||||
|
||||
console.log('表单数据已填充:', editEnterprise)
|
||||
@@ -1101,7 +1273,8 @@ const handleSaveEdit = async () => {
|
||||
contactName: editEnterprise.contactName,
|
||||
contactPhone: editEnterprise.contactPhone,
|
||||
contactEmail: editEnterprise.contactEmail,
|
||||
status: editEnterprise.status
|
||||
status: editEnterprise.status,
|
||||
permissions: { ...editEnterprise.permissions }
|
||||
}
|
||||
|
||||
// 如果选择试用,添加到期时间(转换为时间戳)
|
||||
@@ -1336,10 +1509,56 @@ watch([searchTerm, statusFilter], () => {
|
||||
&.el-button--danger:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.action-invite:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-qrcode-dialog-body {
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.invite-qrcode-pair {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.invite-qrcode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-qrcode-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.invite-qrcode-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.invite-qrcode-placeholder {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
@@ -1962,6 +2181,48 @@ watch([searchTerm, statusFilter], () => {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 权限标签 */
|
||||
.perm-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.perm-tag {
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.perm-form-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 10px 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* 权限开关组(编辑 / 创建弹窗) */
|
||||
.perm-switch-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 24px;
|
||||
}
|
||||
|
||||
.perm-switch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.perm-label {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ud-wrap {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -133,6 +133,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请小程序码(与企业后台一致:企业版 + 个人版) -->
|
||||
<div class="invite-section">
|
||||
<div class="section-header invite-header">
|
||||
<div>
|
||||
<h2 class="section-title">邀请小程序码</h2>
|
||||
<p class="section-subtitle">企业版进企业测评,个人版进小程序首页;企业版太阳码对应下方所选企业</p>
|
||||
</div>
|
||||
<div class="invite-header-actions">
|
||||
<el-select
|
||||
v-model="inviteSelectedEnterpriseId"
|
||||
placeholder="选择企业(生成企业版码)"
|
||||
filterable
|
||||
clearable
|
||||
style="width: 220px"
|
||||
size="small"
|
||||
@change="loadInviteQrcode"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in inviteEnterpriseOptions"
|
||||
:key="opt.id"
|
||||
:label="opt.name"
|
||||
:value="opt.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" :loading="inviteLoading" @click="loadInviteQrcode">
|
||||
{{ inviteQrcodeEnterprise || inviteQrcodePersonal ? '刷新' : '生成' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invite-body">
|
||||
<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>
|
||||
|
||||
<!-- 测试趋势折线图 -->
|
||||
<div class="trend-section">
|
||||
<div class="section-header">
|
||||
@@ -238,6 +282,7 @@ import {
|
||||
Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
@@ -272,10 +317,18 @@ const stats = reactive({
|
||||
|
||||
interface DynamicItem { icon: string; text: string; time: string }
|
||||
interface RankingItem { name: string; tests: number; amount: number }
|
||||
interface InviteEnterpriseOption { id: number; name: string }
|
||||
|
||||
const recentDynamics = ref<DynamicItem[]>([])
|
||||
const enterpriseRanking = ref<RankingItem[]>([])
|
||||
|
||||
const inviteLoading = ref(false)
|
||||
const inviteQrcodeEnterprise = ref<string>('')
|
||||
const inviteQrcodePersonal = ref<string>('')
|
||||
const inviteLoadError = ref<string>('')
|
||||
const inviteEnterpriseOptions = ref<InviteEnterpriseOption[]>([])
|
||||
const inviteSelectedEnterpriseId = ref<number | null>(null)
|
||||
|
||||
type TrendPoint = { date: string; face: number; mbti: number; pdp: number; disc: number; total: number }
|
||||
const testTrends = ref<TrendPoint[]>([])
|
||||
|
||||
@@ -430,11 +483,76 @@ const handleViewAll = () => {
|
||||
router.push('/superadmin/enterprises')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const loadInviteEnterprisesAndSettings = async () => {
|
||||
try {
|
||||
const [settingsRes, entRes]: any[] = await Promise.all([
|
||||
request.get('/superadmin/settings'),
|
||||
request.get('/superadmin/enterprises', { params: { page: 1, pageSize: 500 } })
|
||||
])
|
||||
const list = entRes?.data?.list ?? []
|
||||
inviteEnterpriseOptions.value = list.map((r: any) => ({
|
||||
id: Number(r.id),
|
||||
name: r.name ? String(r.name) : `企业#${r.id}`
|
||||
}))
|
||||
const defRaw = settingsRes?.data?.system?.defaultEnterpriseId
|
||||
const defNum =
|
||||
defRaw != null && defRaw !== '' && !Number.isNaN(Number(defRaw)) ? Number(defRaw) : null
|
||||
const idSet = new Set(inviteEnterpriseOptions.value.map(o => o.id))
|
||||
if (defNum != null && defNum > 0 && idSet.has(defNum)) {
|
||||
inviteSelectedEnterpriseId.value = defNum
|
||||
} else if (inviteEnterpriseOptions.value.length > 0) {
|
||||
inviteSelectedEnterpriseId.value = inviteEnterpriseOptions.value[0].id
|
||||
} else {
|
||||
inviteSelectedEnterpriseId.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载邀请码企业列表失败:', e)
|
||||
inviteEnterpriseOptions.value = []
|
||||
inviteSelectedEnterpriseId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const loadInviteQrcode = async () => {
|
||||
if (inviteLoading.value) return
|
||||
inviteLoading.value = true
|
||||
inviteLoadError.value = ''
|
||||
try {
|
||||
const params: Record<string, number> = {}
|
||||
if (inviteSelectedEnterpriseId.value != null && inviteSelectedEnterpriseId.value > 0) {
|
||||
params.enterpriseId = inviteSelectedEnterpriseId.value
|
||||
}
|
||||
const res: any = await request.get('/superadmin/invite/qrcode', {
|
||||
params: Object.keys(params).length ? params : undefined
|
||||
})
|
||||
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) {
|
||||
const msg = error?.message || '生成失败'
|
||||
inviteLoadError.value = msg
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
inviteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOverview()
|
||||
loadRecentDynamics()
|
||||
loadEnterpriseRanking()
|
||||
loadTestTrends()
|
||||
await loadInviteEnterprisesAndSettings()
|
||||
loadInviteQrcode()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -622,6 +740,68 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.invite-section {
|
||||
margin-bottom: 28px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
|
||||
.invite-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invite-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 112px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.invite-placeholder {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-section {
|
||||
margin-bottom: 28px;
|
||||
background: #fff;
|
||||
|
||||
Reference in New Issue
Block a user