update cors, api test telegram

This commit is contained in:
baodev0
2026-07-24 10:29:41 +07:00
parent 3a9e9f30b5
commit 18106877fc
5 changed files with 25 additions and 518 deletions
@@ -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
View File
@@ -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' => [],
+1
View File
@@ -454,6 +454,7 @@ Route::prefix('v1')
->group(function () {
Route::get('/', 'index');
Route::put('/', 'update');
Route::post('test-telegram', 'testTelegram');
});
Route::prefix('media')
BIN
View File
Binary file not shown.
-514
View File
@@ -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>
)
}