create-simpleadmin-ui 2.0.0

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.
Files changed (55) hide show
  1. package/README.md +53 -0
  2. package/bin/create-simpleadmin-ui.mjs +2 -0
  3. package/package.json +35 -0
  4. package/src/index.mjs +557 -0
  5. package/src/sync-template.mjs +64 -0
  6. package/template/.env.development +4 -0
  7. package/template/.env.production.example +4 -0
  8. package/template/.vscode/extensions.json +3 -0
  9. package/template/README.md +26 -0
  10. package/template/index.html +19 -0
  11. package/template/package.json +30 -0
  12. package/template/public/vite.svg +1 -0
  13. package/template/src/App.vue +3 -0
  14. package/template/src/components/AppBreadcrumb.vue +132 -0
  15. package/template/src/components/AppPage.vue +23 -0
  16. package/template/src/components/ChangePasswordDialog.vue +97 -0
  17. package/template/src/components/ProfileEditDialog.vue +142 -0
  18. package/template/src/components/RichTextEditor.vue +121 -0
  19. package/template/src/components/SidebarMenuItem.vue +56 -0
  20. package/template/src/components/TagsView.vue +172 -0
  21. package/template/src/constants/menuIcons.ts +205 -0
  22. package/template/src/directives/permission.ts +13 -0
  23. package/template/src/env.d.ts +22 -0
  24. package/template/src/layouts/MainLayout.vue +410 -0
  25. package/template/src/main.ts +27 -0
  26. package/template/src/router/index.ts +198 -0
  27. package/template/src/stores/tagsView.ts +161 -0
  28. package/template/src/stores/theme.ts +68 -0
  29. package/template/src/stores/user.ts +191 -0
  30. package/template/src/styles/global.css +314 -0
  31. package/template/src/utils/datetime.ts +34 -0
  32. package/template/src/utils/request.ts +324 -0
  33. package/template/src/utils/requestSign.ts +162 -0
  34. package/template/src/views/error/NotFound.vue +49 -0
  35. package/template/src/views/home/HomeView.vue +1177 -0
  36. package/template/src/views/login/LoginView.vue +576 -0
  37. package/template/src/views/monitor/loginlog/index.vue +366 -0
  38. package/template/src/views/monitor/online/index.vue +298 -0
  39. package/template/src/views/monitor/operlog/index.vue +492 -0
  40. package/template/src/views/system/config/index.vue +407 -0
  41. package/template/src/views/system/dept/index.vue +517 -0
  42. package/template/src/views/system/dict/index.vue +747 -0
  43. package/template/src/views/system/file/index.vue +1031 -0
  44. package/template/src/views/system/menu/index.vue +822 -0
  45. package/template/src/views/system/message/index.vue +422 -0
  46. package/template/src/views/system/notice/index.vue +578 -0
  47. package/template/src/views/system/openApp/index.vue +596 -0
  48. package/template/src/views/system/post/index.vue +495 -0
  49. package/template/src/views/system/role/index.vue +546 -0
  50. package/template/src/views/system/user/index.vue +373 -0
  51. package/template/src/vite-env.d.ts +1 -0
  52. package/template/tsconfig.app.json +30 -0
  53. package/template/tsconfig.json +7 -0
  54. package/template/tsconfig.node.json +24 -0
  55. package/template/vite.config.ts +22 -0
@@ -0,0 +1,366 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, reactive, ref } from 'vue'
3
+ import { ElMessage, ElMessageBox } from 'element-plus'
4
+ import { Refresh, Search, Key, View, Delete } from '@element-plus/icons-vue'
5
+ import { get, del } from '@/utils/request'
6
+ import AppPage from '@/components/AppPage.vue'
7
+ import { formatDateTime } from '@/utils/datetime'
8
+
9
+ interface LoginLogItem {
10
+ id: number
11
+ userName?: string
12
+ ipAddress?: string
13
+ browser?: string
14
+ os?: string
15
+ status: number
16
+ msg?: string
17
+ loginTime?: string
18
+ }
19
+
20
+ const loading = ref(false)
21
+ const total = ref(0)
22
+ const list = ref<LoginLogItem[]>([])
23
+ const detailVisible = ref(false)
24
+ const current = ref<LoginLogItem | null>(null)
25
+
26
+ const query = reactive({
27
+ pageIndex: 1,
28
+ pageSize: 10,
29
+ keyword: '',
30
+ status: undefined as number | undefined,
31
+ })
32
+
33
+ const stats = reactive({ total: 0, ok: 0, fail: 0 })
34
+
35
+ async function loadStats(pageTotal?: number) {
36
+ const base = { pageIndex: 1, pageSize: 1, keyword: query.keyword || undefined }
37
+ const unfiltered = query.status === undefined || query.status === null
38
+ try {
39
+ const tasks: Promise<void>[] = []
40
+ if (unfiltered && pageTotal != null) {
41
+ stats.total = pageTotal
42
+ } else {
43
+ tasks.push(
44
+ get<{ total: number }>('/login-logs', base).then((all) => {
45
+ stats.total = all.total
46
+ }),
47
+ )
48
+ }
49
+ tasks.push(
50
+ get<{ total: number }>('/login-logs', { ...base, status: 0 }).then((r) => {
51
+ stats.ok = r.total
52
+ }),
53
+ get<{ total: number }>('/login-logs', { ...base, status: 1 }).then((r) => {
54
+ stats.fail = r.total
55
+ }),
56
+ )
57
+ await Promise.all(tasks)
58
+ } catch {
59
+ stats.total = total.value
60
+ }
61
+ }
62
+
63
+ async function load() {
64
+ loading.value = true
65
+ try {
66
+ const params: Record<string, unknown> = {
67
+ pageIndex: query.pageIndex,
68
+ pageSize: query.pageSize,
69
+ keyword: query.keyword || undefined,
70
+ }
71
+ if (query.status !== undefined && query.status !== null) params.status = query.status
72
+ const data = await get<{ total: number; items: LoginLogItem[] }>('/login-logs', params)
73
+ total.value = data.total
74
+ list.value = data.items || []
75
+ await loadStats(data.total)
76
+ } finally {
77
+ loading.value = false
78
+ }
79
+ }
80
+
81
+ function search() {
82
+ query.pageIndex = 1
83
+ load()
84
+ }
85
+
86
+ function resetQuery() {
87
+ query.keyword = ''
88
+ query.status = undefined
89
+ query.pageIndex = 1
90
+ load()
91
+ }
92
+
93
+ function openDetail(row: LoginLogItem) {
94
+ current.value = { ...row }
95
+ detailVisible.value = true
96
+ }
97
+
98
+ async function remove(row: LoginLogItem) {
99
+ await ElMessageBox.confirm(`确认删除账号「${row.userName || row.id}」的登录日志?`, '提示', {
100
+ type: 'warning',
101
+ })
102
+ await del('/login-logs', { ids: [row.id] })
103
+ ElMessage.success('已删除')
104
+ load()
105
+ }
106
+
107
+ onMounted(load)
108
+ </script>
109
+
110
+ <template>
111
+ <AppPage title="登录日志" description="登录成功与失败记录,便于安全审计">
112
+ <div class="mon-stats">
113
+ <div class="mon-stats__item">
114
+ <span class="mon-stats__num">{{ stats.total }}</span>
115
+ <span class="mon-stats__label">全部记录</span>
116
+ </div>
117
+ <div class="mon-stats__item mon-stats__item--ok">
118
+ <span class="mon-stats__num">{{ stats.ok }}</span>
119
+ <span class="mon-stats__label">登录成功</span>
120
+ </div>
121
+ <div class="mon-stats__item mon-stats__item--fail">
122
+ <span class="mon-stats__num">{{ stats.fail }}</span>
123
+ <span class="mon-stats__label">登录失败</span>
124
+ </div>
125
+ <div class="mon-stats__item mon-stats__item--hit">
126
+ <span class="mon-stats__num">{{ total }}</span>
127
+ <span class="mon-stats__label">当前筛选</span>
128
+ </div>
129
+ </div>
130
+
131
+ <div class="toolbar">
132
+ <el-input
133
+ v-model="query.keyword"
134
+ clearable
135
+ placeholder="搜索账号 / IP"
136
+ style="width: 200px"
137
+ @keyup.enter="search"
138
+ />
139
+ <el-select v-model="query.status" clearable placeholder="状态" style="width: 120px" @change="search">
140
+ <el-option label="成功" :value="0" />
141
+ <el-option label="失败" :value="1" />
142
+ </el-select>
143
+ <el-button type="primary" :icon="Search" @click="search">查询</el-button>
144
+ <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
145
+ </div>
146
+
147
+ <div class="table-wrap" v-loading="loading">
148
+ <el-table :data="list" border stripe empty-text="暂无登录日志">
149
+ <el-table-column label="账号" min-width="140" show-overflow-tooltip>
150
+ <template #default="{ row }">
151
+ <div class="name-cell">
152
+ <span class="name-cell__icon" :data-ok="row.status === 0">
153
+ <el-icon :size="14"><Key /></el-icon>
154
+ </span>
155
+ <strong>{{ row.userName || '—' }}</strong>
156
+ </div>
157
+ </template>
158
+ </el-table-column>
159
+ <el-table-column label="IP" width="140" show-overflow-tooltip>
160
+ <template #default="{ row }">
161
+ <code class="mono">{{ row.ipAddress || '—' }}</code>
162
+ </template>
163
+ </el-table-column>
164
+ <el-table-column prop="browser" label="浏览器" min-width="140" show-overflow-tooltip>
165
+ <template #default="{ row }">
166
+ {{ row.browser || '—' }}
167
+ </template>
168
+ </el-table-column>
169
+ <el-table-column prop="os" label="系统" min-width="120" show-overflow-tooltip>
170
+ <template #default="{ row }">
171
+ {{ row.os || '—' }}
172
+ </template>
173
+ </el-table-column>
174
+ <el-table-column label="状态" width="88" align="center">
175
+ <template #default="{ row }">
176
+ <el-tag size="small" :type="row.status === 0 ? 'success' : 'danger'">
177
+ {{ row.status === 0 ? '成功' : '失败' }}
178
+ </el-tag>
179
+ </template>
180
+ </el-table-column>
181
+ <el-table-column prop="msg" label="消息" min-width="140" show-overflow-tooltip>
182
+ <template #default="{ row }">
183
+ <span :class="row.status === 0 ? 'muted' : 'fail-msg'">{{ row.msg || '—' }}</span>
184
+ </template>
185
+ </el-table-column>
186
+ <el-table-column label="时间" width="170" class-name="is-datetime" show-overflow-tooltip>
187
+ <template #default="{ row }">
188
+ <span class="datetime-cell">{{ formatDateTime(row.loginTime) }}</span>
189
+ </template>
190
+ </el-table-column>
191
+ <el-table-column label="操作" width="180" fixed="right" align="center">
192
+ <template #default="{ row }">
193
+ <div class="row-actions">
194
+ <el-button size="small" type="primary" :icon="View" @click="openDetail(row)">详情</el-button>
195
+ <el-button
196
+ size="small"
197
+ type="danger"
198
+ :icon="Delete"
199
+ v-permission="'monitor:loginlog:remove'"
200
+ @click="remove(row)"
201
+ >
202
+ 删除
203
+ </el-button>
204
+ </div>
205
+ </template>
206
+ </el-table-column>
207
+ </el-table>
208
+ </div>
209
+
210
+ <div class="pager">
211
+ <el-pagination
212
+ background
213
+ layout="total, sizes, prev, pager, next"
214
+ :total="total"
215
+ v-model:current-page="query.pageIndex"
216
+ v-model:page-size="query.pageSize"
217
+ :page-sizes="[10, 20, 50]"
218
+ @current-change="load"
219
+ @size-change="
220
+ () => {
221
+ query.pageIndex = 1
222
+ load()
223
+ }
224
+ "
225
+ />
226
+ </div>
227
+
228
+ <el-dialog
229
+ v-model="detailVisible"
230
+ title="登录日志详情"
231
+ width="520px"
232
+ class="sa-dialog"
233
+ append-to-body
234
+ align-center
235
+ destroy-on-close
236
+ >
237
+ <template v-if="current">
238
+ <el-descriptions :column="1" border>
239
+ <el-descriptions-item label="账号">{{ current.userName || '—' }}</el-descriptions-item>
240
+ <el-descriptions-item label="IP">{{ current.ipAddress || '—' }}</el-descriptions-item>
241
+ <el-descriptions-item label="浏览器">{{ current.browser || '—' }}</el-descriptions-item>
242
+ <el-descriptions-item label="系统">{{ current.os || '—' }}</el-descriptions-item>
243
+ <el-descriptions-item label="状态">
244
+ <el-tag size="small" :type="current.status === 0 ? 'success' : 'danger'">
245
+ {{ current.status === 0 ? '成功' : '失败' }}
246
+ </el-tag>
247
+ </el-descriptions-item>
248
+ <el-descriptions-item label="消息">{{ current.msg || '—' }}</el-descriptions-item>
249
+ <el-descriptions-item label="时间">{{ formatDateTime(current.loginTime) }}</el-descriptions-item>
250
+ </el-descriptions>
251
+ </template>
252
+ <template #footer>
253
+ <el-button @click="detailVisible = false">关闭</el-button>
254
+ </template>
255
+ </el-dialog>
256
+ </AppPage>
257
+ </template>
258
+
259
+ <style scoped lang="scss">
260
+ .mon-stats {
261
+ display: grid;
262
+ grid-template-columns: repeat(4, minmax(0, 1fr));
263
+ gap: 12px;
264
+ margin-bottom: 14px;
265
+ }
266
+ .mon-stats__item {
267
+ display: flex;
268
+ flex-direction: column;
269
+ gap: 4px;
270
+ padding: 14px 16px;
271
+ border-radius: 14px;
272
+ border: 1px solid var(--sa-border);
273
+ background: var(--sa-panel);
274
+ box-shadow: var(--sa-shadow);
275
+ }
276
+ .mon-stats__num {
277
+ font-size: 22px;
278
+ font-weight: 800;
279
+ letter-spacing: -0.03em;
280
+ color: var(--sa-ink);
281
+ font-variant-numeric: tabular-nums;
282
+ }
283
+ .mon-stats__label {
284
+ font-size: 12px;
285
+ color: var(--sa-ink-muted);
286
+ }
287
+ .mon-stats__item--ok .mon-stats__num {
288
+ color: var(--sa-accent);
289
+ }
290
+ .mon-stats__item--fail .mon-stats__num {
291
+ color: #be123c;
292
+ }
293
+ .mon-stats__item--hit .mon-stats__num {
294
+ color: #0369a1;
295
+ }
296
+ .toolbar {
297
+ display: flex;
298
+ flex-wrap: wrap;
299
+ gap: 10px;
300
+ align-items: center;
301
+ margin-bottom: 14px;
302
+ padding: 12px 14px;
303
+ background: var(--sa-toolbar-bg);
304
+ border: 1px solid var(--sa-border);
305
+ border-radius: var(--sa-radius-sm);
306
+ }
307
+ .table-wrap {
308
+ border: 1px solid var(--sa-border);
309
+ border-radius: 14px;
310
+ overflow: hidden;
311
+ background: var(--sa-panel);
312
+ box-shadow: var(--sa-shadow);
313
+ }
314
+ .name-cell {
315
+ display: inline-flex;
316
+ align-items: center;
317
+ gap: 8px;
318
+ }
319
+ .name-cell__icon {
320
+ width: 26px;
321
+ height: 26px;
322
+ border-radius: 8px;
323
+ display: grid;
324
+ place-items: center;
325
+ background: #fef2f2;
326
+ color: #dc2626;
327
+ }
328
+ .name-cell__icon[data-ok='true'] {
329
+ background: var(--sa-accent-soft);
330
+ color: var(--sa-accent);
331
+ }
332
+ .name-cell strong {
333
+ font-size: 13px;
334
+ font-weight: 650;
335
+ }
336
+ .mono {
337
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
338
+ font-size: 12px;
339
+ color: var(--sa-ink-secondary);
340
+ background: var(--sa-toolbar-bg);
341
+ padding: 1px 6px;
342
+ border-radius: 6px;
343
+ }
344
+ .muted {
345
+ color: var(--sa-ink-muted);
346
+ }
347
+ .fail-msg {
348
+ color: #be123c;
349
+ }
350
+ .pager {
351
+ display: flex;
352
+ justify-content: flex-end;
353
+ margin-top: 14px;
354
+ }
355
+ @media (max-width: 900px) {
356
+ .mon-stats {
357
+ grid-template-columns: repeat(2, minmax(0, 1fr));
358
+ }
359
+ }
360
+ </style>
361
+
362
+ <style>
363
+ .sa-dialog.el-dialog {
364
+ width: min(520px, calc(100vw - 32px)) !important;
365
+ }
366
+ </style>
@@ -0,0 +1,298 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import { ElMessage, ElMessageBox } from 'element-plus'
4
+ import { Refresh, Search, Connection, SwitchButton } from '@element-plus/icons-vue'
5
+ import { get, del } from '@/utils/request'
6
+ import AppPage from '@/components/AppPage.vue'
7
+ import { formatDateTime } from '@/utils/datetime'
8
+ import { useUserStore } from '@/stores/user'
9
+
10
+ interface OnlineUser {
11
+ jti: string
12
+ userId: number
13
+ userName: string
14
+ nickName?: string
15
+ ipAddress?: string
16
+ loginTime: string
17
+ }
18
+
19
+ const loading = ref(false)
20
+ const list = ref<OnlineUser[]>([])
21
+ const keyword = ref('')
22
+ const selected = ref<OnlineUser[]>([])
23
+ const userStore = useUserStore()
24
+
25
+ const filtered = computed(() => {
26
+ const q = keyword.value.trim().toLowerCase()
27
+ if (!q) return list.value
28
+ return list.value.filter(
29
+ (u) =>
30
+ u.userName?.toLowerCase().includes(q) ||
31
+ u.nickName?.toLowerCase().includes(q) ||
32
+ u.ipAddress?.toLowerCase().includes(q),
33
+ )
34
+ })
35
+
36
+ const stats = computed(() => {
37
+ const total = list.value.length
38
+ const shown = filtered.value.length
39
+ const me = list.value.filter((u) => isSelf(u)).length
40
+ return { total, shown, me }
41
+ })
42
+
43
+ async function load() {
44
+ loading.value = true
45
+ try {
46
+ list.value = (await get<OnlineUser[]>('/online-users')) || []
47
+ selected.value = []
48
+ } finally {
49
+ loading.value = false
50
+ }
51
+ }
52
+
53
+ function onSelectionChange(rows: OnlineUser[]) {
54
+ selected.value = rows
55
+ }
56
+
57
+ function isSelf(row: OnlineUser) {
58
+ const u = userStore.userInfo
59
+ if (!u) return false
60
+ return row.userId === u.userId || row.userName === u.userName
61
+ }
62
+
63
+ async function forceLogout(rows: OnlineUser[]) {
64
+ if (!rows.length) {
65
+ ElMessage.warning('请选择要强退的用户')
66
+ return
67
+ }
68
+ const others = rows.filter((r) => !isSelf(r))
69
+ if (!others.length) {
70
+ ElMessage.warning('不能强制退出当前登录会话')
71
+ return
72
+ }
73
+ const names = others.map((r) => r.userName || r.nickName).join('、')
74
+ await ElMessageBox.confirm(`确认强制下线:${names}?`, '强退确认', { type: 'warning' })
75
+ await del('/online-users', others.map((r) => r.jti) as unknown as object)
76
+ ElMessage.success('已强制下线')
77
+ load()
78
+ }
79
+
80
+ function forceOne(row: OnlineUser) {
81
+ forceLogout([row])
82
+ }
83
+
84
+ function forceSelected() {
85
+ forceLogout(selected.value)
86
+ }
87
+
88
+ onMounted(load)
89
+ </script>
90
+
91
+ <template>
92
+ <AppPage title="在线用户" description="查看当前会话,支持强制下线">
93
+ <div class="mon-stats">
94
+ <div class="mon-stats__item">
95
+ <span class="mon-stats__num">{{ stats.total }}</span>
96
+ <span class="mon-stats__label">在线会话</span>
97
+ </div>
98
+ <div class="mon-stats__item mon-stats__item--hit">
99
+ <span class="mon-stats__num">{{ stats.shown }}</span>
100
+ <span class="mon-stats__label">当前筛选</span>
101
+ </div>
102
+ <div class="mon-stats__item mon-stats__item--ok">
103
+ <span class="mon-stats__num">{{ stats.me || 0 }}</span>
104
+ <span class="mon-stats__label">我的会话</span>
105
+ </div>
106
+ <div class="mon-stats__item mon-stats__item--sel">
107
+ <span class="mon-stats__num">{{ selected.length }}</span>
108
+ <span class="mon-stats__label">已选择</span>
109
+ </div>
110
+ </div>
111
+
112
+ <div class="toolbar">
113
+ <el-input
114
+ v-model="keyword"
115
+ clearable
116
+ placeholder="搜索账号 / 昵称 / IP"
117
+ style="width: 240px"
118
+ :prefix-icon="Search"
119
+ />
120
+ <el-button type="primary" :icon="Refresh" @click="load">刷新</el-button>
121
+ <div class="toolbar__spacer" />
122
+ <el-button
123
+ type="danger"
124
+ plain
125
+ :icon="SwitchButton"
126
+ :disabled="!selected.length"
127
+ v-permission="'monitor:online:forceLogout'"
128
+ @click="forceSelected"
129
+ >
130
+ 批量强退
131
+ </el-button>
132
+ </div>
133
+
134
+ <div class="table-wrap" v-loading="loading">
135
+ <el-table
136
+ :data="filtered"
137
+ border
138
+ stripe
139
+ empty-text="当前没有在线用户"
140
+ row-key="jti"
141
+ @selection-change="onSelectionChange"
142
+ >
143
+ <el-table-column type="selection" width="48" :selectable="(row: OnlineUser) => !isSelf(row)" />
144
+ <el-table-column label="账号" min-width="140" show-overflow-tooltip>
145
+ <template #default="{ row }">
146
+ <div class="name-cell">
147
+ <span class="name-cell__icon" :data-self="isSelf(row)">
148
+ <el-icon :size="14"><Connection /></el-icon>
149
+ </span>
150
+ <div class="name-cell__text">
151
+ <strong>{{ row.userName }}</strong>
152
+ <el-tag v-if="isSelf(row)" size="small" type="success" effect="plain">当前</el-tag>
153
+ </div>
154
+ </div>
155
+ </template>
156
+ </el-table-column>
157
+ <el-table-column prop="nickName" label="昵称" min-width="120" show-overflow-tooltip>
158
+ <template #default="{ row }">
159
+ {{ row.nickName || '—' }}
160
+ </template>
161
+ </el-table-column>
162
+ <el-table-column label="IP" width="140" show-overflow-tooltip>
163
+ <template #default="{ row }">
164
+ <code class="mono">{{ row.ipAddress || '—' }}</code>
165
+ </template>
166
+ </el-table-column>
167
+ <el-table-column label="登录时间" width="170" class-name="is-datetime" show-overflow-tooltip>
168
+ <template #default="{ row }">
169
+ <span class="datetime-cell">{{ formatDateTime(row.loginTime) }}</span>
170
+ </template>
171
+ </el-table-column>
172
+ <el-table-column label="会话 Id" min-width="160" show-overflow-tooltip>
173
+ <template #default="{ row }">
174
+ <code class="mono">{{ row.jti }}</code>
175
+ </template>
176
+ </el-table-column>
177
+ <el-table-column label="操作" width="110" fixed="right" align="center">
178
+ <template #default="{ row }">
179
+ <div class="row-actions">
180
+ <el-button
181
+ size="small"
182
+ type="danger"
183
+ :icon="SwitchButton"
184
+ :disabled="isSelf(row)"
185
+ v-permission="'monitor:online:forceLogout'"
186
+ @click="forceOne(row)"
187
+ >
188
+ 强退
189
+ </el-button>
190
+ </div>
191
+ </template>
192
+ </el-table-column>
193
+ </el-table>
194
+ </div>
195
+ </AppPage>
196
+ </template>
197
+
198
+ <style scoped lang="scss">
199
+ .mon-stats {
200
+ display: grid;
201
+ grid-template-columns: repeat(4, minmax(0, 1fr));
202
+ gap: 12px;
203
+ margin-bottom: 14px;
204
+ }
205
+ .mon-stats__item {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 4px;
209
+ padding: 14px 16px;
210
+ border-radius: 14px;
211
+ border: 1px solid var(--sa-border);
212
+ background: var(--sa-panel);
213
+ box-shadow: var(--sa-shadow);
214
+ }
215
+ .mon-stats__num {
216
+ font-size: 22px;
217
+ font-weight: 800;
218
+ letter-spacing: -0.03em;
219
+ color: var(--sa-ink);
220
+ font-variant-numeric: tabular-nums;
221
+ }
222
+ .mon-stats__label {
223
+ font-size: 12px;
224
+ color: var(--sa-ink-muted);
225
+ }
226
+ .mon-stats__item--ok .mon-stats__num {
227
+ color: var(--sa-accent);
228
+ }
229
+ .mon-stats__item--hit .mon-stats__num {
230
+ color: #0369a1;
231
+ }
232
+ .mon-stats__item--sel .mon-stats__num {
233
+ color: #b45309;
234
+ }
235
+ .toolbar {
236
+ display: flex;
237
+ flex-wrap: wrap;
238
+ gap: 10px;
239
+ align-items: center;
240
+ margin-bottom: 14px;
241
+ padding: 12px 14px;
242
+ background: var(--sa-toolbar-bg);
243
+ border: 1px solid var(--sa-border);
244
+ border-radius: var(--sa-radius-sm);
245
+ }
246
+ .toolbar__spacer {
247
+ flex: 1;
248
+ }
249
+ .table-wrap {
250
+ border: 1px solid var(--sa-border);
251
+ border-radius: 14px;
252
+ overflow: hidden;
253
+ background: var(--sa-panel);
254
+ box-shadow: var(--sa-shadow);
255
+ }
256
+ .name-cell {
257
+ display: inline-flex;
258
+ align-items: center;
259
+ gap: 8px;
260
+ }
261
+ .name-cell__icon {
262
+ width: 26px;
263
+ height: 26px;
264
+ border-radius: 8px;
265
+ display: grid;
266
+ place-items: center;
267
+ background: #eff6ff;
268
+ color: #2563eb;
269
+ flex-shrink: 0;
270
+ }
271
+ .name-cell__icon[data-self='true'] {
272
+ background: var(--sa-accent-soft);
273
+ color: var(--sa-accent);
274
+ }
275
+ .name-cell__text {
276
+ display: inline-flex;
277
+ align-items: center;
278
+ gap: 6px;
279
+ min-width: 0;
280
+ }
281
+ .name-cell strong {
282
+ font-size: 13px;
283
+ font-weight: 650;
284
+ }
285
+ .mono {
286
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
287
+ font-size: 12px;
288
+ color: var(--sa-ink-secondary);
289
+ background: var(--sa-toolbar-bg);
290
+ padding: 1px 6px;
291
+ border-radius: 6px;
292
+ }
293
+ @media (max-width: 900px) {
294
+ .mon-stats {
295
+ grid-template-columns: repeat(2, minmax(0, 1fr));
296
+ }
297
+ }
298
+ </style>