@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,2297 @@
1
+
2
+ function HomePage({ pageType = 'overview' } = {}) {
3
+ const state = window.useAppState();
4
+ const dispatch = window.useAppDispatch();
5
+ const { request } = window.useApi();
6
+ const i18n = window.useI18n ? window.useI18n() : null;
7
+ const t = i18n?.t || ((text) => text);
8
+ const viewportWidth = window.useViewportWidth();
9
+ const { navigate } = window.useRouter();
10
+ const { LayoutGrid, List, Clock, Plus, ChevronDown, Heart, FileText, Menu, ArrowLeft, X } = lucide;
11
+
12
+ const [showResourceModal, setShowResourceModal] = React.useState(false);
13
+ const [showBatchModal, setShowBatchModal] = React.useState(false);
14
+ const [editResource, setEditResource] = React.useState(null);
15
+ const [showMobileSidebar, setShowMobileSidebar] = React.useState(false);
16
+ const [showSortMenu, setShowSortMenu] = React.useState(false);
17
+ const [highlightedSort, setHighlightedSort] = React.useState('hot');
18
+ const [showAllSidebarTags, setShowAllSidebarTags] = React.useState(false);
19
+ const [trafficMetrics, setTrafficMetrics] = React.useState({
20
+ totalVisits: 0,
21
+ monthlyVisits: 0,
22
+ dailyVisits: 0,
23
+ });
24
+ const sortMenuRef = React.useRef(null);
25
+ const sortTriggerRef = React.useRef(null);
26
+ const sortItemRefs = React.useRef({});
27
+ const highlightedSortRef = React.useRef('hot');
28
+
29
+ const isDesktop = viewportWidth >= 960;
30
+ const isMobile = viewportWidth < 640;
31
+
32
+ React.useEffect(() => {
33
+ if (isDesktop) setShowMobileSidebar(false);
34
+ }, [isDesktop]);
35
+
36
+ React.useEffect(() => {
37
+ if (viewportWidth < 640) setShowSortMenu(false);
38
+ }, [viewportWidth]);
39
+
40
+ if (!state) return null;
41
+
42
+ const {
43
+ resources,
44
+ categories,
45
+ tags,
46
+ selectedCategory,
47
+ selectedTags,
48
+ quickAccessFilter,
49
+ searchQuery,
50
+ sortBy,
51
+ viewMode,
52
+ currentUser,
53
+ favorites,
54
+ history,
55
+ mine,
56
+ homeMode,
57
+ theme,
58
+ } = state;
59
+ const isDarkTheme = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
60
+ const isLightTheme = !isDarkTheme;
61
+ const { getCategoryTone } = window.helpers;
62
+
63
+ const baseCardColumns = viewportWidth >= 1280 ? 4 : viewportWidth >= 960 ? 3 : viewportWidth >= 640 ? 2 : 1;
64
+ const categoryCountMap = React.useMemo(() => {
65
+ const counts = {};
66
+ (resources || []).forEach((resource) => {
67
+ if (resource.categoryId === null || resource.categoryId === undefined) return;
68
+ counts[resource.categoryId] = (counts[resource.categoryId] || 0) + 1;
69
+ });
70
+ return counts;
71
+ }, [resources]);
72
+
73
+ const tagCountMap = React.useMemo(() => {
74
+ const counts = {};
75
+ (resources || []).forEach((resource) => {
76
+ (resource.tags || []).forEach((tag) => {
77
+ counts[tag] = (counts[tag] || 0) + 1;
78
+ });
79
+ });
80
+ return counts;
81
+ }, [resources]);
82
+
83
+ const orderedTags = React.useMemo(() => {
84
+ const source = Array.isArray(tags) && tags.length > 0 ? tags : Object.keys(tagCountMap);
85
+ return [...source]
86
+ .filter(Boolean)
87
+ .sort((a, b) => (tagCountMap[b] || 0) - (tagCountMap[a] || 0) || a.localeCompare(b, 'zh-CN'));
88
+ }, [tags, tagCountMap]);
89
+
90
+ const resolvedCategories = React.useMemo(() => {
91
+ if (Array.isArray(categories) && categories.length > 0) {
92
+ return categories.map((category) => {
93
+ const tone = getCategoryTone(category, category?.id);
94
+ return {
95
+ ...category,
96
+ color: tone.accent,
97
+ tone,
98
+ };
99
+ });
100
+ }
101
+ const categoryMap = new Map();
102
+ (resources || []).forEach((resource) => {
103
+ if (resource.categoryId === null || resource.categoryId === undefined) return;
104
+ if (categoryMap.has(resource.categoryId)) return;
105
+ const sourceCategory = {
106
+ id: resource.categoryId,
107
+ name: resource.categoryName || resource.category?.name || `分类 ${resource.categoryId}`,
108
+ };
109
+ const tone = getCategoryTone(sourceCategory, resource.categoryId);
110
+ categoryMap.set(resource.categoryId, {
111
+ ...sourceCategory,
112
+ color: tone.accent,
113
+ tone,
114
+ });
115
+ });
116
+ return [...categoryMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '', 'zh-CN'));
117
+ }, [categories, resources, getCategoryTone]);
118
+
119
+ const categoryList = React.useMemo(() => {
120
+ const totalCategory = { id: null, name: '全部', color: null, resourceCount: resources.length };
121
+ const categoriesWithCounts = resolvedCategories.map((category) => ({
122
+ ...category,
123
+ resourceCount: category.resourceCount ?? categoryCountMap[category.id] ?? 0,
124
+ }));
125
+ return [totalCategory, ...categoriesWithCounts];
126
+ }, [resolvedCategories, categoryCountMap, resources.length]);
127
+
128
+ const quickAccessItems = currentUser
129
+ ? [
130
+ { value: 'favorites', key: 'favorites', label: '我的收藏', Icon: Heart },
131
+ { value: 'history', key: 'history', label: '最近访问', Icon: Clock },
132
+ { value: 'mine', key: 'mine', label: '我创建的', Icon: FileText },
133
+ ]
134
+ : [];
135
+
136
+ let pool = resources;
137
+ if (quickAccessFilter === 'favorites') pool = favorites;
138
+ else if (quickAccessFilter === 'history') pool = history;
139
+ else if (quickAccessFilter === 'mine') pool = mine;
140
+
141
+ if (selectedCategory !== null && selectedCategory !== undefined) {
142
+ pool = pool.filter((resource) => resource.categoryId === selectedCategory);
143
+ }
144
+ if (selectedTags && selectedTags.length > 0) {
145
+ pool = pool.filter((resource) => selectedTags.every((tag) => (resource.tags || []).includes(tag)));
146
+ }
147
+
148
+ if (searchQuery && searchQuery.trim()) {
149
+ const query = searchQuery.toLowerCase();
150
+ pool = pool.filter((resource) =>
151
+ (resource.name || '').toLowerCase().includes(query) ||
152
+ (resource.description || '').toLowerCase().includes(query) ||
153
+ (resource.url || '').toLowerCase().includes(query) ||
154
+ (resource.tags || []).some((tag) => tag.toLowerCase().includes(query))
155
+ );
156
+ }
157
+
158
+ if (viewMode !== 'timeline') {
159
+ pool = [...pool].sort((a, b) => {
160
+ if (sortBy === 'hot') return (b.visitCount || 0) - (a.visitCount || 0);
161
+ if (sortBy === 'updated') return (b.updatedAt || 0) - (a.updatedAt || 0);
162
+ return (b.createdAt || 0) - (a.createdAt || 0);
163
+ });
164
+ }
165
+
166
+ const hasFilters = Boolean(
167
+ (selectedCategory !== null && selectedCategory !== undefined) ||
168
+ (selectedTags && selectedTags.length > 0) ||
169
+ quickAccessFilter ||
170
+ (searchQuery && searchQuery.trim())
171
+ );
172
+ const selectedCategoryItem = categoryList.find((item) => item.id === selectedCategory) || categoryList[0];
173
+ const selectedCategoryTone = selectedCategoryItem?.id !== null
174
+ ? (selectedCategoryItem?.tone || getCategoryTone(selectedCategoryItem, selectedCategoryItem?.id))
175
+ : null;
176
+ const activeQuickAccess = quickAccessItems.find((item) => item.key === quickAccessFilter) || null;
177
+ const sortOptions = React.useMemo(() => ([
178
+ { value: 'hot', label: '按热度' },
179
+ { value: 'created', label: '按创建时间' },
180
+ { value: 'updated', label: '按更新时间' },
181
+ ]), []);
182
+ const viewLabelMap = { card: '卡片视图', list: '列表视图', timeline: '时间轴视图' };
183
+ const viewOptions = [
184
+ { value: 'card', mode: 'card', label: '卡片视图', shortLabel: '卡片', Icon: LayoutGrid },
185
+ { value: 'list', mode: 'list', label: '列表视图', shortLabel: '列表', Icon: List },
186
+ { value: 'timeline', mode: 'timeline', label: '时间轴视图', shortLabel: '时间轴', Icon: Clock },
187
+ ];
188
+ const resolvedSort = viewMode === 'timeline' ? 'created' : sortBy;
189
+ const resolvedSortLabel = sortOptions.find((option) => option.value === resolvedSort)?.label || '按热度';
190
+ const trimmedQuery = (searchQuery || '').trim();
191
+ const compactQuery = trimmedQuery.length > 16 ? `${trimmedQuery.slice(0, 16)}...` : trimmedQuery;
192
+ const selectedTagsList = selectedTags || [];
193
+ const headingTitleMap = {
194
+ favorites: t('我的收藏'),
195
+ history: t('最近访问'),
196
+ mine: t('我创建的'),
197
+ };
198
+ const totalResourceCount = resources.length;
199
+ const mineResourceCount = currentUser ? mine.length : 0;
200
+ const headingTitle = headingTitleMap[quickAccessFilter]
201
+ || (selectedCategoryItem.id !== null ? selectedCategoryItem.name : '资源导航');
202
+ const activeFilterChips = [];
203
+ const titleMatchesQuickAccess = Boolean(activeQuickAccess && headingTitle === activeQuickAccess.label);
204
+ const titleMatchesCategory = Boolean(selectedCategoryItem.id !== null && headingTitle === selectedCategoryItem.name);
205
+ if (quickAccessFilter && activeQuickAccess && !titleMatchesQuickAccess) {
206
+ activeFilterChips.push({ key: 'quick-access', label: `范围 · ${activeQuickAccess.label}`, tone: 'brand' });
207
+ }
208
+ if (selectedCategoryItem.id !== null && !titleMatchesCategory) {
209
+ activeFilterChips.push({ key: 'category', label: `类别 · ${selectedCategoryItem.name}`, tone: 'category' });
210
+ }
211
+ selectedTagsList.slice(0, 3).forEach((tag) => {
212
+ activeFilterChips.push({ key: `tag-${tag}`, label: `#${tag}`, tone: 'neutral' });
213
+ });
214
+ if (selectedTagsList.length > 3) {
215
+ activeFilterChips.push({ key: 'tag-rest', label: `+${selectedTagsList.length - 3}`, tone: 'neutral' });
216
+ }
217
+ if (compactQuery) {
218
+ activeFilterChips.push({ key: 'query', label: `搜索 · ${compactQuery}`, tone: 'brand' });
219
+ }
220
+ const mergedOrderedTags = React.useMemo(
221
+ () => [...new Set([...(selectedTags || []), ...orderedTags])],
222
+ [orderedTags, selectedTags]
223
+ );
224
+ const visibleSidebarTags = React.useMemo(() => {
225
+ if (showAllSidebarTags) return mergedOrderedTags;
226
+ const selectedSet = new Set(selectedTagsList);
227
+ const selectedFirst = mergedOrderedTags.filter((tag) => selectedSet.has(tag));
228
+ const remaining = mergedOrderedTags.filter((tag) => !selectedSet.has(tag));
229
+ return [...selectedFirst, ...remaining.slice(0, 4)];
230
+ }, [mergedOrderedTags, selectedTagsList, showAllSidebarTags]);
231
+ const hiddenSidebarTagCount = Math.max(mergedOrderedTags.length - visibleSidebarTags.length, 0);
232
+ const isOverviewMode = pageType !== 'results' && homeMode !== 'results';
233
+ const isResultsMode = !isOverviewMode;
234
+ const visibleCategoryCount = resolvedCategories.length;
235
+ const visibleTagCount = orderedTags.length;
236
+ const overviewSummaryText = currentUser
237
+ ? `${totalResourceCount} 个资源可直接访问,覆盖 ${visibleCategoryCount} 个分类`
238
+ : `${totalResourceCount} 个公开资源,覆盖 ${visibleCategoryCount} 个分类`;
239
+ const metricCards = currentUser
240
+ ? [
241
+ { key: 'total', kind: 'summary', label: '可见资源', value: totalResourceCount, note: '当前可访问入口', accent: 'var(--brand)' },
242
+ { key: 'categories', kind: 'summary', label: '资源分类', value: visibleCategoryCount, note: '按主题浏览入口', accent: 'var(--brand)' },
243
+ { key: 'mine', kind: 'summary', label: '我的资源', value: mineResourceCount, note: '我创建与维护的内容', accent: 'var(--brand)' },
244
+ ]
245
+ : [
246
+ { key: 'public', kind: 'summary', label: '公开资源', value: totalResourceCount, note: '无需登录即可访问', accent: 'var(--brand)' },
247
+ { key: 'categories', kind: 'summary', label: '资源分类', value: visibleCategoryCount, note: '按主题浏览入口', accent: 'var(--brand)' },
248
+ { key: 'tags', kind: 'summary', label: '常用标签', value: visibleTagCount, note: '作为补充过滤条件', accent: 'var(--brand)' },
249
+ ];
250
+ const trafficCards = [
251
+ { key: 'visits-total', kind: 'traffic', label: '总访问量', value: trafficMetrics.totalVisits, note: '页面累计访问量', accent: 'var(--brand)' },
252
+ { key: 'visits-month', kind: 'traffic', label: '近30日访问', value: trafficMetrics.monthlyVisits, note: '近 30 天记录', accent: 'var(--brand)' },
253
+ { key: 'visits-day', kind: 'traffic', label: '近24小时访问', value: trafficMetrics.dailyVisits, note: '最近一天活跃', accent: 'var(--brand)' },
254
+ ];
255
+ const categoryHighlights = React.useMemo(() => {
256
+ return resolvedCategories.map((category) => {
257
+ const categoryResources = (resources || []).filter((resource) => resource.categoryId === category.id);
258
+ const previewNames = categoryResources.slice(0, 2).map((resource) => resource.name).filter(Boolean);
259
+ const previewTags = [...new Set(categoryResources.flatMap((resource) => resource.tags || []))].slice(0, 2);
260
+ return {
261
+ ...category,
262
+ resourceCount: category.resourceCount ?? categoryCountMap[category.id] ?? categoryResources.length,
263
+ previewNames,
264
+ previewTags,
265
+ };
266
+ })
267
+ .sort((a, b) => (b.resourceCount || 0) - (a.resourceCount || 0) || (a.name || '').localeCompare(b.name || '', 'zh-CN'))
268
+ .slice(0, 5);
269
+ }, [resolvedCategories, resources, categoryCountMap]);
270
+ const overviewPopularResources = React.useMemo(
271
+ () => [...resources].sort((a, b) => (b.visitCount || 0) - (a.visitCount || 0)).slice(0, 4),
272
+ [resources]
273
+ );
274
+ const overviewRecentResources = React.useMemo(() => {
275
+ const popularIds = new Set(overviewPopularResources.map((resource) => resource.id));
276
+ return [...resources]
277
+ .filter((resource) => !popularIds.has(resource.id))
278
+ .sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0))
279
+ .slice(0, 4);
280
+ }, [resources, overviewPopularResources]);
281
+ const overviewQuickAccessEntries = currentUser
282
+ ? [
283
+ {
284
+ key: 'favorites',
285
+ label: '我的收藏',
286
+ count: favorites.length,
287
+ note: '快速回到常用资源',
288
+ emptyNote: '还没有收藏资源,先去逛逛并添加常用入口',
289
+ filter: 'favorites',
290
+ accent: 'var(--brand)',
291
+ Icon: Heart,
292
+ },
293
+ {
294
+ key: 'history',
295
+ label: '最近访问',
296
+ count: history.length,
297
+ note: '继续上次浏览路径',
298
+ emptyNote: '还没有访问记录,先随便逛一逛',
299
+ filter: 'history',
300
+ accent: 'var(--brand)',
301
+ Icon: Clock,
302
+ },
303
+ {
304
+ key: 'mine',
305
+ label: '我创建的',
306
+ count: mine.length,
307
+ note: '维护自己的内容',
308
+ emptyNote: '还没有创建内容,可以尝试新增一个资源',
309
+ filter: 'mine',
310
+ accent: 'var(--brand)',
311
+ Icon: FileText,
312
+ },
313
+ ]
314
+ : [];
315
+ const overviewSections = [
316
+ {
317
+ key: 'popular',
318
+ title: '热门资源',
319
+ description: '按访问热度挑选常用入口。',
320
+ resources: overviewPopularResources,
321
+ actionLabel: '查看更多',
322
+ action: () => {
323
+ dispatch({ type: 'CLEAR_FILTERS' });
324
+ dispatch({ type: 'SET_HOME_MODE', mode: 'results' });
325
+ navigate('#/');
326
+ },
327
+ },
328
+ {
329
+ key: 'recent',
330
+ title: '最近更新',
331
+ description: '快速查看最近维护或新增的内容。',
332
+ resources: overviewRecentResources,
333
+ actionLabel: '查看更多',
334
+ action: () => {
335
+ dispatch({ type: 'CLEAR_FILTERS' });
336
+ dispatch({ type: 'SET_HOME_MODE', mode: 'results' });
337
+ navigate('#/');
338
+ },
339
+ },
340
+ ...(currentUser && mine.length > 0
341
+ ? [{
342
+ key: 'mine',
343
+ title: '我的资源',
344
+ description: '继续维护你创建和拥有的资源。',
345
+ resources: mine.slice(0, 4),
346
+ actionLabel: '查看更多',
347
+ action: () => {
348
+ dispatch({ type: 'SET_HOME_MODE', mode: 'results' });
349
+ dispatch({ type: 'SET_QUICK_ACCESS_FILTER', filter: 'mine' });
350
+ navigate('#/');
351
+ },
352
+ }]
353
+ : []),
354
+ ].filter((section) => section.resources.length > 0);
355
+ React.useEffect(() => {
356
+ let cancelled = false;
357
+
358
+ const loadTrafficMetrics = () => {
359
+ const maybeRecordVisit = isOverviewMode
360
+ ? request('/api/resources/analytics/visit', { method: 'POST' }).catch(() => null)
361
+ : Promise.resolve(null);
362
+
363
+ Promise.resolve(maybeRecordVisit)
364
+ .then(() => request('/api/resources/analytics'))
365
+ .then((response) => {
366
+ if (!response.ok || cancelled) return;
367
+ setTrafficMetrics({
368
+ totalVisits: response.data.data?.totalVisits || 0,
369
+ monthlyVisits: response.data.data?.monthlyVisits || 0,
370
+ dailyVisits: response.data.data?.dailyVisits || 0,
371
+ });
372
+ })
373
+ .catch(() => {});
374
+ };
375
+
376
+ const handleAnalyticsInvalidated = () => {
377
+ loadTrafficMetrics();
378
+ };
379
+
380
+ loadTrafficMetrics();
381
+ window.addEventListener('rh:analytics-invalidated', handleAnalyticsInvalidated);
382
+
383
+ return () => {
384
+ cancelled = true;
385
+ window.removeEventListener('rh:analytics-invalidated', handleAnalyticsInvalidated);
386
+ };
387
+ }, [request, currentUser?.id, isOverviewMode]);
388
+ const overviewCardColumns = viewportWidth >= 1440 ? 5 : viewportWidth >= 1280 ? 4 : viewportWidth >= 960 ? 3 : viewportWidth >= 640 ? 2 : 1;
389
+ const filterSummaryLabel = selectedCategoryItem.id !== null
390
+ ? '类别筛选结果'
391
+ : activeQuickAccess
392
+ ? '范围筛选结果'
393
+ : compactQuery
394
+ ? '搜索结果'
395
+ : selectedTagsList.length > 0
396
+ ? '标签筛选结果'
397
+ : '筛选结果';
398
+ const activeFilterGroupCount = [
399
+ selectedCategoryItem.id !== null,
400
+ Boolean(activeQuickAccess),
401
+ Boolean(compactQuery),
402
+ selectedTagsList.length > 0,
403
+ ].filter(Boolean).length;
404
+ const resultsHeadingTitle = hasFilters ? headingTitle : '全部资源';
405
+ const resultsHeadingSubtitle = `${pool.length} 个结果`;
406
+ const resultsSummaryLabel = hasFilters
407
+ ? filterSummaryLabel
408
+ : currentUser
409
+ ? '全部可见资源'
410
+ : '公开资源浏览';
411
+ const resultsSummaryText = hasFilters
412
+ ? `${resultsSummaryLabel} · 已应用 ${Math.max(activeFilterGroupCount, 1)} 组条件`
413
+ : resultsSummaryLabel;
414
+ const toolbarStickyDesktop = false;
415
+ const cardColumns = viewMode === 'card' && pool.length > 0 && pool.length <= baseCardColumns
416
+ ? pool.length
417
+ : baseCardColumns;
418
+ const compactResultsGrid = isDesktop && viewMode === 'card' && cardColumns < baseCardColumns;
419
+ const filteredGridMaxWidth = compactResultsGrid
420
+ ? `${cardColumns * 350 + Math.max(cardColumns - 1, 0) * 14}px`
421
+ : null;
422
+ const resultsTopPadding = isMobile ? '12px' : '16px';
423
+ const sharedTopPanelStyle = {
424
+ border: isLightTheme
425
+ ? '1px solid var(--border)'
426
+ : '1px solid color-mix(in srgb, var(--border-strong) 72%, var(--border))',
427
+ background: isLightTheme
428
+ ? 'var(--surface-elevated)'
429
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--bg-primary))',
430
+ boxShadow: isLightTheme
431
+ ? '0 8px 20px color-mix(in srgb, var(--text-primary) 4%, transparent)'
432
+ : '0 14px 26px color-mix(in srgb, var(--bg-primary) 18%, transparent)',
433
+ };
434
+ const resultsHeadingShellStyle = {
435
+ position: 'relative',
436
+ display: 'grid',
437
+ gap: 0,
438
+ padding: isMobile ? 0 : '10px 12px',
439
+ borderRadius: isMobile ? 0 : '20px',
440
+ ...(isMobile
441
+ ? {
442
+ border: 'none',
443
+ background: 'transparent',
444
+ boxShadow: 'none',
445
+ }
446
+ : sharedTopPanelStyle),
447
+ };
448
+ const resultsContentPanelStyle = {
449
+ border: isLightTheme
450
+ ? '1px solid var(--border)'
451
+ : '1px solid color-mix(in srgb, var(--border-strong) 74%, var(--border))',
452
+ background: isLightTheme
453
+ ? 'var(--surface-elevated)'
454
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--bg-primary))',
455
+ boxShadow: isLightTheme
456
+ ? 'var(--shadow-card)'
457
+ : '0 14px 26px color-mix(in srgb, var(--bg-primary) 18%, transparent)',
458
+ };
459
+
460
+ React.useEffect(() => {
461
+ if (isOverviewMode) setShowMobileSidebar(false);
462
+ }, [isOverviewMode]);
463
+
464
+ const openCreate = () => {
465
+ setEditResource(null);
466
+ setShowResourceModal(true);
467
+ };
468
+
469
+ const openBatch = () => {
470
+ setShowResourceModal(false);
471
+ setEditResource(null);
472
+ setShowBatchModal(true);
473
+ };
474
+
475
+ React.useEffect(() => {
476
+ if (viewMode === 'timeline') setShowSortMenu(false);
477
+ }, [viewMode]);
478
+
479
+ React.useEffect(() => {
480
+ setHighlightedSort(resolvedSort);
481
+ }, [resolvedSort]);
482
+
483
+ React.useEffect(() => {
484
+ highlightedSortRef.current = highlightedSort;
485
+ }, [highlightedSort]);
486
+
487
+ React.useEffect(() => {
488
+ if (!showSortMenu) return undefined;
489
+
490
+ setHighlightedSort(resolvedSort);
491
+
492
+ const moveHighlight = (direction) => {
493
+ const currentIndex = Math.max(sortOptions.findIndex((option) => option.value === highlightedSortRef.current), 0);
494
+ const nextIndex = (currentIndex + direction + sortOptions.length) % sortOptions.length;
495
+ setHighlightedSort(sortOptions[nextIndex].value);
496
+ };
497
+
498
+ const handleMouseDown = (event) => {
499
+ if (sortMenuRef.current?.contains(event.target)) return;
500
+ if (sortTriggerRef.current?.contains(event.target)) return;
501
+ setShowSortMenu(false);
502
+ };
503
+
504
+ const handleKeyDown = (event) => {
505
+ if (event.key === 'Escape') {
506
+ event.preventDefault();
507
+ setShowSortMenu(false);
508
+ sortTriggerRef.current?.focus();
509
+ return;
510
+ }
511
+ if (event.key === 'ArrowDown') {
512
+ event.preventDefault();
513
+ moveHighlight(1);
514
+ return;
515
+ }
516
+ if (event.key === 'ArrowUp') {
517
+ event.preventDefault();
518
+ moveHighlight(-1);
519
+ return;
520
+ }
521
+ if (event.key === 'Enter' || event.key === ' ') {
522
+ event.preventDefault();
523
+ dispatch({ type: 'SET_SORT', sortBy: highlightedSortRef.current });
524
+ setShowSortMenu(false);
525
+ sortTriggerRef.current?.focus();
526
+ }
527
+ };
528
+
529
+ document.addEventListener('mousedown', handleMouseDown);
530
+ document.addEventListener('keydown', handleKeyDown);
531
+ const frame = window.requestAnimationFrame(() => {
532
+ sortItemRefs.current[resolvedSort]?.focus();
533
+ });
534
+
535
+ return () => {
536
+ document.removeEventListener('mousedown', handleMouseDown);
537
+ document.removeEventListener('keydown', handleKeyDown);
538
+ window.cancelAnimationFrame(frame);
539
+ };
540
+ }, [dispatch, resolvedSort, showSortMenu, sortOptions]);
541
+
542
+ const openEdit = (resource) => {
543
+ setEditResource(resource);
544
+ setShowResourceModal(true);
545
+ };
546
+
547
+ const handleModalClose = async () => {
548
+ setShowResourceModal(false);
549
+ setEditResource(null);
550
+ const [categoriesResponse, tagsResponse] = await Promise.all([
551
+ request('/api/categories'),
552
+ request('/api/tags'),
553
+ ]);
554
+ if (categoriesResponse.ok) {
555
+ dispatch({ type: 'SET_CATEGORIES', categories: categoriesResponse.data.data || [] });
556
+ }
557
+ if (tagsResponse.ok) {
558
+ const nextTags = (tagsResponse.data.data || []).map((item) => (typeof item === 'string' ? item : item.tag)).filter(Boolean);
559
+ dispatch({ type: 'SET_TAGS', tags: nextTags });
560
+ }
561
+ };
562
+
563
+ const chipStyle = (active = false, compact = false) => ({
564
+ display: 'inline-flex',
565
+ alignItems: 'center',
566
+ gap: compact ? '5px' : '6px',
567
+ minHeight: compact ? '28px' : '32px',
568
+ padding: compact ? '0 10px' : '0 12px',
569
+ borderRadius: '999px',
570
+ border: active
571
+ ? '1px solid color-mix(in srgb, var(--brand) 30%, var(--control-border))'
572
+ : '1px solid color-mix(in srgb, var(--control-border) 82%, transparent)',
573
+ background: active
574
+ ? 'color-mix(in srgb, var(--brand-soft) 72%, var(--control-bg))'
575
+ : 'color-mix(in srgb, var(--control-bg) 88%, transparent)',
576
+ color: active ? 'var(--brand-strong)' : 'var(--text-secondary)',
577
+ cursor: 'pointer',
578
+ fontSize: compact ? '12px' : '13px',
579
+ fontWeight: active ? 700 : 500,
580
+ whiteSpace: 'nowrap',
581
+ flexShrink: 0,
582
+ boxShadow: active ? '0 4px 10px color-mix(in srgb, var(--brand) 8%, transparent)' : 'none',
583
+ transition: 'border-color 150ms, background 150ms, color 150ms, box-shadow 150ms',
584
+ });
585
+
586
+ const toolbarButtonStyle = (tone = 'control', compact = false) => ({
587
+ display: 'inline-flex',
588
+ alignItems: 'center',
589
+ gap: '8px',
590
+ minHeight: compact ? '31px' : '36px',
591
+ padding: compact ? '0 12px' : '0 14px',
592
+ borderRadius: '12px',
593
+ border: tone === 'brand'
594
+ ? '1px solid var(--brand)'
595
+ : tone === 'ghost'
596
+ ? '1px solid transparent'
597
+ : '1px solid var(--control-border)',
598
+ background: tone === 'brand'
599
+ ? 'var(--brand)'
600
+ : tone === 'ghost'
601
+ ? 'transparent'
602
+ : 'var(--surface-elevated)',
603
+ color: tone === 'brand' ? '#FFFFFF' : tone === 'ghost' ? 'var(--text-secondary)' : 'var(--text-primary)',
604
+ fontSize: compact ? '12px' : '13px',
605
+ fontWeight: tone === 'brand' ? 700 : 600,
606
+ boxShadow: tone === 'brand' ? '0 10px 20px color-mix(in srgb, var(--brand) 16%, transparent)' : 'var(--shadow-control)',
607
+ transition: 'border-color 150ms, background 150ms, color 150ms, box-shadow 150ms',
608
+ outline: 'none',
609
+ appearance: 'none',
610
+ WebkitAppearance: 'none',
611
+ flexShrink: 0,
612
+ cursor: 'pointer',
613
+ });
614
+ const desktopToolbarControlStyle = toolbarButtonStyle('control', false);
615
+ const compactToolbarControlStyle = toolbarButtonStyle('control', true);
616
+ const desktopToolbarClearStyle = toolbarButtonStyle('ghost', false);
617
+ const compactToolbarClearStyle = toolbarButtonStyle('ghost', true);
618
+ const summaryActionButtonStyle = (compact = false) => ({
619
+ ...toolbarButtonStyle('ghost', compact),
620
+ minHeight: compact ? '28px' : '30px',
621
+ padding: compact ? '0 10px' : '0 12px',
622
+ borderRadius: '10px',
623
+ color: 'var(--text-primary)',
624
+ background: 'transparent',
625
+ border: '1px solid transparent',
626
+ fontWeight: 600,
627
+ boxShadow: 'none',
628
+ });
629
+ const desktopViewModeTriggerStyle = {
630
+ ...desktopToolbarControlStyle,
631
+ justifyContent: 'space-between',
632
+ minWidth: '100px',
633
+ };
634
+ const desktopSortTriggerStyle = {
635
+ ...desktopToolbarControlStyle,
636
+ justifyContent: 'space-between',
637
+ minWidth: '104px',
638
+ };
639
+ const activeFilterChipStyle = (tone = 'neutral') => {
640
+ if (tone === 'category' && selectedCategoryTone?.accent) {
641
+ return {
642
+ ...chipStyle(false, true),
643
+ cursor: 'default',
644
+ color: selectedCategoryTone.accent,
645
+ border: `1px solid color-mix(in srgb, ${selectedCategoryTone.accent} 24%, var(--control-border))`,
646
+ background: `color-mix(in srgb, ${selectedCategoryTone.accent} 10%, var(--surface-elevated))`,
647
+ boxShadow: 'none',
648
+ };
649
+ }
650
+ if (tone === 'brand') {
651
+ return {
652
+ ...chipStyle(true, true),
653
+ cursor: 'default',
654
+ boxShadow: 'none',
655
+ };
656
+ }
657
+ return {
658
+ ...chipStyle(false, true),
659
+ cursor: 'default',
660
+ color: 'var(--text-primary)',
661
+ background: 'color-mix(in srgb, var(--control-bg) 88%, transparent)',
662
+ boxShadow: 'none',
663
+ };
664
+ };
665
+
666
+ const renderViewModeSelect = (compact = false, triggerStyle = null) => (
667
+ <window.DropdownSelect
668
+ value={viewMode}
669
+ onChange={(mode) => dispatch({ type: 'SET_VIEW_MODE', viewMode: mode })}
670
+ variant="pill"
671
+ ariaLabel="资源显示模式"
672
+ triggerProps={{
673
+ 'data-rh-view-mode-trigger': viewMode,
674
+ 'data-rh-view-mode-select': 'true',
675
+ ...(triggerStyle ? { style: triggerStyle } : { style: { flexShrink: 0 } }),
676
+ }}
677
+ options={viewOptions.map((option) => ({
678
+ value: option.value,
679
+ label: compact ? option.shortLabel : option.label,
680
+ buttonProps: {
681
+ 'data-rh-view-mode-option': option.value,
682
+ },
683
+ }))}
684
+ renderValue={() => compact ? viewOptions.find((option) => option.value === viewMode)?.shortLabel : viewLabelMap[viewMode]}
685
+ />
686
+ );
687
+
688
+ const renderSortControl = (compact = false, triggerStyle = null) => {
689
+ const disabled = viewMode === 'timeline';
690
+ const sortLabel = sortOptions.find((option) => option.value === resolvedSort)?.label || '按热度';
691
+ const compactSortLabelMap = { hot: '热度', created: '创建', updated: '更新' };
692
+ const triggerLabel = compact ? compactSortLabelMap[resolvedSort] || '热度' : sortLabel;
693
+
694
+ return (
695
+ <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
696
+ <button
697
+ ref={sortTriggerRef}
698
+ data-rh-sort-trigger
699
+ type="button"
700
+ aria-haspopup="menu"
701
+ aria-expanded={!disabled && showSortMenu}
702
+ disabled={disabled}
703
+ onClick={() => {
704
+ if (disabled) return;
705
+ setShowSortMenu((value) => !value);
706
+ }}
707
+ onKeyDown={(event) => {
708
+ if (disabled) return;
709
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
710
+ event.preventDefault();
711
+ setHighlightedSort(resolvedSort);
712
+ setShowSortMenu(true);
713
+ }
714
+ }}
715
+ style={{
716
+ display: 'inline-flex',
717
+ alignItems: 'center',
718
+ gap: '8px',
719
+ minHeight: compact ? '32px' : '38px',
720
+ padding: compact ? '0 12px' : '0 14px',
721
+ borderRadius: '11px',
722
+ border: disabled
723
+ ? '1px solid color-mix(in srgb, var(--control-border) 56%, transparent)'
724
+ : showSortMenu
725
+ ? '1px solid var(--brand)'
726
+ : '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)',
727
+ background: disabled
728
+ ? 'color-mix(in srgb, var(--control-bg-muted) 82%, var(--bg-primary))'
729
+ : showSortMenu
730
+ ? 'color-mix(in srgb, var(--brand-soft) 82%, var(--control-bg))'
731
+ : 'color-mix(in srgb, var(--surface-elevated) 88%, var(--control-bg-muted))',
732
+ color: disabled ? 'var(--text-secondary)' : showSortMenu ? 'var(--brand-strong)' : 'var(--text-primary)',
733
+ cursor: disabled ? 'not-allowed' : 'pointer',
734
+ opacity: disabled ? 0.65 : 1,
735
+ fontSize: compact ? '12px' : '13px',
736
+ fontWeight: showSortMenu ? 700 : 600,
737
+ outline: 'none',
738
+ boxShadow: showSortMenu
739
+ ? '0 0 0 1px color-mix(in srgb, var(--brand) 14%, transparent), var(--shadow-control-hover)'
740
+ : 'none',
741
+ transition: 'border-color 150ms, background 150ms, color 150ms, box-shadow 150ms',
742
+ ...(triggerStyle || {}),
743
+ }}
744
+ >
745
+ <span>{triggerLabel}</span>
746
+ <ChevronDown
747
+ size={14}
748
+ style={{
749
+ color: disabled ? 'var(--text-secondary)' : showSortMenu ? 'var(--brand-strong)' : 'var(--text-secondary)',
750
+ transform: showSortMenu ? 'rotate(180deg)' : 'rotate(0deg)',
751
+ transition: 'transform 150ms, color 150ms',
752
+ }}
753
+ />
754
+ </button>
755
+
756
+ {!disabled && showSortMenu && (
757
+ <div
758
+ ref={sortMenuRef}
759
+ data-rh-sort-menu
760
+ role="menu"
761
+ style={{
762
+ position: 'absolute',
763
+ top: 'calc(100% + 8px)',
764
+ right: 0,
765
+ minWidth: compact ? '148px' : '168px',
766
+ padding: '6px',
767
+ borderRadius: '16px',
768
+ border: '1px solid var(--control-border)',
769
+ background: 'color-mix(in srgb, var(--surface-elevated) 96%, var(--control-bg-muted))',
770
+ boxShadow: 'var(--shadow-dropdown)',
771
+ display: 'grid',
772
+ gap: '4px',
773
+ zIndex: 50,
774
+ backdropFilter: 'blur(18px)',
775
+ }}
776
+ >
777
+ {sortOptions.map((option) => {
778
+ const active = option.value === resolvedSort;
779
+ const highlighted = option.value === highlightedSort;
780
+ return (
781
+ <button
782
+ key={option.value}
783
+ ref={(node) => {
784
+ if (node) sortItemRefs.current[option.value] = node;
785
+ else delete sortItemRefs.current[option.value];
786
+ }}
787
+ type="button"
788
+ role="menuitemradio"
789
+ aria-checked={active}
790
+ tabIndex={active ? 0 : -1}
791
+ onMouseEnter={() => setHighlightedSort(option.value)}
792
+ onFocus={() => setHighlightedSort(option.value)}
793
+ onClick={() => {
794
+ dispatch({ type: 'SET_SORT', sortBy: option.value });
795
+ setShowSortMenu(false);
796
+ sortTriggerRef.current?.focus();
797
+ }}
798
+ style={{
799
+ minHeight: compact ? '34px' : '36px',
800
+ padding: compact ? '0 10px' : '0 12px',
801
+ borderRadius: '12px',
802
+ border: 'none',
803
+ background: active
804
+ ? 'color-mix(in srgb, var(--brand-soft) 84%, var(--control-bg))'
805
+ : highlighted
806
+ ? 'color-mix(in srgb, var(--surface-tint) 68%, var(--control-bg))'
807
+ : 'transparent',
808
+ color: active ? 'var(--brand-strong)' : 'var(--text-primary)',
809
+ cursor: 'pointer',
810
+ fontSize: compact ? '12px' : '13px',
811
+ fontWeight: active ? 700 : highlighted ? 600 : 500,
812
+ textAlign: 'left',
813
+ transition: 'background 150ms, color 150ms',
814
+ }}
815
+ >
816
+ {option.label}
817
+ </button>
818
+ );
819
+ })}
820
+ </div>
821
+ )}
822
+ </div>
823
+ );
824
+ };
825
+
826
+ const handleSummaryActionHover = (event, hovered) => {
827
+ event.currentTarget.style.background = hovered
828
+ ? 'color-mix(in srgb, var(--brand-soft) 78%, var(--surface-elevated))'
829
+ : 'transparent';
830
+ event.currentTarget.style.borderColor = hovered
831
+ ? 'color-mix(in srgb, var(--brand) 18%, var(--control-border))'
832
+ : 'transparent';
833
+ event.currentTarget.style.color = hovered ? 'var(--brand-strong)' : 'var(--text-primary)';
834
+ };
835
+
836
+ return (
837
+ <>
838
+ <window.AppLayout
839
+ showSidebar={false}
840
+ contentPaddingTop="0px"
841
+ headerVariant="home"
842
+ >
843
+ <section
844
+ data-rh-resource-browser
845
+ data-rh-home-mode={isOverviewMode ? 'overview' : 'results'}
846
+ style={{
847
+ position: 'relative',
848
+ display: 'grid',
849
+ gap: isMobile ? '10px' : '12px',
850
+ ...(isOverviewMode
851
+ ? { maxWidth: '1280px', marginLeft: 'auto', marginRight: 'auto' }
852
+ : {}),
853
+ width: '100%',
854
+ paddingLeft: 0,
855
+ paddingRight: 0,
856
+ paddingTop: isOverviewMode ? '25px' : resultsTopPadding,
857
+ paddingBottom: isOverviewMode ? '20px' : 40,
858
+ borderRadius: 0,
859
+ border: 'none',
860
+ background: 'transparent',
861
+ }}
862
+ >
863
+ {isOverviewMode ? (
864
+ <HomeOverview
865
+ currentUser={currentUser}
866
+ isLightTheme={isLightTheme}
867
+ title="资源导航"
868
+ subtitle={overviewSummaryText}
869
+ metrics={[...metricCards, ...trafficCards]}
870
+ quickAccessEntries={overviewQuickAccessEntries}
871
+ categoryHighlights={categoryHighlights}
872
+ sections={overviewSections}
873
+ columns={overviewCardColumns}
874
+ stackActions={viewportWidth < 720}
875
+ inlineMetrics={viewportWidth >= 960}
876
+ onBrowseAll={() => {
877
+ dispatch({ type: 'CLEAR_FILTERS' });
878
+ dispatch({ type: 'SET_HOME_MODE', mode: 'results' });
879
+ setShowSortMenu(false);
880
+ navigate('#/');
881
+ }}
882
+ onCreate={currentUser ? openCreate : null}
883
+ onSelectCategory={(categoryId) => {
884
+ dispatch({ type: 'CLEAR_FILTERS' });
885
+ dispatch({ type: 'SET_HOME_MODE', mode: 'results' });
886
+ dispatch({ type: 'SET_CATEGORY', category: categoryId });
887
+ navigate('#/');
888
+ }}
889
+ onSelectQuickAccess={(filter) => {
890
+ dispatch({ type: 'CLEAR_FILTERS' });
891
+ dispatch({ type: 'SET_HOME_MODE', mode: 'results' });
892
+ dispatch({ type: 'SET_QUICK_ACCESS_FILTER', filter });
893
+ navigate('#/');
894
+ }}
895
+ />
896
+ ) : (
897
+ <>
898
+ <div
899
+ data-rh-home-heading-block
900
+ style={resultsHeadingShellStyle}
901
+ >
902
+ <div
903
+ data-rh-resource-toolbar
904
+ style={{
905
+ display: 'grid',
906
+ gap: isMobile ? '10px' : '8px',
907
+ width: '100%',
908
+ position: toolbarStickyDesktop ? 'sticky' : 'relative',
909
+ top: toolbarStickyDesktop ? 'calc(var(--app-header-height, 72px) + 4px)' : 'auto',
910
+ zIndex: toolbarStickyDesktop ? 24 : 1,
911
+ padding: isMobile ? '0 0 10px' : 0,
912
+ borderRadius: 0,
913
+ border: 'none',
914
+ borderBottom: isMobile
915
+ ? (isLightTheme
916
+ ? '1px solid color-mix(in srgb, var(--border-strong) 24%, transparent)'
917
+ : '1px solid color-mix(in srgb, var(--outline-strong) 34%, transparent)')
918
+ : 'none',
919
+ background: 'transparent',
920
+ backdropFilter: 'none',
921
+ boxShadow: 'none',
922
+ }}
923
+ >
924
+ <div
925
+ style={{
926
+ display: 'grid',
927
+ gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
928
+ gap: isMobile ? '10px' : '14px',
929
+ alignItems: isMobile ? 'stretch' : 'start',
930
+ }}
931
+ >
932
+ <div style={{ display: 'grid', gap: '6px', minWidth: 0, maxWidth: isMobile ? '100%' : '620px' }}>
933
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: isMobile ? '4px' : '10px', alignItems: 'baseline', minWidth: 0 }}>
934
+ <div
935
+ data-rh-home-title
936
+ style={{
937
+ fontSize: isMobile ? '21px' : '23px',
938
+ fontWeight: 800,
939
+ color: 'var(--text-primary)',
940
+ letterSpacing: '-0.03em',
941
+ lineHeight: 1.04,
942
+ }}
943
+ >
944
+ {resultsHeadingTitle}
945
+ </div>
946
+ <div
947
+ data-rh-home-subtitle
948
+ style={{
949
+ width: isMobile ? '100%' : 'auto',
950
+ fontSize: isMobile ? '12px' : '13px',
951
+ lineHeight: 1.35,
952
+ color: 'var(--text-secondary)',
953
+ }}
954
+ >
955
+ {resultsHeadingSubtitle}
956
+ </div>
957
+ </div>
958
+ </div>
959
+
960
+ <div
961
+ style={{
962
+ display: 'flex',
963
+ gap: '10px',
964
+ alignItems: 'center',
965
+ justifyContent: isMobile ? 'flex-start' : 'flex-end',
966
+ justifySelf: isMobile ? 'stretch' : 'end',
967
+ flexWrap: 'wrap',
968
+ }}
969
+ >
970
+ {!isMobile ? (
971
+ <>
972
+ <div style={{ display: 'inline-flex', gap: '8px', alignItems: 'center', flexWrap: 'nowrap' }}>
973
+ {renderViewModeSelect(false, desktopViewModeTriggerStyle)}
974
+ {renderSortControl(false, desktopSortTriggerStyle)}
975
+ </div>
976
+ {currentUser && (
977
+ <div style={{ display: 'inline-flex', gap: '8px', alignItems: 'center' }}>
978
+ <button data-rh-toolbar-create onClick={openCreate} style={toolbarButtonStyle('brand', false)}>
979
+ <Plus size={14} /> 新增资源
980
+ </button>
981
+ </div>
982
+ )}
983
+ </>
984
+ ) : (
985
+ <>
986
+ {currentUser && (
987
+ <button data-rh-toolbar-create onClick={openCreate} style={toolbarButtonStyle('brand', true)}>
988
+ <Plus size={14} /> 新增资源
989
+ </button>
990
+ )}
991
+ {renderViewModeSelect(true, compactToolbarControlStyle)}
992
+ {renderSortControl(true, compactToolbarControlStyle)}
993
+ <button
994
+ data-rh-home-sidebar-trigger
995
+ data-rh-mobile-filter-trigger
996
+ onClick={() => {
997
+ setShowSortMenu(false);
998
+ setShowMobileSidebar(true);
999
+ }}
1000
+ style={toolbarButtonStyle('neutral', true)}
1001
+ >
1002
+ <Menu size={14} /> 筛选
1003
+ </button>
1004
+ </>
1005
+ )}
1006
+ </div>
1007
+ </div>
1008
+
1009
+ <div style={{ display: 'grid', gap: hasFilters && activeFilterChips.length > 0 ? '6px' : '0' }}>
1010
+ <div
1011
+ data-rh-home-toolbar-summary
1012
+ style={{
1013
+ display: 'flex',
1014
+ flexWrap: 'wrap',
1015
+ gap: '8px',
1016
+ alignItems: 'center',
1017
+ }}
1018
+ >
1019
+ <span
1020
+ style={{
1021
+ fontSize: '12px',
1022
+ color: 'var(--text-secondary)',
1023
+ lineHeight: 1.4,
1024
+ }}
1025
+ >
1026
+ {resultsSummaryText}
1027
+ </span>
1028
+ {hasFilters ? (
1029
+ <button
1030
+ data-rh-heading-clear-filters
1031
+ onClick={() => dispatch({ type: 'CLEAR_FILTERS' })}
1032
+ onMouseEnter={(event) => handleSummaryActionHover(event, true)}
1033
+ onMouseLeave={(event) => handleSummaryActionHover(event, false)}
1034
+ style={summaryActionButtonStyle(isMobile)}
1035
+ >
1036
+ <X size={14} />
1037
+ {isMobile ? '清空' : '清空筛选'}
1038
+ </button>
1039
+ ) : (
1040
+ <button
1041
+ data-rh-home-overview-return
1042
+ onClick={() => {
1043
+ dispatch({ type: 'CLEAR_FILTERS' });
1044
+ dispatch({ type: 'SET_HOME_MODE', mode: 'overview' });
1045
+ navigate('#/');
1046
+ }}
1047
+ onMouseEnter={(event) => handleSummaryActionHover(event, true)}
1048
+ onMouseLeave={(event) => handleSummaryActionHover(event, false)}
1049
+ style={summaryActionButtonStyle(isMobile)}
1050
+ >
1051
+ <ArrowLeft size={14} />
1052
+ 返回首页概览
1053
+ </button>
1054
+ )}
1055
+ </div>
1056
+ {hasFilters && activeFilterChips.length > 0 && (
1057
+ <div data-rh-home-active-filters style={{ display: 'flex', flexWrap: 'wrap', gap: '5px', alignItems: 'center' }}>
1058
+ {activeFilterChips.map((chip) => (
1059
+ <span key={chip.key} data-rh-home-active-filter={chip.key} style={activeFilterChipStyle(chip.tone)}>
1060
+ {chip.label}
1061
+ </span>
1062
+ ))}
1063
+ </div>
1064
+ )}
1065
+ </div>
1066
+ </div>
1067
+ </div>
1068
+
1069
+ <div
1070
+ style={{
1071
+ display: 'grid',
1072
+ gridTemplateColumns: isDesktop ? '212px minmax(0, 1fr)' : '1fr',
1073
+ gap: isDesktop ? '16px' : '0',
1074
+ alignItems: 'start',
1075
+ }}
1076
+ >
1077
+ {isDesktop && (
1078
+ <HomeSidebarNav
1079
+ currentUser={currentUser}
1080
+ quickAccessItems={quickAccessItems}
1081
+ quickAccessFilter={quickAccessFilter}
1082
+ categoryList={categoryList}
1083
+ selectedCategory={selectedCategory}
1084
+ selectedTags={selectedTagsList}
1085
+ visibleTags={visibleSidebarTags}
1086
+ hiddenTagCount={hiddenSidebarTagCount}
1087
+ showAllTags={showAllSidebarTags}
1088
+ onToggleShowAllTags={() => setShowAllSidebarTags((value) => !value)}
1089
+ dispatch={dispatch}
1090
+ isLightTheme={isLightTheme}
1091
+ visualMode="results"
1092
+ />
1093
+ )}
1094
+
1095
+ <div style={{ minWidth: 0, display: 'grid', gap: isMobile ? '12px' : '10px' }}>
1096
+ {pool.length === 0 ? (
1097
+ <HomeEmptyState
1098
+ title={hasFilters ? '没有匹配的资源结果' : '当前没有可展示的资源'}
1099
+ description={hasFilters
1100
+ ? '可以放宽关键词、切换类别,或直接清空筛选。'
1101
+ : currentUser
1102
+ ? '从资源区右上角新增资源后,这里会立即出现结果。'
1103
+ : '当前仅显示公开资源。若需要完整内容,请使用 Header 中的登录入口。'}
1104
+ primaryAction={hasFilters
1105
+ ? { label: '清空筛选', onClick: () => dispatch({ type: 'CLEAR_FILTERS' }) }
1106
+ : currentUser
1107
+ ? { label: '新增资源', onClick: openCreate }
1108
+ : null}
1109
+ secondaryAction={hasFilters && currentUser ? { label: '新增资源', onClick: openCreate } : null}
1110
+ />
1111
+ ) : viewMode === 'card' ? (
1112
+ <div
1113
+ data-rh-resource-grid
1114
+ style={{
1115
+ display: 'grid',
1116
+ gridTemplateColumns: `repeat(${cardColumns}, minmax(0, 1fr))`,
1117
+ gap: isMobile ? '14px' : '14px',
1118
+ alignItems: 'stretch',
1119
+ width: '100%',
1120
+ maxWidth: filteredGridMaxWidth || 'none',
1121
+ justifySelf: compactResultsGrid ? 'start' : 'stretch',
1122
+ }}
1123
+ >
1124
+ {pool.map((resource) => (
1125
+ <div key={resource.id} data-rh-resource-item>
1126
+ <window.ResourceCard
1127
+ resource={resource}
1128
+ onEdit={openEdit}
1129
+ compact={isDesktop}
1130
+ />
1131
+ </div>
1132
+ ))}
1133
+ </div>
1134
+ ) : viewMode === 'list' ? (
1135
+ <div data-rh-resource-list-shell style={{
1136
+ ...resultsContentPanelStyle,
1137
+ borderRadius: '20px',
1138
+ overflowX: 'auto',
1139
+ }}>
1140
+ {pool.map((resource, index) => (
1141
+ <window.ResourceRow key={resource.id} resource={resource} onEdit={openEdit} isLast={index === pool.length - 1} />
1142
+ ))}
1143
+ </div>
1144
+ ) : (
1145
+ <div
1146
+ data-rh-resource-timeline-shell
1147
+ style={{
1148
+ ...resultsContentPanelStyle,
1149
+ borderRadius: '20px',
1150
+ padding: isMobile ? '14px 14px 6px' : '16px 18px 8px',
1151
+ }}
1152
+ >
1153
+ <window.ResourceTimeline resources={pool} onEdit={openEdit} />
1154
+ </div>
1155
+ )}
1156
+ </div>
1157
+ </div>
1158
+ </>
1159
+ )}
1160
+ </section>
1161
+ </window.AppLayout>
1162
+
1163
+ <window.ResourceFormModal
1164
+ isOpen={showResourceModal}
1165
+ onClose={handleModalClose}
1166
+ resource={editResource}
1167
+ onOpenBatch={currentUser ? openBatch : undefined}
1168
+ />
1169
+ <window.BatchResourceModal
1170
+ isOpen={showBatchModal}
1171
+ onClose={async () => {
1172
+ setShowBatchModal(false);
1173
+ const [categoriesResponse, tagsResponse] = await Promise.all([
1174
+ request('/api/categories'),
1175
+ request('/api/tags'),
1176
+ ]);
1177
+ if (categoriesResponse.ok) {
1178
+ dispatch({ type: 'SET_CATEGORIES', categories: categoriesResponse.data.data || [] });
1179
+ }
1180
+ if (tagsResponse.ok) {
1181
+ const nextTags = (tagsResponse.data.data || []).map((item) => (typeof item === 'string' ? item : item.tag)).filter(Boolean);
1182
+ dispatch({ type: 'SET_TAGS', tags: nextTags });
1183
+ }
1184
+ }}
1185
+ />
1186
+
1187
+ {isResultsMode && (
1188
+ <HomeSidebarDrawer isOpen={showMobileSidebar} onClose={() => setShowMobileSidebar(false)}>
1189
+ <HomeSidebarNav
1190
+ currentUser={currentUser}
1191
+ quickAccessItems={quickAccessItems}
1192
+ quickAccessFilter={quickAccessFilter}
1193
+ categoryList={categoryList}
1194
+ selectedCategory={selectedCategory}
1195
+ selectedTags={selectedTagsList}
1196
+ visibleTags={visibleSidebarTags}
1197
+ hiddenTagCount={hiddenSidebarTagCount}
1198
+ showAllTags={showAllSidebarTags}
1199
+ onToggleShowAllTags={() => setShowAllSidebarTags((value) => !value)}
1200
+ dispatch={dispatch}
1201
+ isLightTheme={isLightTheme}
1202
+ visualMode="results"
1203
+ isDrawer={true}
1204
+ onNavigate={() => setShowMobileSidebar(false)}
1205
+ />
1206
+ </HomeSidebarDrawer>
1207
+ )}
1208
+ </>
1209
+ );
1210
+ }
1211
+
1212
+ function HomeOverview({
1213
+ currentUser,
1214
+ isLightTheme = false,
1215
+ title,
1216
+ subtitle,
1217
+ metrics,
1218
+ quickAccessEntries,
1219
+ categoryHighlights,
1220
+ sections,
1221
+ columns,
1222
+ stackActions = false,
1223
+ inlineMetrics = false,
1224
+ onBrowseAll,
1225
+ onCreate,
1226
+ onSelectCategory,
1227
+ onSelectQuickAccess,
1228
+ }) {
1229
+ const surfaceStyle = {
1230
+ border: isLightTheme
1231
+ ? '1px solid color-mix(in srgb, var(--control-border) 78%, transparent)'
1232
+ : '1px solid color-mix(in srgb, var(--border-strong) 42%, var(--border))',
1233
+ background: isLightTheme
1234
+ ? 'var(--surface-elevated)'
1235
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--bg-primary))',
1236
+ boxShadow: isLightTheme
1237
+ ? '0 10px 24px color-mix(in srgb, var(--text-primary) 5%, transparent)'
1238
+ : '0 14px 28px color-mix(in srgb, var(--bg-primary) 20%, transparent)',
1239
+ };
1240
+ const heroSurfaceStyle = {
1241
+ ...surfaceStyle,
1242
+ border: isLightTheme
1243
+ ? '1px solid color-mix(in srgb, var(--control-border) 82%, transparent)'
1244
+ : '1px solid color-mix(in srgb, var(--border-strong) 44%, var(--border))',
1245
+ background: isLightTheme
1246
+ ? 'var(--surface-elevated)'
1247
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--bg-primary))',
1248
+ boxShadow: isLightTheme
1249
+ ? '0 12px 28px color-mix(in srgb, var(--text-primary) 5%, transparent)'
1250
+ : '0 16px 32px color-mix(in srgb, var(--bg-primary) 22%, transparent)',
1251
+ };
1252
+ const quickAccessSurfaceStyle = {
1253
+ ...surfaceStyle,
1254
+ border: isLightTheme
1255
+ ? '1px solid color-mix(in srgb, var(--control-border) 82%, transparent)'
1256
+ : '1px solid color-mix(in srgb, var(--border-strong) 44%, var(--border))',
1257
+ background: isLightTheme
1258
+ ? 'var(--surface-elevated)'
1259
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--bg-primary))',
1260
+ boxShadow: isLightTheme
1261
+ ? '0 10px 20px color-mix(in srgb, var(--text-primary) 4%, transparent)'
1262
+ : '0 12px 24px color-mix(in srgb, var(--bg-primary) 18%, transparent)',
1263
+ };
1264
+ const [hoveredQuickAccessKey, setHoveredQuickAccessKey] = React.useState(null);
1265
+ const [hoveredCategoryId, setHoveredCategoryId] = React.useState(null);
1266
+ const interactiveCardTransition = 'transform 170ms, border-color 170ms, background 170ms, box-shadow 170ms';
1267
+ const interactiveCardLift = 'translateY(-2px)';
1268
+ const quickAccessEmptySurfaceStyle = {
1269
+ border: isLightTheme
1270
+ ? '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)'
1271
+ : '1px solid color-mix(in srgb, var(--border-strong) 34%, var(--border))',
1272
+ background: isLightTheme
1273
+ ? 'color-mix(in srgb, var(--surface-elevated) 96%, var(--control-bg-muted))'
1274
+ : 'color-mix(in srgb, var(--surface-elevated) 88%, var(--bg-primary))',
1275
+ boxShadow: isLightTheme
1276
+ ? '0 8px 16px color-mix(in srgb, var(--text-primary) 3%, transparent)'
1277
+ : '0 10px 18px color-mix(in srgb, var(--bg-primary) 14%, transparent)',
1278
+ };
1279
+ const secondaryButtonStyle = {
1280
+ minHeight: '36px',
1281
+ minWidth: '98px',
1282
+ padding: '0 14px',
1283
+ borderRadius: '12px',
1284
+ border: '1px solid color-mix(in srgb, var(--control-border) 82%, transparent)',
1285
+ background: 'transparent',
1286
+ color: 'var(--text-primary)',
1287
+ cursor: 'pointer',
1288
+ fontSize: '13px',
1289
+ fontWeight: 600,
1290
+ boxShadow: '0 1px 2px color-mix(in srgb, var(--text-primary) 3%, transparent)',
1291
+ };
1292
+ const resultsEntryButtonStyle = {
1293
+ minHeight: '32px',
1294
+ padding: '0 11px',
1295
+ borderRadius: '11px',
1296
+ border: '1px solid color-mix(in srgb, var(--control-border) 76%, transparent)',
1297
+ background: 'transparent',
1298
+ color: 'var(--text-primary)',
1299
+ cursor: 'pointer',
1300
+ fontSize: '12px',
1301
+ fontWeight: 600,
1302
+ boxShadow: 'none',
1303
+ };
1304
+ const primaryButtonBorder = '1px solid color-mix(in srgb, var(--brand) 88%, transparent)';
1305
+ const primaryButtonBackground = 'var(--brand)';
1306
+ const primaryButtonHoverBorder = '1px solid color-mix(in srgb, var(--brand-strong) 92%, transparent)';
1307
+ const primaryButtonHoverBackground = 'var(--brand-strong)';
1308
+ const primaryButtonShadow = isLightTheme
1309
+ ? '0 12px 24px color-mix(in srgb, var(--brand) 26%, transparent)'
1310
+ : '0 14px 26px color-mix(in srgb, var(--brand) 18%, transparent)';
1311
+ const primaryButtonHoverShadow = isLightTheme
1312
+ ? '0 16px 28px color-mix(in srgb, var(--brand) 30%, transparent)'
1313
+ : '0 18px 32px color-mix(in srgb, var(--brand) 22%, transparent)';
1314
+ const primaryButtonStyle = {
1315
+ minHeight: '38px',
1316
+ minWidth: '122px',
1317
+ padding: '0 16px',
1318
+ borderRadius: '13px',
1319
+ border: primaryButtonBorder,
1320
+ background: primaryButtonBackground,
1321
+ color: '#FFFFFF',
1322
+ cursor: 'pointer',
1323
+ fontSize: '13px',
1324
+ fontWeight: 800,
1325
+ letterSpacing: '-0.01em',
1326
+ boxShadow: primaryButtonShadow,
1327
+ transition: 'transform 160ms, border-color 160ms, background 160ms, box-shadow 160ms',
1328
+ };
1329
+ const handlePrimaryButtonHover = (event, hovered) => {
1330
+ event.currentTarget.style.transform = hovered ? 'translateY(-1px)' : 'translateY(0)';
1331
+ event.currentTarget.style.border = hovered ? primaryButtonHoverBorder : primaryButtonBorder;
1332
+ event.currentTarget.style.background = hovered ? primaryButtonHoverBackground : primaryButtonBackground;
1333
+ event.currentTarget.style.boxShadow = hovered ? primaryButtonHoverShadow : primaryButtonShadow;
1334
+ };
1335
+ const sectionHeaderShellStyle = {
1336
+ display: 'flex',
1337
+ justifyContent: 'space-between',
1338
+ gap: '12px',
1339
+ alignItems: 'flex-start',
1340
+ flexWrap: 'wrap',
1341
+ };
1342
+ const sectionHeaderBodyStyle = {
1343
+ display: 'grid',
1344
+ gap: '4px',
1345
+ minWidth: 0,
1346
+ };
1347
+ const sectionHeaderTitleRowStyle = {
1348
+ display: 'flex',
1349
+ alignItems: 'center',
1350
+ flexWrap: 'wrap',
1351
+ gap: '8px',
1352
+ minWidth: 0,
1353
+ };
1354
+ const sectionHeaderTitleStyle = {
1355
+ fontSize: '16px',
1356
+ fontWeight: 800,
1357
+ color: 'var(--text-primary)',
1358
+ letterSpacing: '-0.02em',
1359
+ };
1360
+ const sectionHeaderCountStyle = {
1361
+ display: 'inline-flex',
1362
+ alignItems: 'center',
1363
+ minHeight: '22px',
1364
+ padding: '0 8px',
1365
+ borderRadius: '999px',
1366
+ border: '1px solid color-mix(in srgb, var(--control-border) 58%, transparent)',
1367
+ background: isLightTheme
1368
+ ? 'color-mix(in srgb, var(--surface-elevated) 94%, var(--control-bg-muted))'
1369
+ : 'color-mix(in srgb, var(--surface-elevated) 84%, var(--surface-tint))',
1370
+ color: 'var(--text-secondary)',
1371
+ fontSize: '11px',
1372
+ fontWeight: 700,
1373
+ whiteSpace: 'nowrap',
1374
+ };
1375
+ const sectionHeaderDescriptionStyle = {
1376
+ fontSize: '12px',
1377
+ lineHeight: 1.55,
1378
+ color: 'var(--text-secondary)',
1379
+ };
1380
+ const renderSectionHeader = ({ title: headerTitle, description, count, actionLabel, onAction }) => (
1381
+ <div style={sectionHeaderShellStyle}>
1382
+ <div style={sectionHeaderBodyStyle}>
1383
+ <div style={sectionHeaderTitleRowStyle}>
1384
+ <div style={sectionHeaderTitleStyle}>{headerTitle}</div>
1385
+ {count ? <span style={sectionHeaderCountStyle}>{count}</span> : null}
1386
+ </div>
1387
+ {description ? <div style={sectionHeaderDescriptionStyle}>{description}</div> : null}
1388
+ </div>
1389
+ {actionLabel && onAction ? <button type="button" onClick={onAction} style={resultsEntryButtonStyle}>{actionLabel}</button> : null}
1390
+ </div>
1391
+ );
1392
+ const heroPanelGap = stackActions ? '14px' : '12px';
1393
+ const heroPanelPadding = stackActions ? '16px' : '16px 18px';
1394
+ const heroHeaderGap = stackActions ? '12px' : '10px';
1395
+ const heroTitleGroupGap = '6px';
1396
+ const heroMetricsGap = stackActions ? '10px' : '8px';
1397
+ const heroMetricMinHeight = stackActions ? '66px' : '62px';
1398
+ const heroMetricPadding = stackActions ? '10px 12px' : '10px 12px';
1399
+
1400
+ return (
1401
+ <div
1402
+ data-rh-home-overview
1403
+ style={{
1404
+ display: 'grid',
1405
+ gap: '16px',
1406
+ width: '100%',
1407
+ }}
1408
+ >
1409
+ <div
1410
+ data-rh-home-hero
1411
+ style={{
1412
+ ...heroSurfaceStyle,
1413
+ display: 'grid',
1414
+ gap: heroPanelGap,
1415
+ padding: 0,
1416
+ borderRadius: 0,
1417
+ border: 'none',
1418
+ background: 'transparent',
1419
+ boxShadow: 'none',
1420
+ }}
1421
+ >
1422
+ <div
1423
+ style={{
1424
+ display: 'grid',
1425
+ gridTemplateColumns: stackActions ? '1fr' : 'minmax(0, 1fr) auto',
1426
+ gap: heroHeaderGap,
1427
+ alignItems: 'start',
1428
+ width: '100%',
1429
+ }}
1430
+ >
1431
+ <div style={{ display: 'grid', gap: heroTitleGroupGap, minWidth: 0, maxWidth: stackActions ? '100%' : '760px' }}>
1432
+ <div data-rh-home-title style={{ fontSize: '28px', fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.03em' }}>{title}</div>
1433
+ <div data-rh-home-subtitle style={{ fontSize: '14px', lineHeight: 1.55, color: 'var(--text-secondary)', maxWidth: '720px' }}>{subtitle}</div>
1434
+ </div>
1435
+ <div
1436
+ style={{
1437
+ display: 'flex',
1438
+ gap: '8px',
1439
+ justifyContent: stackActions ? 'flex-start' : 'flex-end',
1440
+ alignItems: 'center',
1441
+ justifySelf: stackActions ? 'stretch' : 'end',
1442
+ flexWrap: stackActions ? 'wrap' : 'nowrap',
1443
+ }}
1444
+ >
1445
+ <button
1446
+ data-rh-home-browse-all
1447
+ onClick={onBrowseAll}
1448
+ onMouseEnter={(event) => handlePrimaryButtonHover(event, true)}
1449
+ onMouseLeave={(event) => handlePrimaryButtonHover(event, false)}
1450
+ style={primaryButtonStyle}
1451
+ >
1452
+ 浏览全部资源
1453
+ </button>
1454
+ {currentUser && onCreate && (
1455
+ <button data-rh-toolbar-create onClick={onCreate} style={secondaryButtonStyle}>新增资源</button>
1456
+ )}
1457
+ </div>
1458
+ </div>
1459
+ <div
1460
+ data-rh-home-metrics
1461
+ style={{
1462
+ display: 'grid',
1463
+ gridTemplateColumns: inlineMetrics
1464
+ ? `repeat(${Math.max(metrics.length, 1)}, minmax(0, 1fr))`
1465
+ : 'repeat(auto-fit, minmax(140px, 1fr))',
1466
+ gap: heroMetricsGap,
1467
+ alignItems: 'stretch',
1468
+ width: '100%',
1469
+ }}
1470
+ >
1471
+ {metrics.map((card, index) => {
1472
+ const isZeroMetric = Number(card.value) === 0;
1473
+ const displayValue = isZeroMetric ? '暂无数据' : card.value;
1474
+ const cardAccent = card.accent || 'var(--brand)';
1475
+
1476
+ return (
1477
+ <div
1478
+ key={card.key}
1479
+ data-rh-home-metric-card={card.kind === 'summary' ? card.key : undefined}
1480
+ data-rh-home-traffic-card={card.kind === 'traffic' ? card.key : undefined}
1481
+ data-rh-home-metric-zero={isZeroMetric ? 'true' : 'false'}
1482
+ style={{
1483
+ display: 'grid',
1484
+ gap: '3px',
1485
+ minWidth: 0,
1486
+ minHeight: heroMetricMinHeight,
1487
+ padding: heroMetricPadding,
1488
+ borderRadius: '16px',
1489
+ borderTop: isZeroMetric
1490
+ ? '2px solid color-mix(in srgb, var(--control-border) 72%, transparent)'
1491
+ : `2px solid ${cardAccent}`,
1492
+ border: isZeroMetric
1493
+ ? '1px solid color-mix(in srgb, var(--control-border) 68%, transparent)'
1494
+ : '1px solid color-mix(in srgb, var(--control-border) 78%, transparent)',
1495
+ background: isZeroMetric
1496
+ ? (isLightTheme
1497
+ ? 'color-mix(in srgb, var(--surface-elevated) 94%, var(--control-bg-muted))'
1498
+ : 'color-mix(in srgb, var(--surface-elevated) 82%, var(--bg-primary))')
1499
+ : (isLightTheme
1500
+ ? 'var(--surface-elevated)'
1501
+ : 'var(--surface-elevated)'),
1502
+ color: 'var(--text-primary)',
1503
+ boxShadow: isZeroMetric
1504
+ ? 'none'
1505
+ : (isLightTheme
1506
+ ? '0 6px 16px color-mix(in srgb, var(--text-primary) 4%, transparent)'
1507
+ : '0 1px 3px color-mix(in srgb, var(--bg-primary) 24%, transparent)'),
1508
+ }}
1509
+ >
1510
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
1511
+ <span
1512
+ style={{
1513
+ fontSize: '10px',
1514
+ fontWeight: 800,
1515
+ letterSpacing: '0.02em',
1516
+ color: isZeroMetric
1517
+ ? 'color-mix(in srgb, var(--text-secondary) 82%, transparent)'
1518
+ : 'var(--text-secondary)',
1519
+ }}
1520
+ >
1521
+ {card.label}
1522
+ </span>
1523
+ </div>
1524
+ <div
1525
+ style={{
1526
+ fontSize: isZeroMetric ? '15px' : '21px',
1527
+ lineHeight: 1,
1528
+ fontWeight: 800,
1529
+ letterSpacing: '-0.03em',
1530
+ color: isZeroMetric
1531
+ ? 'color-mix(in srgb, var(--text-primary) 72%, var(--text-secondary))'
1532
+ : 'var(--text-primary)',
1533
+ }}
1534
+ >
1535
+ {displayValue}
1536
+ </div>
1537
+ <div
1538
+ style={{
1539
+ fontSize: '10px',
1540
+ lineHeight: 1.4,
1541
+ color: isZeroMetric
1542
+ ? 'color-mix(in srgb, var(--text-secondary) 78%, transparent)'
1543
+ : 'var(--text-secondary)',
1544
+ }}
1545
+ >
1546
+ {card.note}
1547
+ </div>
1548
+ </div>
1549
+ );
1550
+ })}
1551
+ </div>
1552
+ </div>
1553
+
1554
+ {currentUser && quickAccessEntries.length > 0 && (
1555
+ <div data-rh-home-overview-section="quick-access" style={{ display: 'grid', gap: '10px' }}>
1556
+ {renderSectionHeader({
1557
+ title: '快捷入口',
1558
+ description: '从收藏、历史或我的资源继续浏览和维护内容。',
1559
+ count: `${quickAccessEntries.length} 项`,
1560
+ })}
1561
+ <div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(columns, quickAccessEntries.length)}, minmax(0, 1fr))`, gap: '12px' }}>
1562
+ {quickAccessEntries.map((entry) => {
1563
+ const isEmptyEntry = entry.count === 0;
1564
+ return (
1565
+ <button
1566
+ key={entry.key}
1567
+ aria-label={entry.label}
1568
+ type="button"
1569
+ data-rh-overview-quick-access={entry.key}
1570
+ data-rh-overview-quick-access-empty={isEmptyEntry ? 'true' : 'false'}
1571
+ onMouseEnter={() => setHoveredQuickAccessKey(entry.key)}
1572
+ onMouseLeave={() => setHoveredQuickAccessKey(null)}
1573
+ onClick={() => onSelectQuickAccess(entry.filter)}
1574
+ style={{
1575
+ minHeight: '88px',
1576
+ padding: '13px',
1577
+ borderRadius: '16px',
1578
+ ...((isEmptyEntry ? quickAccessEmptySurfaceStyle : quickAccessSurfaceStyle)),
1579
+ border: hoveredQuickAccessKey === entry.key
1580
+ ? (isLightTheme
1581
+ ? `1px solid color-mix(in srgb, ${entry.accent || 'var(--brand)'} 42%, var(--control-border))`
1582
+ : `1px solid color-mix(in srgb, ${entry.accent || 'var(--brand)'} 48%, var(--border-strong))`)
1583
+ : isEmptyEntry
1584
+ ? quickAccessEmptySurfaceStyle.border
1585
+ : quickAccessSurfaceStyle.border,
1586
+ background: isEmptyEntry
1587
+ ? quickAccessEmptySurfaceStyle.background
1588
+ : (isLightTheme
1589
+ ? 'var(--surface-elevated)'
1590
+ : 'var(--surface-elevated)'),
1591
+ display: 'grid',
1592
+ gap: '6px',
1593
+ textAlign: 'left',
1594
+ cursor: 'pointer',
1595
+ transform: hoveredQuickAccessKey === entry.key ? interactiveCardLift : 'translateY(0)',
1596
+ boxShadow: hoveredQuickAccessKey === entry.key
1597
+ ? (isLightTheme
1598
+ ? `0 14px 26px color-mix(in srgb, ${entry.accent || 'var(--brand)'} 20%, transparent)`
1599
+ : `0 16px 30px color-mix(in srgb, ${entry.accent || 'var(--brand)'} 22%, transparent)`)
1600
+ : (isEmptyEntry ? quickAccessEmptySurfaceStyle.boxShadow : quickAccessSurfaceStyle.boxShadow),
1601
+ transition: interactiveCardTransition,
1602
+ }}
1603
+ >
1604
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
1605
+ <div
1606
+ style={{
1607
+ fontSize: '14px',
1608
+ fontWeight: 700,
1609
+ color: isEmptyEntry ? 'color-mix(in srgb, var(--text-primary) 72%, var(--text-secondary))' : 'var(--text-primary)',
1610
+ }}
1611
+ >
1612
+ {entry.label}
1613
+ </div>
1614
+ <div
1615
+ style={{
1616
+ fontSize: '20px',
1617
+ fontWeight: 800,
1618
+ color: isEmptyEntry ? 'var(--text-secondary)' : (entry.accent || 'var(--brand-strong)'),
1619
+ opacity: isEmptyEntry ? 0.78 : 1,
1620
+ }}
1621
+ >
1622
+ {entry.count}
1623
+ </div>
1624
+ </div>
1625
+ <div
1626
+ style={{
1627
+ fontSize: '12px',
1628
+ lineHeight: 1.5,
1629
+ color: isEmptyEntry
1630
+ ? 'color-mix(in srgb, var(--text-secondary) 92%, transparent)'
1631
+ : 'var(--text-secondary)',
1632
+ }}
1633
+ >
1634
+ {isEmptyEntry ? entry.emptyNote : entry.note}
1635
+ </div>
1636
+ </button>
1637
+ );
1638
+ })}
1639
+ </div>
1640
+ </div>
1641
+ )}
1642
+
1643
+ <div data-rh-home-overview-categories style={{ display: 'grid', gap: '10px' }}>
1644
+ {renderSectionHeader({
1645
+ title: '分类入口',
1646
+ description: '按主题快速浏览全部资源。',
1647
+ count: `${categoryHighlights.length} 个分类`,
1648
+ })}
1649
+ <div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(columns, Math.max(categoryHighlights.length, 1))}, minmax(0, 1fr))`, gap: '12px' }}>
1650
+ {categoryHighlights.map((category) => {
1651
+ const categoryTone = category.tone || window.helpers.getCategoryTone(category, category.id);
1652
+
1653
+ return (
1654
+ <button
1655
+ key={category.id}
1656
+ type="button"
1657
+ data-rh-overview-category-card={String(category.id)}
1658
+ aria-label={category.name}
1659
+ onMouseEnter={() => setHoveredCategoryId(category.id)}
1660
+ onMouseLeave={() => setHoveredCategoryId(null)}
1661
+ onClick={() => onSelectCategory(category.id)}
1662
+ style={{
1663
+ position: 'relative',
1664
+ minHeight: '104px',
1665
+ padding: '15px 13px 13px',
1666
+ borderRadius: '16px',
1667
+ ...surfaceStyle,
1668
+ border: isLightTheme
1669
+ ? `1px solid color-mix(in srgb, ${categoryTone.accent} ${hoveredCategoryId === category.id ? 24 : 14}%, var(--control-border))`
1670
+ : `1px solid color-mix(in srgb, ${categoryTone.accent} ${hoveredCategoryId === category.id ? 34 : 20}%, var(--border))`,
1671
+ background: hoveredCategoryId === category.id
1672
+ ? (isLightTheme
1673
+ ? `color-mix(in srgb, ${categoryTone.accent} 6%, var(--surface-elevated))`
1674
+ : `color-mix(in srgb, ${categoryTone.accent} 10%, var(--surface-elevated))`)
1675
+ : surfaceStyle.background,
1676
+ display: 'grid',
1677
+ gap: '8px',
1678
+ textAlign: 'left',
1679
+ cursor: 'pointer',
1680
+ transform: hoveredCategoryId === category.id ? interactiveCardLift : 'translateY(0)',
1681
+ boxShadow: hoveredCategoryId === category.id
1682
+ ? (isLightTheme
1683
+ ? '0 14px 28px color-mix(in srgb, var(--text-primary) 8%, transparent)'
1684
+ : '0 16px 30px color-mix(in srgb, var(--bg-primary) 28%, transparent)')
1685
+ : surfaceStyle.boxShadow,
1686
+ transition: interactiveCardTransition,
1687
+ overflow: 'hidden',
1688
+ }}
1689
+ >
1690
+ <span
1691
+ aria-hidden="true"
1692
+ style={{
1693
+ position: 'absolute',
1694
+ inset: '0 0 auto 0',
1695
+ height: '3px',
1696
+ background: categoryTone.accent,
1697
+ }}
1698
+ />
1699
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '10px', alignItems: 'center' }}>
1700
+ <div style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>{category.name}</div>
1701
+ <div style={{ fontSize: '18px', fontWeight: 800, color: categoryTone.accent }}>{category.resourceCount || 0}</div>
1702
+ </div>
1703
+ <div style={{ fontSize: '12px', lineHeight: 1.55, color: 'var(--text-secondary)' }}>
1704
+ {category.previewNames.length > 0
1705
+ ? category.previewNames.join(' · ')
1706
+ : category.previewTags.length > 0
1707
+ ? category.previewTags.map((tag) => `#${tag}`).join(' ')
1708
+ : '点击进入该分类结果页'}
1709
+ </div>
1710
+ </button>
1711
+ );
1712
+ })}
1713
+ </div>
1714
+ </div>
1715
+
1716
+ {sections.map((section) => {
1717
+ const sectionColumns = Math.min(columns, section.resources.length);
1718
+ return (
1719
+ <div key={section.key} data-rh-home-overview-section={section.key} style={{ display: 'grid', gap: '10px' }}>
1720
+ {renderSectionHeader({
1721
+ title: section.title,
1722
+ description: section.description,
1723
+ count: `${section.resources.length} 项`,
1724
+ actionLabel: section.actionLabel,
1725
+ onAction: section.action,
1726
+ })}
1727
+ <div style={{ display: 'grid', gridTemplateColumns: `repeat(${sectionColumns}, minmax(0, 1fr))`, gap: '10px' }}>
1728
+ {section.resources.map((resource) => (
1729
+ <div key={resource.id} data-rh-resource-item>
1730
+ <window.ResourceCard resource={resource} featured={true} />
1731
+ </div>
1732
+ ))}
1733
+ </div>
1734
+ </div>
1735
+ );
1736
+ })}
1737
+ </div>
1738
+ );
1739
+ }
1740
+
1741
+ function HomeSidebarNav({
1742
+ currentUser,
1743
+ quickAccessItems,
1744
+ quickAccessFilter,
1745
+ categoryList,
1746
+ selectedCategory,
1747
+ selectedTags,
1748
+ visibleTags,
1749
+ hiddenTagCount,
1750
+ showAllTags,
1751
+ onToggleShowAllTags,
1752
+ dispatch,
1753
+ isLightTheme = false,
1754
+ visualMode = 'results',
1755
+ isDrawer = false,
1756
+ onNavigate,
1757
+ }) {
1758
+ const { Heart, Clock, FileText, Tags } = lucide;
1759
+ const quietResults = visualMode === 'results' && !isDrawer;
1760
+ const sidebarShellSurfaceStyle = isDrawer
1761
+ ? {
1762
+ border: 'none',
1763
+ background: 'transparent',
1764
+ boxShadow: 'none',
1765
+ }
1766
+ : quietResults
1767
+ ? {
1768
+ border: isLightTheme
1769
+ ? '1px solid var(--border)'
1770
+ : '1px solid color-mix(in srgb, var(--border-strong) 72%, var(--border))',
1771
+ background: isLightTheme
1772
+ ? 'var(--bg-tertiary)'
1773
+ : 'color-mix(in srgb, var(--surface-elevated) 88%, var(--bg-primary))',
1774
+ boxShadow: isLightTheme
1775
+ ? '0 6px 16px color-mix(in srgb, var(--text-primary) 4%, transparent)'
1776
+ : '0 12px 22px color-mix(in srgb, var(--bg-primary) 14%, transparent)',
1777
+ }
1778
+ : {
1779
+ border: isLightTheme
1780
+ ? '1px solid color-mix(in srgb, var(--control-border) 18%, transparent)'
1781
+ : '1px solid color-mix(in srgb, var(--border-strong) 22%, var(--border))',
1782
+ background: isLightTheme
1783
+ ? 'color-mix(in srgb, var(--surface-elevated) 92%, var(--surface-tint))'
1784
+ : 'color-mix(in srgb, var(--surface-elevated) 88%, var(--bg-primary))',
1785
+ boxShadow: '0 3px 10px color-mix(in srgb, var(--text-primary) 2%, transparent)',
1786
+ };
1787
+ const sectionTitleStyle = {
1788
+ fontSize: '10px',
1789
+ fontWeight: 800,
1790
+ color: quietResults
1791
+ ? isLightTheme
1792
+ ? 'color-mix(in srgb, var(--text-secondary) 92%, transparent)'
1793
+ : 'color-mix(in srgb, var(--text-secondary) 94%, transparent)'
1794
+ : isLightTheme
1795
+ ? 'color-mix(in srgb, var(--text-secondary) 72%, transparent)'
1796
+ : 'var(--text-secondary)',
1797
+ letterSpacing: '0.08em',
1798
+ textTransform: 'uppercase',
1799
+ };
1800
+ const sidebarSectionGap = quietResults ? '7px' : '8px';
1801
+ const sidebarSectionPaddingTop = quietResults ? '8px' : '10px';
1802
+ const sidebarSectionItemGap = quietResults ? '3px' : '4px';
1803
+ const sidebarActionGap = quietResults ? '5px' : '6px';
1804
+ const sidebarMenuMinHeight = quietResults ? '30px' : '32px';
1805
+ const sidebarMenuRadius = quietResults ? '10px' : '11px';
1806
+ const sidebarChipMinHeight = quietResults ? '21px' : '23px';
1807
+ const sidebarChipPadding = quietResults ? '0 6px' : '0 7px';
1808
+ const sectionStyle = {
1809
+ display: 'grid',
1810
+ gap: sidebarSectionGap,
1811
+ paddingTop: sidebarSectionPaddingTop,
1812
+ borderTop: quietResults
1813
+ ? isLightTheme
1814
+ ? '1px solid color-mix(in srgb, var(--border) 86%, transparent)'
1815
+ : '1px solid color-mix(in srgb, var(--outline-strong) 12%, transparent)'
1816
+ : isLightTheme
1817
+ ? '1px solid color-mix(in srgb, var(--control-border) 18%, transparent)'
1818
+ : '1px solid color-mix(in srgb, var(--outline-strong) 10%, transparent)',
1819
+ };
1820
+ const menuButtonStyle = (active) => ({
1821
+ minHeight: sidebarMenuMinHeight,
1822
+ padding: '0 8px',
1823
+ borderRadius: sidebarMenuRadius,
1824
+ border: active
1825
+ ? '1px solid color-mix(in srgb, var(--brand) 18%, var(--control-border))'
1826
+ : quietResults
1827
+ ? isLightTheme
1828
+ ? '1px solid color-mix(in srgb, var(--control-border) 84%, transparent)'
1829
+ : '1px solid color-mix(in srgb, var(--outline-strong) 30%, var(--surface-tint))'
1830
+ : '1px solid transparent',
1831
+ background: active
1832
+ ? 'color-mix(in srgb, var(--brand-soft) 88%, var(--surface-elevated))'
1833
+ : quietResults
1834
+ ? isLightTheme
1835
+ ? 'var(--surface-elevated)'
1836
+ : 'color-mix(in srgb, var(--surface-elevated) 42%, var(--control-bg))'
1837
+ : isLightTheme
1838
+ ? 'color-mix(in srgb, var(--control-bg) 42%, transparent)'
1839
+ : 'color-mix(in srgb, var(--control-bg) 72%, var(--control-bg-muted))',
1840
+ color: active
1841
+ ? 'var(--brand-strong)'
1842
+ : quietResults
1843
+ ? isLightTheme
1844
+ ? 'color-mix(in srgb, var(--text-primary) 76%, var(--text-secondary))'
1845
+ : 'color-mix(in srgb, var(--text-secondary) 88%, transparent)'
1846
+ : 'var(--text-primary)',
1847
+ cursor: 'pointer',
1848
+ display: 'flex',
1849
+ alignItems: 'center',
1850
+ justifyContent: 'space-between',
1851
+ gap: quietResults ? '8px' : '9px',
1852
+ fontSize: '12px',
1853
+ fontWeight: active ? 700 : quietResults ? 600 : 500,
1854
+ textAlign: 'left',
1855
+ boxShadow: active
1856
+ ? '0 4px 12px color-mix(in srgb, var(--brand) 10%, transparent)'
1857
+ : 'none',
1858
+ });
1859
+ const categoryRowStyle = (active) => ({
1860
+ minHeight: sidebarMenuMinHeight,
1861
+ padding: quietResults ? '0 7px 0 0' : '0 8px 0 0',
1862
+ border: active
1863
+ ? '1px solid color-mix(in srgb, var(--brand) 18%, var(--control-border))'
1864
+ : quietResults
1865
+ ? isLightTheme
1866
+ ? '1px solid color-mix(in srgb, var(--control-border) 84%, transparent)'
1867
+ : '1px solid color-mix(in srgb, var(--outline-strong) 30%, var(--surface-tint))'
1868
+ : '1px solid transparent',
1869
+ borderRadius: sidebarMenuRadius,
1870
+ background: active
1871
+ ? 'color-mix(in srgb, var(--brand-soft) 88%, var(--surface-elevated))'
1872
+ : quietResults
1873
+ ? isLightTheme
1874
+ ? 'var(--surface-elevated)'
1875
+ : 'color-mix(in srgb, var(--surface-elevated) 40%, var(--control-bg))'
1876
+ : 'transparent',
1877
+ color: active
1878
+ ? 'var(--brand-strong)'
1879
+ : isLightTheme
1880
+ ? quietResults
1881
+ ? 'color-mix(in srgb, var(--text-primary) 76%, var(--text-secondary))'
1882
+ : 'color-mix(in srgb, var(--text-primary) 82%, transparent)'
1883
+ : 'var(--text-secondary)',
1884
+ cursor: 'pointer',
1885
+ display: 'flex',
1886
+ alignItems: 'center',
1887
+ justifyContent: 'space-between',
1888
+ gap: quietResults ? '7px' : '8px',
1889
+ fontSize: '12px',
1890
+ fontWeight: active ? 700 : quietResults ? 600 : 500,
1891
+ textAlign: 'left',
1892
+ boxShadow: 'none',
1893
+ });
1894
+ const tagChipStyle = (active) => ({
1895
+ display: 'inline-flex',
1896
+ alignItems: 'center',
1897
+ minHeight: sidebarChipMinHeight,
1898
+ padding: sidebarChipPadding,
1899
+ borderRadius: '999px',
1900
+ border: active
1901
+ ? '1px solid color-mix(in srgb, var(--brand) 18%, var(--control-border))'
1902
+ : quietResults
1903
+ ? isLightTheme
1904
+ ? '1px solid color-mix(in srgb, var(--control-border) 84%, transparent)'
1905
+ : '1px solid color-mix(in srgb, var(--outline-strong) 30%, var(--surface-tint))'
1906
+ : '1px solid transparent',
1907
+ background: active
1908
+ ? 'color-mix(in srgb, var(--brand-soft) 88%, var(--surface-elevated))'
1909
+ : quietResults
1910
+ ? isLightTheme
1911
+ ? 'var(--surface-muted)'
1912
+ : 'color-mix(in srgb, var(--surface-elevated) 40%, var(--control-bg))'
1913
+ : isLightTheme
1914
+ ? 'color-mix(in srgb, var(--control-bg) 34%, transparent)'
1915
+ : 'color-mix(in srgb, var(--control-bg) 62%, var(--control-bg-muted))',
1916
+ color: active ? 'var(--brand-strong)' : 'var(--text-secondary)',
1917
+ cursor: 'pointer',
1918
+ fontSize: quietResults ? '9px' : '10px',
1919
+ fontWeight: active ? 700 : 500,
1920
+ });
1921
+
1922
+ const handleQuickAccess = (filter) => {
1923
+ dispatch({ type: 'SET_QUICK_ACCESS_FILTER', filter });
1924
+ onNavigate?.();
1925
+ };
1926
+
1927
+ const handleCategory = (categoryId) => {
1928
+ dispatch({ type: 'SET_CATEGORY', category: categoryId });
1929
+ onNavigate?.();
1930
+ };
1931
+
1932
+ const quickAccessIconMap = { favorites: Heart, history: Clock, mine: FileText };
1933
+
1934
+ return (
1935
+ <aside
1936
+ data-rh-home-sidebar={!isDrawer ? 'desktop' : undefined}
1937
+ data-rh-home-sidebar-visual-mode={visualMode}
1938
+ data-rh-home-sidebar-quiet={quietResults ? 'true' : 'false'}
1939
+ style={{
1940
+ position: isDrawer ? 'relative' : 'sticky',
1941
+ top: isDrawer ? 'auto' : 'var(--app-header-height, 78px)',
1942
+ alignSelf: 'start',
1943
+ width: '100%',
1944
+ maxHeight: isDrawer ? 'none' : 'calc(100vh - var(--app-header-height, 78px))',
1945
+ overflowY: 'auto',
1946
+ padding: 0,
1947
+ }}
1948
+ >
1949
+ <div
1950
+ style={{
1951
+ display: 'grid',
1952
+ gap: 0,
1953
+ padding: isDrawer ? 0 : quietResults ? '8px' : '8px',
1954
+ borderRadius: isDrawer ? 0 : quietResults ? '18px' : '16px',
1955
+ ...sidebarShellSurfaceStyle,
1956
+ }}
1957
+ >
1958
+ <div data-rh-home-sidebar-section="categories" style={{ display: 'grid', gap: sidebarSectionGap, paddingTop: 0 }}>
1959
+ <div data-rh-home-category-title style={sectionTitleStyle}>类别过滤</div>
1960
+ <div style={{ display: 'grid', gap: sidebarSectionItemGap }}>
1961
+ {categoryList.map((category) => {
1962
+ const active = selectedCategory === category.id;
1963
+ return (
1964
+ <button
1965
+ key={category.id === null ? 'all' : category.id}
1966
+ type="button"
1967
+ data-rh-home-category-item={category.id === null ? 'all' : String(category.id)}
1968
+ data-rh-home-category-active={active ? 'true' : 'false'}
1969
+ onClick={() => handleCategory(category.id)}
1970
+ style={categoryRowStyle(active)}
1971
+ >
1972
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: quietResults ? '7px' : '8px', minWidth: 0, flex: 1 }}>
1973
+ <span
1974
+ style={{
1975
+ width: '2px',
1976
+ height: quietResults ? '14px' : '16px',
1977
+ borderRadius: '999px',
1978
+ background: active ? 'var(--brand)' : 'transparent',
1979
+ flexShrink: 0,
1980
+ }}
1981
+ />
1982
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{category.name}</span>
1983
+ </span>
1984
+ <span
1985
+ style={{
1986
+ minWidth: '28px',
1987
+ padding: quietResults ? '0 5px' : '0 6px',
1988
+ height: quietResults ? '18px' : '20px',
1989
+ borderRadius: '999px',
1990
+ display: 'inline-flex',
1991
+ alignItems: 'center',
1992
+ justifyContent: 'center',
1993
+ background: active
1994
+ ? 'color-mix(in srgb, var(--brand-soft) 92%, var(--surface-elevated))'
1995
+ : quietResults
1996
+ ? 'var(--surface-muted)'
1997
+ : isLightTheme
1998
+ ? 'color-mix(in srgb, var(--surface-tint) 24%, var(--control-bg))'
1999
+ : 'color-mix(in srgb, var(--surface-tint) 64%, transparent)',
2000
+ color: active
2001
+ ? 'var(--brand-strong)'
2002
+ : quietResults
2003
+ ? 'color-mix(in srgb, var(--text-secondary) 82%, transparent)'
2004
+ : 'var(--text-secondary)',
2005
+ fontSize: quietResults ? '9px' : '10px',
2006
+ fontWeight: 700,
2007
+ fontVariantNumeric: 'tabular-nums',
2008
+ flexShrink: 0,
2009
+ }}
2010
+ >
2011
+ {category.resourceCount || 0}
2012
+ </span>
2013
+ </button>
2014
+ );
2015
+ })}
2016
+ </div>
2017
+ </div>
2018
+
2019
+ <div data-rh-home-sidebar-section="tags" style={sectionStyle}>
2020
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
2021
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: quietResults ? '6px' : '7px', minWidth: 0 }}>
2022
+ <Tags size={13} style={{ color: 'var(--text-secondary)' }} />
2023
+ <span style={sectionTitleStyle}>标签筛选</span>
2024
+ </div>
2025
+ {selectedTags.length > 0 && (
2026
+ <button
2027
+ type="button"
2028
+ onClick={() => selectedTags.forEach((tag) => dispatch({ type: 'TOGGLE_TAG', tag }))}
2029
+ style={{
2030
+ minHeight: quietResults ? '22px' : '24px',
2031
+ padding: quietResults ? '0 8px' : '0 9px',
2032
+ borderRadius: '999px',
2033
+ border: '1px solid var(--control-border)',
2034
+ background: isLightTheme ? 'var(--surface-elevated)' : 'var(--surface-muted)',
2035
+ color: 'var(--text-secondary)',
2036
+ cursor: 'pointer',
2037
+ fontSize: quietResults ? '9px' : '10px',
2038
+ fontWeight: 600,
2039
+ }}
2040
+ >
2041
+ 清空标签
2042
+ </button>
2043
+ )}
2044
+ </div>
2045
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: sidebarActionGap }}>
2046
+ {visibleTags.map((tag) => {
2047
+ const active = selectedTags.includes(tag);
2048
+ return (
2049
+ <button
2050
+ key={tag}
2051
+ type="button"
2052
+ data-rh-home-tag-item={tag}
2053
+ data-rh-home-tag-active={active ? 'true' : 'false'}
2054
+ onClick={() => dispatch({ type: 'TOGGLE_TAG', tag })}
2055
+ style={tagChipStyle(active)}
2056
+ >
2057
+ #{tag}
2058
+ </button>
2059
+ );
2060
+ })}
2061
+ </div>
2062
+ {hiddenTagCount > 0 && (
2063
+ <button
2064
+ type="button"
2065
+ data-rh-home-tags-toggle
2066
+ onClick={onToggleShowAllTags}
2067
+ style={{
2068
+ justifySelf: 'start',
2069
+ minHeight: quietResults ? '22px' : '24px',
2070
+ padding: quietResults ? '0 7px' : '0 8px',
2071
+ borderRadius: '999px',
2072
+ border: '1px solid var(--control-border)',
2073
+ background: 'transparent',
2074
+ color: 'var(--text-primary)',
2075
+ cursor: 'pointer',
2076
+ fontSize: quietResults ? '9px' : '10px',
2077
+ fontWeight: 600,
2078
+ }}
2079
+ >
2080
+ {showAllTags ? '收起标签' : `展开更多 (${hiddenTagCount})`}
2081
+ </button>
2082
+ )}
2083
+ </div>
2084
+
2085
+ {currentUser && quickAccessItems.length > 0 && (
2086
+ <div data-rh-home-sidebar-section="quick-access" style={sectionStyle}>
2087
+ <div style={sectionTitleStyle}>快捷访问</div>
2088
+ <div style={{ display: 'grid', gap: sidebarActionGap }}>
2089
+ <button
2090
+ type="button"
2091
+ data-rh-quick-access-item="all"
2092
+ data-rh-quick-access-active={quickAccessFilter ? 'false' : 'true'}
2093
+ onClick={() => handleQuickAccess(null)}
2094
+ style={menuButtonStyle(!quickAccessFilter)}
2095
+ >
2096
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: quietResults ? '8px' : '10px', minWidth: 0, flex: 1 }}>
2097
+ <span
2098
+ style={{
2099
+ width: '2px',
2100
+ height: quietResults ? '14px' : '16px',
2101
+ borderRadius: '999px',
2102
+ background: !quickAccessFilter ? 'var(--brand)' : 'transparent',
2103
+ flexShrink: 0,
2104
+ }}
2105
+ />
2106
+ <span style={{ width: 15, height: 15, flexShrink: 0, display: 'inline-block' }} aria-hidden="true" />
2107
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>全部资源</span>
2108
+ </span>
2109
+ </button>
2110
+ {quickAccessItems.map((item) => {
2111
+ const Icon = quickAccessIconMap[item.value] || item.Icon;
2112
+ const active = quickAccessFilter === item.key;
2113
+ return (
2114
+ <button
2115
+ key={item.value}
2116
+ type="button"
2117
+ data-rh-quick-access-item={item.value}
2118
+ data-rh-quick-access-active={active ? 'true' : 'false'}
2119
+ onClick={() => handleQuickAccess(active ? null : item.key)}
2120
+ style={menuButtonStyle(active)}
2121
+ >
2122
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: quietResults ? '8px' : '10px', minWidth: 0, flex: 1 }}>
2123
+ <span
2124
+ style={{
2125
+ width: '2px',
2126
+ height: quietResults ? '14px' : '16px',
2127
+ borderRadius: '999px',
2128
+ background: active ? 'var(--brand)' : 'transparent',
2129
+ flexShrink: 0,
2130
+ }}
2131
+ />
2132
+ <Icon size={15} />
2133
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.label}</span>
2134
+ </span>
2135
+ </button>
2136
+ );
2137
+ })}
2138
+ </div>
2139
+ </div>
2140
+ )}
2141
+ </div>
2142
+ </aside>
2143
+ );
2144
+ }
2145
+
2146
+ function HomeEmptyState({ title, description, primaryAction, secondaryAction }) {
2147
+ return (
2148
+ <div style={{
2149
+ display: 'grid',
2150
+ gap: '12px',
2151
+ padding: '22px',
2152
+ borderRadius: '14px',
2153
+ border: '1px dashed color-mix(in srgb, var(--border-strong) 76%, var(--border))',
2154
+ background: 'color-mix(in srgb, var(--surface-elevated) 92%, var(--surface-tint))',
2155
+ textAlign: 'center',
2156
+ }}>
2157
+ <div style={{ fontSize: '20px', fontWeight: 700, color: 'var(--text-primary)' }}>{title}</div>
2158
+ <div style={{ fontSize: '14px', lineHeight: 1.7, color: 'var(--text-secondary)', maxWidth: '540px', margin: '0 auto' }}>{description}</div>
2159
+ {(primaryAction || secondaryAction) && (
2160
+ <div style={{ display: 'flex', justifyContent: 'center', gap: '10px', flexWrap: 'wrap' }}>
2161
+ {primaryAction && (
2162
+ <button
2163
+ onClick={primaryAction.onClick}
2164
+ style={{
2165
+ minHeight: '40px',
2166
+ padding: '0 18px',
2167
+ borderRadius: '12px',
2168
+ border: 'none',
2169
+ background: 'var(--brand)',
2170
+ color: '#fff',
2171
+ cursor: 'pointer',
2172
+ fontSize: '14px',
2173
+ fontWeight: 600,
2174
+ }}
2175
+ >
2176
+ {primaryAction.label}
2177
+ </button>
2178
+ )}
2179
+ {secondaryAction && (
2180
+ <button
2181
+ onClick={secondaryAction.onClick}
2182
+ style={{
2183
+ minHeight: '40px',
2184
+ padding: '0 18px',
2185
+ borderRadius: '12px',
2186
+ border: '1px solid var(--control-border)',
2187
+ background: 'color-mix(in srgb, var(--control-bg) 94%, var(--control-bg-muted))',
2188
+ color: 'var(--text-primary)',
2189
+ cursor: 'pointer',
2190
+ fontSize: '14px',
2191
+ fontWeight: 600,
2192
+ boxShadow: 'var(--shadow-control)',
2193
+ }}
2194
+ >
2195
+ {secondaryAction.label}
2196
+ </button>
2197
+ )}
2198
+ </div>
2199
+ )}
2200
+ </div>
2201
+ );
2202
+ }
2203
+
2204
+ function HomeSidebarDrawer({ isOpen, onClose, children }) {
2205
+ const CloseIcon = lucide.X;
2206
+
2207
+ React.useEffect(() => {
2208
+ if (!isOpen) return undefined;
2209
+
2210
+ const handleKeyDown = (event) => {
2211
+ if (event.key === 'Escape') onClose();
2212
+ };
2213
+
2214
+ const previousOverflow = document.body.style.overflow;
2215
+ document.body.style.overflow = 'hidden';
2216
+ document.addEventListener('keydown', handleKeyDown);
2217
+
2218
+ return () => {
2219
+ document.body.style.overflow = previousOverflow;
2220
+ document.removeEventListener('keydown', handleKeyDown);
2221
+ };
2222
+ }, [isOpen, onClose]);
2223
+
2224
+ if (!isOpen) return null;
2225
+
2226
+ return (
2227
+ <div
2228
+ data-rh-home-sidebar-drawer
2229
+ style={{
2230
+ position: 'fixed',
2231
+ inset: 0,
2232
+ zIndex: 90,
2233
+ }}
2234
+ >
2235
+ <button
2236
+ type="button"
2237
+ aria-label="关闭导航遮罩"
2238
+ onClick={onClose}
2239
+ style={{
2240
+ position: 'absolute',
2241
+ inset: 0,
2242
+ border: 'none',
2243
+ background: 'rgba(2, 6, 23, 0.42)',
2244
+ cursor: 'pointer',
2245
+ }}
2246
+ />
2247
+ <div
2248
+ role="dialog"
2249
+ aria-modal="true"
2250
+ style={{
2251
+ position: 'absolute',
2252
+ inset: '0 auto 0 0',
2253
+ width: 'min(88vw, 320px)',
2254
+ height: '100%',
2255
+ padding: '18px 16px 20px',
2256
+ background: 'color-mix(in srgb, var(--surface-elevated) 94%, var(--surface-muted))',
2257
+ borderRight: '1px solid color-mix(in srgb, var(--border-strong) 76%, var(--border))',
2258
+ boxShadow: 'var(--shadow-modal)',
2259
+ display: 'grid',
2260
+ gap: '18px',
2261
+ overflowY: 'auto',
2262
+ backdropFilter: 'blur(18px)',
2263
+ }}
2264
+ >
2265
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
2266
+ <div style={{ display: 'grid', gap: '4px' }}>
2267
+ <div style={{ fontSize: '16px', fontWeight: 800, color: 'var(--text-primary)' }}>资源导航</div>
2268
+ <div style={{ fontSize: '12px', lineHeight: 1.6, color: 'var(--text-secondary)' }}>
2269
+ 快捷访问、类别和标签筛选已收进左侧导航。
2270
+ </div>
2271
+ </div>
2272
+ <button
2273
+ type="button"
2274
+ onClick={onClose}
2275
+ style={{
2276
+ width: '34px',
2277
+ height: '34px',
2278
+ borderRadius: '12px',
2279
+ border: '1px solid color-mix(in srgb, var(--border-strong) 76%, var(--border))',
2280
+ background: 'var(--surface-muted)',
2281
+ color: 'var(--text-secondary)',
2282
+ cursor: 'pointer',
2283
+ display: 'inline-flex',
2284
+ alignItems: 'center',
2285
+ justifyContent: 'center',
2286
+ }}
2287
+ >
2288
+ <CloseIcon size={16} />
2289
+ </button>
2290
+ </div>
2291
+ {children}
2292
+ </div>
2293
+ </div>
2294
+ );
2295
+ }
2296
+
2297
+ window.HomePage = HomePage;