feat: 霓虹都市→白日梦境 + 渐变网格→云石纹理 — 两个白色浅色主题登录页重写
This commit is contained in:
1 parent
b5739ba955
commit
5a61babd33
241 files changed
+58974
-7370
No files matched your search
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from '@/hooks/useAuth';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import Layout from '@/components/Layout';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import InstancePage from '@/pages/InstancePage';
|
||||
import TicketsPage from '@/pages/TicketsPage';
|
||||
import QueriesPage from '@/pages/QueriesPage';
|
||||
import RolesPage from '@/pages/RolesPage';
|
||||
import UsersPage from '@/pages/UsersPage';
|
||||
import AuditRulesPage from '@/pages/AuditRulesPage';
|
||||
import WorkflowPage from '@/pages/WorkflowPage';
|
||||
import AutoTaskPage from '@/pages/AutoTaskPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
import AuditLogsPage from '@/pages/AuditLogsPage';
|
||||
import DataDictionaryPage from '@/pages/DataDictionaryPage';
|
||||
import InstanceDetailPage from '@/pages/InstanceDetailPage';
|
||||
import RedisDetailPage from '@/pages/RedisDetailPage';
|
||||
import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
|
||||
import TicketCreatePage from '@/pages/TicketCreatePage';
|
||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
||||
import { ToastContainer } from '@/components/ToastContainer';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
function RouteGuard() {
|
||||
const { isLoggedIn, hasPermission } = useAuth();
|
||||
const path = location.pathname.split('/')[1] || 'dashboard';
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// 检查当前路径是否有权限访问
|
||||
if (path !== 'dashboard' && !hasPermission(path)) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// 登录页守卫:已登录用户自动跳转到仪表盘
|
||||
function LoginGuard() {
|
||||
const { isLoggedIn } = useAuth();
|
||||
if (isLoggedIn) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard />} />
|
||||
<Route element={<RouteGuard />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
||||
<Route path="instances/:id" element={<InstanceDetailPage />} />
|
||||
<Route path="redis/:id" element={<RedisDetailPage />} />
|
||||
<Route path="mongo/:id" element={<MongoDBDetailPage />} />
|
||||
<Route path="data-dictionary" element={<DataDictionaryPage />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="tickets/create" element={<TicketCreatePage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="queries" element={<QueriesPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||
<Route path="audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="workflow" element={<WorkflowPage />} />
|
||||
<Route path="auto-task" element={<AutoTaskPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
<ToastContainer />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,34 @@
|
||||
import request from './request';
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
email: string | null;
|
||||
data_source_permissions?: string;
|
||||
custom_permissions?: string;
|
||||
group_ids?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
email: string | null;
|
||||
data_source_permissions?: string;
|
||||
custom_permissions?: string;
|
||||
group_ids?: number[];
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
request.post<any, LoginResponse>('/auth/login', { username, password }),
|
||||
getMe: () => request.get<any, MeResponse>('/auth/me'),
|
||||
logout: () => request.post('/auth/logout'),
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import request from './request';
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: () => request.get<any, any>('/dashboard/stats'),
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import request from './request';
|
||||
|
||||
export interface DBTypeOverview {
|
||||
type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
category: string;
|
||||
version: string;
|
||||
supported_operations: number;
|
||||
security_rules_count: number;
|
||||
}
|
||||
|
||||
export interface DBTypeQueryType {
|
||||
type: string;
|
||||
description: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface DBTypeDetail {
|
||||
type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
version: string;
|
||||
category: string;
|
||||
supported_query_types: DBTypeQueryType[];
|
||||
security_rules: string[];
|
||||
tips: string[];
|
||||
common_commands: Record<string, string>;
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export interface DBTypeCommand {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const dbTypesApi = {
|
||||
list: () =>
|
||||
request.get<any, { items: DBTypeOverview[]; total: number }>('/db-types'),
|
||||
|
||||
getDetail: (dbType: string) =>
|
||||
request.get<any, DBTypeDetail>('/db-types/' + dbType),
|
||||
|
||||
getCommands: (dbType: string) =>
|
||||
request.get<any, { items: DBTypeCommand[]; total: number }>('/db-types/' + dbType + '/commands'),
|
||||
|
||||
getTips: (dbType: string) =>
|
||||
request.get<any, { items: string[]; total: number }>('/db-types/' + dbType + '/tips'),
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import request from './request';
|
||||
|
||||
export var dictionaryApi = {
|
||||
listDatabases: function (instanceId: number) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/databases');
|
||||
},
|
||||
listTables: function (instanceId: number, database: string) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/tables', {
|
||||
params: { database: database },
|
||||
});
|
||||
},
|
||||
getTableDetail: function (instanceId: number, tableName: string, database: string) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, {
|
||||
params: { database: database },
|
||||
});
|
||||
},
|
||||
exportDictionary: function (instanceId: number, database: string) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/export', {
|
||||
params: { database: database },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import request from './request';
|
||||
|
||||
export const instanceApi = {
|
||||
list: (params?: any) => request.get<any, any[]>('/instances', { params }),
|
||||
accessible: (accessType: 'readonly' | 'write') => request.get<any, any[]>('/instances/accessible', { params: { access_type: accessType } }),
|
||||
get: (id: number) => request.get<any, any>(`/instances/${id}`),
|
||||
create: (data: any) => request.post<any, any>('/instances', data),
|
||||
update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data),
|
||||
delete: (id: number) => request.delete(`/instances/${id}`),
|
||||
testConnection: (id: number) => request.post<any, any>(`/instances/${id}/test-connection`),
|
||||
testNewConnection: (data: any) => request.post<any, any>('/instances/test-connection', data),
|
||||
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
|
||||
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import request from './request';
|
||||
|
||||
/** 实例详情 API — 数据库/表/字段/索引/Redis Key */
|
||||
export const instanceDetailApi = {
|
||||
/** 获取 MySQL 数据库列表 */
|
||||
listDatabases: (instanceId: number) =>
|
||||
request.get<any, string[]>(`/instances/${instanceId}/detail/databases`),
|
||||
|
||||
/** 获取表列表(含引擎、行数等) */
|
||||
listTables: (instanceId: number, database: string) =>
|
||||
request.get<any, TableMeta[]>(`/instances/${instanceId}/detail/tables`, {
|
||||
params: { database },
|
||||
}),
|
||||
|
||||
/** 获取整库表+列结构(用于 SQL 自动补全) */
|
||||
getDbSchema: (instanceId: number, database: string) =>
|
||||
request.get<any, Record<string, string[]>>(`/instances/${instanceId}/detail/db-schema`, {
|
||||
params: { database },
|
||||
}),
|
||||
|
||||
/** 获取表详情(DDL + 字段 + 索引) */
|
||||
getTableDetail: (instanceId: number, database: string, table: string) =>
|
||||
request.get<any, TableDetail>(`/instances/${instanceId}/detail/table-detail`, {
|
||||
params: { database, table },
|
||||
}),
|
||||
|
||||
/** SHOW PROCESSLIST */
|
||||
getProcesslist: (instanceId: number) =>
|
||||
request.get<any, any[]>(`/instances/${instanceId}/detail/processlist`),
|
||||
|
||||
/** SHOW GLOBAL STATUS */
|
||||
getStatus: (instanceId: number) =>
|
||||
request.get<any, { key_metrics: Record<string, string>; all_status: any[] }>(
|
||||
`/instances/${instanceId}/detail/status`
|
||||
),
|
||||
|
||||
/** 终止进程 */
|
||||
killProcess: (instanceId: number, pid: number) =>
|
||||
request.post<any, any>(`/instances/${instanceId}/detail/processlist/${pid}/kill`),
|
||||
|
||||
/** 获取数据库用户列表 */
|
||||
listUsers: (instanceId: number) =>
|
||||
request.get<any, any[]>(`/instances/${instanceId}/detail/users`),
|
||||
};
|
||||
|
||||
/** 表元信息 */
|
||||
export interface TableMeta {
|
||||
name: string;
|
||||
engine: string;
|
||||
rows: number;
|
||||
data_length: number;
|
||||
index_length: number;
|
||||
comment: string;
|
||||
collation: string;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
/** 表详情 */
|
||||
export interface TableDetail {
|
||||
ddl: string;
|
||||
columns: ColumnInfo[];
|
||||
indexes: IndexInfo[];
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
Field: string;
|
||||
Type: string;
|
||||
Null: string;
|
||||
Key: string;
|
||||
Default: string | null;
|
||||
Extra: string;
|
||||
}
|
||||
|
||||
export interface IndexInfo {
|
||||
Table: string;
|
||||
Non_unique: number;
|
||||
Key_name: string;
|
||||
Seq_in_index: number;
|
||||
Column_name: string;
|
||||
Collation: string;
|
||||
Cardinality: number;
|
||||
Sub_part: string | null;
|
||||
Packed: string | null;
|
||||
Null: string;
|
||||
Index_type: string;
|
||||
Comment: string;
|
||||
Index_comment: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import request from './request';
|
||||
|
||||
export interface InstanceGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
db_type: string;
|
||||
permissions?: string[];
|
||||
allowed_databases?: string[];
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
readonly_instance_ids?: number[];
|
||||
write_instance_ids?: number[];
|
||||
readonly_instance_count: number;
|
||||
write_instance_count: number;
|
||||
user_count: number;
|
||||
readonly_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
|
||||
write_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
|
||||
users?: { id: number; username: string; display_name: string; role: string }[];
|
||||
}
|
||||
|
||||
export const instanceGroupApi = {
|
||||
list: (params?: any) => request.get<any, InstanceGroup[]>('/instance-groups', { params }),
|
||||
get: (id: number) => request.get<any, InstanceGroup>(`/instance-groups/${id}`),
|
||||
create: (data: any) => request.post<any, InstanceGroup>('/instance-groups', data),
|
||||
update: (id: number, data: any) => request.put<any, InstanceGroup>(`/instance-groups/${id}`, data),
|
||||
delete: (id: number) => request.delete(`/instance-groups/${id}`),
|
||||
assignInstances: (groupId: number, instanceIds: number[], accessType: string = 'readonly') =>
|
||||
request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds, access_type: accessType }),
|
||||
removeInstance: (groupId: number, instanceId: number, accessType?: string) =>
|
||||
request.delete(`/instance-groups/${groupId}/instances/${instanceId}`, { params: accessType ? { access_type: accessType } : undefined }),
|
||||
listUsers: (groupId: number) => request.get<any, any[]>(`/instance-groups/${groupId}/users`),
|
||||
assignUsers: (groupId: number, userIds: number[], permissions?: string[]) =>
|
||||
request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }),
|
||||
updateUserGroups: (userId: number, groupIds: number[]) =>
|
||||
request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }),
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import request from './request';
|
||||
|
||||
export var mongoApi = {
|
||||
getDatabases: function (id: number) {
|
||||
return request.get('/mongo/' + id + '/databases');
|
||||
},
|
||||
getCollections: function (id: number, database: string) {
|
||||
return request.get('/mongo/' + id + '/databases/' + encodeURIComponent(database) + '/collections');
|
||||
},
|
||||
getCollectionStats: function (id: number, collection: string, database?: string) {
|
||||
var params: Record<string, any> = {};
|
||||
if (database) { params.database = database; }
|
||||
return request.get('/mongo/' + id + '/collections/' + encodeURIComponent(collection) + '/stats', { params: params });
|
||||
},
|
||||
query: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/mongo/' + id + '/query', data);
|
||||
},
|
||||
execute: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/mongo/' + id + '/execute', data);
|
||||
},
|
||||
testConnection: function (id: number) {
|
||||
return request.post('/mongo/' + id + '/test-connection');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from './request';
|
||||
|
||||
export const notificationApi = {
|
||||
list: () => request.get<any, any[]>('/notifications'),
|
||||
create: (data: any) => request.post<any, any>('/notifications', data),
|
||||
update: (id: number, data: any) => request.put<any, any>(`/notifications/${id}`, data),
|
||||
delete: (id: number) => request.delete(`/notifications/${id}`),
|
||||
test: (id: number) => request.post<any, any>(`/notifications/${id}/test`),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import request from './request';
|
||||
|
||||
export const queryApi = {
|
||||
execute: (data: { instance_id: number; database?: string; sql: string }) =>
|
||||
request.post<any, any>('/query', data),
|
||||
|
||||
formatSQL: (sql: string) =>
|
||||
request.post<any, any>('/sql-assistant/format', { sql }),
|
||||
|
||||
analyzeSQL: (sql: string, db_type: string) =>
|
||||
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
|
||||
|
||||
getDBTypeDetail: (dbType: string) =>
|
||||
request.get<any, any>('/db-types/' + dbType),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import request from './request';
|
||||
|
||||
export const redisApi = {
|
||||
// Redis 信息概览
|
||||
getInfo: (instanceId: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/info'),
|
||||
|
||||
// DBSIZE
|
||||
getDbsize: (instanceId: number, db?: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
|
||||
|
||||
// 键列表
|
||||
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
||||
|
||||
// 键详情
|
||||
getKeyDetail: (instanceId: number, key: string, db?: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
|
||||
|
||||
// 慢日志
|
||||
getSlowlog: (instanceId: number, count?: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
|
||||
|
||||
// 客户端列表
|
||||
getClients: (instanceId: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/clients'),
|
||||
|
||||
// 执行命令
|
||||
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
|
||||
request.post<any, any>('/redis/' + instanceId + '/command', data),
|
||||
|
||||
// 获取键列表(兼容旧接口)
|
||||
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
||||
|
||||
// 创建键
|
||||
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
|
||||
request.post<any, any>('/redis/' + instanceId + '/keys', data),
|
||||
|
||||
// 删除键
|
||||
deleteKey: (instanceId: number, key: string, db?: number) =>
|
||||
request.delete<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import axios from 'axios';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
request.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
const detail = error.response?.data?.detail;
|
||||
const msg = detail || error.message || '未知错误';
|
||||
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
} else if (status === 429) {
|
||||
toast('warning', msg);
|
||||
} else if (status === 422) {
|
||||
// 验证错误 — 取第一个错误信息
|
||||
const errors = error.response?.data?.detail;
|
||||
if (Array.isArray(errors)) {
|
||||
const first = errors[0];
|
||||
toast('error', first?.msg || '请求参数错误');
|
||||
} else {
|
||||
toast('error', typeof errors === 'string' ? errors : '请求参数错误');
|
||||
}
|
||||
} else if (status && status >= 400) {
|
||||
toast('error', msg);
|
||||
} else {
|
||||
toast('error', '网络连接失败');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
@@ -0,0 +1,37 @@
|
||||
import request from './request';
|
||||
|
||||
export var ticketApi = {
|
||||
list: function (params?: any) {
|
||||
return request.get('/tickets', { params: params });
|
||||
},
|
||||
get: function (id: number) {
|
||||
return request.get('/tickets/' + id);
|
||||
},
|
||||
create: function (data: any) {
|
||||
return request.post('/tickets', data);
|
||||
},
|
||||
review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) {
|
||||
return request.post('/tickets/' + id + '/review', data);
|
||||
},
|
||||
execute: function (id: number) {
|
||||
return request.post('/tickets/' + id + '/execute');
|
||||
},
|
||||
statistics: function () {
|
||||
return request.get('/tickets/statistics');
|
||||
},
|
||||
timeline: function (id: number) {
|
||||
return request.get('/tickets/' + id + '/timeline');
|
||||
},
|
||||
approvals: function (id: number) {
|
||||
return request.get('/tickets/' + id + '/approvals');
|
||||
},
|
||||
cancel: function (id: number) {
|
||||
return request.post('/tickets/' + id + '/cancel');
|
||||
},
|
||||
rollback: function (id: number) {
|
||||
return request.post('/tickets/' + id + '/rollback');
|
||||
},
|
||||
audit: function (data: { sql_content: string; instance_id: number; database: string }) {
|
||||
return request.post('/tickets/audit', data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from './request';
|
||||
|
||||
export const userApi = {
|
||||
list: (params?: any) => request.get<any, any>('/users', { params }),
|
||||
get: (id: number) => request.get<any, any>(`/users/${id}`),
|
||||
create: (data: any) => request.post<any, any>('/users', data),
|
||||
update: (id: number, data: any) => request.put<any, any>(`/users/${id}`, data),
|
||||
delete: (id: number) => request.delete(`/users/${id}`),
|
||||
resetPassword: (id: number, data: { new_password: string }) =>
|
||||
request.post(`/users/${id}/reset-password`, data),
|
||||
batchImport: (data: any) => request.post('/users/batch-import', data),
|
||||
batchImportLdap: (data: any) => request.post('/users/batch-import-ldap', data),
|
||||
downloadTemplate: () => request.get('/users/import-template', { responseType: 'blob' }),
|
||||
};
|
||||
|
||||
export const ldapApi = {
|
||||
getConfig: () => request.get('/ldap-config'),
|
||||
updateConfig: (data: any) => request.put('/ldap-config', data),
|
||||
testConnection: (data: any) => request.post('/ldap-config/test', data),
|
||||
listOus: () => request.post('/ldap-config/ou-list'),
|
||||
listUsers: (data?: any) => request.post('/ldap-config/user-list', data || {}),
|
||||
createOu: (data: any) => request.post('/ldap-config/create-ou', data),
|
||||
createUser: (data: any) => request.post('/ldap-config/create-user', data),
|
||||
batchImportToLdap: (data: any) => request.post('/ldap-config/batch-import-ldap', data),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import request from './request';
|
||||
|
||||
export const workflowApi = {
|
||||
list: () => request.get<any, any[]>('/workflows'),
|
||||
get: (id: number) => request.get<any, any>(`/workflows/${id}`),
|
||||
create: (data: any) => request.post<any, any>('/workflows', data),
|
||||
update: (id: number, data: any) => request.put<any, any>(`/workflows/${id}`, data),
|
||||
delete: (id: number) => request.delete(`/workflows/${id}`),
|
||||
setDefault: (id: number) => request.post<any, any>(`/workflows/${id}/set-default`),
|
||||
clone: (id: number) => request.post<any, any>(`/workflows/${id}/clone`),
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { menuItems, menuSections } from '@/constants/menu';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export default function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="flex items-center text-xs">
|
||||
<ol className="flex items-center gap-1">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
)}
|
||||
{isLast ? (
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
) : item.path ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(item.path!)}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors inline-flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
{index === 0 && <Home className="w-3 h-3" />}
|
||||
{item.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[var(--dg-text-secondary)]">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that builds breadcrumb items based on the current route.
|
||||
*
|
||||
* It matches the current pathname against menuItems, then constructs:
|
||||
* Home > Section label > Page label
|
||||
*
|
||||
* For detail/sub pages (e.g. /instances/:id, /tickets/:id, /tickets/create),
|
||||
* it prepends the parent breadcrumb entries.
|
||||
*/
|
||||
export function useBreadcrumb(): { items: BreadcrumbItem[] } {
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
const items: BreadcrumbItem[] = [];
|
||||
|
||||
// Find a direct menu item match by id (exact route)
|
||||
const directMatch = menuItems.find(
|
||||
(item) => item.id === pathname.replace(/^\//, '')
|
||||
);
|
||||
|
||||
if (directMatch) {
|
||||
// Exact menu match: Home > Section > Page
|
||||
const section = menuSections.find((s) => s.id === directMatch.section);
|
||||
|
||||
items.push({ label: '首页', path: '/dashboard' });
|
||||
if (section && section.id !== 'overview') {
|
||||
items.push({ label: section.label });
|
||||
}
|
||||
items.push({ label: directMatch.label });
|
||||
return { items };
|
||||
}
|
||||
|
||||
// Parse the pathname for sub/detail pages
|
||||
const segments = pathname.split('/').filter(Boolean); // e.g. ['instances', '123'] or ['tickets', 'create']
|
||||
if (segments.length === 0) {
|
||||
return { items: [{ label: '首页', path: '/dashboard' }] };
|
||||
}
|
||||
|
||||
const rootSegment = segments[0];
|
||||
const parentMenuItem = menuItems.find((item) => item.id === rootSegment);
|
||||
|
||||
if (!parentMenuItem) {
|
||||
// Unknown route — just show Home
|
||||
return { items: [{ label: '首页', path: '/dashboard' }] };
|
||||
}
|
||||
|
||||
const section = menuSections.find((s) => s.id === parentMenuItem.section);
|
||||
|
||||
// Home
|
||||
items.push({ label: '首页', path: '/dashboard' });
|
||||
|
||||
// Section (skip for overview section like dashboard)
|
||||
if (section && section.id !== 'overview') {
|
||||
items.push({ label: section.label });
|
||||
}
|
||||
|
||||
// Parent page (clickable for detail pages)
|
||||
if (segments.length > 1) {
|
||||
items.push({ label: parentMenuItem.label, path: `/${rootSegment}` });
|
||||
}
|
||||
|
||||
// Sub-page label
|
||||
if (segments.length > 1) {
|
||||
const subSegment = segments[1];
|
||||
const isDetailPage = !isNaN(Number(subSegment));
|
||||
|
||||
if (isDetailPage) {
|
||||
// Numeric ID — use a generic "详情" label
|
||||
items.push({ label: '详情' });
|
||||
} else {
|
||||
// Named sub-pages like 'create'
|
||||
const subPageLabels: Record<string, string> = {
|
||||
create: '新建',
|
||||
edit: '编辑',
|
||||
};
|
||||
items.push({ label: subPageLabels[subSegment] || subSegment });
|
||||
}
|
||||
} else {
|
||||
// Single segment — this is the current page
|
||||
items.push({ label: parentMenuItem.label });
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'default';
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title = '确认操作',
|
||||
description,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
variant = 'default',
|
||||
}: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onCancel(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="border-0 sm:max-w-[400px]"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="text-base">{title}</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={variant === 'danger' ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { X, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { workflowApi } from '@/api/workflow';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
interface InstanceData {
|
||||
id: number;
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
database: string;
|
||||
env: string;
|
||||
access_mode: string;
|
||||
workflow_id?: number | null;
|
||||
workflow_name?: string | null;
|
||||
excluded_databases?: string[] | null;
|
||||
mask_fields?: { table: string; field: string; rule: string }[] | null;
|
||||
group_id?: number | null;
|
||||
}
|
||||
|
||||
interface WorkflowItem {
|
||||
id: number;
|
||||
name: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
instance: InstanceData;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
mode?: 'edit' | 'clone';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reusable Edit Instance Dialog */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function EditInstanceDialog({ instance, open, onClose, onSaved, mode = 'edit' }: Props) {
|
||||
const [name, setName] = useState(instance.name);
|
||||
const [host, setHost] = useState(instance.host);
|
||||
const [port, setPort] = useState(String(instance.port));
|
||||
const [username, setUsername] = useState(instance.username);
|
||||
const [password, setPassword] = useState('');
|
||||
const [database, setDatabase] = useState(instance.database || '');
|
||||
const [env, setEnv] = useState(instance.env || 'development');
|
||||
const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite');
|
||||
const [workflowId, setWorkflowId] = useState<number | null>(instance.workflow_id || null);
|
||||
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
|
||||
// excluded_databases: comma-separated input
|
||||
const [excludedDbs, setExcludedDbs] = useState((instance.excluded_databases || []).join(', '));
|
||||
// mask_fields
|
||||
const [maskFields, setMaskFields] = useState<{ table: string; field: string; rule: string }[]>(
|
||||
instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : []
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
/* Sync when instance changes */
|
||||
useEffect(() => {
|
||||
setName(instance.name);
|
||||
setHost(instance.host);
|
||||
setPort(String(instance.port));
|
||||
setUsername(instance.username);
|
||||
setPassword('');
|
||||
setDatabase(instance.database || '');
|
||||
setEnv(instance.env || 'development');
|
||||
setAccessMode(instance.access_mode || 'readwrite');
|
||||
setWorkflowId(instance.workflow_id || null);
|
||||
setExcludedDbs((instance.excluded_databases || []).join(', '));
|
||||
setMaskFields(instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : []);
|
||||
setError('');
|
||||
}, [instance]);
|
||||
|
||||
/* Load workflows when modal opens */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
workflowApi.list().then((data: any) => {
|
||||
const items = Array.isArray(data) ? data : (data?.items || []);
|
||||
setWorkflows(items.filter((w: WorkflowItem) => w.is_active !== false));
|
||||
}).catch(() => setWorkflows([]));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const addMaskField = () => setMaskFields([...maskFields, { table: '', field: '', rule: 'mask' }]);
|
||||
const removeMaskField = (idx: number) => setMaskFields(maskFields.filter((_, i) => i !== idx));
|
||||
const updateMaskField = (idx: number, key: string, value: string) => {
|
||||
const next = [...maskFields];
|
||||
next[idx] = { ...next[idx], [key]: value };
|
||||
setMaskFields(next);
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!name.trim()) { setError('请填写名称'); return; }
|
||||
if (!host.trim()) { setError('请填写地址'); return; }
|
||||
setSubmitting(true); setError('');
|
||||
try {
|
||||
const parsedExcluded = excludedDbs.trim()
|
||||
? excludedDbs.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const parsedMask = maskFields.some(m => m.table && m.field)
|
||||
? maskFields.filter(m => m.table && m.field)
|
||||
: undefined;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
host: host.trim(),
|
||||
port: Number(port),
|
||||
username: username.trim() || undefined,
|
||||
database: database.trim() || undefined,
|
||||
env,
|
||||
access_mode: accessMode,
|
||||
workflow_id: workflowId,
|
||||
excluded_databases: parsedExcluded,
|
||||
mask_fields: parsedMask,
|
||||
};
|
||||
if (mode === 'clone') {
|
||||
payload.db_type = instance.db_type;
|
||||
if (!password) {
|
||||
setError('克隆数据源需要重新输入密码');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
payload.password = password;
|
||||
await instanceApi.create(payload);
|
||||
} else {
|
||||
payload.password = password || undefined;
|
||||
await instanceApi.update(instance.id, payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [name, host, port, username, password, database, env, accessMode, workflowId, excludedDbs, maskFields, instance.id, onSaved]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors';
|
||||
const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>名称</label>
|
||||
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} placeholder="实例名称" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className={labelCls}>地址</label>
|
||||
<input className={inputCls} value={host} onChange={e => setHost(e.target.value)} placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>端口</label>
|
||||
<input className={inputCls} value={port} onChange={e => setPort(e.target.value)} placeholder="3306" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>用户名</label>
|
||||
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{mode === 'clone' ? '密码' : '密码(留空不修改)'}</label>
|
||||
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>默认数据库</label>
|
||||
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>环境</label>
|
||||
<select className={inputCls} value={env} onChange={e => setEnv(e.target.value)}>
|
||||
<option value="development">开发</option>
|
||||
<option value="testing">测试</option>
|
||||
<option value="staging">预发</option>
|
||||
<option value="production">生产</option>
|
||||
<option value="uat">UAT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>访问模式</label>
|
||||
<select className={inputCls} value={accessMode} onChange={e => setAccessMode(e.target.value)}>
|
||||
<option value="readwrite">读写</option>
|
||||
<option value="read">只读</option>
|
||||
<option value="write">只写</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审批流程 */}
|
||||
<div>
|
||||
<label className={labelCls}>审批流程</label>
|
||||
<select className={inputCls} value={workflowId ?? ''} onChange={e => setWorkflowId(e.target.value ? Number(e.target.value) : null)}>
|
||||
<option value="">不关联审批流程</option>
|
||||
{workflows.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
{workflows.length === 0 && <p className="text-xs text-[var(--dg-text-muted)] mt-1">暂无可用审批流程</p>}
|
||||
</div>
|
||||
|
||||
{/* 排除数据库 */}
|
||||
<div>
|
||||
<label className={labelCls}>排除数据库 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,如 sys, mysql)</span></label>
|
||||
<input className={inputCls} value={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} placeholder="sys, information_schema, mysql" />
|
||||
</div>
|
||||
|
||||
{/* 脱敏字段 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className={labelCls}>脱敏字段</label>
|
||||
<button onClick={addMaskField} className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
{maskFields.length === 0 && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] py-2">未配置脱敏规则</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{maskFields.map((mf, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input className="flex-1 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.table} onChange={e => updateMaskField(idx, 'table', e.target.value)} placeholder="表名" />
|
||||
<input className="flex-1 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.field} onChange={e => updateMaskField(idx, 'field', e.target.value)} placeholder="字段名" />
|
||||
<select className="w-20 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.rule} onChange={e => updateMaskField(idx, 'rule', e.target.value)}>
|
||||
<option value="mask">遮盖</option>
|
||||
<option value="hide">隐藏</option>
|
||||
<option value="hash">哈希</option>
|
||||
</select>
|
||||
<button onClick={() => removeMaskField(idx)} className="p-1 text-[var(--dg-text-muted)] hover:text-red-400">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors">
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{mode === 'clone' ? '克隆' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SearchX, Inbox, FileQuestion } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
type?: 'search' | 'data' | 'error';
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const config = {
|
||||
search: { icon: SearchX, title: '没有找到匹配的结果', desc: '请尝试调整搜索关键词或筛选条件' },
|
||||
data: { icon: Inbox, title: '暂无数据', desc: '当前没有可展示的数据' },
|
||||
error: { icon: FileQuestion, title: '出错了', desc: '请稍后重试' },
|
||||
};
|
||||
|
||||
export default function EmptyState({ type = 'data', title, description }: EmptyStateProps) {
|
||||
const c = config[type];
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center mb-4">
|
||||
<Icon className="w-7 h-7 text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-secondary)]">{title || c.title}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{description || c.desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
width?: string;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function FormDialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width = 'max-w-lg',
|
||||
footer,
|
||||
}: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`border-0 ${width} flex max-h-[85vh] flex-col`}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-base font-semibold">{title}</DialogTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xs p-1 text-[var(--dg-text-muted)] transition-opacity hover:opacity-70"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto pr-1">{children}</div>
|
||||
|
||||
{/* Optional footer */}
|
||||
{footer && <div className="flex justify-end gap-2 pt-2">{footer}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Database, Server, Zap, Leaf,
|
||||
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
|
||||
LogOut, Settings, Timer, ScrollText, Megaphone, Sun, Moon, BookOpen,
|
||||
ChevronRight, Monitor
|
||||
} from 'lucide-react';
|
||||
import { menuSections, menuItems } from '@/constants/menu';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
LayoutDashboard, Database, Server, Zap, Leaf,
|
||||
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
|
||||
Settings, Timer, ScrollText, Megaphone, BookOpen,
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout, visibleMenuItems } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentPath = location.pathname.split('/')[1] || 'dashboard';
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => m.default.get('/dashboard/stats'))
|
||||
.then((data: any) => setPendingCount(data?.ticket_pending ?? 0))
|
||||
.catch(() => {});
|
||||
}, [location.pathname]);
|
||||
|
||||
// Build breadcrumb info from current path
|
||||
const breadcrumbInfo = useMemo(() => {
|
||||
const currentItem = menuItems.find(m => m.id === currentPath);
|
||||
if (!currentItem) {
|
||||
return { sectionLabel: '', pageLabel: '仪表盘' };
|
||||
}
|
||||
const section = menuSections.find(s => s.id === currentItem.section);
|
||||
return {
|
||||
sectionLabel: section?.label || '',
|
||||
pageLabel: currentItem.label,
|
||||
};
|
||||
}, [currentPath]);
|
||||
|
||||
const themeOption = useMemo(() => THEME_OPTIONS.find(o => o.id === theme), [theme]);
|
||||
const themeIcon = useMemo(() => {
|
||||
if (!themeOption) return <Monitor className="w-4 h-4" />;
|
||||
if (themeOption.isDark) return <Moon className="w-4 h-4" />;
|
||||
return <Sun className="w-4 h-4" />;
|
||||
}, [themeOption]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
||||
{/* 侧边栏 */}
|
||||
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
|
||||
{/* Logo */}
|
||||
<div className="h-14 flex items-center px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-base font-bold dg-gradient-text whitespace-nowrap">
|
||||
SQL-Platform
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单 */}
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-1">
|
||||
{menuSections.map(section => {
|
||||
const items = visibleMenuItems.filter(item => item.section === section.id);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={section.id} className="mb-1">
|
||||
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
|
||||
{section.label}
|
||||
</div>
|
||||
{items.map(item => {
|
||||
const Icon = iconMap[item.icon] || Database;
|
||||
const isActive = currentPath === item.id;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => navigate('/' + item.id)}
|
||||
className={
|
||||
'group flex items-center mx-2 px-3 py-2 rounded-lg cursor-pointer '
|
||||
+ 'transition-all duration-200 relative text-[13px] '
|
||||
+ (isActive
|
||||
? 'bg-[var(--dg-blue)]/10 text-[var(--dg-blue)] font-medium'
|
||||
: 'text-[var(--dg-text-secondary)] '
|
||||
+ 'hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] '
|
||||
+ 'hover:ml-0.5')
|
||||
}
|
||||
>
|
||||
{/* Active indicator bar - gradient with glow */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r animate-[slideInLeft_200ms_ease-out]"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, var(--dg-blue), var(--dg-purple, var(--dg-blue)))',
|
||||
boxShadow: '0 0 8px 1px var(--dg-blue), 0 0 16px 2px color-mix(in srgb, var(--dg-blue) 40%, transparent)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={
|
||||
'w-4 h-4 flex-shrink-0 transition-colors duration-200 '
|
||||
+ (isActive
|
||||
? 'text-[var(--dg-blue)]'
|
||||
: 'group-hover:text-[var(--dg-blue)]')
|
||||
}
|
||||
/>
|
||||
<span className="ml-2.5">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* 顶部栏 */}
|
||||
<header className="h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
|
||||
{/* Breadcrumb area */}
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="text-[var(--dg-text-muted)] font-medium">SQL-Platform</span>
|
||||
{breadcrumbInfo.sectionLabel && (
|
||||
<>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||
<span className="text-[var(--dg-text-muted)] opacity-70">{breadcrumbInfo.sectionLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{breadcrumbInfo.pageLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle with tooltip */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-all duration-200"
|
||||
title={`当前主题: ${themeOption?.name || theme}\n点击切换下一个主题`}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
|
||||
{/* Notification bell */}
|
||||
<button onClick={() => navigate('/tickets')}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-all duration-200 relative">
|
||||
<Bell className="w-4 h-4" />
|
||||
{pendingCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-red-500 rounded-full text-[10px] text-white font-bold flex items-center justify-center animate-pulse">
|
||||
{pendingCount > 99 ? '99+' : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
|
||||
|
||||
{/* User area */}
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||
{user.avatar}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login'); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors duration-200"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 页面内容 */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-5">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Keyframe animation for active indicator */}
|
||||
<style>{`
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(0);
|
||||
transform-origin: left;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
transform-origin: left;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useAuth, actionPermissionMap } from '@/hooks/useAuth';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ActionConfig {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
onClick: () => void;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ActionConfig;
|
||||
secondaryAction?: ActionConfig;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, description, action, secondaryAction, children }: PageHeaderProps) {
|
||||
const { user, hasPermission } = useAuth();
|
||||
|
||||
const canShowAction = !action?.permission
|
||||
|| hasPermission(action.permission)
|
||||
|| (actionPermissionMap[action.permission] || []).includes(user.role);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
{description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{secondaryAction && (
|
||||
<button
|
||||
onClick={secondaryAction.onClick}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all"
|
||||
>
|
||||
{(() => { const Icon = secondaryAction.icon; return <Icon className="w-4 h-4" />; })()}
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
{canShowAction && action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{(() => { const Icon = action.icon; return <Icon className="w-4 h-4" />; })()}
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ChevronDown, ChevronRight, Database, Table2, Columns3,
|
||||
KeyRound, Search, Loader2, FolderOpen, Server,
|
||||
} from 'lucide-react';
|
||||
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||
import { redisApi } from '@/api/redis';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface Props {
|
||||
instanceId: string;
|
||||
database: string;
|
||||
dbType: string;
|
||||
onInsert: (text: string) => void;
|
||||
}
|
||||
|
||||
interface RedisKeyItem {
|
||||
key: string;
|
||||
type: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/** Redis key 树节点 */
|
||||
interface RedisTreeNode {
|
||||
name: string;
|
||||
fullPrefix: string;
|
||||
isLeaf: boolean;
|
||||
keyItem?: RedisKeyItem; // 叶子节点才有
|
||||
children: Map<string, RedisTreeNode>;
|
||||
}
|
||||
|
||||
/** 把 flat keys 按 ':' 构建成树 */
|
||||
function buildRedisTree(keys: RedisKeyItem[]): RedisTreeNode {
|
||||
const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map() };
|
||||
for (const item of keys) {
|
||||
const parts = item.key.split(':');
|
||||
let node = root;
|
||||
let prefix = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
prefix += (i === 0 ? '' : ':') + parts[i];
|
||||
if (!node.children.has(parts[i])) {
|
||||
node.children.set(parts[i], {
|
||||
name: parts[i],
|
||||
fullPrefix: prefix,
|
||||
isLeaf: i === parts.length - 1,
|
||||
keyItem: i === parts.length - 1 ? item : undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
node = node.children.get(parts[i])!;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Redis 树形渲染组件 */
|
||||
/** 根据 key type 生成查询命令 */
|
||||
function redisCommand(key: string, type: string): string {
|
||||
switch (type) {
|
||||
case 'hash': return `HGETALL ${key}`;
|
||||
case 'list': return `LRANGE ${key} 0 -1`;
|
||||
case 'set': return `SMEMBERS ${key}`;
|
||||
case 'zset': return `ZRANGE ${key} 0 -1 WITHSCORES`;
|
||||
default: return `GET ${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail, onInsert }: {
|
||||
tree: RedisTreeNode;
|
||||
expanded: Set<string>;
|
||||
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
selectedKey: string | null;
|
||||
onSelectKey: (key: string) => void;
|
||||
keyDetail: any;
|
||||
loadingDetail: boolean;
|
||||
onInsert?: (cmd: string) => void;
|
||||
}) {
|
||||
const toggle = (prefix: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(prefix) ? next.delete(prefix) : next.add(prefix);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderNode = (node: RedisTreeNode, depth: number) => {
|
||||
const sorted = [...node.children.values()].sort((a, b) => {
|
||||
// 文件夹优先,叶子在后
|
||||
if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return sorted.map(child => {
|
||||
const indent = depth * 12;
|
||||
|
||||
if (child.isLeaf && child.keyItem) {
|
||||
// 叶子节点:实际 Redis key
|
||||
return (
|
||||
<div key={child.fullPrefix}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelectKey(child.keyItem!.key);
|
||||
if (onInsert) onInsert(redisCommand(child.keyItem!.key, child.keyItem!.type));
|
||||
}}
|
||||
className={`flex items-center gap-1.5 w-full px-2 py-1 text-xs transition-colors ${
|
||||
selectedKey === child.keyItem!.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
>
|
||||
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<span className="font-mono truncate flex-1 text-left">{child.name}</span>
|
||||
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] flex-shrink-0">{child.keyItem.type}</span>
|
||||
</button>
|
||||
{selectedKey === child.keyItem.key && (
|
||||
<div className="px-2 pb-2" style={{ paddingLeft: 8 + indent }}>
|
||||
{loadingDetail ? (
|
||||
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||
</div>
|
||||
) : keyDetail ? (
|
||||
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||
{typeof keyDetail.value === 'object'
|
||||
? JSON.stringify(keyDetail.value, null, 2)
|
||||
: String(keyDetail.value)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 文件夹节点
|
||||
const isOpen = expanded.has(child.fullPrefix);
|
||||
const childCount = child.children.size;
|
||||
return (
|
||||
<div key={child.fullPrefix}>
|
||||
<button
|
||||
onClick={() => toggle(child.fullPrefix)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
>
|
||||
{isOpen
|
||||
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
}
|
||||
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
|
||||
<span className="truncate flex-1 text-left">{child.name}</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">{childCount}</span>
|
||||
</button>
|
||||
{isOpen && renderNode(child, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return <div>{renderNode(tree, 0)}</div>;
|
||||
}
|
||||
|
||||
/** 每个数据库节点缓存的表列表 */
|
||||
interface DbNode {
|
||||
name: string;
|
||||
tables: TableMeta[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function formatTTL(ttl: number): string {
|
||||
if (ttl < 0) return '永不过期';
|
||||
if (ttl < 60) return `${ttl}s`;
|
||||
if (ttl < 3600) return `${Math.floor(ttl / 60)}m`;
|
||||
if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`;
|
||||
return `${Math.floor(ttl / 86400)}d`;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* COMPONENT */
|
||||
/* ================================================================== */
|
||||
|
||||
export default function SchemaSidebar({ instanceId, database, dbType, onInsert }: Props) {
|
||||
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
|
||||
const isRedis = dbType === 'redis';
|
||||
|
||||
// -- MySQL: DB → Table → Column 三级 --
|
||||
const [databases, setDatabases] = useState<string[]>([]);
|
||||
const [loadingDbs, setLoadingDbs] = useState(false);
|
||||
const [expandedDb, setExpandedDb] = useState<string | null>(null);
|
||||
const [dbNodes, setDbNodes] = useState<Record<string, DbNode>>({});
|
||||
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// -- Redis State --
|
||||
const [redisDb, setRedisDb] = useState(0);
|
||||
const [redisPattern, setRedisPattern] = useState('*');
|
||||
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
|
||||
const [loadingRedis, setLoadingRedis] = useState(false);
|
||||
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [keyDetail, setKeyDetail] = useState<any>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
|
||||
// -- Tab --
|
||||
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch databases (level 1) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !isMySQL) { setDatabases([]); return; }
|
||||
setLoadingDbs(true);
|
||||
instanceDetailApi.listDatabases(Number(instanceId))
|
||||
.then((data) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setDatabases(list);
|
||||
// 如果当前已选了 database,自动展开
|
||||
if (database && list.includes(database)) {
|
||||
setExpandedDb(database);
|
||||
}
|
||||
})
|
||||
.catch(() => setDatabases([]))
|
||||
.finally(() => setLoadingDbs(false));
|
||||
}, [instanceId, isMySQL]);
|
||||
|
||||
// 当 database prop 变化时自动展开
|
||||
useEffect(() => {
|
||||
if (database && databases.includes(database) && expandedDb !== database) {
|
||||
setExpandedDb(database);
|
||||
}
|
||||
}, [database, databases]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Expand database → load tables (level 2) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleExpandDb = useCallback(async (dbName: string) => {
|
||||
if (expandedDb === dbName) {
|
||||
setExpandedDb(null);
|
||||
return;
|
||||
}
|
||||
setExpandedDb(dbName);
|
||||
setExpandedTable(null);
|
||||
|
||||
// 已加载过则跳过
|
||||
if (dbNodes[dbName]?.loaded) return;
|
||||
|
||||
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } }));
|
||||
try {
|
||||
const data = await instanceDetailApi.listTables(Number(instanceId), dbName);
|
||||
const tables = Array.isArray(data) ? data : [];
|
||||
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } }));
|
||||
} catch {
|
||||
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } }));
|
||||
}
|
||||
}, [expandedDb, instanceId, dbNodes]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Expand table → load columns (level 3) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleExpandTable = useCallback(async (dbName: string, tableName: string) => {
|
||||
const key = `${dbName}.${tableName}`;
|
||||
if (expandedTable === key) { setExpandedTable(null); return; }
|
||||
setExpandedTable(key);
|
||||
setTableColumns([]);
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName);
|
||||
setTableColumns(detail?.columns || []);
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingColumns(false); }
|
||||
}, [expandedTable, instanceId]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch Redis keys */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const fetchRedisKeys = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoadingRedis(true);
|
||||
try {
|
||||
const res = await redisApi.scanKeys(Number(instanceId), {
|
||||
db: redisDb, pattern: redisPattern || '*', count: 100,
|
||||
});
|
||||
const keys = (res?.keys || []).map((k: any) => ({
|
||||
key: typeof k === 'string' ? k : k.key,
|
||||
type: typeof k === 'string' ? 'string' : (k.type || 'string'),
|
||||
ttl: k.ttl ?? -1,
|
||||
}));
|
||||
setRedisKeys(keys);
|
||||
} catch { setRedisKeys([]); }
|
||||
finally { setLoadingRedis(false); }
|
||||
}, [instanceId, redisDb, redisPattern]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRedis && instanceId) fetchRedisKeys();
|
||||
}, [isRedis, instanceId, fetchRedisKeys]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch Redis key detail */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleSelectKey = useCallback(async (key: string) => {
|
||||
setSelectedKey(key);
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
|
||||
setKeyDetail(detail);
|
||||
} catch { setKeyDetail(null); }
|
||||
finally { setLoadingDetail(false); }
|
||||
}, [instanceId, redisDb]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Filtered databases (search matches db name or table name) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const filteredDatabases = databases.filter(dbName => {
|
||||
// 如果已选目标数据库,只显示该库
|
||||
if (database && dbName !== database) return false;
|
||||
if (!search) return true;
|
||||
const kw = search.toLowerCase();
|
||||
// 库名匹配
|
||||
if (dbName.toLowerCase().includes(kw)) return true;
|
||||
// 该库下的表名匹配
|
||||
const node = dbNodes[dbName];
|
||||
if (node?.loaded) {
|
||||
return node.tables.some(t => t.name.toLowerCase().includes(kw));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const getFilteredTables = (dbName: string) => {
|
||||
const node = dbNodes[dbName];
|
||||
if (!node?.loaded) return [];
|
||||
if (!search) return node.tables;
|
||||
const kw = search.toLowerCase();
|
||||
return node.tables.filter(t => t.name.toLowerCase().includes(kw));
|
||||
};
|
||||
|
||||
/* ================================================================== */
|
||||
/* RENDER */
|
||||
/* ================================================================== */
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<FolderOpen className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-medium text-[var(--dg-text-muted)]">数据库导航</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center px-4">请先选择实例</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[var(--dg-bg-secondary)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<Server className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-medium truncate flex-1">{dbType.toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{(isMySQL || isRedis) && (
|
||||
<div className="flex border-b border-[var(--dg-border)]">
|
||||
{isMySQL && (
|
||||
<button
|
||||
onClick={() => setActiveTab('tables')}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
||||
activeTab === 'tables'
|
||||
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
||||
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<Database className="w-3 h-3" /> 库表
|
||||
</button>
|
||||
)}
|
||||
{isRedis && (
|
||||
<button
|
||||
onClick={() => setActiveTab('redis')}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
||||
activeTab === 'redis'
|
||||
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
||||
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="w-3 h-3" /> Keys
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === 'tables' && isMySQL && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Search */}
|
||||
<div className="px-2 py-2 border-b border-[var(--dg-border-light)]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
placeholder="搜索库/表..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB → Table → Column tree */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingDbs ? (
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
|
||||
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredDatabases.length === 0 ? (
|
||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredDatabases.map(dbName => {
|
||||
const isExpanded = expandedDb === dbName;
|
||||
const node = dbNodes[dbName];
|
||||
const tables = getFilteredTables(dbName);
|
||||
const isActive = dbName === database;
|
||||
|
||||
return (
|
||||
<div key={dbName}>
|
||||
{/* Level 1: Database */}
|
||||
<button
|
||||
onClick={() => handleExpandDb(dbName)}
|
||||
className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs hover:bg-[var(--dg-bg-hover)] transition-colors ${
|
||||
isActive ? 'text-blue-400' : ''
|
||||
}`}
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
}
|
||||
<Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
||||
<span className="font-mono truncate flex-1 text-left">{dbName}</span>
|
||||
{isActive && (
|
||||
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">当前</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Level 2: Tables */}
|
||||
{isExpanded && (
|
||||
<div className="ml-3 border-l border-[var(--dg-border-light)]">
|
||||
{node?.loading ? (
|
||||
<div className="space-y-1 px-2 py-1">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-1 py-1">
|
||||
<div className="w-2.5 h-2.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
|
||||
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : tables.length === 0 ? (
|
||||
<div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div>
|
||||
) : (
|
||||
tables.map(t => {
|
||||
const tableKey = `${dbName}.${t.name}`;
|
||||
const isTableExpanded = expandedTable === tableKey;
|
||||
|
||||
return (
|
||||
<div key={t.name}>
|
||||
<button
|
||||
onClick={() => handleExpandTable(dbName, t.name)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||
>
|
||||
{isTableExpanded
|
||||
? <ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
: <ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
}
|
||||
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
|
||||
<span
|
||||
className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onInsert(t.name); }}
|
||||
title="点击插入表名"
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
{t.rows !== undefined && (
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">{t.rows.toLocaleString()}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Level 3: Columns */}
|
||||
{isTableExpanded && (
|
||||
<div className="ml-4 border-l border-[var(--dg-border-light)]">
|
||||
{loadingColumns ? (
|
||||
<div className="flex items-center gap-1 p-1.5 text-[10px] text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||
</div>
|
||||
) : (
|
||||
tableColumns.map(col => (
|
||||
<button
|
||||
key={col.Field}
|
||||
onClick={() => onInsert(col.Field)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-0.5 text-[10px] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
|
||||
>
|
||||
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">{col.Field}</span>
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{col.Type}</span>
|
||||
{col.Key === 'PRI' && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">PK</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'redis' && isRedis && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* DB + Search */}
|
||||
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
|
||||
<select
|
||||
value={redisDb}
|
||||
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
|
||||
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<option key={i} value={i}>DB{i}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={redisPattern}
|
||||
onChange={(e) => setRedisPattern(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
|
||||
className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
placeholder="*"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRedisKeys}
|
||||
disabled={loadingRedis}
|
||||
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Key Tree + Detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingRedis ? (
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
|
||||
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
|
||||
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : redisKeys.length === 0 ? (
|
||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
|
||||
) : (
|
||||
<RedisTree
|
||||
tree={buildRedisTree(redisKeys)}
|
||||
expanded={redisExpanded}
|
||||
setExpanded={setRedisExpanded}
|
||||
selectedKey={selectedKey}
|
||||
onSelectKey={handleSelectKey}
|
||||
keyDetail={keyDetail}
|
||||
loadingDetail={loadingDetail}
|
||||
onInsert={onInsert}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export function SkeletonCard({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="dg-card p-4 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-6 w-12 bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
<div className="h-3 w-16 bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonTable({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="dg-card animate-pulse">
|
||||
<div className="p-0">
|
||||
<div className="flex gap-4 px-5 py-3 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<div key={i} className="h-4 flex-1 bg-[var(--dg-bg-primary)] rounded max-w-[120px]" />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="flex gap-4 px-5 py-3.5 border-b border-[var(--dg-border-light)]">
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<div key={c} className="h-4 flex-1 bg-[var(--dg-bg-tertiary)] rounded max-w-[120px]" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonDetail() {
|
||||
return (
|
||||
<div className="space-y-5 animate-pulse max-w-5xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-7 w-48 bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
<div className="h-4 w-32 bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="dg-card p-5 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between py-2">
|
||||
<div className="h-4 w-24 bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
<div className="h-4 w-32 bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="dg-card p-5">
|
||||
<div className="h-48 bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||
import { sql, SQLDialect } from '@codemirror/lang-sql';
|
||||
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import {
|
||||
syntaxHighlighting,
|
||||
defaultHighlightStyle,
|
||||
bracketMatching,
|
||||
} from '@codemirror/language';
|
||||
import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SqlEditorProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
placeholder?: string;
|
||||
language?: 'sql' | 'redis' | 'mongodb';
|
||||
height?: string;
|
||||
readOnly?: boolean;
|
||||
onExecute?: () => void;
|
||||
/** SQL schema: { [dbName]: { [tableName]: string[] (columns) } } or { [tableName]: string[] } */
|
||||
schema?: Record<string, Record<string, string[]> | string[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language configs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REDIS_KEYWORDS = [
|
||||
'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET',
|
||||
'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD',
|
||||
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN',
|
||||
'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER',
|
||||
'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL',
|
||||
'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL',
|
||||
'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT',
|
||||
'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH',
|
||||
'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE',
|
||||
'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN',
|
||||
'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT',
|
||||
'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX',
|
||||
'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE',
|
||||
'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF',
|
||||
];
|
||||
|
||||
const MONGODB_KEYWORDS = [
|
||||
// CRUD
|
||||
'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany',
|
||||
'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete',
|
||||
'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct',
|
||||
// Aggregation stages
|
||||
'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup',
|
||||
'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount',
|
||||
'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count',
|
||||
// Operators
|
||||
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not',
|
||||
'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then',
|
||||
'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays',
|
||||
'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last',
|
||||
// Index / admin
|
||||
'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint',
|
||||
// Collection / DB
|
||||
'use', 'show', 'db', 'collection', 'collections', 'databases',
|
||||
// JS keywords commonly used
|
||||
'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while',
|
||||
'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof',
|
||||
'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal',
|
||||
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef',
|
||||
];
|
||||
|
||||
function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEditorProps['schema']): Extension {
|
||||
const lang = language ?? 'sql';
|
||||
|
||||
if (lang === 'redis') {
|
||||
const dialect = SQLDialect.define({
|
||||
keywords: REDIS_KEYWORDS.join(' '),
|
||||
});
|
||||
return sql({ dialect });
|
||||
}
|
||||
|
||||
if (lang === 'mongodb') {
|
||||
const dialect = SQLDialect.define({
|
||||
keywords: MONGODB_KEYWORDS.join(' '),
|
||||
});
|
||||
return sql({ dialect });
|
||||
}
|
||||
|
||||
// MySQL/PG — pass schema for autocomplete (tables + columns)
|
||||
if (schema && Object.keys(schema).length > 0) {
|
||||
// Flatten: if first value is an array, it's { table: columns[] }
|
||||
// if first value is an object, it's { db: { table: columns[] } }
|
||||
const sampleValue = Object.values(schema)[0];
|
||||
let tables: Record<string, readonly string[]> = {};
|
||||
|
||||
if (Array.isArray(sampleValue)) {
|
||||
// Direct: { table: [columns] }
|
||||
tables = Object.fromEntries(
|
||||
Object.entries(schema).map(([k, v]) => [k, v as string[]])
|
||||
);
|
||||
} else {
|
||||
// Nested: { db: { table: [columns] } }
|
||||
// Register: db → table names (so "db." shows tables)
|
||||
// Register: table → columns (so "table." shows columns)
|
||||
for (const [db, tableMap] of Object.entries(schema)) {
|
||||
const entries = tableMap as Record<string, string[]>;
|
||||
const tableNames = Object.keys(entries);
|
||||
// "shop_db" → shows table names as completions after "."
|
||||
tables[db] = tableNames;
|
||||
for (const [table, cols] of Object.entries(entries)) {
|
||||
// "goods" → shows column names after "."
|
||||
if (cols.length > 0) {
|
||||
tables[table] = cols;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dialect = SQLDialect.define({
|
||||
keywords: 'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
|
||||
schema: tables,
|
||||
});
|
||||
return sql({ dialect });
|
||||
}
|
||||
|
||||
return sql();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom theme to match app CSS variables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const appTheme = EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'var(--dg-bg-primary)',
|
||||
color: 'var(--dg-text-primary)',
|
||||
border: '1px solid var(--dg-border)',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--dg-text-primary)',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--dg-text-primary)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--dg-bg-tertiary)',
|
||||
color: 'var(--dg-text-muted)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--dg-border)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--dg-bg-tertiary)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--dg-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatSQL helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SQL_BREAK_KEYWORDS = [
|
||||
'SELECT',
|
||||
'FROM',
|
||||
'WHERE',
|
||||
'ORDER BY',
|
||||
'GROUP BY',
|
||||
'HAVING',
|
||||
'LIMIT',
|
||||
'OFFSET',
|
||||
'JOIN',
|
||||
'INNER JOIN',
|
||||
'LEFT JOIN',
|
||||
'RIGHT JOIN',
|
||||
'FULL JOIN',
|
||||
'CROSS JOIN',
|
||||
'LEFT OUTER JOIN',
|
||||
'RIGHT OUTER JOIN',
|
||||
'FULL OUTER JOIN',
|
||||
'ON',
|
||||
'UNION',
|
||||
'UNION ALL',
|
||||
'INTERSECT',
|
||||
'EXCEPT',
|
||||
'INSERT INTO',
|
||||
'VALUES',
|
||||
'UPDATE',
|
||||
'SET',
|
||||
'DELETE FROM',
|
||||
'CREATE TABLE',
|
||||
'ALTER TABLE',
|
||||
'DROP TABLE',
|
||||
];
|
||||
|
||||
const SQL_KEYWORDS_UPPER = new Set([
|
||||
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET',
|
||||
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON',
|
||||
'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS',
|
||||
'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
|
||||
'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT',
|
||||
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER',
|
||||
'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION',
|
||||
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT',
|
||||
'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION',
|
||||
'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN',
|
||||
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
|
||||
'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE',
|
||||
'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON',
|
||||
'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE',
|
||||
]);
|
||||
|
||||
export function formatSQL(input: string): string {
|
||||
// Normalise whitespace
|
||||
let formatted = input.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Uppercase SQL keywords (word-boundary aware)
|
||||
formatted = formatted.replace(/\b([A-Za-z_]+)\b/g, (match) => {
|
||||
const upper = match.toUpperCase();
|
||||
return SQL_KEYWORDS_UPPER.has(upper) ? upper : match;
|
||||
});
|
||||
|
||||
// Insert newlines before major clauses
|
||||
// We iterate in the order they might appear; use a single pass regex approach.
|
||||
for (const kw of SQL_BREAK_KEYWORDS) {
|
||||
// Only break on keyword that is not already at start of a line
|
||||
const escaped = kw.replace(/\s+/g, '\\s+');
|
||||
const re = new RegExp(`(?<!^)(?<!\\n)\\b(${escaped})\\b`, 'gi');
|
||||
formatted = formatted.replace(re, '\n$1');
|
||||
}
|
||||
|
||||
// Clean up any double newlines
|
||||
formatted = formatted.replace(/\n{2,}/g, '\n');
|
||||
|
||||
return formatted.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SqlEditor component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SqlEditor: React.FC<SqlEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Enter SQL query…',
|
||||
language = 'sql',
|
||||
height = '200px',
|
||||
readOnly = false,
|
||||
onExecute,
|
||||
schema,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const langCompartmentRef = useRef(new Compartment());
|
||||
const readOnlyCompartmentRef = useRef(new Compartment());
|
||||
const placeholderCompartmentRef = useRef(new Compartment());
|
||||
const isExternalUpdateRef = useRef(false);
|
||||
|
||||
// Keep onChange in a stable ref so callbacks never go stale
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// Ctrl+Enter handler
|
||||
const runKeymap = useCallback(
|
||||
(): Extension[] => [
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: () => {
|
||||
if (onExecute) {
|
||||
onExecute();
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('sql-editor:execute'));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Mod-Shift-f',
|
||||
run: (view: EditorView) => {
|
||||
const current = view.state.doc.toString();
|
||||
const formatted = formatSQL(current);
|
||||
if (formatted !== current) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: formatted,
|
||||
},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[onExecute],
|
||||
);
|
||||
|
||||
// Create editor once
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !isExternalUpdateRef.current) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion({
|
||||
activateOnTyping: true,
|
||||
icons: false,
|
||||
}),
|
||||
highlightSelectionMatches(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
appTheme,
|
||||
oneDark,
|
||||
langCompartmentRef.current.of(getSQLExtension(language, schema)),
|
||||
readOnlyCompartmentRef.current.of(
|
||||
EditorState.readOnly.of(readOnly),
|
||||
),
|
||||
placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)),
|
||||
updateListener,
|
||||
...runKeymap(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.lineWrapping,
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// We intentionally only run this once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sync external value changes into the editor (avoid loops)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
|
||||
const currentContent = view.state.doc.toString();
|
||||
if (currentContent !== value) {
|
||||
isExternalUpdateRef.current = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
isExternalUpdateRef.current = false;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Update language/schema when they change
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
|
||||
});
|
||||
}, [language, schema]);
|
||||
|
||||
// Update readOnly when it changes
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: readOnlyCompartmentRef.current.reconfigure(
|
||||
EditorState.readOnly.of(readOnly),
|
||||
),
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
// Update placeholder when it changes
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: placeholderCompartmentRef.current.reconfigure(
|
||||
cmPlaceholder(placeholder ?? ''),
|
||||
),
|
||||
});
|
||||
}, [placeholder]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ height, minHeight: height }}
|
||||
className="sql-editor-wrapper"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SqlEditor;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
const ICONS = {
|
||||
success: CheckCircle2,
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const COLORS = {
|
||||
success: 'bg-green-500/10 border-green-500/30 text-green-400',
|
||||
error: 'bg-red-500/10 border-red-500/30 text-red-400',
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400',
|
||||
info: 'bg-blue-500/10 border-blue-500/30 text-blue-400',
|
||||
};
|
||||
|
||||
export function ToastContainer() {
|
||||
const { toasts, remove } = useToast();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map(t => {
|
||||
const Icon = ICONS[t.type];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-sm animate-in slide-in-from-right fade-in duration-200 ${COLORS[t.type]}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm flex-1">{t.message}</span>
|
||||
<button onClick={() => remove(t.id)} className="shrink-0 opacity-50 hover:opacity-100">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import React from 'react';
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
/* ──────────────── 极光之境 · 登录组件 ──────────────── */
|
||||
|
||||
const KEYFRAMES = `
|
||||
@keyframes aurora-drift-1 {
|
||||
0% { transform: translate(-30%, -10%) rotate(-12deg) scaleX(1.2); opacity: 0.35; }
|
||||
25% { transform: translate(-10%, 5%) rotate(-8deg) scaleX(1.5); opacity: 0.5; }
|
||||
50% { transform: translate(10%, -5%) rotate(-14deg) scaleX(1.3); opacity: 0.4; }
|
||||
75% { transform: translate(-5%, 10%) rotate(-10deg) scaleX(1.6); opacity: 0.55; }
|
||||
100% { transform: translate(-30%, -10%) rotate(-12deg) scaleX(1.2); opacity: 0.35; }
|
||||
}
|
||||
|
||||
@keyframes aurora-drift-2 {
|
||||
0% { transform: translate(20%, 15%) rotate(8deg) scaleX(1.3); opacity: 0.25; }
|
||||
30% { transform: translate(-10%, -5%) rotate(12deg) scaleX(1.6); opacity: 0.45; }
|
||||
60% { transform: translate(5%, 10%) rotate(6deg) scaleX(1.4); opacity: 0.35; }
|
||||
100% { transform: translate(20%, 15%) rotate(8deg) scaleX(1.3); opacity: 0.25; }
|
||||
}
|
||||
|
||||
@keyframes aurora-drift-3 {
|
||||
0% { transform: translate(-15%, 25%) rotate(-5deg) scaleX(1.5); opacity: 0.3; }
|
||||
33% { transform: translate(15%, 5%) rotate(-9deg) scaleX(1.8); opacity: 0.5; }
|
||||
66% { transform: translate(0%, 20%) rotate(-3deg) scaleX(1.4); opacity: 0.4; }
|
||||
100% { transform: translate(-15%, 25%) rotate(-5deg) scaleX(1.5); opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes aurora-drift-4 {
|
||||
0% { transform: translate(10%, -15%) rotate(15deg) scaleX(1.4); opacity: 0.2; }
|
||||
40% { transform: translate(-20%, 0%) rotate(10deg) scaleX(1.7); opacity: 0.4; }
|
||||
80% { transform: translate(0%, 8%) rotate(18deg) scaleX(1.5); opacity: 0.3; }
|
||||
100% { transform: translate(10%, -15%) rotate(15deg) scaleX(1.4); opacity: 0.2; }
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.15; transform: scale(0.8); }
|
||||
50% { opacity: 0.9; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@keyframes float-card {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(124, 58, 237, 0.15), 0 0 60px rgba(5, 150, 105, 0.08); }
|
||||
50% { box-shadow: 0 0 30px rgba(124, 58, 237, 0.25), 0 0 80px rgba(5, 150, 105, 0.15); }
|
||||
}
|
||||
`;
|
||||
|
||||
/* ──────────────── 星星数据 ──────────────── */
|
||||
const STARS = [
|
||||
{ left: '8%', top: '5%', size: 2, delay: 0, duration: 3 },
|
||||
{ left: '18%', top: '15%', size: 1.5, delay: 0.7, duration: 4 },
|
||||
{ left: '5%', top: '35%', size: 1, delay: 1.2, duration: 2.5 },
|
||||
{ left: '25%', top: '8%', size: 2.5, delay: 0.3, duration: 3.5 },
|
||||
{ left: '75%', top: '6%', size: 1.5, delay: 1.5, duration: 4.5 },
|
||||
{ left: '88%', top: '12%', size: 2, delay: 0.5, duration: 3 },
|
||||
{ left: '92%', top: '30%', size: 1, delay: 2, duration: 2.8 },
|
||||
{ left: '82%', top: '45%', size: 1.5, delay: 0.9, duration: 3.8 },
|
||||
{ left: '68%', top: '18%', size: 1, delay: 1.8, duration: 2.2 },
|
||||
{ left: '38%', top: '3%', size: 2, delay: 0.2, duration: 4 },
|
||||
{ left: '55%', top: '10%', size: 1.5, delay: 1.1, duration: 3.2 },
|
||||
{ left: '45%', top: '22%', size: 1, delay: 0.6, duration: 2.6 },
|
||||
{ left: '12%', top: '55%', size: 1, delay: 1.6, duration: 3.6 },
|
||||
{ left: '95%', top: '55%', size: 1.5, delay: 0.4, duration: 2.4 },
|
||||
{ left: '60%', top: '30%', size: 1, delay: 2.2, duration: 4.2 },
|
||||
];
|
||||
|
||||
export default function AuroraView(): JSX.Element {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{KEYFRAMES}</style>
|
||||
|
||||
{/* ═══════════════ 全屏背景容器 ═══════════════ */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
background: 'linear-gradient(180deg, #050810 0%, #0a0e1a 30%, #0d1425 60%, #080c14 100%)',
|
||||
}}
|
||||
>
|
||||
{/* ────────── 极光光带 1:紫罗兰色 ────────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '15%',
|
||||
left: '10%',
|
||||
width: '80%',
|
||||
height: '35%',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.6) 0%, rgba(124, 58, 237, 0.3) 40%, transparent 80%)',
|
||||
filter: 'blur(60px)',
|
||||
borderRadius: '50%',
|
||||
animation: 'aurora-drift-1 18s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 极光光带 2:翠绿色 ────────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '25%',
|
||||
left: '20%',
|
||||
width: '75%',
|
||||
height: '30%',
|
||||
background: 'linear-gradient(120deg, rgba(16, 185, 129, 0.5) 0%, rgba(5, 150, 105, 0.25) 50%, transparent 90%)',
|
||||
filter: 'blur(70px)',
|
||||
borderRadius: '50%',
|
||||
animation: 'aurora-drift-2 22s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 极光光带 3:青蓝色 ────────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '35%',
|
||||
left: '5%',
|
||||
width: '70%',
|
||||
height: '28%',
|
||||
background: 'linear-gradient(145deg, rgba(6, 182, 212, 0.4) 0%, rgba(6, 182, 212, 0.15) 45%, transparent 85%)',
|
||||
filter: 'blur(55px)',
|
||||
borderRadius: '50%',
|
||||
animation: 'aurora-drift-3 20s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 极光光带 4:紫绿混合 ────────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10%',
|
||||
left: '30%',
|
||||
width: '65%',
|
||||
height: '40%',
|
||||
background: 'linear-gradient(100deg, rgba(124, 58, 237, 0.35) 0%, rgba(16, 185, 129, 0.2) 50%, rgba(6, 182, 212, 0.15) 100%)',
|
||||
filter: 'blur(75px)',
|
||||
borderRadius: '50%',
|
||||
animation: 'aurora-drift-4 25s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 星光点缀 ────────── */}
|
||||
{STARS.map((star, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: star.left,
|
||||
top: star.top,
|
||||
width: star.size,
|
||||
height: star.size,
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
boxShadow: `0 0 ${star.size * 2}px rgba(255,255,255,0.5)`,
|
||||
animation: `twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ═══════════════ 登录卡片 ═══════════════ */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
margin: '0 20px',
|
||||
padding: '44px 36px',
|
||||
borderRadius: 20,
|
||||
background: 'rgba(8, 12, 25, 0.65)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 20px 60px rgba(0,0,0,0.4)',
|
||||
animation: 'float-card 8s ease-in-out infinite, pulse-glow 6s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
{/* ──────── 品牌区域 ──────── */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #059669 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
boxShadow: '0 4px 20px rgba(124, 58, 237, 0.3), 0 2px 10px rgba(5, 150, 105, 0.2)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={22}
|
||||
height={22}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
margin: '0 0 6px',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
|
||||
{/* 副标题 */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
margin: 0,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
企业级数据库审核与合规管理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ──────── 表单区域 ──────── */}
|
||||
<form onSubmit={handleLogin} noValidate>
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户名输入框 */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 46,
|
||||
padding: '0 16px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.25s ease, box-shadow 0.25s ease',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(124, 58, 237, 0.08)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 46,
|
||||
padding: '0 16px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.25s ease, box-shadow 0.25s ease',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(124, 58, 237, 0.08)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username || !password || loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 46,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #059669 100%)',
|
||||
color: '#fff',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
cursor: !username || !password || loading ? 'not-allowed' : 'pointer',
|
||||
opacity: !username || !password ? 0.55 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
fontFamily: 'inherit',
|
||||
letterSpacing: 0.5,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (username && password && !loading) {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(124, 58, 237, 0.35), 0 4px 15px rgba(5, 150, 105, 0.25)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: '#fff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
登录中...
|
||||
</span>
|
||||
) : (
|
||||
'登 录'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* ──────── 版权信息 ──────── */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 28,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.2)',
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
© 2025 SQL审核平台
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ──────── 底部辉光装饰 ──────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-20%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '120%',
|
||||
height: '50%',
|
||||
background: 'radial-gradient(ellipse at center, rgba(124, 58, 237, 0.08) 0%, rgba(5, 150, 105, 0.05) 30%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 全局旋转动画 */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
export default function CenteredCard() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
// Light rays: 10 beams fanning out from bottom center
|
||||
const rays = [
|
||||
{ angle: -55, height: 70, opacity: 0.12, duration: 8 },
|
||||
{ angle: -42, height: 85, opacity: 0.08, duration: 7 },
|
||||
{ angle: -28, height: 60, opacity: 0.15, duration: 9 },
|
||||
{ angle: -14, height: 90, opacity: 0.06, duration: 6 },
|
||||
{ angle: -4, height: 75, opacity: 0.10, duration: 10 },
|
||||
{ angle: 5, height: 80, opacity: 0.09, duration: 7 },
|
||||
{ angle: 16, height: 65, opacity: 0.13, duration: 9 },
|
||||
{ angle: 30, height: 88, opacity: 0.07, duration: 8 },
|
||||
{ angle: 44, height: 72, opacity: 0.11, duration: 6 },
|
||||
{ angle: 56, height: 55, opacity: 0.14, duration: 10 },
|
||||
];
|
||||
|
||||
// Fading stars at the top
|
||||
const stars = [
|
||||
{ top: '6%', left: '15%', size: 2, opacity: 0.2, duration: 5 },
|
||||
{ top: '10%', left: '72%', size: 3, opacity: 0.15, duration: 6 },
|
||||
{ top: '3%', left: '45%', size: 2, opacity: 0.25, duration: 4.5 },
|
||||
{ top: '14%', left: '30%', size: 2, opacity: 0.1, duration: 5.5 },
|
||||
{ top: '8%', left: '88%', size: 3, opacity: 0.18, duration: 7 },
|
||||
{ top: '5%', left: '60%', size: 2, opacity: 0.3, duration: 4 },
|
||||
];
|
||||
|
||||
const inputStyle = {
|
||||
padding: '14px 18px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(251,191,36,0.06)',
|
||||
color: '#fef3c7',
|
||||
fontSize: 14,
|
||||
outline: 'none' as const,
|
||||
width: '100%',
|
||||
transition: 'border-color 0.3s, background 0.3s, box-shadow 0.3s',
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.target.style.borderColor = 'rgba(251,191,36,0.3)';
|
||||
e.target.style.background = 'rgba(255,255,255,0.06)';
|
||||
e.target.style.boxShadow = '0 0 0 3px rgba(251,191,36,0.08)';
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.target.style.borderColor = 'rgba(251,191,36,0.06)';
|
||||
e.target.style.background = 'rgba(255,255,255,0.04)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: 24,
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(to bottom, #0a0a1a 0%, #0f1729 25%, #1a1035 50%, #2d1810 75%, #1a0f05 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Dawn light rays */}
|
||||
{rays.map((ray, i) => (
|
||||
<div
|
||||
key={`ray-${i}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
width: 120,
|
||||
height: `${ray.height}vh`,
|
||||
transform: `translateX(-50%) rotate(${ray.angle}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
background: `linear-gradient(to top, rgba(251,191,36,${ray.opacity}), transparent)`,
|
||||
clipPath: 'polygon(40% 100%, 60% 100%, 55% 0%, 45% 0%)',
|
||||
opacity: ray.opacity,
|
||||
zIndex: 1,
|
||||
animation: `dawn-ray-pulse ${ray.duration}s ease-in-out infinite`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Horizon glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: -100,
|
||||
left: '-10%',
|
||||
width: '120%',
|
||||
height: 200,
|
||||
background: 'radial-gradient(ellipse at center, rgba(251,191,36,0.12), transparent 70%)',
|
||||
zIndex: 2,
|
||||
animation: 'dawn-horizon-glow 8s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stars — fading remnants of night */}
|
||||
{stars.map((star, i) => (
|
||||
<div
|
||||
key={`star-${i}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: star.top,
|
||||
left: star.left,
|
||||
width: star.size,
|
||||
height: star.size,
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
opacity: star.opacity,
|
||||
zIndex: 1,
|
||||
animation: `dawn-star-fade ${star.duration}s ease-in-out infinite`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Glassmorphism card */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
background: 'rgba(10,10,26,0.6)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(251,191,36,0.08)',
|
||||
borderRadius: 20,
|
||||
padding: '48px 36px',
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.4), 0 0 60px rgba(251,191,36,0.05)',
|
||||
}}
|
||||
>
|
||||
{/* Logo — 44x44 golden gradient with sunrise SVG icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 24px',
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<line x1="12" y1="2" x2="12" y2="5" />
|
||||
<line x1="12" y1="19" x2="12" y2="22" />
|
||||
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05" />
|
||||
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07" />
|
||||
<line x1="2" y1="12" x2="5" y2="12" />
|
||||
<line x1="19" y1="12" x2="22" y2="12" />
|
||||
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95" />
|
||||
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 style={{ textAlign: 'center', fontSize: 22, fontWeight: 600, color: '#fef3c7', marginBottom: 6 }}>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
<p style={{ textAlign: 'center', color: 'rgba(251,191,36,0.5)', fontSize: 14, marginBottom: 36 }}>
|
||||
守护每一次数据变更
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && (
|
||||
<div style={{ color: '#f87171', fontSize: 13, textAlign: 'center', padding: '10px 14px', background: 'rgba(248,113,113,0.08)', borderRadius: 10 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="dg-gradient-bg w-full"
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
|
||||
opacity: loading || !username || !password ? 0.5 : 1,
|
||||
marginTop: 8,
|
||||
transition: 'opacity 0.2s, transform 0.1s',
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
boxShadow: '0 4px 20px rgba(251,191,36,0.25)',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Dawn keyframe animations */}
|
||||
<style>{`
|
||||
@keyframes dawn-ray-pulse {
|
||||
0%, 100% { opacity: 0.08; }
|
||||
50% { opacity: 0.15; }
|
||||
}
|
||||
@keyframes dawn-horizon-glow {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes dawn-star-fade {
|
||||
0%, 100% { opacity: 0.1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(251,191,36,0.3);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
/* ── 动画关键帧 ── */
|
||||
const KEYFRAMES = `
|
||||
@keyframes star-twinkle {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@keyframes meteor-fly {
|
||||
0% { transform: translateX(0) translateY(0) rotate(-45deg); opacity: 1; }
|
||||
100% { transform: translateX(600px) translateY(600px) rotate(-45deg); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes planet-rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes float-particle {
|
||||
0%, 100% { transform: translateY(0) translateX(0); opacity: 0.4; }
|
||||
25% { transform: translateY(-20px) translateX(10px); opacity: 0.8; }
|
||||
50% { transform: translateY(-40px) translateX(-5px); opacity: 0.5; }
|
||||
75% { transform: translateY(-20px) translateX(15px); opacity: 0.9; }
|
||||
}
|
||||
|
||||
@keyframes nebula-drift {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(30px, -20px) scale(1.05); }
|
||||
66% { transform: translate(-20px, 15px) scale(0.95); }
|
||||
}
|
||||
|
||||
@keyframes card-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 1px rgba(255,255,255,0.03), 0 24px 80px rgba(0,0,0,0.5), 0 0 60px rgba(79,70,229,0.08); }
|
||||
50% { box-shadow: 0 0 0 1px rgba(255,255,255,0.05), 0 24px 80px rgba(0,0,0,0.5), 0 0 80px rgba(139,92,246,0.12); }
|
||||
}
|
||||
|
||||
@keyframes shooting-star {
|
||||
0% { transform: translateX(0) translateY(0) rotate(-35deg) scaleX(1); opacity: 1; }
|
||||
70% { opacity: 0.6; }
|
||||
100% { transform: translateX(500px) translateY(500px) rotate(-35deg) scaleX(0.5); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
/* ── 类型 ── */
|
||||
interface Star {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
color: string;
|
||||
twinkle: boolean;
|
||||
delay: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/* ── 组件 ── */
|
||||
export default function CosmosView(): JSX.Element {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
/* 生成静态星星数据 */
|
||||
const stars = useMemo<Star[]>(() => {
|
||||
const colors = ['#ffffff', '#dbeafe', '#e0e7ff', '#f3e8ff', '#ede9fe'];
|
||||
return Array.from({ length: 45 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 2.5 + 0.5,
|
||||
opacity: Math.random() * 0.7 + 0.3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
twinkle: Math.random() > 0.4,
|
||||
delay: Math.random() * 5,
|
||||
duration: Math.random() * 2 + 1.5,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/* 生成漂浮粒子数据 */
|
||||
const particles = useMemo<Particle[]>(() => {
|
||||
return Array.from({ length: 15 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 2 + 1,
|
||||
delay: Math.random() * 6,
|
||||
duration: Math.random() * 4 + 4,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/* 输入框基础样式 */
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
padding: '0 16px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 12,
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
transition: 'all 0.25s ease',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const isFormValid = username.length > 0 && password.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '100vh',
|
||||
overflow: 'hidden',
|
||||
background: '#030508',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
||||
}}
|
||||
>
|
||||
<style>{KEYFRAMES}</style>
|
||||
|
||||
{/* ── 星云色块 ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-15%',
|
||||
width: '60%',
|
||||
height: '60%',
|
||||
background: 'radial-gradient(circle, rgba(59,130,246,0.15), transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(100px)',
|
||||
animation: 'nebula-drift 20s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-15%',
|
||||
right: '-10%',
|
||||
width: '55%',
|
||||
height: '55%',
|
||||
background: 'radial-gradient(circle, rgba(139,92,246,0.12), transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(100px)',
|
||||
animation: 'nebula-drift 25s ease-in-out infinite reverse',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '30%',
|
||||
right: '25%',
|
||||
width: '35%',
|
||||
height: '35%',
|
||||
background: 'radial-gradient(circle, rgba(236,72,153,0.08), transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(100px)',
|
||||
animation: 'nebula-drift 18s ease-in-out infinite 3s',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── 星星层 ── */}
|
||||
{stars.map((star) => (
|
||||
<div
|
||||
key={star.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${star.x}%`,
|
||||
top: `${star.y}%`,
|
||||
width: star.size,
|
||||
height: star.size,
|
||||
borderRadius: '50%',
|
||||
background: star.color,
|
||||
opacity: star.twinkle ? undefined : star.opacity,
|
||||
boxShadow: `0 0 ${star.size * 2}px ${star.color}`,
|
||||
animation: star.twinkle
|
||||
? `star-twinkle ${star.duration}s ease-in-out infinite ${star.delay}s`
|
||||
: 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── 流星 ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
left: '5%',
|
||||
width: 120,
|
||||
height: 2,
|
||||
background: 'linear-gradient(90deg, rgba(255,255,255,0.9), rgba(255,255,255,0))',
|
||||
borderRadius: '0 50% 50% 0',
|
||||
transform: 'rotate(-45deg)',
|
||||
animation: 'meteor-fly 4s ease-out infinite 2s',
|
||||
pointerEvents: 'none',
|
||||
filter: 'blur(0.5px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '15%',
|
||||
left: '25%',
|
||||
width: 80,
|
||||
height: 1.5,
|
||||
background: 'linear-gradient(90deg, rgba(200,220,255,0.8), rgba(255,255,255,0))',
|
||||
borderRadius: '0 50% 50% 0',
|
||||
transform: 'rotate(-35deg)',
|
||||
animation: 'shooting-star 5s ease-out infinite 7s',
|
||||
pointerEvents: 'none',
|
||||
filter: 'blur(0.3px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── 漂浮粒子星尘 ── */}
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={`p-${p.id}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(200, 210, 255, 0.5)',
|
||||
boxShadow: '0 0 4px rgba(200,210,255,0.3)',
|
||||
animation: `float-particle ${p.duration}s ease-in-out infinite ${p.delay}s`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── 行星装饰(左下角) ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '8%',
|
||||
left: '5%',
|
||||
width: 100,
|
||||
height: 100,
|
||||
animation: 'planet-rotate 60s linear infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* 行星本体 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle at 35% 35%, rgba(100,149,237,0.9), rgba(65,45,120,0.8), rgba(30,20,60,0.95))',
|
||||
boxShadow: 'inset -8px -8px 20px rgba(0,0,0,0.5), 0 0 20px rgba(100,149,237,0.2)',
|
||||
}}
|
||||
/>
|
||||
{/* 行星光环 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%) rotateX(75deg) rotateY(15deg)',
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid rgba(180,180,255,0.25)',
|
||||
boxShadow: '0 0 8px rgba(180,180,255,0.15)',
|
||||
}}
|
||||
/>
|
||||
{/* 内环 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%) rotateX(75deg) rotateY(15deg)',
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
border: '1px solid rgba(200,200,255,0.12)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 小行星(右上角装饰) ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12%',
|
||||
right: '12%',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle at 40% 40%, rgba(236,200,160,0.7), rgba(160,120,80,0.5))',
|
||||
boxShadow: 'inset -4px -4px 10px rgba(0,0,0,0.4), 0 0 15px rgba(236,200,160,0.15)',
|
||||
animation: 'float-particle 10s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── 登录卡片 ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
marginLeft: '18vw',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
padding: '48px 36px',
|
||||
background: 'rgba(5, 8, 16, 0.75)',
|
||||
backdropFilter: 'blur(30px)',
|
||||
WebkitBackdropFilter: 'blur(30px)',
|
||||
borderRadius: 24,
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
animation: 'card-glow 6s ease-in-out infinite',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* 品牌区域 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 36 }}>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, #1e3a8a, #7c3aed)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
boxShadow: '0 4px 20px rgba(79,70,229,0.3), inset 0 1px 0 rgba(255,255,255,0.15)',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
margin: '0 0 8px',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
margin: 0,
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
企业级数据库审核与合规管理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 表单区域 */}
|
||||
<form onSubmit={handleLogin}>
|
||||
{/* 用户名 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
marginBottom: 6,
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
style={inputStyle}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(139,92,246,0.4)';
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
marginBottom: 6,
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(139,92,246,0.4)';
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isFormValid || loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: isFormValid
|
||||
? 'linear-gradient(135deg, #4f46e5, #8b5cf6)'
|
||||
: 'rgba(255,255,255,0.08)',
|
||||
color: isFormValid ? '#fff' : 'rgba(255,255,255,0.3)',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: isFormValid && !loading ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
letterSpacing: '0.5px',
|
||||
boxShadow: isFormValid
|
||||
? '0 4px 20px rgba(79,70,229,0.35), inset 0 1px 0 rgba(255,255,255,0.15)'
|
||||
: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isFormValid && !loading) {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(79,70,229,0.45), inset 0 1px 0 rgba(255,255,255,0.2)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = isFormValid
|
||||
? '0 4px 20px rgba(79,46,229,0.35), inset 0 1px 0 rgba(255,255,255,0.15)'
|
||||
: 'none';
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: '#fff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
登录中...
|
||||
</span>
|
||||
) : (
|
||||
'登 录'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.1)',
|
||||
border: '1px solid rgba(239,68,68,0.15)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 底部版权 */}
|
||||
<p
|
||||
style={{
|
||||
marginTop: 28,
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
color: 'rgba(255,255,255,0.2)',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
© 2025 SQL审核平台 — 企业级数据库安全合规
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 全局旋转动画补充 */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
export default function FullImage() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen relative"
|
||||
style={{ padding: 24, overflow: 'hidden' }}
|
||||
>
|
||||
{/* Deep navy background */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
background: '#0a1628',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Star field — 30+ individual stars */}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1 }}>
|
||||
{/* Row 1 - top area */}
|
||||
<div style={{ position: 'absolute', top: '3%', left: '12%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 3s ease-in-out infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '5%', left: '28%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '2%', left: '45%', width: 3.5, height: 3.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 4s ease-in-out 1s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '8%', left: '62%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '4%', left: '78%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 5s ease-in-out 2s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '6%', left: '91%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))', animation: 'star-twinkle 4.2s ease-in-out 0.6s infinite' }} />
|
||||
|
||||
{/* Row 2 */}
|
||||
<div style={{ position: 'absolute', top: '14%', left: '7%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))' }} />
|
||||
<div style={{ position: 'absolute', top: '16%', left: '22%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))', animation: 'star-twinkle 4.5s ease-in-out 0.5s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '12%', left: '55%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '18%', left: '70%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))', animation: 'star-twinkle 3.5s ease-in-out 1.5s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '15%', left: '88%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
|
||||
|
||||
{/* Row 3 */}
|
||||
<div style={{ position: 'absolute', top: '25%', left: '5%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 5s ease-in-out 3s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '28%', left: '35%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.3))' }} />
|
||||
<div style={{ position: 'absolute', top: '22%', left: '50%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 4s ease-in-out 2s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '30%', left: '82%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
|
||||
|
||||
{/* Row 4 — mid area */}
|
||||
<div style={{ position: 'absolute', top: '38%', left: '15%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '42%', left: '48%', width: 3.5, height: 3.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 6s ease-in-out 1s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '36%', left: '75%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '44%', left: '92%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 3s ease-in-out 4s infinite' }} />
|
||||
|
||||
{/* Row 5 */}
|
||||
<div style={{ position: 'absolute', top: '55%', left: '8%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 4s ease-in-out 2.5s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '52%', left: '40%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '58%', left: '60%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 5.5s ease-in-out 0.8s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '50%', left: '85%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
|
||||
|
||||
{/* Row 6 — lower area */}
|
||||
<div style={{ position: 'absolute', top: '68%', left: '18%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '72%', left: '52%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 4.5s ease-in-out 3.5s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '65%', left: '72%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
|
||||
|
||||
{/* Row 7 — bottom area */}
|
||||
<div style={{ position: 'absolute', top: '80%', left: '10%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 3.5s ease-in-out 1.2s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '85%', left: '38%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '78%', left: '65%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 5s ease-in-out 0.3s infinite' }} />
|
||||
<div style={{ position: 'absolute', top: '88%', left: '82%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
|
||||
|
||||
{/* Row 8 — very bottom */}
|
||||
<div style={{ position: 'absolute', top: '93%', left: '25%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
|
||||
<div style={{ position: 'absolute', top: '95%', left: '58%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 4s ease-in-out 2.2s infinite' }} />
|
||||
</div>
|
||||
|
||||
{/* Constellation lines connecting adjacent stars */}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1 }}>
|
||||
{/* Line 1: top 3%/12% → 5%/28% → 2%/45% */}
|
||||
<div style={{ position: 'absolute', top: '4%', left: '12%', width: '34%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.18), rgba(167,139,250,0.1), transparent)', transform: 'rotate(-3deg)' }} />
|
||||
{/* Line 2: top 16%/22% → 12%/55% */}
|
||||
<div style={{ position: 'absolute', top: '13%', left: '22%', width: '35%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.15), rgba(167,139,250,0.08), transparent)', transform: 'rotate(-8deg)' }} />
|
||||
{/* Line 3: top 42%/48% → 36%/75% → 30%/82% */}
|
||||
<div style={{ position: 'absolute', top: '38%', left: '48%', width: '30%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.15), rgba(167,139,250,0.08), transparent)', transform: 'rotate(-12deg)' }} />
|
||||
{/* Line 4: top 55%/8% → 52%/40% */}
|
||||
<div style={{ position: 'absolute', top: '53%', left: '8%', width: '34%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.12), rgba(167,139,250,0.06), transparent)', transform: 'rotate(5deg)' }} />
|
||||
{/* Line 5: top 72%/52% → 78%/65% */}
|
||||
<div style={{ position: 'absolute', top: '74%', left: '52%', width: '15%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.12), rgba(167,139,250,0.05), transparent)', transform: 'rotate(20deg)' }} />
|
||||
{/* Line 6 (vertical): top 5%/28% → 16%/22% */}
|
||||
<div style={{ position: 'absolute', top: '5%', left: '27%', width: 1, height: '12%', background: 'linear-gradient(180deg, rgba(147,197,253,0.12), rgba(167,139,250,0.05), transparent)', transform: 'rotate(5deg)' }} />
|
||||
</div>
|
||||
|
||||
{/* Large pulsating glow orb — top right (blue-purple) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '-15%',
|
||||
right: '-8%',
|
||||
width: 500,
|
||||
height: 500,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(99,102,241,0.25) 0%, rgba(168,85,247,0.1) 40%, transparent 70%)',
|
||||
zIndex: 1,
|
||||
filter: 'blur(80px)',
|
||||
animation: 'star-glow-pulse 8s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Smaller warm glow orb — bottom left */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '-10%',
|
||||
left: '-5%',
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(249,115,22,0.12) 0%, transparent 70%)',
|
||||
zIndex: 1,
|
||||
filter: 'blur(60px)',
|
||||
opacity: 0.08,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center card */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
background: 'rgba(10, 22, 40, 0.7)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: 16,
|
||||
padding: '40px 32px',
|
||||
boxShadow: '0 0 0 1px rgba(255,255,255,0.06), 0 8px 40px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{/* Small brand mark */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6366f1, #a855f7)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff', marginBottom: 4, textAlign: 'center' }}>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
<p style={{ color: 'rgba(255,255,255,0.4)', fontSize: 13, textAlign: 'center' }}>
|
||||
登录您的工作区
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{error && (
|
||||
<div style={{ color: '#f87171', fontSize: 13, textAlign: 'center', padding: '10px 14px', background: 'rgba(248,113,113,0.06)', borderRadius: 10 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inset-style input: username */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.3s, box-shadow 0.3s',
|
||||
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = 'rgba(99,102,241,0.4)';
|
||||
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 3px rgba(99,102,241,0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'rgba(255,255,255,0.06)';
|
||||
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2)';
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Inset-style input: password */}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.3s, box-shadow 0.3s',
|
||||
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = 'rgba(99,102,241,0.4)';
|
||||
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 3px rgba(99,102,241,0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'rgba(255,255,255,0.06)';
|
||||
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2)';
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Full-width submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="dg-gradient-bg w-full"
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
|
||||
opacity: loading || !username || !password ? 0.5 : 1,
|
||||
marginTop: 4,
|
||||
transition: 'opacity 0.2s, transform 0.1s',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Keyframe animations */}
|
||||
<style>{`
|
||||
@keyframes star-twinkle {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes star-glow-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.7; }
|
||||
50% { transform: scale(1.1); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* KEYFRAMES */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const KEYFRAMES = `
|
||||
@keyframes mesh-drift-1 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(30px, 20px); }
|
||||
}
|
||||
@keyframes mesh-drift-2 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-25px, -30px); }
|
||||
}
|
||||
@keyframes mesh-drift-3 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(20px, -25px); }
|
||||
}
|
||||
@keyframes mesh-drift-4 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-30px, 20px); }
|
||||
}
|
||||
@keyframes mesh-drift-5 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(25px, 30px); }
|
||||
}
|
||||
@keyframes card-glow {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
@keyframes input-glow-pulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* COMPONENT */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function GradientMeshView(): React.JSX.Element {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
const [focusedField, setFocusedField] = useState<string | null>(null);
|
||||
const [hoverBtn, setHoverBtn] = useState(false);
|
||||
|
||||
const isReady = username.trim().length > 0 && password.trim().length > 0;
|
||||
|
||||
/* ---- gradient mesh blobs ---- */
|
||||
const blobs: React.CSSProperties[] = [
|
||||
{
|
||||
position: 'fixed', top: '-100px', left: '-100px',
|
||||
width: '600px', height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.3), transparent 70%)',
|
||||
filter: 'blur(80px)', pointerEvents: 'none',
|
||||
animation: 'mesh-drift-1 12s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
position: 'fixed', bottom: '-80px', right: '-80px',
|
||||
width: '500px', height: '500px',
|
||||
background: 'radial-gradient(circle, rgba(244, 63, 94, 0.2), transparent 70%)',
|
||||
filter: 'blur(90px)', pointerEvents: 'none',
|
||||
animation: 'mesh-drift-2 10s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
position: 'fixed', top: '40%', right: '10%',
|
||||
width: '400px', height: '400px',
|
||||
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.15), transparent 70%)',
|
||||
filter: 'blur(100px)', pointerEvents: 'none',
|
||||
animation: 'mesh-drift-3 14s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
position: 'fixed', bottom: '5%', left: '5%',
|
||||
width: '450px', height: '450px',
|
||||
background: 'radial-gradient(circle, rgba(245, 158, 11, 0.1), transparent 70%)',
|
||||
filter: 'blur(85px)', pointerEvents: 'none',
|
||||
animation: 'mesh-drift-4 11s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
position: 'fixed', top: '-50px', right: '20%',
|
||||
width: '550px', height: '550px',
|
||||
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.2), transparent 70%)',
|
||||
filter: 'blur(95px)', pointerEvents: 'none',
|
||||
animation: 'mesh-drift-5 13s ease-in-out infinite',
|
||||
},
|
||||
];
|
||||
|
||||
/* ---- input base style ---- */
|
||||
const inputBase: React.CSSProperties = {
|
||||
width: '100%',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: 12,
|
||||
padding: '14px 18px',
|
||||
color: '#fff',
|
||||
fontSize: 15,
|
||||
outline: 'none',
|
||||
transition: 'all 0.25s ease',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const getInputStyle = (field: string): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
borderColor: focusedField === field ? 'rgba(99,102,241,0.5)' : 'rgba(255,255,255,0.06)',
|
||||
background: focusedField === field ? 'rgba(255,255,255,0.07)' : 'rgba(255,255,255,0.04)',
|
||||
});
|
||||
|
||||
/* ---- card top accent line ---- */
|
||||
const topAccent: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0, left: 24, right: 24,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: 'linear-gradient(90deg, #6366f1, #f43f5e, #10b981)',
|
||||
opacity: 0.6,
|
||||
};
|
||||
|
||||
/* ---- subtle glow inside card bottom ---- */
|
||||
const innerGlow: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: -60, left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '70%', height: 120,
|
||||
background: 'radial-gradient(ellipse, rgba(99,102,241,0.08), transparent 70%)',
|
||||
filter: 'blur(40px)',
|
||||
pointerEvents: 'none',
|
||||
animation: 'card-glow 8s ease-in-out infinite',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{KEYFRAMES}</style>
|
||||
|
||||
{/* ===================== ROOT CONTAINER ===================== */}
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#0a0a0f',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* ---- gradient mesh blobs ---- */}
|
||||
{blobs.map((b, i) => (
|
||||
<div key={i} style={b} />
|
||||
))}
|
||||
|
||||
{/* ---- subtle dot-grid texture ---- */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.03,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, rgba(255,255,255,0.8) 1px, transparent 1px)',
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ===================== LOGIN CARD ===================== */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
margin: '0 20px',
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 24,
|
||||
padding: '48px 36px',
|
||||
boxShadow:
|
||||
'0 0 0 1px rgba(255,255,255,0.04), 0 25px 50px -12px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* top accent line */}
|
||||
<div style={topAccent} />
|
||||
|
||||
{/* inner glow */}
|
||||
<div style={innerGlow} />
|
||||
|
||||
{/* ---- brand ---- */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 36 }}>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, #6366f1, #f43f5e)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.5px',
|
||||
margin: '0 0 8px 0',
|
||||
}}
|
||||
>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
fontSize: 14,
|
||||
letterSpacing: '0.3px',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
企业级数据库审核与合规管理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ---- form ---- */}
|
||||
<form
|
||||
onSubmit={handleLogin}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
|
||||
>
|
||||
{/* username */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onFocus={() => setFocusedField('username')}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
style={getInputStyle('username')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
{focusedField === 'username' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: -1,
|
||||
borderRadius: 13,
|
||||
background: 'radial-gradient(ellipse at center, rgba(99,102,241,0.12), transparent 70%)',
|
||||
filter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
animation: 'input-glow-pulse 3s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* password */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onFocus={() => setFocusedField('password')}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
style={getInputStyle('password')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{focusedField === 'password' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: -1,
|
||||
borderRadius: 13,
|
||||
background: 'radial-gradient(ellipse at center, rgba(99,102,241,0.12), transparent 70%)',
|
||||
filter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
animation: 'input-glow-pulse 3s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
color: '#f87171',
|
||||
borderRadius: 10,
|
||||
padding: '10px 14px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isReady || loading}
|
||||
onMouseEnter={() => setHoverBtn(true)}
|
||||
onMouseLeave={() => setHoverBtn(false)}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: 4,
|
||||
padding: '14px 24px',
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
cursor: isReady && !loading ? 'pointer' : 'not-allowed',
|
||||
opacity: isReady ? 1 : 0.5,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%)',
|
||||
boxShadow: hoverBtn && isReady && !loading
|
||||
? '0 8px 32px rgba(99,102,241,0.45)'
|
||||
: '0 4px 20px rgba(99,102,241,0.3)',
|
||||
transform: hoverBtn && isReady && !loading ? 'translateY(-1px)' : 'translateY(0)',
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* ---- copyright ---- */}
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'rgba(255,255,255,0.2)',
|
||||
fontSize: 12,
|
||||
margin: '28px 0 0 0',
|
||||
}}
|
||||
>
|
||||
© 2025 SQL审核平台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import React from 'react';
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* KEYFRAMES */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const KEYFRAMES = `
|
||||
@keyframes ink-spread {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.45;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mist-flow {
|
||||
0% {
|
||||
transform: translateX(-8%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(4%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-8%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gold-shimmer {
|
||||
0%, 100% {
|
||||
opacity: 0.35;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scaleX(1.06);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function InkWashView(): JSX.Element {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{KEYFRAMES}</style>
|
||||
|
||||
{/* ===================== 全屏容器 ===================== */}
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f0e8',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* ────────── 水墨晕染装饰 ────────── */}
|
||||
{/* 左上角淡墨点 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8%',
|
||||
left: '-6%',
|
||||
width: '50vw',
|
||||
height: '50vh',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(ellipse, rgba(160, 150, 135, 0.25) 0%, transparent 70%)',
|
||||
filter: 'blur(80px)',
|
||||
animation: 'ink-spread 10s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* 右下角浓墨点 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-10%',
|
||||
right: '-8%',
|
||||
width: '55vw',
|
||||
height: '55vh',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(ellipse, rgba(80, 72, 60, 0.35) 0%, transparent 70%)',
|
||||
filter: 'blur(90px)',
|
||||
animation: 'ink-spread 14s ease-in-out infinite reverse',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* 中央偏上淡淡墨痕 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '18%',
|
||||
left: '35%',
|
||||
width: '30vw',
|
||||
height: '20vh',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(ellipse, rgba(140, 130, 115, 0.15) 0%, transparent 70%)',
|
||||
filter: 'blur(70px)',
|
||||
animation: 'ink-spread 12s ease-in-out infinite 2s',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 远山轮廓 ────────── */}
|
||||
{/* 最远山 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '40%',
|
||||
background: 'linear-gradient(to top, rgba(180, 175, 165, 0.4) 0%, transparent 60%)',
|
||||
borderRadius: '50% 50% 0 0 / 80px 80px 0 0',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* 中山 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '-10%',
|
||||
right: '-5%',
|
||||
height: '25%',
|
||||
background: 'linear-gradient(to top, rgba(120, 115, 108, 0.5) 0%, transparent 50%)',
|
||||
borderRadius: '40% 60% 0 0 / 60px 50px 0 0',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* 近山 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '-15%',
|
||||
right: '-15%',
|
||||
height: '15%',
|
||||
background: 'linear-gradient(to top, rgba(60, 58, 52, 0.6) 0%, transparent 40%)',
|
||||
borderRadius: '60% 40% 0 0 / 40px 30px 0 0',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 雾气效果 ────────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '8%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '22%',
|
||||
background: 'linear-gradient(to top, rgba(245, 240, 232, 0.85) 0%, rgba(245, 240, 232, 0.3) 50%, transparent 100%)',
|
||||
animation: 'mist-flow 18s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 金色点缀线(水面微光) ────────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '12%',
|
||||
left: '20%',
|
||||
width: '25%',
|
||||
height: '1px',
|
||||
background: '#c9a96e',
|
||||
opacity: 0.35,
|
||||
animation: 'gold-shimmer 6s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '15%',
|
||||
right: '18%',
|
||||
width: '18%',
|
||||
height: '1px',
|
||||
background: '#c9a96e',
|
||||
opacity: 0.3,
|
||||
animation: 'gold-shimmer 8s ease-in-out infinite 1.5s',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ────────── 竖排 SQL 背景装饰 ────────── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12%',
|
||||
right: '8%',
|
||||
writingMode: 'vertical-rl',
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
color: 'rgba(45, 42, 36, 0.04)',
|
||||
letterSpacing: 12,
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'Georgia, "Times New Roman", serif',
|
||||
}}
|
||||
>
|
||||
SQL
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '18%',
|
||||
left: '6%',
|
||||
writingMode: 'vertical-rl',
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: 'rgba(45, 42, 36, 0.03)',
|
||||
letterSpacing: 8,
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'Georgia, "Times New Roman", serif',
|
||||
}}
|
||||
>
|
||||
AUDIT
|
||||
</div>
|
||||
|
||||
{/* ===================== 登录卡片 ===================== */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
margin: '0 20px',
|
||||
marginTop: '-6vh',
|
||||
background: 'rgba(255, 253, 248, 0.85)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid rgba(201, 169, 110, 0.2)',
|
||||
borderRadius: 16,
|
||||
padding: '44px 36px',
|
||||
boxShadow: '0 4px 30px rgba(0,0,0,0.06), 0 0 0 1px rgba(255,255,255,0.8)',
|
||||
}}
|
||||
>
|
||||
{/* ──── 品牌区域 ──── */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #3a362e, #1f1d19)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 18,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#c9a96e"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: '#2d2a24',
|
||||
margin: '0 0 6px',
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
|
||||
{/* 副标题 */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: '#8a8478',
|
||||
margin: 0,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
企业级数据库审核与合规管理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ──── 中国风细线装饰(上方) ──── */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 24, gap: 8 }}>
|
||||
<div style={{ width: 32, height: 1, background: 'linear-gradient(to left, #c9a96e, transparent)' }} />
|
||||
<div style={{ width: 4, height: 4, borderRadius: '50%', background: '#c9a96e', opacity: 0.5 }} />
|
||||
<div style={{ width: 32, height: 1, background: 'linear-gradient(to right, #c9a96e, transparent)' }} />
|
||||
</div>
|
||||
|
||||
{/* ──── 表单 ──── */}
|
||||
<form onSubmit={handleLogin}>
|
||||
{/* 用户名 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#5a5448',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 44,
|
||||
padding: '0 14px',
|
||||
fontSize: 14,
|
||||
borderRadius: 10,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.3s, box-shadow 0.3s',
|
||||
background: '#faf8f4',
|
||||
border: '1px solid #e5e0d5',
|
||||
color: '#2d2a24',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#c9a96e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(201, 169, 110, 0.12)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e0d5';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#5a5448',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 44,
|
||||
padding: '0 14px',
|
||||
fontSize: 14,
|
||||
borderRadius: 10,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.3s, box-shadow 0.3s',
|
||||
background: '#faf8f4',
|
||||
border: '1px solid #e5e0d5',
|
||||
color: '#2d2a24',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#c9a96e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(201, 169, 110, 0.12)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e0d5';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
background: '#fef2f2',
|
||||
color: '#dc2626',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username || !password || loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 46,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
cursor: !username || !password || loading ? 'not-allowed' : 'pointer',
|
||||
opacity: !username || !password || loading ? 0.65 : 1,
|
||||
background: 'linear-gradient(135deg, #2d2a24, #4a453d)',
|
||||
transition: 'all 0.3s ease',
|
||||
letterSpacing: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (username && password && !loading) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #3a362e, #5a554d)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(45, 42, 36, 0.25)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #2d2a24, #4a453d)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* ──── 中国风细线装饰(下方) ──── */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 24, gap: 8 }}>
|
||||
<div style={{ width: 24, height: 1, background: 'linear-gradient(to left, #c9a96e, transparent)' }} />
|
||||
<div style={{ width: 3, height: 3, borderRadius: '50%', background: '#c9a96e', opacity: 0.4 }} />
|
||||
<div style={{ width: 24, height: 1, background: 'linear-gradient(to right, #c9a96e, transparent)' }} />
|
||||
</div>
|
||||
|
||||
{/* ──── 版权信息 ──── */}
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
color: '#a09a8e',
|
||||
marginTop: 20,
|
||||
marginBottom: 0,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
© SQL Audit Platform. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
/* ---------- Flowing curve configs ---------- */
|
||||
const CURVES = [
|
||||
{
|
||||
// Curve 1: Wide pink-to-purple ribbon, top area
|
||||
width: '140%', height: 320, top: '-5%', left: '-20%',
|
||||
background: 'linear-gradient(135deg, #ec4899 0%, #a855f7 40%, #6366f1 100%)',
|
||||
opacity: 0.12, blur: 60, rotate: -12,
|
||||
animation: 'curve-flow-1 20s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
// Curve 2: Thin bright pink ribbon, mid-upper
|
||||
width: '120%', height: 180, top: '15%', left: '-10%',
|
||||
background: 'linear-gradient(90deg, transparent 0%, #f472b6 30%, #c084fc 60%, transparent 100%)',
|
||||
opacity: 0.18, blur: 40, rotate: -8,
|
||||
animation: 'curve-flow-2 16s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
// Curve 3: Deep purple band, center
|
||||
width: '160%', height: 240, top: '35%', left: '-30%',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 50%, #2563eb 100%)',
|
||||
opacity: 0.10, blur: 70, rotate: 5,
|
||||
animation: 'curve-flow-3 24s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
// Curve 4: Bright cyan-pink accent, lower-mid
|
||||
width: '110%', height: 120, top: '55%', left: '-5%',
|
||||
background: 'linear-gradient(90deg, transparent 10%, #06b6d4 35%, #a855f7 65%, transparent 90%)',
|
||||
opacity: 0.15, blur: 35, rotate: -15,
|
||||
animation: 'curve-flow-4 14s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
// Curve 5: Soft pink glow, bottom
|
||||
width: '130%', height: 200, top: '70%', left: '-15%',
|
||||
background: 'linear-gradient(135deg, #db2777 0%, #9333ea 60%, #4f46e5 100%)',
|
||||
opacity: 0.08, blur: 80, rotate: 10,
|
||||
animation: 'curve-flow-5 22s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
// Curve 6: Thin bright line accent
|
||||
width: '100%', height: 3, top: '40%', left: '0%',
|
||||
background: 'linear-gradient(90deg, transparent 0%, #f472b6 20%, #c084fc 50%, #818cf8 80%, transparent 100%)',
|
||||
opacity: 0.35, blur: 8, rotate: -5,
|
||||
animation: 'curve-line-float 18s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
// Curve 7: Another thin accent line
|
||||
width: '90%', height: 2, top: '65%', left: '5%',
|
||||
background: 'linear-gradient(90deg, transparent 5%, #818cf8 30%, #e879f9 70%, transparent 95%)',
|
||||
opacity: 0.25, blur: 6, rotate: 8,
|
||||
animation: 'curve-line-float 15s ease-in-out infinite reverse',
|
||||
},
|
||||
];
|
||||
|
||||
const KEYFRAMES = `
|
||||
@keyframes curve-flow-1 {
|
||||
0%, 100% { transform: rotate(-12deg) translateX(0) scaleY(1); }
|
||||
33% { transform: rotate(-10deg) translateX(30px) scaleY(1.05); }
|
||||
66% { transform: rotate(-14deg) translateX(-20px) scaleY(0.95); }
|
||||
}
|
||||
@keyframes curve-flow-2 {
|
||||
0%, 100% { transform: rotate(-8deg) translateX(0); }
|
||||
50% { transform: rotate(-6deg) translateX(40px); }
|
||||
}
|
||||
@keyframes curve-flow-3 {
|
||||
0%, 100% { transform: rotate(5deg) translateX(0) scaleX(1); }
|
||||
40% { transform: rotate(3deg) translateX(-30px) scaleX(1.02); }
|
||||
70% { transform: rotate(7deg) translateX(20px) scaleX(0.98); }
|
||||
}
|
||||
@keyframes curve-flow-4 {
|
||||
0%, 100% { transform: rotate(-15deg) translateX(0); }
|
||||
50% { transform: rotate(-13deg) translateX(50px); }
|
||||
}
|
||||
@keyframes curve-flow-5 {
|
||||
0%, 100% { transform: rotate(10deg) translateX(0); }
|
||||
50% { transform: rotate(8deg) translateX(-40px); }
|
||||
}
|
||||
@keyframes curve-line-float {
|
||||
0%, 100% { transform: rotate(-5deg) translateX(0); opacity: 0.3; }
|
||||
25% { opacity: 0.5; }
|
||||
50% { transform: rotate(-3deg) translateX(30px); opacity: 0.35; }
|
||||
75% { opacity: 0.5; }
|
||||
}
|
||||
@keyframes curve-dot-pulse {
|
||||
0%, 100% { opacity: 0.15; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(1.5); }
|
||||
}
|
||||
`;
|
||||
|
||||
/* ---------- Decorative dots ---------- */
|
||||
const DOTS = [
|
||||
{ top: '18%', left: '75%', size: 4, color: '#f472b6', delay: '0s' },
|
||||
{ top: '30%', left: '20%', size: 3, color: '#818cf8', delay: '2s' },
|
||||
{ top: '72%', left: '80%', size: 5, color: '#c084fc', delay: '1s' },
|
||||
{ top: '85%', left: '30%', size: 3, color: '#ec4899', delay: '3s' },
|
||||
{ top: '45%', left: '90%', size: 4, color: '#a855f7', delay: '1.5s' },
|
||||
];
|
||||
|
||||
export default function MinimalDark() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{KEYFRAMES}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: 24,
|
||||
background: 'linear-gradient(160deg, #0a0a1a 0%, #0f0a2a 30%, #150825 60%, #0a0a1a 100%)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* -------- Background glow orbs -------- */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: '-20%', right: '-10%', width: 500, height: 500,
|
||||
borderRadius: '50%', background: 'radial-gradient(circle, rgba(168,85,247,0.08), transparent 70%)',
|
||||
filter: 'blur(40px)', pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', bottom: '-15%', left: '-5%', width: 400, height: 400,
|
||||
borderRadius: '50%', background: 'radial-gradient(circle, rgba(236,72,153,0.06), transparent 70%)',
|
||||
filter: 'blur(40px)', pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* -------- Flowing curves -------- */}
|
||||
{CURVES.map((c, i) => (
|
||||
<div
|
||||
key={`curve-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: c.top, left: c.left,
|
||||
width: c.width, height: c.height,
|
||||
background: c.background,
|
||||
opacity: c.opacity,
|
||||
filter: `blur(${c.blur}px)`,
|
||||
borderRadius: '50%',
|
||||
animation: c.animation,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* -------- Decorative dots -------- */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={`dot-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: d.top, left: d.left,
|
||||
width: d.size, height: d.size,
|
||||
borderRadius: '50%',
|
||||
background: d.color,
|
||||
animation: `curve-dot-pulse ${3 + i * 0.5}s ${d.delay} ease-in-out infinite`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* -------- Central content -------- */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%', maxWidth: 400, textAlign: 'center',
|
||||
position: 'relative', zIndex: 10,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
borderRadius: 24, padding: '48px 36px',
|
||||
boxShadow: '0 0 0 1px rgba(255,255,255,0.05), 0 8px 40px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{/* Brand logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, #ec4899, #a855f7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 24px',
|
||||
boxShadow: '0 4px 20px rgba(168,85,247,0.3)',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Brand name */}
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600, color: '#fff', marginBottom: 6, letterSpacing: '-0.3px' }}>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
<p style={{ color: 'rgba(255,255,255,0.4)', fontSize: 14, marginBottom: 36 }}>
|
||||
企业级数据库审核与合规管理
|
||||
</p>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && (
|
||||
<div style={{ color: '#f87171', fontSize: 13, textAlign: 'center', padding: '10px 14px', background: 'rgba(248,113,113,0.08)', borderRadius: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full"
|
||||
style={{
|
||||
padding: '13px 16px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: '#fff', fontSize: 14, outline: 'none',
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = 'rgba(168,85,247,0.4)'; e.target.style.background = 'rgba(255,255,255,0.08)'; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; e.target.style.background = 'rgba(255,255,255,0.05)'; }}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full"
|
||||
style={{
|
||||
padding: '13px 16px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: '#fff', fontSize: 14, outline: 'none',
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = 'rgba(168,85,247,0.4)'; e.target.style.background = 'rgba(255,255,255,0.08)'; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; e.target.style.background = 'rgba(255,255,255,0.05)'; }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="dg-gradient-bg w-full"
|
||||
style={{
|
||||
padding: '13px 20px', borderRadius: 10,
|
||||
border: 'none', color: '#fff', fontWeight: 600, fontSize: 15,
|
||||
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
|
||||
opacity: loading || !username || !password ? 0.5 : 1,
|
||||
marginTop: 4,
|
||||
transition: 'opacity 0.2s, transform 0.1s',
|
||||
boxShadow: '0 4px 20px rgba(168,85,247,0.25)',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import React from 'react';
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
const KEYFRAMES = `
|
||||
@keyframes neon-pulse {
|
||||
0%, 100% { opacity: 0.6; box-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 40px currentColor; }
|
||||
50% { opacity: 1; box-shadow: 0 0 20px currentColor, 0 0 40px currentColor, 0 0 80px currentColor; }
|
||||
}
|
||||
|
||||
@keyframes neon-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
3% { opacity: 0.4; }
|
||||
6% { opacity: 1; }
|
||||
7% { opacity: 0.2; }
|
||||
8% { opacity: 1; }
|
||||
92% { opacity: 1; }
|
||||
93% { opacity: 0.3; }
|
||||
94% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes rain-fall {
|
||||
0% { transform: translateY(-100vh) translateX(0); opacity: 0; }
|
||||
10% { opacity: 0.6; }
|
||||
90% { opacity: 0.6; }
|
||||
100% { transform: translateY(100vh) translateX(20px); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes grid-move {
|
||||
0% { transform: perspective(500px) rotateX(60deg) translateY(0); }
|
||||
100% { transform: perspective(500px) rotateX(60deg) translateY(40px); }
|
||||
}
|
||||
|
||||
@keyframes city-breathe {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(1.02); }
|
||||
}
|
||||
|
||||
@keyframes neon-glow-text {
|
||||
0%, 100% { text-shadow: 0 0 10px rgba(236, 72, 153, 0.5), 0 0 20px rgba(236, 72, 153, 0.3); }
|
||||
50% { text-shadow: 0 0 20px rgba(236, 72, 153, 0.8), 0 0 40px rgba(236, 72, 153, 0.5); }
|
||||
}
|
||||
`;
|
||||
|
||||
// Building data: [width, heightPercent, leftPercent, glowColor]
|
||||
const BUILDINGS: [number, number, number, string][] = [
|
||||
[40, 18, 0, 'rgba(236, 72, 153, 0.4)'],
|
||||
[55, 28, 4, 'rgba(168, 85, 247, 0.35)'],
|
||||
[35, 15, 9, 'rgba(6, 182, 212, 0.3)'],
|
||||
[70, 35, 12, 'rgba(236, 72, 153, 0.45)'],
|
||||
[45, 22, 19, 'rgba(168, 85, 247, 0.4)'],
|
||||
[50, 30, 23, 'rgba(6, 182, 212, 0.35)'],
|
||||
[30, 16, 28, 'rgba(236, 72, 153, 0.3)'],
|
||||
[80, 38, 31, 'rgba(168, 85, 247, 0.5)'],
|
||||
[42, 20, 39, 'rgba(6, 182, 212, 0.4)'],
|
||||
[60, 32, 43, 'rgba(236, 72, 153, 0.45)'],
|
||||
[38, 17, 49, 'rgba(168, 85, 247, 0.3)'],
|
||||
[75, 36, 52, 'rgba(6, 182, 212, 0.5)'],
|
||||
[48, 25, 60, 'rgba(236, 72, 153, 0.4)'],
|
||||
[55, 30, 65, 'rgba(168, 85, 247, 0.45)'],
|
||||
[33, 14, 70, 'rgba(6, 182, 212, 0.3)'],
|
||||
[85, 40, 73, 'rgba(236, 72, 153, 0.5)'],
|
||||
[44, 21, 82, 'rgba(168, 85, 247, 0.4)'],
|
||||
[58, 33, 86, 'rgba(6, 182, 212, 0.45)'],
|
||||
[36, 16, 92, 'rgba(236, 72, 153, 0.35)'],
|
||||
[50, 27, 95, 'rgba(168, 85, 247, 0.4)'],
|
||||
];
|
||||
|
||||
// Neon tube data: [topPercent, color, animationDelay, animationDuration]
|
||||
const NEON_TUBES: [number, string, string, string][] = [
|
||||
[22, '#ec4899', '0s', '3s'],
|
||||
[38, '#06b6d4', '1s', '4s'],
|
||||
[52, '#a855f7', '2s', '3.5s'],
|
||||
];
|
||||
|
||||
// Rain drop data: [leftPercent, delay, duration]
|
||||
const RAIN_DROPS: [number, string, string][] = [
|
||||
[5, '0s', '1.5s'],
|
||||
[15, '0.3s', '1.8s'],
|
||||
[25, '0.7s', '1.3s'],
|
||||
[35, '0.2s', '1.6s'],
|
||||
[45, '0.9s', '1.4s'],
|
||||
[55, '0.5s', '1.7s'],
|
||||
[65, '0.1s', '1.5s'],
|
||||
[75, '0.8s', '1.9s'],
|
||||
[85, '0.4s', '1.3s'],
|
||||
[95, '0.6s', '1.6s'],
|
||||
[10, '1.1s', '1.4s'],
|
||||
[30, '1.3s', '1.7s'],
|
||||
[50, '1.0s', '1.5s'],
|
||||
[70, '1.5s', '1.8s'],
|
||||
[90, '1.2s', '1.4s'],
|
||||
[20, '0.15s', '2.0s'],
|
||||
[40, '0.85s', '1.35s'],
|
||||
[60, '1.25s', '1.65s'],
|
||||
[80, '0.45s', '1.75s'],
|
||||
[8, '0.95s', '1.55s'],
|
||||
];
|
||||
|
||||
export default function NeonCityView(): JSX.Element {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{KEYFRAMES}</style>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(180deg, #0a0418 0%, #1a0b2e 40%, #0d0718 100%)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Grid floor background */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-60%',
|
||||
left: '-50%',
|
||||
width: '200%',
|
||||
height: '100%',
|
||||
backgroundImage:
|
||||
'linear-gradient(rgba(236, 72, 153, 0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(6, 182, 212, 0.04) 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px',
|
||||
animation: 'grid-move 8s linear infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Neon tubes */}
|
||||
{NEON_TUBES.map(([top, color, delay, duration], i) => (
|
||||
<div
|
||||
key={`neon-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${top}%`,
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
height: '2px',
|
||||
background: color,
|
||||
color: color,
|
||||
boxShadow: `0 0 10px ${color}, 0 0 20px ${color}, 0 0 40px ${color}`,
|
||||
animation: `neon-pulse ${duration} ease-in-out infinite ${delay}, neon-flicker ${duration} ease-in-out infinite ${delay}`,
|
||||
borderRadius: '1px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* City skyline buildings */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '40%',
|
||||
animation: 'city-breathe 6s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{BUILDINGS.map(([width, heightPct, leftPct, glowColor], i) => (
|
||||
<div
|
||||
key={`building-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: `${leftPct}%`,
|
||||
width: `${width}px`,
|
||||
height: `${heightPct}%`,
|
||||
background: 'rgba(5, 3, 15, 0.9)',
|
||||
borderRadius: '2px 2px 0 0',
|
||||
boxShadow: `0 -2px 10px ${glowColor}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rain drops */}
|
||||
{RAIN_DROPS.map(([left, delay, duration], i) => (
|
||||
<div
|
||||
key={`rain-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${left}%`,
|
||||
top: 0,
|
||||
width: '1px',
|
||||
height: '25px',
|
||||
background: 'linear-gradient(180deg, transparent, rgba(6, 182, 212, 0.3))',
|
||||
animation: `rain-fall ${duration} linear infinite ${delay}`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Fog layer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '35%',
|
||||
background: 'linear-gradient(180deg, transparent 0%, rgba(10, 4, 24, 0.6) 50%, rgba(10, 4, 24, 0.85) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '600px',
|
||||
height: '300px',
|
||||
background: 'radial-gradient(ellipse, rgba(168, 85, 247, 0.12) 0%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Login card */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
margin: '0 20px',
|
||||
background: 'rgba(10, 5, 25, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(236, 72, 153, 0.2)',
|
||||
borderRadius: 16,
|
||||
padding: '44px 36px',
|
||||
boxShadow: '0 0 20px rgba(236, 72, 153, 0.1), 0 8px 40px rgba(0,0,0,0.5)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Top neon line decoration */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
background: 'linear-gradient(90deg, #ec4899, #06b6d4, #a855f7, #ec4899)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'neon-pulse 3s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Brand area */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, #ec4899, #06b6d4)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
boxShadow: '0 0 15px rgba(236, 72, 153, 0.5), 0 0 30px rgba(6, 182, 212, 0.3)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
margin: '0 0 8px',
|
||||
letterSpacing: '0.5px',
|
||||
animation: 'neon-glow-text 3s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'rgba(6, 182, 212, 0.8)',
|
||||
margin: 0,
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
企业级数据库审核与合规管理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleLogin}>
|
||||
{/* Username input */}
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<label
|
||||
htmlFor="username"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
marginBottom: 8,
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
autoComplete="username"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 46,
|
||||
padding: '0 16px',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 10,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(236, 72, 153, 0.6)';
|
||||
e.currentTarget.style.boxShadow = '0 0 10px rgba(236, 72, 153, 0.2)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password input */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label
|
||||
htmlFor="password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
marginBottom: 8,
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 46,
|
||||
padding: '0 16px',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 10,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.6)';
|
||||
e.currentTarget.style.boxShadow = '0 0 10px rgba(6, 182, 212, 0.2)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
textShadow: '0 0 8px rgba(239, 68, 68, 0.4)',
|
||||
animation: 'neon-flicker 0.5s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username || !password || loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 48,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: 'linear-gradient(135deg, #ec4899, #06b6d4)',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
cursor: !username || !password || loading ? 'not-allowed' : 'pointer',
|
||||
opacity: !username || !password ? 0.6 : 1,
|
||||
boxShadow: '0 0 15px rgba(236, 72, 153, 0.4)',
|
||||
transition: 'all 0.3s ease',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (username && password && !loading) {
|
||||
e.currentTarget.style.boxShadow = '0 0 25px rgba(236, 72, 153, 0.6), 0 0 40px rgba(6, 182, 212, 0.3)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = '0 0 15px rgba(236, 72, 153, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
登录中...
|
||||
</span>
|
||||
) : (
|
||||
'登 录'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Copyright */}
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
marginTop: 28,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
© SQL审核平台 · 安全合规的数据库管理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
export default function SidePanel() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
/* 15 bioluminescent particles scattered across the deep-sea scene */
|
||||
const biolumParticles = [
|
||||
{ top: '8%', left: '20%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '0s', dur: '4s' },
|
||||
{ top: '15%', left: '65%', size: 5, color: 'rgba(20,184,166,0.3)', delay: '1.2s', dur: '5s' },
|
||||
{ top: '22%', left: '40%', size: 3, color: 'rgba(34,211,238,0.5)', delay: '0.5s', dur: '3.5s' },
|
||||
{ top: '28%', left: '12%', size: 6, color: 'rgba(6,182,212,0.4)', delay: '2.8s', dur: '4.5s' },
|
||||
{ top: '35%', left: '78%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '3.5s', dur: '3s' },
|
||||
{ top: '42%', left: '30%', size: 7, color: 'rgba(20,184,166,0.3)', delay: '1s', dur: '5.5s' },
|
||||
{ top: '48%', left: '55%', size: 3, color: 'rgba(34,211,238,0.5)', delay: '4.2s', dur: '4s' },
|
||||
{ top: '55%', left: '18%', size: 5, color: 'rgba(6,182,212,0.4)', delay: '0.8s', dur: '6s' },
|
||||
{ top: '60%', left: '70%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '5s', dur: '3.5s' },
|
||||
{ top: '67%', left: '45%', size: 8, color: 'rgba(20,184,166,0.3)', delay: '2s', dur: '4.5s' },
|
||||
{ top: '73%', left: '25%', size: 3, color: 'rgba(6,182,212,0.4)', delay: '6s', dur: '5s' },
|
||||
{ top: '78%', left: '85%', size: 5, color: 'rgba(34,211,238,0.5)', delay: '1.5s', dur: '3s' },
|
||||
{ top: '83%', left: '50%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '7s', dur: '4.5s' },
|
||||
{ top: '88%', left: '15%', size: 6, color: 'rgba(6,182,212,0.4)', delay: '3s', dur: '5.5s' },
|
||||
{ top: '92%', left: '60%', size: 3, color: 'rgba(20,184,166,0.3)', delay: '8s', dur: '3.5s' },
|
||||
];
|
||||
|
||||
/* 5 ocean wave layers */
|
||||
const waveLayers = [
|
||||
{ top: '20%', height: 80, border: 'border-bottom', borderColor: 'rgba(6,182,212,0.06)', anim: 'ocean-wave-1 12s ease-in-out infinite' },
|
||||
{ top: '30%', height: 100, border: 'border-bottom', borderColor: 'rgba(20,184,166,0.08)', anim: 'ocean-wave-2 15s ease-in-out infinite' },
|
||||
{ top: '45%', height: 120, border: 'border-top', borderColor: 'rgba(6,182,212,0.10)', anim: 'ocean-wave-3 18s ease-in-out infinite' },
|
||||
{ top: '60%', height: 90, border: 'border-bottom', borderColor: 'rgba(20,184,166,0.06)', anim: 'ocean-wave-4 14s ease-in-out infinite' },
|
||||
{ top: '75%', height: 70, border: 'border-top', borderColor: 'rgba(6,182,212,0.04)', anim: 'ocean-wave-5 20s ease-in-out infinite' },
|
||||
];
|
||||
|
||||
/* Feature pills */
|
||||
const featurePills = ['安全审核', '变更管控', '权限隔离'];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* ========== Left 40% — Deep Sea Scene ========== */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '40%',
|
||||
background: 'linear-gradient(180deg, #020810 0%, #041420 30%, #062030 60%, #041820 100%)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* --- Deep-sea light shafts --- */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '25%',
|
||||
width: 1,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(to bottom, rgba(6,182,212,0.08), transparent)',
|
||||
animation: 'ocean-lightshaft 5s ease-in-out 0s infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '55%',
|
||||
width: 2,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(to bottom, rgba(6,182,212,0.06), transparent)',
|
||||
animation: 'ocean-lightshaft 7s ease-in-out 2s infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '78%',
|
||||
width: 1,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(to bottom, rgba(20,184,166,0.05), transparent)',
|
||||
animation: 'ocean-lightshaft 4s ease-in-out 4s infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* --- 5 Wave layers --- */}
|
||||
{waveLayers.map((wave, i) => (
|
||||
<div
|
||||
key={`wave-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: wave.top,
|
||||
left: '-50%',
|
||||
width: '200%',
|
||||
height: wave.height,
|
||||
borderRadius: '50%',
|
||||
[wave.border]: `1px solid ${wave.borderColor}`,
|
||||
animation: wave.anim,
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* --- 15 Bioluminescent particles --- */}
|
||||
{biolumParticles.map((p, i) => (
|
||||
<div
|
||||
key={`biolum-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: p.top,
|
||||
left: p.left,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
borderRadius: '50%',
|
||||
background: p.color,
|
||||
boxShadow: `0 0 ${p.size * 2}px ${p.color}`,
|
||||
animation: `ocean-biolum-pulse ${p.dur} ease-in-out ${p.delay} infinite`,
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* --- Brand content (left-aligned, bottom section) --- */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
height: '100%',
|
||||
padding: '0 44px 56px',
|
||||
}}
|
||||
>
|
||||
{/* Main title */}
|
||||
<div style={{ fontSize: 42, fontWeight: 700, color: 'rgba(6,182,212,0.9)', marginBottom: 12, lineHeight: 1.2 }}>
|
||||
SQL审核平台
|
||||
</div>
|
||||
{/* Subtitle */}
|
||||
<div style={{ fontSize: 15, color: 'rgba(148,163,184,0.5)', marginBottom: 32 }}>
|
||||
让数据安全如深海般沉静
|
||||
</div>
|
||||
{/* Feature pills */}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{featurePills.map((pill) => (
|
||||
<span
|
||||
key={pill}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: 20,
|
||||
background: 'rgba(6,182,212,0.08)',
|
||||
color: 'rgba(6,182,212,0.5)',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{pill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Keyframes (ocean- prefix) --- */}
|
||||
<style>{`
|
||||
@keyframes ocean-wave-1 {
|
||||
0%, 100% { transform: translateX(-30px); }
|
||||
50% { transform: translateX(30px); }
|
||||
}
|
||||
@keyframes ocean-wave-2 {
|
||||
0%, 100% { transform: translateX(30px); }
|
||||
50% { transform: translateX(-30px); }
|
||||
}
|
||||
@keyframes ocean-wave-3 {
|
||||
0%, 100% { transform: translateX(-20px); }
|
||||
50% { transform: translateX(20px); }
|
||||
}
|
||||
@keyframes ocean-wave-4 {
|
||||
0%, 100% { transform: translateX(25px); }
|
||||
50% { transform: translateX(-25px); }
|
||||
}
|
||||
@keyframes ocean-wave-5 {
|
||||
0%, 100% { transform: translateX(-30px); }
|
||||
50% { transform: translateX(30px); }
|
||||
}
|
||||
|
||||
@keyframes ocean-biolum-pulse {
|
||||
0%, 100% { opacity: 0.1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(1.5); }
|
||||
}
|
||||
|
||||
@keyframes ocean-lightshaft {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
{/* ========== Right 60% — Login Form ========== */}
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: '60%', background: '#060e18' }}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleLogin}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '0 40px',
|
||||
}}
|
||||
>
|
||||
{/* Form title */}
|
||||
<h2 style={{ fontSize: 28, fontWeight: 700, marginBottom: 8, color: '#e2e8f0' }}>
|
||||
欢迎回来
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: 'rgba(148,163,184,0.5)', marginBottom: 40 }}>
|
||||
登录你的工作空间
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div style={{ color: '#f87171', marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username field */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
color: 'rgba(6,182,212,0.5)',
|
||||
marginBottom: 8,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full"
|
||||
style={{
|
||||
padding: '13px 16px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(6,30,50,0.6)',
|
||||
border: '1px solid rgba(6,182,212,0.08)',
|
||||
color: '#e2e8f0',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.3s, background 0.3s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = 'rgba(6,182,212,0.3)';
|
||||
e.target.style.background = 'rgba(6,30,50,0.8)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'rgba(6,182,212,0.08)';
|
||||
e.target.style.background = 'rgba(6,30,50,0.6)';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
color: 'rgba(6,182,212,0.5)',
|
||||
marginBottom: 8,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full"
|
||||
style={{
|
||||
padding: '13px 16px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(6,30,50,0.6)',
|
||||
border: '1px solid rgba(6,182,212,0.08)',
|
||||
color: '#e2e8f0',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.3s, background 0.3s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = 'rgba(6,182,212,0.3)';
|
||||
e.target.style.background = 'rgba(6,30,50,0.8)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'rgba(6,182,212,0.08)';
|
||||
e.target.style.background = 'rgba(6,30,50,0.6)';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="dg-gradient-bg w-full"
|
||||
style={{
|
||||
padding: '13px 20px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
|
||||
opacity: loading || !username || !password ? 0.5 : 1,
|
||||
transition: 'opacity 0.2s, transform 0.1s',
|
||||
background: 'linear-gradient(135deg, #06b6d4, #14b8a6)',
|
||||
boxShadow: '0 4px 20px rgba(6,182,212,0.2)',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
|
||||
/* ── Lattice node positions (percentage-based) ── */
|
||||
const LATTICE_NODES = [
|
||||
{ top: '15%', left: '12%', size: 5, delay: 0, duration: 2.5 },
|
||||
{ top: '15%', left: '42%', size: 4, delay: 0.6, duration: 3 },
|
||||
{ top: '15%', left: '72%', size: 5, delay: 1.2, duration: 2.8 },
|
||||
{ top: '30%', left: '12%', size: 4, delay: 0.3, duration: 3.2 },
|
||||
{ top: '30%', left: '42%', size: 6, delay: 0.9, duration: 2.6 },
|
||||
{ top: '30%', left: '72%', size: 4, delay: 1.5, duration: 3.5 },
|
||||
{ top: '45%', left: '12%', size: 5, delay: 0.2, duration: 2.4 },
|
||||
{ top: '45%', left: '42%', size: 4, delay: 1.1, duration: 3.8 },
|
||||
{ top: '45%', left: '72%', size: 5, delay: 0.7, duration: 2.9 },
|
||||
{ top: '60%', left: '12%', size: 4, delay: 1.4, duration: 3.1 },
|
||||
{ top: '60%', left: '42%', size: 5, delay: 0.4, duration: 2.7 },
|
||||
{ top: '60%', left: '72%', size: 4, delay: 1.0, duration: 3.4 },
|
||||
{ top: '75%', left: '27%', size: 6, delay: 0.5, duration: 2.3 },
|
||||
{ top: '75%', left: '57%', size: 5, delay: 1.3, duration: 3.6 },
|
||||
{ top: '85%', left: '42%', size: 4, delay: 0.8, duration: 2.2 },
|
||||
{ top: '22%', left: '27%', size: 4, delay: 1.6, duration: 3.3 },
|
||||
{ top: '22%', left: '57%', size: 5, delay: 0.1, duration: 2.1 },
|
||||
{ top: '52%', left: '27%', size: 4, delay: 1.7, duration: 4.0 },
|
||||
{ top: '52%', left: '57%', size: 5, delay: 0.35, duration: 2.5 },
|
||||
];
|
||||
|
||||
const INPUT_STYLE_LIGHT: React.CSSProperties = {
|
||||
padding: '12px 16px',
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 10,
|
||||
color: '#1e293b',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
transition: 'border-color 0.3s, box-shadow 0.3s',
|
||||
};
|
||||
|
||||
const LABEL_STYLE_LIGHT: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
marginBottom: 8,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
export default function SplitView() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen" style={{ fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
{/* ═══════════════════════════════════════════════════════════════
|
||||
Left Panel 55% — 晶格矩阵区
|
||||
═══════════════════════════════════════════════════════════════ */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
width: '55%',
|
||||
background: '#080c14',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 56px',
|
||||
}}
|
||||
>
|
||||
{/* ── Perspective Grid ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
perspective: '500px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Horizontal lines (11) */}
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
<div
|
||||
key={`h-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: `${8 + i * 8}%`,
|
||||
height: 1,
|
||||
background: 'rgba(0, 212, 170, 0.08)',
|
||||
transform: 'rotateX(60deg)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Vertical lines (11) */}
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
<div
|
||||
key={`v-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `${8 + i * 8}%`,
|
||||
width: 1,
|
||||
background: 'rgba(0, 212, 170, 0.08)',
|
||||
transform: 'rotateX(60deg)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Lattice Glow Nodes ── */}
|
||||
{LATTICE_NODES.map((n, i) => (
|
||||
<div
|
||||
key={`node-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: n.top,
|
||||
left: n.left,
|
||||
width: n.size,
|
||||
height: n.size,
|
||||
borderRadius: '50%',
|
||||
background: '#00d4aa',
|
||||
boxShadow: '0 0 8px rgba(0,212,170,0.6)',
|
||||
animation: `lattice-node-pulse ${n.duration}s ease-in-out ${n.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── Right-Bottom Glow Decoration ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -60,
|
||||
bottom: -60,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #00d4aa, transparent)',
|
||||
opacity: 0.06,
|
||||
animation: 'lattice-glow-drift 15s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Brand Content (centered-low, above grid) ── */}
|
||||
<div style={{ position: 'relative', zIndex: 10, maxWidth: 400 }}>
|
||||
{/* Logo — 48×48 gradient square with hexagon icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, #00d4aa, #0ea5e9)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 32,
|
||||
boxShadow: '0 4px 24px rgba(0,212,170,0.3)',
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L22 8.5V15.5L12 22L2 15.5V8.5L12 2Z" />
|
||||
<path d="M12 22V15.5" />
|
||||
<path d="M22 8.5L12 15.5L2 8.5" />
|
||||
<path d="M2 15.5L12 8.5L22 15.5" />
|
||||
<line x1="12" y1="2" x2="12" y2="8.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontSize: 36, fontWeight: 700, color: '#e2e8f0', marginBottom: 10, letterSpacing: '-0.5px', lineHeight: 1.2 }}>
|
||||
SQL审核平台
|
||||
</h1>
|
||||
<p style={{ color: 'rgba(148,163,184,0.7)', fontSize: 14, marginBottom: 24, lineHeight: 1.6 }}>
|
||||
数据安全的第一道防线
|
||||
</p>
|
||||
<p style={{ color: 'rgba(0,212,170,0.5)', fontSize: 12, letterSpacing: '0.5px' }}>
|
||||
实时审核 · 智能分析 · 合规管控
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Keyframes ── */}
|
||||
<style>{`
|
||||
@keyframes lattice-node-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
@keyframes lattice-glow-drift {
|
||||
0%, 100% {
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(30px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════
|
||||
Right Panel 45% — 登录表单区
|
||||
═══════════════════════════════════════════════════════════════ */}
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: '45%',
|
||||
background: '#ffffff',
|
||||
padding: 48,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleLogin}
|
||||
style={{ width: '100%', maxWidth: 360, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 600, color: '#1e293b', marginBottom: 8 }}>
|
||||
登录
|
||||
</h2>
|
||||
<p style={{ color: '#94a3b8', fontSize: 14, marginBottom: 40 }}>
|
||||
访问你的工作空间
|
||||
</p>
|
||||
|
||||
{error && <div style={{ color: '#ef4444', fontSize: 13, marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label style={LABEL_STYLE_LIGHT}>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full"
|
||||
style={INPUT_STYLE_LIGHT}
|
||||
onFocus={(e) => { e.target.style.borderColor = '#00d4aa'; e.target.style.boxShadow = '0 0 0 3px rgba(0,212,170,0.1)'; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = '#e2e8f0'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<label style={LABEL_STYLE_LIGHT}>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full"
|
||||
style={INPUT_STYLE_LIGHT}
|
||||
onFocus={(e) => { e.target.style.borderColor = '#00d4aa'; e.target.style.boxShadow = '0 0 0 3px rgba(0,212,170,0.1)'; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = '#e2e8f0'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="dg-gradient-bg w-full"
|
||||
style={{
|
||||
padding: '13px 20px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #00d4aa, #0ea5e9)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
|
||||
opacity: loading || !username || !password ? 0.5 : 1,
|
||||
boxShadow: '0 4px 20px rgba(0,212,170,0.2)',
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,155 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "p-4 gap-4 ",
|
||||
sm: "py-3 px-4 gap-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,274 @@
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants, type Button } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,188 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,21 @@
|
||||
export const menuSections = [
|
||||
{ id: 'overview', label: '概览' },
|
||||
{ id: 'data', label: '数据源' },
|
||||
{ id: 'ticket', label: '工单' },
|
||||
{ id: 'permission', label: '权限' },
|
||||
{ id: 'system', label: '系统' },
|
||||
];
|
||||
|
||||
export const menuItems = [
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard.view' },
|
||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'data', roles: ['admin', 'dba'], permission: 'instances.manage' },
|
||||
{ id: 'queries', label: '查询数据', icon: 'Code2', section: 'data', roles: ['admin', 'dba', 'developer'], permission: 'queries.execute' },
|
||||
{ id: 'tickets', label: '数据变更', icon: 'FileEdit', section: 'ticket', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'tickets.manage' },
|
||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'], permission: 'users.manage' },
|
||||
{ id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'permission', roles: ['admin'], permission: 'roles.manage' },
|
||||
{ id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'permission', roles: ['admin'], permission: 'instance-groups.manage' },
|
||||
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'], permission: 'workflow.manage' },
|
||||
{ id: 'audit-logs', label: '审计日志', icon: 'ScrollText', section: 'system', roles: ['admin', 'dba'], permission: 'audit-logs.view' },
|
||||
{ id: 'auto-task', label: '自动任务', icon: 'Timer', section: 'system', roles: ['admin'], permission: 'auto-task.manage' },
|
||||
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings.manage' },
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { menuItems } from '@/constants/menu';
|
||||
|
||||
const defaultUser = {
|
||||
id: 0,
|
||||
username: '',
|
||||
email: '',
|
||||
role: 'developer' as const,
|
||||
roleName: '',
|
||||
avatar: '',
|
||||
};
|
||||
import { authApi } from '@/api/auth';
|
||||
|
||||
export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor';
|
||||
|
||||
/** 细粒度权限类型 */
|
||||
export type PermissionAction =
|
||||
| 'ticket.create' | 'ticket.approve' | 'ticket.execute' | 'ticket.rollback' | 'ticket.delete'
|
||||
| 'query.create' | 'query.export'
|
||||
| 'instance.add' | 'instance.edit' | 'instance.delete'
|
||||
| 'user.manage' | 'role.manage' | 'rule.manage'
|
||||
| 'workflow.manage' | 'config.manage'
|
||||
| 'announcement.manage' | 'auto-task.manage' | 'instance-group.manage';
|
||||
|
||||
/** 数据源级权限 */
|
||||
export interface DataSourcePermission {
|
||||
instanceId: number;
|
||||
database?: string;
|
||||
tables?: string[];
|
||||
columns?: Record<string, string[]>;
|
||||
actions: ('read' | 'write' | 'delete' | 'export')[];
|
||||
}
|
||||
|
||||
/** 扩展的用户信息 */
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
roleName: string;
|
||||
avatar: string;
|
||||
/** 用户组ID列表 */
|
||||
groupIds?: number[];
|
||||
/** 细粒度数据源权限 */
|
||||
dataSourcePermissions?: DataSourcePermission[];
|
||||
/** 自定义权限(覆盖角色默认权限) */
|
||||
customPermissions?: Record<string, boolean>;
|
||||
/** 权限最后更新时间 */
|
||||
permissionsUpdatedAt?: number;
|
||||
}
|
||||
|
||||
/** 权限缓存结构 */
|
||||
interface PermissionCache {
|
||||
menu: Map<string, boolean>;
|
||||
action: Map<string, boolean>;
|
||||
dataSource: Map<string, boolean>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser;
|
||||
isLoggedIn: boolean;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
switchRole: (role: UserRole) => void;
|
||||
/** 菜单级权限检查(带缓存) */
|
||||
hasPermission: (menuId: string) => boolean;
|
||||
/** 操作级权限检查(带缓存) */
|
||||
hasActionPermission: (action: PermissionAction | string) => boolean;
|
||||
/** 数据源级权限检查 */
|
||||
hasDataSourcePermission: (
|
||||
instanceId: number,
|
||||
action: 'read' | 'write' | 'delete' | 'export',
|
||||
database?: string,
|
||||
table?: string
|
||||
) => boolean;
|
||||
/** 批量检查数据源权限 */
|
||||
checkDataSourcePermissions: (
|
||||
instanceId: number,
|
||||
actions: ('read' | 'write' | 'delete' | 'export')[],
|
||||
database?: string,
|
||||
table?: string
|
||||
) => Record<string, boolean>;
|
||||
/** 刷新用户权限 */
|
||||
refreshPermissions: () => Promise<void>;
|
||||
/** 清除权限缓存 */
|
||||
clearPermissionCache: () => void;
|
||||
visibleMenuItems: typeof menuItems;
|
||||
isAdmin: boolean;
|
||||
isDBA: boolean;
|
||||
isDeveloper: boolean;
|
||||
isGuest: boolean;
|
||||
/** 是否拥有指定角色或以上级别权限 */
|
||||
hasRoleLevel: (minRole: UserRole) => boolean;
|
||||
}
|
||||
|
||||
const roleNameMap: Record<UserRole, string> = {
|
||||
admin: '超级管理员',
|
||||
dba: '数据库管理员',
|
||||
developer: '开发人员',
|
||||
auditor: '审计员',
|
||||
};
|
||||
|
||||
/** 角色权重映射,用于权限级别比较 */
|
||||
const roleWeightMap: Record<UserRole, number> = {
|
||||
admin: 100,
|
||||
dba: 80,
|
||||
developer: 60,
|
||||
auditor: 40,
|
||||
};
|
||||
|
||||
const routePermissionMap: Record<string, UserRole[]> = {
|
||||
dashboard: ['admin', 'dba', 'developer', 'auditor'],
|
||||
instances: ['admin', 'dba'],
|
||||
'instance-groups': ['admin'],
|
||||
'data-dictionary': ['admin', 'dba', 'developer'],
|
||||
tickets: ['admin', 'dba', 'developer', 'auditor'],
|
||||
queries: ['admin', 'dba', 'developer'],
|
||||
roles: ['admin'],
|
||||
users: ['admin'],
|
||||
'audit-logs': ['admin', 'dba'],
|
||||
workflow: ['admin', 'dba'],
|
||||
'auto-task': ['admin'],
|
||||
settings: ['admin'],
|
||||
};
|
||||
|
||||
export const actionPermissionMap: Record<string, UserRole[]> = {
|
||||
'ticket.create': ['admin', 'dba', 'developer'],
|
||||
'ticket.approve': ['admin', 'dba'],
|
||||
'ticket.execute': ['admin', 'dba'],
|
||||
'ticket.rollback': ['admin', 'dba'],
|
||||
'ticket.delete': ['admin'],
|
||||
'query.create': ['admin', 'dba', 'developer'],
|
||||
'query.export': ['admin', 'dba', 'developer'],
|
||||
'instance.add': ['admin', 'dba'],
|
||||
'instance.edit': ['admin', 'dba'],
|
||||
'instance.delete': ['admin'],
|
||||
'user.manage': ['admin'],
|
||||
'role.manage': ['admin'],
|
||||
'rule.manage': ['admin', 'dba'],
|
||||
'workflow.manage': ['admin', 'dba'],
|
||||
'config.manage': ['admin'],
|
||||
'announcement.manage': ['admin'],
|
||||
'auto-task.manage': ['admin'],
|
||||
'instance-group.manage': ['admin'],
|
||||
};
|
||||
|
||||
/** 权限变更事件名称 */
|
||||
const PERMISSION_CHANGE_EVENT = 'auth:permissions_changed';
|
||||
/** 缓存有效期(毫秒) */
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser>({
|
||||
id: defaultUser.id,
|
||||
username: defaultUser.username,
|
||||
email: defaultUser.email,
|
||||
role: defaultUser.role,
|
||||
roleName: defaultUser.roleName,
|
||||
avatar: defaultUser.avatar,
|
||||
groupIds: [],
|
||||
dataSourcePermissions: [],
|
||||
customPermissions: {},
|
||||
});
|
||||
|
||||
// 从 token 恢复基本用户信息(页面刷新时)
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return false;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const role = payload.role as UserRole;
|
||||
if (role) {
|
||||
setUser(u => ({
|
||||
...u,
|
||||
id: Number(payload.sub) || 0,
|
||||
role,
|
||||
roleName: roleNameMap[role] || role,
|
||||
avatar: role.charAt(0).toUpperCase(),
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return true;
|
||||
});
|
||||
|
||||
/** 权限缓存引用 */
|
||||
const permissionCacheRef = useRef<PermissionCache>({
|
||||
menu: new Map(),
|
||||
action: new Map(),
|
||||
dataSource: new Map(),
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
/** 权限变更监听器引用 */
|
||||
const listenersRef = useRef<Set<(user: AuthUser) => void>>(new Set());
|
||||
|
||||
/** 检查缓存是否有效 */
|
||||
const isCacheValid = useCallback((): boolean => {
|
||||
return Date.now() - permissionCacheRef.current.timestamp < CACHE_TTL;
|
||||
}, []);
|
||||
|
||||
/** 清除权限缓存 */
|
||||
const clearPermissionCache = useCallback(() => {
|
||||
permissionCacheRef.current.menu.clear();
|
||||
permissionCacheRef.current.action.clear();
|
||||
permissionCacheRef.current.dataSource.clear();
|
||||
permissionCacheRef.current.timestamp = 0;
|
||||
}, []);
|
||||
|
||||
/** 触发权限变更事件 */
|
||||
const emitPermissionChange = useCallback((updatedUser: AuthUser) => {
|
||||
listenersRef.current.forEach(listener => listener(updatedUser));
|
||||
// 触发自定义事件,供其他组件监听
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: updatedUser }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 注册权限变更监听器 */
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'token') {
|
||||
if (!e.newValue) {
|
||||
setIsLoggedIn(false);
|
||||
clearPermissionCache();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [clearPermissionCache]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await authApi.login(username, password);
|
||||
localStorage.setItem('token', res.access_token);
|
||||
|
||||
const u = res.user;
|
||||
const role = u.role as UserRole;
|
||||
|
||||
// 解析后端返回的细粒度权限
|
||||
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
|
||||
? JSON.parse(u.data_source_permissions)
|
||||
: [];
|
||||
const customPermissions: Record<string, boolean> = u.custom_permissions
|
||||
? JSON.parse(u.custom_permissions)
|
||||
: {};
|
||||
|
||||
const newUser: AuthUser = {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email || '',
|
||||
role,
|
||||
roleName: roleNameMap[role] || role,
|
||||
avatar: u.display_name?.charAt(0) || username.charAt(0).toUpperCase(),
|
||||
groupIds: u.group_ids || [],
|
||||
dataSourcePermissions,
|
||||
customPermissions,
|
||||
permissionsUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
setUser(newUser);
|
||||
setIsLoggedIn(true);
|
||||
clearPermissionCache();
|
||||
emitPermissionChange(newUser);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [clearPermissionCache, emitPermissionChange]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
setIsLoggedIn(false);
|
||||
clearPermissionCache();
|
||||
setUser({
|
||||
id: defaultUser.id,
|
||||
username: defaultUser.username,
|
||||
email: defaultUser.email,
|
||||
role: defaultUser.role,
|
||||
roleName: defaultUser.roleName,
|
||||
avatar: defaultUser.avatar,
|
||||
groupIds: [],
|
||||
dataSourcePermissions: [],
|
||||
customPermissions: {},
|
||||
});
|
||||
emitPermissionChange({} as AuthUser);
|
||||
}, [clearPermissionCache, emitPermissionChange]);
|
||||
|
||||
const switchRole = useCallback((role: UserRole) => {
|
||||
setUser(prev => {
|
||||
const newUser: AuthUser = {
|
||||
...prev,
|
||||
role,
|
||||
roleName: roleNameMap[role],
|
||||
username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor',
|
||||
email: `${role}@company.com`,
|
||||
avatar: role.charAt(0).toUpperCase(),
|
||||
};
|
||||
clearPermissionCache();
|
||||
emitPermissionChange(newUser);
|
||||
return newUser;
|
||||
});
|
||||
}, [clearPermissionCache, emitPermissionChange]);
|
||||
|
||||
/** 菜单权限检查(带缓存) */
|
||||
const hasPermission = useCallback((menuId: string): boolean => {
|
||||
if (!isLoggedIn) return false;
|
||||
|
||||
const cache = permissionCacheRef.current;
|
||||
if (isCacheValid() && cache.menu.has(menuId)) {
|
||||
return cache.menu.get(menuId)!;
|
||||
}
|
||||
|
||||
const allowedRoles = routePermissionMap[menuId];
|
||||
if (!allowedRoles) {
|
||||
cache.menu.set(menuId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = allowedRoles.includes(user.role);
|
||||
cache.menu.set(menuId, result);
|
||||
cache.timestamp = Date.now();
|
||||
return result;
|
||||
}, [user.role, isLoggedIn, isCacheValid]);
|
||||
|
||||
/** 操作权限检查(带缓存,支持自定义权限覆盖) */
|
||||
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => {
|
||||
if (!isLoggedIn) return false;
|
||||
|
||||
const cache = permissionCacheRef.current;
|
||||
if (isCacheValid() && cache.action.has(action)) {
|
||||
return cache.action.get(action)!;
|
||||
}
|
||||
|
||||
// 优先检查自定义权限
|
||||
if (user.customPermissions && action in user.customPermissions) {
|
||||
const customResult = user.customPermissions[action];
|
||||
cache.action.set(action, customResult);
|
||||
cache.timestamp = Date.now();
|
||||
return customResult;
|
||||
}
|
||||
|
||||
const allowedRoles = actionPermissionMap[action];
|
||||
if (!allowedRoles) {
|
||||
cache.action.set(action, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = allowedRoles.includes(user.role);
|
||||
cache.action.set(action, result);
|
||||
cache.timestamp = Date.now();
|
||||
return result;
|
||||
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
|
||||
|
||||
/** 数据源级权限检查 */
|
||||
const hasDataSourcePermission = useCallback((
|
||||
instanceId: number,
|
||||
action: 'read' | 'write' | 'delete' | 'export',
|
||||
database?: string,
|
||||
table?: string
|
||||
): boolean => {
|
||||
if (!isLoggedIn) return false;
|
||||
if (user.role === 'admin') return true; // 管理员拥有所有数据源权限
|
||||
|
||||
const cache = permissionCacheRef.current;
|
||||
const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`;
|
||||
if (isCacheValid() && cache.dataSource.has(cacheKey)) {
|
||||
return cache.dataSource.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const perms = user.dataSourcePermissions || [];
|
||||
if (perms.length === 0) {
|
||||
cache.dataSource.set(cacheKey, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasPerm = perms.some((ds: DataSourcePermission) => {
|
||||
// 检查实例ID
|
||||
if (ds.instanceId !== instanceId) return false;
|
||||
|
||||
// 检查操作权限
|
||||
if (!ds.actions.includes(action)) return false;
|
||||
|
||||
// 检查数据库级别
|
||||
if (database && ds.database && ds.database !== database) return false;
|
||||
|
||||
// 检查表级别
|
||||
if (table && ds.tables && ds.tables.length > 0 && !ds.tables.includes(table)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
cache.dataSource.set(cacheKey, hasPerm);
|
||||
cache.timestamp = Date.now();
|
||||
return hasPerm;
|
||||
}, [user.dataSourcePermissions, user.role, isLoggedIn, isCacheValid]);
|
||||
|
||||
/** 批量检查数据源权限 */
|
||||
const checkDataSourcePermissions = useCallback((
|
||||
instanceId: number,
|
||||
actions: ('read' | 'write' | 'delete' | 'export')[],
|
||||
database?: string,
|
||||
table?: string
|
||||
): Record<string, boolean> => {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const action of actions) {
|
||||
result[action] = hasDataSourcePermission(instanceId, action, database, table);
|
||||
}
|
||||
return result;
|
||||
}, [hasDataSourcePermission]);
|
||||
|
||||
/** 从服务器刷新用户权限 */
|
||||
const refreshPermissions = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const u = await authApi.getMe();
|
||||
const role = u.role as UserRole;
|
||||
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
|
||||
? JSON.parse(u.data_source_permissions)
|
||||
: [];
|
||||
const customPermissions: Record<string, boolean> = u.custom_permissions
|
||||
? JSON.parse(u.custom_permissions)
|
||||
: {};
|
||||
|
||||
const newUser: AuthUser = {
|
||||
...user,
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email || '',
|
||||
role,
|
||||
roleName: roleNameMap[role] || role,
|
||||
groupIds: u.group_ids || [],
|
||||
dataSourcePermissions,
|
||||
customPermissions,
|
||||
permissionsUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
setUser(newUser);
|
||||
clearPermissionCache();
|
||||
emitPermissionChange(newUser);
|
||||
} catch (error) {
|
||||
console.error('刷新权限失败:', error);
|
||||
}
|
||||
}, [user, clearPermissionCache, emitPermissionChange]);
|
||||
|
||||
const visibleMenuItems = useMemo(() => {
|
||||
return menuItems.filter(item => item.roles.includes(user.role));
|
||||
}, [user.role]);
|
||||
|
||||
/** 检查是否拥有指定角色或以上级别权限 */
|
||||
const hasRoleLevel = useCallback((minRole: UserRole): boolean => {
|
||||
return roleWeightMap[user.role] >= roleWeightMap[minRole];
|
||||
}, [user.role]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
user,
|
||||
isLoggedIn,
|
||||
login,
|
||||
logout,
|
||||
switchRole,
|
||||
hasPermission,
|
||||
hasActionPermission,
|
||||
hasDataSourcePermission,
|
||||
checkDataSourcePermissions,
|
||||
refreshPermissions,
|
||||
clearPermissionCache,
|
||||
visibleMenuItems,
|
||||
isAdmin: user.role === 'admin',
|
||||
isDBA: user.role === 'dba',
|
||||
isDeveloper: user.role === 'developer',
|
||||
isGuest: user.role === 'auditor',
|
||||
hasRoleLevel,
|
||||
}), [user, isLoggedIn, login, logout, switchRole, hasPermission, hasActionPermission,
|
||||
hasDataSourcePermission, checkDataSourcePermissions, refreshPermissions,
|
||||
clearPermissionCache, visibleMenuItems, hasRoleLevel]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Hook: 检查操作权限(带缓存) */
|
||||
export function useActionPermission(action: PermissionAction | string): boolean {
|
||||
const { hasActionPermission } = useAuth();
|
||||
return hasActionPermission(action);
|
||||
}
|
||||
|
||||
/** Hook: 检查数据源权限 */
|
||||
export function useDataSourcePermission(
|
||||
instanceId: number,
|
||||
action: 'read' | 'write' | 'delete' | 'export',
|
||||
database?: string,
|
||||
table?: string
|
||||
): boolean {
|
||||
const { hasDataSourcePermission } = useAuth();
|
||||
return hasDataSourcePermission(instanceId, action, database, table);
|
||||
}
|
||||
|
||||
/** Hook: 监听权限变更 */
|
||||
export function usePermissionChange(callback: (user: AuthUser) => void): void {
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<AuthUser>;
|
||||
callback(customEvent.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(PERMISSION_CHANGE_EVENT, handleChange);
|
||||
return () => window.removeEventListener(PERMISSION_CHANGE_EVENT, handleChange);
|
||||
}, [callback]);
|
||||
|
||||
// 当user变化时也触发回调
|
||||
useEffect(() => {
|
||||
if (user && user.id) {
|
||||
callback(user);
|
||||
}
|
||||
}, [user.id, user.permissionsUpdatedAt, callback]);
|
||||
}
|
||||
|
||||
/** Hook: 检查角色级别 */
|
||||
export function useRoleLevel(minRole: UserRole): boolean {
|
||||
const { hasRoleLevel } = useAuth();
|
||||
return hasRoleLevel(minRole);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export function useFetchList<T>(fetchFn: () => Promise<T[]>, deps: unknown[] = []) {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fetchFnRef = useRef(fetchFn);
|
||||
fetchFnRef.current = fetchFn;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchFnRef
|
||||
.current()
|
||||
.then((res) => setData(res))
|
||||
.catch(() => setData([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return { data, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
|
||||
import api from '@/api/request';
|
||||
|
||||
export interface LoginState {
|
||||
username: string;
|
||||
password: string;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
setUsername: (v: string) => void;
|
||||
setPassword: (v: string) => void;
|
||||
handleLogin: (e: React.FormEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useLogin(): LoginState {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { setTheme } = useTheme();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const ok = await login(username, password);
|
||||
if (ok) {
|
||||
// Apply user preferences from backend (cross-browser sync)
|
||||
try {
|
||||
const prefs = await api.get<{ theme?: string; login_style?: string }>('/user/preferences');
|
||||
if (prefs?.theme) {
|
||||
const t = prefs.theme;
|
||||
if (t === 'dark' || t === 'light') {
|
||||
setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional');
|
||||
} else if (THEME_OPTIONS.some(o => o.id === t)) {
|
||||
setTheme(t as Theme);
|
||||
}
|
||||
}
|
||||
if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style);
|
||||
} catch { /* ignore */ }
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError('用户名或密码错误');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [username, password, login, navigate, setTheme]);
|
||||
|
||||
return { username, password, loading, error, setUsername, setPassword, handleLogin };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
|
||||
export type Theme = 'ocean-deep' | 'light-professional' | 'midnight-purple' | 'pine-green' | 'orange-vitality';
|
||||
|
||||
export interface ThemeOption {
|
||||
id: Theme;
|
||||
name: string;
|
||||
description: string;
|
||||
isDark: boolean;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: 'ocean-deep', name: '深海蓝', description: '专业稳重的深蓝色调', isDark: true, primaryColor: '#3b82f6' },
|
||||
{ id: 'light-professional', name: '明亮白', description: '清爽干净的浅色主题', isDark: false, primaryColor: '#2563eb' },
|
||||
{ id: 'midnight-purple', name: '午夜紫', description: '神秘优雅的紫色调', isDark: true, primaryColor: '#a855f7' },
|
||||
{ id: 'pine-green', name: '松林绿', description: '自然清新的绿色主题', isDark: true, primaryColor: '#22c55e' },
|
||||
{ id: 'orange-vitality', name: '活力橙', description: '温暖活力的橙色主题', isDark: true, primaryColor: '#f97316' },
|
||||
];
|
||||
|
||||
const THEME_ORDER: Theme[] = THEME_OPTIONS.map(o => o.id);
|
||||
const DEFAULT_THEME: Theme = 'ocean-deep';
|
||||
|
||||
// Backward compatibility: map old theme names to new ones
|
||||
function migrateLegacyTheme(value: string | null): Theme | null {
|
||||
if (value === 'dark') return 'ocean-deep';
|
||||
if (value === 'light') return 'light-professional';
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME;
|
||||
const stored = localStorage.getItem('dg-theme');
|
||||
const migrated = migrateLegacyTheme(stored);
|
||||
if (migrated) {
|
||||
// Persist the migrated value so we don't migrate every time
|
||||
localStorage.setItem('dg-theme', migrated);
|
||||
return migrated;
|
||||
}
|
||||
if (stored && THEME_ORDER.includes(stored as Theme)) {
|
||||
return stored as Theme;
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState(prev => {
|
||||
const idx = THEME_ORDER.indexOf(prev);
|
||||
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
|
||||
localStorage.setItem('dg-theme', next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
localStorage.setItem('dg-theme', t);
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
theme,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
}), [theme, toggleTheme, setTheme]);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextType {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
let _addToast: ((t: Toast) => void) | null = null;
|
||||
|
||||
export function toast(type: Toast['type'], message: string) {
|
||||
if (_addToast) {
|
||||
_addToast({ id: ++toastId, type, message });
|
||||
}
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
_addToast = useCallback((t: Toast) => {
|
||||
setToasts(prev => [...prev, t]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(x => x.id !== t.id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setToasts(prev => prev.filter(x => x.id !== id));
|
||||
}, []);
|
||||
|
||||
return { toasts, remove };
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* ===== Theme 1: Ocean Deep (深蓝专业) — Default ===== */
|
||||
:root {
|
||||
--background: 220 13% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 220 13% 7%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 220 13% 7%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 13% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 220 13% 14%;
|
||||
--muted-foreground: 215 20% 55%;
|
||||
--accent: 220 13% 14%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 18%;
|
||||
--input: 220 13% 14%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 220 13% 7%;
|
||||
--sidebar-foreground: 210 40% 90%;
|
||||
--sidebar-primary: 217 91% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 220 13% 14%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 220 13% 18%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
--dg-bg-primary: #0d1117;
|
||||
--dg-bg-secondary: #161b22;
|
||||
--dg-bg-tertiary: #21262d;
|
||||
--dg-bg-hover: #30363d;
|
||||
--dg-border: #30363d;
|
||||
--dg-border-light: #21262d;
|
||||
--dg-text-primary: #f0f6fc;
|
||||
--dg-text-secondary: #8b949e;
|
||||
--dg-text-muted: #6e7681;
|
||||
--dg-blue: #58a6ff;
|
||||
--dg-green: #3fb950;
|
||||
--dg-red: #f85149;
|
||||
--dg-orange: #f0883e;
|
||||
--dg-purple: #8957e5;
|
||||
--dg-cyan: #39c5cf;
|
||||
--dg-yellow: #d29922;
|
||||
--dg-card-hover-border: rgba(88, 166, 255, 0.5);
|
||||
--dg-gradient-start: #2563eb;
|
||||
--dg-gradient-end: #9333ea;
|
||||
--dg-gradient-text-start: #60a5fa;
|
||||
--dg-gradient-text-end: #c084fc;
|
||||
--dg-input-focus-ring: rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="ocean-deep"] {
|
||||
--background: 220 13% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 220 13% 7%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 220 13% 7%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 13% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 220 13% 14%;
|
||||
--muted-foreground: 215 20% 55%;
|
||||
--accent: 220 13% 14%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 18%;
|
||||
--input: 220 13% 14%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 220 13% 7%;
|
||||
--sidebar-foreground: 210 40% 90%;
|
||||
--sidebar-primary: 217 91% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 220 13% 14%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 220 13% 18%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
--dg-bg-primary: #0d1117;
|
||||
--dg-bg-secondary: #161b22;
|
||||
--dg-bg-tertiary: #21262d;
|
||||
--dg-bg-hover: #30363d;
|
||||
--dg-border: #30363d;
|
||||
--dg-border-light: #21262d;
|
||||
--dg-text-primary: #f0f6fc;
|
||||
--dg-text-secondary: #8b949e;
|
||||
--dg-text-muted: #6e7681;
|
||||
--dg-blue: #58a6ff;
|
||||
--dg-green: #3fb950;
|
||||
--dg-red: #f85149;
|
||||
--dg-orange: #f0883e;
|
||||
--dg-purple: #8957e5;
|
||||
--dg-cyan: #39c5cf;
|
||||
--dg-yellow: #d29922;
|
||||
--dg-card-hover-border: rgba(88, 166, 255, 0.5);
|
||||
--dg-gradient-start: #2563eb;
|
||||
--dg-gradient-end: #9333ea;
|
||||
--dg-gradient-text-start: #60a5fa;
|
||||
--dg-gradient-text-end: #c084fc;
|
||||
--dg-input-focus-ring: rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
/* ===== Theme 2: Light Professional (浅色商务) ===== */
|
||||
[data-theme="light-professional"],
|
||||
[data-theme="light"] {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 220 14% 10%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 14% 10%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 14% 10%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 220 14% 10%;
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 220 10% 46%;
|
||||
--accent: 220 14% 96%;
|
||||
--accent-foreground: 220 14% 10%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 220 14% 20%;
|
||||
--sidebar-primary: 217 91% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 220 14% 94%;
|
||||
--sidebar-accent-foreground: 220 14% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
--dg-bg-primary: #ffffff;
|
||||
--dg-bg-secondary: #f8f9fb;
|
||||
--dg-bg-tertiary: #f0f1f5;
|
||||
--dg-bg-hover: #e8eaef;
|
||||
--dg-border: #e2e5eb;
|
||||
--dg-border-light: #eaecf0;
|
||||
--dg-text-primary: #1a1d23;
|
||||
--dg-text-secondary: #5c6370;
|
||||
--dg-text-muted: #9ca3af;
|
||||
--dg-blue: #2563eb;
|
||||
--dg-green: #16a34a;
|
||||
--dg-red: #dc2626;
|
||||
--dg-orange: #ea580c;
|
||||
--dg-purple: #7c3aed;
|
||||
--dg-cyan: #0891b2;
|
||||
--dg-yellow: #ca8a04;
|
||||
--dg-card-hover-border: rgba(37, 99, 235, 0.3);
|
||||
--dg-gradient-start: #2563eb;
|
||||
--dg-gradient-end: #7c3aed;
|
||||
--dg-gradient-text-start: #1d4ed8;
|
||||
--dg-gradient-text-end: #6d28d9;
|
||||
--dg-input-focus-ring: rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
/* ===== Theme 3: Midnight Purple (午夜紫韵) ===== */
|
||||
[data-theme="midnight-purple"] {
|
||||
--background: 264 56% 4%;
|
||||
--foreground: 280 100% 98%;
|
||||
--card: 264 48% 8%;
|
||||
--card-foreground: 280 100% 98%;
|
||||
--popover: 264 48% 8%;
|
||||
--popover-foreground: 280 100% 98%;
|
||||
--primary: 270 91% 65%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 264 40% 20%;
|
||||
--secondary-foreground: 280 100% 98%;
|
||||
--muted: 264 40% 20%;
|
||||
--muted-foreground: 280 30% 60%;
|
||||
--accent: 264 40% 20%;
|
||||
--accent-foreground: 280 100% 98%;
|
||||
--destructive: 348 100% 72%;
|
||||
--destructive-foreground: 264 56% 4%;
|
||||
--border: 264 40% 20%;
|
||||
--input: 264 40% 20%;
|
||||
--ring: 270 91% 65%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 264 48% 6%;
|
||||
--sidebar-foreground: 280 100% 92%;
|
||||
--sidebar-primary: 270 91% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 264 40% 20%;
|
||||
--sidebar-accent-foreground: 280 100% 98%;
|
||||
--sidebar-border: 264 40% 20%;
|
||||
--sidebar-ring: 270 91% 65%;
|
||||
--dg-bg-primary: #0a0612;
|
||||
--dg-bg-secondary: #130c21;
|
||||
--dg-bg-tertiary: #1c1330;
|
||||
--dg-bg-hover: #2d1f4a;
|
||||
--dg-border: #2d1f4a;
|
||||
--dg-border-light: #1c1330;
|
||||
--dg-text-primary: #faf5ff;
|
||||
--dg-text-secondary: #b794c7;
|
||||
--dg-text-muted: #7c5a8a;
|
||||
--dg-blue: #818cf8;
|
||||
--dg-green: #34d399;
|
||||
--dg-red: #fb7185;
|
||||
--dg-orange: #fb923c;
|
||||
--dg-purple: #c084fc;
|
||||
--dg-cyan: #22d3ee;
|
||||
--dg-yellow: #facc15;
|
||||
--dg-card-hover-border: rgba(192, 132, 252, 0.5);
|
||||
--dg-gradient-start: #9333ea;
|
||||
--dg-gradient-end: #c084fc;
|
||||
--dg-gradient-text-start: #a855f7;
|
||||
--dg-gradient-text-end: #c084fc;
|
||||
--dg-input-focus-ring: rgba(192, 132, 252, 0.2);
|
||||
}
|
||||
|
||||
/* ===== Theme 4: Pine Green (松绿自然) ===== */
|
||||
[data-theme="pine-green"] {
|
||||
--background: 150 25% 5%;
|
||||
--foreground: 140 60% 97%;
|
||||
--card: 150 25% 9%;
|
||||
--card-foreground: 140 60% 97%;
|
||||
--popover: 150 25% 9%;
|
||||
--popover-foreground: 140 60% 97%;
|
||||
--primary: 142 71% 45%;
|
||||
--primary-foreground: 150 25% 5%;
|
||||
--secondary: 150 20% 18%;
|
||||
--secondary-foreground: 140 60% 97%;
|
||||
--muted: 150 20% 18%;
|
||||
--muted-foreground: 150 20% 55%;
|
||||
--accent: 150 20% 18%;
|
||||
--accent-foreground: 140 60% 97%;
|
||||
--destructive: 0 90% 72%;
|
||||
--destructive-foreground: 150 25% 5%;
|
||||
--border: 150 20% 18%;
|
||||
--input: 150 20% 18%;
|
||||
--ring: 142 71% 45%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 150 25% 7%;
|
||||
--sidebar-foreground: 140 40% 88%;
|
||||
--sidebar-primary: 142 71% 45%;
|
||||
--sidebar-primary-foreground: 150 25% 5%;
|
||||
--sidebar-accent: 150 20% 18%;
|
||||
--sidebar-accent-foreground: 140 60% 97%;
|
||||
--sidebar-border: 150 20% 18%;
|
||||
--sidebar-ring: 142 71% 45%;
|
||||
--dg-bg-primary: #0a1210;
|
||||
--dg-bg-secondary: #111f1a;
|
||||
--dg-bg-tertiary: #192c24;
|
||||
--dg-bg-hover: #253d32;
|
||||
--dg-border: #253d32;
|
||||
--dg-border-light: #192c24;
|
||||
--dg-text-primary: #f0fdf4;
|
||||
--dg-text-secondary: #86b8a0;
|
||||
--dg-text-muted: #5a7c6b;
|
||||
--dg-blue: #38bdf8;
|
||||
--dg-green: #4ade80;
|
||||
--dg-red: #f87171;
|
||||
--dg-orange: #fb923c;
|
||||
--dg-purple: #c084fc;
|
||||
--dg-cyan: #2dd4bf;
|
||||
--dg-yellow: #facc15;
|
||||
--dg-card-hover-border: rgba(74, 222, 128, 0.5);
|
||||
--dg-gradient-start: #059669;
|
||||
--dg-gradient-end: #10b981;
|
||||
--dg-gradient-text-start: #059669;
|
||||
--dg-gradient-text-end: #34d399;
|
||||
--dg-input-focus-ring: rgba(74, 222, 128, 0.2);
|
||||
}
|
||||
|
||||
/* ===== Theme 5: Orange Vitality (橙光活力) ===== */
|
||||
[data-theme="orange-vitality"] {
|
||||
--background: 30 35% 5%;
|
||||
--foreground: 30 100% 97%;
|
||||
--card: 30 30% 9%;
|
||||
--card-foreground: 30 100% 97%;
|
||||
--popover: 30 30% 9%;
|
||||
--popover-foreground: 30 100% 97%;
|
||||
--primary: 25 95% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 30 25% 18%;
|
||||
--secondary-foreground: 30 100% 97%;
|
||||
--muted: 30 25% 18%;
|
||||
--muted-foreground: 30 25% 55%;
|
||||
--accent: 30 25% 18%;
|
||||
--accent-foreground: 30 100% 97%;
|
||||
--destructive: 0 80% 70%;
|
||||
--destructive-foreground: 30 35% 5%;
|
||||
--border: 30 25% 18%;
|
||||
--input: 30 25% 18%;
|
||||
--ring: 25 95% 53%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 30 30% 7%;
|
||||
--sidebar-foreground: 30 80% 88%;
|
||||
--sidebar-primary: 25 95% 53%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 30 25% 18%;
|
||||
--sidebar-accent-foreground: 30 100% 97%;
|
||||
--sidebar-border: 30 25% 18%;
|
||||
--sidebar-ring: 25 95% 53%;
|
||||
--dg-bg-primary: #110d08;
|
||||
--dg-bg-secondary: #1d1610;
|
||||
--dg-bg-tertiary: #2b1f16;
|
||||
--dg-bg-hover: #3d2d20;
|
||||
--dg-border: #3d2d20;
|
||||
--dg-border-light: #2b1f16;
|
||||
--dg-text-primary: #fef7ed;
|
||||
--dg-text-secondary: #d4a574;
|
||||
--dg-text-muted: #9c7a54;
|
||||
--dg-blue: #60a5fa;
|
||||
--dg-green: #4ade80;
|
||||
--dg-red: #f87171;
|
||||
--dg-orange: #fb923c;
|
||||
--dg-purple: #a78bfa;
|
||||
--dg-cyan: #2dd4bf;
|
||||
--dg-yellow: #fcd34d;
|
||||
--dg-card-hover-border: rgba(251, 146, 60, 0.5);
|
||||
--dg-gradient-start: #ea580c;
|
||||
--dg-gradient-end: #f59e0b;
|
||||
--dg-gradient-text-start: #f97316;
|
||||
--dg-gradient-text-end: #fbbf24;
|
||||
--dg-input-focus-ring: rgba(251, 146, 60, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] antialiased;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
code, pre, .font-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.dg-card {
|
||||
@apply bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
.dg-card-hover {
|
||||
@apply transition-all duration-300 hover:-translate-y-0.5;
|
||||
}
|
||||
.dg-card-hover:hover {
|
||||
border-color: var(--dg-card-hover-border);
|
||||
}
|
||||
.dg-input {
|
||||
@apply bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:border-[var(--dg-blue)] focus:ring-1;
|
||||
}
|
||||
.dg-input:focus {
|
||||
--tw-ring-color: var(--dg-input-focus-ring);
|
||||
}
|
||||
.dg-text-muted {
|
||||
color: var(--dg-text-muted);
|
||||
}
|
||||
.dg-text-secondary {
|
||||
color: var(--dg-text-secondary);
|
||||
}
|
||||
.dg-gradient-text {
|
||||
background: linear-gradient(to right, var(--dg-gradient-text-start), var(--dg-gradient-text-end));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.dg-gradient-bg {
|
||||
background: linear-gradient(to right, var(--dg-gradient-start), var(--dg-gradient-end));
|
||||
}
|
||||
.dg-gradient-br {
|
||||
background: linear-gradient(to bottom right, var(--dg-gradient-start), var(--dg-gradient-end));
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: var(--dg-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dg-text-muted);
|
||||
}
|
||||
[data-theme="light-professional"] .scrollbar-thin::-webkit-scrollbar-thumb,
|
||||
[data-theme="light"] .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
}
|
||||
[data-theme="light-professional"] .scrollbar-thin::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="light"] .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* ===== Design System Utilities ===== */
|
||||
|
||||
/* Buttons */
|
||||
.dg-btn {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200;
|
||||
}
|
||||
.dg-btn-primary {
|
||||
@apply dg-btn dg-gradient-bg text-white hover:opacity-90 active:scale-[0.98];
|
||||
}
|
||||
.dg-btn-secondary {
|
||||
@apply dg-btn bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] active:scale-[0.98];
|
||||
}
|
||||
.dg-btn-ghost {
|
||||
@apply dg-btn text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)];
|
||||
}
|
||||
.dg-btn-danger {
|
||||
@apply dg-btn bg-red-500/10 text-red-400 hover:bg-red-500/20 active:scale-[0.98];
|
||||
}
|
||||
.dg-btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
}
|
||||
.dg-btn-lg {
|
||||
@apply px-6 py-3 text-base;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.dg-card-section {
|
||||
@apply dg-card p-5;
|
||||
}
|
||||
.dg-card-header {
|
||||
@apply text-sm font-semibold text-[var(--dg-text-primary)] mb-3;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.dg-badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium;
|
||||
}
|
||||
.dg-badge-blue {
|
||||
@apply dg-badge bg-blue-500/10 text-blue-400;
|
||||
}
|
||||
.dg-badge-green {
|
||||
@apply dg-badge bg-green-500/10 text-green-400;
|
||||
}
|
||||
.dg-badge-red {
|
||||
@apply dg-badge bg-red-500/10 text-red-400;
|
||||
}
|
||||
.dg-badge-yellow {
|
||||
@apply dg-badge bg-yellow-500/10 text-yellow-400;
|
||||
}
|
||||
.dg-badge-purple {
|
||||
@apply dg-badge bg-purple-500/10 text-purple-400;
|
||||
}
|
||||
.dg-badge-orange {
|
||||
@apply dg-badge bg-orange-500/10 text-orange-400;
|
||||
}
|
||||
.dg-badge-cyan {
|
||||
@apply dg-badge bg-cyan-500/10 text-cyan-400;
|
||||
}
|
||||
.dg-badge-muted {
|
||||
@apply dg-badge bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)];
|
||||
}
|
||||
|
||||
/* Spacing helpers */
|
||||
.dg-gap-section {
|
||||
@apply gap-6;
|
||||
}
|
||||
.dg-gap-card {
|
||||
@apply gap-4;
|
||||
}
|
||||
|
||||
/* Table rows */
|
||||
.dg-table-row {
|
||||
@apply flex items-center px-4 py-3 border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] transition-colors duration-150;
|
||||
}
|
||||
.dg-table-header {
|
||||
@apply flex items-center px-4 py-2.5 text-xs font-semibold text-[var(--dg-text-muted)] uppercase tracking-wider border-b border-[var(--dg-border)];
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.dg-status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
.dg-status-dot-green {
|
||||
@apply dg-status-dot bg-green-400;
|
||||
}
|
||||
.dg-status-dot-red {
|
||||
@apply dg-status-dot bg-red-400;
|
||||
}
|
||||
.dg-status-dot-yellow {
|
||||
@apply dg-status-dot bg-yellow-400;
|
||||
}
|
||||
.dg-status-dot-blue {
|
||||
@apply dg-status-dot bg-blue-400;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { opacity: 0; transform: translateX(-8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { PermissionNode } from '@/types';
|
||||
|
||||
export const permissionTreeData: PermissionNode[] = [
|
||||
{ id: 'dashboard', label: '仪表盘(查看统计、图表)' },
|
||||
{ id: 'datasource', label: '数据源管理', children: [
|
||||
{ id: 'mysql', label: 'MySQL 实例(查看、添加、编辑、删除)' },
|
||||
{ id: 'postgres', label: 'PostgreSQL 实例(查看、添加、编辑、删除)' },
|
||||
{ id: 'redis', label: 'Redis 实例(查看、添加、编辑、删除)' },
|
||||
{ id: 'mongo', label: 'MongoDB 实例(查看、添加、编辑、删除)' },
|
||||
]},
|
||||
{ id: 'tickets', label: '数据变更工单(创建、查看、审批、执行)', children: [
|
||||
{ id: 'ticket_create', label: '创建工单' },
|
||||
{ id: 'ticket_approve', label: '审批工单' },
|
||||
{ id: 'ticket_execute', label: '执行工单' },
|
||||
{ id: 'ticket_rollback', label: '回滚工单' },
|
||||
]},
|
||||
{ id: 'queries', label: '查询数据(查看日志、新建查询、导出)', children: [
|
||||
{ id: 'query_create', label: '执行查询' },
|
||||
{ id: 'query_export', label: '导出结果' },
|
||||
{ id: 'query_view_all', label: '查看全部用户查询' },
|
||||
]},
|
||||
{ id: 'permission', label: '权限管理', children: [
|
||||
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
|
||||
{ id: 'users', label: '用户管理(创建、编辑、禁用)' },
|
||||
{ id: 'instance-groups', label: '权限组管理(创建、编辑、分配)' },
|
||||
]},
|
||||
{ id: 'system', label: '系统配置', children: [
|
||||
{ id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' },
|
||||
{ id: 'audit-logs', label: '审计日志(查看操作记录)' },
|
||||
{ id: 'workflow', label: '审批流程(创建、编辑流程模板)' },
|
||||
{ id: 'auto-task', label: '自动任务' },
|
||||
{ id: 'announcement', label: '公告管理' },
|
||||
]},
|
||||
{ id: 'config', label: '配置平台', children: [
|
||||
{ id: 'config-platform', label: '全局配置(消息推送/LDAP/查询限制/工单规则)' },
|
||||
{ id: 'notifications', label: '通知渠道(配置 Webhook、测试)' },
|
||||
]},
|
||||
];
|
||||
|
||||
export const statusMap: Record<string, string> = {
|
||||
pending: '待审批',
|
||||
reviewing: '审批中',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
success: '执行成功',
|
||||
failed: '执行失败',
|
||||
rollback: '已回滚',
|
||||
};
|
||||
|
||||
export const statusColorMap: Record<string, string> = {
|
||||
pending: 'bg-yellow-500/15 text-yellow-500',
|
||||
reviewing: 'bg-blue-500/15 text-blue-400',
|
||||
approved: 'bg-green-500/15 text-green-400',
|
||||
rejected: 'bg-red-500/15 text-red-400',
|
||||
executing: 'bg-purple-500/15 text-purple-400',
|
||||
success: 'bg-green-500/15 text-green-400',
|
||||
failed: 'bg-red-500/15 text-red-400',
|
||||
rollback: 'bg-orange-500/15 text-orange-400',
|
||||
};
|
||||
|
||||
export const dbTypeColorMap: Record<string, string> = {
|
||||
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
|
||||
postgres: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
||||
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
|
||||
mongo: 'bg-[rgba(71,162,72,0.15)] text-[#66bb6a]',
|
||||
};
|
||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user