Files
dy/frontend/src/views/Users.vue
T
2026-08-07 17:51:57 +08:00

909 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '../api'
import { useAuthStore } from '../stores/auth'
import { useIsMobile } from '../composables/useIsMobile'
const isMobile = useIsMobile()
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
const pageCurrent = ref(1)
const pageSize = ref(10)
const searchKeyword = ref('')
const FIELD_LABELS = {
username: '用户名',
password: '密码',
display_name: '显示名称',
email: '邮箱',
role: '角色',
max_accounts: '可添加抖音账号数',
is_active: '账号状态',
email_verified: '邮箱验证状态'
}
/** FastAPI 422 detail 可能是字符串,也可能是校验错误对象数组。 */
const formatApiError = (error, fallback = '操作失败') => {
const detail = error?.response?.data?.detail
if (detail == null || detail === '') {
return error?.message || fallback
}
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const parts = detail.map((item) => {
if (typeof item === 'string') return item
if (!item || typeof item !== 'object') return String(item)
const rawField = Array.isArray(item.loc)
? item.loc.filter((part) => part !== 'body' && part !== 'query').join('.')
: ''
const field = FIELD_LABELS[rawField] || rawField
let msg = item.msg || item.message || JSON.stringify(item)
if (/at least 2 characters/i.test(msg)) msg = '至少 2 个字符'
else if (/at least 6 characters/i.test(msg)) msg = '至少 6 位'
else if (/valid email/i.test(msg)) msg = '邮箱格式不正确'
return field ? `${field}${msg}` : msg
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof detail === 'object') {
return detail.msg || detail.message || JSON.stringify(detail)
}
return String(detail)
}
const auth = useAuthStore()
const users = ref([])
const loading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref('新增用户')
const editingId = ref(null)
const userForm = ref({
username: '',
password: '',
display_name: '',
email: '',
email_verified: true,
role: 'operator',
is_active: true,
max_accounts: 3
})
const defaultRegisterMaxAccounts = ref(3)
const emailVerificationRequired = ref(true)
const emailBindingRequired = ref(false)
const roleOptions = ref([
{ value: 'admin', label: '管理员', is_admin: true },
{ value: 'operator', label: '运营', is_admin: false },
{ value: 'viewer', label: '只读', is_admin: false }
])
const assignableRoleOptions = computed(() => {
if (auth.isAdmin) return roleOptions.value
return roleOptions.value.filter((r) => !r.is_admin)
})
const isAdminRole = computed(() => {
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
return !!(hit?.is_admin || userForm.value.role === 'admin')
})
const emailRequiredForRole = computed(
() => emailBindingRequired.value && !isAdminRole.value
)
const hasEmail = computed(() => !!userForm.value.email?.trim())
const getRoleLabel = (role) =>
roleOptions.value.find((r) => r.value === role)?.label || role
const filteredUsers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword) return users.value
return users.value.filter((user) => {
const roleLabel = (getRoleLabel(user.role) || '').toLowerCase()
const haystack = [
String(user.id ?? ''),
user.username || '',
user.display_name || '',
user.email || '',
user.role || '',
roleLabel
].join(' ').toLowerCase()
return haystack.includes(keyword)
})
})
const paginatedUsers = computed(() => {
const start = (pageCurrent.value - 1) * pageSize.value
return filteredUsers.value.slice(start, start + pageSize.value)
})
const paginationConfig = computed(() => ({
current: pageCurrent.value,
pageSize: pageSize.value,
total: filteredUsers.value.length,
showSizeChanger: !isMobile.value,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `共 ${total} 条`,
size: isMobile.value ? 'small' : 'default',
onChange: (page, size) => {
pageCurrent.value = page
pageSize.value = size
}
}))
const fetchUsers = async () => {
loading.value = true
try {
const res = await api.get('/users')
users.value = res.data
} catch (error) {
message.error(formatApiError(error, '获取用户列表失败'))
} finally {
loading.value = false
}
}
const fetchRoles = async () => {
try {
const res = await api.get('/auth/roles')
const list = res.data.roles || []
if (list.length) {
roleOptions.value = list.map((r) => ({
value: r.value,
label: r.label,
is_admin: !!r.is_admin
}))
}
} catch {
// keep defaults
}
}
const fetchDefaultMaxAccounts = async () => {
try {
const res = await api.get('/settings')
defaultRegisterMaxAccounts.value = res.data.default_register_max_accounts ?? 3
emailVerificationRequired.value = res.data.email_verification_required !== false
emailBindingRequired.value = !!res.data.email_binding_required
} catch {
defaultRegisterMaxAccounts.value = 3
emailVerificationRequired.value = true
emailBindingRequired.value = false
}
}
const openAdd = () => {
editingId.value = null
modalTitle.value = '新增用户'
userForm.value = {
username: '',
password: '',
display_name: '',
email: '',
email_verified: true,
role: 'operator',
is_active: true,
max_accounts: defaultRegisterMaxAccounts.value
}
modalVisible.value = true
}
const openEdit = (record) => {
editingId.value = record.id
modalTitle.value = '编辑用户'
userForm.value = {
username: record.username,
password: '',
display_name: record.display_name || record.username,
email: record.email || '',
email_verified: !!record.email_verified,
role: record.role,
is_active: record.is_active,
max_accounts: isUnlimitedQuota(record)
? defaultRegisterMaxAccounts.value
: resolveAccountLimit(record)
}
modalVisible.value = true
}
const onEmailChange = () => {
if (!userForm.value.email?.trim()) {
userForm.value.email_verified = true
}
}
const handleSave = async () => {
const email = userForm.value.email?.trim() || null
const username = userForm.value.username.trim()
const password = userForm.value.password || ''
if (emailRequiredForRole.value && !email) {
message.warning('当前系统要求非管理员用户必须绑定邮箱')
return
}
if (!editingId.value) {
if (!username || !password) {
message.warning('请填写用户名和密码')
return
}
if (username.length < 2) {
message.warning('用户名至少 2 个字符')
return
}
if (password.length < 6) {
message.warning('密码至少 6 位')
return
}
try {
await api.post('/users', {
username,
password,
display_name: userForm.value.display_name || username,
role: userForm.value.role,
email: email || undefined,
email_verified: email ? userForm.value.email_verified : true,
max_accounts: userForm.value.max_accounts
})
message.success('用户创建成功')
modalVisible.value = false
fetchUsers()
} catch (error) {
message.error(formatApiError(error, '创建失败'))
}
return
}
if (password && password.length < 6) {
message.warning('新密码至少 6 位')
return
}
const payload = {
display_name: userForm.value.display_name,
role: userForm.value.role,
is_active: userForm.value.is_active,
email: email
}
if (email) {
payload.email_verified = userForm.value.email_verified
}
if (password) {
payload.password = password
}
if (!isAdminRole.value) {
payload.max_accounts = userForm.value.max_accounts
}
try {
await api.put(`/users/${editingId.value}`, payload)
message.success('用户更新成功')
modalVisible.value = false
fetchUsers()
if (auth.user?.id === editingId.value) {
await auth.fetchMe()
}
} catch (error) {
message.error(formatApiError(error, '更新失败'))
}
}
const handleDelete = (record) => {
Modal.confirm({
title: `确定删除用户「${record.username}」吗?`,
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
} catch (error) {
message.error(formatApiError(error, '删除失败'))
}
}
})
}
const roleTagColor = (role) => {
if (role === 'admin') return 'purple'
if (role === 'operator') return 'geekblue'
return 'default'
}
const emailVerifyLabel = (record) => {
if (!record.email) {
if (record.role === 'admin') return '无需验证'
if (emailBindingRequired.value) return '需绑定邮箱'
return '无需验证'
}
if (!emailVerificationRequired.value) {
return record.email_verified ? '已验证' : '未验证(可登录)'
}
return record.email_verified ? '已验证' : '未验证'
}
const emailVerifyColor = (record) => {
if (!record.email) {
if (record.role === 'admin') return 'default'
if (emailBindingRequired.value) return 'gold'
return 'default'
}
if (!emailVerificationRequired.value && !record.email_verified) return 'geekblue'
return record.email_verified ? 'green' : 'gold'
}
const isUnlimitedQuota = (record) =>
!!(record.is_admin || record.role === 'admin' ||
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
const resolveAccountLimit = (record) => {
if (isUnlimitedQuota(record)) return null
const raw = record.max_accounts
if (raw == null || raw < 0) return 3
return raw
}
const accountQuotaLabel = (record) => {
const total = record.account_count ?? 0
const active = record.active_account_count ?? total
const disabled = record.disabled_account_count ?? Math.max(0, total - active)
if (isUnlimitedQuota(record)) {
return total > 0 ? `${total} 个 / 不限` : '0 个 / 不限'
}
const limit = resolveAccountLimit(record)
if (disabled > 0) {
return `${active}/${limit}(停用 ${disabled}`
}
return `${total}/${limit}`
}
watch([filteredUsers, pageSize], () => {
const maxPage = Math.max(1, Math.ceil(filteredUsers.value.length / pageSize.value))
if (pageCurrent.value > maxPage) {
pageCurrent.value = maxPage
}
})
watch(searchKeyword, () => {
pageCurrent.value = 1
})
onMounted(() => {
fetchRoles()
fetchDefaultMaxAccounts()
fetchUsers()
})
</script>
<template>
<div class="users-page">
<div class="page-header glass-card">
<div class="page-header-main">
<h2 class="page-title">
<TeamOutlined class="page-title-icon" />
用户管理
</h2>
<p class="subtitle">创建与管理后台用户并分配角色非管理员仅能查看自己创建的用户</p>
</div>
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
<template #icon><PlusOutlined /></template>
新增用户
</a-button>
</div>
<div class="users-toolbar glass-card">
<a-input
v-model:value="searchKeyword"
allow-clear
placeholder="搜索用户名、显示名、邮箱、角色、ID"
class="users-search-input"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<span class="users-toolbar-meta">
{{ searchKeyword.trim() ? `匹配 ${filteredUsers.length} / 共 ${users.length}` : `共 ${users.length}` }} 个用户
</span>
</div>
<div v-if="!isMobile" class="glass-card table-card">
<a-table
:data-source="paginatedUsers"
:loading="loading"
row-key="id"
:pagination="paginationConfig"
:scroll="{ x: 960 }"
>
<a-table-column title="ID" data-index="id" key="id" :width="72" />
<a-table-column title="用户名" data-index="username" key="username" />
<a-table-column title="邮箱" key="email" :width="220">
<template #default="{ record }">
<span :class="{ 'text-muted': !record.email }">{{ record.email || '未绑定' }}</span>
</template>
</a-table-column>
<a-table-column title="邮箱验证" key="email_verified" :width="110">
<template #default="{ record }">
<a-tag :color="emailVerifyColor(record)">
{{ emailVerifyLabel(record) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="显示名" data-index="display_name" key="display_name" />
<a-table-column title="角色" key="role">
<template #default="{ record }">
<a-tag :color="roleTagColor(record.role)">
{{ getRoleLabel(record.role) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="托管额度" key="max_accounts" :width="148">
<template #default="{ record }">
{{ accountQuotaLabel(record) }}
</template>
</a-table-column>
<a-table-column title="状态" key="is_active">
<template #default="{ record }">
<a-tag :color="record.is_active ? 'green' : 'red'">
{{ record.is_active ? '启用' : '禁用' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" key="created_at">
<template #default="{ record }">
{{ new Date(record.created_at).toLocaleString() }}
</template>
</a-table-column>
<a-table-column title="操作" key="action" width="180px">
<template #default="{ record }">
<a-space>
<a-button type="text" class="edit-btn" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button
v-if="record.id !== auth.user?.id"
type="text"
danger
@click="handleDelete(record)"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</div>
<div v-else class="users-mobile-list">
<a-spin :spinning="loading">
<div v-if="paginatedUsers.length" class="user-card-list">
<div v-for="record in paginatedUsers" :key="record.id" class="user-card glass-card">
<div class="user-card-head">
<div>
<div class="user-card-name">
{{ record.username }}
<span class="user-card-id">#{{ record.id }}</span>
</div>
<div class="user-card-display">{{ record.display_name || record.username }}</div>
</div>
<a-tag :color="roleTagColor(record.role)">
{{ getRoleLabel(record.role) }}
</a-tag>
</div>
<div class="user-card-meta">
<div class="user-card-row">
<span class="user-card-label">邮箱</span>
<span :class="{ 'text-muted': !record.email }" class="user-card-value">
{{ record.email || '未绑定' }}
</span>
</div>
<div class="user-card-row">
<span class="user-card-label">邮箱验证</span>
<a-tag :color="emailVerifyColor(record)">
{{ emailVerifyLabel(record) }}
</a-tag>
</div>
<div class="user-card-row">
<span class="user-card-label">托管额度</span>
<span class="user-card-value">{{ accountQuotaLabel(record) }}</span>
</div>
<div class="user-card-row">
<span class="user-card-label">状态</span>
<a-tag :color="record.is_active ? 'green' : 'red'">
{{ record.is_active ? '启用' : '禁用' }}
</a-tag>
</div>
<div class="user-card-row">
<span class="user-card-label">创建时间</span>
<span class="user-card-value user-card-time">
{{ new Date(record.created_at).toLocaleString() }}
</span>
</div>
</div>
<div class="user-card-actions">
<a-button type="text" class="edit-btn" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button
v-if="record.id !== auth.user?.id"
type="text"
danger
@click="handleDelete(record)"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</div>
</div>
</div>
<a-empty
v-else
:description="searchKeyword.trim() ? '未找到匹配用户' : '暂无用户'"
/>
</a-spin>
<div v-if="filteredUsers.length" class="users-mobile-pagination">
<a-pagination
v-model:current="pageCurrent"
v-model:page-size="pageSize"
:total="filteredUsers.length"
:show-size-changer="false"
size="small"
:show-total="(total) => `共 ${total} 条`"
/>
</div>
</div>
<a-modal
v-model:visible="modalVisible"
:title="modalTitle"
:width="modalWidth"
@ok="handleSave"
ok-text="保存"
cancel-text="取消"
>
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
<a-form-item v-if="!editingId" label="用户名" required>
<a-input
v-model:value="userForm.username"
placeholder="登录用户名至少 2 个字符"
:maxlength="50"
/>
</a-form-item>
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
<a-input-password
v-model:value="userForm.password"
placeholder="至少 6 "
:maxlength="128"
/>
</a-form-item>
<a-form-item label="显示名称">
<a-input v-model:value="userForm.display_name" placeholder="界面展示名称" />
</a-form-item>
<a-form-item :label="emailRequiredForRole ? '邮箱(必填)' : '邮箱'">
<a-input
v-model:value="userForm.email"
:placeholder="emailRequiredForRole ? '非管理员用户必须绑定邮箱' : '选填,绑定后可用于邮箱验证登录'"
@change="onEmailChange"
/>
</a-form-item>
<a-form-item v-if="hasEmail" label="邮箱验证状态">
<a-switch
v-model:checked="userForm.email_verified"
checked-children="已验证"
un-checked-children="未验证"
/>
<div class="field-hint">
<template v-if="emailVerificationRequired">
设为「已验证」后,用户可直接登录;设为「未验证」则需完成邮箱验证
</template>
<template v-else>
系统已关闭全局邮箱验证,用户未验证也可登录;此处可手动调整验证状态
</template>
</div>
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="userForm.role" :options="assignableRoleOptions" />
</a-form-item>
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
<a-input-number
v-model:value="userForm.max_accounts"
:min="0"
:max="999"
style="width: 100%;"
/>
<div class="field-hint">该用户最多可添加的抖音托管账号数量</div>
</a-form-item>
<a-form-item v-else label="可添加抖音账号数">
<span class="text-muted">管理员不限制</span>
</a-form-item>
<a-form-item v-if="editingId" label="账号状态">
<a-switch v-model:checked="userForm.is_active" checked-children="启用" un-checked-children="禁用" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.users-page {
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 24px;
margin-bottom: 24px;
}
.page-header-main {
flex: 1;
min-width: 0;
}
.page-title {
margin: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 1.25rem;
}
.page-title-icon {
color: #c084fc;
}
.subtitle {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
.users-toolbar:hover,
.table-card:hover {
transform: none;
}
.users-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 20px;
margin-bottom: 16px;
}
.users-search-input {
flex: 1;
min-width: 220px;
max-width: 420px;
}
.users-toolbar-meta {
color: var(--text-secondary);
font-size: 0.88rem;
white-space: nowrap;
}
.gradient-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
border: none !important;
flex-shrink: 0;
}
.table-card {
padding: 0;
overflow: hidden;
border-radius: 12px;
}
.table-card :deep(.ant-table-wrapper) {
overflow-x: auto;
}
.users-mobile-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.user-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.user-card {
padding: 16px;
border-radius: 12px;
background: hsla(230, 20%, 12%, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.user-card:hover {
transform: none;
background: hsla(230, 20%, 14%, 0.9);
border-color: rgba(192, 132, 252, 0.2);
}
.users-page :deep(.ant-tag) {
margin: 0;
border-width: 1px;
border-style: solid;
}
.users-page :deep(.ant-table) {
background: transparent !important;
}
.users-page :deep(.ant-table-thead > tr > th) {
background: rgba(255, 255, 255, 0.05) !important;
color: #d1d5db !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
font-weight: 600;
}
.users-page :deep(.ant-table-tbody > tr > td) {
background: transparent !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #f3f4f6;
}
.users-page :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(170, 59, 255, 0.08) !important;
}
.user-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.user-card-name {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.user-card-id {
margin-left: 6px;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-muted);
}
.user-card-display {
margin-top: 4px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.user-card-meta {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 0;
border-top: 1px solid var(--border-light);
border-bottom: 1px solid var(--border-light);
}
.user-card-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.user-card-label {
flex-shrink: 0;
font-size: 0.82rem;
color: var(--text-muted);
}
.user-card-value {
text-align: right;
font-size: 0.88rem;
color: var(--text-primary);
word-break: break-all;
}
.user-card-time {
font-size: 0.8rem;
color: var(--text-secondary);
}
.user-card-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 12px;
}
.edit-btn {
color: #d8b4fe !important;
}
.edit-btn:hover {
color: #f3e8ff !important;
}
.users-page :deep(.ant-btn-dangerous.ant-btn-text) {
color: #fca5a5 !important;
}
.users-page :deep(.ant-btn-dangerous.ant-btn-text:hover) {
color: #fecaca !important;
background: rgba(239, 68, 68, 0.12) !important;
}
.users-mobile-pagination {
display: flex;
justify-content: center;
padding-bottom: 8px;
}
.text-muted {
color: var(--text-muted);
}
.field-hint {
margin-top: 6px;
font-size: 0.8rem;
color: var(--text-muted);
}
.user-form :deep(.ant-form-item-label > label) {
color: var(--text-secondary) !important;
}
@media (max-width: 768px) {
.page-header {
padding: 16px;
margin-bottom: 16px;
align-items: stretch;
}
.page-title {
font-size: 1.1rem;
}
.subtitle {
font-size: 0.85rem;
}
.add-user-btn {
width: 100%;
}
.users-toolbar {
padding: 12px 16px;
margin-bottom: 12px;
}
.users-search-input {
max-width: none;
width: 100%;
}
}
</style>