@zhang_libo/resource-hub 1.0.2

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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +80 -0
  3. package/README.ja.md +80 -0
  4. package/README.md +79 -0
  5. package/README.zh-TW.md +80 -0
  6. package/bin/cli.js +10 -0
  7. package/dist/app.d.ts +2 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +59 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/db/index.js +12 -0
  12. package/dist/db/index.js.map +1 -0
  13. package/dist/db/migrate.d.ts +3 -0
  14. package/dist/db/migrate.d.ts.map +1 -0
  15. package/dist/db/migrate.js +169 -0
  16. package/dist/db/migrate.js.map +1 -0
  17. package/dist/db/schema.d.ts +743 -0
  18. package/dist/db/schema.d.ts.map +1 -0
  19. package/dist/db/schema.js +88 -0
  20. package/dist/db/schema.js.map +1 -0
  21. package/dist/i18n.js +309 -0
  22. package/dist/i18n.js.map +1 -0
  23. package/dist/plugins/admin.d.ts +4 -0
  24. package/dist/plugins/admin.d.ts.map +1 -0
  25. package/dist/plugins/admin.js +19 -0
  26. package/dist/plugins/admin.js.map +1 -0
  27. package/dist/plugins/auth.d.ts +4 -0
  28. package/dist/plugins/auth.d.ts.map +1 -0
  29. package/dist/plugins/auth.js +35 -0
  30. package/dist/plugins/auth.js.map +1 -0
  31. package/dist/routes/auth.d.ts +4 -0
  32. package/dist/routes/auth.d.ts.map +1 -0
  33. package/dist/routes/auth.js +352 -0
  34. package/dist/routes/auth.js.map +1 -0
  35. package/dist/routes/categories.d.ts +4 -0
  36. package/dist/routes/categories.d.ts.map +1 -0
  37. package/dist/routes/categories.js +112 -0
  38. package/dist/routes/categories.js.map +1 -0
  39. package/dist/routes/config.d.ts +4 -0
  40. package/dist/routes/config.d.ts.map +1 -0
  41. package/dist/routes/config.js +227 -0
  42. package/dist/routes/config.js.map +1 -0
  43. package/dist/routes/resources.d.ts +4 -0
  44. package/dist/routes/resources.d.ts.map +1 -0
  45. package/dist/routes/resources.js +474 -0
  46. package/dist/routes/resources.js.map +1 -0
  47. package/dist/routes/tags.d.ts +4 -0
  48. package/dist/routes/tags.d.ts.map +1 -0
  49. package/dist/routes/tags.js +37 -0
  50. package/dist/routes/tags.js.map +1 -0
  51. package/dist/routes/users.d.ts +4 -0
  52. package/dist/routes/users.d.ts.map +1 -0
  53. package/dist/routes/users.js +181 -0
  54. package/dist/routes/users.js.map +1 -0
  55. package/dist/services/crypto.js +49 -0
  56. package/dist/services/crypto.js.map +1 -0
  57. package/dist/services/mail.d.ts +16 -0
  58. package/dist/services/mail.d.ts.map +1 -0
  59. package/dist/services/mail.js +33 -0
  60. package/dist/services/mail.js.map +1 -0
  61. package/dist/services/rsa.js +49 -0
  62. package/dist/services/rsa.js.map +1 -0
  63. package/dist/services/token.d.ts +9 -0
  64. package/dist/services/token.d.ts.map +1 -0
  65. package/dist/services/token.js +29 -0
  66. package/dist/services/token.js.map +1 -0
  67. package/dist/types.d.ts +80 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +2 -0
  70. package/dist/types.js.map +1 -0
  71. package/package.json +73 -0
  72. package/public/admin/AdminCategories.jsx +310 -0
  73. package/public/admin/AdminConfig.jsx +254 -0
  74. package/public/admin/AdminEmail.jsx +279 -0
  75. package/public/admin/AdminTags.jsx +263 -0
  76. package/public/admin/AdminUsers.jsx +452 -0
  77. package/public/app.jsx +186 -0
  78. package/public/components/ConfirmDialog.jsx +78 -0
  79. package/public/components/DropdownSelect.jsx +281 -0
  80. package/public/components/EmailPreviewModal.jsx +104 -0
  81. package/public/components/EmptyState.jsx +50 -0
  82. package/public/components/Modal.jsx +127 -0
  83. package/public/components/PasswordStrength.jsx +45 -0
  84. package/public/components/Skeleton.jsx +68 -0
  85. package/public/components/Toast.jsx +80 -0
  86. package/public/components/TooltipIconButton.jsx +55 -0
  87. package/public/context/AppContext.jsx +314 -0
  88. package/public/features/BatchResourceModal.jsx +606 -0
  89. package/public/features/ChangePasswordModal.jsx +187 -0
  90. package/public/features/ProfileModal.jsx +170 -0
  91. package/public/features/ResourceCard.jsx +422 -0
  92. package/public/features/ResourceFormModal.jsx +915 -0
  93. package/public/features/ResourceRow.jsx +287 -0
  94. package/public/features/ResourceTimeline.jsx +472 -0
  95. package/public/hooks/useApi.jsx +26 -0
  96. package/public/hooks/useRouter.jsx +35 -0
  97. package/public/index.html +258 -0
  98. package/public/layout/AdminLayout.jsx +167 -0
  99. package/public/layout/AppLayout.jsx +119 -0
  100. package/public/layout/AuthLayout.jsx +503 -0
  101. package/public/layout/Header.jsx +543 -0
  102. package/public/layout/Sidebar.jsx +175 -0
  103. package/public/pages/AdminPage.jsx +30 -0
  104. package/public/pages/ForgotPasswordPage.jsx +93 -0
  105. package/public/pages/HomePage.jsx +2297 -0
  106. package/public/pages/LoginPage.jsx +191 -0
  107. package/public/pages/RegisterPage.jsx +137 -0
  108. package/public/pages/ResetPasswordPage.jsx +169 -0
  109. package/public/pages/SetupPage.jsx +157 -0
  110. package/public/utils/helpers.jsx +152 -0
  111. package/public/utils/i18n.jsx +1374 -0
  112. package/public/utils/preferences.jsx +220 -0
  113. package/public/utils/security.jsx +88 -0
  114. package/public/utils/theme.jsx +24 -0
  115. package/public/vendor/babel.min.js +2 -0
  116. package/public/vendor/lucide-react.min.js +9 -0
  117. package/public/vendor/react-dom.development.js +29869 -0
  118. package/public/vendor/react.development.js +3342 -0
@@ -0,0 +1,543 @@
1
+ const { useState, useEffect, useRef } = React;
2
+ const { Search, Sun, Moon, Monitor, User, LogOut, Settings, Shield, ChevronDown, Languages } = lucide;
3
+
4
+ function Header({ variant = 'default', showSearch = true }) {
5
+ const state = window.useAppState();
6
+ const dispatch = window.useAppDispatch();
7
+ const { navigate } = window.useRouter();
8
+ const { locale, setLocale, locales, getNativeLabel } = window.useI18n();
9
+ const [showLanguageMenu, setShowLanguageMenu] = useState(false);
10
+ const [showThemeMenu, setShowThemeMenu] = useState(false);
11
+ const [showUserMenu, setShowUserMenu] = useState(false);
12
+ const searchRef = useRef(null);
13
+ const languageMenuRef = useRef(null);
14
+ const themeMenuRef = useRef(null);
15
+ const userMenuRef = useRef(null);
16
+ const viewportWidth = window.useViewportWidth();
17
+ const isTablet = viewportWidth < 1100;
18
+ const isMobile = viewportWidth < 720;
19
+
20
+ useEffect(() => {
21
+ const handler = (e) => {
22
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
23
+ e.preventDefault();
24
+ searchRef.current?.focus();
25
+ }
26
+ };
27
+ document.addEventListener('keydown', handler);
28
+ return () => document.removeEventListener('keydown', handler);
29
+ }, []);
30
+
31
+ useEffect(() => {
32
+ const handler = (e) => {
33
+ if (languageMenuRef.current && !languageMenuRef.current.contains(e.target)) setShowLanguageMenu(false);
34
+ if (themeMenuRef.current && !themeMenuRef.current.contains(e.target)) setShowThemeMenu(false);
35
+ if (userMenuRef.current && !userMenuRef.current.contains(e.target)) setShowUserMenu(false);
36
+ };
37
+ document.addEventListener('mousedown', handler);
38
+ return () => document.removeEventListener('mousedown', handler);
39
+ }, []);
40
+
41
+ if (!state) return null;
42
+ const { currentUser, theme, config, searchQuery } = state;
43
+ const isHomeVariant = variant === 'home';
44
+ const isDarkTheme = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
45
+ const baseControlBorderColor = isHomeVariant
46
+ ? 'var(--control-border)'
47
+ : 'var(--control-border)';
48
+ const baseControlBackground = isHomeVariant
49
+ ? 'var(--surface-elevated)'
50
+ : 'color-mix(in srgb, var(--control-bg) 94%, var(--control-bg-muted))';
51
+ const baseControlShadow = isHomeVariant
52
+ ? 'var(--shadow-control)'
53
+ : 'var(--shadow-control)';
54
+ const menuSurface = 'color-mix(in srgb, var(--surface-elevated) 94%, var(--control-bg-muted))';
55
+ const menuOptionHover = 'color-mix(in srgb, var(--surface-tint) 62%, var(--control-bg))';
56
+ const menuOptionActive = 'color-mix(in srgb, var(--brand-soft) 82%, var(--control-bg))';
57
+ const searchFieldBackground = isHomeVariant
58
+ ? isDarkTheme
59
+ ? 'color-mix(in srgb, var(--surface-elevated) 88%, var(--control-bg-muted))'
60
+ : 'var(--surface-elevated)'
61
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--control-bg-muted))';
62
+ const searchFieldFocusBackground = isHomeVariant
63
+ ? isDarkTheme
64
+ ? 'color-mix(in srgb, var(--surface-elevated) 90%, var(--control-bg))'
65
+ : 'var(--surface-elevated)'
66
+ : 'color-mix(in srgb, var(--surface-elevated) 96%, var(--control-bg))';
67
+
68
+ const themeOptions = [
69
+ { value: 'light', label: '浅色', icon: Sun },
70
+ { value: 'dark', label: '深色', icon: Moon },
71
+ { value: 'system', label: '跟随系统', icon: Monitor },
72
+ ];
73
+
74
+ const ThemeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
75
+ const logoInitial = (config?.siteTitle || '资')[0];
76
+ const baseControlButtonStyle = {
77
+ background: baseControlBackground,
78
+ border: `1px solid ${baseControlBorderColor}`,
79
+ borderRadius: '12px',
80
+ cursor: 'pointer',
81
+ color: 'var(--text-secondary)',
82
+ transition: 'background 150ms, border-color 150ms, box-shadow 150ms',
83
+ boxShadow: baseControlShadow,
84
+ };
85
+
86
+ const searchField = showSearch ? (
87
+ <div style={{
88
+ width: '100%',
89
+ maxWidth: isTablet ? '100%' : '600px',
90
+ minWidth: 0,
91
+ position: 'relative',
92
+ justifySelf: isTablet ? 'stretch' : 'center',
93
+ }}>
94
+ <div style={{
95
+ position: 'absolute',
96
+ left: '12px',
97
+ top: '50%',
98
+ transform: 'translateY(-50%)',
99
+ color: 'var(--text-secondary)',
100
+ display: 'flex',
101
+ alignItems: 'center',
102
+ pointerEvents: 'none',
103
+ }}>
104
+ <Search size={16} />
105
+ </div>
106
+ <input
107
+ ref={searchRef}
108
+ data-rh-global-search
109
+ type="text"
110
+ value={searchQuery || ''}
111
+ onChange={(e) => dispatch({ type: 'SET_SEARCH', query: e.target.value })}
112
+ onKeyDown={(e) => {
113
+ if (e.key === 'Enter') {
114
+ e.preventDefault();
115
+ dispatch({ type: 'SET_HOME_MODE', mode: 'results' });
116
+ navigate('#/');
117
+ }
118
+ }}
119
+ placeholder={isMobile ? '搜索资源...' : '搜索资源名称、描述、标签... (Ctrl+K)'}
120
+ style={{
121
+ width: '100%',
122
+ minHeight: isMobile ? '38px' : '40px',
123
+ padding: '9px 14px 9px 40px',
124
+ background: searchFieldBackground,
125
+ border: `1px solid ${baseControlBorderColor}`,
126
+ borderRadius: '15px',
127
+ fontSize: '14px',
128
+ color: 'var(--text-primary)',
129
+ outline: 'none',
130
+ boxSizing: 'border-box',
131
+ transition: 'border-color 150ms, background 150ms, box-shadow 150ms',
132
+ boxShadow: 'var(--shadow-control)',
133
+ }}
134
+ onFocus={(e) => {
135
+ e.target.style.borderColor = 'var(--brand)';
136
+ e.target.style.background = searchFieldFocusBackground;
137
+ e.target.style.boxShadow = isHomeVariant
138
+ ? '0 0 0 1px color-mix(in srgb, var(--brand) 24%, transparent), 0 0 0 6px color-mix(in srgb, var(--brand) 10%, transparent), var(--shadow-control-hover)'
139
+ : '0 0 0 1px color-mix(in srgb, var(--brand) 24%, transparent), 0 0 0 6px color-mix(in srgb, var(--brand) 10%, transparent), var(--shadow-control-hover)';
140
+ }}
141
+ onBlur={(e) => {
142
+ e.target.style.borderColor = baseControlBorderColor;
143
+ e.target.style.background = searchFieldBackground;
144
+ e.target.style.boxShadow = 'var(--shadow-control)';
145
+ }}
146
+ />
147
+ </div>
148
+ ) : null;
149
+
150
+ const brand = (
151
+ <div
152
+ style={{
153
+ display: 'flex',
154
+ alignItems: 'center',
155
+ gap: '10px',
156
+ cursor: 'pointer',
157
+ minWidth: 0,
158
+ justifySelf: 'start',
159
+ }}
160
+ onClick={() => {
161
+ dispatch({ type: 'CLEAR_FILTERS' });
162
+ dispatch({ type: 'SET_HOME_MODE', mode: 'overview' });
163
+ navigate('#/');
164
+ }}
165
+ >
166
+ {config?.logoUrl ? (
167
+ <img src={config.logoUrl} style={{ width: '34px', height: '34px', borderRadius: '10px', objectFit: 'contain', border: '1px solid color-mix(in srgb, var(--border-strong) 82%, var(--border))', background: 'var(--surface-elevated)', boxShadow: 'var(--shadow-card)' }} />
168
+ ) : (
169
+ <div style={{
170
+ width: '34px',
171
+ height: '34px',
172
+ borderRadius: '10px',
173
+ background: 'var(--brand)',
174
+ color: '#fff',
175
+ display: 'flex',
176
+ alignItems: 'center',
177
+ justifyContent: 'center',
178
+ fontSize: '14px',
179
+ fontWeight: 700,
180
+ boxShadow: isHomeVariant ? '0 0 0 1px color-mix(in srgb, white 12%, transparent)' : 'none',
181
+ }}>{logoInitial}</div>
182
+ )}
183
+ {!isMobile && (
184
+ <span data-rh-header-title style={{ fontSize: '16px', fontWeight: 800, color: 'var(--text-primary)', whiteSpace: 'nowrap', letterSpacing: '-0.02em' }}>
185
+ {config?.siteTitle || '资源导航系统'}
186
+ </span>
187
+ )}
188
+ </div>
189
+ );
190
+
191
+ const actions = (
192
+ <div data-rh-header-actions style={{ display: 'flex', alignItems: 'center', gap: '8px', justifySelf: 'end', justifyContent: 'flex-end' }}>
193
+ <div ref={languageMenuRef} style={{ position: 'relative' }}>
194
+ <button
195
+ data-rh-language-trigger
196
+ onClick={() => setShowLanguageMenu((value) => !value)}
197
+ style={{
198
+ ...baseControlButtonStyle,
199
+ minHeight: '38px',
200
+ padding: isMobile ? '0 10px' : '0 12px',
201
+ display: 'inline-flex',
202
+ alignItems: 'center',
203
+ gap: '8px',
204
+ color: 'var(--text-primary)',
205
+ fontSize: '13px',
206
+ fontWeight: 700,
207
+ }}
208
+ onMouseEnter={(e) => {
209
+ e.currentTarget.style.background = 'color-mix(in srgb, var(--brand-soft) 54%, var(--surface-elevated))';
210
+ e.currentTarget.style.borderColor = 'var(--brand)';
211
+ e.currentTarget.style.boxShadow = 'var(--shadow-control-hover)';
212
+ }}
213
+ onMouseLeave={(e) => {
214
+ e.currentTarget.style.background = baseControlButtonStyle.background;
215
+ e.currentTarget.style.borderColor = baseControlBorderColor;
216
+ e.currentTarget.style.boxShadow = baseControlShadow;
217
+ }}
218
+ >
219
+ <Languages size={16} />
220
+ {!isMobile && <span>{getNativeLabel(locale)}</span>}
221
+ </button>
222
+
223
+ {showLanguageMenu && (
224
+ <div style={{
225
+ position: 'absolute',
226
+ right: 0,
227
+ top: 'calc(100% + 6px)',
228
+ width: '168px',
229
+ background: menuSurface,
230
+ border: '1px solid var(--control-border)',
231
+ borderRadius: '12px',
232
+ boxShadow: 'var(--shadow-dropdown)',
233
+ padding: '4px',
234
+ zIndex: 200,
235
+ }}>
236
+ {locales.map((item) => (
237
+ <button
238
+ key={item}
239
+ data-rh-language-option={item}
240
+ onClick={() => { setLocale(item); setShowLanguageMenu(false); }}
241
+ style={{
242
+ display: 'flex',
243
+ alignItems: 'center',
244
+ width: '100%',
245
+ padding: '0 10px',
246
+ height: '36px',
247
+ background: locale === item ? menuOptionActive : 'transparent',
248
+ border: 'none',
249
+ cursor: 'pointer',
250
+ color: locale === item ? 'var(--brand-strong)' : 'var(--text-primary)',
251
+ fontSize: '14px',
252
+ borderRadius: '8px',
253
+ fontWeight: locale === item ? 700 : 500,
254
+ }}
255
+ onMouseEnter={(e) => { if (locale !== item) e.currentTarget.style.background = menuOptionHover; }}
256
+ onMouseLeave={(e) => { e.currentTarget.style.background = locale === item ? menuOptionActive : 'transparent'; }}
257
+ >
258
+ {getNativeLabel(item)}
259
+ </button>
260
+ ))}
261
+ </div>
262
+ )}
263
+ </div>
264
+
265
+ <div ref={themeMenuRef} style={{ position: 'relative' }}>
266
+ <button
267
+ data-rh-theme-trigger
268
+ onClick={() => setShowThemeMenu((value) => !value)}
269
+ style={{
270
+ ...baseControlButtonStyle,
271
+ width: '38px',
272
+ height: '38px',
273
+ padding: 0,
274
+ display: 'flex',
275
+ alignItems: 'center',
276
+ justifyContent: 'center',
277
+ }}
278
+ onMouseEnter={(e) => {
279
+ e.currentTarget.style.background = 'color-mix(in srgb, var(--brand-soft) 54%, var(--surface-elevated))';
280
+ e.currentTarget.style.borderColor = 'var(--brand)';
281
+ e.currentTarget.style.boxShadow = 'var(--shadow-control-hover)';
282
+ }}
283
+ onMouseLeave={(e) => {
284
+ e.currentTarget.style.background = baseControlButtonStyle.background;
285
+ e.currentTarget.style.borderColor = baseControlBorderColor;
286
+ e.currentTarget.style.boxShadow = baseControlShadow;
287
+ }}
288
+ >
289
+ <ThemeIcon size={18} />
290
+ </button>
291
+
292
+ {showThemeMenu && (
293
+ <div style={{
294
+ position: 'absolute',
295
+ right: 0,
296
+ top: 'calc(100% + 6px)',
297
+ width: '160px',
298
+ background: menuSurface,
299
+ border: '1px solid var(--control-border)',
300
+ borderRadius: '12px',
301
+ boxShadow: 'var(--shadow-dropdown)',
302
+ padding: '4px',
303
+ zIndex: 200,
304
+ }}>
305
+ {themeOptions.map((opt) => (
306
+ <button
307
+ key={opt.value}
308
+ data-rh-theme-option={opt.value}
309
+ onClick={() => { dispatch({ type: 'SET_THEME', theme: opt.value }); setShowThemeMenu(false); }}
310
+ style={{
311
+ display: 'flex',
312
+ alignItems: 'center',
313
+ gap: '8px',
314
+ width: '100%',
315
+ padding: '0 10px',
316
+ height: '36px',
317
+ background: theme === opt.value ? menuOptionActive : 'transparent',
318
+ border: 'none',
319
+ cursor: 'pointer',
320
+ color: theme === opt.value ? 'var(--brand-strong)' : 'var(--text-primary)',
321
+ fontSize: '14px',
322
+ borderRadius: '8px',
323
+ fontWeight: theme === opt.value ? 600 : 400,
324
+ }}
325
+ onMouseEnter={(e) => { if (theme !== opt.value) e.currentTarget.style.background = menuOptionHover; }}
326
+ onMouseLeave={(e) => { e.currentTarget.style.background = theme === opt.value ? menuOptionActive : 'transparent'; }}
327
+ >
328
+ <opt.icon size={15} />
329
+ {opt.label}
330
+ </button>
331
+ ))}
332
+ </div>
333
+ )}
334
+ </div>
335
+
336
+ {currentUser ? (
337
+ <div ref={userMenuRef} style={{ position: 'relative' }}>
338
+ <button
339
+ data-rh-user-trigger
340
+ onClick={() => setShowUserMenu((value) => !value)}
341
+ style={{
342
+ ...baseControlButtonStyle,
343
+ display: 'flex',
344
+ alignItems: 'center',
345
+ gap: isMobile ? '6px' : '8px',
346
+ padding: isMobile ? '4px 10px 4px 6px' : '4px 12px 4px 6px',
347
+ }}
348
+ onMouseEnter={(e) => {
349
+ e.currentTarget.style.background = 'color-mix(in srgb, var(--brand-soft) 52%, var(--surface-elevated))';
350
+ e.currentTarget.style.borderColor = 'var(--brand)';
351
+ e.currentTarget.style.boxShadow = 'var(--shadow-control-hover)';
352
+ }}
353
+ onMouseLeave={(e) => {
354
+ e.currentTarget.style.background = baseControlButtonStyle.background;
355
+ e.currentTarget.style.borderColor = baseControlBorderColor;
356
+ e.currentTarget.style.boxShadow = baseControlShadow;
357
+ }}
358
+ >
359
+ <div style={{
360
+ width: '28px',
361
+ height: '28px',
362
+ borderRadius: '50%',
363
+ background: 'var(--brand)',
364
+ color: '#fff',
365
+ display: 'flex',
366
+ alignItems: 'center',
367
+ justifyContent: 'center',
368
+ fontSize: '12px',
369
+ fontWeight: 600,
370
+ }}>
371
+ {(currentUser.displayName || currentUser.username || 'U')[0].toUpperCase()}
372
+ </div>
373
+ {!isMobile && (
374
+ <span style={{ fontSize: '14px', color: 'var(--text-primary)', fontWeight: 500, maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
375
+ {currentUser.displayName || currentUser.username}
376
+ </span>
377
+ )}
378
+ <ChevronDown size={14} style={{ color: 'var(--text-secondary)' }} />
379
+ </button>
380
+
381
+ {showUserMenu && (
382
+ <div style={{
383
+ position: 'absolute',
384
+ right: 0,
385
+ top: 'calc(100% + 6px)',
386
+ width: '180px',
387
+ background: menuSurface,
388
+ border: '1px solid var(--control-border)',
389
+ borderRadius: '12px',
390
+ boxShadow: 'var(--shadow-dropdown)',
391
+ padding: '4px',
392
+ zIndex: 200,
393
+ }}>
394
+ {[
395
+ { label: '个人信息', icon: User, action: () => { dispatch({ type: 'OPEN_MODAL', modal: 'profile' }); setShowUserMenu(false); } },
396
+ { label: '修改密码', icon: Settings, action: () => { dispatch({ type: 'OPEN_MODAL', modal: 'changePassword' }); setShowUserMenu(false); } },
397
+ ].map((item) => (
398
+ <button
399
+ key={item.label}
400
+ onClick={item.action}
401
+ style={{
402
+ display: 'flex',
403
+ alignItems: 'center',
404
+ gap: '8px',
405
+ width: '100%',
406
+ padding: '0 10px',
407
+ height: '36px',
408
+ background: 'none',
409
+ border: 'none',
410
+ cursor: 'pointer',
411
+ color: 'var(--text-primary)',
412
+ fontSize: '14px',
413
+ borderRadius: '8px',
414
+ }}
415
+ onMouseEnter={(e) => { e.currentTarget.style.background = menuOptionHover; }}
416
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
417
+ >
418
+ <item.icon size={14} style={{ color: 'var(--text-secondary)' }} />
419
+ {item.label}
420
+ </button>
421
+ ))}
422
+
423
+ <div style={{ height: '1px', background: 'color-mix(in srgb, var(--control-border) 72%, transparent)', margin: '4px 8px' }} />
424
+
425
+ {currentUser.role === 'admin' && (
426
+ <button
427
+ onClick={() => { navigate('#/admin/categories'); setShowUserMenu(false); }}
428
+ style={{
429
+ display: 'flex',
430
+ alignItems: 'center',
431
+ gap: '8px',
432
+ width: '100%',
433
+ padding: '0 10px',
434
+ height: '36px',
435
+ background: 'none',
436
+ border: 'none',
437
+ cursor: 'pointer',
438
+ color: 'var(--text-primary)',
439
+ fontSize: '14px',
440
+ borderRadius: '8px',
441
+ }}
442
+ onMouseEnter={(e) => { e.currentTarget.style.background = menuOptionHover; }}
443
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
444
+ >
445
+ <Shield size={14} style={{ color: 'var(--text-secondary)' }} />
446
+ 后台管理
447
+ </button>
448
+ )}
449
+
450
+ {currentUser.role === 'admin' && (
451
+ <div style={{ height: '1px', background: 'color-mix(in srgb, var(--control-border) 72%, transparent)', margin: '4px 8px' }} />
452
+ )}
453
+
454
+ <button
455
+ data-rh-logout-trigger
456
+ onClick={() => { dispatch({ type: 'LOGOUT' }); setShowUserMenu(false); navigate('#/'); }}
457
+ style={{
458
+ display: 'flex',
459
+ alignItems: 'center',
460
+ gap: '8px',
461
+ width: '100%',
462
+ padding: '0 10px',
463
+ height: '36px',
464
+ background: 'none',
465
+ border: 'none',
466
+ cursor: 'pointer',
467
+ color: 'var(--danger)',
468
+ fontSize: '14px',
469
+ borderRadius: '8px',
470
+ }}
471
+ onMouseEnter={(e) => { e.currentTarget.style.background = 'color-mix(in srgb, var(--danger) 10%, var(--control-bg))'; }}
472
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
473
+ >
474
+ <LogOut size={14} />
475
+ 注销登录
476
+ </button>
477
+ </div>
478
+ )}
479
+ </div>
480
+ ) : (
481
+ <button
482
+ onClick={() => navigate('#/login')}
483
+ style={{
484
+ ...baseControlButtonStyle,
485
+ minHeight: '38px',
486
+ padding: isMobile ? '0 14px' : '0 16px',
487
+ color: 'var(--text-primary)',
488
+ fontSize: '14px',
489
+ fontWeight: 600,
490
+ }}
491
+ onMouseEnter={(e) => {
492
+ e.currentTarget.style.background = 'color-mix(in srgb, var(--brand-soft) 52%, var(--surface-elevated))';
493
+ e.currentTarget.style.borderColor = 'var(--brand)';
494
+ e.currentTarget.style.boxShadow = 'var(--shadow-control-hover)';
495
+ }}
496
+ onMouseLeave={(e) => {
497
+ e.currentTarget.style.background = baseControlButtonStyle.background;
498
+ e.currentTarget.style.borderColor = baseControlBorderColor;
499
+ e.currentTarget.style.boxShadow = baseControlShadow;
500
+ }}
501
+ >
502
+ 登录
503
+ </button>
504
+ )}
505
+ </div>
506
+ );
507
+
508
+ return (
509
+ <header style={{
510
+ minHeight: isTablet ? 'auto' : '72px',
511
+ position: 'relative',
512
+ top: 'auto',
513
+ zIndex: 1,
514
+ background: isHomeVariant
515
+ ? 'color-mix(in srgb, var(--surface-elevated) 46%, transparent)'
516
+ : 'color-mix(in srgb, var(--surface-elevated) 88%, transparent)',
517
+ borderBottom: isHomeVariant
518
+ ? 'none'
519
+ : '1px solid color-mix(in srgb, var(--border-strong) 72%, transparent)',
520
+ display: isTablet ? 'flex' : 'grid',
521
+ gridTemplateColumns: isTablet
522
+ ? undefined
523
+ : showSearch
524
+ ? 'minmax(220px, 1fr) minmax(420px, 620px) minmax(220px, 1fr)'
525
+ : 'minmax(220px, 1fr) minmax(220px, auto)',
526
+ alignItems: 'center',
527
+ flexWrap: isTablet ? 'wrap' : 'nowrap',
528
+ padding: isMobile ? '10px 16px' : '12px 24px',
529
+ gap: isTablet ? '10px' : '16px',
530
+ backdropFilter: 'blur(16px)',
531
+ boxShadow: isHomeVariant
532
+ ? 'none'
533
+ : '0 8px 24px color-mix(in srgb, black 6%, transparent)',
534
+ }}>
535
+ {brand}
536
+ {!isTablet && searchField}
537
+ {actions}
538
+ {isTablet && searchField}
539
+ </header>
540
+ );
541
+ }
542
+
543
+ window.Header = Header;