@usethink/cf-admin-fe 0.1.0 → 0.2.6

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +242 -53
  3. package/dist/composables/createAdminFlagSession.d.ts +42 -0
  4. package/dist/composables/createAdminRequest.d.ts +95 -0
  5. package/dist/composables/createAdminTokenSession.d.ts +49 -0
  6. package/dist/composables/index.d.ts +7 -1
  7. package/dist/composables/useAdminListLoader.d.ts +45 -0
  8. package/dist/composables/useAdminSidebar.d.ts +17 -0
  9. package/dist/composables/useClipboard.d.ts +24 -2
  10. package/dist/composables/useConfirmDialog.d.ts +10 -4
  11. package/dist/composables/useNow.d.ts +3 -0
  12. package/dist/composables/useTablePagination.d.ts +6 -0
  13. package/dist/composables/useTableSelection.d.ts +2 -2
  14. package/dist/composables/useToast.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/system-config.d.ts +34 -0
  17. package/dist/utils/admin-navigation.d.ts +66 -0
  18. package/dist/utils/admin-query.d.ts +29 -0
  19. package/dist/utils/batch-limit.d.ts +38 -0
  20. package/dist/utils/batch-result.d.ts +41 -0
  21. package/dist/utils/body-scroll-lock.d.ts +13 -0
  22. package/dist/utils/csv-export.d.ts +8 -0
  23. package/dist/utils/currency-cents.d.ts +16 -0
  24. package/dist/utils/datetime.d.ts +20 -0
  25. package/dist/utils/error-message.d.ts +7 -0
  26. package/dist/utils/format-metadata.d.ts +5 -0
  27. package/dist/utils/index.d.ts +12 -0
  28. package/dist/utils/otp-input.d.ts +11 -0
  29. package/dist/utils/system-config-engine.d.ts +65 -0
  30. package/docs/001_cf-admin-fe/346/217/220/345/217/226/345/267/256/350/267/235/345/210/206/346/236/220/346/212/245/345/221/212_2026-08-05.md +224 -0
  31. package/docs/002_cf-admin-fe/344/275/277/347/224/250/350/257/264/346/230/216/344/271/246/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.md +801 -0
  32. package/package.json +30 -6
  33. package/recipes/AdminLayout.vue +73 -0
  34. package/recipes/README.md +130 -0
  35. package/recipes/i18n-merge.snippet.ts +29 -0
  36. package/recipes/list-page.snippet.ts +141 -0
  37. package/recipes/main-styles.snippet.ts +13 -0
  38. package/recipes/reexports/components.ts +19 -0
  39. package/recipes/reexports/composables.ts +29 -0
  40. package/recipes/reexports/utils.ts +31 -0
  41. package/recipes/router-guard.snippet.ts +32 -0
  42. package/recipes/useAdminAuth.cookie.ts +29 -0
  43. package/recipes/useAdminAuth.shop.ts +29 -0
  44. package/recipes/useAdminRequest.cookie.ts +36 -0
  45. package/recipes/useAdminRequest.ts +37 -0
  46. package/src/components/AdminLoginShell.vue +31 -0
  47. package/src/components/AdminMetadataDetail.vue +121 -0
  48. package/src/components/AdminShell.vue +396 -0
  49. package/src/components/ConfigField.vue +401 -0
  50. package/src/components/ConfirmDialog.vue +67 -3
  51. package/src/components/index.ts +6 -0
  52. package/src/composables/createAdminFlagSession.ts +129 -0
  53. package/src/composables/createAdminRequest.ts +322 -0
  54. package/src/composables/createAdminTokenSession.ts +149 -0
  55. package/src/composables/index.ts +51 -0
  56. package/src/composables/useAdminBatchOperation.ts +4 -0
  57. package/src/composables/useAdminListLoader.ts +124 -0
  58. package/src/composables/useAdminSidebar.ts +68 -0
  59. package/src/composables/useClipboard.ts +41 -2
  60. package/src/composables/useConfirmDialog.ts +26 -4
  61. package/src/composables/useNow.ts +38 -0
  62. package/src/composables/useTablePagination.ts +6 -0
  63. package/src/composables/useTableSelection.ts +2 -2
  64. package/src/composables/useToast.ts +4 -1
  65. package/src/env.d.ts +2 -2
  66. package/src/i18n/index.ts +8 -0
  67. package/src/i18n/kit-messages.ts +297 -0
  68. package/src/index.ts +143 -4
  69. package/src/styles/admin-primitives.css +23 -0
  70. package/src/styles/index.css +1 -0
  71. package/src/styles/login-primitives.css +149 -0
  72. package/src/types/index.ts +7 -0
  73. package/src/types/system-config.ts +38 -0
  74. package/src/utils/admin-navigation.ts +138 -0
  75. package/src/utils/admin-query.ts +51 -0
  76. package/src/utils/batch-limit.ts +66 -0
  77. package/src/utils/batch-result.ts +81 -0
  78. package/src/utils/body-scroll-lock.ts +63 -0
  79. package/src/utils/csv-export.ts +25 -0
  80. package/src/utils/currency-cents.ts +30 -0
  81. package/src/utils/datetime.ts +54 -0
  82. package/src/utils/error-message.ts +32 -0
  83. package/src/utils/format-metadata.ts +11 -0
  84. package/src/utils/index.ts +81 -0
  85. package/src/utils/otp-input.ts +18 -0
  86. package/src/utils/system-config-engine.ts +212 -0
  87. package/dist/components/index.d.ts +0 -4
  88. package/dist/index.d.ts +0 -17
@@ -0,0 +1,36 @@
1
+ /**
2
+ * RECIPE — cookie 会话 adminRequest(cf-auth / Better Auth 风格)。
3
+ *
4
+ * 工具包一等路径(与 Bearer 对称):
5
+ * - createAdminFlagSession — 本地守卫标志(不是 HTTP cookie)
6
+ * - createAdminRequest({ credentials: 'include', onSuccess 修复, onUnauthorized 清除 })
7
+ *
8
+ * PRODUCT 仍负责:健康探测、登录/登出、OAuth、ADMIN_* 允许列表、领域路径。
9
+ */
10
+ import {
11
+ createAdminFlagSession,
12
+ createAdminRequest,
13
+ } from '@usethink/cf-admin-fe/composables'
14
+ import { createAdminUnauthorizedHandler } from '@usethink/cf-admin-fe/utils'
15
+
16
+ // PRODUCT: 本地管理员守卫标志的存储 key
17
+ export const adminFlagSession = createAdminFlagSession({
18
+ storageKey: 'cf_auth_admin_ok',
19
+ })
20
+
21
+ const handleUnauthorized = createAdminUnauthorizedHandler({
22
+ clearSession: () => adminFlagSession.clearSession(),
23
+ loginPath: '/admin/login',
24
+ defaultRedirectPath: '/admin',
25
+ })
26
+
27
+ export const adminRequest = createAdminRequest({
28
+ credentials: 'include',
29
+ resultMode: 'ok-envelope',
30
+ errorField: 'message',
31
+ onUnauthorized: handleUnauthorized,
32
+ // 任何管理员调用成功后修复缺失的标志(会话 cookie 已有效)
33
+ onSuccess: () => {
34
+ if (!adminFlagSession.readFlag()) adminFlagSession.markLoggedIn()
35
+ },
36
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * RECIPE — 可注入的管理员 fetch(抽奖风格 ok-envelope)。
3
+ *
4
+ * Shop 风格:使用 `resultMode: 'http-ok'`、`errorField: 'error'`,并在领域
5
+ * API 方法中逐次调用传入 `token`(参见 cf-shop `api/admin.ts`)。
6
+ *
7
+ * 复制到 `src/composables/useAdminRequest.ts`。
8
+ */
9
+ import {
10
+ createAdminRequest,
11
+ type AdminRequestFn,
12
+ } from '@usethink/cf-admin-fe/composables'
13
+ import { createAdminUnauthorizedHandler } from '@usethink/cf-admin-fe/utils'
14
+ import { readAdminToken, useAdminAuth } from './useAdminAuth'
15
+
16
+ // 接入产品会话清理 — shop 通常只删除 admin_token;
17
+ // 抽奖产品可能调用完整业务会话清空。
18
+ function clearProductSession() {
19
+ try {
20
+ useAdminAuth().clearToken()
21
+ } catch {
22
+ /* 忽略 */
23
+ }
24
+ }
25
+
26
+ const handleUnauthorized = createAdminUnauthorizedHandler({
27
+ clearSession: clearProductSession,
28
+ loginPath: '/admin/login',
29
+ defaultRedirectPath: '/admin',
30
+ })
31
+
32
+ export const adminRequest: AdminRequestFn = createAdminRequest({
33
+ getToken: () => readAdminToken(),
34
+ resultMode: 'ok-envelope',
35
+ errorField: 'message',
36
+ onUnauthorized: handleUnauthorized,
37
+ })
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 仅提供管理端登录页的展示性外壳。
4
+ * 认证流程(token / 密码 / OTP / OAuth)由业务产品自行实现。
5
+ */
6
+ defineProps<{
7
+ /** #title 插槽为空时的回退标题 */
8
+ title?: string
9
+ /** #subtitle 插槽为空时的回退副标题 */
10
+ subtitle?: string
11
+ }>()
12
+ </script>
13
+
14
+ <template>
15
+ <div class="admin-login">
16
+ <div class="login-card">
17
+ <div v-if="$slots.title || $slots.subtitle || title || subtitle" class="login-header">
18
+ <slot name="title">
19
+ <h1 v-if="title" class="login-title">{{ title }}</h1>
20
+ </slot>
21
+ <slot name="subtitle">
22
+ <p v-if="subtitle" class="login-subtitle">{{ subtitle }}</p>
23
+ </slot>
24
+ </div>
25
+ <slot />
26
+ <div v-if="$slots.footer" class="login-footer">
27
+ <slot name="footer" />
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </template>
@@ -0,0 +1,121 @@
1
+ <template>
2
+ <AdminModal v-model="innerVisible" :title="$t('adminLogs.metadataTitle')" max-width="640px">
3
+ <div v-if="log" class="detail-box">
4
+ <div class="detail-section">
5
+ <div class="detail-title">{{ $t('adminLogs.basicInfo') }}</div>
6
+ <div class="detail-row">
7
+ <span class="detail-label">{{ $t('adminLogs.labelAction') }}</span>
8
+ <span class="detail-value">{{ log.action }}</span>
9
+ </div>
10
+ <div class="detail-row">
11
+ <span class="detail-label">{{ $t('adminLogs.labelTargetType') }}</span>
12
+ <span class="detail-value">{{ log.targetType }}</span>
13
+ </div>
14
+ <div class="detail-row">
15
+ <span class="detail-label">{{ $t('adminLogs.labelTargetId') }}</span>
16
+ <span class="detail-value">{{ log.targetId }}</span>
17
+ </div>
18
+ <div class="detail-row">
19
+ <span class="detail-label">{{ $t('adminLogs.labelTime') }}</span>
20
+ <span class="detail-value">{{ formatDate(log.createdAt) }}</span>
21
+ </div>
22
+ <div class="detail-row">
23
+ <span class="detail-label">{{ $t('adminLogs.labelIpFingerprint') }}</span>
24
+ <span class="detail-value">{{ log.ipHash || '-' }}</span>
25
+ </div>
26
+ </div>
27
+ <div v-if="log.metadata && Object.keys(log.metadata).length > 0" class="detail-section">
28
+ <div class="detail-title">{{ $t('adminLogs.labelMetadata') }}</div>
29
+ <pre class="detail-json">{{ formatMetadata(log.metadata) }}</pre>
30
+ </div>
31
+ </div>
32
+ </AdminModal>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { ref, watch } from 'vue'
37
+ import AdminModal from './AdminModal.vue'
38
+ import { formatDate } from '../utils/datetime'
39
+ import { formatMetadata } from '../utils/format-metadata'
40
+
41
+ export interface AdminLogEntry {
42
+ action: string
43
+ targetType: string
44
+ targetId: string
45
+ createdAt: string
46
+ ipHash?: string | null
47
+ metadata?: Record<string, unknown> | null
48
+ }
49
+
50
+ const props = withDefaults(defineProps<{
51
+ modelValue: boolean
52
+ log: AdminLogEntry | null
53
+ }>(), {
54
+ modelValue: false,
55
+ log: null,
56
+ })
57
+
58
+ const emit = defineEmits<{
59
+ 'update:modelValue': [value: boolean]
60
+ }>()
61
+
62
+ const innerVisible = ref(false)
63
+
64
+ watch(() => props.modelValue, (val) => {
65
+ innerVisible.value = val
66
+ }, { immediate: true })
67
+
68
+ watch(innerVisible, (val, wasVisible) => {
69
+ if (!val && wasVisible && props.modelValue) {
70
+ emit('update:modelValue', false)
71
+ }
72
+ })
73
+ </script>
74
+
75
+ <style scoped>
76
+ .detail-box {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 16px;
80
+ }
81
+ .detail-section {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 8px;
85
+ }
86
+ .detail-title {
87
+ font-size: 13px;
88
+ font-weight: 600;
89
+ color: var(--tg-hint, #9aa4b2);
90
+ text-transform: uppercase;
91
+ letter-spacing: 0.04em;
92
+ }
93
+ .detail-row {
94
+ display: flex;
95
+ align-items: flex-start;
96
+ gap: 8px;
97
+ font-size: 13px;
98
+ line-height: 1.55;
99
+ }
100
+ .detail-label {
101
+ flex-shrink: 0;
102
+ width: 100px;
103
+ color: var(--tg-hint, #9aa4b2);
104
+ }
105
+ .detail-value {
106
+ color: var(--tg-text, #f0f2f5);
107
+ word-break: break-all;
108
+ }
109
+ .detail-json {
110
+ margin: 0;
111
+ padding: 10px 12px;
112
+ border-radius: var(--r-sm, 6px);
113
+ background: rgba(0, 0, 0, 0.2);
114
+ font-size: 12px;
115
+ line-height: 1.55;
116
+ overflow-x: auto;
117
+ white-space: pre-wrap;
118
+ word-break: break-all;
119
+ color: var(--tg-text, #f0f2f5);
120
+ }
121
+ </style>
@@ -0,0 +1,396 @@
1
+ <template>
2
+ <div class="admin-layout">
3
+ <div
4
+ v-if="isMobile && sidebarOpen"
5
+ class="sidebar-overlay"
6
+ @click="closeSidebar"
7
+ />
8
+
9
+ <aside class="admin-sidebar" :class="{ 'sidebar-open': sidebarOpen }">
10
+ <div class="sidebar-brand">
11
+ <slot name="brand">{{ brand }}</slot>
12
+ </div>
13
+
14
+ <nav class="sidebar-nav" @click="isMobile && closeSidebar()">
15
+ <slot name="nav" :items="menuItems" :current-name="currentName" :on-nav="onNavClick">
16
+ <a
17
+ v-for="item in menuItems"
18
+ :key="item.to"
19
+ class="nav-item"
20
+ :class="{ active: currentName === item.name, ...(item.className ? { [item.className]: true } : {}) }"
21
+ :href="item.to"
22
+ :title="item.label"
23
+ @click.prevent="onNavClick(item.to)"
24
+ >
25
+ <span
26
+ v-if="item.iconHtml"
27
+ class="nav-icon"
28
+ v-html="item.iconHtml"
29
+ />
30
+ <span v-else class="nav-icon">{{ item.icon || '' }}</span>
31
+ <span class="nav-label">{{ item.label }}</span>
32
+ </a>
33
+ </slot>
34
+ <slot name="nav-extra" />
35
+ </nav>
36
+
37
+ <!-- 仅当产品显式提供 #sidebar-footer 时渲染(默认用户信息在顶栏右上,省侧栏高度) -->
38
+ <div v-if="$slots['sidebar-footer']" class="sidebar-footer">
39
+ <slot name="sidebar-footer" />
40
+ </div>
41
+ </aside>
42
+
43
+ <div class="admin-main" :class="{ 'sidebar-open': sidebarOpen }">
44
+ <header class="admin-header">
45
+ <div class="admin-header-left">
46
+ <button
47
+ v-if="isMobile"
48
+ class="btn-hamburger"
49
+ type="button"
50
+ :aria-label="resolvedToggleLabel"
51
+ @click="toggleSidebar"
52
+ >
53
+ <span class="hamburger-line" />
54
+ <span class="hamburger-line" />
55
+ <span class="hamburger-line" />
56
+ </button>
57
+ <h1 class="admin-header-title">
58
+ <slot name="title">{{ resolvedTitle }}</slot>
59
+ </h1>
60
+ </div>
61
+ <div class="admin-header-actions">
62
+ <slot name="header-actions" />
63
+ <div v-if="showUser" class="header-user" :title="userName">
64
+ <span class="header-user-avatar" aria-hidden="true">{{ userAvatar }}</span>
65
+ <span class="header-user-name">{{ userName }}</span>
66
+ </div>
67
+ <button
68
+ v-if="showLogout"
69
+ class="btn btn-ghost header-logout"
70
+ type="button"
71
+ @click="$emit('logout')"
72
+ >{{ resolvedLogoutLabel }}</button>
73
+ </div>
74
+ </header>
75
+
76
+ <main class="admin-content">
77
+ <slot />
78
+ </main>
79
+ </div>
80
+
81
+ <slot name="banner" />
82
+ </div>
83
+ </template>
84
+
85
+ <script setup lang="ts">
86
+ import { computed, getCurrentInstance } from 'vue'
87
+ import { useAdminSidebar } from '../composables/useAdminSidebar'
88
+
89
+ export type AdminShellMenuItem = {
90
+ name: string
91
+ to: string
92
+ label: string
93
+ /** 纯文本 / emoji 图标 */
94
+ icon?: string
95
+ /** HTML 实体图标(shop 使用 `&#x1F4CA;`)——尽可能优先使用文本图标 */
96
+ iconHtml?: string
97
+ className?: string
98
+ }
99
+
100
+ const props = withDefaults(
101
+ defineProps<{
102
+ brand?: string
103
+ menuItems?: AdminShellMenuItem[]
104
+ /** 当前激活的路由名;缺省时标题回退为 brand */
105
+ currentName?: string | null
106
+ pageTitle?: string
107
+ userName?: string
108
+ userAvatar?: string
109
+ /** 顶栏右上展示头像+名称(默认 true;侧栏底默认不再占位) */
110
+ showUser?: boolean
111
+ showLogout?: boolean
112
+ logoutLabel?: string
113
+ toggleSidebarLabel?: string
114
+ }>(),
115
+ {
116
+ brand: 'Admin',
117
+ menuItems: () => [],
118
+ currentName: null,
119
+ pageTitle: '',
120
+ userName: 'Admin',
121
+ userAvatar: 'A',
122
+ showUser: true,
123
+ showLogout: true,
124
+ logoutLabel: '',
125
+ toggleSidebarLabel: '',
126
+ },
127
+ )
128
+
129
+ const emit = defineEmits<{
130
+ logout: []
131
+ navigate: [to: string]
132
+ }>()
133
+
134
+ const { isMobile, sidebarOpen, toggleSidebar, closeSidebar } = useAdminSidebar()
135
+
136
+ function tFallback(key: string, fallback: string): string {
137
+ const proxy = getCurrentInstance()?.proxy as { $t?: (k: string) => string } | undefined
138
+ try {
139
+ if (typeof proxy?.$t === 'function') {
140
+ const out = proxy.$t(key)
141
+ if (typeof out === 'string' && out !== key) return out
142
+ }
143
+ } catch {
144
+ /* 无 i18n */
145
+ }
146
+ return fallback
147
+ }
148
+
149
+ const resolvedLogoutLabel = computed(
150
+ () => props.logoutLabel || tFallback('adminLayout.logout', '退出'),
151
+ )
152
+ const resolvedToggleLabel = computed(
153
+ () => props.toggleSidebarLabel || tFallback('adminLayout.toggleSidebar', '菜单'),
154
+ )
155
+
156
+ const resolvedTitle = computed(() => {
157
+ if (props.pageTitle) return props.pageTitle
158
+ const item = props.menuItems.find((m) => m.name === props.currentName)
159
+ return item?.label || props.brand
160
+ })
161
+
162
+ function onNavClick(to: string) {
163
+ emit('navigate', to)
164
+ }
165
+ </script>
166
+
167
+ <style scoped>
168
+ .admin-layout {
169
+ display: flex;
170
+ height: 100vh;
171
+ background: var(--tg-bg, #0a0e17);
172
+ overflow: hidden;
173
+ color-scheme: dark;
174
+ }
175
+
176
+ .sidebar-overlay {
177
+ position: fixed;
178
+ inset: 0;
179
+ background: rgba(0, 0, 0, 0.45);
180
+ z-index: 90;
181
+ }
182
+
183
+ .admin-sidebar {
184
+ width: var(--admin-sidebar-w, 220px);
185
+ background: #1a1a2e;
186
+ color: #fff;
187
+ display: flex;
188
+ flex-direction: column;
189
+ position: fixed;
190
+ inset: 0 auto 0 0;
191
+ z-index: 100;
192
+ overflow-y: auto;
193
+ transform: translateX(0);
194
+ transition: transform 0.2s ease;
195
+ }
196
+
197
+ .sidebar-brand {
198
+ padding: 16px 14px;
199
+ font-size: 15px;
200
+ font-weight: 700;
201
+ letter-spacing: 0.3px;
202
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
203
+ }
204
+
205
+ .sidebar-nav {
206
+ flex: 1;
207
+ padding: 8px 0;
208
+ display: flex;
209
+ flex-direction: column;
210
+ gap: 1px;
211
+ }
212
+
213
+ .nav-item {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ padding: 9px 14px;
218
+ color: rgba(255, 255, 255, 0.72);
219
+ text-decoration: none;
220
+ font-size: 13px;
221
+ transition: background 0.15s ease, color 0.15s ease;
222
+ border-left: 3px solid transparent;
223
+ }
224
+
225
+ .nav-item:hover {
226
+ background: rgba(255, 255, 255, 0.08);
227
+ color: #fff;
228
+ }
229
+
230
+ .nav-item.active {
231
+ background: rgba(255, 255, 255, 0.14);
232
+ color: #fff;
233
+ font-weight: 600;
234
+ border-left-color: var(--admin-accent, #f59e0b);
235
+ }
236
+
237
+ .nav-icon {
238
+ font-size: 16px;
239
+ width: 20px;
240
+ text-align: center;
241
+ }
242
+
243
+ .sidebar-footer {
244
+ padding: 12px 14px;
245
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
246
+ display: flex;
247
+ flex-direction: column;
248
+ gap: 8px;
249
+ flex-shrink: 0;
250
+ }
251
+
252
+ .admin-main {
253
+ flex: 1;
254
+ margin-left: var(--admin-sidebar-w, 220px);
255
+ display: flex;
256
+ flex-direction: column;
257
+ min-width: 0;
258
+ height: 100vh;
259
+ overflow: hidden;
260
+ }
261
+
262
+ .admin-header {
263
+ position: sticky;
264
+ top: 0;
265
+ z-index: 50;
266
+ flex-shrink: 0;
267
+ display: flex;
268
+ align-items: center;
269
+ justify-content: space-between;
270
+ gap: 10px;
271
+ padding: 8px var(--admin-content-pad, 12px);
272
+ border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.1));
273
+ backdrop-filter: saturate(180%) blur(12px);
274
+ background: rgba(var(--tg-bg-rgb, 10, 14, 23), 0.88);
275
+ }
276
+
277
+ .admin-header-left {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 10px;
281
+ min-width: 0;
282
+ }
283
+
284
+ .admin-header-title {
285
+ margin: 0;
286
+ font-size: 16px;
287
+ font-weight: 650;
288
+ color: var(--tg-text, #f0f2f5);
289
+ white-space: nowrap;
290
+ overflow: hidden;
291
+ text-overflow: ellipsis;
292
+ }
293
+
294
+ .admin-header-actions {
295
+ display: flex;
296
+ align-items: center;
297
+ gap: 8px;
298
+ flex-shrink: 0;
299
+ }
300
+
301
+ .header-user {
302
+ display: inline-flex;
303
+ align-items: center;
304
+ gap: 8px;
305
+ max-width: 160px;
306
+ min-width: 0;
307
+ padding: 2px 4px 2px 2px;
308
+ border-radius: 999px;
309
+ }
310
+
311
+ .header-user-avatar {
312
+ width: 26px;
313
+ height: 26px;
314
+ border-radius: 50%;
315
+ background: var(--admin-accent, #f59e0b);
316
+ color: #111;
317
+ display: inline-flex;
318
+ align-items: center;
319
+ justify-content: center;
320
+ font-size: 12px;
321
+ font-weight: 700;
322
+ flex-shrink: 0;
323
+ }
324
+
325
+ .header-user-name {
326
+ font-size: 13px;
327
+ font-weight: 500;
328
+ color: var(--tg-text, #f0f2f5);
329
+ white-space: nowrap;
330
+ overflow: hidden;
331
+ text-overflow: ellipsis;
332
+ }
333
+
334
+ .header-logout {
335
+ flex-shrink: 0;
336
+ height: 28px;
337
+ padding: 0 10px;
338
+ font-size: 12px;
339
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
340
+ border-radius: 6px;
341
+ color: var(--tg-hint, #9aa4b2);
342
+ background: transparent;
343
+ cursor: pointer;
344
+ }
345
+
346
+ .header-logout:hover {
347
+ color: var(--tg-text, #f0f2f5);
348
+ border-color: rgba(255, 255, 255, 0.22);
349
+ background: rgba(255, 255, 255, 0.06);
350
+ }
351
+
352
+ .admin-content {
353
+ flex: 1;
354
+ min-height: 0;
355
+ overflow: auto;
356
+ padding: var(--admin-content-pad, 12px);
357
+ }
358
+
359
+ .btn-hamburger {
360
+ display: inline-flex;
361
+ flex-direction: column;
362
+ justify-content: center;
363
+ gap: 4px;
364
+ width: 36px;
365
+ height: 36px;
366
+ padding: 8px;
367
+ border: none;
368
+ border-radius: 8px;
369
+ background: transparent;
370
+ cursor: pointer;
371
+ }
372
+
373
+ .hamburger-line {
374
+ display: block;
375
+ height: 2px;
376
+ width: 100%;
377
+ background: var(--tg-text, #f0f2f5);
378
+ border-radius: 1px;
379
+ }
380
+
381
+ @media (max-width: 1023px) {
382
+ .admin-sidebar {
383
+ transform: translateX(-100%);
384
+ }
385
+ .admin-sidebar.sidebar-open {
386
+ transform: translateX(0);
387
+ }
388
+ .admin-main {
389
+ margin-left: 0;
390
+ }
391
+ /* 窄屏顶栏优先保退出;名称可藏,头像仍可见 */
392
+ .header-user-name {
393
+ display: none;
394
+ }
395
+ }
396
+ </style>