This commit is contained in:
Your Name
2026-08-07 15:35:02 +08:00
parent 3fc94c4a89
commit 6119fdd767
25 changed files with 2126 additions and 355 deletions
+28 -60
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@@ -22,6 +22,24 @@ import {
InboxOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from './stores/auth'
import { HEADER_TITLES } from './config/menus'
const ICON_MAP = {
DashboardOutlined,
UserOutlined,
SettingOutlined,
FileTextOutlined,
MessageOutlined,
BugOutlined,
TeamOutlined,
ControlOutlined,
PayCircleOutlined,
UnorderedListOutlined,
QuestionCircleOutlined,
RocketOutlined,
CloudDownloadOutlined,
InboxOutlined
}
const route = useRoute()
const router = useRouter()
@@ -29,6 +47,9 @@ const auth = useAuthStore()
const selectedKeys = computed(() => [route.path])
const isLoginPage = computed(() => route.path === '/login')
const headerTitle = computed(
() => HEADER_TITLES[route.name] || route.name || '工作台'
)
const navigate = ({ key }) => {
router.push(key)
@@ -57,61 +78,11 @@ const handleLogout = () => {
@click="navigate"
class="custom-menu"
>
<a-menu-item key="/">
<template #icon><DashboardOutlined /></template>
<span>数据概览</span>
</a-menu-item>
<a-menu-item key="/accounts">
<template #icon><UserOutlined /></template>
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
</a-menu-item>
<a-menu-item key="/messages">
<template #icon><MessageOutlined /></template>
<span>私信收发</span>
</a-menu-item>
<a-menu-item key="/rules">
<template #icon><SettingOutlined /></template>
<span>自动回复规则</span>
</a-menu-item>
<a-menu-item key="/logs">
<template #icon><FileTextOutlined /></template>
<span>回复日志面板</span>
</a-menu-item>
<a-menu-item key="/received-messages">
<template #icon><InboxOutlined /></template>
<span>接收消息日志</span>
</a-menu-item>
<a-menu-item key="/system-logs">
<template #icon><BugOutlined /></template>
<span>系统诊断日志</span>
</a-menu-item>
<a-menu-item key="/download">
<template #icon><CloudDownloadOutlined /></template>
<span>软件下载</span>
</a-menu-item>
<a-menu-item key="/help">
<template #icon><QuestionCircleOutlined /></template>
<span>帮助中心</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/users">
<template #icon><TeamOutlined /></template>
<span>用户与角色</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/settings">
<template #icon><ControlOutlined /></template>
<span>系统设置</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/desktop-update">
<template #icon><RocketOutlined /></template>
<span>桌面端升级</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/payment-settings">
<template #icon><PayCircleOutlined /></template>
<span>支付配置</span>
</a-menu-item>
<a-menu-item v-if="!auth.isAdmin" key="/payment-orders">
<template #icon><UnorderedListOutlined /></template>
<span>我的订单</span>
<a-menu-item v-for="item in auth.visibleMenus" :key="item.path">
<template #icon>
<component :is="ICON_MAP[item.icon]" />
</template>
<span>{{ item.title }}</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
@@ -119,9 +90,7 @@ const handleLogout = () => {
<a-layout>
<a-layout-header class="app-header">
<div class="header-left">
<h2 class="header-title">
{{ route.name === 'Dashboard' ? '数据中心' : route.name === 'Accounts' ? '账号中心' : route.name === 'Messages' ? '私信中心' : route.name === 'Rules' ? '策略中心' : route.name === 'ReceivedMessages' ? '接收消息日志' : route.name === 'SystemLogs' ? '诊断中心' : route.name === 'Users' ? '权限中心' : route.name === 'Settings' ? '系统设置' : route.name === 'DesktopUpdate' ? '桌面端升级' : route.name === 'PaymentSettings' ? '支付配置' : route.name === 'MyPaymentOrders' ? '我的订单' : route.name === 'Download' ? '软件下载' : route.name === 'Help' ? '帮助中心' : '日志中心' }}
</h2>
<h2 class="header-title">{{ headerTitle }}</h2>
</div>
<div class="header-right">
<a-space size="middle">
@@ -180,7 +149,6 @@ const handleLogout = () => {
-webkit-text-fill-color: transparent;
}
/* 侧边栏折叠时只保留图标,避免标题文字竖排变形 */
.app-sider.ant-layout-sider-collapsed .logo-container {
justify-content: center;
padding: 0;
+126
View File
@@ -0,0 +1,126 @@
/**
* Sidebar menu catalog. Visibility is driven by permission codes from /auth/me.
*/
export const MENU_ITEMS = [
{
path: '/',
name: 'Dashboard',
title: '数据概览',
permission: 'menu.dashboard',
icon: 'DashboardOutlined'
},
{
path: '/accounts',
name: 'Accounts',
title: '我的账号',
adminTitle: '账号管理',
permission: 'menu.accounts',
icon: 'UserOutlined'
},
{
path: '/messages',
name: 'Messages',
title: '私信收发',
permission: 'menu.messages',
icon: 'MessageOutlined'
},
{
path: '/rules',
name: 'Rules',
title: '自动回复规则',
permission: 'menu.rules',
icon: 'SettingOutlined'
},
{
path: '/logs',
name: 'Logs',
title: '回复日志面板',
permission: 'menu.logs',
icon: 'FileTextOutlined'
},
{
path: '/received-messages',
name: 'ReceivedMessages',
title: '接收消息日志',
permission: 'menu.received_messages',
icon: 'InboxOutlined'
},
{
path: '/system-logs',
name: 'SystemLogs',
title: '系统诊断日志',
permission: 'menu.system_logs',
icon: 'BugOutlined'
},
{
path: '/download',
name: 'Download',
title: '软件下载',
permission: 'menu.download',
icon: 'CloudDownloadOutlined'
},
{
path: '/help',
name: 'Help',
title: '帮助中心',
permission: 'menu.help',
icon: 'QuestionCircleOutlined'
},
{
path: '/users',
name: 'Users',
title: '用户与角色',
permission: 'menu.users',
icon: 'TeamOutlined'
},
{
path: '/settings',
name: 'Settings',
title: '系统设置',
permission: 'menu.settings',
icon: 'ControlOutlined'
},
{
path: '/desktop-update',
name: 'DesktopUpdate',
title: '桌面端升级',
permission: 'menu.desktop_update',
icon: 'RocketOutlined'
},
{
path: '/payment-settings',
name: 'PaymentSettings',
title: '支付配置',
permission: 'menu.payment_settings',
icon: 'PayCircleOutlined'
},
{
path: '/payment-orders',
name: 'MyPaymentOrders',
title: '我的订单',
permission: 'menu.payment_orders',
icon: 'UnorderedListOutlined'
}
]
export const HEADER_TITLES = {
Dashboard: '数据中心',
Accounts: '账号中心',
Messages: '私信中心',
Rules: '策略中心',
ReceivedMessages: '接收消息日志',
SystemLogs: '诊断中心',
Users: '权限中心',
Settings: '系统设置',
DesktopUpdate: '桌面端升级',
PaymentSettings: '支付配置',
MyPaymentOrders: '我的订单',
Download: '软件下载',
Help: '帮助中心',
Logs: '日志中心'
}
export function firstAccessiblePath(hasPermission) {
const hit = MENU_ITEMS.find((item) => hasPermission(item.permission))
return hit?.path || '/help'
}
+52 -21
View File
@@ -15,23 +15,49 @@ import MyPaymentOrders from '../views/MyPaymentOrders.vue'
import Help from '../views/Help.vue'
import Download from '../views/Download.vue'
import { useAuthStore } from '../stores/auth'
import { firstAccessiblePath } from '../config/menus'
const routes = [
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
{ path: '/', component: Dashboard, name: 'Dashboard' },
{ path: '/accounts', component: Accounts, name: 'Accounts', meta: { write: true } },
{ path: '/messages', component: Messages, name: 'Messages', meta: { write: true } },
{ path: '/rules', component: Rules, name: 'Rules', meta: { write: true } },
{ path: '/logs', component: Logs, name: 'Logs' },
{ path: '/received-messages', component: ReceivedMessages, name: 'ReceivedMessages' },
{ path: '/system-logs', component: SystemLogs, name: 'SystemLogs' },
{ path: '/users', component: Users, name: 'Users', meta: { admin: true } },
{ path: '/settings', component: Settings, name: 'Settings', meta: { admin: true } },
{ path: '/desktop-update', component: DesktopUpdate, name: 'DesktopUpdate', meta: { admin: true } },
{ path: '/payment-settings', component: PaymentSettings, name: 'PaymentSettings', meta: { admin: true } },
{ path: '/payment-orders', component: MyPaymentOrders, name: 'MyPaymentOrders' },
{ path: '/help', component: Help, name: 'Help' },
{ path: '/download', component: Download, name: 'Download' }
{ path: '/', component: Dashboard, name: 'Dashboard', meta: { permission: 'menu.dashboard' } },
{ path: '/accounts', component: Accounts, name: 'Accounts', meta: { permission: 'menu.accounts' } },
{ path: '/messages', component: Messages, name: 'Messages', meta: { permission: 'menu.messages' } },
{ path: '/rules', component: Rules, name: 'Rules', meta: { permission: 'menu.rules' } },
{ path: '/logs', component: Logs, name: 'Logs', meta: { permission: 'menu.logs' } },
{
path: '/received-messages',
component: ReceivedMessages,
name: 'ReceivedMessages',
meta: { permission: 'menu.received_messages' }
},
{
path: '/system-logs',
component: SystemLogs,
name: 'SystemLogs',
meta: { permission: 'menu.system_logs' }
},
{ path: '/users', component: Users, name: 'Users', meta: { permission: 'menu.users' } },
{ path: '/settings', component: Settings, name: 'Settings', meta: { permission: 'menu.settings' } },
{
path: '/desktop-update',
component: DesktopUpdate,
name: 'DesktopUpdate',
meta: { permission: 'menu.desktop_update' }
},
{
path: '/payment-settings',
component: PaymentSettings,
name: 'PaymentSettings',
meta: { permission: 'menu.payment_settings' }
},
{
path: '/payment-orders',
component: MyPaymentOrders,
name: 'MyPaymentOrders',
meta: { permission: 'menu.payment_orders' }
},
{ path: '/help', component: Help, name: 'Help', meta: { permission: 'menu.help' } },
{ path: '/download', component: Download, name: 'Download', meta: { permission: 'menu.download' } }
]
const router = createRouter({
@@ -44,7 +70,7 @@ router.beforeEach(async (to) => {
if (to.meta.public) {
if (auth.isLoggedIn && to.path === '/login') {
return '/'
return firstAccessiblePath((code) => auth.hasPermission(code))
}
return true
}
@@ -60,14 +86,19 @@ router.beforeEach(async (to) => {
auth.clearSession()
return '/login'
}
} else if (!Array.isArray(auth.user.permissions)) {
// Old localStorage sessions lack the permission list.
try {
await auth.fetchMe()
} catch {
auth.clearSession()
return '/login'
}
}
if (to.meta.admin && !auth.isAdmin) {
return '/'
}
if (to.meta.write && auth.isViewer) {
return '/'
const required = to.meta.permission
if (required && !auth.hasPermission(required)) {
return firstAccessiblePath((code) => auth.hasPermission(code))
}
return true
+43 -105
View File
@@ -1,197 +1,135 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api'
import { MENU_ITEMS } from '../config/menus'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('kefu_token') || '')
const user = ref(JSON.parse(localStorage.getItem('kefu_user') || 'null'))
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const canWrite = computed(() => ['admin', 'operator'].includes(user.value?.role))
const isViewer = computed(() => user.value?.role === 'viewer')
const roleLabel = computed(() => {
const map = { admin: '管理员', operator: '运营', viewer: '只读' }
return map[user.value?.role] || user.value?.role || ''
const permissions = computed(() => {
const list = user.value?.permissions
return Array.isArray(list) ? list : []
})
const permissionSet = computed(() => new Set(permissions.value))
const isAdmin = computed(() => {
if (typeof user.value?.is_admin === 'boolean') return user.value.is_admin
return user.value?.role === 'admin'
})
const hasPermission = (code) => {
if (!code) return true
if (isAdmin.value) return true
return permissionSet.value.has(code)
}
const canWrite = computed(() => hasPermission('accounts.write'))
const canWriteMessages = computed(() => hasPermission('messages.write'))
const canWriteRules = computed(() => hasPermission('rules.write'))
const canManageUsers = computed(() => hasPermission('users.manage'))
const isViewer = computed(() => !canWrite.value && !isAdmin.value)
const roleLabel = computed(
() => user.value?.role_label || user.value?.role || ''
)
const visibleMenus = computed(() =>
MENU_ITEMS.filter((item) => hasPermission(item.permission)).map((item) => ({
...item,
title: isAdmin.value && item.adminTitle ? item.adminTitle : item.title
}))
)
const setSession = (accessToken, userData) => {
token.value = accessToken
user.value = userData
localStorage.setItem('kefu_token', accessToken)
localStorage.setItem('kefu_user', JSON.stringify(userData))
}
const clearSession = () => {
token.value = ''
user.value = null
localStorage.removeItem('kefu_token')
localStorage.removeItem('kefu_user')
}
const login = async (username, password) => {
const res = await api.post('/auth/login', { username, password })
const accessToken = res.data.access_token
const me = await api.get('/auth/me', {
headers: { Authorization: `Bearer ${accessToken}` }
})
setSession(accessToken, me.data)
return me.data
}
const register = async (payload) => {
const res = await api.post('/auth/register', payload)
return res.data
}
const verifyEmail = async (verifyToken) => {
const res = await api.post('/auth/verify-email', { token: verifyToken })
return res.data
}
const resendVerification = async (payload) => {
const res = await api.post('/auth/resend-verification', payload)
return res.data
}
const forgotPassword = async (payload) => {
const res = await api.post('/auth/forgot-password', payload)
return res.data
}
const resetPassword = async (token, password) => {
const res = await api.post('/auth/reset-password', { token, password })
const resetPassword = async (resetToken, password) => {
const res = await api.post('/auth/reset-password', {
token: resetToken,
password
})
return res.data
}
const fetchMe = async () => {
if (!token.value) return null
const res = await api.get('/auth/me')
user.value = res.data
localStorage.setItem('kefu_user', JSON.stringify(res.data))
return res.data
}
const logout = () => {
clearSession()
}
return {
token,
user,
isLoggedIn,
permissions,
isAdmin,
canWrite,
canWriteMessages,
canWriteRules,
canManageUsers,
isViewer,
roleLabel,
visibleMenus,
hasPermission,
login,
register,
verifyEmail,
resendVerification,
forgotPassword,
resetPassword,
fetchMe,
logout,
clearSession
}
})
+138 -7
View File
@@ -19,12 +19,12 @@
--accent-green: hsl(150, 75%, 50%);
--accent-red: hsl(360, 75%, 60%);
--text-primary: hsl(0, 0%, 95%);
--text-secondary: hsl(230, 10%, 65%);
--text-muted: hsl(230, 10%, 45%);
--text-primary: hsl(0, 0%, 96%);
--text-secondary: hsl(230, 12%, 72%);
--text-muted: hsl(230, 10%, 58%);
--border-light: rgba(255, 255, 255, 0.06);
--border-glow: hsla(270, 85%, 65%, 0.2);
--border-light: rgba(255, 255, 255, 0.1);
--border-glow: hsla(270, 85%, 65%, 0.28);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
@@ -287,17 +287,148 @@ h1, h2, h3, h4, h5, h6 {
.ant-modal .ant-input-password .ant-input,
.ant-modal textarea.ant-input,
.ant-modal .ant-select-selector {
background: rgba(255, 255, 255, 0.05) !important;
border-color: var(--border-light) !important;
background: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(255, 255, 255, 0.16) !important;
color: var(--text-primary) !important;
box-shadow: none !important;
}
.ant-modal .ant-input:hover,
.ant-modal .ant-input-affix-wrapper:hover,
.ant-modal .ant-select-selector:hover,
.ant-modal .ant-input-number:hover {
border-color: rgba(192, 132, 252, 0.45) !important;
}
.ant-modal .ant-input:focus,
.ant-modal .ant-input-focused,
.ant-modal .ant-input-affix-wrapper-focused,
.ant-modal .ant-select-focused .ant-select-selector,
.ant-modal .ant-input-number-focused {
border-color: rgba(192, 132, 252, 0.65) !important;
box-shadow: 0 0 0 2px rgba(170, 59, 255, 0.18) !important;
}
.ant-modal .ant-input::placeholder,
.ant-modal .ant-input-number-input::placeholder,
.ant-modal textarea.ant-input::placeholder {
color: hsl(230, 10%, 62%) !important;
opacity: 1 !important;
}
.ant-modal .ant-input-affix-wrapper input::placeholder {
color: hsl(230, 10%, 62%) !important;
opacity: 1 !important;
}
.ant-modal .ant-checkbox-wrapper {
color: var(--text-primary) !important;
}
.ant-modal .ant-checkbox-inner {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.35) !important;
}
.ant-modal .ant-checkbox-checked .ant-checkbox-inner {
background: #7c3aed !important;
border-color: #a78bfa !important;
}
.ant-modal .ant-select-arrow,
.ant-modal .ant-select-selection-placeholder {
color: var(--text-muted) !important;
}
.ant-modal .ant-select-selection-item {
color: var(--text-primary) !important;
}
.ant-modal .ant-alert-info {
background: rgba(59, 130, 246, 0.12) !important;
border: 1px solid rgba(96, 165, 250, 0.35) !important;
}
.ant-modal .ant-alert-message {
color: #dbeafe !important;
}
/* Tabs / 搜索框 / 分页 — 暗色可读性 */
.ant-tabs-top > .ant-tabs-nav::before {
border-bottom-color: rgba(255, 255, 255, 0.08) !important;
}
.ant-tabs .ant-tabs-tab {
color: var(--text-secondary) !important;
}
.ant-tabs .ant-tabs-tab:hover {
color: #e9d5ff !important;
}
.ant-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #f3e8ff !important;
text-shadow: none;
}
.ant-tabs .ant-tabs-ink-bar {
background: linear-gradient(90deg, #aa3bff, #c084fc) !important;
}
.ant-input,
.ant-input-affix-wrapper,
.ant-select:not(.ant-select-customize-input) .ant-select-selector {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--text-primary) !important;
}
.ant-input::placeholder,
.ant-input-affix-wrapper input::placeholder {
color: var(--text-muted) !important;
opacity: 1 !important;
}
.ant-pagination {
color: var(--text-secondary) !important;
}
.ant-pagination .ant-pagination-item {
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.ant-pagination .ant-pagination-item a {
color: var(--text-secondary) !important;
}
.ant-pagination .ant-pagination-item-active {
background: rgba(147, 51, 234, 0.25) !important;
border-color: rgba(192, 132, 252, 0.55) !important;
}
.ant-pagination .ant-pagination-item-active a {
color: #f3e8ff !important;
}
.ant-pagination .ant-pagination-prev .ant-pagination-item-link,
.ant-pagination .ant-pagination-next .ant-pagination-item-link,
.ant-pagination .ant-select-selector {
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
color: var(--text-secondary) !important;
}
.ant-pagination-options-quick-jumper input {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--text-primary) !important;
}
.ant-table-pagination.ant-pagination {
margin: 16px 20px !important;
}
.ant-modal .ant-radio-button-wrapper {
color: var(--text-secondary) !important;
background: rgba(255, 255, 255, 0.03) !important;
+1 -1
View File
@@ -464,7 +464,7 @@ const onSelectAllChange = (e) => {
else clearSelection()
}
const showMyOrdersEntry = computed(() => !auth.isAdmin)
const showMyOrdersEntry = computed(() => auth.hasPermission('menu.payment_orders'))
const goMyOrders = () => {
router.push('/payment-orders')
+33 -18
View File
@@ -11,7 +11,9 @@ import {
buildStickerPayload,
parseMessageContent
} from '../utils/messageContent'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const accounts = ref([])
const selectedAccount = ref(undefined)
const conversations = ref([])
@@ -96,6 +98,10 @@ const onPickSticker = (item) => {
const previewContent = (raw) => messagePreview(raw)
const sendMessage = async () => {
if (!auth.canWriteMessages) {
message.warning('当前角色无发送私信权限')
return
}
if (!selectedAccount.value || !selectedConv.value) {
message.warning('请选择账号和会话')
return
@@ -223,25 +229,34 @@ onMounted(fetchAccounts)
<MessageBubble :content="pendingPayload" compact />
<a-button type="link" size="small" @click="pendingPayload = ''">取消</a-button>
</div>
<div class="compose-toolbar">
<EmojiPicker @pick-emoji="onPickEmoji" @pick-sticker="onPickSticker" />
</div>
<a-textarea
v-model:value="sendContent"
:rows="6"
placeholder="输入文字,或使用上方按钮发送表情..."
:disabled="!selectedConv"
<template v-if="auth.canWriteMessages">
<div class="compose-toolbar">
<EmojiPicker @pick-emoji="onPickEmoji" @pick-sticker="onPickSticker" />
</div>
<a-textarea
v-model:value="sendContent"
:rows="6"
placeholder="输入文字,或使用上方按钮发送表情..."
:disabled="!selectedConv"
/>
<a-button
type="primary"
class="gradient-btn send-btn"
:loading="sending"
:disabled="!selectedConv || (!sendContent.trim() && !pendingPayload)"
@click="sendMessage"
>
<template #icon><SendOutlined /></template>
发送
</a-button>
</template>
<a-alert
v-else
type="info"
show-icon
message="当前角色为只读,可查看会话但无法发送私信"
style="margin-top: 12px;"
/>
<a-button
type="primary"
class="gradient-btn send-btn"
:loading="sending"
:disabled="!selectedConv || (!sendContent.trim() && !pendingPayload)"
@click="sendMessage"
>
<template #icon><SendOutlined /></template>
发送
</a-button>
</div>
</a-col>
</a-row>
+32 -7
View File
@@ -12,6 +12,7 @@ import {
FilterOutlined
} from '@ant-design/icons-vue'
import api from '../api'
import { useAuthStore } from '../stores/auth'
import ReplyRuleEditor from '../components/ReplyRuleEditor.vue'
import {
emptyReplyForm,
@@ -26,6 +27,7 @@ import {
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const isMobile = useIsMobile()
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 720))
@@ -452,7 +454,12 @@ watch(
>
<template #suffixIcon><FilterOutlined /></template>
</a-select>
<a-button type="primary" class="gradient-btn add-rule-btn" @click="openAddModal">
<a-button
v-if="auth.canWriteRules"
type="primary"
class="gradient-btn add-rule-btn"
@click="openAddModal"
>
<template #icon><PlusOutlined /></template>
添加规则
</a-button>
@@ -489,8 +496,15 @@ watch(
:max="86400"
style="width: 220px;"
:placeholder="`留空用全局默认 ${cooldownEffective} 秒`"
:disabled="!auth.canWriteRules"
/>
<a-button type="primary" class="gradient-btn" :loading="cooldownSaving" @click="saveCooldown">
<a-button
v-if="auth.canWriteRules"
type="primary"
class="gradient-btn"
:loading="cooldownSaving"
@click="saveCooldown"
>
保存冷却时间
</a-button>
</div>
@@ -535,7 +549,7 @@ watch(
</template>
<template v-if="column.key === 'sort_order'">
<a-space size="small">
<a-space v-if="auth.canWriteRules" size="small">
<a-button
type="text"
size="small"
@@ -553,6 +567,7 @@ watch(
<template #icon><ArrowDownOutlined /></template>
</a-button>
</a-space>
<span v-else>{{ record.sort_order ?? '-' }}</span>
</template>
<template v-if="column.key === 'account_id'">
@@ -562,11 +577,15 @@ watch(
</template>
<template v-if="column.key === 'is_active'">
<a-switch :checked="record.is_active" @change="handleToggleRule(record)" />
<a-switch
:checked="record.is_active"
:disabled="!auth.canWriteRules"
@change="handleToggleRule(record)"
/>
</template>
<template v-if="column.key === 'action'">
<a-space size="middle">
<a-space v-if="auth.canWriteRules" size="middle">
<a-button type="text" style="color: #c084fc;" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
编辑
@@ -584,6 +603,7 @@ watch(
</a-button>
</a-popconfirm>
</a-space>
<span v-else class="muted-readonly">只读</span>
</template>
</template>
</a-table>
@@ -597,7 +617,12 @@ watch(
<a-tag :color="matchTypeColor(record.match_type)">
{{ matchTypeLabel(record.match_type) }}
</a-tag>
<a-switch :checked="record.is_active" size="small" @change="handleToggleRule(record)" />
<a-switch
:checked="record.is_active"
size="small"
:disabled="!auth.canWriteRules"
@change="handleToggleRule(record)"
/>
</div>
<div class="rule-card-keyword">
@@ -618,7 +643,7 @@ watch(
</a-tag>
</div>
<div class="rule-card-actions">
<div v-if="auth.canWriteRules" class="rule-card-actions">
<a-space size="small">
<a-button type="text" size="small" class="sort-move-btn" @click="handleMoveRule(record, 'up')">
<ArrowUpOutlined />
+587 -61
View File
@@ -1,7 +1,7 @@
<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 { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '../api'
import { useAuthStore } from '../stores/auth'
import { useIsMobile } from '../composables/useIsMobile'
@@ -11,33 +11,69 @@ const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
const pageCurrent = ref(1)
const pageSize = ref(10)
const searchKeyword = ref('')
const paginatedUsers = computed(() => {
const start = (pageCurrent.value - 1) * pageSize.value
return users.value.slice(start, start + pageSize.value)
})
const FIELD_LABELS = {
username: '用户名',
password: '密码',
display_name: '显示名称',
email: '邮箱',
role: '角色',
max_accounts: '可添加抖音账号数',
is_active: '账号状态',
email_verified: '邮箱验证状态'
}
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
/** 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 activeTab = ref('users')
const users = ref([])
const roles = ref([])
const roleRecords = ref([])
const permissionCatalog = ref({ menus: [], actions: [] })
const loading = ref(false)
const rolesLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref('新增用户')
const editingId = ref(null)
const roleModalVisible = ref(false)
const roleModalTitle = ref('新建角色')
const editingRoleCode = ref(null)
const roleSaving = ref(false)
const roleForm = ref({
code: '',
label: '',
description: '',
permissions: []
})
const userForm = ref({
username: '',
@@ -54,26 +90,73 @@ const defaultRegisterMaxAccounts = ref(3)
const emailVerificationRequired = ref(true)
const emailBindingRequired = ref(false)
const isAdminRole = computed(() => userForm.value.role === 'admin')
const roleOptions = ref([
{ value: 'admin', label: '管理员', is_admin: true },
{ value: 'operator', label: '运营', is_admin: false },
{ value: 'viewer', label: '只读', is_admin: false }
])
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 roleOptions = ref([
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '运营' },
{ value: 'viewer', label: '只读' }
])
const rolePermissionsReadonly = computed(
() => editingRoleCode.value === 'admin'
)
const hasEmail = computed(() => !!userForm.value.email?.trim())
const getRoleLabel = (role) =>
roleOptions.value.find((r) => r.value === role)?.label ||
roleRecords.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(error.response?.data?.detail || '获取用户列表失败')
message.error(formatApiError(error, '获取用户列表失败'))
} finally {
loading.value = false
}
@@ -82,15 +165,133 @@ const fetchUsers = async () => {
const fetchRoles = async () => {
try {
const res = await api.get('/auth/roles')
roles.value = res.data.roles
roles.value = res.data.roles || []
if (roles.value.length) {
roleOptions.value = roles.value
roleOptions.value = roles.value.map((r) => ({
value: r.value,
label: r.label,
is_admin: !!r.is_admin
}))
}
} catch {
// keep defaults
}
}
const fetchRoleRecords = async () => {
rolesLoading.value = true
try {
const [rolesRes, catalogRes] = await Promise.all([
api.get('/roles'),
api.get('/roles/catalog')
])
roleRecords.value = rolesRes.data.roles || []
permissionCatalog.value = catalogRes.data || { menus: [], actions: [] }
if (roleRecords.value.length) {
roleOptions.value = roleRecords.value.map((r) => ({
value: r.value,
label: r.label,
is_admin: !!r.is_admin
}))
}
} catch (error) {
message.error(formatApiError(error, '获取角色列表失败'))
} finally {
rolesLoading.value = false
}
}
const openAddRole = () => {
editingRoleCode.value = null
roleModalTitle.value = '新建角色'
roleForm.value = {
code: '',
label: '',
description: '',
permissions: [
'menu.dashboard',
'menu.accounts',
'menu.messages',
'menu.rules',
'menu.help',
'menu.download'
]
}
roleModalVisible.value = true
}
const openEditRole = (record) => {
editingRoleCode.value = record.value
roleModalTitle.value = record.is_admin ? '查看管理员角色' : '编辑角色'
roleForm.value = {
code: record.value,
label: record.label,
description: record.description || '',
permissions: [...(record.permissions || [])]
}
roleModalVisible.value = true
}
const handleSaveRole = async () => {
const code = (roleForm.value.code || '').trim().toLowerCase()
const label = (roleForm.value.label || '').trim()
if (!editingRoleCode.value && !code) {
message.warning('请填写角色码')
return
}
if (!label) {
message.warning('请填写角色名称')
return
}
roleSaving.value = true
try {
if (editingRoleCode.value) {
await api.put(`/roles/${encodeURIComponent(editingRoleCode.value)}`, {
label,
description: roleForm.value.description || null,
permissions: rolePermissionsReadonly.value
? undefined
: roleForm.value.permissions
})
message.success('角色已更新')
} else {
await api.post('/roles', {
code,
label,
description: roleForm.value.description || null,
permissions: roleForm.value.permissions
})
message.success('角色已创建')
}
roleModalVisible.value = false
await Promise.all([fetchRoleRecords(), fetchRoles()])
} catch (error) {
message.error(formatApiError(error, '保存角色失败'))
} finally {
roleSaving.value = false
}
}
const handleDeleteRole = (record) => {
if (record.is_system) {
message.warning('系统内置角色不可删除')
return
}
Modal.confirm({
title: `确定删除角色「${record.label}」吗?`,
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/roles/${encodeURIComponent(record.value)}`)
message.success('角色已删除')
await Promise.all([fetchRoleRecords(), fetchRoles()])
} catch (error) {
message.error(formatApiError(error, '删除角色失败'))
}
}
})
}
const fetchDefaultMaxAccounts = async () => {
try {
const res = await api.get('/settings')
@@ -131,7 +332,9 @@ const openEdit = (record) => {
email_verified: !!record.email_verified,
role: record.role,
is_active: record.is_active,
max_accounts: record.role === 'admin' ? defaultRegisterMaxAccounts.value : resolveAccountLimit(record)
max_accounts: isUnlimitedQuota(record)
? defaultRegisterMaxAccounts.value
: resolveAccountLimit(record)
}
modalVisible.value = true
}
@@ -144,6 +347,8 @@ const onEmailChange = () => {
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('当前系统要求非管理员用户必须绑定邮箱')
@@ -151,15 +356,23 @@ const handleSave = async () => {
}
if (!editingId.value) {
if (!userForm.value.username.trim() || !userForm.value.password) {
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: userForm.value.username.trim(),
password: userForm.value.password,
display_name: userForm.value.display_name || userForm.value.username,
username,
password,
display_name: userForm.value.display_name || username,
role: userForm.value.role,
email: email || undefined,
email_verified: email ? userForm.value.email_verified : true,
@@ -169,11 +382,16 @@ const handleSave = async () => {
modalVisible.value = false
fetchUsers()
} catch (error) {
message.error(error.response?.data?.detail || '创建失败')
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,
@@ -183,8 +401,8 @@ const handleSave = async () => {
if (email) {
payload.email_verified = userForm.value.email_verified
}
if (userForm.value.password) {
payload.password = userForm.value.password
if (password) {
payload.password = password
}
if (!isAdminRole.value) {
payload.max_accounts = userForm.value.max_accounts
@@ -198,7 +416,7 @@ const handleSave = async () => {
await auth.fetchMe()
}
} catch (error) {
message.error(error.response?.data?.detail || '更新失败')
message.error(formatApiError(error, '更新失败'))
}
}
@@ -207,15 +425,17 @@ const handleDelete = (record) => {
title: `确定删除用户「${record.username}」吗?`,
okType: 'danger',
onOk: async () => {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
try {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
} catch (error) {
message.error(formatApiError(error, '删除失败'))
}
}
})
}
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'
@@ -244,7 +464,15 @@ const emailVerifyColor = (record) => {
return record.email_verified ? 'green' : 'gold'
}
const isUnlimitedQuota = (record) => record.role === 'admin'
const isUnlimitedQuota = (record) =>
!!(record.is_admin || record.role === 'admin' ||
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
watch(activeTab, (tab) => {
if (tab === 'roles' && !roleRecords.value.length) {
fetchRoleRecords()
}
})
const resolveAccountLimit = (record) => {
if (isUnlimitedQuota(record)) return null
@@ -269,13 +497,17 @@ const accountQuotaLabel = (record) => {
return `${total}/${limit}`
}
watch(users, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
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()
@@ -291,12 +523,48 @@ onMounted(() => {
<TeamOutlined class="page-title-icon" />
用户与角色管理
</h2>
<p class="subtitle">管理员可创建用户并分配角色实现数据隔离与权限控制</p>
<p class="subtitle">创建用户自定义角色并勾选菜单与操作权限</p>
</div>
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
<a-button
v-if="activeTab === 'users'"
type="primary"
class="gradient-btn add-user-btn"
@click="openAdd"
>
<template #icon><PlusOutlined /></template>
新增用户
</a-button>
<a-button
v-else
type="primary"
class="gradient-btn add-user-btn"
@click="openAddRole"
>
<template #icon><PlusOutlined /></template>
新建角色
</a-button>
</div>
<a-tabs v-model:activeKey="activeTab" class="users-tabs glass-card">
<a-tab-pane key="users" tab="用户管理" />
<a-tab-pane key="roles" tab="角色设定" />
</a-tabs>
<template v-if="activeTab === 'users'">
<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>
<!-- 桌面端表格 -->
@@ -435,20 +703,113 @@ onMounted(() => {
</div>
</div>
</div>
<a-empty v-else description="暂无用户" />
<a-empty
v-else
:description="searchKeyword.trim() ? '未找到匹配用户' : '暂无用户'"
/>
</a-spin>
<div v-if="users.length" class="users-mobile-pagination">
<div v-if="filteredUsers.length" class="users-mobile-pagination">
<a-pagination
v-model:current="pageCurrent"
v-model:page-size="pageSize"
:total="users.length"
:total="filteredUsers.length"
:show-size-changer="false"
size="small"
:show-total="(total) => `${total}`"
/>
</div>
</div>
</template>
<div v-else class="glass-card roles-panel">
<a-spin :spinning="rolesLoading">
<a-table
v-if="!isMobile"
:data-source="roleRecords"
row-key="value"
:pagination="false"
>
<a-table-column title="角色码" data-index="value" key="value" :width="140" />
<a-table-column title="名称" data-index="label" key="label" :width="140" />
<a-table-column title="说明" key="description">
<template #default="{ record }">
<span :class="{ 'text-muted': !record.description }">
{{ record.description || '—' }}
</span>
</template>
</a-table-column>
<a-table-column title="类型" key="type" :width="100">
<template #default="{ record }">
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
<a-tag v-else color="geekblue">自定义</a-tag>
</template>
</a-table-column>
<a-table-column title="权限数" key="perm_count" :width="90">
<template #default="{ record }">
{{ (record.permissions || []).length }}
</template>
</a-table-column>
<a-table-column title="用户数" data-index="user_count" key="user_count" :width="80" />
<a-table-column title="操作" key="action" :width="160">
<template #default="{ record }">
<a-space>
<a-button type="text" style="color: #c084fc;" @click="openEditRole(record)">
{{ record.is_admin ? '查看' : '编辑' }}
</a-button>
<a-button
v-if="!record.is_system"
type="text"
danger
@click="handleDeleteRole(record)"
>
删除
</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
<div v-else class="user-card-list">
<div v-for="record in roleRecords" :key="record.value" class="user-card glass-card">
<div class="user-card-head">
<div>
<div class="user-card-name">{{ record.label }}</div>
<div class="user-card-display">{{ record.value }}</div>
</div>
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
<a-tag v-else color="geekblue">自定义</a-tag>
</div>
<div class="user-card-meta">
<div class="user-card-row">
<span class="user-card-label">权限</span>
<span class="user-card-value">{{ (record.permissions || []).length }} 项</span>
</div>
<div class="user-card-row">
<span class="user-card-label">用户</span>
<span class="user-card-value">{{ record.user_count ?? 0 }}</span>
</div>
</div>
<div class="user-card-actions">
<a-button type="text" class="edit-btn" @click="openEditRole(record)">
{{ record.is_admin ? '查看' : '编辑' }}
</a-button>
<a-button
v-if="!record.is_system"
type="text"
danger
@click="handleDeleteRole(record)"
>
删除
</a-button>
</div>
</div>
<a-empty v-if="!roleRecords.length" description="暂无角色" />
</div>
</a-spin>
</div>
<a-modal
v-model:visible="modalVisible"
@@ -460,10 +821,18 @@ onMounted(() => {
>
<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-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 " />
<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="界面展示名称" />
@@ -511,12 +880,81 @@ onMounted(() => {
</a-form>
</a-modal>
<a-modal
v-model:visible="roleModalVisible"
:title="roleModalTitle"
:width="isMobile ? 'calc(100vw - 32px)' : 720"
:confirm-loading="roleSaving"
@ok="handleSaveRole"
ok-text="保存"
cancel-text="取消"
>
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
<a-form-item label="角色码" required>
<a-input
v-model:value="roleForm.code"
placeholder="小写字母开头 ops_leader"
:disabled="!!editingRoleCode"
:maxlength="50"
/>
<div class="field-hint">创建后不可修改;仅小写字母、数字、下划线</div>
</a-form-item>
<a-form-item label="显示名称" required>
<a-input v-model:value="roleForm.label" placeholder="界面展示名称" :maxlength="100" />
</a-form-item>
<a-form-item label="说明">
<a-input
v-model:value="roleForm.description"
placeholder="可选描述该角色的职责"
:maxlength="255"
/>
</a-form-item>
<a-alert
v-if="rolePermissionsReadonly"
type="info"
show-icon
message="管理员角色固定拥有全部权限不可取消勾选"
style="margin-bottom: 16px;"
/>
<a-form-item label="菜单权限">
<a-checkbox-group
v-model:value="roleForm.permissions"
:disabled="rolePermissionsReadonly"
class="perm-grid"
>
<a-checkbox
v-for="item in permissionCatalog.menus"
:key="item.code"
:value="item.code"
>
{{ item.label }}
</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="操作权限">
<a-checkbox-group
v-model:value="roleForm.permissions"
:disabled="rolePermissionsReadonly"
class="perm-grid"
>
<a-checkbox
v-for="item in permissionCatalog.actions"
:key="item.code"
:value="item.code"
>
{{ item.label }}
</a-checkbox>
</a-checkbox-group>
</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>
<li><strong>管理员</strong>全局数据范围 + 全部权限不可删除</li>
<li><strong>运营 / 只读 / 自定义角色</strong>仅能访问自己的账号数据菜单与操作由勾选权限决定</li>
<li>自定义角色可在角色设定中新建并分配给用户</li>
</ul>
</div>
</div>
@@ -562,6 +1000,70 @@ onMounted(() => {
line-height: 1.5;
}
.users-tabs {
padding: 8px 16px 0;
margin-bottom: 16px;
}
.users-tabs:hover,
.users-toolbar:hover,
.table-card:hover,
.roles-panel:hover,
.role-help:hover {
transform: none;
}
.users-tabs :deep(.ant-tabs-nav) {
margin-bottom: 0;
}
.users-tabs :deep(.ant-tabs-tab) {
font-size: 0.95rem;
font-weight: 500;
padding: 12px 4px;
}
.roles-panel {
padding: 16px;
margin-bottom: 16px;
overflow: hidden;
}
.perm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px 14px;
width: 100%;
}
.perm-grid :deep(.ant-checkbox-wrapper) {
color: #e5e7eb !important;
margin-left: 0 !important;
line-height: 1.45;
}
.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;
@@ -614,19 +1116,20 @@ onMounted(() => {
}
.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;
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 var(--border-light) !important;
color: var(--text-primary);
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.05) !important;
background: rgba(170, 59, 255, 0.08) !important;
}
.user-card-head {
@@ -699,7 +1202,20 @@ onMounted(() => {
}
.edit-btn {
color: #c084fc !important;
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 {
@@ -753,6 +1269,16 @@ onMounted(() => {
width: 100%;
}
.users-toolbar {
padding: 12px 16px;
margin-bottom: 12px;
}
.users-search-input {
max-width: none;
width: 100%;
}
.role-help {
margin-top: 16px !important;
padding: 16px !important;