更新
This commit is contained in:
+28
-60
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user