@zhang_libo/resource-hub 1.0.2 → 1.0.10

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 (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +13 -3
  3. package/README.ja.md +12 -2
  4. package/README.md +12 -2
  5. package/README.zh-TW.md +12 -2
  6. package/dist/app.js +4 -1
  7. package/dist/app.js.map +1 -1
  8. package/dist/db/migrate.js +136 -111
  9. package/dist/db/migrate.js.map +1 -1
  10. package/package.json +70 -73
  11. package/public/app.jsx +174 -174
  12. package/public/features/ChangePasswordModal.jsx +198 -187
  13. package/public/features/ResourceFormModal.jsx +915 -915
  14. package/public/layout/AppLayout.jsx +119 -119
  15. package/public/layout/Sidebar.jsx +1 -1
  16. package/public/pages/LoginPage.jsx +10 -0
  17. package/public/pages/SetupPage.jsx +1 -1
  18. package/public/utils/security.jsx +65 -28
  19. package/public/vendor/forge.min.js +2 -0
  20. package/dist/app.d.ts +0 -2
  21. package/dist/app.d.ts.map +0 -1
  22. package/dist/db/migrate.d.ts +0 -3
  23. package/dist/db/migrate.d.ts.map +0 -1
  24. package/dist/db/schema.d.ts +0 -743
  25. package/dist/db/schema.d.ts.map +0 -1
  26. package/dist/plugins/admin.d.ts +0 -4
  27. package/dist/plugins/admin.d.ts.map +0 -1
  28. package/dist/plugins/auth.d.ts +0 -4
  29. package/dist/plugins/auth.d.ts.map +0 -1
  30. package/dist/routes/auth.d.ts +0 -4
  31. package/dist/routes/auth.d.ts.map +0 -1
  32. package/dist/routes/categories.d.ts +0 -4
  33. package/dist/routes/categories.d.ts.map +0 -1
  34. package/dist/routes/config.d.ts +0 -4
  35. package/dist/routes/config.d.ts.map +0 -1
  36. package/dist/routes/resources.d.ts +0 -4
  37. package/dist/routes/resources.d.ts.map +0 -1
  38. package/dist/routes/tags.d.ts +0 -4
  39. package/dist/routes/tags.d.ts.map +0 -1
  40. package/dist/routes/users.d.ts +0 -4
  41. package/dist/routes/users.d.ts.map +0 -1
  42. package/dist/services/crypto.js +0 -49
  43. package/dist/services/crypto.js.map +0 -1
  44. package/dist/services/mail.d.ts +0 -16
  45. package/dist/services/mail.d.ts.map +0 -1
  46. package/dist/services/token.d.ts +0 -9
  47. package/dist/services/token.d.ts.map +0 -1
  48. package/dist/types.d.ts +0 -80
  49. package/dist/types.d.ts.map +0 -1
@@ -1,915 +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;
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;