fastapi-sqlite-ui 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_sqlite_ui/__init__.py +16 -0
- fastapi_sqlite_ui/driver.py +174 -0
- fastapi_sqlite_ui/router.py +120 -0
- fastapi_sqlite_ui/ui.py +1131 -0
- fastapi_sqlite_ui-1.0.0.dist-info/METADATA +106 -0
- fastapi_sqlite_ui-1.0.0.dist-info/RECORD +8 -0
- fastapi_sqlite_ui-1.0.0.dist-info/WHEEL +4 -0
- fastapi_sqlite_ui-1.0.0.dist-info/licenses/LICENSE +21 -0
fastapi_sqlite_ui/ui.py
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
def get_html_template(base_path: str) -> str:
|
|
2
|
+
cleaned_base_path = base_path[:-1] if base_path.endswith('/') else base_path
|
|
3
|
+
|
|
4
|
+
html = r"""<!DOCTYPE html>
|
|
5
|
+
<html lang="en" class="h-full">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="UTF-8">
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
9
|
+
<title>SQLite Admin Dashboard</title>
|
|
10
|
+
<!-- Google Fonts: Inter -->
|
|
11
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
12
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
13
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
|
14
|
+
|
|
15
|
+
<!-- Tailwind CSS CDN -->
|
|
16
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
17
|
+
<script>
|
|
18
|
+
tailwind.config = {
|
|
19
|
+
darkMode: 'class',
|
|
20
|
+
theme: {
|
|
21
|
+
extend: {
|
|
22
|
+
fontFamily: {
|
|
23
|
+
sans: ['Inter', 'sans-serif'],
|
|
24
|
+
mono: ['Fira Code', 'monospace'],
|
|
25
|
+
},
|
|
26
|
+
colors: {
|
|
27
|
+
brand: {
|
|
28
|
+
50: '#f4f5fa',
|
|
29
|
+
100: '#eae9f5',
|
|
30
|
+
200: '#d7d4ee',
|
|
31
|
+
300: '#bab2e2',
|
|
32
|
+
400: '#9b8bd2',
|
|
33
|
+
500: '#7c65be',
|
|
34
|
+
600: '#684da7',
|
|
35
|
+
700: '#563e8a',
|
|
36
|
+
800: '#483473',
|
|
37
|
+
900: '#3d2d60',
|
|
38
|
+
950: '#251a3d',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<!-- Alpine.js CDN -->
|
|
47
|
+
<script defer src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js"></script>
|
|
48
|
+
|
|
49
|
+
<!-- Lucide Icons -->
|
|
50
|
+
<script src="https://unpkg.com/lucide@latest"></script>
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
body {
|
|
54
|
+
background-color: #0b0f19;
|
|
55
|
+
color: #f3f4f6;
|
|
56
|
+
}
|
|
57
|
+
/* Custom Scrollbar */
|
|
58
|
+
::-webkit-scrollbar {
|
|
59
|
+
width: 6px;
|
|
60
|
+
height: 6px;
|
|
61
|
+
}
|
|
62
|
+
::-webkit-scrollbar-track {
|
|
63
|
+
background: #0f172a;
|
|
64
|
+
}
|
|
65
|
+
::-webkit-scrollbar-thumb {
|
|
66
|
+
background: #334155;
|
|
67
|
+
border-radius: 3px;
|
|
68
|
+
}
|
|
69
|
+
::-webkit-scrollbar-thumb:hover {
|
|
70
|
+
background: #475569;
|
|
71
|
+
}
|
|
72
|
+
/* Glassmorphism Classes */
|
|
73
|
+
.glass-card {
|
|
74
|
+
background: rgba(17, 24, 39, 0.7);
|
|
75
|
+
backdrop-filter: blur(12px);
|
|
76
|
+
-webkit-backdrop-filter: blur(12px);
|
|
77
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
78
|
+
}
|
|
79
|
+
.glass-input {
|
|
80
|
+
background: rgba(31, 41, 55, 0.5);
|
|
81
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
82
|
+
}
|
|
83
|
+
.glass-input:focus {
|
|
84
|
+
border-color: #9b8bd2;
|
|
85
|
+
background: rgba(31, 41, 55, 0.8);
|
|
86
|
+
box-shadow: 0 0 0 2px rgba(155, 139, 210, 0.2);
|
|
87
|
+
}
|
|
88
|
+
</style>
|
|
89
|
+
</head>
|
|
90
|
+
<body class="h-full flex overflow-hidden antialiased font-sans" x-data="appState()">
|
|
91
|
+
|
|
92
|
+
<!-- Toast Notification System -->
|
|
93
|
+
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
|
94
|
+
<template x-for="toast in toasts" :key="toast.id">
|
|
95
|
+
<div
|
|
96
|
+
x-transition:enter="transition ease-out duration-300 transform translate-y-[-10px] opacity-0"
|
|
97
|
+
x-transition:enter-start="opacity-0 translate-y-[-10px]"
|
|
98
|
+
x-transition:enter-end="opacity-100 translate-y-0"
|
|
99
|
+
x-transition:leave="transition ease-in duration-200 opacity-0 transform translate-x-[10px]"
|
|
100
|
+
class="pointer-events-auto p-4 rounded-xl flex items-start gap-3 shadow-lg w-80 max-w-full border"
|
|
101
|
+
:class="toast.type === 'success' ? 'bg-emerald-950/90 border-emerald-500/30 text-emerald-200' : 'bg-rose-950/90 border-rose-500/30 text-rose-200'"
|
|
102
|
+
>
|
|
103
|
+
<span class="mt-0.5">
|
|
104
|
+
<template x-if="toast.type === 'success'">
|
|
105
|
+
<svg class="w-5 h-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
106
|
+
</template>
|
|
107
|
+
<template x-if="toast.type === 'error'">
|
|
108
|
+
<svg class="w-5 h-5 text-rose-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
|
109
|
+
</template>
|
|
110
|
+
</span>
|
|
111
|
+
<div class="flex-1">
|
|
112
|
+
<p class="text-sm font-semibold" x-text="toast.title"></p>
|
|
113
|
+
<p class="text-xs mt-0.5 opacity-80" x-text="toast.message"></p>
|
|
114
|
+
</div>
|
|
115
|
+
<button @click="removeToast(toast.id)" class="text-gray-400 hover:text-white transition">
|
|
116
|
+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- Sidebar -->
|
|
123
|
+
<aside class="w-64 flex-shrink-0 flex flex-col border-r border-slate-800 bg-[#0f1524]">
|
|
124
|
+
<!-- Brand / Title -->
|
|
125
|
+
<div class="p-5 border-b border-slate-800 flex items-center gap-3">
|
|
126
|
+
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-brand-600 to-indigo-500 flex items-center justify-center shadow-lg shadow-brand-500/20">
|
|
127
|
+
<i data-lucide="database" class="w-4.5 h-4.5 text-white"></i>
|
|
128
|
+
</div>
|
|
129
|
+
<div>
|
|
130
|
+
<h1 class="font-bold text-sm tracking-wide text-white">HONOLITE</h1>
|
|
131
|
+
<p class="text-[10px] text-slate-400 font-medium tracking-wider uppercase">SQLite Admin Panel</p>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- Search Tables -->
|
|
136
|
+
<div class="p-3">
|
|
137
|
+
<div class="relative">
|
|
138
|
+
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
139
|
+
<i data-lucide="search" class="h-4 w-4 text-slate-500"></i>
|
|
140
|
+
</span>
|
|
141
|
+
<input
|
|
142
|
+
type="text"
|
|
143
|
+
placeholder="Tìm kiếm bảng..."
|
|
144
|
+
x-model="tableSearchQuery"
|
|
145
|
+
class="w-full pl-9 pr-3 py-1.5 text-xs rounded-lg glass-input focus:outline-none text-slate-200 placeholder-slate-500"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<!-- Tables List -->
|
|
151
|
+
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
|
152
|
+
<nav class="space-y-0.5">
|
|
153
|
+
<!-- SQL Editor Tab Button -->
|
|
154
|
+
<button
|
|
155
|
+
@click="selectSQLEditor()"
|
|
156
|
+
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-xs font-medium transition duration-200 group"
|
|
157
|
+
:class="isSQLEditorActive ? 'bg-brand-950/80 border border-brand-500/30 text-brand-300' : 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200'"
|
|
158
|
+
>
|
|
159
|
+
<i data-lucide="terminal" class="w-4 h-4 flex-shrink-0" :class="isSQLEditorActive ? 'text-brand-400' : 'text-slate-500 group-hover:text-slate-400'"></i>
|
|
160
|
+
<span class="flex-1 text-left">Trình chạy SQL Console</span>
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
<div class="h-px bg-slate-800 my-2"></div>
|
|
164
|
+
|
|
165
|
+
<p class="px-3 py-1 text-[10px] font-semibold text-slate-500 uppercase tracking-wider">Danh sách bảng</p>
|
|
166
|
+
|
|
167
|
+
<template x-for="table in filteredTables" :key="table">
|
|
168
|
+
<button
|
|
169
|
+
@click="selectTable(table)"
|
|
170
|
+
class="w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition duration-200 group"
|
|
171
|
+
:class="activeTable === table && !isSQLEditorActive ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'"
|
|
172
|
+
>
|
|
173
|
+
<div class="flex items-center gap-3 overflow-hidden">
|
|
174
|
+
<i data-lucide="table-2" class="w-4 h-4 flex-shrink-0" :class="activeTable === table && !isSQLEditorActive ? 'text-brand-400' : 'text-slate-500 group-hover:text-slate-400'"></i>
|
|
175
|
+
<span class="truncate" x-text="table"></span>
|
|
176
|
+
</div>
|
|
177
|
+
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity"></i>
|
|
178
|
+
</button>
|
|
179
|
+
</template>
|
|
180
|
+
</nav>
|
|
181
|
+
</div>
|
|
182
|
+
</aside>
|
|
183
|
+
|
|
184
|
+
<!-- Main View -->
|
|
185
|
+
<main class="flex-1 flex flex-col overflow-hidden bg-[#070a13]">
|
|
186
|
+
<!-- Topbar / Header -->
|
|
187
|
+
<header class="h-16 flex items-center justify-between px-8 border-b border-slate-800/80 bg-[#0c101d]">
|
|
188
|
+
<div class="flex items-center gap-4">
|
|
189
|
+
<template x-if="isSQLEditorActive">
|
|
190
|
+
<div class="flex items-center gap-2">
|
|
191
|
+
<span class="p-1.5 rounded-lg bg-brand-950 border border-brand-500/20 text-brand-400">
|
|
192
|
+
<i data-lucide="terminal" class="w-4 h-4"></i>
|
|
193
|
+
</span>
|
|
194
|
+
<span class="text-sm font-semibold text-white">SQL Raw Console</span>
|
|
195
|
+
</div>
|
|
196
|
+
</template>
|
|
197
|
+
<template x-if="!isSQLEditorActive && activeTable">
|
|
198
|
+
<div class="flex items-center gap-2">
|
|
199
|
+
<span class="p-1.5 rounded-lg bg-slate-800 border border-slate-700 text-slate-300">
|
|
200
|
+
<i data-lucide="table-2" class="w-4 h-4"></i>
|
|
201
|
+
</span>
|
|
202
|
+
<div>
|
|
203
|
+
<span class="text-sm font-semibold text-white" x-text="activeTable"></span>
|
|
204
|
+
<span class="text-xs text-slate-400 ml-2" x-text="'(' + totalRows + ' dòng)'"></span>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</template>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div class="flex items-center gap-3">
|
|
211
|
+
<!-- Read-Only Badge -->
|
|
212
|
+
<template x-if="isReadOnly">
|
|
213
|
+
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-950/60 text-amber-300 border border-amber-500/20">
|
|
214
|
+
<i data-lucide="eye" class="w-3 h-3"></i> Read-Only
|
|
215
|
+
</span>
|
|
216
|
+
</template>
|
|
217
|
+
|
|
218
|
+
<!-- Refresh Button -->
|
|
219
|
+
<button
|
|
220
|
+
@click="refreshData()"
|
|
221
|
+
class="flex items-center justify-center p-2 rounded-lg border border-slate-800 text-slate-400 hover:text-white hover:bg-slate-800/60 transition"
|
|
222
|
+
title="Tải lại dữ liệu"
|
|
223
|
+
>
|
|
224
|
+
<i data-lucide="refresh-cw" class="w-4 h-4" :class="loading ? 'animate-spin' : ''"></i>
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
</header>
|
|
228
|
+
|
|
229
|
+
<!-- Content Workspace -->
|
|
230
|
+
<div class="flex-1 overflow-y-auto p-8 relative">
|
|
231
|
+
<!-- Loading Overlay -->
|
|
232
|
+
<template x-if="loading && !rows.length && !queryResult.rows">
|
|
233
|
+
<div class="absolute inset-0 bg-slate-950/40 backdrop-blur-xs flex items-center justify-center z-10">
|
|
234
|
+
<div class="flex flex-col items-center gap-3">
|
|
235
|
+
<div class="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
|
236
|
+
<p class="text-xs text-slate-400 font-medium">Đang tải dữ liệu...</p>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</template>
|
|
240
|
+
|
|
241
|
+
<!-- 1. SQL Editor View -->
|
|
242
|
+
<template x-if="isSQLEditorActive">
|
|
243
|
+
<div class="space-y-6">
|
|
244
|
+
<div class="glass-card rounded-xl overflow-hidden">
|
|
245
|
+
<div class="px-5 py-3 border-b border-slate-800 bg-slate-900/50 flex items-center justify-between">
|
|
246
|
+
<span class="text-xs font-semibold text-slate-300">Nhập truy vấn SQL của bạn</span>
|
|
247
|
+
<span class="text-[10px] text-slate-500 font-mono">Hỗ trợ các câu lệnh SQLite tiêu chuẩn</span>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="p-5 space-y-4">
|
|
250
|
+
<textarea
|
|
251
|
+
x-model="sqlQuery"
|
|
252
|
+
class="w-full h-40 p-4 font-mono text-sm rounded-xl glass-input text-slate-100 focus:outline-none"
|
|
253
|
+
placeholder="SELECT * FROM users LIMIT 10;"
|
|
254
|
+
></textarea>
|
|
255
|
+
<div class="flex items-center justify-between">
|
|
256
|
+
<button
|
|
257
|
+
@click="clearSqlQuery()"
|
|
258
|
+
class="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-white transition"
|
|
259
|
+
>
|
|
260
|
+
Xóa câu lệnh
|
|
261
|
+
</button>
|
|
262
|
+
<button
|
|
263
|
+
@click="runSqlQuery()"
|
|
264
|
+
:disabled="loading || !sqlQuery.trim()"
|
|
265
|
+
class="px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed font-semibold text-xs tracking-wide text-white transition flex items-center gap-2 shadow-lg shadow-brand-500/20"
|
|
266
|
+
>
|
|
267
|
+
<i data-lucide="play" class="w-3.5 h-3.5 fill-current"></i> Chạy câu lệnh (Ctrl+Enter)
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<!-- Query results -->
|
|
274
|
+
<template x-if="queryResult.rows || queryResult.error || queryResult.affectedRows !== undefined">
|
|
275
|
+
<div class="glass-card rounded-xl overflow-hidden">
|
|
276
|
+
<div class="px-5 py-3.5 border-b border-slate-800 bg-slate-900/30 flex items-center justify-between">
|
|
277
|
+
<span class="text-xs font-semibold text-slate-300">Kết quả truy vấn</span>
|
|
278
|
+
<span
|
|
279
|
+
class="text-xs text-slate-400"
|
|
280
|
+
x-text="queryResult.timeMs !== undefined ? 'Thời gian chạy: ' + queryResult.timeMs + 'ms' : ''"
|
|
281
|
+
></span>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="p-5">
|
|
284
|
+
<!-- Success Write Alert -->
|
|
285
|
+
<template x-if="queryResult.affectedRows !== undefined && !queryResult.error">
|
|
286
|
+
<div class="p-4 rounded-xl bg-indigo-950/40 border border-indigo-500/20 text-indigo-300 text-sm flex items-center gap-3">
|
|
287
|
+
<i data-lucide="info" class="w-5 h-5 text-indigo-400"></i>
|
|
288
|
+
<span>Thành công! Số dòng bị ảnh hưởng: <strong class="text-white font-semibold" x-text="queryResult.affectedRows"></strong> dòng.</span>
|
|
289
|
+
</div>
|
|
290
|
+
</template>
|
|
291
|
+
|
|
292
|
+
<!-- Error Alert -->
|
|
293
|
+
<template x-if="queryResult.error">
|
|
294
|
+
<div class="p-4 rounded-xl bg-rose-950/40 border border-rose-500/20 text-rose-300 text-sm flex items-start gap-3">
|
|
295
|
+
<i data-lucide="alert-triangle" class="w-5 h-5 text-rose-400 mt-0.5 flex-shrink-0"></i>
|
|
296
|
+
<div>
|
|
297
|
+
<h4 class="font-bold text-white mb-0.5">Lỗi SQL:</h4>
|
|
298
|
+
<code class="text-xs font-mono break-all text-rose-200" x-text="queryResult.error"></code>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</template>
|
|
302
|
+
|
|
303
|
+
<!-- Result Table -->
|
|
304
|
+
<template x-if="queryResult.rows && queryResult.rows.length > 0">
|
|
305
|
+
<div class="overflow-x-auto border border-slate-800 rounded-lg">
|
|
306
|
+
<table class="w-full text-left border-collapse text-xs">
|
|
307
|
+
<thead>
|
|
308
|
+
<tr class="bg-slate-900 border-b border-slate-800 text-slate-300 uppercase tracking-wider font-semibold">
|
|
309
|
+
<template x-for="col in queryResult.columns" :key="col">
|
|
310
|
+
<th class="px-4 py-3" x-text="col"></th>
|
|
311
|
+
</template>
|
|
312
|
+
</tr>
|
|
313
|
+
</thead>
|
|
314
|
+
<tbody class="divide-y divide-slate-800/60">
|
|
315
|
+
<template x-for="(row, idx) in queryResult.rows" :key="idx">
|
|
316
|
+
<tr class="hover:bg-slate-900/30 transition">
|
|
317
|
+
<template x-for="col in queryResult.columns" :key="col">
|
|
318
|
+
<td class="px-4 py-2.5 font-mono text-slate-300 break-words max-w-xs" x-text="formatCellValue(row[col])"></td>
|
|
319
|
+
</template>
|
|
320
|
+
</tr>
|
|
321
|
+
</template>
|
|
322
|
+
</tbody>
|
|
323
|
+
</table>
|
|
324
|
+
</div>
|
|
325
|
+
</template>
|
|
326
|
+
|
|
327
|
+
<!-- Empty Results -->
|
|
328
|
+
<template x-if="queryResult.rows && queryResult.rows.length === 0 && queryResult.affectedRows === undefined && !queryResult.error">
|
|
329
|
+
<div class="py-8 text-center text-slate-500 text-xs">
|
|
330
|
+
Truy vấn không trả về dòng dữ liệu nào.
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</template>
|
|
336
|
+
</div>
|
|
337
|
+
</template>
|
|
338
|
+
|
|
339
|
+
<!-- 2. Table Detail View -->
|
|
340
|
+
<template x-if="!isSQLEditorActive && activeTable">
|
|
341
|
+
<div class="space-y-6" x-init="$nextTick(() => { lucide.createIcons(); })">
|
|
342
|
+
<!-- View Tab Selector -->
|
|
343
|
+
<div class="flex border-b border-slate-800/80 gap-6">
|
|
344
|
+
<button
|
|
345
|
+
@click="activeTab = 'browse'"
|
|
346
|
+
class="pb-3 text-xs font-semibold tracking-wide border-b-2 transition duration-200 flex items-center gap-2"
|
|
347
|
+
:class="activeTab === 'browse' ? 'border-brand-500 text-brand-400' : 'border-transparent text-slate-400 hover:text-slate-200'"
|
|
348
|
+
>
|
|
349
|
+
<i data-lucide="eye" class="w-4 h-4"></i> Duyệt Dữ Liệu
|
|
350
|
+
</button>
|
|
351
|
+
<button
|
|
352
|
+
@click="activeTab = 'schema'"
|
|
353
|
+
class="pb-3 text-xs font-semibold tracking-wide border-b-2 transition duration-200 flex items-center gap-2"
|
|
354
|
+
:class="activeTab === 'schema' ? 'border-brand-500 text-brand-400' : 'border-transparent text-slate-400 hover:text-slate-200'"
|
|
355
|
+
>
|
|
356
|
+
<i data-lucide="info" class="w-4 h-4"></i> Cấu Trúc Bảng
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<!-- Browse Tab -->
|
|
361
|
+
<div x-show="activeTab === 'browse'" class="space-y-4">
|
|
362
|
+
<!-- Search & Actions -->
|
|
363
|
+
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
364
|
+
<!-- Search inside table -->
|
|
365
|
+
<div class="relative w-full sm:w-72">
|
|
366
|
+
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
367
|
+
<i data-lucide="search" class="h-4 w-4 text-slate-500"></i>
|
|
368
|
+
</span>
|
|
369
|
+
<input
|
|
370
|
+
type="text"
|
|
371
|
+
placeholder="Tìm trong các cột Text..."
|
|
372
|
+
x-model.debounce.500ms="searchQuery"
|
|
373
|
+
class="w-full pl-9 pr-3 py-2 text-xs rounded-xl glass-input focus:outline-none text-slate-200 placeholder-slate-500"
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
<!-- Create record button -->
|
|
378
|
+
<template x-if="!isReadOnly">
|
|
379
|
+
<button
|
|
380
|
+
@click="openAddModal()"
|
|
381
|
+
class="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-xl text-xs font-semibold text-white tracking-wide transition shadow-lg shadow-brand-500/10 flex items-center gap-2"
|
|
382
|
+
>
|
|
383
|
+
<i data-lucide="plus" class="w-3.5 h-3.5"></i> Thêm Dòng Mới
|
|
384
|
+
</button>
|
|
385
|
+
</template>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
<!-- Data Table Grid -->
|
|
389
|
+
<div class="glass-card rounded-xl overflow-hidden">
|
|
390
|
+
<div class="overflow-x-auto">
|
|
391
|
+
<table class="w-full text-left border-collapse text-xs">
|
|
392
|
+
<thead>
|
|
393
|
+
<tr class="bg-slate-900 border-b border-slate-800 text-slate-400 font-semibold uppercase tracking-wider">
|
|
394
|
+
<!-- Operations column header -->
|
|
395
|
+
<template x-if="!isReadOnly">
|
|
396
|
+
<th class="px-5 py-3.5 w-24 text-center">Thao tác</th>
|
|
397
|
+
</template>
|
|
398
|
+
<template x-for="col in schema.columns" :key="col.name">
|
|
399
|
+
<th class="px-5 py-3.5 font-semibold">
|
|
400
|
+
<div class="flex items-center gap-1">
|
|
401
|
+
<span class="text-slate-200" x-text="col.name"></span>
|
|
402
|
+
<span class="text-[9px] text-slate-500 normal-case" x-text="col.type"></span>
|
|
403
|
+
<template x-if="col.primaryKey">
|
|
404
|
+
<span class="px-1 text-[8px] bg-brand-950 text-brand-300 border border-brand-500/20 rounded font-bold">PK</span>
|
|
405
|
+
</template>
|
|
406
|
+
</div>
|
|
407
|
+
</th>
|
|
408
|
+
</template>
|
|
409
|
+
</tr>
|
|
410
|
+
</thead>
|
|
411
|
+
<tbody class="divide-y divide-slate-800/50">
|
|
412
|
+
<template x-for="(row, idx) in rows" :key="idx">
|
|
413
|
+
<tr class="hover:bg-slate-900/30 transition">
|
|
414
|
+
<!-- Operations column cells -->
|
|
415
|
+
<template x-if="!isReadOnly">
|
|
416
|
+
<td class="px-5 py-3 w-24 text-center">
|
|
417
|
+
<div class="flex items-center justify-center gap-2.5">
|
|
418
|
+
<button @click="openEditModal(row)" class="text-slate-400 hover:text-brand-400 transition" title="Sửa">
|
|
419
|
+
<i data-lucide="edit-3" class="w-4 h-4"></i>
|
|
420
|
+
</button>
|
|
421
|
+
<button @click="openDeleteModal(row)" class="text-slate-400 hover:text-rose-400 transition" title="Xóa">
|
|
422
|
+
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
</td>
|
|
426
|
+
</template>
|
|
427
|
+
<template x-for="col in schema.columns" :key="col.name">
|
|
428
|
+
<td class="px-5 py-3 font-mono text-slate-300 break-words max-w-xs" x-text="formatCellValue(row[col.name])"></td>
|
|
429
|
+
</template>
|
|
430
|
+
</tr>
|
|
431
|
+
</template>
|
|
432
|
+
|
|
433
|
+
<!-- Empty Data Row -->
|
|
434
|
+
<template x-if="rows.length === 0">
|
|
435
|
+
<tr>
|
|
436
|
+
<td :colspan="schema.columns.length + (isReadOnly ? 0 : 1)" class="py-12 text-center text-slate-500">
|
|
437
|
+
Bảng hiện không có dữ liệu nào phù hợp.
|
|
438
|
+
</td>
|
|
439
|
+
</tr>
|
|
440
|
+
</template>
|
|
441
|
+
</tbody>
|
|
442
|
+
</table>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<!-- Pagination Info and Actions -->
|
|
446
|
+
<div class="px-5 py-4 border-t border-slate-800/80 bg-slate-900/10 flex items-center justify-between flex-wrap gap-4 text-xs text-slate-400">
|
|
447
|
+
<div>
|
|
448
|
+
Hiển thị từ <strong class="text-slate-200" x-text="((page - 1) * limit) + 1"></strong>
|
|
449
|
+
đến <strong class="text-slate-200" x-text="Math.min(page * limit, totalRows)"></strong>
|
|
450
|
+
trong tổng số <strong class="text-slate-200" x-text="totalRows"></strong> dòng.
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<div class="flex items-center gap-4">
|
|
454
|
+
<!-- Rows per page selector -->
|
|
455
|
+
<div class="flex items-center gap-2">
|
|
456
|
+
<span>Số dòng:</span>
|
|
457
|
+
<select x-model="limit" class="px-2 py-1 rounded-md glass-input focus:outline-none text-slate-300">
|
|
458
|
+
<option value="10">10</option>
|
|
459
|
+
<option value="25">25</option>
|
|
460
|
+
<option value="50">50</option>
|
|
461
|
+
<option value="100">100</option>
|
|
462
|
+
</select>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<!-- Pagination Controls -->
|
|
466
|
+
<div class="flex items-center gap-1.5">
|
|
467
|
+
<button
|
|
468
|
+
@click="prevPage()"
|
|
469
|
+
:disabled="page <= 1"
|
|
470
|
+
class="px-2.5 py-1.5 rounded-lg border border-slate-800 hover:bg-slate-800 disabled:opacity-40 disabled:hover:bg-transparent transition text-slate-300"
|
|
471
|
+
>
|
|
472
|
+
<i data-lucide="chevron-left" class="w-3.5 h-3.5"></i>
|
|
473
|
+
</button>
|
|
474
|
+
<span class="px-2 font-medium" x-text="'Trang ' + page + ' / ' + Math.ceil(totalRows / limit)"></span>
|
|
475
|
+
<button
|
|
476
|
+
@click="nextPage()"
|
|
477
|
+
:disabled="page >= Math.ceil(totalRows / limit)"
|
|
478
|
+
class="px-2.5 py-1.5 rounded-lg border border-slate-800 hover:bg-slate-800 disabled:opacity-40 disabled:hover:bg-transparent transition text-slate-300"
|
|
479
|
+
>
|
|
480
|
+
<i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
|
|
481
|
+
</button>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<!-- Schema Tab -->
|
|
489
|
+
<div x-show="activeTab === 'schema'" class="space-y-6">
|
|
490
|
+
<div class="glass-card rounded-xl overflow-hidden">
|
|
491
|
+
<div class="px-5 py-3 border-b border-slate-800 bg-slate-900/30">
|
|
492
|
+
<h3 class="text-xs font-semibold text-slate-300 uppercase tracking-wide">Cấu trúc các cột</h3>
|
|
493
|
+
</div>
|
|
494
|
+
<div class="overflow-x-auto">
|
|
495
|
+
<table class="w-full text-left border-collapse text-xs">
|
|
496
|
+
<thead>
|
|
497
|
+
<tr class="bg-slate-900 border-b border-slate-800 text-slate-400 font-semibold uppercase tracking-wider">
|
|
498
|
+
<th class="px-5 py-3.5">Cột</th>
|
|
499
|
+
<th class="px-5 py-3.5">Kiểu dữ liệu</th>
|
|
500
|
+
<th class="px-5 py-3.5">Thuộc tính</th>
|
|
501
|
+
<th class="px-5 py-3.5">Giá trị mặc định</th>
|
|
502
|
+
</tr>
|
|
503
|
+
</thead>
|
|
504
|
+
<tbody class="divide-y divide-slate-800/50">
|
|
505
|
+
<template x-for="col in schema.columns" :key="col.name">
|
|
506
|
+
<tr class="hover:bg-slate-900/30 transition">
|
|
507
|
+
<td class="px-5 py-3 font-semibold text-slate-200">
|
|
508
|
+
<div class="flex items-center gap-2">
|
|
509
|
+
<span x-text="col.name"></span>
|
|
510
|
+
<template x-if="col.primaryKey">
|
|
511
|
+
<span class="px-1.5 py-0.5 text-[8px] bg-brand-950 text-brand-300 border border-brand-500/20 rounded font-bold uppercase">Primary Key</span>
|
|
512
|
+
</template>
|
|
513
|
+
</div>
|
|
514
|
+
</td>
|
|
515
|
+
<td class="px-5 py-3 font-mono text-xs text-brand-300" x-text="col.type || 'INTEGER/TEXT/BLOB'"></td>
|
|
516
|
+
<td class="px-5 py-3 text-slate-400">
|
|
517
|
+
<span class="px-1.5 py-0.5 rounded text-[10px] border border-slate-800" :class="col.notNull ? 'bg-rose-950/20 text-rose-400 border-rose-500/10' : 'bg-emerald-950/20 text-emerald-400 border-emerald-500/10'" x-text="col.notNull ? 'NOT NULL' : 'NULLABLE'"></span>
|
|
518
|
+
</td>
|
|
519
|
+
<td class="px-5 py-3 font-mono text-slate-400" x-text="col.defaultValue !== null ? col.defaultValue : 'NULL'"></td>
|
|
520
|
+
</tr>
|
|
521
|
+
</template>
|
|
522
|
+
</tbody>
|
|
523
|
+
</table>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<!-- Foreign Keys Section -->
|
|
528
|
+
<template x-if="schema.foreignKeys && schema.foreignKeys.length > 0">
|
|
529
|
+
<div class="glass-card rounded-xl overflow-hidden">
|
|
530
|
+
<div class="px-5 py-3 border-b border-slate-800 bg-slate-900/30">
|
|
531
|
+
<h3 class="text-xs font-semibold text-slate-300 uppercase tracking-wide">Khóa ngoại (Foreign Keys)</h3>
|
|
532
|
+
</div>
|
|
533
|
+
<div class="overflow-x-auto">
|
|
534
|
+
<table class="w-full text-left border-collapse text-xs">
|
|
535
|
+
<thead>
|
|
536
|
+
<tr class="bg-slate-900 border-b border-slate-800 text-slate-400 font-semibold uppercase tracking-wider">
|
|
537
|
+
<th class="px-5 py-3.5">Cột tham chiếu</th>
|
|
538
|
+
<th class="px-5 py-3.5">Bảng đích</th>
|
|
539
|
+
<th class="px-5 py-3.5">Cột đích</th>
|
|
540
|
+
<th class="px-5 py-3.5">On Update / Delete</th>
|
|
541
|
+
</tr>
|
|
542
|
+
</thead>
|
|
543
|
+
<tbody class="divide-y divide-slate-800/50">
|
|
544
|
+
<template x-for="fk in schema.foreignKeys" :key="fk.id + '-' + fk.seq">
|
|
545
|
+
<tr class="hover:bg-slate-900/30 transition text-slate-300">
|
|
546
|
+
<td class="px-5 py-3 font-mono font-semibold" x-text="fk.from"></td>
|
|
547
|
+
<td class="px-5 py-3 text-slate-200" x-text="fk.table"></td>
|
|
548
|
+
<td class="px-5 py-3 font-mono" x-text="fk.to"></td>
|
|
549
|
+
<td class="px-5 py-3 text-slate-400 font-mono text-[10px]" x-text="'ON UPDATE: ' + fk.onUpdate + ' | ON DELETE: ' + fk.onDelete"></td>
|
|
550
|
+
</tr>
|
|
551
|
+
</template>
|
|
552
|
+
</tbody>
|
|
553
|
+
</table>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
</template>
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
</template>
|
|
560
|
+
|
|
561
|
+
<!-- 3. Welcome / Empty Screen -->
|
|
562
|
+
<template x-if="!isSQLEditorActive && !activeTable">
|
|
563
|
+
<div class="h-96 flex flex-col items-center justify-center text-center">
|
|
564
|
+
<div class="w-16 h-16 rounded-2xl bg-gradient-to-tr from-brand-600/20 to-indigo-500/20 border border-brand-500/30 flex items-center justify-center shadow-2xl mb-5 text-brand-400 animate-pulse">
|
|
565
|
+
<i data-lucide="database" class="w-8 h-8"></i>
|
|
566
|
+
</div>
|
|
567
|
+
<h2 class="text-lg font-bold text-white">Chào mừng tới trang quản trị SQLite</h2>
|
|
568
|
+
<p class="text-sm text-slate-400 mt-2 max-w-sm">Chọn một bảng từ thanh bên để xem cấu trúc và dữ liệu, hoặc sử dụng SQL Console để truy vấn.</p>
|
|
569
|
+
</div>
|
|
570
|
+
</template>
|
|
571
|
+
</div>
|
|
572
|
+
</main>
|
|
573
|
+
|
|
574
|
+
<!-- CRUD Modals -->
|
|
575
|
+
|
|
576
|
+
<!-- Add Record Modal -->
|
|
577
|
+
<div
|
|
578
|
+
x-show="addModalOpen"
|
|
579
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm"
|
|
580
|
+
x-transition
|
|
581
|
+
style="display: none;"
|
|
582
|
+
>
|
|
583
|
+
<div @click.outside="closeAddModal()" class="w-full max-w-lg glass-card rounded-2xl overflow-hidden shadow-2xl">
|
|
584
|
+
<div class="px-6 py-4 border-b border-slate-800 bg-slate-900/50 flex items-center justify-between">
|
|
585
|
+
<h3 class="text-sm font-bold text-white flex items-center gap-2">
|
|
586
|
+
<i data-lucide="plus-circle" class="w-4 h-4 text-brand-400"></i> Thêm dòng dữ liệu mới
|
|
587
|
+
</h3>
|
|
588
|
+
<button @click="closeAddModal()" class="text-slate-400 hover:text-white transition">
|
|
589
|
+
<i data-lucide="x" class="w-5 h-5"></i>
|
|
590
|
+
</button>
|
|
591
|
+
</div>
|
|
592
|
+
<form @submit.prevent="submitAddForm()" class="p-6 space-y-4">
|
|
593
|
+
<div class="max-h-96 overflow-y-auto space-y-4 pr-1">
|
|
594
|
+
<template x-for="col in schema.columns" :key="col.name">
|
|
595
|
+
<div class="space-y-1.5">
|
|
596
|
+
<label class="block text-xs font-semibold text-slate-300">
|
|
597
|
+
<span x-text="col.name"></span>
|
|
598
|
+
<span class="text-[10px] text-slate-500 ml-1 font-mono" x-text="'(' + (col.type || 'TEXT') + ')'"></span>
|
|
599
|
+
<template x-if="col.primaryKey">
|
|
600
|
+
<span class="text-[9px] text-brand-400 font-bold ml-1">PK</span>
|
|
601
|
+
</template>
|
|
602
|
+
<template x-if="col.notNull">
|
|
603
|
+
<span class="text-rose-500 ml-0.5">*</span>
|
|
604
|
+
</template>
|
|
605
|
+
</label>
|
|
606
|
+
|
|
607
|
+
<!-- Inputs dynamically generated -->
|
|
608
|
+
<input
|
|
609
|
+
type="text"
|
|
610
|
+
x-model="formData[col.name]"
|
|
611
|
+
:placeholder="col.primaryKey ? 'Tự động tạo (nếu tự tăng)' : 'Nhập giá trị...'"
|
|
612
|
+
:disabled="col.primaryKey"
|
|
613
|
+
class="w-full px-3 py-2 text-xs rounded-xl glass-input text-slate-200 focus:outline-none disabled:opacity-40"
|
|
614
|
+
/>
|
|
615
|
+
</div>
|
|
616
|
+
</template>
|
|
617
|
+
</div>
|
|
618
|
+
<div class="pt-4 border-t border-slate-800/80 flex items-center justify-end gap-3">
|
|
619
|
+
<button
|
|
620
|
+
type="button"
|
|
621
|
+
@click="closeAddModal()"
|
|
622
|
+
class="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-white transition"
|
|
623
|
+
>
|
|
624
|
+
Hủy
|
|
625
|
+
</button>
|
|
626
|
+
<button
|
|
627
|
+
type="submit"
|
|
628
|
+
class="px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 font-semibold text-xs text-white transition shadow-lg shadow-brand-500/20"
|
|
629
|
+
>
|
|
630
|
+
Thêm dòng
|
|
631
|
+
</button>
|
|
632
|
+
</div>
|
|
633
|
+
</form>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
<!-- Edit Record Modal -->
|
|
638
|
+
<div
|
|
639
|
+
x-show="editModalOpen"
|
|
640
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm"
|
|
641
|
+
x-transition
|
|
642
|
+
style="display: none;"
|
|
643
|
+
>
|
|
644
|
+
<div @click.outside="closeEditModal()" class="w-full max-w-lg glass-card rounded-2xl overflow-hidden shadow-2xl">
|
|
645
|
+
<div class="px-6 py-4 border-b border-slate-800 bg-slate-900/50 flex items-center justify-between">
|
|
646
|
+
<h3 class="text-sm font-bold text-white flex items-center gap-2">
|
|
647
|
+
<i data-lucide="edit-3" class="w-4 h-4 text-brand-400"></i> Cập nhật thông tin dòng
|
|
648
|
+
</h3>
|
|
649
|
+
<button @click="closeEditModal()" class="text-slate-400 hover:text-white transition">
|
|
650
|
+
<i data-lucide="x" class="w-5 h-5"></i>
|
|
651
|
+
</button>
|
|
652
|
+
</div>
|
|
653
|
+
<form @submit.prevent="submitEditForm()" class="p-6 space-y-4">
|
|
654
|
+
<div class="max-h-96 overflow-y-auto space-y-4 pr-1">
|
|
655
|
+
<template x-for="col in schema.columns" :key="col.name">
|
|
656
|
+
<div class="space-y-1.5">
|
|
657
|
+
<label class="block text-xs font-semibold text-slate-300">
|
|
658
|
+
<span x-text="col.name"></span>
|
|
659
|
+
<span class="text-[10px] text-slate-500 ml-1 font-mono" x-text="'(' + (col.type || 'TEXT') + ')'"></span>
|
|
660
|
+
<template x-if="col.primaryKey">
|
|
661
|
+
<span class="text-[9px] text-brand-400 font-bold ml-1">PK (Khóa)</span>
|
|
662
|
+
</template>
|
|
663
|
+
<template x-if="col.notNull">
|
|
664
|
+
<span class="text-rose-500 ml-0.5">*</span>
|
|
665
|
+
</template>
|
|
666
|
+
</label>
|
|
667
|
+
|
|
668
|
+
<!-- Inputs -->
|
|
669
|
+
<input
|
|
670
|
+
type="text"
|
|
671
|
+
x-model="formData[col.name]"
|
|
672
|
+
:disabled="col.primaryKey"
|
|
673
|
+
class="w-full px-3 py-2 text-xs rounded-xl glass-input text-slate-200 focus:outline-none disabled:opacity-40"
|
|
674
|
+
/>
|
|
675
|
+
</div>
|
|
676
|
+
</template>
|
|
677
|
+
</div>
|
|
678
|
+
<div class="pt-4 border-t border-slate-800/80 flex items-center justify-end gap-3">
|
|
679
|
+
<button
|
|
680
|
+
type="button"
|
|
681
|
+
@click="closeEditModal()"
|
|
682
|
+
class="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-white transition"
|
|
683
|
+
>
|
|
684
|
+
Hủy
|
|
685
|
+
</button>
|
|
686
|
+
<button
|
|
687
|
+
type="submit"
|
|
688
|
+
class="px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 font-semibold text-xs text-white transition shadow-lg shadow-brand-500/20"
|
|
689
|
+
>
|
|
690
|
+
Lưu thay đổi
|
|
691
|
+
</button>
|
|
692
|
+
</div>
|
|
693
|
+
</form>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<!-- Delete Record Confirmation Modal -->
|
|
698
|
+
<div
|
|
699
|
+
x-show="deleteModalOpen"
|
|
700
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm"
|
|
701
|
+
x-transition
|
|
702
|
+
style="display: none;"
|
|
703
|
+
>
|
|
704
|
+
<div @click.outside="closeDeleteModal()" class="w-full max-w-sm glass-card rounded-2xl overflow-hidden shadow-2xl border-rose-500/20">
|
|
705
|
+
<div class="p-6 text-center space-y-4">
|
|
706
|
+
<div class="w-12 h-12 rounded-full bg-rose-950/60 border border-rose-500/30 text-rose-400 flex items-center justify-center mx-auto mb-2">
|
|
707
|
+
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
|
|
708
|
+
</div>
|
|
709
|
+
<h3 class="text-sm font-bold text-white">Xác nhận xóa dữ liệu</h3>
|
|
710
|
+
<p class="text-xs text-slate-400 leading-relaxed">Hành động này không thể hoàn tác. Bạn có chắc chắn muốn xóa dòng dữ liệu đang chọn khỏi cơ sở dữ liệu?</p>
|
|
711
|
+
|
|
712
|
+
<div class="flex items-center justify-center gap-3 pt-2">
|
|
713
|
+
<button
|
|
714
|
+
type="button"
|
|
715
|
+
@click="closeDeleteModal()"
|
|
716
|
+
class="px-4 py-2.5 text-xs font-semibold text-slate-400 hover:text-white transition"
|
|
717
|
+
>
|
|
718
|
+
Hủy bỏ
|
|
719
|
+
</button>
|
|
720
|
+
<button
|
|
721
|
+
type="button"
|
|
722
|
+
@click="submitDelete()"
|
|
723
|
+
class="px-5 py-2.5 rounded-xl bg-rose-600 hover:bg-rose-500 font-semibold text-xs text-white transition"
|
|
724
|
+
>
|
|
725
|
+
Chắc chắn xóa
|
|
726
|
+
</button>
|
|
727
|
+
</div>
|
|
728
|
+
</div>
|
|
729
|
+
</div>
|
|
730
|
+
</div>
|
|
731
|
+
|
|
732
|
+
<!-- Script logics -->
|
|
733
|
+
<script>
|
|
734
|
+
function appState() {
|
|
735
|
+
return {
|
|
736
|
+
basePath: '${cleanedBasePath}',
|
|
737
|
+
tables: [],
|
|
738
|
+
tableSearchQuery: '',
|
|
739
|
+
activeTable: null,
|
|
740
|
+
isSQLEditorActive: false,
|
|
741
|
+
activeTab: 'browse',
|
|
742
|
+
|
|
743
|
+
// Data states
|
|
744
|
+
schema: { columns: [], foreignKeys: [] },
|
|
745
|
+
rows: [],
|
|
746
|
+
totalRows: 0,
|
|
747
|
+
page: 1,
|
|
748
|
+
limit: 10,
|
|
749
|
+
searchQuery: '',
|
|
750
|
+
loading: false,
|
|
751
|
+
isReadOnly: false,
|
|
752
|
+
|
|
753
|
+
// SQL Editor state
|
|
754
|
+
sqlQuery: '',
|
|
755
|
+
queryResult: { rows: null, columns: [], error: null },
|
|
756
|
+
|
|
757
|
+
// CRUD Modals and form state
|
|
758
|
+
addModalOpen: false,
|
|
759
|
+
editModalOpen: false,
|
|
760
|
+
deleteModalOpen: false,
|
|
761
|
+
formData: {},
|
|
762
|
+
selectedRow: null, // Used for edit/delete reference
|
|
763
|
+
|
|
764
|
+
// Toasts
|
|
765
|
+
toasts: [],
|
|
766
|
+
|
|
767
|
+
init() {
|
|
768
|
+
this.fetchTables();
|
|
769
|
+
// Event listener for Ctrl+Enter in SQL Editor
|
|
770
|
+
window.addEventListener('keydown', (e) => {
|
|
771
|
+
if (this.isSQLEditorActive && (e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
772
|
+
this.runSqlQuery();
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
this.$watch('searchQuery', () => {
|
|
777
|
+
if (this.activeTable) {
|
|
778
|
+
this.page = 1;
|
|
779
|
+
this.fetchTableData();
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
this.$watch('limit', () => {
|
|
784
|
+
if (this.activeTable) {
|
|
785
|
+
this.page = 1;
|
|
786
|
+
this.fetchTableData();
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
this.$nextTick(() => {
|
|
791
|
+
lucide.createIcons();
|
|
792
|
+
});
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
get filteredTables() {
|
|
796
|
+
if (!this.tableSearchQuery.trim()) return this.tables;
|
|
797
|
+
const query = this.tableSearchQuery.toLowerCase();
|
|
798
|
+
return this.tables.filter(t => t.toLowerCase().includes(query));
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
// Toast notifications
|
|
802
|
+
showToast(title, message, type = 'success') {
|
|
803
|
+
const id = Date.now();
|
|
804
|
+
this.toasts.push({ id, title, message, type });
|
|
805
|
+
setTimeout(() => {
|
|
806
|
+
this.removeToast(id);
|
|
807
|
+
}, 4000);
|
|
808
|
+
},
|
|
809
|
+
|
|
810
|
+
removeToast(id) {
|
|
811
|
+
this.toasts = this.toasts.filter(t => t.id !== id);
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
// API calls
|
|
815
|
+
async fetchTables() {
|
|
816
|
+
this.loading = true;
|
|
817
|
+
try {
|
|
818
|
+
const res = await fetch(`${this.basePath}/api/tables`);
|
|
819
|
+
const data = await res.json();
|
|
820
|
+
if (data.success) {
|
|
821
|
+
this.tables = data.tables;
|
|
822
|
+
this.isReadOnly = data.readOnly;
|
|
823
|
+
} else {
|
|
824
|
+
this.showToast('Lỗi tải bảng', data.error || 'Không rõ lý do', 'error');
|
|
825
|
+
}
|
|
826
|
+
} catch (err) {
|
|
827
|
+
this.showToast('Lỗi mạng', err.message, 'error');
|
|
828
|
+
} finally {
|
|
829
|
+
this.loading = false;
|
|
830
|
+
this.$nextTick(() => { lucide.createIcons(); });
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
|
|
834
|
+
async fetchTableData() {
|
|
835
|
+
if (!this.activeTable) return;
|
|
836
|
+
this.loading = true;
|
|
837
|
+
try {
|
|
838
|
+
const url = `${this.basePath}/api/tables/${encodeURIComponent(this.activeTable)}?page=${this.page}&limit=${this.limit}&search=${encodeURIComponent(this.searchQuery)}`;
|
|
839
|
+
const res = await fetch(url);
|
|
840
|
+
const data = await res.json();
|
|
841
|
+
if (data.success) {
|
|
842
|
+
this.schema = data.info;
|
|
843
|
+
this.rows = data.rows;
|
|
844
|
+
this.totalRows = data.total;
|
|
845
|
+
} else {
|
|
846
|
+
this.showToast('Lỗi dữ liệu', data.error, 'error');
|
|
847
|
+
}
|
|
848
|
+
} catch (err) {
|
|
849
|
+
this.showToast('Lỗi mạng', err.message, 'error');
|
|
850
|
+
} finally {
|
|
851
|
+
this.loading = false;
|
|
852
|
+
this.$nextTick(() => { lucide.createIcons(); });
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
selectTable(table) {
|
|
857
|
+
this.isSQLEditorActive = false;
|
|
858
|
+
this.activeTable = table;
|
|
859
|
+
this.page = 1;
|
|
860
|
+
this.searchQuery = '';
|
|
861
|
+
this.activeTab = 'browse';
|
|
862
|
+
this.queryResult = { rows: null, columns: [], error: null };
|
|
863
|
+
this.fetchTableData();
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
selectSQLEditor() {
|
|
867
|
+
this.isSQLEditorActive = true;
|
|
868
|
+
this.activeTable = null;
|
|
869
|
+
this.$nextTick(() => { lucide.createIcons(); });
|
|
870
|
+
},
|
|
871
|
+
|
|
872
|
+
refreshData() {
|
|
873
|
+
if (this.isSQLEditorActive) {
|
|
874
|
+
if (this.sqlQuery.trim()) this.runSqlQuery();
|
|
875
|
+
} else if (this.activeTable) {
|
|
876
|
+
this.fetchTableData();
|
|
877
|
+
} else {
|
|
878
|
+
this.fetchTables();
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
|
|
882
|
+
prevPage() {
|
|
883
|
+
if (this.page > 1) {
|
|
884
|
+
this.page--;
|
|
885
|
+
this.fetchTableData();
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
|
|
889
|
+
nextPage() {
|
|
890
|
+
if (this.page < Math.ceil(this.totalRows / this.limit)) {
|
|
891
|
+
this.page++;
|
|
892
|
+
this.fetchTableData();
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
formatCellValue(val) {
|
|
897
|
+
if (val === null || val === undefined) return 'NULL';
|
|
898
|
+
if (typeof val === 'object') return JSON.stringify(val);
|
|
899
|
+
return val.toString();
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
// SQL Query methods
|
|
903
|
+
async runSqlQuery() {
|
|
904
|
+
if (!this.sqlQuery.trim()) return;
|
|
905
|
+
this.loading = true;
|
|
906
|
+
const startTime = performance.now();
|
|
907
|
+
try {
|
|
908
|
+
const res = await fetch(`${this.basePath}/api/query`, {
|
|
909
|
+
method: 'POST',
|
|
910
|
+
headers: { 'Content-Type': 'application/json' },
|
|
911
|
+
body: JSON.stringify({ sql: this.sqlQuery })
|
|
912
|
+
});
|
|
913
|
+
const data = await res.json();
|
|
914
|
+
const timeMs = Math.round(performance.now() - startTime);
|
|
915
|
+
|
|
916
|
+
if (data.success) {
|
|
917
|
+
this.queryResult = {
|
|
918
|
+
rows: data.rows || null,
|
|
919
|
+
columns: data.columns || [],
|
|
920
|
+
affectedRows: data.affectedRows,
|
|
921
|
+
error: null,
|
|
922
|
+
timeMs
|
|
923
|
+
};
|
|
924
|
+
this.showToast('Truy vấn hoàn thành', `Thành công sau ${timeMs}ms`);
|
|
925
|
+
// Reload sidebar if a write happened
|
|
926
|
+
if (data.affectedRows !== undefined) {
|
|
927
|
+
this.fetchTables();
|
|
928
|
+
}
|
|
929
|
+
} else {
|
|
930
|
+
this.queryResult = {
|
|
931
|
+
rows: null,
|
|
932
|
+
columns: [],
|
|
933
|
+
error: data.error,
|
|
934
|
+
timeMs
|
|
935
|
+
};
|
|
936
|
+
this.showToast('Lỗi truy vấn', 'Vui lòng kiểm tra lại câu lệnh', 'error');
|
|
937
|
+
}
|
|
938
|
+
} catch (err) {
|
|
939
|
+
this.queryResult = { rows: null, columns: [], error: err.message, timeMs: 0 };
|
|
940
|
+
this.showToast('Lỗi kết nối', err.message, 'error');
|
|
941
|
+
} finally {
|
|
942
|
+
this.loading = false;
|
|
943
|
+
this.$nextTick(() => { lucide.createIcons(); });
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
|
|
947
|
+
clearSqlQuery() {
|
|
948
|
+
this.sqlQuery = '';
|
|
949
|
+
this.queryResult = { rows: null, columns: [], error: null };
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
// CRUD UI Actions
|
|
953
|
+
openAddModal() {
|
|
954
|
+
if (this.isReadOnly) return;
|
|
955
|
+
this.formData = {};
|
|
956
|
+
// Initialize empty values matching schema
|
|
957
|
+
this.schema.columns.forEach(col => {
|
|
958
|
+
this.formData[col.name] = '';
|
|
959
|
+
});
|
|
960
|
+
this.addModalOpen = true;
|
|
961
|
+
this.$nextTick(() => { lucide.createIcons(); });
|
|
962
|
+
},
|
|
963
|
+
|
|
964
|
+
closeAddModal() {
|
|
965
|
+
this.addModalOpen = false;
|
|
966
|
+
},
|
|
967
|
+
|
|
968
|
+
async submitAddForm() {
|
|
969
|
+
this.loading = true;
|
|
970
|
+
// Filter primary key column from payload if empty (to let SQLite autoincrement work)
|
|
971
|
+
const payload = {};
|
|
972
|
+
this.schema.columns.forEach(col => {
|
|
973
|
+
const val = this.formData[col.name];
|
|
974
|
+
if (col.primaryKey && (val === '' || val === null || val === undefined)) {
|
|
975
|
+
// Skip primary keys to let database autoincrement them
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Type conversion basics
|
|
980
|
+
if (val === '') {
|
|
981
|
+
payload[col.name] = col.notNull ? '' : null;
|
|
982
|
+
} else if (col.type && (col.type.toUpperCase().includes('INT') || col.type.toUpperCase().includes('NUM'))) {
|
|
983
|
+
payload[col.name] = val.includes('.') ? parseFloat(val) : parseInt(val, 10);
|
|
984
|
+
} else {
|
|
985
|
+
payload[col.name] = val;
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
const res = await fetch(`${this.basePath}/api/tables/${encodeURIComponent(this.activeTable)}`, {
|
|
991
|
+
method: 'POST',
|
|
992
|
+
headers: { 'Content-Type': 'application/json' },
|
|
993
|
+
body: JSON.stringify({ data: payload })
|
|
994
|
+
});
|
|
995
|
+
const data = await res.json();
|
|
996
|
+
if (data.success) {
|
|
997
|
+
this.showToast('Thành công', 'Đã thêm 1 dòng dữ liệu mới');
|
|
998
|
+
this.closeAddModal();
|
|
999
|
+
this.fetchTableData();
|
|
1000
|
+
} else {
|
|
1001
|
+
this.showToast('Lỗi ghi dữ liệu', data.error, 'error');
|
|
1002
|
+
}
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
this.showToast('Lỗi kết nối', err.message, 'error');
|
|
1005
|
+
} finally {
|
|
1006
|
+
this.loading = false;
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
|
|
1010
|
+
openEditModal(row) {
|
|
1011
|
+
if (this.isReadOnly) return;
|
|
1012
|
+
this.selectedRow = row;
|
|
1013
|
+
this.formData = { ...row };
|
|
1014
|
+
this.editModalOpen = true;
|
|
1015
|
+
this.$nextTick(() => { lucide.createIcons(); });
|
|
1016
|
+
},
|
|
1017
|
+
|
|
1018
|
+
closeEditModal() {
|
|
1019
|
+
this.editModalOpen = false;
|
|
1020
|
+
this.selectedRow = null;
|
|
1021
|
+
},
|
|
1022
|
+
|
|
1023
|
+
async submitEditForm() {
|
|
1024
|
+
this.loading = true;
|
|
1025
|
+
|
|
1026
|
+
// Separate PK and update values
|
|
1027
|
+
const pk = {};
|
|
1028
|
+
const updateData = {};
|
|
1029
|
+
|
|
1030
|
+
this.schema.columns.forEach(col => {
|
|
1031
|
+
const currentVal = this.formData[col.name];
|
|
1032
|
+
|
|
1033
|
+
// Parse numerical columns
|
|
1034
|
+
let finalVal = currentVal;
|
|
1035
|
+
if (currentVal !== '' && currentVal !== null && currentVal !== undefined) {
|
|
1036
|
+
if (col.type && (col.type.toUpperCase().includes('INT') || col.type.toUpperCase().includes('NUM'))) {
|
|
1037
|
+
finalVal = currentVal.toString().includes('.') ? parseFloat(currentVal) : parseInt(currentVal, 10);
|
|
1038
|
+
}
|
|
1039
|
+
} else {
|
|
1040
|
+
finalVal = col.notNull ? '' : null;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (col.primaryKey) {
|
|
1044
|
+
// PK criteria remains unchanged from selected row
|
|
1045
|
+
pk[col.name] = this.selectedRow[col.name];
|
|
1046
|
+
} else {
|
|
1047
|
+
updateData[col.name] = finalVal;
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// If table has no defined PK, use the entire selected row as identification criteria
|
|
1052
|
+
if (Object.keys(pk).length === 0) {
|
|
1053
|
+
Object.assign(pk, this.selectedRow);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
const res = await fetch(`${this.basePath}/api/tables/${encodeURIComponent(this.activeTable)}`, {
|
|
1058
|
+
method: 'PUT',
|
|
1059
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1060
|
+
body: JSON.stringify({ pk, data: updateData })
|
|
1061
|
+
});
|
|
1062
|
+
const data = await res.json();
|
|
1063
|
+
if (data.success) {
|
|
1064
|
+
this.showToast('Thành công', 'Đã lưu các thay đổi');
|
|
1065
|
+
this.closeEditModal();
|
|
1066
|
+
this.fetchTableData();
|
|
1067
|
+
} else {
|
|
1068
|
+
this.showToast('Lỗi cập nhật', data.error, 'error');
|
|
1069
|
+
}
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
this.showToast('Lỗi kết nối', err.message, 'error');
|
|
1072
|
+
} finally {
|
|
1073
|
+
this.loading = false;
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
|
|
1077
|
+
openDeleteModal(row) {
|
|
1078
|
+
if (this.isReadOnly) return;
|
|
1079
|
+
this.selectedRow = row;
|
|
1080
|
+
this.deleteModalOpen = true;
|
|
1081
|
+
this.$nextTick(() => { lucide.createIcons(); });
|
|
1082
|
+
},
|
|
1083
|
+
|
|
1084
|
+
closeDeleteModal() {
|
|
1085
|
+
this.deleteModalOpen = false;
|
|
1086
|
+
this.selectedRow = null;
|
|
1087
|
+
},
|
|
1088
|
+
|
|
1089
|
+
async submitDelete() {
|
|
1090
|
+
this.loading = true;
|
|
1091
|
+
|
|
1092
|
+
// Formulate primary key identifier
|
|
1093
|
+
const pk = {};
|
|
1094
|
+
this.schema.columns.forEach(col => {
|
|
1095
|
+
if (col.primaryKey) {
|
|
1096
|
+
pk[col.name] = this.selectedRow[col.name];
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
// If no PK, use whole row
|
|
1101
|
+
if (Object.keys(pk).length === 0) {
|
|
1102
|
+
Object.assign(pk, this.selectedRow);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
try {
|
|
1106
|
+
const res = await fetch(`${this.basePath}/api/tables/${encodeURIComponent(this.activeTable)}`, {
|
|
1107
|
+
method: 'DELETE',
|
|
1108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1109
|
+
body: JSON.stringify({ pk })
|
|
1110
|
+
});
|
|
1111
|
+
const data = await res.json();
|
|
1112
|
+
if (data.success) {
|
|
1113
|
+
this.showToast('Đã xóa', 'Dòng dữ liệu đã được gỡ bỏ khỏi bảng');
|
|
1114
|
+
this.closeDeleteModal();
|
|
1115
|
+
this.fetchTableData();
|
|
1116
|
+
} else {
|
|
1117
|
+
this.showToast('Lỗi xóa', data.error, 'error');
|
|
1118
|
+
}
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
this.showToast('Lỗi kết nối', err.message, 'error');
|
|
1121
|
+
} finally {
|
|
1122
|
+
this.loading = false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
</script>
|
|
1128
|
+
</body>
|
|
1129
|
+
</html>"""
|
|
1130
|
+
|
|
1131
|
+
return html.replace('${cleanedBasePath}', cleaned_base_path)
|