update cors, api test telegram
This commit is contained in:
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\Admin\Setting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Facade\SignRSA;
|
||||
use App\Http\Services\Telegram;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Cache\CacheKey;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -102,6 +103,26 @@ class SettingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /administrator/settings/test-telegram
|
||||
* Sends a test message using the currently saved Telegram config.
|
||||
*/
|
||||
public function testTelegram(): JsonResponse
|
||||
{
|
||||
$response = (new Telegram())->SendMessage('🔔 Đây là tin nhắn test từ Portal Admin — cấu hình Telegram đang hoạt động!');
|
||||
|
||||
if (!$response || !$response->successful()) {
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => $response
|
||||
? 'Telegram trả về lỗi: ' . $response->body()
|
||||
: 'Chưa cấu hình đủ Bot Token / Bot ID.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json(['error' => false, 'message' => 'Đã gửi tin nhắn test thành công.']);
|
||||
}
|
||||
|
||||
public function uploadMedia(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
|
||||
+3
-4
@@ -24,10 +24,9 @@ return [
|
||||
'http://localhost:5174',
|
||||
'http://localhost:4173',
|
||||
'https://portal.tgs.com.vn',
|
||||
'https://demo.client.tgs.com.vn',
|
||||
'https://demo.admin.tgs.com.vn',
|
||||
'http://demo.client.tgs.com.vn',
|
||||
'http://demo.admin.tgs.com.vn',
|
||||
'https://admin.portal.tgs.com.vn',
|
||||
'http://portal.tgs.com.vn',
|
||||
'http://admin.portal.tgs.com.vn',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
@@ -454,6 +454,7 @@ Route::prefix('v1')
|
||||
->group(function () {
|
||||
Route::get('/', 'index');
|
||||
Route::put('/', 'update');
|
||||
Route::post('test-telegram', 'testTelegram');
|
||||
});
|
||||
|
||||
Route::prefix('media')
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,514 +0,0 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/vi'
|
||||
import {
|
||||
BellOutlined,
|
||||
CheckOutlined,
|
||||
ClockCircleOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
LogoutOutlined,
|
||||
MoonOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
ShoppingCartOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
List,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem,
|
||||
} from '@/features/notifications/api'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('vi')
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Breadcrumb labels (mirrors src/components/layout/app-breadcrumb.tsx)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const segmentLabels: Record<string, string> = {
|
||||
'': 'Tổng quan',
|
||||
dashboard: 'Tổng quan',
|
||||
create: 'Thêm mới',
|
||||
groups: 'Nhóm',
|
||||
tasks: 'Công việc',
|
||||
customers: 'Khách hàng',
|
||||
'product-service': 'Sản phẩm / Dịch vụ',
|
||||
products: 'Sản phẩm',
|
||||
'product-groups': 'Nhóm sản phẩm',
|
||||
'vcenter-manager': 'vCenter Manager',
|
||||
'infra-management': 'Hạ tầng',
|
||||
'order-invoice': 'Đơn hàng / Hoá đơn',
|
||||
apps: 'Ứng dụng',
|
||||
chats: 'Tin nhắn',
|
||||
users: 'Người dùng',
|
||||
settings: 'Cài đặt',
|
||||
account: 'Tài khoản',
|
||||
appearance: 'Giao diện',
|
||||
notifications: 'Thông báo',
|
||||
display: 'Hiển thị',
|
||||
'help-center': 'Trung tâm trợ giúp',
|
||||
tickets: 'Ticket hỗ trợ',
|
||||
roles: 'Vai trò',
|
||||
'activity-logs': 'Nhật ký hoạt động',
|
||||
'server-manager': 'Server Manager',
|
||||
'domain-manager': 'Domain Manager',
|
||||
'domain-fluctuations': 'Khai báo biến động tên miền',
|
||||
'ip-pool': 'IP Pool',
|
||||
'os-groups': 'OS Groups',
|
||||
addons: 'Addons',
|
||||
transactions: 'Giao dịch',
|
||||
posts: 'Bài viết',
|
||||
'post-categories': 'Danh mục bài viết',
|
||||
resolve: 'Phân giải',
|
||||
}
|
||||
|
||||
function labelFor(segment: string) {
|
||||
if (/^\d+$/.test(segment)) return 'Chi tiết'
|
||||
return segmentLabels[segment] ?? segment
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Notifications
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const notifIconMap: Record<
|
||||
NotificationItem['type'],
|
||||
{ icon: typeof BellOutlined; color: string }
|
||||
> = {
|
||||
ticket_reply: { icon: TeamOutlined, color: '#722ed1' },
|
||||
deposit_success: { icon: ShoppingCartOutlined, color: '#f16723' },
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// AppHeader
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export function AppHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const [notifOpen, setNotifOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [compact, setCompact] = useState(false)
|
||||
const [language, setLanguage] = useState<'vi' | 'en'>('vi')
|
||||
|
||||
const qc = useQueryClient()
|
||||
const { data: notifData } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: fetchNotifications,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const notifs = notifData?.data.items ?? []
|
||||
const unreadCount = notifData?.data.unread_count ?? 0
|
||||
|
||||
const markOneRead = useMutation({
|
||||
mutationFn: markNotificationRead,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
})
|
||||
const markAllRead = useMutation({
|
||||
mutationFn: markAllNotificationsRead,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
})
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const crumbs =
|
||||
segments.length === 0
|
||||
? [{ title: <Text strong>Tổng quan</Text> }]
|
||||
: segments.map((seg, idx) => {
|
||||
const isLast = idx === segments.length - 1
|
||||
const label = labelFor(seg)
|
||||
return {
|
||||
title: isLast ? <Text strong>{label}</Text> : <span>{label}</span>,
|
||||
}
|
||||
})
|
||||
|
||||
const fullName = auth.user
|
||||
? `${auth.user.firstname ?? ''} ${auth.user.lastname ?? ''}`.trim() ||
|
||||
auth.user.username
|
||||
: 'Khách'
|
||||
const userRole = auth.user?.roles?.[0] ?? 'Người dùng'
|
||||
const initials =
|
||||
fullName
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(-2)
|
||||
.map((s) => s[0]?.toUpperCase())
|
||||
.join('') || 'U'
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: 'Hồ sơ',
|
||||
onClick: () => navigate({ to: '/settings' }),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Đăng xuất',
|
||||
danger: true,
|
||||
onClick: () => auth.reset(),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#f16723',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'"Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<header className='sticky top-0 z-50 flex h-16 items-center gap-2 border-b border-[#f0f0f0] bg-white px-3 md:gap-4 md:px-6'>
|
||||
<SidebarTrigger />
|
||||
<Divider
|
||||
type='vertical'
|
||||
className='!hidden md:!block'
|
||||
style={{ height: 24 }}
|
||||
/>
|
||||
<div className='hidden min-w-0 flex-1 md:block'>
|
||||
<Breadcrumb items={crumbs} />
|
||||
</div>
|
||||
<div className='flex-1 md:hidden' />
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||
placeholder='Tìm kiếm...'
|
||||
className='!hidden lg:!inline-flex'
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
{/* Mobile/Tablet: search as icon button only */}
|
||||
<Tooltip title='Tìm kiếm' mouseEnterDelay={0.4}>
|
||||
<Button
|
||||
type='text'
|
||||
icon={<SearchOutlined />}
|
||||
className='lg:!hidden'
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Notifications */}
|
||||
<Popover
|
||||
trigger='click'
|
||||
placement='bottomRight'
|
||||
open={notifOpen}
|
||||
onOpenChange={setNotifOpen}
|
||||
overlayStyle={{ width: 'min(380px, calc(100vw - 24px))' }}
|
||||
content={
|
||||
<div>
|
||||
<Flex
|
||||
justify='space-between'
|
||||
align='center'
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Space>
|
||||
<Text strong>Thông báo</Text>
|
||||
{unreadCount > 0 && (
|
||||
<Tag color='blue' style={{ margin: 0 }}>
|
||||
{unreadCount} mới
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type='text'
|
||||
icon={<CheckOutlined />}
|
||||
disabled={unreadCount === 0}
|
||||
loading={markAllRead.isPending}
|
||||
onClick={() => markAllRead.mutate()}
|
||||
>
|
||||
Đọc hết
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<Divider style={{ margin: '4px 0 8px' }} />
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{notifs.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '32px 0',
|
||||
textAlign: 'center',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
>
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>
|
||||
Chưa có thông báo
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
dataSource={notifs}
|
||||
split={false}
|
||||
renderItem={(n: NotificationItem) => {
|
||||
const meta = notifIconMap[n.type] ?? { icon: BellOutlined, color: '#8c8c8c' }
|
||||
const Icon = meta.icon
|
||||
const unread = !n.is_read
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => { if (unread) markOneRead.mutate(n.id) }}
|
||||
style={{
|
||||
padding: '10px 8px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
background: unread ? '#f0f7ff' : 'transparent',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<Flex gap={10} style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: `${meta.color}1A`,
|
||||
color: meta.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Flex justify='space-between' align='center'>
|
||||
<Text
|
||||
strong={unread}
|
||||
style={{ fontSize: 13 }}
|
||||
ellipsis
|
||||
>
|
||||
{n.title}
|
||||
</Text>
|
||||
{unread && (
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 4,
|
||||
background: '#f16723',
|
||||
flexShrink: 0,
|
||||
marginLeft: 6,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{n.body && (
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: 'block',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{n.body}
|
||||
</Text>
|
||||
)}
|
||||
<Text type='secondary' style={{ fontSize: 11 }}>
|
||||
<ClockCircleOutlined /> {dayjs(n.created_at).fromNow()}
|
||||
</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Button block type='link' onClick={() => setNotifOpen(false)}>
|
||||
Xem tất cả thông báo
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tooltip title='Thông báo' mouseEnterDelay={0.4}>
|
||||
<Badge count={unreadCount}>
|
||||
<Button type='text' icon={<BellOutlined />} />
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
{/* Settings */}
|
||||
<Popover
|
||||
trigger='click'
|
||||
placement='bottomRight'
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
overlayStyle={{ width: 'min(280px, calc(100vw - 24px))' }}
|
||||
content={
|
||||
<div>
|
||||
<Text strong>Cài đặt nhanh</Text>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Flex
|
||||
justify='space-between'
|
||||
align='center'
|
||||
style={{ padding: '6px 0' }}
|
||||
>
|
||||
<Space>
|
||||
<MoonOutlined />
|
||||
<Text>Chế độ tối</Text>
|
||||
</Space>
|
||||
<Switch checked={darkMode} onChange={setDarkMode} />
|
||||
</Flex>
|
||||
<Flex
|
||||
justify='space-between'
|
||||
align='center'
|
||||
style={{ padding: '6px 0' }}
|
||||
>
|
||||
<Space>
|
||||
<InboxOutlined />
|
||||
<Text>Giao diện gọn</Text>
|
||||
</Space>
|
||||
<Switch checked={compact} onChange={setCompact} />
|
||||
</Flex>
|
||||
<Flex
|
||||
justify='space-between'
|
||||
align='center'
|
||||
style={{ padding: '6px 0' }}
|
||||
>
|
||||
<Space>
|
||||
<GlobalOutlined />
|
||||
<Text>Ngôn ngữ</Text>
|
||||
</Space>
|
||||
<Select
|
||||
value={language}
|
||||
onChange={setLanguage}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'vi', label: 'Tiếng Việt' },
|
||||
{ value: 'en', label: 'English' },
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<List
|
||||
split={false}
|
||||
dataSource={[
|
||||
{
|
||||
icon: <UserOutlined />,
|
||||
label: 'Hồ sơ cá nhân',
|
||||
key: 'profile',
|
||||
},
|
||||
{
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Cài đặt hệ thống',
|
||||
key: 'system',
|
||||
},
|
||||
{
|
||||
icon: <QuestionCircleOutlined />,
|
||||
label: 'Trợ giúp & hỗ trợ',
|
||||
key: 'help',
|
||||
},
|
||||
{
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Đăng xuất',
|
||||
key: 'logout',
|
||||
danger: true,
|
||||
},
|
||||
]}
|
||||
renderItem={(it) => (
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
setSettingsOpen(false)
|
||||
if (it.key === 'logout') auth.reset()
|
||||
}}
|
||||
style={{
|
||||
padding: '8px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
color: it.danger ? '#ff4d4f' : undefined,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{it.icon}
|
||||
<Text
|
||||
style={{ color: it.danger ? '#ff4d4f' : undefined }}
|
||||
>
|
||||
{it.label}
|
||||
</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tooltip title='Cài đặt' mouseEnterDelay={0.4}>
|
||||
<Button type='text' icon={<SettingOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
<Divider
|
||||
type='vertical'
|
||||
className='!hidden sm:!block'
|
||||
style={{ height: 24 }}
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems }}
|
||||
placement='bottomRight'
|
||||
arrow
|
||||
>
|
||||
<Space size={8} className='cursor-pointer'>
|
||||
<Avatar style={{ background: '#f16723' }}>{initials}</Avatar>
|
||||
<div className='hidden leading-tight sm:block'>
|
||||
<div
|
||||
style={{ fontWeight: 600, fontSize: 13 }}
|
||||
className='max-w-[160px] truncate'
|
||||
>
|
||||
{fullName}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ fontSize: 12 }}
|
||||
className='block max-w-[160px] truncate'
|
||||
>
|
||||
{userRole}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</header>
|
||||
{/* Keep Fragment children possible for future right-aligned actions */}
|
||||
<Fragment />
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user