Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { ZqDialog } from '#/components/zq-dialog';
import { $t } from '#/locales';
import OrgChartPanel from '#/views/_core/org-chart/modules/OrgChartPanel.vue';
defineOptions({
name: 'UserProfileDialog',
});
const props = defineProps<{
modelValue: boolean;
userId: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('user-avatar.profile.organization')"
width="80%"
:show-footer="false"
content-height="70vh"
destroy-on-close
>
<div class="org-chart-wrapper">
<OrgChartPanel :user-id="userId" :show-mode-toggle="true" />
</div>
</ZqDialog>
</template>
<style lang="scss" scoped>
.org-chart-wrapper {
height: 65vh;
overflow: hidden;
}
</style>
@@ -0,0 +1,5 @@
import { defineAsyncComponent } from 'vue';
export const UserAvatar = defineAsyncComponent(() =>
import('./index.vue').then((module) => module.default),
);
@@ -0,0 +1,793 @@
<script lang="ts" setup>
import type { User } from '#/api/core/user';
import { computed, onMounted, ref, watch } from 'vue';
import { Network } from '@vben/icons';
import { ElAvatar, ElPopover, ElTooltip } from 'element-plus';
import { getUserDetailApi } from '#/api/core/user';
import DeptTag from '#/components/dept-tag/index.vue';
import PostTag from '#/components/post-tag/index.vue';
import RoleTag from '#/components/role-tag/index.vue';
import { getFileUrl } from '#/composables/useFileUrl';
import { $t } from '#/locales';
import { generateAvatarGradient, generateAvatarText } from '#/utils/avatar';
import UserProfileDialog from './UserProfileDialog.vue';
defineOptions({
name: 'UserAvatar',
});
const props = withDefaults(defineProps<Props>(), {
size: 56,
fontSize: 24,
shadow: true,
showPopover: true,
autoLoad: false,
showInfo: false,
hideUsername: false,
infoPosition: 'bottom',
});
interface Props {
/**
* 用户对象(包含 id, name, avatar 等信息)
*/
user?: User;
/**
* 用户ID(用于获取详细信息和用户对象)
*/
userId?: string;
/**
* 用户名字或用户名(可选,如果不提供则从 user 或 userId 中获取)
*/
name?: string;
/**
* 头像文件路径(不需要调用 getFileStreamUrl,组件内部处理)
* @deprecated 建议使用 user 或 userId 代替
*/
avatar?: string;
/**
* 头像尺寸(像素)
* @default 56
*/
size?: number;
/**
* 文字大小(像素)
* @default 24
*/
fontSize?: number;
/**
* 是否显示阴影
* @default true
*/
shadow?: boolean;
/**
* 是否启用悬停显示详细信息
* @default true
*/
showPopover?: boolean;
/**
* 是否自动加载用户信息(用于只传 userId 时自动获取用户名和头像)
* @default false
*/
autoLoad?: boolean;
/**
* 是否在头像下方显示用户名和username
* @default false
*/
showInfo?: boolean;
/**
* 是否隐藏 username(只显示 name
* @default false
*/
hideUsername?: boolean;
/**
* showInfo 的布局方向:bottom 在下方,right 在右侧
* @default 'bottom'
*/
infoPosition?: 'bottom' | 'right';
}
// Popover 相关状态(需要在其他computed/watch之前定义)
const popoverVisible = ref(false);
const userDetail = ref<User>();
const loading = ref(false);
const hasLoaded = ref(false);
// 用户详情 Dialog
const profileDialogVisible = ref(false);
function openProfileDialog() {
popoverVisible.value = false;
profileDialogVisible.value = true;
}
// 获取有效的 ID
const effectiveUserId = computed(() => {
return props.userId || props.user?.id;
});
// 获取有效的用户名
const effectiveUserName = computed(() => {
return props.name || props.user?.name || userDetail.value?.name;
});
// 获取有效的头像 URL(处理 getFileUrl
const effectiveAvatarUrl = ref<string | undefined>(undefined);
// 异步加载头像URL
async function loadAvatarUrl() {
const avatarPath =
props.avatar || props.user?.avatar || userDetail.value?.avatar;
effectiveAvatarUrl.value = avatarPath
? await getFileUrl(avatarPath)
: undefined;
}
// 监听头像路径变化
watch(
() => props.avatar || props.user?.avatar || userDetail.value?.avatar,
() => {
loadAvatarUrl();
},
{ immediate: true },
);
const userInitials = computed(() => {
return generateAvatarText(effectiveUserName.value);
});
const avatarGradient = computed(() => {
return generateAvatarGradient(effectiveUserName.value);
});
// 加载用户详细信息
async function loadUserDetail() {
if (hasLoaded.value) return; // 已加载过,不再重复加载
if (!effectiveUserId.value) {
// 没有 ID 但有 user 对象,直接使用
if (props.user) {
userDetail.value = props.user;
hasLoaded.value = true;
}
return;
}
loading.value = true;
try {
userDetail.value = await getUserDetailApi(effectiveUserId.value);
hasLoaded.value = true;
} catch (error) {
console.error('Failed to load user detail:', error);
// API 失败时回退到 props.user
if (props.user) {
userDetail.value = props.user;
hasLoaded.value = true;
}
} finally {
loading.value = false;
}
}
// Popover 显示时加载数据
function handlePopoverShow() {
if (!hasLoaded.value) {
loadUserDetail();
}
}
// 获取性别显示文本
function getGenderText(gender?: number) {
if (gender === 1) return '男';
if (gender === 0) return '女';
return '未知';
}
// 获取状态标签类型
function getStatusType(isActive?: number) {
return isActive === 1 ? 'success' : 'danger';
}
// 获取状态显示文本
function getStatusText(isActive?: number) {
return isActive === 1 ? '启用' : '禁用';
}
// 自动加载用户信息
onMounted(() => {
// autoLoad 或 showInfo 时都需要加载用户详情
if ((props.autoLoad || props.showInfo) && props.userId && !props.user) {
loadUserDetail();
}
});
// 监听 userId 变化,自动重新加载
watch(
() => props.userId,
(newId, oldId) => {
if ((props.autoLoad || props.showInfo) && newId && newId !== oldId) {
hasLoaded.value = false;
userDetail.value = undefined;
loadUserDetail();
}
},
);
</script>
<template>
<div class="user-avatar-root">
<ElPopover
v-if="showPopover && effectiveUserId"
v-model:visible="popoverVisible"
placement="right"
:width="280"
trigger="hover"
:show-after="300"
@show="handlePopoverShow"
>
<template #reference>
<div
class="avatar-wrapper"
:class="{
'with-info': showInfo,
'info-bottom': showInfo && infoPosition === 'bottom',
'info-right': showInfo && infoPosition === 'right',
}"
>
<div
class="avatar-generator cursor-pointer"
:class="{ 'is-loading': loading }"
>
<div
v-if="!effectiveAvatarUrl"
class="avatar-gradient"
:style="{
width: `${size}px`,
height: `${size}px`,
background: avatarGradient,
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
fontSize: `${fontSize}px`,
}"
>
<span class="avatar-text">{{ userInitials }}</span>
</div>
<ElAvatar
v-else
:src="effectiveAvatarUrl"
:alt="effectiveUserName"
:size="size"
:style="{
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
}"
/>
</div>
<div v-if="showInfo" class="avatar-info">
<div v-if="loading" class="info-skeleton">
<div class="skeleton-line name"></div>
<div class="skeleton-line username"></div>
</div>
<template v-else-if="userDetail">
<div class="info-name">{{ userDetail.name }}</div>
<div v-if="!hideUsername" class="info-username">
@{{ userDetail.username }}
</div>
</template>
</div>
</div>
</template>
<!-- Popover 内容 -->
<div class="user-detail-popover">
<!-- 加载骨架屏 -->
<div v-if="loading || !userDetail" class="popover-skeleton">
<div class="skeleton-header">
<div class="skeleton-avatar"></div>
<div class="skeleton-info">
<div class="skeleton-bar skeleton-name"></div>
<div class="skeleton-bar skeleton-username"></div>
</div>
</div>
<div class="skeleton-details">
<div v-for="i in 6" :key="i" class="skeleton-row">
<div class="skeleton-bar skeleton-label"></div>
<div
class="skeleton-bar skeleton-value"
:style="{ width: `${60 + (i % 3) * 20}px` }"
></div>
</div>
</div>
</div>
<!-- 用户详细信息 -->
<div v-else class="user-detail-content">
<!-- 头像和基本信息 -->
<div class="user-header">
<div class="user-avatar-large">
<div
v-if="!userDetail.avatar"
class="avatar-gradient"
:style="{
width: '64px',
height: '64px',
background: generateAvatarGradient(userDetail.name),
fontSize: '24px',
}"
>
<span class="avatar-text">{{
generateAvatarText(userDetail.name)
}}</span>
</div>
<ElAvatar
v-else
:src="effectiveAvatarUrl"
:alt="userDetail.name"
:size="64"
/>
</div>
<div class="user-basic-info">
<div class="user-name">{{ userDetail.name }}</div>
<div class="user-username">@{{ userDetail.username }}</div>
</div>
<!-- 操作按钮 -->
<div class="user-actions">
<ElTooltip
:content="$t('user-avatar.profile.organization')"
placement="top"
>
<button class="action-btn" @click="openProfileDialog">
<Network :size="15" />
</button>
</ElTooltip>
</div>
</div>
<!-- 详细信息 -->
<div class="user-details">
<div v-if="userDetail.email" class="detail-item">
<span class="detail-label">邮箱</span>
<span class="detail-value">{{ userDetail.email }}</span>
</div>
<div v-if="userDetail.mobile" class="detail-item">
<span class="detail-label">手机</span>
<span class="detail-value">{{ userDetail.mobile }}</span>
</div>
<div v-if="userDetail.city" class="detail-item">
<span class="detail-label">城市</span>
<span class="detail-value">{{ userDetail.city }}</span>
</div>
<div v-if="userDetail.dept_id" class="detail-item">
<span class="detail-label">部门</span>
<span class="detail-value">
<DeptTag :dept-id="userDetail.dept_id" />
</span>
</div>
<div v-if="userDetail.post_id" class="detail-item">
<span class="detail-label">岗位</span>
<span class="detail-value">
<PostTag :post-id="userDetail.post_id" />
</span>
</div>
<div
v-if="userDetail.role_ids && userDetail.role_ids.length > 0"
class="detail-item"
>
<span class="detail-label">角色</span>
<span class="detail-value">
<RoleTag :role-ids="userDetail.role_ids" />
</span>
</div>
<div v-if="userDetail.manager_id" class="detail-item">
<span class="detail-label">经理</span>
<span class="detail-value">
<UserAvatar
:user-id="userDetail.manager_id"
:size="20"
:font-size="10"
:shadow="false"
show-info
hide-username
info-position="right"
/>
</span>
</div>
<!-- <div class="detail-item">
<span class="detail-label">性别</span>
<span class="detail-value">{{ getGenderText(userDetail.gender) }}</span>
</div> -->
<div v-if="userDetail.user_type_display" class="detail-item">
<span class="detail-label">类型</span>
<span class="detail-value">{{
userDetail.user_type_display
}}</span>
</div>
<!-- <div class="detail-item">
<span class="detail-label">状态</span>
<ElTag :type="getStatusType(userDetail.is_active)" size="small">
{{ getStatusText(userDetail.is_active) }}
</ElTag>
</div> -->
</div>
</div>
</div>
</ElPopover>
<!-- 用户详情 Dialog -->
<UserProfileDialog
v-if="effectiveUserId"
v-model="profileDialogVisible"
:user-id="effectiveUserId"
/>
<!-- Popover -->
<div
v-if="!(showPopover && effectiveUserId)"
class="avatar-wrapper"
:class="{
'with-info': showInfo,
'info-bottom': showInfo && infoPosition === 'bottom',
'info-right': showInfo && infoPosition === 'right',
}"
>
<div class="avatar-generator" :class="{ 'is-loading': loading }">
<div
v-if="!effectiveAvatarUrl"
class="avatar-gradient"
:style="{
width: `${size}px`,
height: `${size}px`,
background: avatarGradient,
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
fontSize: `${fontSize}px`,
}"
>
<span class="avatar-text">{{ userInitials }}</span>
</div>
<ElAvatar
v-else
:src="effectiveAvatarUrl"
:alt="effectiveUserName"
:size="size"
:style="{
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
}"
/>
</div>
<div v-if="showInfo" class="avatar-info">
<div v-if="loading" class="info-skeleton">
<div class="skeleton-line name"></div>
<div class="skeleton-line username"></div>
</div>
<template v-else-if="userDetail">
<div class="info-name">{{ userDetail.name }}</div>
<div v-if="!hideUsername" class="info-username">
@{{ userDetail.username }}
</div>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.user-avatar-root {
display: inline-flex;
}
.avatar-wrapper {
display: inline-flex;
align-items: center;
&.info-bottom {
flex-direction: column;
gap: 6px;
}
&.info-right {
flex-direction: row;
gap: 8px;
}
}
.avatar-generator {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
&.is-loading {
.avatar-gradient,
:deep(.el-avatar) {
animation: pulse 1.5s ease-in-out infinite;
}
}
.avatar-gradient {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 50%;
.avatar-text {
font-weight: 700;
color: white;
white-space: nowrap;
text-shadow: 0 1px 2px rgb(0 0 0 / 20%);
}
}
:deep(.el-avatar) {
flex-shrink: 0;
}
}
.avatar-info {
min-width: 60px;
max-width: 80px;
text-align: center;
.info-right & {
max-width: 120px;
text-align: left;
}
.info-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.info-username {
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.info-skeleton {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
.info-right & {
align-items: flex-start;
}
.skeleton-line {
background: linear-gradient(
90deg,
var(--el-fill-color) 25%,
var(--el-fill-color-light) 50%,
var(--el-fill-color) 75%
);
background-size: 200% 100%;
border-radius: 4px;
animation: shimmer 1.5s infinite;
&.name {
width: 48px;
height: 12px;
}
&.username {
width: 56px;
height: 10px;
}
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.user-detail-popover {
padding: 12px;
.popover-skeleton {
.skeleton-header {
display: flex;
gap: 12px;
align-items: center;
padding-bottom: 12px;
}
.skeleton-avatar {
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(
90deg,
var(--el-fill-color-light) 25%,
var(--el-fill-color) 50%,
var(--el-fill-color-light) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-info {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
.skeleton-bar {
border-radius: 4px;
background: linear-gradient(
90deg,
var(--el-fill-color-light) 25%,
var(--el-fill-color) 50%,
var(--el-fill-color-light) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-name {
width: 72px;
height: 16px;
}
.skeleton-username {
width: 56px;
height: 16px;
}
.skeleton-details {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
}
.skeleton-row {
display: flex;
gap: 16px;
align-items: center;
}
.skeleton-label {
flex-shrink: 0;
width: 42px;
height: 16px;
}
.skeleton-value {
height: 16px;
}
}
.user-detail-content {
.user-header {
display: flex;
gap: 12px;
align-items: center;
padding-bottom: 12px;
// border-bottom: 1px solid hsl(var(--border));
.user-avatar-large {
flex-shrink: 0;
.avatar-gradient {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
.avatar-text {
font-weight: 700;
color: white;
text-shadow: 0 1px 2px rgb(0 0 0 / 20%);
}
}
}
.user-basic-info {
flex: 1;
min-width: 0;
.user-name {
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.user-username {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
}
}
.user-actions {
display: flex;
gap: 6px;
padding: 8px 0;
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
color: var(--el-text-color-secondary);
// background: var(--el-fill-color-light);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
}
.user-details {
padding-top: 12px;
.detail-item {
display: flex;
align-items: center;
padding: 6px 0;
font-size: 13px;
.detail-label {
flex-shrink: 0;
width: 60px;
color: var(--el-text-color-secondary);
}
.detail-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
color: var(--el-text-color-primary);
white-space: nowrap;
}
}
}
}
}
</style>