@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,915 @@
1
+ function ResourceFormModal({ isOpen, onClose, resource, onOpenBatch }) {
2
+ const state = window.useAppState();
3
+ const dispatch = window.useAppDispatch();
4
+ const { request } = window.useApi();
5
+ const isEdit = !!resource;
6
+
7
+ const getInitial = () => ({
8
+ name: resource?.name || '',
9
+ url: resource?.url || '',
10
+ categoryId: resource?.categoryId || '',
11
+ visibility: resource?.visibility || 'public',
12
+ logoUrl: resource?.logoUrl || '',
13
+ description: resource?.description || '',
14
+ tags: resource?.tags ? [...resource.tags] : [],
15
+ enabled: resource?.enabled !== false,
16
+ });
17
+
18
+ const categories = state?.categories || [];
19
+ const normalizeLabel = (value) => (value || '').trim().toLowerCase();
20
+
21
+ const [form, setForm] = React.useState(getInitial);
22
+ const [errors, setErrors] = React.useState({});
23
+ const [loading, setLoading] = React.useState(false);
24
+ const [tagInput, setTagInput] = React.useState('');
25
+ const [categoryInput, setCategoryInput] = React.useState('');
26
+ const [addingCatLoading, setAddingCatLoading] = React.useState(false);
27
+ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
28
+ const [deleting, setDeleting] = React.useState(false);
29
+ const [showTagSuggestions, setShowTagSuggestions] = React.useState(false);
30
+ const [showCategorySuggestions, setShowCategorySuggestions] = React.useState(false);
31
+ const [hoveredCategoryId, setHoveredCategoryId] = React.useState(null);
32
+ const tagPanelRef = React.useRef(null);
33
+ const tagInputRef = React.useRef(null);
34
+ const categoryPanelRef = React.useRef(null);
35
+ const categoryInputRef = React.useRef(null);
36
+
37
+ const selectedCategory = React.useMemo(
38
+ () => categories.find((category) => category.id === form.categoryId) || null,
39
+ [categories, form.categoryId]
40
+ );
41
+
42
+ React.useEffect(() => {
43
+ if (!isOpen) return;
44
+ const initial = getInitial();
45
+ const initialCategoryName = resource?.category?.name
46
+ || categories.find((category) => category.id === initial.categoryId)?.name
47
+ || '';
48
+ setForm(initial);
49
+ setErrors({});
50
+ setTagInput('');
51
+ setCategoryInput(initialCategoryName);
52
+ setShowTagSuggestions(false);
53
+ setShowCategorySuggestions(false);
54
+ setHoveredCategoryId(null);
55
+ }, [isOpen, resource?.id]);
56
+
57
+ const allTags = React.useMemo(() => {
58
+ const explicitTags = state?.tags || [];
59
+ const derivedTags = (state?.resources || []).flatMap((item) => item.tags || []);
60
+ const source = explicitTags.length > 0 ? explicitTags : derivedTags;
61
+ return [...new Set(source.map((tag) => (typeof tag === 'string' ? tag : tag.name)).filter(Boolean))];
62
+ }, [state?.resources, state?.tags]);
63
+
64
+ const selectedTagSet = React.useMemo(
65
+ () => new Set(form.tags.map((tag) => normalizeLabel(tag))),
66
+ [form.tags]
67
+ );
68
+
69
+ const filteredCategorySuggestions = React.useMemo(() => {
70
+ const keyword = categoryInput.trim().toLowerCase();
71
+ return categories
72
+ .filter((category) => !keyword || category.name.toLowerCase().includes(keyword))
73
+ .slice(0, 8);
74
+ }, [categories, categoryInput]);
75
+
76
+ const filteredTagSuggestions = React.useMemo(() => {
77
+ const keyword = tagInput.trim().toLowerCase();
78
+ return allTags
79
+ .filter((tag) => !selectedTagSet.has(normalizeLabel(tag)))
80
+ .filter((tag) => !keyword || tag.toLowerCase().includes(keyword))
81
+ .slice(0, 8);
82
+ }, [allTags, selectedTagSet, tagInput]);
83
+
84
+ const categorySuggestionsVisible = showCategorySuggestions && filteredCategorySuggestions.length > 0;
85
+ const tagSuggestionsVisible = showTagSuggestions && filteredTagSuggestions.length > 0;
86
+ const categorySuggestionHeading = categoryInput.trim() ? '匹配类别' : '已有类别';
87
+ const tagSuggestionHeading = tagInput.trim() ? '匹配标签' : '已有标签';
88
+
89
+ const validate = () => {
90
+ const errs = {};
91
+ if (!form.name || form.name.trim().length === 0 || form.name.length > 50) {
92
+ errs.name = '资源名称不能为空(最多50字符)';
93
+ }
94
+ if (!form.url || !/^https?:\/\//.test(form.url)) {
95
+ errs.url = 'URL需以 http:// 或 https:// 开头';
96
+ }
97
+ if (form.logoUrl && !/^https?:\/\//.test(form.logoUrl)) {
98
+ errs.logoUrl = 'Logo URL格式不正确';
99
+ }
100
+ if (form.description && form.description.length > 200) {
101
+ errs.description = '描述最多200字符';
102
+ }
103
+ if (form.tags.length > 10) {
104
+ errs.tags = '最多添加10个标签';
105
+ }
106
+ return errs;
107
+ };
108
+
109
+ const handleCategorySelect = React.useCallback((category) => {
110
+ setForm((prev) => ({ ...prev, categoryId: category.id }));
111
+ setCategoryInput(category.name);
112
+ setShowCategorySuggestions(false);
113
+ setHoveredCategoryId(null);
114
+ }, []);
115
+
116
+ const handleCreateCategory = React.useCallback(async (rawValue) => {
117
+ const trimmedValue = (rawValue || '').trim();
118
+ const normalizedCategoryName = normalizeLabel(trimmedValue);
119
+ if (!normalizedCategoryName) return false;
120
+
121
+ const existingCategory = categories.find(
122
+ (category) => normalizeLabel(category.name) === normalizedCategoryName
123
+ );
124
+ if (existingCategory) {
125
+ handleCategorySelect(existingCategory);
126
+ return true;
127
+ }
128
+
129
+ setAddingCatLoading(true);
130
+ try {
131
+ const { ok, data } = await request('/api/categories', {
132
+ method: 'POST',
133
+ body: JSON.stringify({ name: trimmedValue }),
134
+ });
135
+ if (ok) {
136
+ const category = data.data;
137
+ dispatch({ type: 'ADD_CATEGORY', category });
138
+ handleCategorySelect(category);
139
+ return true;
140
+ }
141
+ if (data.code === 'CATEGORY_NAME_TAKEN') {
142
+ const fallbackCategory = categories.find(
143
+ (category) => normalizeLabel(category.name) === normalizedCategoryName
144
+ );
145
+ if (fallbackCategory) {
146
+ handleCategorySelect(fallbackCategory);
147
+ return true;
148
+ }
149
+ }
150
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '创建类别失败' });
151
+ return false;
152
+ } finally {
153
+ setAddingCatLoading(false);
154
+ }
155
+ }, [categories, dispatch, handleCategorySelect, request]);
156
+
157
+ const commitCategoryInput = React.useCallback(() => {
158
+ const trimmedValue = categoryInput.trim();
159
+ if (!trimmedValue) {
160
+ setForm((prev) => ({ ...prev, categoryId: '' }));
161
+ setCategoryInput('');
162
+ return;
163
+ }
164
+
165
+ const exactCategory = categories.find(
166
+ (category) => normalizeLabel(category.name) === normalizeLabel(trimmedValue)
167
+ );
168
+ if (exactCategory) {
169
+ setForm((prev) => ({ ...prev, categoryId: exactCategory.id }));
170
+ setCategoryInput(exactCategory.name);
171
+ return;
172
+ }
173
+
174
+ setCategoryInput(selectedCategory?.name || '');
175
+ if (!selectedCategory) {
176
+ setForm((prev) => ({ ...prev, categoryId: '' }));
177
+ }
178
+ }, [categories, categoryInput, selectedCategory]);
179
+
180
+ React.useEffect(() => {
181
+ if (!showCategorySuggestions) return undefined;
182
+ const handleMouseDown = (event) => {
183
+ if (categoryPanelRef.current?.contains(event.target)) return;
184
+ commitCategoryInput();
185
+ setShowCategorySuggestions(false);
186
+ };
187
+ document.addEventListener('mousedown', handleMouseDown);
188
+ return () => document.removeEventListener('mousedown', handleMouseDown);
189
+ }, [commitCategoryInput, showCategorySuggestions]);
190
+
191
+ React.useEffect(() => {
192
+ if (!showTagSuggestions) return undefined;
193
+ const handleMouseDown = (event) => {
194
+ if (tagPanelRef.current?.contains(event.target)) return;
195
+ setShowTagSuggestions(false);
196
+ };
197
+ document.addEventListener('mousedown', handleMouseDown);
198
+ return () => document.removeEventListener('mousedown', handleMouseDown);
199
+ }, [showTagSuggestions]);
200
+
201
+ const resolveTagValue = (rawTag) => {
202
+ const trimmedTag = rawTag.trim();
203
+ if (!trimmedTag) return '';
204
+ const matchedTag = allTags.find((tag) => normalizeLabel(tag) === normalizeLabel(trimmedTag));
205
+ return matchedTag || trimmedTag;
206
+ };
207
+
208
+ const handleAddTag = (rawValue = tagInput) => {
209
+ const resolvedTag = resolveTagValue(rawValue);
210
+ if (!resolvedTag || resolvedTag.length > 20 || selectedTagSet.has(normalizeLabel(resolvedTag)) || form.tags.length >= 10) {
211
+ return false;
212
+ }
213
+ setForm((prev) => ({ ...prev, tags: [...prev.tags, resolvedTag] }));
214
+ setTagInput('');
215
+ window.requestAnimationFrame(() => {
216
+ tagInputRef.current?.focus();
217
+ setShowTagSuggestions(true);
218
+ });
219
+ return true;
220
+ };
221
+
222
+ const handleTagKeyDown = (event) => {
223
+ if (event.key === 'Enter') {
224
+ event.preventDefault();
225
+ handleAddTag();
226
+ return;
227
+ }
228
+ if (event.key === 'Escape') {
229
+ event.preventDefault();
230
+ setShowTagSuggestions(false);
231
+ return;
232
+ }
233
+ if (event.key === 'Backspace' && !tagInput.trim() && form.tags.length > 0) {
234
+ event.preventDefault();
235
+ setForm((prev) => ({ ...prev, tags: prev.tags.slice(0, -1) }));
236
+ setShowTagSuggestions(true);
237
+ }
238
+ };
239
+
240
+ const handleCategoryKeyDown = async (event) => {
241
+ if (event.key === 'Enter') {
242
+ event.preventDefault();
243
+ const trimmedValue = categoryInput.trim();
244
+ if (!trimmedValue) {
245
+ setForm((prev) => ({ ...prev, categoryId: '' }));
246
+ setCategoryInput('');
247
+ setShowCategorySuggestions(false);
248
+ return;
249
+ }
250
+ const exactCategory = categories.find(
251
+ (category) => normalizeLabel(category.name) === normalizeLabel(trimmedValue)
252
+ );
253
+ if (exactCategory) {
254
+ handleCategorySelect(exactCategory);
255
+ return;
256
+ }
257
+ await handleCreateCategory(trimmedValue);
258
+ return;
259
+ }
260
+ if (event.key === 'Escape') {
261
+ event.preventDefault();
262
+ setCategoryInput(selectedCategory?.name || '');
263
+ setShowCategorySuggestions(false);
264
+ categoryInputRef.current?.blur();
265
+ return;
266
+ }
267
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
268
+ setShowCategorySuggestions(true);
269
+ }
270
+ };
271
+
272
+ const handleDelete = async () => {
273
+ setDeleting(true);
274
+ try {
275
+ const { ok, data } = await request(`/api/resources/${resource.id}`, { method: 'DELETE' });
276
+ if (ok) {
277
+ dispatch({ type: 'DELETE_RESOURCE', id: resource.id });
278
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '资源已删除' });
279
+ setShowDeleteConfirm(false);
280
+ onClose();
281
+ } else {
282
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '删除失败' });
283
+ }
284
+ } finally {
285
+ setDeleting(false);
286
+ }
287
+ };
288
+
289
+ const handleSubmit = async () => {
290
+ const errs = validate();
291
+ if (Object.keys(errs).length > 0) {
292
+ setErrors(errs);
293
+ return;
294
+ }
295
+ setLoading(true);
296
+ try {
297
+ const body = {
298
+ name: form.name.trim(),
299
+ url: form.url.trim(),
300
+ categoryId: form.categoryId || null,
301
+ visibility: form.visibility,
302
+ logoUrl: form.logoUrl.trim(),
303
+ description: form.description,
304
+ tags: form.tags,
305
+ enabled: form.enabled,
306
+ };
307
+ let ok;
308
+ let data;
309
+ if (isEdit) {
310
+ ({ ok, data } = await request(`/api/resources/${resource.id}`, {
311
+ method: 'PUT',
312
+ body: JSON.stringify(body),
313
+ }));
314
+ } else {
315
+ ({ ok, data } = await request('/api/resources', {
316
+ method: 'POST',
317
+ body: JSON.stringify(body),
318
+ }));
319
+ }
320
+ if (ok) {
321
+ const saved = data.data;
322
+ const category = categories.find((item) => item.id === saved.categoryId) || null;
323
+ const enriched = { ...saved, category, tags: form.tags };
324
+ if (isEdit) {
325
+ dispatch({ type: 'UPDATE_RESOURCE', resource: enriched });
326
+ } else {
327
+ dispatch({ type: 'ADD_RESOURCE', resource: enriched });
328
+ dispatch({ type: 'SET_MINE', mine: [enriched, ...(state?.mine || [])] });
329
+ }
330
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '保存成功' });
331
+ onClose();
332
+ } else {
333
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '保存失败' });
334
+ }
335
+ } finally {
336
+ setLoading(false);
337
+ }
338
+ };
339
+
340
+ const inputStyle = (field) => ({
341
+ width: '100%',
342
+ padding: '8px 12px',
343
+ border: `1px solid ${errors[field] ? 'var(--danger)' : 'var(--border)'}`,
344
+ borderRadius: '8px',
345
+ background: 'var(--bg-secondary)',
346
+ color: 'var(--text-primary)',
347
+ fontSize: '14px',
348
+ outline: 'none',
349
+ boxSizing: 'border-box',
350
+ });
351
+ const labelStyle = {
352
+ fontSize: '13px',
353
+ fontWeight: 500,
354
+ color: 'var(--text-primary)',
355
+ display: 'block',
356
+ marginBottom: '6px',
357
+ };
358
+ const fieldStyle = { marginBottom: '14px' };
359
+ const settingsBlockStyle = {
360
+ ...fieldStyle,
361
+ padding: '0',
362
+ display: 'grid',
363
+ gap: '10px',
364
+ };
365
+ const suggestionPanelStyle = {
366
+ position: 'absolute',
367
+ top: 'calc(100% + 8px)',
368
+ left: 0,
369
+ right: 0,
370
+ padding: '10px',
371
+ borderRadius: '14px',
372
+ border: '1px solid color-mix(in srgb, var(--outline-strong) 84%, var(--border))',
373
+ background: 'color-mix(in srgb, var(--surface-elevated) 94%, var(--surface-muted))',
374
+ boxShadow: 'var(--shadow-dropdown)',
375
+ zIndex: 48,
376
+ display: 'grid',
377
+ gap: '8px',
378
+ };
379
+ const tagInputShellStyle = {
380
+ minHeight: '40px',
381
+ padding: '6px 8px',
382
+ borderRadius: '8px',
383
+ border: showTagSuggestions ? '1px solid var(--brand)' : '1px solid var(--border)',
384
+ background: 'var(--bg-secondary)',
385
+ boxShadow: showTagSuggestions
386
+ ? '0 0 0 3px color-mix(in srgb, var(--brand) 16%, transparent)'
387
+ : 'none',
388
+ display: 'flex',
389
+ flexWrap: 'wrap',
390
+ gap: '6px',
391
+ alignItems: 'center',
392
+ cursor: 'text',
393
+ boxSizing: 'border-box',
394
+ };
395
+ const tagChipStyle = {
396
+ display: 'inline-flex',
397
+ alignItems: 'center',
398
+ gap: '6px',
399
+ background: 'color-mix(in srgb, var(--brand-soft) 72%, var(--bg-secondary))',
400
+ color: 'var(--brand-strong)',
401
+ border: '1px solid color-mix(in srgb, var(--brand) 24%, var(--border))',
402
+ borderRadius: '999px',
403
+ padding: '4px 10px',
404
+ fontSize: '13px',
405
+ lineHeight: 1,
406
+ };
407
+
408
+ return (
409
+ <>
410
+ <window.Modal
411
+ isOpen={isOpen}
412
+ onClose={onClose}
413
+ title={isEdit ? '编辑资源' : '新增资源'}
414
+ width="520px"
415
+ closeOnBackdrop={false}
416
+ closeOnEscape
417
+ >
418
+ <div>
419
+ <div style={fieldStyle}>
420
+ <label style={labelStyle}>
421
+ 资源名称 <span style={{ color: 'var(--danger)' }}>*</span>
422
+ </label>
423
+ <input
424
+ data-rh-resource-name-input
425
+ value={form.name}
426
+ onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
427
+ style={inputStyle('name')}
428
+ disabled={loading}
429
+ maxLength={50}
430
+ placeholder="请输入资源名称"
431
+ />
432
+ {errors.name && (
433
+ <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.name}</div>
434
+ )}
435
+ </div>
436
+
437
+ <div style={fieldStyle}>
438
+ <label style={labelStyle}>
439
+ URL <span style={{ color: 'var(--danger)' }}>*</span>
440
+ </label>
441
+ <input
442
+ data-rh-resource-url-input
443
+ value={form.url}
444
+ onChange={(event) => setForm((prev) => ({ ...prev, url: event.target.value }))}
445
+ style={inputStyle('url')}
446
+ disabled={loading}
447
+ placeholder="https://example.com"
448
+ />
449
+ {errors.url && (
450
+ <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.url}</div>
451
+ )}
452
+ </div>
453
+
454
+ <div style={settingsBlockStyle}>
455
+ <label style={{ ...labelStyle, marginBottom: 0 }}>访问权限</label>
456
+ <div style={{ display: 'flex', gap: '16px', minHeight: '24px', alignItems: 'center', flexWrap: 'wrap' }}>
457
+ {[{ value: 'public', label: '公开' }, { value: 'private', label: '私有' }].map((option) => (
458
+ <label
459
+ key={option.value}
460
+ style={{
461
+ display: 'flex',
462
+ alignItems: 'center',
463
+ gap: '6px',
464
+ cursor: 'pointer',
465
+ fontSize: '14px',
466
+ color: 'var(--text-primary)',
467
+ }}
468
+ >
469
+ <input
470
+ type="radio"
471
+ name="visibility"
472
+ value={option.value}
473
+ checked={form.visibility === option.value}
474
+ onChange={() => setForm((prev) => ({ ...prev, visibility: option.value }))}
475
+ disabled={loading}
476
+ />
477
+ {option.label}
478
+ </label>
479
+ ))}
480
+ </div>
481
+ </div>
482
+
483
+ <div style={fieldStyle}>
484
+ <label style={labelStyle}>类别</label>
485
+ <div ref={categoryPanelRef} style={{ position: 'relative' }}>
486
+ <input
487
+ ref={categoryInputRef}
488
+ value={categoryInput}
489
+ onChange={(event) => {
490
+ setCategoryInput(event.target.value);
491
+ setShowCategorySuggestions(true);
492
+ }}
493
+ onFocus={() => setShowCategorySuggestions(true)}
494
+ onClick={() => setShowCategorySuggestions(true)}
495
+ onBlur={() => {
496
+ window.requestAnimationFrame(() => {
497
+ if (categoryPanelRef.current?.contains(document.activeElement)) return;
498
+ commitCategoryInput();
499
+ setShowCategorySuggestions(false);
500
+ });
501
+ }}
502
+ onKeyDown={handleCategoryKeyDown}
503
+ disabled={loading || addingCatLoading}
504
+ placeholder="选择类别"
505
+ style={{
506
+ ...inputStyle('category'),
507
+ paddingRight: '38px',
508
+ }}
509
+ />
510
+ {addingCatLoading ? (
511
+ <lucide.Loader
512
+ size={16}
513
+ style={{
514
+ position: 'absolute',
515
+ right: '12px',
516
+ top: '50%',
517
+ transform: 'translateY(-50%)',
518
+ color: 'var(--text-secondary)',
519
+ animation: 'spin 1s linear infinite',
520
+ pointerEvents: 'none',
521
+ }}
522
+ />
523
+ ) : (
524
+ <button
525
+ type="button"
526
+ aria-label={showCategorySuggestions ? '收起类别列表' : '展开类别列表'}
527
+ onMouseDown={(event) => event.preventDefault()}
528
+ onClick={() => {
529
+ if (loading || addingCatLoading) return;
530
+ setShowCategorySuggestions((prev) => !prev);
531
+ if (!showCategorySuggestions) {
532
+ categoryInputRef.current?.focus();
533
+ }
534
+ }}
535
+ style={{
536
+ position: 'absolute',
537
+ right: '8px',
538
+ top: '50%',
539
+ transform: 'translateY(-50%)',
540
+ width: '24px',
541
+ height: '24px',
542
+ border: 'none',
543
+ borderRadius: '8px',
544
+ background: 'transparent',
545
+ display: 'inline-flex',
546
+ alignItems: 'center',
547
+ justifyContent: 'center',
548
+ cursor: 'pointer',
549
+ color: 'var(--text-secondary)',
550
+ }}
551
+ >
552
+ <lucide.ChevronDown
553
+ size={16}
554
+ style={{
555
+ transform: `rotate(${showCategorySuggestions ? 180 : 0}deg)`,
556
+ transition: 'transform 150ms ease',
557
+ }}
558
+ />
559
+ </button>
560
+ )}
561
+ {categorySuggestionsVisible && (
562
+ <div style={suggestionPanelStyle}>
563
+ <div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)' }}>
564
+ {categorySuggestionHeading}
565
+ </div>
566
+ <div style={{ display: 'grid', gap: '4px', maxHeight: '216px', overflowY: 'auto' }}>
567
+ {filteredCategorySuggestions.map((category) => (
568
+ <button
569
+ key={category.id}
570
+ type="button"
571
+ onMouseDown={(event) => event.preventDefault()}
572
+ onMouseEnter={() => setHoveredCategoryId(category.id)}
573
+ onMouseLeave={() => setHoveredCategoryId(null)}
574
+ onClick={() => handleCategorySelect(category)}
575
+ style={{
576
+ minHeight: '38px',
577
+ padding: '8px 12px',
578
+ borderRadius: '12px',
579
+ border: 'none',
580
+ background: category.id === form.categoryId
581
+ ? 'color-mix(in srgb, var(--brand-soft) 84%, var(--control-bg))'
582
+ : hoveredCategoryId === category.id
583
+ ? 'color-mix(in srgb, var(--surface-tint) 68%, var(--control-bg))'
584
+ : 'transparent',
585
+ color: category.id === form.categoryId ? 'var(--brand-strong)' : 'var(--text-primary)',
586
+ cursor: 'pointer',
587
+ fontSize: '14px',
588
+ fontWeight: category.id === form.categoryId ? 700 : hoveredCategoryId === category.id ? 600 : 500,
589
+ textAlign: 'left',
590
+ transition: 'background 120ms ease, color 120ms ease',
591
+ }}
592
+ >
593
+ {category.name}
594
+ </button>
595
+ ))}
596
+ </div>
597
+ </div>
598
+ )}
599
+ </div>
600
+ </div>
601
+
602
+ <div style={fieldStyle}>
603
+ <label style={labelStyle}>Logo URL(可选)</label>
604
+ <input
605
+ value={form.logoUrl}
606
+ onChange={(event) => setForm((prev) => ({ ...prev, logoUrl: event.target.value }))}
607
+ style={inputStyle('logoUrl')}
608
+ disabled={loading}
609
+ placeholder="https://example.com/logo.png"
610
+ />
611
+ {errors.logoUrl && (
612
+ <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.logoUrl}</div>
613
+ )}
614
+ </div>
615
+
616
+ <div style={fieldStyle}>
617
+ <label style={labelStyle}>描述(可选,最多200字符)</label>
618
+ <textarea
619
+ data-rh-resource-description-input
620
+ value={form.description}
621
+ onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
622
+ style={{ ...inputStyle('description'), resize: 'vertical', minHeight: '72px' }}
623
+ disabled={loading}
624
+ maxLength={200}
625
+ placeholder="简短描述这个资源…"
626
+ />
627
+ {errors.description && (
628
+ <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.description}</div>
629
+ )}
630
+ </div>
631
+
632
+ <div style={fieldStyle}>
633
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
634
+ <label style={{ ...labelStyle, marginBottom: 0 }}>标签(最多10个,每个最多20字符)</label>
635
+ {form.tags.length > 0 && (
636
+ <button
637
+ type="button"
638
+ onClick={() => {
639
+ setForm((prev) => ({ ...prev, tags: [] }));
640
+ setTagInput('');
641
+ window.requestAnimationFrame(() => {
642
+ tagInputRef.current?.focus();
643
+ setShowTagSuggestions(true);
644
+ });
645
+ }}
646
+ disabled={loading}
647
+ style={{
648
+ border: 'none',
649
+ background: 'transparent',
650
+ color: 'var(--text-secondary)',
651
+ fontSize: '12px',
652
+ cursor: loading ? 'not-allowed' : 'pointer',
653
+ padding: 0,
654
+ }}
655
+ >
656
+ 清空标签
657
+ </button>
658
+ )}
659
+ </div>
660
+ <div ref={tagPanelRef} style={{ position: 'relative' }}>
661
+ <div
662
+ onClick={() => {
663
+ if (loading || form.tags.length >= 10) return;
664
+ tagInputRef.current?.focus();
665
+ setShowTagSuggestions(true);
666
+ }}
667
+ style={tagInputShellStyle}
668
+ >
669
+ {form.tags.map((tag) => (
670
+ <span key={tag} style={tagChipStyle}>
671
+ {tag}
672
+ <button
673
+ type="button"
674
+ onMouseDown={(event) => event.preventDefault()}
675
+ onClick={(event) => {
676
+ event.stopPropagation();
677
+ setForm((prev) => ({ ...prev, tags: prev.tags.filter((item) => item !== tag) }));
678
+ window.requestAnimationFrame(() => {
679
+ tagInputRef.current?.focus();
680
+ setShowTagSuggestions(true);
681
+ });
682
+ }}
683
+ style={{
684
+ background: 'none',
685
+ border: 'none',
686
+ cursor: 'pointer',
687
+ padding: '0',
688
+ display: 'inline-flex',
689
+ alignItems: 'center',
690
+ color: 'var(--brand-strong)',
691
+ }}
692
+ >
693
+ <lucide.X size={12} />
694
+ </button>
695
+ </span>
696
+ ))}
697
+ <input
698
+ ref={tagInputRef}
699
+ data-rh-tag-input
700
+ value={tagInput}
701
+ onChange={(event) => {
702
+ setTagInput(event.target.value);
703
+ setShowTagSuggestions(true);
704
+ }}
705
+ onFocus={() => setShowTagSuggestions(true)}
706
+ onClick={() => setShowTagSuggestions(true)}
707
+ onBlur={() => {
708
+ window.requestAnimationFrame(() => {
709
+ if (tagPanelRef.current?.contains(document.activeElement)) return;
710
+ setShowTagSuggestions(false);
711
+ });
712
+ }}
713
+ onKeyDown={handleTagKeyDown}
714
+ placeholder={form.tags.length === 0 ? '输入标签后回车添加' : ''}
715
+ disabled={loading || form.tags.length >= 10}
716
+ style={{
717
+ flex: '1 0 140px',
718
+ minWidth: '120px',
719
+ padding: '2px 0',
720
+ border: 0,
721
+ background: 'transparent',
722
+ color: 'var(--text-primary)',
723
+ fontSize: '14px',
724
+ outline: 'none',
725
+ boxShadow: 'none',
726
+ boxSizing: 'border-box',
727
+ appearance: 'none',
728
+ WebkitAppearance: 'none',
729
+ }}
730
+ />
731
+ </div>
732
+ {tagSuggestionsVisible && (
733
+ <div style={suggestionPanelStyle}>
734
+ <div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)' }}>
735
+ {tagSuggestionHeading}
736
+ </div>
737
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', maxHeight: '108px', overflowY: 'auto', paddingRight: '2px', alignContent: 'flex-start' }}>
738
+ {filteredTagSuggestions.map((tag) => (
739
+ <button
740
+ key={tag}
741
+ type="button"
742
+ onMouseDown={(event) => event.preventDefault()}
743
+ onClick={() => {
744
+ handleAddTag(tag);
745
+ }}
746
+ style={{
747
+ fontSize: '12px',
748
+ background: 'color-mix(in srgb, var(--surface-elevated) 84%, var(--surface-muted))',
749
+ color: 'var(--text-secondary)',
750
+ border: '1px solid color-mix(in srgb, var(--outline-strong) 82%, var(--border))',
751
+ borderRadius: '999px',
752
+ padding: '5px 10px',
753
+ cursor: 'pointer',
754
+ }}
755
+ >
756
+ {tag}
757
+ </button>
758
+ ))}
759
+ </div>
760
+ </div>
761
+ )}
762
+ </div>
763
+ {errors.tags && (
764
+ <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.tags}</div>
765
+ )}
766
+ </div>
767
+
768
+ <div style={settingsBlockStyle}>
769
+ <label style={{ ...labelStyle, marginBottom: 0 }}>启用状态</label>
770
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' }}>
771
+ <div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
772
+ {form.enabled ? '资源可正常展示与访问' : '资源将被隐藏并停止对外展示'}
773
+ </div>
774
+ <button
775
+ onClick={() => setForm((prev) => ({ ...prev, enabled: !prev.enabled }))}
776
+ disabled={loading}
777
+ style={{
778
+ width: '44px',
779
+ height: '24px',
780
+ borderRadius: '12px',
781
+ background: form.enabled ? 'var(--brand)' : 'var(--bg-tertiary)',
782
+ border: 'none',
783
+ cursor: 'pointer',
784
+ position: 'relative',
785
+ transition: 'background 200ms',
786
+ flexShrink: 0,
787
+ }}
788
+ >
789
+ <div
790
+ style={{
791
+ width: '18px',
792
+ height: '18px',
793
+ borderRadius: '50%',
794
+ background: '#fff',
795
+ position: 'absolute',
796
+ top: '3px',
797
+ left: form.enabled ? '23px' : '3px',
798
+ transition: 'left 200ms',
799
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
800
+ }}
801
+ />
802
+ </button>
803
+ </div>
804
+ </div>
805
+
806
+ <div
807
+ style={{
808
+ display: 'flex',
809
+ gap: '8px',
810
+ justifyContent: 'space-between',
811
+ marginTop: '8px',
812
+ paddingTop: '16px',
813
+ borderTop: '1px solid var(--border)',
814
+ }}
815
+ >
816
+ {isEdit ? (
817
+ <button
818
+ onClick={() => setShowDeleteConfirm(true)}
819
+ disabled={loading}
820
+ style={{
821
+ padding: '8px 16px',
822
+ background: 'none',
823
+ color: 'var(--danger)',
824
+ border: '1px solid var(--danger)',
825
+ borderRadius: '8px',
826
+ cursor: loading ? 'not-allowed' : 'pointer',
827
+ fontSize: '14px',
828
+ }}
829
+ >
830
+ 删除
831
+ </button>
832
+ ) : (
833
+ <div style={{ display: 'flex', gap: '8px' }}>
834
+ {onOpenBatch && (
835
+ <button
836
+ type="button"
837
+ onClick={() => {
838
+ onClose();
839
+ onOpenBatch();
840
+ }}
841
+ disabled={loading}
842
+ style={{
843
+ padding: '8px 16px',
844
+ border: '1px solid var(--border)',
845
+ background: 'var(--bg-secondary)',
846
+ color: 'var(--text-secondary)',
847
+ borderRadius: '8px',
848
+ cursor: loading ? 'not-allowed' : 'pointer',
849
+ fontSize: '14px',
850
+ display: 'inline-flex',
851
+ alignItems: 'center',
852
+ gap: '6px',
853
+ }}
854
+ >
855
+ <lucide.ListPlus size={14} /> 批量录入
856
+ </button>
857
+ )}
858
+ </div>
859
+ )}
860
+ <div style={{ display: 'flex', gap: '8px' }}>
861
+ <button
862
+ onClick={onClose}
863
+ disabled={loading}
864
+ style={{
865
+ padding: '8px 20px',
866
+ border: '1px solid var(--border)',
867
+ background: 'var(--bg-secondary)',
868
+ color: 'var(--text-primary)',
869
+ borderRadius: '8px',
870
+ cursor: 'pointer',
871
+ fontSize: '14px',
872
+ }}
873
+ >
874
+ 取消
875
+ </button>
876
+ <button
877
+ onClick={handleSubmit}
878
+ disabled={loading || addingCatLoading}
879
+ style={{
880
+ padding: '8px 20px',
881
+ background: 'var(--brand)',
882
+ color: '#fff',
883
+ border: 'none',
884
+ borderRadius: '8px',
885
+ cursor: loading ? 'not-allowed' : 'pointer',
886
+ fontSize: '14px',
887
+ fontWeight: 500,
888
+ opacity: loading || addingCatLoading ? 0.8 : 1,
889
+ display: 'flex',
890
+ alignItems: 'center',
891
+ gap: '6px',
892
+ }}
893
+ >
894
+ {(loading || addingCatLoading) && <lucide.Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
895
+ 保存
896
+ </button>
897
+ </div>
898
+ </div>
899
+ </div>
900
+ </window.Modal>
901
+
902
+ <window.ConfirmDialog
903
+ isOpen={showDeleteConfirm}
904
+ onClose={() => setShowDeleteConfirm(false)}
905
+ onConfirm={handleDelete}
906
+ title="删除资源"
907
+ message={`确认删除资源「${resource?.name}」?该操作不可撤销。`}
908
+ confirmText="确认删除"
909
+ loading={deleting}
910
+ />
911
+ </>
912
+ );
913
+ }
914
+
915
+ window.ResourceFormModal = ResourceFormModal;