@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,606 @@
1
+ const normalizeLabel = (value) => (value || '').trim().toLowerCase();
2
+
3
+ function useFloatingPosition(anchorRef, isVisible) {
4
+ const [pos, setPos] = React.useState({ top: 0, left: 0, width: 0 });
5
+ React.useEffect(() => {
6
+ if (!isVisible || !anchorRef.current) return;
7
+ const update = () => {
8
+ const rect = anchorRef.current.getBoundingClientRect();
9
+ setPos({ top: rect.bottom + 2, left: rect.left, width: rect.width });
10
+ };
11
+ update();
12
+ window.addEventListener('scroll', update, true);
13
+ window.addEventListener('resize', update);
14
+ return () => {
15
+ window.removeEventListener('scroll', update, true);
16
+ window.removeEventListener('resize', update);
17
+ };
18
+ }, [isVisible, anchorRef]);
19
+ return pos;
20
+ }
21
+
22
+ function FloatingPanel({ anchorRef, isVisible, children }) {
23
+ const pos = useFloatingPosition(anchorRef, isVisible);
24
+ if (!isVisible) return null;
25
+ return ReactDOM.createPortal(
26
+ <div
27
+ style={{
28
+ position: 'fixed',
29
+ top: pos.top,
30
+ left: pos.left,
31
+ width: pos.width,
32
+ zIndex: 9999,
33
+ padding: '8px',
34
+ borderRadius: '10px',
35
+ border: '1px solid color-mix(in srgb, var(--outline-strong) 84%, var(--border))',
36
+ background: 'color-mix(in srgb, var(--surface-elevated) 94%, var(--surface-muted))',
37
+ boxShadow: 'var(--shadow-dropdown)',
38
+ maxHeight: '220px',
39
+ overflowY: 'auto',
40
+ }}
41
+ >
42
+ {children}
43
+ </div>,
44
+ document.body
45
+ );
46
+ }
47
+
48
+ function BatchResourceModal({ isOpen, onClose }) {
49
+ const state = window.useAppState();
50
+ const dispatch = window.useAppDispatch();
51
+ const { request } = window.useApi();
52
+ const categories = state?.categories || [];
53
+
54
+ const allTags = React.useMemo(() => {
55
+ const explicitTags = state?.tags || [];
56
+ const derivedTags = (state?.resources || []).flatMap((item) => item.tags || []);
57
+ const source = explicitTags.length > 0 ? explicitTags : derivedTags;
58
+ return [...new Set(source.map((tag) => (typeof tag === 'string' ? tag : tag.name)).filter(Boolean))];
59
+ }, [state?.resources, state?.tags]);
60
+
61
+ const emptyRow = () => ({
62
+ name: '',
63
+ url: '',
64
+ categoryValue: '',
65
+ visibility: 'public',
66
+ enabled: true,
67
+ description: '',
68
+ tags: [],
69
+ });
70
+
71
+ const [rows, setRows] = React.useState(() => [emptyRow(), emptyRow(), emptyRow()]);
72
+ const [loading, setLoading] = React.useState(false);
73
+ const [rowErrors, setRowErrors] = React.useState({});
74
+
75
+ const [activeTagRow, setActiveTagRow] = React.useState(null);
76
+ const [tagInputValue, setTagInputValue] = React.useState('');
77
+ const tagAnchorRefs = React.useRef({});
78
+ const tagPanelRef = React.useRef(null);
79
+
80
+ const [activeCategoryRow, setActiveCategoryRow] = React.useState(null);
81
+ const categoryAnchorRefs = React.useRef({});
82
+ const categoryPanelRef = React.useRef(null);
83
+
84
+ React.useEffect(() => {
85
+ if (!isOpen) return;
86
+ setRows([emptyRow(), emptyRow(), emptyRow()]);
87
+ setRowErrors({});
88
+ setActiveTagRow(null);
89
+ setTagInputValue('');
90
+ setActiveCategoryRow(null);
91
+ tagAnchorRefs.current = {};
92
+ categoryAnchorRefs.current = {};
93
+ }, [isOpen]);
94
+
95
+ React.useEffect(() => {
96
+ if (activeTagRow === null) return;
97
+ const handleMouseDown = (e) => {
98
+ if (tagPanelRef.current?.contains(e.target)) return;
99
+ const anchor = tagAnchorRefs.current[activeTagRow];
100
+ if (anchor?.contains(e.target)) return;
101
+ setActiveTagRow(null);
102
+ setTagInputValue('');
103
+ };
104
+ document.addEventListener('mousedown', handleMouseDown);
105
+ return () => document.removeEventListener('mousedown', handleMouseDown);
106
+ }, [activeTagRow]);
107
+
108
+ React.useEffect(() => {
109
+ if (activeCategoryRow === null) return;
110
+ const handleMouseDown = (e) => {
111
+ if (categoryPanelRef.current?.contains(e.target)) return;
112
+ const anchor = categoryAnchorRefs.current[activeCategoryRow];
113
+ if (anchor?.contains(e.target)) return;
114
+ setActiveCategoryRow(null);
115
+ };
116
+ document.addEventListener('mousedown', handleMouseDown);
117
+ return () => document.removeEventListener('mousedown', handleMouseDown);
118
+ }, [activeCategoryRow]);
119
+
120
+ const updateRow = (index, field, value) => {
121
+ setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
122
+ setRowErrors((prev) => {
123
+ const next = { ...prev };
124
+ if (next[index]) {
125
+ delete next[index][field];
126
+ if (Object.keys(next[index]).length === 0) delete next[index];
127
+ }
128
+ return next;
129
+ });
130
+ };
131
+
132
+ const addTagToRow = (index, tag) => {
133
+ const trimmed = (typeof tag === 'string' ? tag : '').trim();
134
+ if (!trimmed || trimmed.length > 20) return;
135
+ setRows((prev) => {
136
+ const row = prev[index];
137
+ const normalized = normalizeLabel(trimmed);
138
+ if (row.tags.length >= 10 || row.tags.some((t) => normalizeLabel(t) === normalized)) return prev;
139
+ return prev.map((r, i) => (i === index ? { ...r, tags: [...r.tags, trimmed] } : r));
140
+ });
141
+ if (activeTagRow === index) setTagInputValue('');
142
+ };
143
+
144
+ const removeTagFromRow = (index, tag) => {
145
+ setRows((prev) => prev.map((r, i) => (i === index ? { ...r, tags: r.tags.filter((t) => t !== tag) } : r)));
146
+ };
147
+
148
+ const addRow = () => setRows((prev) => [...prev, emptyRow()]);
149
+ const removeRow = (index) => {
150
+ setRows((prev) => prev.filter((_, i) => i !== index));
151
+ if (activeTagRow === index) { setActiveTagRow(null); setTagInputValue(''); }
152
+ else if (activeTagRow != null && activeTagRow > index) setActiveTagRow(activeTagRow - 1);
153
+ if (activeCategoryRow === index) setActiveCategoryRow(null);
154
+ else if (activeCategoryRow != null && activeCategoryRow > index) setActiveCategoryRow(activeCategoryRow - 1);
155
+ setRowErrors((prev) => {
156
+ const next = {};
157
+ Object.keys(prev).forEach((i) => {
158
+ const idx = Number(i);
159
+ if (idx < index) next[idx] = prev[i];
160
+ else if (idx > index) next[idx - 1] = prev[i];
161
+ });
162
+ return next;
163
+ });
164
+ };
165
+
166
+ const validate = () => {
167
+ const errs = {};
168
+ rows.forEach((row, i) => {
169
+ const name = (row.name || '').trim();
170
+ const url = (row.url || '').trim();
171
+ if (!name || name.length > 50) errs[i] = { ...(errs[i] || {}), name: '名称必填,最多50字符' };
172
+ if (!url || !/^https?:\/\//.test(url)) errs[i] = { ...(errs[i] || {}), url: 'URL需以 http:// 或 https:// 开头' };
173
+ if (row.description && row.description.length > 200) errs[i] = { ...(errs[i] || {}), description: '描述最多200字符' };
174
+ if (Array.isArray(row.tags) && row.tags.length > 10) errs[i] = { ...(errs[i] || {}), tags: '最多10个标签' };
175
+ });
176
+ setRowErrors(errs);
177
+ return Object.keys(errs).length === 0;
178
+ };
179
+
180
+ const handleSubmit = async () => {
181
+ if (!validate()) return;
182
+ const categoryNameToId = new Map();
183
+ (categories || []).forEach((cat) => categoryNameToId.set(normalizeLabel(cat.name), cat.id));
184
+
185
+ const pendingRows = rows.map((r) => ({
186
+ name: (r.name || '').trim(),
187
+ url: (r.url || '').trim(),
188
+ categoryName: (r.categoryValue || '').trim(),
189
+ visibility: r.visibility === 'private' ? 'private' : 'public',
190
+ enabled: r.enabled !== false,
191
+ description: (r.description || '').trim().slice(0, 200),
192
+ tags: Array.isArray(r.tags) ? r.tags.slice(0, 10) : [],
193
+ }));
194
+
195
+ const newCategoryNames = [];
196
+ pendingRows.forEach((row) => {
197
+ if (!row.categoryName) return;
198
+ const key = normalizeLabel(row.categoryName);
199
+ if (!key || categoryNameToId.has(key)) return;
200
+ categoryNameToId.set(key, null);
201
+ newCategoryNames.push({ key, name: row.categoryName });
202
+ });
203
+
204
+ if (newCategoryNames.length > 0) {
205
+ for (const item of newCategoryNames) {
206
+ try {
207
+ const { ok, data } = await request('/api/categories', {
208
+ method: 'POST',
209
+ body: JSON.stringify({ name: item.name }),
210
+ });
211
+ if (ok && data?.data) {
212
+ categoryNameToId.set(item.key, data.data.id);
213
+ dispatch({ type: 'ADD_CATEGORY', category: data.data });
214
+ } else if (data?.code === 'CATEGORY_NAME_TAKEN') {
215
+ const fallback = (state?.categories || []).find((c) => normalizeLabel(c.name) === item.key);
216
+ if (fallback) categoryNameToId.set(item.key, fallback.id);
217
+ }
218
+ } catch (_) { /* skip */ }
219
+ }
220
+ }
221
+
222
+ const toCreate = pendingRows
223
+ .map((row) => ({
224
+ name: row.name,
225
+ url: row.url,
226
+ categoryId: row.categoryName ? categoryNameToId.get(normalizeLabel(row.categoryName)) || null : null,
227
+ visibility: row.visibility,
228
+ enabled: row.enabled,
229
+ description: row.description,
230
+ tags: row.tags,
231
+ }))
232
+ .filter((r) => r.name && r.url);
233
+ if (toCreate.length === 0) return;
234
+ setLoading(true);
235
+ let successCount = 0;
236
+ const created = [];
237
+ for (const body of toCreate) {
238
+ const { ok, data } = await request('/api/resources', {
239
+ method: 'POST',
240
+ body: JSON.stringify({
241
+ name: body.name, url: body.url, categoryId: body.categoryId,
242
+ visibility: body.visibility, logoUrl: '', description: body.description,
243
+ tags: body.tags, enabled: body.enabled,
244
+ }),
245
+ });
246
+ if (ok) {
247
+ successCount += 1;
248
+ const saved = data.data;
249
+ const category = categories.find((c) => c.id === saved.categoryId) || null;
250
+ created.push({ ...saved, category, tags: saved.tags || body.tags });
251
+ }
252
+ }
253
+ setLoading(false);
254
+ created.forEach((resource) => dispatch({ type: 'ADD_RESOURCE', resource }));
255
+ if (created.length > 0) {
256
+ dispatch({ type: 'SET_MINE', mine: [...created, ...(state?.mine || [])] });
257
+ }
258
+ if (successCount === toCreate.length) {
259
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: `成功添加 ${successCount} 条资源` });
260
+ } else if (successCount > 0) {
261
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: `成功 ${successCount} 条,失败 ${toCreate.length - successCount} 条` });
262
+ } else {
263
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: '批量添加失败' });
264
+ }
265
+ onClose();
266
+ };
267
+
268
+ const filteredCategorySuggestions = React.useMemo(() => {
269
+ const keyword = activeCategoryRow == null ? '' : (rows[activeCategoryRow]?.categoryValue || '').trim().toLowerCase();
270
+ return categories.filter((c) => !keyword || c.name.toLowerCase().includes(keyword)).slice(0, 8);
271
+ }, [activeCategoryRow, categories, rows]);
272
+
273
+ const inputCellStyle = (hasError) => ({
274
+ width: '100%', padding: '6px 8px',
275
+ border: `1px solid ${hasError ? 'var(--danger)' : 'var(--border)'}`,
276
+ borderRadius: '6px', background: 'var(--bg-secondary)',
277
+ color: 'var(--text-primary)', fontSize: '13px',
278
+ outline: 'none', boxSizing: 'border-box',
279
+ });
280
+ const thStyle = {
281
+ textAlign: 'left', padding: '10px 8px',
282
+ borderBottom: '1px solid var(--border)',
283
+ color: 'var(--text-secondary)', fontWeight: 600, whiteSpace: 'nowrap',
284
+ };
285
+ const tdStyle = { padding: '6px 8px', borderBottom: '1px solid var(--border)', verticalAlign: 'top' };
286
+ const tagChipStyle = {
287
+ display: 'inline-flex', alignItems: 'center', gap: '4px',
288
+ background: 'color-mix(in srgb, var(--brand-soft) 72%, var(--bg-secondary))',
289
+ color: 'var(--brand-strong)',
290
+ border: '1px solid color-mix(in srgb, var(--brand) 24%, var(--border))',
291
+ borderRadius: '999px', padding: '2px 6px', fontSize: '12px', lineHeight: 1,
292
+ };
293
+ const tagInputShellStyle = (isActive) => ({
294
+ minHeight: '32px', padding: '4px 6px', borderRadius: '6px',
295
+ border: `1px solid ${isActive ? 'var(--brand)' : 'var(--border)'}`,
296
+ background: 'var(--bg-secondary)',
297
+ boxShadow: isActive ? '0 0 0 3px color-mix(in srgb, var(--brand) 16%, transparent)' : 'none',
298
+ display: 'flex', flexWrap: 'wrap', gap: '4px',
299
+ alignItems: 'center', cursor: 'text', boxSizing: 'border-box',
300
+ });
301
+ const suggestionBtnStyle = {
302
+ display: 'block', width: '100%', padding: '6px 10px',
303
+ textAlign: 'left', border: 'none', borderRadius: '8px',
304
+ background: 'transparent', color: 'var(--text-primary)',
305
+ fontSize: '13px', cursor: 'pointer',
306
+ };
307
+
308
+ if (!isOpen) return null;
309
+
310
+ const categoryDropdownVisible = activeCategoryRow != null && filteredCategorySuggestions.length > 0;
311
+ const catAnchorRef = { current: categoryAnchorRefs.current[activeCategoryRow] || null };
312
+
313
+ const tagSuggestions = (() => {
314
+ if (activeTagRow == null) return [];
315
+ const row = rows[activeTagRow];
316
+ if (!row) return [];
317
+ const keyword = tagInputValue.trim().toLowerCase();
318
+ const selectedSet = new Set((row.tags || []).map(normalizeLabel));
319
+ return allTags
320
+ .filter((t) => !selectedSet.has(normalizeLabel(t)))
321
+ .filter((t) => !keyword || (typeof t === 'string' ? t : '').toLowerCase().includes(keyword))
322
+ .slice(0, 8);
323
+ })();
324
+ const tagDropdownVisible = activeTagRow != null && tagSuggestions.length > 0;
325
+ const tagAnchorRef = { current: tagAnchorRefs.current[activeTagRow] || null };
326
+
327
+ return (
328
+ <>
329
+ <window.Modal
330
+ isOpen={isOpen}
331
+ onClose={onClose}
332
+ title="批量录入资源"
333
+ width="100%"
334
+ closeOnBackdrop={false}
335
+ closeOnEscape
336
+ fullScreen
337
+ >
338
+ <div style={{ marginBottom: '12px', color: 'var(--text-secondary)', fontSize: '13px' }}>
339
+ 每行一条资源,名称与 URL 必填;类别、访问权限、启用状态可选;标签可输入并从已有标签联想选择,最多 10 个。
340
+ </div>
341
+ <div style={{ overflowX: 'auto', marginBottom: '16px' }}>
342
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
343
+ <thead>
344
+ <tr>
345
+ <th style={{ ...thStyle, width: '14%' }}>名称 <span style={{ color: 'var(--danger)' }}>*</span></th>
346
+ <th style={{ ...thStyle, width: '18%' }}>URL <span style={{ color: 'var(--danger)' }}>*</span></th>
347
+ <th style={{ ...thStyle, width: '15%' }}>类别</th>
348
+ <th style={{ ...thStyle, width: '10%' }}>访问权限</th>
349
+ <th style={{ ...thStyle, width: '5%', textAlign: 'center' }}>启用</th>
350
+ <th style={{ ...thStyle, width: '12%' }}>描述</th>
351
+ <th style={{ ...thStyle, width: '18%' }}>标签</th>
352
+ <th style={{ ...thStyle, width: '40px' }}>操作</th>
353
+ </tr>
354
+ </thead>
355
+ <tbody>
356
+ {rows.map((row, index) => (
357
+ <tr key={index}>
358
+ <td style={tdStyle}>
359
+ <input
360
+ value={row.name}
361
+ onChange={(e) => updateRow(index, 'name', e.target.value)}
362
+ placeholder="资源名称"
363
+ style={inputCellStyle(!!rowErrors[index]?.name)}
364
+ disabled={loading}
365
+ maxLength={50}
366
+ />
367
+ {rowErrors[index]?.name && (
368
+ <div style={{ fontSize: '11px', color: 'var(--danger)', marginTop: '2px' }}>{rowErrors[index].name}</div>
369
+ )}
370
+ </td>
371
+ <td style={tdStyle}>
372
+ <input
373
+ value={row.url}
374
+ onChange={(e) => updateRow(index, 'url', e.target.value)}
375
+ placeholder="https://..."
376
+ style={inputCellStyle(!!rowErrors[index]?.url)}
377
+ disabled={loading}
378
+ />
379
+ {rowErrors[index]?.url && (
380
+ <div style={{ fontSize: '11px', color: 'var(--danger)', marginTop: '2px' }}>{rowErrors[index].url}</div>
381
+ )}
382
+ </td>
383
+ <td style={tdStyle}>
384
+ <div ref={(el) => { categoryAnchorRefs.current[index] = el; }}>
385
+ <input
386
+ value={row.categoryValue}
387
+ onChange={(e) => {
388
+ updateRow(index, 'categoryValue', e.target.value);
389
+ setActiveCategoryRow(index);
390
+ }}
391
+ onFocus={() => setActiveCategoryRow(index)}
392
+ placeholder="输入或选择类别"
393
+ style={inputCellStyle(false)}
394
+ disabled={loading}
395
+ />
396
+ </div>
397
+ </td>
398
+ <td style={tdStyle}>
399
+ <select
400
+ value={row.visibility}
401
+ onChange={(e) => updateRow(index, 'visibility', e.target.value)}
402
+ style={inputCellStyle(false)}
403
+ disabled={loading}
404
+ >
405
+ <option value="public">公开</option>
406
+ <option value="private">私有</option>
407
+ </select>
408
+ </td>
409
+ <td style={tdStyle}>
410
+ <div style={{ display: 'flex', justifyContent: 'center' }}>
411
+ <input
412
+ type="checkbox"
413
+ checked={row.enabled}
414
+ onChange={(e) => updateRow(index, 'enabled', e.target.checked)}
415
+ disabled={loading}
416
+ />
417
+ </div>
418
+ </td>
419
+ <td style={tdStyle}>
420
+ <input
421
+ value={row.description}
422
+ onChange={(e) => updateRow(index, 'description', e.target.value)}
423
+ placeholder="可选"
424
+ style={inputCellStyle(!!rowErrors[index]?.description)}
425
+ disabled={loading}
426
+ maxLength={200}
427
+ />
428
+ {rowErrors[index]?.description && (
429
+ <div style={{ fontSize: '11px', color: 'var(--danger)', marginTop: '2px' }}>{rowErrors[index].description}</div>
430
+ )}
431
+ </td>
432
+ <td style={tdStyle}>
433
+ <div ref={(el) => { tagAnchorRefs.current[index] = el; }}>
434
+ <div
435
+ style={tagInputShellStyle(activeTagRow === index)}
436
+ onClick={() => { if (row.tags?.length < 10) setActiveTagRow(index); }}
437
+ >
438
+ {(row.tags || []).map((tag) => (
439
+ <span key={tag} style={tagChipStyle}>
440
+ {tag}
441
+ <button
442
+ type="button"
443
+ onMouseDown={(e) => e.preventDefault()}
444
+ onClick={() => removeTagFromRow(index, tag)}
445
+ disabled={loading}
446
+ style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', color: 'inherit' }}
447
+ >
448
+ <lucide.X size={10} />
449
+ </button>
450
+ </span>
451
+ ))}
452
+ {row.tags?.length < 10 && (
453
+ <input
454
+ value={activeTagRow === index ? tagInputValue : ''}
455
+ onChange={(e) => { setActiveTagRow(index); setTagInputValue(e.target.value); }}
456
+ onFocus={() => { setActiveTagRow(index); setTagInputValue(activeTagRow === index ? tagInputValue : ''); }}
457
+ onKeyDown={(e) => {
458
+ if (e.key === 'Enter') {
459
+ e.preventDefault();
460
+ const v = (activeTagRow === index ? tagInputValue : '').trim();
461
+ if (v) addTagToRow(index, v);
462
+ }
463
+ if (e.key === 'Backspace' && !(activeTagRow === index ? tagInputValue : '').trim() && row.tags?.length > 0) {
464
+ e.preventDefault();
465
+ removeTagFromRow(index, row.tags[row.tags.length - 1]);
466
+ }
467
+ }}
468
+ placeholder={row.tags?.length === 0 ? '输入选择标签' : ''}
469
+ style={{
470
+ flex: '1 0 72px', minWidth: '72px', padding: '2px 0',
471
+ border: 0, background: 'transparent', color: 'var(--text-primary)',
472
+ fontSize: '13px', outline: 'none', boxSizing: 'border-box', boxShadow: 'none',
473
+ }}
474
+ disabled={loading}
475
+ />
476
+ )}
477
+ </div>
478
+ </div>
479
+ {rowErrors[index]?.tags && (
480
+ <div style={{ fontSize: '11px', color: 'var(--danger)', marginTop: '2px' }}>{rowErrors[index].tags}</div>
481
+ )}
482
+ </td>
483
+ <td style={tdStyle}>
484
+ <button
485
+ type="button"
486
+ onClick={() => removeRow(index)}
487
+ disabled={loading || rows.length <= 1}
488
+ style={{
489
+ padding: '4px 8px', border: 'none', background: 'transparent',
490
+ color: 'var(--text-secondary)',
491
+ cursor: rows.length <= 1 || loading ? 'not-allowed' : 'pointer',
492
+ fontSize: '12px', opacity: rows.length <= 1 ? 0.5 : 1,
493
+ }}
494
+ title="删除此行"
495
+ >
496
+ <lucide.Trash2 size={14} />
497
+ </button>
498
+ </td>
499
+ </tr>
500
+ ))}
501
+ </tbody>
502
+ </table>
503
+ </div>
504
+ <div style={{ marginBottom: '16px' }}>
505
+ <button
506
+ type="button"
507
+ onClick={addRow}
508
+ disabled={loading}
509
+ style={{
510
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
511
+ padding: '6px 12px', border: '1px dashed var(--border)',
512
+ background: 'var(--bg-secondary)', color: 'var(--text-secondary)',
513
+ borderRadius: '8px', fontSize: '13px',
514
+ cursor: loading ? 'not-allowed' : 'pointer',
515
+ }}
516
+ >
517
+ <lucide.Plus size={14} /> 添加一行
518
+ </button>
519
+ </div>
520
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', paddingTop: '12px', borderTop: '1px solid var(--border)' }}>
521
+ <button
522
+ onClick={onClose}
523
+ disabled={loading}
524
+ style={{
525
+ padding: '8px 20px', border: '1px solid var(--border)',
526
+ background: 'var(--bg-secondary)', color: 'var(--text-primary)',
527
+ borderRadius: '8px', cursor: 'pointer', fontSize: '14px',
528
+ }}
529
+ >
530
+ 取消
531
+ </button>
532
+ <button
533
+ onClick={handleSubmit}
534
+ disabled={loading}
535
+ style={{
536
+ padding: '8px 20px', background: 'var(--brand)', color: '#fff',
537
+ border: 'none', borderRadius: '8px',
538
+ cursor: loading ? 'not-allowed' : 'pointer',
539
+ fontSize: '14px', fontWeight: 500,
540
+ display: 'flex', alignItems: 'center', gap: '6px',
541
+ }}
542
+ >
543
+ {loading && <lucide.Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
544
+ 批量保存
545
+ </button>
546
+ </div>
547
+ </window.Modal>
548
+
549
+ <FloatingPanel anchorRef={catAnchorRef} isVisible={categoryDropdownVisible}>
550
+ <div ref={categoryPanelRef}>
551
+ <div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: '4px' }}>
552
+ {(rows[activeCategoryRow]?.categoryValue || '').trim() ? '匹配类别' : '已有类别'}
553
+ </div>
554
+ <div style={{ display: 'grid', gap: '2px' }}>
555
+ {filteredCategorySuggestions.map((category) => (
556
+ <button
557
+ key={category.id}
558
+ type="button"
559
+ onMouseDown={(e) => e.preventDefault()}
560
+ onClick={() => {
561
+ updateRow(activeCategoryRow, 'categoryValue', category.name);
562
+ setActiveCategoryRow(null);
563
+ }}
564
+ style={suggestionBtnStyle}
565
+ >
566
+ {category.name}
567
+ </button>
568
+ ))}
569
+ </div>
570
+ </div>
571
+ </FloatingPanel>
572
+
573
+ <FloatingPanel anchorRef={tagAnchorRef} isVisible={tagDropdownVisible}>
574
+ <div ref={tagPanelRef}>
575
+ <div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: '4px' }}>
576
+ {tagInputValue.trim() ? '匹配标签' : '已有标签'}
577
+ </div>
578
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
579
+ {tagSuggestions.map((tag) => {
580
+ const label = typeof tag === 'string' ? tag : tag.name;
581
+ return (
582
+ <button
583
+ key={label}
584
+ type="button"
585
+ onMouseDown={(e) => e.preventDefault()}
586
+ onClick={() => addTagToRow(activeTagRow, label)}
587
+ style={{
588
+ fontSize: '12px', padding: '5px 10px',
589
+ border: '1px solid color-mix(in srgb, var(--outline-strong) 82%, var(--border))',
590
+ borderRadius: '999px', cursor: 'pointer',
591
+ background: 'color-mix(in srgb, var(--surface-elevated) 84%, var(--surface-muted))',
592
+ color: 'var(--text-secondary)',
593
+ }}
594
+ >
595
+ {label}
596
+ </button>
597
+ );
598
+ })}
599
+ </div>
600
+ </div>
601
+ </FloatingPanel>
602
+ </>
603
+ );
604
+ }
605
+
606
+ window.BatchResourceModal = BatchResourceModal;