fix(ws): 发送 WebSocket 协议级 Ping 控制帧防止 60s 后连接断开
writePump 只发 ActionCable 文本 JSON ping,从不发 websocket.PingMessage, 导致 readPump 的 PongHandler 永远不触发,ReadDeadline(60s) 到期后连接 被强制关闭。前端每 60 秒弹出"离线""重连"通知。 修复:在 ticker 中先发 PingMessage 控制帧(浏览器自动回 Pong 重置 ReadDeadline),再发 ActionCable 文本 ping(保活 ConnectionMonitor)。 同时包含此前会话中的其他变更: - 移除安全设置(SAML)页面及路由 - 新增超级管理员控制台前端页面 - platform agent_bots 路由使用 Platform* handler - 超级管理员入口改为 SPA 内路由跳转
This commit is contained in:
@@ -9,6 +9,7 @@ import { frontendURL } from '../../helper/URLHelper';
|
||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||
import campaignsRoutes from './campaigns/campaigns.routes';
|
||||
import { routes as captainRoutes } from './captain/captain.routes';
|
||||
import { routes as superAdminRoutes } from './super_admin/superAdmin.routes';
|
||||
import AppContainer from './Dashboard.vue';
|
||||
import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
@@ -47,6 +48,7 @@ export default {
|
||||
...notificationRoutes,
|
||||
...helpcenterRoutes.routes,
|
||||
...campaignsRoutes.routes,
|
||||
...superAdminRoutes,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
|
||||
const SecurityIndex = {
|
||||
render() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/security'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'SECURITY_SETTINGS.TITLE',
|
||||
icon: 'i-lucide-shield',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'security_settings_index',
|
||||
component: SecurityIndex,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -23,7 +23,6 @@ import sla from './sla/sla.routes';
|
||||
import teams from './teams/teams.routes';
|
||||
import customRoles from './customRoles/customRole.routes';
|
||||
import profile from './profile/profile.routes';
|
||||
import security from './security/security.routes';
|
||||
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
|
||||
import captain from './captain/captain.routes';
|
||||
|
||||
@@ -64,7 +63,6 @@ export default {
|
||||
...teams.routes,
|
||||
...customRoles.routes,
|
||||
...profile.routes,
|
||||
...security.routes,
|
||||
...conversationWorkflow.routes,
|
||||
...captain.routes,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { isSuperAdminUser } from '../settings/captain/utils';
|
||||
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const isSuperAdmin = computed(() => isSuperAdminUser(currentUser.value));
|
||||
|
||||
const navItems = computed(() => [
|
||||
{
|
||||
name: 'super_admin_dashboard',
|
||||
label: t('SUPER_ADMIN.SIDEBAR.DASHBOARD'),
|
||||
icon: 'i-lucide-layout-dashboard',
|
||||
},
|
||||
{
|
||||
name: 'super_admin_accounts',
|
||||
label: t('SUPER_ADMIN.SIDEBAR.ACCOUNTS'),
|
||||
icon: 'i-lucide-building-2',
|
||||
},
|
||||
{
|
||||
name: 'super_admin_users',
|
||||
label: t('SUPER_ADMIN.SIDEBAR.USERS'),
|
||||
icon: 'i-lucide-users',
|
||||
},
|
||||
{
|
||||
name: 'super_admin_agent_bots',
|
||||
label: t('SUPER_ADMIN.SIDEBAR.AGENT_BOTS'),
|
||||
icon: 'i-lucide-bot',
|
||||
},
|
||||
{
|
||||
name: 'super_admin_configs',
|
||||
label: t('SUPER_ADMIN.SIDEBAR.CONFIGS'),
|
||||
icon: 'i-lucide-settings-2',
|
||||
},
|
||||
]);
|
||||
|
||||
const navigateTo = name => {
|
||||
router.push({ name, params: { accountId: accountId.value } });
|
||||
};
|
||||
|
||||
const backToDashboard = () => {
|
||||
router.push({
|
||||
name: 'home',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
|
||||
const isActive = name => {
|
||||
return route.name === name;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isSuperAdmin"
|
||||
class="flex w-full h-full overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<!-- Super Admin Sidebar -->
|
||||
<aside
|
||||
class="flex flex-col flex-shrink-0 w-56 h-full bg-n-background border-r border-n-weak"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-2 px-4 h-14 border-b border-n-weak">
|
||||
<div class="grid place-content-center size-6">
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-n-slate-12 truncate">
|
||||
{{ t('SUPER_ADMIN.SIDEBAR.TITLE') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 px-2 py-3 overflow-y-auto no-scrollbar">
|
||||
<ul class="flex flex-col gap-0.5 m-0 list-none">
|
||||
<li v-for="item in navItems" :key="item.name">
|
||||
<button
|
||||
class="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors duration-100"
|
||||
:class="
|
||||
isActive(item.name)
|
||||
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
|
||||
"
|
||||
@click="navigateTo(item.name)"
|
||||
>
|
||||
<span class="text-base flex-shrink-0" :class="item.icon" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Back to Dashboard -->
|
||||
<div class="p-2 border-t border-n-weak">
|
||||
<Button
|
||||
:label="t('SUPER_ADMIN.SIDEBAR.BACK_TO_DASHBOARD')"
|
||||
icon="i-lucide-arrow-left"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="backToDashboard"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Not super admin -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center w-full h-full gap-2 bg-n-surface-1"
|
||||
>
|
||||
<span class="i-lucide-shield-x size-12 text-n-slate-10" />
|
||||
<p class="text-lg font-medium text-n-slate-12">
|
||||
{{ t('SUPER_ADMIN.NO_ACCESS.TITLE') }}
|
||||
</p>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('SUPER_ADMIN.NO_ACCESS.MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import FormDialog from '../components/FormDialog.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import {
|
||||
BaseTable,
|
||||
BaseTableRow,
|
||||
BaseTableCell,
|
||||
} from 'dashboard/components-next/table';
|
||||
|
||||
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accounts = useMapGetter('platform/getAccounts');
|
||||
const uiFlags = useMapGetter('platform/getUIFlags');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const modalType = ref(MODAL_TYPES.CREATE);
|
||||
const selectedAccount = ref({});
|
||||
const formDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const loading = ref({});
|
||||
|
||||
const isLoading = computed(() => uiFlags.value?.isFetchingAccounts);
|
||||
|
||||
const filteredAccounts = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return accounts.value;
|
||||
return picoSearch(accounts.value, query, ['name']);
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
modalType.value === MODAL_TYPES.CREATE
|
||||
? t('SUPER_ADMIN.ACCOUNTS.CREATE.TITLE')
|
||||
: t('SUPER_ADMIN.ACCOUNTS.EDIT.TITLE')
|
||||
);
|
||||
|
||||
const formFields = computed(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('SUPER_ADMIN.ACCOUNTS.FIELDS.NAME'),
|
||||
placeholder: t('SUPER_ADMIN.ACCOUNTS.FIELDS.NAME_PLACEHOLDER'),
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
modalType.value = MODAL_TYPES.CREATE;
|
||||
selectedAccount.value = {};
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openEditModal = account => {
|
||||
modalType.value = MODAL_TYPES.EDIT;
|
||||
selectedAccount.value = { ...account };
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openDeleteDialog = account => {
|
||||
selectedAccount.value = account;
|
||||
deleteDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = async data => {
|
||||
try {
|
||||
if (modalType.value === MODAL_TYPES.CREATE) {
|
||||
await store.dispatch('platform/createAccount', data);
|
||||
useAlert(t('SUPER_ADMIN.ACCOUNTS.CREATE.SUCCESS'));
|
||||
} else {
|
||||
await store.dispatch('platform/updateAccount', {
|
||||
id: selectedAccount.value.id,
|
||||
data,
|
||||
});
|
||||
useAlert(t('SUPER_ADMIN.ACCOUNTS.EDIT.SUCCESS'));
|
||||
}
|
||||
formDialogRef.value?.close();
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.ACCOUNTS.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
loading.value[selectedAccount.value.id] = true;
|
||||
try {
|
||||
await store.dispatch('platform/deleteAccount', selectedAccount.value.id);
|
||||
useAlert(t('SUPER_ADMIN.ACCOUNTS.DELETE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.ACCOUNTS.ERROR'));
|
||||
} finally {
|
||||
loading.value[selectedAccount.value.id] = false;
|
||||
deleteDialogRef.value?.close();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('platform/fetchAccounts');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="t('SUPER_ADMIN.ACCOUNTS.TITLE')"
|
||||
:description="t('SUPER_ADMIN.ACCOUNTS.DESCRIPTION')"
|
||||
v-model:search-query="searchQuery"
|
||||
:search-placeholder="t('SUPER_ADMIN.ACCOUNTS.SEARCH')"
|
||||
:button-label="t('SUPER_ADMIN.ACCOUNTS.CREATE.TITLE')"
|
||||
:count="accounts?.length"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
<div class="flex-1 px-6">
|
||||
<div class="w-full max-w-5xl mx-auto py-6">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredAccounts?.length"
|
||||
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
|
||||
>
|
||||
<BaseTable
|
||||
:headers="[
|
||||
t('SUPER_ADMIN.ACCOUNTS.TABLE.NAME'),
|
||||
t('SUPER_ADMIN.ACCOUNTS.TABLE.STATUS'),
|
||||
t('SUPER_ADMIN.ACCOUNTS.TABLE.ACTIONS'),
|
||||
]"
|
||||
:items="filteredAccounts"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow
|
||||
v-for="account in items"
|
||||
:key="account.id"
|
||||
:item="account"
|
||||
>
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
:name="account.name"
|
||||
:size="36"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm text-n-slate-12 truncate block">
|
||||
{{ account.name }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10 truncate block">
|
||||
ID: {{ account.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-md"
|
||||
:class="
|
||||
account.status === 'active'
|
||||
? 'bg-n-green-3 text-n-green-11'
|
||||
: 'bg-n-amber-3 text-n-amber-11'
|
||||
"
|
||||
>
|
||||
{{ account.status || '—' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell align="end" class="w-24">
|
||||
<div class="flex gap-2 justify-end flex-shrink-0">
|
||||
<Button
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
|
||||
icon="i-woot-edit-pen"
|
||||
slate
|
||||
sm
|
||||
:is-loading="loading[account.id]"
|
||||
@click="openEditModal(account)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[account.id]"
|
||||
@click="openDeleteDialog(account)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-20 gap-2"
|
||||
>
|
||||
<span class="i-lucide-building-2 size-10 text-n-slate-10" />
|
||||
<p class="text-sm text-n-slate-10">
|
||||
{{ t('SUPER_ADMIN.ACCOUNTS.EMPTY') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormDialog
|
||||
ref="formDialogRef"
|
||||
:title="dialogTitle"
|
||||
:fields="formFields"
|
||||
:initial-data="selectedAccount"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
@confirm="handleConfirm"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="deleteDialogRef"
|
||||
type="alert"
|
||||
:title="t('SUPER_ADMIN.ACCOUNTS.DELETE.TITLE')"
|
||||
:description="
|
||||
t('SUPER_ADMIN.ACCOUNTS.DELETE.MESSAGE', {
|
||||
name: selectedAccount?.name,
|
||||
})
|
||||
"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
|
||||
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import FormDialog from '../components/FormDialog.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import {
|
||||
BaseTable,
|
||||
BaseTableRow,
|
||||
BaseTableCell,
|
||||
} from 'dashboard/components-next/table';
|
||||
|
||||
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const agentBots = useMapGetter('platform/getPlatformAgentBots');
|
||||
const uiFlags = useMapGetter('platform/getUIFlags');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const modalType = ref(MODAL_TYPES.CREATE);
|
||||
const selectedBot = ref({});
|
||||
const formDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const loading = ref({});
|
||||
|
||||
const isLoading = computed(() => uiFlags.value?.isFetchingAgentBots);
|
||||
|
||||
const filteredBots = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return agentBots.value;
|
||||
return picoSearch(agentBots.value, query, ['name', 'description']);
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
modalType.value === MODAL_TYPES.CREATE
|
||||
? t('SUPER_ADMIN.AGENT_BOTS.CREATE.TITLE')
|
||||
: t('SUPER_ADMIN.AGENT_BOTS.EDIT.TITLE')
|
||||
);
|
||||
|
||||
const formFields = computed(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.NAME'),
|
||||
placeholder: 'My Bot',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.DESCRIPTION'),
|
||||
type: 'textarea',
|
||||
placeholder: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.DESCRIPTION_PLACEHOLDER'),
|
||||
},
|
||||
{
|
||||
key: 'outgoing_url',
|
||||
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.URL'),
|
||||
placeholder: 'https://example.com/webhook',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'bot_type',
|
||||
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.TYPE'),
|
||||
placeholder: 'webhook',
|
||||
hint: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.TYPE_HINT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
modalType.value = MODAL_TYPES.CREATE;
|
||||
selectedBot.value = {};
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openEditModal = bot => {
|
||||
modalType.value = MODAL_TYPES.EDIT;
|
||||
selectedBot.value = { ...bot };
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openDeleteDialog = bot => {
|
||||
selectedBot.value = bot;
|
||||
deleteDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = async data => {
|
||||
try {
|
||||
if (modalType.value === MODAL_TYPES.CREATE) {
|
||||
await store.dispatch('platform/createAgentBot', data);
|
||||
useAlert(t('SUPER_ADMIN.AGENT_BOTS.CREATE.SUCCESS'));
|
||||
} else {
|
||||
await store.dispatch('platform/updateAgentBot', {
|
||||
id: selectedBot.value.id,
|
||||
data,
|
||||
});
|
||||
useAlert(t('SUPER_ADMIN.AGENT_BOTS.EDIT.SUCCESS'));
|
||||
}
|
||||
formDialogRef.value?.close();
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.AGENT_BOTS.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
loading.value[selectedBot.value.id] = true;
|
||||
try {
|
||||
await store.dispatch('platform/deleteAgentBot', selectedBot.value.id);
|
||||
useAlert(t('SUPER_ADMIN.AGENT_BOTS.DELETE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.AGENT_BOTS.ERROR'));
|
||||
} finally {
|
||||
loading.value[selectedBot.value.id] = false;
|
||||
deleteDialogRef.value?.close();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('platform/fetchAgentBots');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="t('SUPER_ADMIN.AGENT_BOTS.TITLE')"
|
||||
:description="t('SUPER_ADMIN.AGENT_BOTS.DESCRIPTION')"
|
||||
v-model:search-query="searchQuery"
|
||||
:search-placeholder="t('SUPER_ADMIN.AGENT_BOTS.SEARCH')"
|
||||
:button-label="t('SUPER_ADMIN.AGENT_BOTS.CREATE.TITLE')"
|
||||
:count="agentBots?.length"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
<div class="flex-1 px-6">
|
||||
<div class="w-full max-w-5xl mx-auto py-6">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredBots?.length"
|
||||
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
|
||||
>
|
||||
<BaseTable
|
||||
:headers="[
|
||||
t('SUPER_ADMIN.AGENT_BOTS.TABLE.NAME'),
|
||||
t('SUPER_ADMIN.AGENT_BOTS.TABLE.URL'),
|
||||
t('SUPER_ADMIN.AGENT_BOTS.TABLE.ACTIONS'),
|
||||
]"
|
||||
:items="filteredBots"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow v-for="bot in items" :key="bot.id" :item="bot">
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:src="bot.thumbnail"
|
||||
:size="36"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-n-slate-12 truncate">
|
||||
{{ bot.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="bot.system_bot"
|
||||
class="text-xs text-n-slate-12 bg-n-blue-5 rounded-md py-0.5 px-1 flex-shrink-0"
|
||||
>
|
||||
{{ t('SUPER_ADMIN.AGENT_BOTS.GLOBAL_BADGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-n-slate-10 truncate block">
|
||||
{{ bot.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span class="text-sm text-n-slate-11 truncate block">
|
||||
{{ bot.outgoing_url || bot.bot_config?.webhook_url || '—' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell align="end" class="w-24">
|
||||
<div class="flex gap-2 justify-end flex-shrink-0">
|
||||
<Button
|
||||
v-if="!bot.system_bot"
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
|
||||
icon="i-woot-edit-pen"
|
||||
slate
|
||||
sm
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openEditModal(bot)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!bot.system_bot"
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openDeleteDialog(bot)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-20 gap-2"
|
||||
>
|
||||
<span class="i-lucide-bot size-10 text-n-slate-10" />
|
||||
<p class="text-sm text-n-slate-10">
|
||||
{{ t('SUPER_ADMIN.AGENT_BOTS.EMPTY') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormDialog
|
||||
ref="formDialogRef"
|
||||
:title="dialogTitle"
|
||||
:fields="formFields"
|
||||
:initial-data="selectedBot"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
@confirm="handleConfirm"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="deleteDialogRef"
|
||||
type="alert"
|
||||
:title="t('SUPER_ADMIN.AGENT_BOTS.DELETE.TITLE')"
|
||||
:description="
|
||||
t('SUPER_ADMIN.AGENT_BOTS.DELETE.MESSAGE', { name: selectedBot?.name })
|
||||
"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
|
||||
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</template>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Textarea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const formData = ref({});
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
(val) => {
|
||||
formData.value = { ...val };
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
formData.value = { ...props.initialData };
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm', { ...formData.value });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
close();
|
||||
};
|
||||
|
||||
const isFieldType = field => field.type === 'select';
|
||||
const isTextarea = field => field.type === 'textarea';
|
||||
const isCheckbox = field => field.type === 'checkbox';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="title"
|
||||
:is-loading="isLoading"
|
||||
:confirm-button-label="confirmLabel"
|
||||
:cancel-button-label="cancelLabel"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<label
|
||||
v-if="field.label && !isCheckbox(field)"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-n-ruby-11">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Select -->
|
||||
<Select
|
||||
v-if="isFieldType(field)"
|
||||
v-model="formData[field.key]"
|
||||
:options="field.options || []"
|
||||
:placeholder="field.placeholder || ''"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Textarea -->
|
||||
<Textarea
|
||||
v-else-if="isTextarea(field)"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:rows="field.rows || 3"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<div
|
||||
v-else-if="isCheckbox(field)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox v-model="formData[field.key]" />
|
||||
<label class="text-sm text-n-slate-12 cursor-pointer">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Input (default) -->
|
||||
<Input
|
||||
v-else
|
||||
v-model="formData[field.key]"
|
||||
:type="field.inputType || 'text'"
|
||||
:placeholder="field.placeholder || ''"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<p v-if="field.hint" class="text-xs text-n-slate-10">
|
||||
{{ field.hint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { useSlots } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'update:searchQuery']);
|
||||
const slots = useSlots();
|
||||
|
||||
const searchQuery = defineModel('searchQuery', { type: String, default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10 px-6 bg-n-surface-1">
|
||||
<div class="w-full max-w-5xl mx-auto">
|
||||
<div
|
||||
class="flex items-start justify-between w-full py-6 gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5 min-w-0">
|
||||
<h1 class="text-xl font-medium text-n-slate-12 truncate">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="text-sm text-n-slate-11 max-w-2xl">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<Input
|
||||
v-if="searchPlaceholder"
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="group w-56 min-w-0 [&>input]:ltr:!pl-8 [&>input]:rtl:!pr-8 [&>input]:!rounded-[0.625rem]"
|
||||
size="sm"
|
||||
type="search"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-1/2 -translate-y-1/2 text-n-slate-11 group-focus-within:text-n-brand size-3.5 ltr:left-2.5 rtl:right-2.5"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
<span
|
||||
v-if="count !== null"
|
||||
class="text-sm text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="buttonLabel"
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="emit('click')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import FormDialog from '../components/FormDialog.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import {
|
||||
BaseTable,
|
||||
BaseTableRow,
|
||||
BaseTableCell,
|
||||
} from 'dashboard/components-next/table';
|
||||
|
||||
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const configs = useMapGetter('platform/getInstallationConfigs');
|
||||
const uiFlags = useMapGetter('platform/getUIFlags');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const modalType = ref(MODAL_TYPES.CREATE);
|
||||
const selectedConfig = ref({});
|
||||
const formDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const loading = ref({});
|
||||
|
||||
const isLoading = computed(() => uiFlags.value?.isFetchingConfigs);
|
||||
|
||||
const filteredConfigs = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return configs.value;
|
||||
return picoSearch(configs.value, query, ['name', 'value']);
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
modalType.value === MODAL_TYPES.CREATE
|
||||
? t('SUPER_ADMIN.CONFIGS.CREATE.TITLE')
|
||||
: t('SUPER_ADMIN.CONFIGS.EDIT.TITLE')
|
||||
);
|
||||
|
||||
const formFields = computed(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('SUPER_ADMIN.CONFIGS.FIELDS.NAME'),
|
||||
placeholder: 'INSTALLATION_NAME',
|
||||
required: true,
|
||||
hint: t('SUPER_ADMIN.CONFIGS.FIELDS.NAME_HINT'),
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
label: t('SUPER_ADMIN.CONFIGS.FIELDS.VALUE'),
|
||||
type: 'textarea',
|
||||
placeholder: t('SUPER_ADMIN.CONFIGS.FIELDS.VALUE_PLACEHOLDER'),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'locked',
|
||||
label: t('SUPER_ADMIN.CONFIGS.FIELDS.LOCKED'),
|
||||
type: 'checkbox',
|
||||
hint: t('SUPER_ADMIN.CONFIGS.FIELDS.LOCKED_HINT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
modalType.value = MODAL_TYPES.CREATE;
|
||||
selectedConfig.value = {};
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openEditModal = config => {
|
||||
modalType.value = MODAL_TYPES.EDIT;
|
||||
selectedConfig.value = { ...config };
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openDeleteDialog = config => {
|
||||
selectedConfig.value = config;
|
||||
deleteDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = async data => {
|
||||
try {
|
||||
if (modalType.value === MODAL_TYPES.CREATE) {
|
||||
await store.dispatch('platform/createInstallationConfig', data);
|
||||
useAlert(t('SUPER_ADMIN.CONFIGS.CREATE.SUCCESS'));
|
||||
} else {
|
||||
await store.dispatch('platform/updateInstallationConfig', {
|
||||
id: selectedConfig.value.id,
|
||||
data,
|
||||
});
|
||||
useAlert(t('SUPER_ADMIN.CONFIGS.EDIT.SUCCESS'));
|
||||
}
|
||||
formDialogRef.value?.close();
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.CONFIGS.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
loading.value[selectedConfig.value.id] = true;
|
||||
try {
|
||||
await store.dispatch(
|
||||
'platform/deleteInstallationConfig',
|
||||
selectedConfig.value.id
|
||||
);
|
||||
useAlert(t('SUPER_ADMIN.CONFIGS.DELETE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.CONFIGS.ERROR'));
|
||||
} finally {
|
||||
loading.value[selectedConfig.value.id] = false;
|
||||
deleteDialogRef.value?.close();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('platform/fetchInstallationConfigs');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="t('SUPER_ADMIN.CONFIGS.TITLE')"
|
||||
:description="t('SUPER_ADMIN.CONFIGS.DESCRIPTION')"
|
||||
v-model:search-query="searchQuery"
|
||||
:search-placeholder="t('SUPER_ADMIN.CONFIGS.SEARCH')"
|
||||
:button-label="t('SUPER_ADMIN.CONFIGS.CREATE.TITLE')"
|
||||
:count="configs?.length"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
<div class="flex-1 px-6">
|
||||
<div class="w-full max-w-5xl mx-auto py-6">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredConfigs?.length"
|
||||
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
|
||||
>
|
||||
<BaseTable
|
||||
:headers="[
|
||||
t('SUPER_ADMIN.CONFIGS.TABLE.NAME'),
|
||||
t('SUPER_ADMIN.CONFIGS.TABLE.VALUE'),
|
||||
t('SUPER_ADMIN.CONFIGS.TABLE.ACTIONS'),
|
||||
]"
|
||||
:items="filteredConfigs"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow
|
||||
v-for="config in items"
|
||||
:key="config.id"
|
||||
:item="config"
|
||||
>
|
||||
<template #default>
|
||||
<BaseTableCell class="max-w-xs">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-sm font-medium text-n-slate-12 truncate"
|
||||
>
|
||||
{{ config.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="config.locked"
|
||||
class="i-lucide-lock text-n-amber-11 size-3.5 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell class="max-w-xs">
|
||||
<span class="text-sm text-n-slate-11 truncate block">
|
||||
{{ config.value || '—' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell align="end" class="w-24">
|
||||
<div class="flex gap-2 justify-end flex-shrink-0">
|
||||
<Button
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
|
||||
icon="i-woot-edit-pen"
|
||||
slate
|
||||
sm
|
||||
:is-loading="loading[config.id]"
|
||||
@click="openEditModal(config)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!config.locked"
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[config.id]"
|
||||
@click="openDeleteDialog(config)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-20 gap-2"
|
||||
>
|
||||
<span class="i-lucide-settings-2 size-10 text-n-slate-10" />
|
||||
<p class="text-sm text-n-slate-10">
|
||||
{{ t('SUPER_ADMIN.CONFIGS.EMPTY') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormDialog
|
||||
ref="formDialogRef"
|
||||
:title="dialogTitle"
|
||||
:fields="formFields"
|
||||
:initial-data="selectedConfig"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
@confirm="handleConfirm"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="deleteDialogRef"
|
||||
type="alert"
|
||||
:title="t('SUPER_ADMIN.CONFIGS.DELETE.TITLE')"
|
||||
:description="
|
||||
t('SUPER_ADMIN.CONFIGS.DELETE.MESSAGE', { name: selectedConfig?.name })
|
||||
"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
|
||||
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import {
|
||||
BaseTable,
|
||||
BaseTableRow,
|
||||
BaseTableCell,
|
||||
} from 'dashboard/components-next/table';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accounts = useMapGetter('platform/getAccounts');
|
||||
const users = useMapGetter('platform/getUsers');
|
||||
const uiFlags = useMapGetter('platform/getUIFlags');
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: t('SUPER_ADMIN.DASHBOARD.STATS.ACCOUNTS'),
|
||||
value: accounts.value?.length ?? 0,
|
||||
icon: 'i-lucide-building-2',
|
||||
color: 'text-n-blue-11 bg-n-blue-3',
|
||||
},
|
||||
{
|
||||
label: t('SUPER_ADMIN.DASHBOARD.STATS.USERS'),
|
||||
value: users.value?.length ?? 0,
|
||||
icon: 'i-lucide-users',
|
||||
color: 'text-n-green-11 bg-n-green-3',
|
||||
},
|
||||
]);
|
||||
|
||||
const isLoading = computed(
|
||||
() => uiFlags.value?.isFetchingAccounts || uiFlags.value?.isFetchingUsers
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('platform/fetchAccounts');
|
||||
store.dispatch('platform/fetchUsers');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="t('SUPER_ADMIN.DASHBOARD.TITLE')"
|
||||
:description="t('SUPER_ADMIN.DASHBOARD.DESCRIPTION')"
|
||||
/>
|
||||
<div class="flex-1 px-6">
|
||||
<div class="w-full max-w-5xl mx-auto py-6">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="flex items-center gap-4 p-5 rounded-xl bg-n-background border border-n-weak"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center size-12 rounded-lg flex-shrink-0"
|
||||
:class="stat.color"
|
||||
>
|
||||
<span class="text-xl" :class="stat.icon" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ stat.value }}
|
||||
</div>
|
||||
<div class="text-sm text-n-slate-11 truncate">
|
||||
{{ stat.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Accounts -->
|
||||
<div
|
||||
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
|
||||
>
|
||||
<div class="px-5 py-4 border-b border-n-weak">
|
||||
<h2 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('SUPER_ADMIN.DASHBOARD.RECENT_ACCOUNTS') }}
|
||||
</h2>
|
||||
</div>
|
||||
<BaseTable
|
||||
v-if="accounts?.length"
|
||||
:headers="[
|
||||
t('SUPER_ADMIN.ACCOUNTS.TABLE.NAME'),
|
||||
t('SUPER_ADMIN.ACCOUNTS.TABLE.STATUS'),
|
||||
]"
|
||||
:items="accounts.slice(0, 5)"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow
|
||||
v-for="account in items"
|
||||
:key="account.id"
|
||||
:item="account"
|
||||
>
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
:name="account.name"
|
||||
:size="32"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 truncate">
|
||||
{{ account.name }}
|
||||
</span>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-md"
|
||||
:class="
|
||||
account.status === 'active'
|
||||
? 'bg-n-green-3 text-n-green-11'
|
||||
: 'bg-n-amber-3 text-n-amber-11'
|
||||
"
|
||||
>
|
||||
{{ account.status || '—' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<div v-else class="px-5 py-10 text-center text-sm text-n-slate-10">
|
||||
{{ t('SUPER_ADMIN.ACCOUNTS.EMPTY') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
import SuperAdminLayout from './Layout.vue';
|
||||
import DashboardIndex from './dashboard/Index.vue';
|
||||
import AccountsIndex from './accounts/Index.vue';
|
||||
import UsersIndex from './users/Index.vue';
|
||||
import AgentBotsIndex from './agentBots/Index.vue';
|
||||
import ConfigsIndex from './configs/Index.vue';
|
||||
|
||||
const meta = {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
isSuperAdmin: true,
|
||||
};
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/super_admin'),
|
||||
component: SuperAdminLayout,
|
||||
meta,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'super_admin_dashboard',
|
||||
component: DashboardIndex,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
name: 'super_admin_accounts',
|
||||
component: AccountsIndex,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'super_admin_users',
|
||||
component: UsersIndex,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: 'agent_bots',
|
||||
name: 'super_admin_agent_bots',
|
||||
component: AgentBotsIndex,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: 'configs',
|
||||
name: 'super_admin_configs',
|
||||
component: ConfigsIndex,
|
||||
meta,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import FormDialog from '../components/FormDialog.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import {
|
||||
BaseTable,
|
||||
BaseTableRow,
|
||||
BaseTableCell,
|
||||
} from 'dashboard/components-next/table';
|
||||
|
||||
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const users = useMapGetter('platform/getUsers');
|
||||
const uiFlags = useMapGetter('platform/getUIFlags');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const modalType = ref(MODAL_TYPES.CREATE);
|
||||
const selectedUser = ref({});
|
||||
const formDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const loading = ref({});
|
||||
|
||||
const isLoading = computed(() => uiFlags.value?.isFetchingUsers);
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return users.value;
|
||||
return picoSearch(users.value, query, ['name', 'email']);
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
modalType.value === MODAL_TYPES.CREATE
|
||||
? t('SUPER_ADMIN.USERS.CREATE.TITLE')
|
||||
: t('SUPER_ADMIN.USERS.EDIT.TITLE')
|
||||
);
|
||||
|
||||
const formFields = computed(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('SUPER_ADMIN.USERS.FIELDS.NAME'),
|
||||
placeholder: t('SUPER_ADMIN.USERS.FIELDS.NAME_PLACEHOLDER'),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('SUPER_ADMIN.USERS.FIELDS.EMAIL'),
|
||||
placeholder: 'user@example.com',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: t('SUPER_ADMIN.USERS.FIELDS.PASSWORD'),
|
||||
placeholder: '••••••••',
|
||||
hint: t('SUPER_ADMIN.USERS.FIELDS.PASSWORD_HINT'),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: t('SUPER_ADMIN.USERS.FIELDS.TYPE'),
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'user', label: t('SUPER_ADMIN.USERS.FIELDS.TYPE_USER') },
|
||||
{ value: 'SuperAdmin', label: t('SUPER_ADMIN.USERS.FIELDS.TYPE_SUPER_ADMIN') },
|
||||
],
|
||||
hint: t('SUPER_ADMIN.USERS.FIELDS.TYPE_HINT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
modalType.value = MODAL_TYPES.CREATE;
|
||||
selectedUser.value = { type: 'user' };
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openEditModal = user => {
|
||||
modalType.value = MODAL_TYPES.EDIT;
|
||||
selectedUser.value = { ...user, password: '' };
|
||||
formDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openDeleteDialog = user => {
|
||||
selectedUser.value = user;
|
||||
deleteDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = async data => {
|
||||
try {
|
||||
if (modalType.value === MODAL_TYPES.CREATE) {
|
||||
await store.dispatch('platform/createUser', data);
|
||||
useAlert(t('SUPER_ADMIN.USERS.CREATE.SUCCESS'));
|
||||
} else {
|
||||
const updateData = { ...data };
|
||||
if (!updateData.password) delete updateData.password;
|
||||
await store.dispatch('platform/updateUser', {
|
||||
id: selectedUser.value.id,
|
||||
data: updateData,
|
||||
});
|
||||
useAlert(t('SUPER_ADMIN.USERS.EDIT.SUCCESS'));
|
||||
}
|
||||
formDialogRef.value?.close();
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.USERS.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
loading.value[selectedUser.value.id] = true;
|
||||
try {
|
||||
await store.dispatch('platform/deleteUser', selectedUser.value.id);
|
||||
useAlert(t('SUPER_ADMIN.USERS.DELETE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('SUPER_ADMIN.USERS.ERROR'));
|
||||
} finally {
|
||||
loading.value[selectedUser.value.id] = false;
|
||||
deleteDialogRef.value?.close();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('platform/fetchUsers');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="t('SUPER_ADMIN.USERS.TITLE')"
|
||||
:description="t('SUPER_ADMIN.USERS.DESCRIPTION')"
|
||||
v-model:search-query="searchQuery"
|
||||
:search-placeholder="t('SUPER_ADMIN.USERS.SEARCH')"
|
||||
:button-label="t('SUPER_ADMIN.USERS.CREATE.TITLE')"
|
||||
:count="users?.length"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
<div class="flex-1 px-6">
|
||||
<div class="w-full max-w-5xl mx-auto py-6">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredUsers?.length"
|
||||
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
|
||||
>
|
||||
<BaseTable
|
||||
:headers="[
|
||||
t('SUPER_ADMIN.USERS.TABLE.NAME'),
|
||||
t('SUPER_ADMIN.USERS.TABLE.TYPE'),
|
||||
t('SUPER_ADMIN.USERS.TABLE.ACTIONS'),
|
||||
]"
|
||||
:items="filteredUsers"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow v-for="user in items" :key="user.id" :item="user">
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
:name="user.name || user.available_name"
|
||||
:src="user.avatar_url"
|
||||
:size="36"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm text-n-slate-12 truncate block">
|
||||
{{ user.name || user.available_name }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10 truncate block">
|
||||
{{ user.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-md"
|
||||
:class="
|
||||
user.type === 'SuperAdmin'
|
||||
? 'bg-n-violet-3 text-n-violet-11'
|
||||
: 'bg-n-slate-3 text-n-slate-11'
|
||||
"
|
||||
>
|
||||
{{ user.type || 'User' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell align="end" class="w-24">
|
||||
<div class="flex gap-2 justify-end flex-shrink-0">
|
||||
<Button
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
|
||||
icon="i-woot-edit-pen"
|
||||
slate
|
||||
sm
|
||||
:is-loading="loading[user.id]"
|
||||
@click="openEditModal(user)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[user.id]"
|
||||
@click="openDeleteDialog(user)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-20 gap-2"
|
||||
>
|
||||
<span class="i-lucide-users size-10 text-n-slate-10" />
|
||||
<p class="text-sm text-n-slate-10">
|
||||
{{ t('SUPER_ADMIN.USERS.EMPTY') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormDialog
|
||||
ref="formDialogRef"
|
||||
:title="dialogTitle"
|
||||
:fields="formFields"
|
||||
:initial-data="selectedUser"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
@confirm="handleConfirm"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="deleteDialogRef"
|
||||
type="alert"
|
||||
:title="t('SUPER_ADMIN.USERS.DELETE.TITLE')"
|
||||
:description="
|
||||
t('SUPER_ADMIN.USERS.DELETE.MESSAGE', { name: selectedUser?.name })
|
||||
"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
|
||||
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user