This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
+766
View File
@@ -0,0 +1,766 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } 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 paginatedUsers = computed(() => {
const start = (pageCurrent.value - 1) * pageSize.value
return users.value.slice(start, start + pageSize.value)
})
const paginationConfig = computed(() => ({
current: pageCurrent.value,
pageSize: pageSize.value,
total: users.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 auth = useAuthStore()
const users = ref([])
const roles = 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 isAdminRole = computed(() => userForm.value.role === 'admin')
const emailRequiredForRole = computed(
() => emailBindingRequired.value && !isAdminRole.value
)
const roleOptions = ref([
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '运营' },
{ value: 'viewer', label: '只读' }
])
const hasEmail = computed(() => !!userForm.value.email?.trim())
const fetchUsers = async () => {
loading.value = true
try {
const res = await api.get('/users')
users.value = res.data
} catch (error) {
message.error(error.response?.data?.detail || '获取用户列表失败')
} finally {
loading.value = false
}
}
const fetchRoles = async () => {
try {
const res = await api.get('/auth/roles')
roles.value = res.data.roles
if (roles.value.length) {
roleOptions.value = roles.value
}
} 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: record.role === 'admin' ? 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
if (emailRequiredForRole.value && !email) {
message.warning('当前系统要求非管理员用户必须绑定邮箱')
return
}
if (!editingId.value) {
if (!userForm.value.username.trim() || !userForm.value.password) {
message.warning('请填写用户名和密码')
return
}
try {
await api.post('/users', {
username: userForm.value.username.trim(),
password: userForm.value.password,
display_name: userForm.value.display_name || userForm.value.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(error.response?.data?.detail || '创建失败')
}
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 (userForm.value.password) {
payload.password = userForm.value.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(error.response?.data?.detail || '更新失败')
}
}
const handleDelete = (record) => {
Modal.confirm({
title: `确定删除用户「${record.username}」吗?`,
okType: 'danger',
onOk: async () => {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
}
})
}
const getRoleLabel = (role) => roleOptions.value.find(r => r.value === role)?.label || role
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.role === '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(users, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
if (pageCurrent.value > maxPage) {
pageCurrent.value = maxPage
}
})
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 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" style="color: #c084fc;" @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="暂无用户" />
</a-spin>
<div v-if="users.length" class="users-mobile-pagination">
<a-pagination
v-model:current="pageCurrent"
v-model:page-size="pageSize"
:total="users.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="登录用户名" />
</a-form-item>
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
<a-input-password v-model:value="userForm.password" placeholder="至少 6 " />
</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="roleOptions" />
</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 class="glass-card role-help">
<h3 style="margin-top: 0; color: #fff;">角色权限说明</h3>
<ul class="role-list">
<li><strong>管理员</strong>管理所有抖音账号用户全局规则与系统日志</li>
<li><strong>运营</strong>管理自己创建的抖音账号规则与私信不可见他人数据</li>
<li><strong>只读</strong>仅查看自己账号的数据不可修改或发送</li>
</ul>
</div>
</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;
}
.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.03) !important;
color: var(--text-secondary) !important;
border-bottom: 1px solid var(--border-light) !important;
}
.users-page :deep(.ant-table-tbody > tr > td) {
background: transparent !important;
border-bottom: 1px solid var(--border-light) !important;
color: var(--text-primary);
}
.users-page :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(170, 59, 255, 0.05) !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: #c084fc !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);
}
.role-help {
margin-top: 24px;
padding: 20px;
}
.role-list {
color: var(--text-secondary);
line-height: 1.8;
margin: 0;
padding-left: 20px;
}
.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%;
}
.role-help {
margin-top: 16px !important;
padding: 16px !important;
}
.role-list {
padding-left: 18px;
font-size: 0.88rem;
}
}
</style>