新增
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
MessageOutlined,
|
||||
BugOutlined,
|
||||
TeamOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
LogoutOutlined,
|
||||
ControlOutlined,
|
||||
PayCircleOutlined,
|
||||
@@ -32,6 +33,7 @@ const ICON_MAP = {
|
||||
MessageOutlined,
|
||||
BugOutlined,
|
||||
TeamOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ControlOutlined,
|
||||
PayCircleOutlined,
|
||||
UnorderedListOutlined,
|
||||
|
||||
@@ -21,6 +21,14 @@ const props = defineProps({
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canUploadCards: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,10 +47,12 @@ const typeOptions = replyTypeOptions.map((opt) => ({
|
||||
const cardUploading = ref({})
|
||||
|
||||
const addReplyItem = () => {
|
||||
if (props.readonly) return
|
||||
replyItems.value = [...replyItems.value, emptyReplyForm()]
|
||||
}
|
||||
|
||||
const removeReplyItem = (index) => {
|
||||
if (props.readonly) return
|
||||
if (replyItems.value.length <= 1) {
|
||||
message.warning('至少保留一条回复消息')
|
||||
return
|
||||
@@ -53,6 +63,7 @@ const removeReplyItem = (index) => {
|
||||
}
|
||||
|
||||
const updateReplyField = (index, field, value) => {
|
||||
if (props.readonly) return
|
||||
const next = replyItems.value.map((item, i) =>
|
||||
i === index ? { ...item, [field]: value } : item
|
||||
)
|
||||
@@ -60,6 +71,7 @@ const updateReplyField = (index, field, value) => {
|
||||
}
|
||||
|
||||
const updateReplyFields = (index, fields) => {
|
||||
if (props.readonly) return
|
||||
const next = replyItems.value.map((item, i) =>
|
||||
i === index ? { ...item, ...fields } : item
|
||||
)
|
||||
@@ -67,6 +79,11 @@ const updateReplyFields = (index, fields) => {
|
||||
}
|
||||
|
||||
const uploadCardImage = async (index, options) => {
|
||||
if (props.readonly || !props.canUploadCards) {
|
||||
message.warning('当前账号无卡片上传权限')
|
||||
options?.onError?.(new Error('no permission'))
|
||||
return
|
||||
}
|
||||
const { file, onSuccess, onError } = options
|
||||
cardUploading.value = { ...cardUploading.value, [index]: true }
|
||||
try {
|
||||
@@ -104,7 +121,13 @@ const copyPageUrl = async (url) => {
|
||||
<div class="reply-rule-editor">
|
||||
<div v-if="showHeader" class="reply-list-header">
|
||||
<span class="reply-list-title">自动回复消息</span>
|
||||
<a-button type="dashed" size="small" class="reply-add-btn" @click="addReplyItem">
|
||||
<a-button
|
||||
v-if="!readonly"
|
||||
type="dashed"
|
||||
size="small"
|
||||
class="reply-add-btn"
|
||||
@click="addReplyItem"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加一条消息
|
||||
</a-button>
|
||||
@@ -114,7 +137,7 @@ const copyPageUrl = async (url) => {
|
||||
<div class="reply-item-header">
|
||||
<span>消息 {{ replyIndex + 1 }}</span>
|
||||
<a-button
|
||||
v-if="replyItems.length > 1"
|
||||
v-if="!readonly && replyItems.length > 1"
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
@@ -130,6 +153,7 @@ const copyPageUrl = async (url) => {
|
||||
:value="reply.reply_type"
|
||||
button-style="solid"
|
||||
class="reply-type-group"
|
||||
:disabled="readonly"
|
||||
@update:value="(v) => updateReplyField(replyIndex, 'reply_type', v)"
|
||||
>
|
||||
<a-radio-button v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
|
||||
@@ -193,6 +217,7 @@ const copyPageUrl = async (url) => {
|
||||
<a-form-item label="封面图片" required>
|
||||
<div class="card-upload-row">
|
||||
<a-upload
|
||||
v-if="canUploadCards && !readonly"
|
||||
name="file"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
@@ -214,6 +239,15 @@ const copyPageUrl = async (url) => {
|
||||
<div>上传封面</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div v-else class="card-upload-placeholder">
|
||||
<img
|
||||
v-if="reply.reply_card_cover_url || reply.reply_card_image_path"
|
||||
:src="reply.reply_card_cover_url || reply.reply_card_image_path"
|
||||
alt="封面"
|
||||
class="card-cover-preview"
|
||||
/>
|
||||
<template v-else>无上传权限</template>
|
||||
</div>
|
||||
<span class="card-upload-tip">上传后自动转为 PNG favicon(32×32)与卡片封面(256×256)</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Sidebar menu catalog. Visibility is driven by permission codes from /auth/me.
|
||||
* Admin pages also require paired action permissions (alsoRequires).
|
||||
*/
|
||||
export const MENU_ITEMS = [
|
||||
{
|
||||
@@ -36,6 +37,7 @@ export const MENU_ITEMS = [
|
||||
name: 'Logs',
|
||||
title: '回复日志面板',
|
||||
permission: 'menu.logs',
|
||||
alsoRequires: ['logs.read'],
|
||||
icon: 'FileTextOutlined'
|
||||
},
|
||||
{
|
||||
@@ -43,6 +45,7 @@ export const MENU_ITEMS = [
|
||||
name: 'ReceivedMessages',
|
||||
title: '接收消息日志',
|
||||
permission: 'menu.received_messages',
|
||||
alsoRequires: ['received_messages.read'],
|
||||
icon: 'InboxOutlined'
|
||||
},
|
||||
{
|
||||
@@ -50,6 +53,7 @@ export const MENU_ITEMS = [
|
||||
name: 'SystemLogs',
|
||||
title: '系统诊断日志',
|
||||
permission: 'menu.system_logs',
|
||||
alsoRequires: ['system_logs.read'],
|
||||
icon: 'BugOutlined'
|
||||
},
|
||||
{
|
||||
@@ -69,15 +73,25 @@ export const MENU_ITEMS = [
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
title: '用户与角色',
|
||||
title: '用户管理',
|
||||
permission: 'menu.users',
|
||||
alsoRequires: ['users.manage'],
|
||||
icon: 'TeamOutlined'
|
||||
},
|
||||
{
|
||||
path: '/roles',
|
||||
name: 'Roles',
|
||||
title: '角色设定',
|
||||
permission: 'menu.roles',
|
||||
alsoRequires: ['roles.manage'],
|
||||
icon: 'SafetyCertificateOutlined'
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
title: '系统设置',
|
||||
permission: 'menu.settings',
|
||||
alsoRequires: ['settings.manage'],
|
||||
icon: 'ControlOutlined'
|
||||
},
|
||||
{
|
||||
@@ -85,6 +99,7 @@ export const MENU_ITEMS = [
|
||||
name: 'DesktopUpdate',
|
||||
title: '桌面端升级',
|
||||
permission: 'menu.desktop_update',
|
||||
alsoRequires: ['desktop.manage'],
|
||||
icon: 'RocketOutlined'
|
||||
},
|
||||
{
|
||||
@@ -92,6 +107,7 @@ export const MENU_ITEMS = [
|
||||
name: 'PaymentSettings',
|
||||
title: '支付配置',
|
||||
permission: 'menu.payment_settings',
|
||||
alsoRequires: ['payments.manage'],
|
||||
icon: 'PayCircleOutlined'
|
||||
},
|
||||
{
|
||||
@@ -99,6 +115,7 @@ export const MENU_ITEMS = [
|
||||
name: 'MyPaymentOrders',
|
||||
title: '我的订单',
|
||||
permission: 'menu.payment_orders',
|
||||
alsoRequires: ['orders.read'],
|
||||
icon: 'UnorderedListOutlined'
|
||||
}
|
||||
]
|
||||
@@ -110,7 +127,8 @@ export const HEADER_TITLES = {
|
||||
Rules: '策略中心',
|
||||
ReceivedMessages: '接收消息日志',
|
||||
SystemLogs: '诊断中心',
|
||||
Users: '权限中心',
|
||||
Users: '用户管理',
|
||||
Roles: '角色设定',
|
||||
Settings: '系统设置',
|
||||
DesktopUpdate: '桌面端升级',
|
||||
PaymentSettings: '支付配置',
|
||||
@@ -120,7 +138,13 @@ export const HEADER_TITLES = {
|
||||
Logs: '日志中心'
|
||||
}
|
||||
|
||||
export function menuAccessible(item, hasPermission) {
|
||||
if (!item || !hasPermission(item.permission)) return false
|
||||
const extra = item.alsoRequires || []
|
||||
return extra.every((code) => hasPermission(code))
|
||||
}
|
||||
|
||||
export function firstAccessiblePath(hasPermission) {
|
||||
const hit = MENU_ITEMS.find((item) => hasPermission(item.permission))
|
||||
const hit = MENU_ITEMS.find((item) => menuAccessible(item, hasPermission))
|
||||
return hit?.path || '/help'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import SystemLogs from '../views/SystemLogs.vue'
|
||||
import ReceivedMessages from '../views/ReceivedMessages.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Users from '../views/Users.vue'
|
||||
import Roles from '../views/Roles.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
import DesktopUpdate from '../views/DesktopUpdate.vue'
|
||||
import PaymentSettings from '../views/PaymentSettings.vue'
|
||||
@@ -15,7 +16,8 @@ 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'
|
||||
import { firstAccessiblePath, MENU_ITEMS, menuAccessible } from '../config/menus'
|
||||
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
|
||||
@@ -37,6 +39,7 @@ const routes = [
|
||||
meta: { permission: 'menu.system_logs' }
|
||||
},
|
||||
{ path: '/users', component: Users, name: 'Users', meta: { permission: 'menu.users' } },
|
||||
{ path: '/roles', component: Roles, name: 'Roles', meta: { permission: 'menu.roles' } },
|
||||
{ path: '/settings', component: Settings, name: 'Settings', meta: { permission: 'menu.settings' } },
|
||||
{
|
||||
path: '/desktop-update',
|
||||
@@ -101,6 +104,11 @@ router.beforeEach(async (to) => {
|
||||
return firstAccessiblePath((code) => auth.hasPermission(code))
|
||||
}
|
||||
|
||||
const menuItem = MENU_ITEMS.find((item) => item.path === to.path)
|
||||
if (menuItem && !menuAccessible(menuItem, (code) => auth.hasPermission(code))) {
|
||||
return firstAccessiblePath((code) => auth.hasPermission(code))
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api'
|
||||
import { MENU_ITEMS } from '../config/menus'
|
||||
import { MENU_ITEMS, menuAccessible } from '../config/menus'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('kefu_token') || '')
|
||||
@@ -24,21 +24,60 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const hasPermission = (code) => {
|
||||
if (!code) return true
|
||||
if (isAdmin.value) return true
|
||||
return permissionSet.value.has(code)
|
||||
if (permissionSet.value.has(code)) return true
|
||||
// Legacy accounts.write covers all granular account write buttons.
|
||||
if (
|
||||
code.startsWith('accounts.') &&
|
||||
code !== 'accounts.write' &&
|
||||
permissionSet.value.has('accounts.write')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const canWrite = computed(() => hasPermission('accounts.write'))
|
||||
const canWrite = computed(() =>
|
||||
hasPermission('accounts.write') ||
|
||||
hasPermission('accounts.create') ||
|
||||
hasPermission('accounts.update') ||
|
||||
hasPermission('accounts.delete') ||
|
||||
hasPermission('accounts.start') ||
|
||||
hasPermission('accounts.stop') ||
|
||||
hasPermission('accounts.cookie')
|
||||
)
|
||||
const canCreateAccounts = computed(() => hasPermission('accounts.create'))
|
||||
const canUpdateAccounts = computed(() => hasPermission('accounts.update'))
|
||||
const canDeleteAccounts = computed(() => hasPermission('accounts.delete'))
|
||||
const canStartAccounts = computed(() => hasPermission('accounts.start'))
|
||||
const canStopAccounts = computed(() => hasPermission('accounts.stop'))
|
||||
const canManageCookies = computed(() => hasPermission('accounts.cookie'))
|
||||
const canWriteMessages = computed(() => hasPermission('messages.write'))
|
||||
const canWriteRules = computed(() => hasPermission('rules.write'))
|
||||
const canWriteLinkCards = computed(() => hasPermission('link_cards.write'))
|
||||
const canClearSystemLogs = computed(() => hasPermission('system_logs.clear'))
|
||||
const canManageUsers = computed(() => hasPermission('users.manage'))
|
||||
const isViewer = computed(() => !canWrite.value && !isAdmin.value)
|
||||
const canManageRoles = computed(() => hasPermission('roles.manage'))
|
||||
const canManagePayments = computed(() => hasPermission('payments.manage'))
|
||||
const canManageSettings = computed(() => hasPermission('settings.manage'))
|
||||
const canManageDatabase = computed(() => hasPermission('settings.database'))
|
||||
const canCreateOrders = computed(() => hasPermission('orders.create'))
|
||||
const hasGlobalDataScope = computed(
|
||||
() => isAdmin.value || hasPermission('data.scope_all')
|
||||
)
|
||||
const isViewer = computed(
|
||||
() =>
|
||||
!canWrite.value &&
|
||||
!canWriteMessages.value &&
|
||||
!canWriteRules.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) => ({
|
||||
MENU_ITEMS.filter((item) => menuAccessible(item, hasPermission)).map((item) => ({
|
||||
...item,
|
||||
title: isAdmin.value && item.adminTitle ? item.adminTitle : item.title
|
||||
}))
|
||||
@@ -115,9 +154,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
permissions,
|
||||
isAdmin,
|
||||
canWrite,
|
||||
canCreateAccounts,
|
||||
canUpdateAccounts,
|
||||
canDeleteAccounts,
|
||||
canStartAccounts,
|
||||
canStopAccounts,
|
||||
canManageCookies,
|
||||
canWriteMessages,
|
||||
canWriteRules,
|
||||
canWriteLinkCards,
|
||||
canClearSystemLogs,
|
||||
canManageUsers,
|
||||
canManageRoles,
|
||||
canManagePayments,
|
||||
canManageSettings,
|
||||
canManageDatabase,
|
||||
canCreateOrders,
|
||||
hasGlobalDataScope,
|
||||
isViewer,
|
||||
roleLabel,
|
||||
visibleMenus,
|
||||
|
||||
@@ -395,7 +395,12 @@ const accountQuotaLabel = computed(() => {
|
||||
})
|
||||
|
||||
const canPurchaseSlots = computed(() => {
|
||||
return paymentConfig.value?.payment_enabled && accountQuota.value.limited && !canAddAccount.value
|
||||
return (
|
||||
auth.canCreateOrders &&
|
||||
paymentConfig.value?.payment_enabled &&
|
||||
accountQuota.value.limited &&
|
||||
!canAddAccount.value
|
||||
)
|
||||
})
|
||||
|
||||
const startableAccounts = computed(() =>
|
||||
@@ -857,7 +862,7 @@ const queueActionLabel = (item) => {
|
||||
}
|
||||
|
||||
const isQueueSendDisabled = (item) => {
|
||||
if (auth.user?.role === 'viewer') return true
|
||||
if (!auth.canWriteMessages) return true
|
||||
if (!queueSnapshot.value.running) return true
|
||||
if (queueSendingJobId.value) return true
|
||||
return item?.status !== 'waiting' || !!item?.expedited
|
||||
@@ -1000,8 +1005,8 @@ const goAccountRulesPage = (accountId) => {
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
if (!auth.canWrite) {
|
||||
message.warning('当前账号为只读角色,不能添加托管账号')
|
||||
if (!auth.canCreateAccounts) {
|
||||
message.warning('当前账号无添加账号权限')
|
||||
return
|
||||
}
|
||||
if (!canAddAccount.value) {
|
||||
@@ -1599,11 +1604,13 @@ const openEditModal = async (acc) => {
|
||||
}
|
||||
initUserAgentFields(acc)
|
||||
try {
|
||||
const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`)
|
||||
editForm.value.cookie_data = res.data.cookie_data
|
||||
? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2)
|
||||
: ''
|
||||
applyCookieResponse(res.data)
|
||||
if (auth.canManageCookies) {
|
||||
const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`)
|
||||
editForm.value.cookie_data = res.data.cookie_data
|
||||
? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2)
|
||||
: ''
|
||||
applyCookieResponse(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载 Cookie 失败')
|
||||
} finally {
|
||||
@@ -1798,7 +1805,7 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div v-if="startableAccounts.length > 0" class="batch-toolbar">
|
||||
<div v-if="auth.canStartAccounts && startableAccounts.length > 0" class="batch-toolbar">
|
||||
<a-checkbox
|
||||
:indeterminate="selectedStartableCount > 0 && selectedStartableCount < startableAccounts.length"
|
||||
:checked="startableAccounts.length > 0 && selectedStartableCount === startableAccounts.length"
|
||||
@@ -1823,6 +1830,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<a-popconfirm
|
||||
v-if="auth.canStartAccounts"
|
||||
title="将启动所有未启动的账号(含其他分页),确认继续?"
|
||||
ok-text="全部启动"
|
||||
cancel-text="取消"
|
||||
@@ -1857,7 +1865,7 @@ onUnmounted(() => {
|
||||
购买额度
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="auth.canWrite"
|
||||
v-if="auth.canCreateAccounts"
|
||||
type="primary"
|
||||
class="gradient-btn"
|
||||
:disabled="!canAddAccount && !canPurchaseSlots"
|
||||
@@ -2050,7 +2058,13 @@ onUnmounted(() => {
|
||||
<template #icon><MessageOutlined /></template>
|
||||
自动回复
|
||||
</a-button>
|
||||
<a-button type="text" size="small" class="action-edit-btn" @click="openEditModal(acc)">
|
||||
<a-button
|
||||
v-if="auth.canUpdateAccounts"
|
||||
type="text"
|
||||
size="small"
|
||||
class="action-edit-btn"
|
||||
@click="openEditModal(acc)"
|
||||
>
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
@@ -2061,7 +2075,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="account-actions-primary">
|
||||
<a-button
|
||||
v-if="!acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error' || acc.status === 'starting')"
|
||||
v-if="auth.canStartAccounts && !acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error' || acc.status === 'starting')"
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
@@ -2073,7 +2087,7 @@ onUnmounted(() => {
|
||||
启动托管
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="!acc.quota_disabled && acc.status === 'logging_in'"
|
||||
v-if="auth.canStartAccounts && !acc.quota_disabled && acc.status === 'logging_in'"
|
||||
type="primary"
|
||||
size="small"
|
||||
class="action-btn-warn"
|
||||
@@ -2083,7 +2097,7 @@ onUnmounted(() => {
|
||||
扫码登录
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="!acc.quota_disabled && acc.status === 'online'"
|
||||
v-if="auth.canStopAccounts && !acc.quota_disabled && acc.status === 'online'"
|
||||
danger
|
||||
ghost
|
||||
size="small"
|
||||
@@ -2093,6 +2107,7 @@ onUnmounted(() => {
|
||||
停止托管
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="auth.canDeleteAccounts"
|
||||
title="确认删除该账号?删除后其所有的自动回复规则和消息日志也将被清除。"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@@ -2113,7 +2128,7 @@ onUnmounted(() => {
|
||||
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
||||
<h3>暂无托管账号</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
||||
<a-button v-if="auth.canWrite" type="primary" class="gradient-btn" @click="openAddModal">
|
||||
<a-button v-if="auth.canCreateAccounts" type="primary" class="gradient-btn" @click="openAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
立即添加
|
||||
</a-button>
|
||||
@@ -2168,7 +2183,10 @@ onUnmounted(() => {
|
||||
<a-form-item label="手机号 (可选,方便记录备注)">
|
||||
<a-input v-model:value="addForm.phone" placeholder="请输入绑定的手机号码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Cookie 数据 (可选,可直接导入登录态)">
|
||||
<a-form-item
|
||||
v-if="auth.canManageCookies"
|
||||
label="Cookie 数据 (可选,可直接导入登录态)"
|
||||
>
|
||||
<a-textarea
|
||||
v-model:value="addForm.cookie_data"
|
||||
:rows="10"
|
||||
@@ -2381,7 +2399,10 @@ onUnmounted(() => {
|
||||
启动托管时,浏览器登录与 IM 发送将使用所选设备头;须与 a_bogus 签名一致,修改后请重新启动托管。
|
||||
</p>
|
||||
|
||||
<a-form-item label="Cookie 数据(Playwright storage_state JSON)">
|
||||
<a-form-item
|
||||
v-if="auth.canManageCookies"
|
||||
label="Cookie 数据(Playwright storage_state JSON)"
|
||||
>
|
||||
<a-textarea
|
||||
v-model:value="editForm.cookie_data"
|
||||
:rows="isMobile ? 8 : 12"
|
||||
@@ -2392,6 +2413,7 @@ onUnmounted(() => {
|
||||
|
||||
<div class="edit-modal-footer">
|
||||
<a-popconfirm
|
||||
v-if="auth.canManageCookies"
|
||||
title="确认清除该账号的 Cookie?清除后需重新扫码登录。"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@@ -2399,8 +2421,20 @@ onUnmounted(() => {
|
||||
>
|
||||
<a-button danger :loading="editSaving">清除 Cookie</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button :loading="editSaving" @click="saveAccountInfo">保存账号信息</a-button>
|
||||
<a-button type="primary" class="gradient-btn" :loading="editSaving" @click="saveCookie">
|
||||
<a-button
|
||||
v-if="auth.canUpdateAccounts"
|
||||
:loading="editSaving"
|
||||
@click="saveAccountInfo"
|
||||
>
|
||||
保存账号信息
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="auth.canManageCookies"
|
||||
type="primary"
|
||||
class="gradient-btn"
|
||||
:loading="editSaving"
|
||||
@click="saveCookie"
|
||||
>
|
||||
保存 Cookie
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -2439,17 +2473,30 @@ onUnmounted(() => {
|
||||
关闭后该账号不再发送兜底回复,状态与策略中心的「是否启用」开关同步。
|
||||
</span>
|
||||
</div>
|
||||
<a-switch v-model:checked="accountDefaultRuleForm.is_active" />
|
||||
<a-switch
|
||||
v-model:checked="accountDefaultRuleForm.is_active"
|
||||
:disabled="!auth.canWriteRules"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReplyRuleEditor v-model:replies="accountDefaultRuleForm.replies" />
|
||||
<ReplyRuleEditor
|
||||
v-model:replies="accountDefaultRuleForm.replies"
|
||||
:readonly="!auth.canWriteRules"
|
||||
:can-upload-cards="auth.canWriteLinkCards"
|
||||
/>
|
||||
|
||||
<a-space style="width: 100%; justify-content: flex-end; margin-top: 8px;">
|
||||
<a-button @click="goAccountRulesPage(rulesModalAccount.id)">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
管理全部规则
|
||||
</a-button>
|
||||
<a-button type="primary" class="gradient-btn" :loading="rulesModalSaving" @click="saveAccountDefaultRule">
|
||||
<a-button
|
||||
v-if="auth.canWriteRules"
|
||||
type="primary"
|
||||
class="gradient-btn"
|
||||
:loading="rulesModalSaving"
|
||||
@click="saveAccountDefaultRule"
|
||||
>
|
||||
保存兜底回复
|
||||
</a-button>
|
||||
</a-space>
|
||||
@@ -2491,10 +2538,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="auth.user?.role === 'viewer'"
|
||||
v-if="!auth.canWriteMessages"
|
||||
type="info"
|
||||
show-icon
|
||||
message="当前账号为只读权限,可查看队列详情,但不能执行立即发送。"
|
||||
message="当前账号无消息发送权限,可查看队列详情,但不能执行立即发送。"
|
||||
class="reply-queue-alert reply-queue-readonly-alert"
|
||||
/>
|
||||
|
||||
|
||||
@@ -98,20 +98,20 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="banner-side">
|
||||
<div v-if="auth.canWrite" class="banner-actions">
|
||||
<div v-if="auth.hasPermission('menu.accounts')" class="banner-actions">
|
||||
<router-link to="/accounts">
|
||||
<a-button size="large">
|
||||
<template #icon><UserOutlined /></template>
|
||||
{{ auth.isAdmin ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
||||
{{ auth.hasGlobalDataScope ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
||||
</a-button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="auth.canWrite"
|
||||
v-if="auth.canCreateAccounts"
|
||||
:to="{ path: '/accounts', query: { action: 'add' } }"
|
||||
>
|
||||
<a-button type="primary" size="large" class="gradient-btn">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ auth.isAdmin ? '添加账号' : '添加自己的账号' }}
|
||||
{{ auth.hasGlobalDataScope ? '添加账号' : '添加自己的账号' }}
|
||||
</a-button>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -231,8 +231,8 @@ onUnmounted(() => {
|
||||
<div class="quick-actions-grid" style="margin-top: 20px;">
|
||||
<router-link to="/accounts" class="quick-action-card">
|
||||
<UserOutlined class="action-icon text-gradient" />
|
||||
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
||||
<p v-if="auth.isAdmin">管理全平台账号配置与运行状态</p>
|
||||
<span>{{ auth.hasGlobalDataScope ? '账号管理' : '我的账号' }}</span>
|
||||
<p v-if="auth.hasGlobalDataScope">管理全平台账号配置与运行状态</p>
|
||||
<p v-else>仅查看和管理自己添加的账号,当前 {{ stats.myAccounts }} 个</p>
|
||||
</router-link>
|
||||
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import api from '../api'
|
||||
import { useIsMobile } from '../composables/useIsMobile'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const roleRecords = ref([])
|
||||
const permissionCatalog = ref({ menus: [], actions: [], data: [], tree: [], pairs: [] })
|
||||
const rolesLoading = ref(false)
|
||||
const roleModalVisible = ref(false)
|
||||
const roleModalTitle = ref('新建角色')
|
||||
const editingRoleCode = ref(null)
|
||||
const roleSaving = ref(false)
|
||||
const roleForm = ref({
|
||||
code: '',
|
||||
label: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
})
|
||||
|
||||
const rolePermissionsReadonly = computed(() => editingRoleCode.value === 'admin')
|
||||
|
||||
/** Build parent/child tree when API omits ``tree`` (older backends). */
|
||||
const buildPermissionTreeFallback = (catalog) => {
|
||||
const menus = catalog?.menus || []
|
||||
const actions = catalog?.actions || []
|
||||
const data = catalog?.data || []
|
||||
const pairs = catalog?.pairs || []
|
||||
const actionToMenu = {}
|
||||
for (const pair of pairs) {
|
||||
if (pair?.action && pair?.menu) actionToMenu[pair.action] = pair.menu
|
||||
}
|
||||
// Heuristic: accounts.* → menu.accounts, messages.* → menu.messages, etc.
|
||||
const guessParent = (code) => {
|
||||
if (actionToMenu[code]) return actionToMenu[code]
|
||||
if (code.startsWith('accounts.')) return 'menu.accounts'
|
||||
if (code.startsWith('messages.')) return 'menu.messages'
|
||||
if (code.startsWith('rules.') || code === 'link_cards.write') return 'menu.rules'
|
||||
if (code.startsWith('logs.')) return 'menu.logs'
|
||||
if (code.startsWith('received_messages.')) return 'menu.received_messages'
|
||||
if (code.startsWith('system_logs.')) return 'menu.system_logs'
|
||||
if (code === 'users.manage') return 'menu.users'
|
||||
if (code === 'roles.manage') return 'menu.roles'
|
||||
if (code.startsWith('settings.')) return 'menu.settings'
|
||||
if (code === 'desktop.manage') return 'menu.desktop_update'
|
||||
if (code === 'payments.manage') return 'menu.payment_settings'
|
||||
if (code.startsWith('orders.')) return 'menu.payment_orders'
|
||||
return null
|
||||
}
|
||||
const childrenByMenu = Object.fromEntries(menus.map((m) => [m.code, []]))
|
||||
for (const action of actions) {
|
||||
const parent = guessParent(action.code)
|
||||
if (parent && childrenByMenu[parent]) {
|
||||
childrenByMenu[parent].push({ code: action.code, label: action.label })
|
||||
}
|
||||
}
|
||||
const tree = menus.map((menu) => ({
|
||||
code: menu.code,
|
||||
label: menu.label,
|
||||
kind: 'menu',
|
||||
children: childrenByMenu[menu.code] || []
|
||||
}))
|
||||
if (data.length) {
|
||||
tree.push({
|
||||
code: '__group.data__',
|
||||
label: '数据权限',
|
||||
kind: 'group',
|
||||
children: data.map((item) => ({ code: item.code, label: item.label }))
|
||||
})
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
const permissionTree = computed(() => {
|
||||
const catalog = permissionCatalog.value || {}
|
||||
if (Array.isArray(catalog.tree) && catalog.tree.length) return catalog.tree
|
||||
return buildPermissionTreeFallback(catalog)
|
||||
})
|
||||
|
||||
const selectedPermissionSet = computed(
|
||||
() => new Set(roleForm.value.permissions || [])
|
||||
)
|
||||
|
||||
const childCodesOf = (node) => (node.children || []).map((c) => c.code)
|
||||
|
||||
const applyPermissionPairs = (codes) => {
|
||||
const selected = new Set(codes || [])
|
||||
const pairs = permissionCatalog.value.pairs || []
|
||||
for (const pair of pairs) {
|
||||
if (selected.has(pair.menu)) selected.add(pair.action)
|
||||
}
|
||||
const actionPrimary = {}
|
||||
for (const pair of pairs) {
|
||||
if (!actionPrimary[pair.action]) actionPrimary[pair.action] = pair.menu
|
||||
}
|
||||
// Also map from tree: any selected child implies its menu parent.
|
||||
for (const node of permissionTree.value) {
|
||||
if (node.kind === 'menu') {
|
||||
for (const child of node.children || []) {
|
||||
if (selected.has(child.code)) selected.add(node.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [action, menu] of Object.entries(actionPrimary)) {
|
||||
if (selected.has(action)) selected.add(menu)
|
||||
}
|
||||
return [...selected]
|
||||
}
|
||||
|
||||
const isChecked = (code) => selectedPermissionSet.value.has(code)
|
||||
|
||||
const isParentChecked = (node) => {
|
||||
if (node.kind === 'group') {
|
||||
const kids = childCodesOf(node)
|
||||
return kids.length > 0 && kids.every((code) => isChecked(code))
|
||||
}
|
||||
return isChecked(node.code)
|
||||
}
|
||||
|
||||
const isParentIndeterminate = (node) => {
|
||||
const kids = childCodesOf(node)
|
||||
if (!kids.length) return false
|
||||
const checkedCount = kids.filter((code) => isChecked(code)).length
|
||||
if (node.kind === 'group') {
|
||||
return checkedCount > 0 && checkedCount < kids.length
|
||||
}
|
||||
// Menu parent: indeterminate when some (not all) children checked,
|
||||
// or when menu is checked but children are mixed.
|
||||
if (checkedCount === 0) return false
|
||||
if (checkedCount === kids.length && isChecked(node.code)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const setPermissions = (codes) => {
|
||||
roleForm.value.permissions = applyPermissionPairs(codes)
|
||||
}
|
||||
|
||||
const toggleParent = (node, checked) => {
|
||||
const current = new Set(roleForm.value.permissions || [])
|
||||
const kids = childCodesOf(node)
|
||||
if (node.kind === 'group') {
|
||||
for (const code of kids) {
|
||||
if (checked) current.add(code)
|
||||
else current.delete(code)
|
||||
}
|
||||
} else {
|
||||
if (checked) {
|
||||
current.add(node.code)
|
||||
} else {
|
||||
current.delete(node.code)
|
||||
for (const code of kids) current.delete(code)
|
||||
}
|
||||
}
|
||||
setPermissions([...current])
|
||||
}
|
||||
|
||||
const toggleChild = (parent, childCode, checked) => {
|
||||
const current = new Set(roleForm.value.permissions || [])
|
||||
if (checked) {
|
||||
current.add(childCode)
|
||||
if (parent.kind === 'menu') current.add(parent.code)
|
||||
} else {
|
||||
current.delete(childCode)
|
||||
}
|
||||
setPermissions([...current])
|
||||
}
|
||||
|
||||
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)) {
|
||||
return detail
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item
|
||||
return item?.msg || item?.message || JSON.stringify(item)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';') || fallback
|
||||
}
|
||||
if (typeof detail === 'object') {
|
||||
return detail.msg || detail.message || JSON.stringify(detail)
|
||||
}
|
||||
return String(detail)
|
||||
}
|
||||
|
||||
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: [],
|
||||
data: [],
|
||||
tree: [],
|
||||
pairs: []
|
||||
}
|
||||
} 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 fetchRoleRecords()
|
||||
} 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 fetchRoleRecords()
|
||||
} catch (error) {
|
||||
message.error(formatApiError(error, '删除角色失败'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoleRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="roles-page">
|
||||
<div class="page-header glass-card">
|
||||
<div class="page-header-main">
|
||||
<h2 class="page-title">
|
||||
<SafetyCertificateOutlined class="page-title-icon" />
|
||||
角色设定
|
||||
</h2>
|
||||
<p class="subtitle">按菜单父子层级配置权限:父级进页面,子级控按钮与数据范围</p>
|
||||
</div>
|
||||
<a-button type="primary" class="gradient-btn add-btn" @click="openAddRole">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建角色
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div 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="110">
|
||||
<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" class="edit-btn" @click="openEditRole(record)">
|
||||
{{ record.is_admin ? '查看' : '编辑' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="!record.is_system"
|
||||
type="text"
|
||||
danger
|
||||
@click="handleDeleteRole(record)"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<div v-else class="role-card-list">
|
||||
<div v-for="record in roleRecords" :key="record.value" class="role-card glass-card">
|
||||
<div class="role-card-head">
|
||||
<div>
|
||||
<div class="role-card-name">{{ record.label }}</div>
|
||||
<div class="role-card-code">{{ 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="role-card-meta">
|
||||
<div class="role-card-row">
|
||||
<span class="role-card-label">权限</span>
|
||||
<span>{{ (record.permissions || []).length }} 项</span>
|
||||
</div>
|
||||
<div class="role-card-row">
|
||||
<span class="role-card-label">用户</span>
|
||||
<span>{{ record.user_count ?? 0 }}</span>
|
||||
</div>
|
||||
<div v-if="record.description" class="role-card-desc">
|
||||
{{ record.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-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="roleModalVisible"
|
||||
:title="roleModalTitle"
|
||||
:width="isMobile ? 'calc(100vw - 32px)' : 760"
|
||||
:confirm-loading="roleSaving"
|
||||
@ok="handleSaveRole"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-form layout="vertical" class="role-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
|
||||
type="info"
|
||||
show-icon
|
||||
message="父级为菜单入口,子级为页面内按钮/操作;勾选子级会自动勾选父级菜单。取消父级会清除其下全部子权限。"
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
<a-alert
|
||||
v-if="rolePermissionsReadonly"
|
||||
type="info"
|
||||
show-icon
|
||||
message="管理员角色固定拥有全部权限,不可取消勾选"
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
<a-form-item label="权限配置">
|
||||
<div class="perm-tree" :class="{ 'perm-tree-readonly': rolePermissionsReadonly }">
|
||||
<div
|
||||
v-for="node in permissionTree"
|
||||
:key="node.code"
|
||||
class="perm-tree-node"
|
||||
:class="{ 'perm-tree-node-group': node.kind === 'group' }"
|
||||
>
|
||||
<label class="perm-tree-parent">
|
||||
<a-checkbox
|
||||
:checked="isParentChecked(node)"
|
||||
:indeterminate="isParentIndeterminate(node)"
|
||||
:disabled="rolePermissionsReadonly"
|
||||
@change="(e) => toggleParent(node, e.target.checked)"
|
||||
>
|
||||
<span class="perm-tree-parent-label">{{ node.label }}</span>
|
||||
<span v-if="node.kind === 'menu' && !(node.children || []).length" class="perm-tree-tag">仅菜单</span>
|
||||
<span v-else-if="node.kind === 'group'" class="perm-tree-tag">数据范围</span>
|
||||
</a-checkbox>
|
||||
</label>
|
||||
<div v-if="(node.children || []).length" class="perm-tree-children">
|
||||
<label
|
||||
v-for="child in node.children"
|
||||
:key="child.code"
|
||||
class="perm-tree-child"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="isChecked(child.code)"
|
||||
:disabled="rolePermissionsReadonly"
|
||||
@change="(e) => toggleChild(node, child.code, e.target.checked)"
|
||||
>
|
||||
{{ child.label }}
|
||||
</a-checkbox>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="node.kind === 'group'" class="field-hint perm-tree-hint">
|
||||
勾选「查看全部用户数据」后可跨用户查看账号/规则/日志;未勾选则仅本人数据。内置管理员始终拥有全局范围。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>自定义角色创建后,可在「用户管理」中分配给用户</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.roles-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.page-title-icon {
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roles-panel {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roles-panel:hover,
|
||||
.role-help:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.roles-page :deep(.ant-table) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.roles-page :deep(.ant-table-thead > tr > th) {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
color: #d1d5db !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.roles-page :deep(.ant-table-tbody > tr > td) {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.roles-page :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: rgba(170, 59, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.roles-page :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.perm-tree {
|
||||
max-height: min(58vh, 520px);
|
||||
overflow: auto;
|
||||
padding: 4px 2px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.perm-tree-node {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.perm-tree-node-group {
|
||||
border-color: rgba(96, 165, 250, 0.28);
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.perm-tree-parent {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.perm-tree-parent :deep(.ant-checkbox-wrapper) {
|
||||
color: #f3f4f6 !important;
|
||||
font-weight: 600;
|
||||
margin-left: 0 !important;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.perm-tree-parent-label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.perm-tree-tag {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.perm-tree-children {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px 12px;
|
||||
margin: 10px 0 0 28px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.perm-tree-child {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.perm-tree-child :deep(.ant-checkbox-wrapper) {
|
||||
color: #e5e7eb !important;
|
||||
margin-left: 0 !important;
|
||||
line-height: 1.45;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.perm-tree-hint {
|
||||
margin: 10px 0 0 28px;
|
||||
}
|
||||
|
||||
.perm-tree-readonly {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #d8b4fe !important;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
color: #f3e8ff !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-form :deep(.ant-form-item-label > label) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.role-help {
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.role-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: hsla(230, 20%, 12%, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
transform: none;
|
||||
border-color: rgba(192, 132, 252, 0.2);
|
||||
}
|
||||
|
||||
.role-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.role-card-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.role-card-code {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.role-card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.role-card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.role-card-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-card-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.role-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.role-help {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -714,7 +714,11 @@ watch(
|
||||
<a-input v-model:value="ruleForm.keyword" placeholder="当对方发来的信息包含此词语时触发回复..." />
|
||||
</a-form-item>
|
||||
|
||||
<ReplyRuleEditor v-model:replies="ruleForm.replies" />
|
||||
<ReplyRuleEditor
|
||||
v-model:replies="ruleForm.replies"
|
||||
:readonly="!auth.canWriteRules"
|
||||
:can-upload-cards="auth.canWriteLinkCards"
|
||||
/>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ref, onMounted } from 'vue'
|
||||
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
import {
|
||||
|
||||
SaveOutlined,
|
||||
@@ -24,6 +26,8 @@ import api from '../api'
|
||||
|
||||
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const saving = ref(false)
|
||||
@@ -589,7 +593,9 @@ const handleTestEmail = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
fetchDatabaseSettings()
|
||||
if (auth.canManageDatabase) {
|
||||
fetchDatabaseSettings()
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -978,7 +984,7 @@ onMounted(() => {
|
||||
|
||||
|
||||
|
||||
<div class="glass-card panel db-panel">
|
||||
<div v-if="auth.canManageDatabase" class="glass-card panel db-panel">
|
||||
|
||||
<div class="db-panel-header">
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ onMounted(() => {
|
||||
刷新
|
||||
</a-button>
|
||||
|
||||
<a-button v-if="auth.isAdmin" danger @click="clearLogs">
|
||||
<a-button v-if="auth.canClearSystemLogs" danger @click="clearLogs">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
清空
|
||||
</a-button>
|
||||
|
||||
+15
-399
@@ -54,26 +54,11 @@ const formatApiError = (error, fallback = '操作失败') => {
|
||||
}
|
||||
|
||||
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: '',
|
||||
@@ -96,6 +81,11 @@ const roleOptions = ref([
|
||||
{ value: 'viewer', label: '只读', is_admin: false }
|
||||
])
|
||||
|
||||
const assignableRoleOptions = computed(() => {
|
||||
if (auth.isAdmin) return roleOptions.value
|
||||
return roleOptions.value.filter((r) => !r.is_admin)
|
||||
})
|
||||
|
||||
const isAdminRole = computed(() => {
|
||||
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
|
||||
return !!(hit?.is_admin || userForm.value.role === 'admin')
|
||||
@@ -103,16 +93,11 @@ const isAdminRole = computed(() => {
|
||||
const emailRequiredForRole = computed(
|
||||
() => emailBindingRequired.value && !isAdminRole.value
|
||||
)
|
||||
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
|
||||
roleOptions.value.find((r) => r.value === role)?.label || role
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
@@ -165,9 +150,9 @@ const fetchUsers = async () => {
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await api.get('/auth/roles')
|
||||
roles.value = res.data.roles || []
|
||||
if (roles.value.length) {
|
||||
roleOptions.value = roles.value.map((r) => ({
|
||||
const list = res.data.roles || []
|
||||
if (list.length) {
|
||||
roleOptions.value = list.map((r) => ({
|
||||
value: r.value,
|
||||
label: r.label,
|
||||
is_admin: !!r.is_admin
|
||||
@@ -178,120 +163,6 @@ const fetchRoles = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -468,12 +339,6 @@ 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
|
||||
const raw = record.max_accounts
|
||||
@@ -521,36 +386,16 @@ onMounted(() => {
|
||||
<div class="page-header-main">
|
||||
<h2 class="page-title">
|
||||
<TeamOutlined class="page-title-icon" />
|
||||
用户与角色管理
|
||||
用户管理
|
||||
</h2>
|
||||
<p class="subtitle">创建用户、自定义角色,并勾选菜单与操作权限</p>
|
||||
<p class="subtitle">创建与管理后台用户并分配角色;非管理员仅能查看自己创建的用户</p>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="activeTab === 'users'"
|
||||
type="primary"
|
||||
class="gradient-btn add-user-btn"
|
||||
@click="openAdd"
|
||||
>
|
||||
<a-button 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"
|
||||
@@ -567,7 +412,6 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端:表格 -->
|
||||
<div v-if="!isMobile" class="glass-card table-card">
|
||||
<a-table
|
||||
:data-source="paginatedUsers"
|
||||
@@ -618,7 +462,7 @@ onMounted(() => {
|
||||
<a-table-column title="操作" key="action" width="180px">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" style="color: #c084fc;" @click="openEdit(record)">
|
||||
<a-button type="text" class="edit-btn" @click="openEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
@@ -637,7 +481,6 @@ onMounted(() => {
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 手机端:卡片列表 -->
|
||||
<div v-else class="users-mobile-list">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="paginatedUsers.length" class="user-card-list">
|
||||
@@ -720,96 +563,6 @@ onMounted(() => {
|
||||
/>
|
||||
</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"
|
||||
@@ -860,7 +613,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="userForm.role" :options="roleOptions" />
|
||||
<a-select v-model:value="userForm.role" :options="assignableRoleOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
|
||||
<a-input-number
|
||||
@@ -879,84 +632,6 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</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>自定义角色可在「角色设定」中新建,并分配给用户</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1000,48 +675,11 @@ 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 {
|
||||
.table-card: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;
|
||||
@@ -1234,18 +872,6 @@ onMounted(() => {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-help {
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.user-form :deep(.ant-form-item-label > label) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
@@ -1278,15 +904,5 @@ onMounted(() => {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.role-help {
|
||||
margin-top: 16px !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
padding-left: 18px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user