815 lines
22 KiB
Vue
815 lines
22 KiB
Vue
<template>
|
||
<div class="page-shell">
|
||
<section class="page-card table-fill">
|
||
<div class="page-toolbar">
|
||
<n-form inline :show-label="false" class="toolbar-form">
|
||
<n-form-item>
|
||
<n-input v-model:value="queryForm.username" clearable placeholder="用户名" />
|
||
</n-form-item>
|
||
<n-form-item>
|
||
<n-input v-model:value="queryForm.nickname" clearable placeholder="昵称" />
|
||
</n-form-item>
|
||
<n-form-item>
|
||
<n-select
|
||
v-model:value="queryForm.status"
|
||
clearable
|
||
placeholder="状态"
|
||
:options="statusOptions"
|
||
style="width: 140px"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item>
|
||
<n-space>
|
||
<n-button type="primary" @click="handleSearch">查询</n-button>
|
||
<n-button @click="handleReset">重置</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
</n-form>
|
||
<PermissionButton permission="system:user:create">
|
||
<n-button type="primary" @click="openCreate">新增用户</n-button>
|
||
</PermissionButton>
|
||
</div>
|
||
|
||
<div class="card-body card-body-fill table-fill">
|
||
<n-data-table
|
||
flex-height
|
||
remote
|
||
size="small"
|
||
:columns="columns"
|
||
:data="tableRows"
|
||
:loading="usersQuery.isLoading.value"
|
||
:pagination="pagination"
|
||
:row-key="(row: UserListItem) => row.id"
|
||
@update:page="onPageChange"
|
||
@update:page-size="onPageSizeChange"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<n-modal
|
||
v-model:show="detailModal.visible"
|
||
preset="card"
|
||
title="用户详情"
|
||
:style="{ width: '920px', maxWidth: '92vw' }"
|
||
:mask-closable="false"
|
||
content-style="padding: 0; max-height: calc(85vh - 130px); overflow-y: auto"
|
||
>
|
||
<n-spin :show="detailModal.loading">
|
||
<template v-if="detailUser">
|
||
<div class="detail-shell">
|
||
<div class="detail-header">
|
||
<div>
|
||
<div class="detail-name">{{ detailUser.nickname || detailUser.realName || detailUser.username }}</div>
|
||
<div class="detail-username">{{ detailUser.username }}</div>
|
||
<div class="detail-tags">
|
||
<n-tag :type="statusTagType(detailUser.status)" size="small" round>
|
||
{{ detailUser.statusLabel || statusLabel(detailUser.status) }}
|
||
</n-tag>
|
||
<n-tag v-if="detailUser.orgName || detailUser.orgCode" size="small" round>
|
||
{{ detailUser.orgName || detailUser.orgCode }}
|
||
</n-tag>
|
||
</div>
|
||
</div>
|
||
<div class="detail-total">
|
||
<span>角色数</span>
|
||
<strong>{{ detailUser.roles?.length || 0 }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">基础信息</div>
|
||
<div class="detail-grid">
|
||
<div v-for="item in baseDetailItems" :key="item.label" class="detail-item">
|
||
<span>{{ item.label }}</span>
|
||
<strong>{{ item.value }}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">角色信息</div>
|
||
<div class="role-tags">
|
||
<n-tag
|
||
v-for="role in detailUser.roles"
|
||
:key="role.id"
|
||
size="small"
|
||
round
|
||
type="info"
|
||
>
|
||
{{ role.name }}({{ role.code }})
|
||
</n-tag>
|
||
<span v-if="detailUser.roles.length === 0" class="empty-text">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">系统信息</div>
|
||
<div class="detail-grid">
|
||
<div v-for="item in systemDetailItems" :key="item.label" class="detail-item">
|
||
<span>{{ item.label }}</span>
|
||
<strong>{{ item.value }}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</n-spin>
|
||
</n-modal>
|
||
|
||
<n-modal
|
||
v-model:show="editModal.visible"
|
||
preset="card"
|
||
:title="editModal.title"
|
||
class="w-[560px]"
|
||
>
|
||
<n-form
|
||
ref="editFormRef"
|
||
:model="editForm"
|
||
:rules="editRules"
|
||
label-placement="left"
|
||
label-width="90"
|
||
>
|
||
<n-form-item label="用户名" path="username">
|
||
<n-input v-model:value="editForm.username" :disabled="editModal.mode === 'edit'" />
|
||
</n-form-item>
|
||
<n-form-item v-if="editModal.mode === 'create'" label="密码" path="password">
|
||
<n-input v-model:value="editForm.password" type="password" show-password-on="click" />
|
||
</n-form-item>
|
||
<n-form-item label="昵称" path="nickname">
|
||
<n-input v-model:value="editForm.nickname" />
|
||
</n-form-item>
|
||
<n-form-item label="姓名" path="realName">
|
||
<n-input v-model:value="editForm.realName" />
|
||
</n-form-item>
|
||
<n-form-item label="手机号" path="phone">
|
||
<n-input v-model:value="editForm.phone" />
|
||
</n-form-item>
|
||
<n-form-item label="邮箱" path="email">
|
||
<n-input v-model:value="editForm.email" />
|
||
</n-form-item>
|
||
<n-form-item v-if="editModal.mode === 'create'" label="状态" path="status">
|
||
<n-radio-group v-model:value="editForm.status">
|
||
<n-radio-button value="ENABLED">启用</n-radio-button>
|
||
<n-radio-button value="DISABLED">禁用</n-radio-button>
|
||
</n-radio-group>
|
||
</n-form-item>
|
||
</n-form>
|
||
<template #footer>
|
||
<div class="flex justify-end gap-2">
|
||
<n-button @click="editModal.visible = false">取消</n-button>
|
||
<n-button type="primary" :loading="saveMutation.isPending.value" @click="handleSave"
|
||
>保存</n-button
|
||
>
|
||
</div>
|
||
</template>
|
||
</n-modal>
|
||
|
||
<n-modal v-model:show="passwordModal.visible" preset="card" title="重置密码" class="w-[460px]">
|
||
<n-form
|
||
ref="passwordFormRef"
|
||
:model="passwordForm"
|
||
:rules="passwordRules"
|
||
label-placement="left"
|
||
label-width="90"
|
||
>
|
||
<n-form-item label="新密码" path="password">
|
||
<n-input v-model:value="passwordForm.password" type="password" show-password-on="click" />
|
||
</n-form-item>
|
||
</n-form>
|
||
<template #footer>
|
||
<div class="flex justify-end gap-2">
|
||
<n-button @click="passwordModal.visible = false">取消</n-button>
|
||
<n-button
|
||
type="primary"
|
||
:loading="passwordMutation.isPending.value"
|
||
@click="handleResetPassword"
|
||
>确认</n-button
|
||
>
|
||
</div>
|
||
</template>
|
||
</n-modal>
|
||
|
||
<n-modal v-model:show="rolesModal.visible" preset="card" title="分配角色" class="w-[520px]">
|
||
<n-form label-placement="left" label-width="90">
|
||
<n-form-item label="角色">
|
||
<n-select
|
||
v-model:value="rolesModal.roleIds"
|
||
multiple
|
||
clearable
|
||
filterable
|
||
:options="roleOptions"
|
||
placeholder="请选择角色"
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
<template #footer>
|
||
<div class="flex justify-end gap-2">
|
||
<n-button @click="rolesModal.visible = false">取消</n-button>
|
||
<n-button
|
||
type="primary"
|
||
:loading="rolesMutation.isPending.value"
|
||
@click="handleAssignRoles"
|
||
>确认</n-button
|
||
>
|
||
</div>
|
||
</template>
|
||
</n-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, h, reactive, ref } from 'vue'
|
||
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
|
||
import { NButton, NPopconfirm, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||
import PermissionButton from '@/components/PermissionButton.vue'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { statusLabel, statusTagType } from '@/utils/display'
|
||
import { renderPagePrefix } from '@/utils/pagination'
|
||
import {
|
||
createUserApi,
|
||
deleteUserApi,
|
||
getUserDetailApi,
|
||
listUsersApi,
|
||
updateUserApi,
|
||
updateUserPasswordApi,
|
||
updateUserRolesApi,
|
||
updateUserStatusApi
|
||
} from '@/api/system/user'
|
||
import { listRolesApi } from '@/api/system/role'
|
||
import type {
|
||
CreateUserRequest,
|
||
UpdateUserRequest,
|
||
UserDetail,
|
||
UserListItem
|
||
} from '@/types/system/user'
|
||
|
||
const message = useMessage()
|
||
const authStore = useAuthStore()
|
||
const queryClient = useQueryClient()
|
||
|
||
const queryForm = reactive({
|
||
username: '',
|
||
nickname: '',
|
||
status: null as string | null
|
||
})
|
||
|
||
const pager = reactive({
|
||
page: 1,
|
||
pageSize: 10
|
||
})
|
||
|
||
const statusOptions = [
|
||
{ label: '启用', value: 'ENABLED' },
|
||
{ label: '禁用', value: 'DISABLED' }
|
||
]
|
||
|
||
const usersQuery = useQuery({
|
||
queryKey: computed(() => ['system', 'users', { ...queryForm, ...pager }]),
|
||
queryFn: () =>
|
||
listUsersApi({
|
||
page: pager.page,
|
||
pageSize: pager.pageSize,
|
||
username: queryForm.username || undefined,
|
||
nickname: queryForm.nickname || undefined,
|
||
status: queryForm.status || undefined
|
||
})
|
||
})
|
||
|
||
const rolesQuery = useQuery({
|
||
queryKey: ['system', 'roles', 'enabled'],
|
||
queryFn: () => listRolesApi({ page: 1, pageSize: 200, status: 'ENABLED' })
|
||
})
|
||
|
||
const roleOptions = computed(() =>
|
||
(rolesQuery.data.value?.items ?? []).map((role) => ({
|
||
label: `${role.name} (${role.code})`,
|
||
value: role.id
|
||
}))
|
||
)
|
||
|
||
const tableRows = computed(() => usersQuery.data.value?.items ?? [])
|
||
|
||
const pagination = computed(() => ({
|
||
page: pager.page,
|
||
pageSize: pager.pageSize,
|
||
itemCount: usersQuery.data.value?.total ?? 0,
|
||
pageSizes: [10, 20, 50],
|
||
showSizePicker: true,
|
||
prefix: renderPagePrefix
|
||
}))
|
||
|
||
const editFormRef = ref<FormInst | null>(null)
|
||
const detailModal = reactive({
|
||
visible: false,
|
||
loading: false
|
||
})
|
||
const detailUser = ref<UserDetail | null>(null)
|
||
|
||
const baseDetailItems = computed(() => {
|
||
const user = detailUser.value
|
||
if (!user) return []
|
||
return [
|
||
{ label: '用户ID', value: displayValue(user.id) },
|
||
{ label: '用户名', value: displayValue(user.username) },
|
||
{ label: '昵称', value: displayValue(user.nickname) },
|
||
{ label: '姓名', value: displayValue(user.realName) },
|
||
{ label: '手机号', value: displayValue(user.phone) },
|
||
{ label: '邮箱', value: displayValue(user.email) },
|
||
{ label: '组织', value: formatOrg(user) },
|
||
{ label: '状态', value: user.statusLabel || statusLabel(user.status) },
|
||
{ label: '头像', value: displayValue(user.avatar) }
|
||
]
|
||
})
|
||
|
||
const systemDetailItems = computed(() => {
|
||
const user = detailUser.value
|
||
if (!user) return []
|
||
return [
|
||
{ label: 'Token版本', value: displayValue(user.tokenVersion) },
|
||
{ label: '数据版本', value: displayValue(user.version) },
|
||
{ label: '最后登录时间', value: displayValue(user.lastLoginAt) },
|
||
{ label: '最后登录IP', value: displayValue(user.lastLoginIp) },
|
||
{ label: '创建时间', value: displayValue(user.createdAt) },
|
||
{ label: '创建人', value: displayValue(user.createdBy) },
|
||
{ label: '更新时间', value: displayValue(user.updatedAt) },
|
||
{ label: '更新人', value: displayValue(user.updatedBy) },
|
||
{ label: '删除时间', value: displayValue(user.deletedAt) },
|
||
{ label: '删除人', value: displayValue(user.deletedBy) }
|
||
]
|
||
})
|
||
|
||
const editModal = reactive({
|
||
visible: false,
|
||
mode: 'create' as 'create' | 'edit',
|
||
title: '新增用户',
|
||
id: ''
|
||
})
|
||
const editForm = reactive({
|
||
username: '',
|
||
password: '',
|
||
nickname: '',
|
||
realName: '',
|
||
phone: '',
|
||
email: '',
|
||
status: 'ENABLED'
|
||
})
|
||
|
||
const editRules: FormRules = {
|
||
username: [{ required: true, message: '请输入用户名', trigger: ['input', 'blur'] }],
|
||
password: [{ required: true, message: '请输入密码', trigger: ['input', 'blur'] }]
|
||
}
|
||
|
||
const saveMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (editModal.mode === 'create') {
|
||
const payload: CreateUserRequest = {
|
||
username: editForm.username.trim(),
|
||
password: editForm.password,
|
||
nickname: editForm.nickname.trim() || undefined,
|
||
realName: editForm.realName.trim() || undefined,
|
||
phone: editForm.phone.trim() || undefined,
|
||
email: editForm.email.trim() || undefined,
|
||
status: editForm.status
|
||
}
|
||
return createUserApi(payload)
|
||
}
|
||
const payload: UpdateUserRequest = {
|
||
nickname: editForm.nickname.trim() || undefined,
|
||
realName: editForm.realName.trim() || undefined,
|
||
phone: editForm.phone.trim() || undefined,
|
||
email: editForm.email.trim() || undefined
|
||
}
|
||
return updateUserApi(editModal.id, payload)
|
||
},
|
||
onSuccess: async () => {
|
||
message.success('保存成功')
|
||
editModal.visible = false
|
||
await usersQuery.refetch()
|
||
}
|
||
})
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => deleteUserApi(id),
|
||
onSuccess: async () => {
|
||
message.success('删除成功')
|
||
await usersQuery.refetch()
|
||
}
|
||
})
|
||
|
||
const statusMutation = useMutation({
|
||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||
updateUserStatusApi(id, { status }),
|
||
onSuccess: async () => {
|
||
message.success('状态更新成功')
|
||
await usersQuery.refetch()
|
||
}
|
||
})
|
||
|
||
const passwordFormRef = ref<FormInst | null>(null)
|
||
const passwordModal = reactive({
|
||
visible: false,
|
||
id: ''
|
||
})
|
||
const passwordForm = reactive({
|
||
password: ''
|
||
})
|
||
const passwordRules: FormRules = {
|
||
password: [{ required: true, message: '请输入新密码', trigger: ['input', 'blur'] }]
|
||
}
|
||
|
||
const passwordMutation = useMutation({
|
||
mutationFn: () => updateUserPasswordApi(passwordModal.id, { password: passwordForm.password }),
|
||
onSuccess: async () => {
|
||
message.success('密码重置成功')
|
||
passwordModal.visible = false
|
||
passwordForm.password = ''
|
||
await queryClient.invalidateQueries({ queryKey: ['system', 'users'] })
|
||
}
|
||
})
|
||
|
||
const rolesModal = reactive({
|
||
visible: false,
|
||
id: '',
|
||
roleIds: [] as string[]
|
||
})
|
||
|
||
const rolesMutation = useMutation({
|
||
mutationFn: () => updateUserRolesApi(rolesModal.id, { roleIds: rolesModal.roleIds }),
|
||
onSuccess: async () => {
|
||
message.success('角色分配成功')
|
||
rolesModal.visible = false
|
||
await usersQuery.refetch()
|
||
}
|
||
})
|
||
|
||
function onPageChange(page: number) {
|
||
pager.page = page
|
||
}
|
||
|
||
function onPageSizeChange(pageSize: number) {
|
||
pager.pageSize = pageSize
|
||
pager.page = 1
|
||
}
|
||
|
||
function handleSearch() {
|
||
pager.page = 1
|
||
usersQuery.refetch()
|
||
}
|
||
|
||
function handleReset() {
|
||
queryForm.username = ''
|
||
queryForm.nickname = ''
|
||
queryForm.status = null
|
||
pager.page = 1
|
||
usersQuery.refetch()
|
||
}
|
||
|
||
function resetEditForm() {
|
||
editForm.username = ''
|
||
editForm.password = ''
|
||
editForm.nickname = ''
|
||
editForm.realName = ''
|
||
editForm.phone = ''
|
||
editForm.email = ''
|
||
editForm.status = 'ENABLED'
|
||
}
|
||
|
||
function openCreate() {
|
||
editModal.mode = 'create'
|
||
editModal.title = '新增用户'
|
||
editModal.id = ''
|
||
resetEditForm()
|
||
editModal.visible = true
|
||
}
|
||
|
||
function displayValue(value: unknown) {
|
||
if (value === null || value === undefined || value === '') return '-'
|
||
return String(value)
|
||
}
|
||
|
||
function formatOrg(user: UserDetail) {
|
||
if (user.orgName && user.orgCode) return `${user.orgName}(${user.orgCode})`
|
||
return user.orgName || user.orgCode || user.orgId || '-'
|
||
}
|
||
|
||
async function openDetail(row: UserListItem) {
|
||
detailModal.visible = true
|
||
detailModal.loading = true
|
||
detailUser.value = null
|
||
try {
|
||
detailUser.value = await getUserDetailApi(row.id)
|
||
} catch {
|
||
message.error('查询用户详情失败')
|
||
} finally {
|
||
detailModal.loading = false
|
||
}
|
||
}
|
||
|
||
async function openEdit(row: UserListItem) {
|
||
editModal.mode = 'edit'
|
||
editModal.title = `编辑用户 - ${row.username}`
|
||
editModal.id = row.id
|
||
const detail = await getUserDetailApi(row.id)
|
||
editForm.username = detail.username
|
||
editForm.password = ''
|
||
editForm.nickname = detail.nickname ?? ''
|
||
editForm.realName = detail.realName ?? ''
|
||
editForm.phone = detail.phone ?? ''
|
||
editForm.email = detail.email ?? ''
|
||
editForm.status = detail.status
|
||
editModal.visible = true
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (editModal.mode === 'create') {
|
||
await editFormRef.value?.validate()
|
||
} else {
|
||
await editFormRef.value?.validate(
|
||
(errors) => {
|
||
if (!errors) return
|
||
throw errors
|
||
},
|
||
(rule) => rule?.key !== 'password'
|
||
)
|
||
}
|
||
await saveMutation.mutateAsync()
|
||
}
|
||
|
||
function openResetPassword(row: UserListItem) {
|
||
passwordModal.id = row.id
|
||
passwordForm.password = ''
|
||
passwordModal.visible = true
|
||
}
|
||
|
||
async function handleResetPassword() {
|
||
await passwordFormRef.value?.validate()
|
||
await passwordMutation.mutateAsync()
|
||
}
|
||
|
||
async function openAssignRoles(row: UserListItem) {
|
||
const detail = await getUserDetailApi(row.id)
|
||
rolesModal.id = row.id
|
||
rolesModal.roleIds = [...detail.roleIds]
|
||
rolesModal.visible = true
|
||
}
|
||
|
||
async function handleAssignRoles() {
|
||
await rolesMutation.mutateAsync()
|
||
}
|
||
|
||
function updateStatus(row: UserListItem, checked: boolean) {
|
||
statusMutation.mutate({ id: row.id, status: checked ? 'ENABLED' : 'DISABLED' })
|
||
}
|
||
|
||
const columns = computed<DataTableColumns<UserListItem>>(() => [
|
||
{
|
||
title: '用户信息',
|
||
key: 'username',
|
||
minWidth: 180,
|
||
render: (row) =>
|
||
h('div', { class: 'leading-5' }, [
|
||
h(
|
||
'div',
|
||
{ class: 'font-medium text-slate-900' },
|
||
row.nickname || row.realName || row.username
|
||
),
|
||
h('div', { class: 'text-xs text-slate-500' }, row.username)
|
||
])
|
||
},
|
||
{
|
||
title: '角色',
|
||
key: 'roleCodes',
|
||
minWidth: 180,
|
||
render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-')
|
||
},
|
||
{
|
||
title: '状态',
|
||
key: 'status',
|
||
width: 120,
|
||
render: (row) =>
|
||
h(
|
||
NTag,
|
||
{ type: statusTagType(row.status), size: 'small' },
|
||
{ default: () => statusLabel(row.status) }
|
||
)
|
||
},
|
||
{
|
||
title: '启停',
|
||
key: 'statusSwitch',
|
||
width: 110,
|
||
render: (row) =>
|
||
authStore.hasPermission('system:user:update')
|
||
? h(NSwitch, {
|
||
size: 'small',
|
||
value: row.status === 'ENABLED',
|
||
onUpdateValue: (checked: boolean) => updateStatus(row, checked)
|
||
})
|
||
: '-'
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 430,
|
||
render: (row) =>
|
||
h(
|
||
NSpace,
|
||
{ size: 6 },
|
||
{
|
||
default: () => [
|
||
h(
|
||
NButton,
|
||
{
|
||
size: 'small',
|
||
tertiary: true,
|
||
type: 'info',
|
||
class: 'action-btn',
|
||
onClick: () => openDetail(row)
|
||
},
|
||
{ default: () => '详情' }
|
||
),
|
||
authStore.hasPermission('system:user:update')
|
||
? h(
|
||
NButton,
|
||
{
|
||
size: 'small',
|
||
tertiary: true,
|
||
type: 'primary',
|
||
class: 'action-btn',
|
||
onClick: () => openEdit(row)
|
||
},
|
||
{ default: () => '编辑' }
|
||
)
|
||
: null,
|
||
authStore.hasPermission('system:user:update')
|
||
? h(
|
||
NButton,
|
||
{
|
||
size: 'small',
|
||
tertiary: true,
|
||
type: 'warning',
|
||
class: 'action-btn',
|
||
onClick: () => openResetPassword(row)
|
||
},
|
||
{ default: () => '重置密码' }
|
||
)
|
||
: null,
|
||
authStore.hasPermission('system:role:assign')
|
||
? h(
|
||
NButton,
|
||
{
|
||
size: 'small',
|
||
tertiary: true,
|
||
type: 'info',
|
||
class: 'action-btn',
|
||
onClick: () => openAssignRoles(row)
|
||
},
|
||
{ default: () => '分配角色' }
|
||
)
|
||
: null,
|
||
authStore.hasPermission('system:user:delete')
|
||
? h(
|
||
NPopconfirm,
|
||
{
|
||
onPositiveClick: () => deleteMutation.mutate(row.id)
|
||
},
|
||
{
|
||
trigger: () =>
|
||
h(
|
||
NButton,
|
||
{
|
||
size: 'small',
|
||
tertiary: true,
|
||
type: 'error',
|
||
class: 'action-btn',
|
||
loading: deleteMutation.isPending.value
|
||
},
|
||
{ default: () => '删除' }
|
||
),
|
||
default: () => `确认删除用户 ${row.username} 吗?`
|
||
}
|
||
)
|
||
: null
|
||
]
|
||
}
|
||
)
|
||
}
|
||
])
|
||
</script>
|
||
|
||
<style scoped>
|
||
.detail-shell {
|
||
padding: 0 20px 20px;
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 20px 0 16px;
|
||
border-bottom: 1px solid #eef1f5;
|
||
}
|
||
|
||
.detail-name {
|
||
color: #111827;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detail-username {
|
||
margin-top: 4px;
|
||
color: #6b7280;
|
||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.detail-tags {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.detail-total {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 4px;
|
||
}
|
||
|
||
.detail-total span {
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.detail-total strong {
|
||
color: #111827;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detail-section {
|
||
padding-top: 18px;
|
||
}
|
||
|
||
.detail-section-title {
|
||
margin-bottom: 12px;
|
||
color: #111827;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detail-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-item {
|
||
padding: 14px;
|
||
border: 1px solid #eef1f5;
|
||
border-radius: 8px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.detail-item span {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.detail-item strong {
|
||
color: #111827;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.role-tags {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.empty-text {
|
||
color: #9ca3af;
|
||
font-size: 13px;
|
||
}
|
||
|
||
@media (max-width: 960px) {
|
||
.detail-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.detail-header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.detail-total {
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
</style>
|