@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,310 @@
1
+ const PRESET_COLORS = ['#4F46E5', '#8B5CF6', '#14B8A6', '#F59E0B', '#F43F5E', '#2563EB', '#0EA5E9', '#64748B'];
2
+
3
+ function AdminCategories() {
4
+ const state = window.useAppState();
5
+ const dispatch = window.useAppDispatch();
6
+ const { request } = window.useApi();
7
+ const { Plus, Edit2, Trash2, Loader, Check } = lucide;
8
+ const { getCategoryTone } = window.helpers;
9
+
10
+ const categories = state?.categories || [];
11
+ const [showModal, setShowModal] = React.useState(false);
12
+ const [editTarget, setEditTarget] = React.useState(null); // null = create
13
+ const [form, setForm] = React.useState({ name: '', color: '#4F46E5' });
14
+ const [formError, setFormError] = React.useState('');
15
+ const [saving, setSaving] = React.useState(false);
16
+ const [deleteTarget, setDeleteTarget] = React.useState(null);
17
+ const [deleting, setDeleting] = React.useState(false);
18
+ const [page, setPage] = React.useState(1);
19
+ const PAGE_SIZE = 20;
20
+
21
+ const openCreate = () => { setEditTarget(null); setForm({ name: '', color: '#4F46E5' }); setFormError(''); setShowModal(true); };
22
+ const openEdit = (cat) => { setEditTarget(cat); setForm({ name: cat.name, color: cat.color }); setFormError(''); setShowModal(true); };
23
+
24
+ const handleSave = async () => {
25
+ if (!form.name.trim()) { setFormError('类别名称不能为空'); return; }
26
+ setSaving(true);
27
+ try {
28
+ if (editTarget) {
29
+ const { ok, data } = await request(`/api/categories/${editTarget.id}`, {
30
+ method: 'PUT', body: JSON.stringify({ name: form.name.trim(), color: form.color }),
31
+ });
32
+ if (ok) {
33
+ dispatch({ type: 'UPDATE_CATEGORY', category: data.data });
34
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '类别已更新' });
35
+ setShowModal(false);
36
+ } else {
37
+ setFormError(data.error || '更新失败');
38
+ }
39
+ } else {
40
+ const { ok, data } = await request('/api/categories', {
41
+ method: 'POST', body: JSON.stringify({ name: form.name.trim(), color: form.color }),
42
+ });
43
+ if (ok) {
44
+ dispatch({ type: 'ADD_CATEGORY', category: data.data });
45
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '类别已创建' });
46
+ setShowModal(false);
47
+ } else {
48
+ setFormError(data.error || '创建失败');
49
+ }
50
+ }
51
+ } finally {
52
+ setSaving(false);
53
+ }
54
+ };
55
+
56
+ const handleDelete = async () => {
57
+ if (!deleteTarget) return;
58
+ setDeleting(true);
59
+ try {
60
+ const { ok, data } = await request(`/api/categories/${deleteTarget.id}`, { method: 'DELETE' });
61
+ if (ok) {
62
+ dispatch({ type: 'DELETE_CATEGORY', id: deleteTarget.id });
63
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '类别已删除' });
64
+ setDeleteTarget(null);
65
+ } else {
66
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '删除失败' });
67
+ }
68
+ } finally {
69
+ setDeleting(false);
70
+ }
71
+ };
72
+
73
+ const totalPages = Math.ceil(categories.length / PAGE_SIZE);
74
+ const pageData = categories.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
75
+
76
+ const headerPanelStyle = {
77
+ display: 'flex',
78
+ justifyContent: 'space-between',
79
+ alignItems: 'center',
80
+ gap: '12px',
81
+ flexWrap: 'wrap',
82
+ marginBottom: '18px',
83
+ padding: '18px 20px',
84
+ background: 'var(--surface-elevated)',
85
+ border: '1px solid var(--border)',
86
+ borderRadius: '18px',
87
+ boxShadow: 'var(--shadow-card)',
88
+ };
89
+ const primaryButtonStyle = {
90
+ display: 'inline-flex',
91
+ alignItems: 'center',
92
+ gap: '6px',
93
+ minHeight: '38px',
94
+ padding: '0 16px',
95
+ background: 'var(--brand)',
96
+ color: '#fff',
97
+ border: '1px solid var(--brand)',
98
+ borderRadius: '12px',
99
+ cursor: 'pointer',
100
+ fontSize: '14px',
101
+ fontWeight: 700,
102
+ boxShadow: '0 10px 20px color-mix(in srgb, var(--brand) 16%, transparent)',
103
+ };
104
+ const secondaryButtonStyle = {
105
+ minHeight: '38px',
106
+ padding: '0 16px',
107
+ border: '1px solid var(--control-border)',
108
+ background: 'var(--surface-elevated)',
109
+ color: 'var(--text-primary)',
110
+ borderRadius: '12px',
111
+ cursor: 'pointer',
112
+ fontSize: '14px',
113
+ fontWeight: 600,
114
+ boxShadow: 'var(--shadow-control)',
115
+ };
116
+ const tableShellStyle = {
117
+ background: 'var(--surface-elevated)',
118
+ border: '1px solid var(--border)',
119
+ borderRadius: '18px',
120
+ overflowX: 'auto',
121
+ boxShadow: 'var(--shadow-card)',
122
+ };
123
+ const thStyle = {
124
+ padding: '0 18px',
125
+ textAlign: 'left',
126
+ fontSize: '12px',
127
+ fontWeight: 700,
128
+ color: 'var(--text-secondary)',
129
+ letterSpacing: '0.02em',
130
+ };
131
+ const tdStyle = { padding: '0 18px', fontSize: '14px', color: 'var(--text-primary)' };
132
+ const iconButtonStyle = (tone = 'neutral') => ({
133
+ width: '32px',
134
+ height: '32px',
135
+ borderRadius: '10px',
136
+ border: `1px solid ${tone === 'danger' ? 'color-mix(in srgb, var(--danger) 20%, var(--control-border))' : 'var(--control-border)'}`,
137
+ background: tone === 'danger'
138
+ ? 'color-mix(in srgb, var(--danger) 8%, var(--surface-elevated))'
139
+ : 'var(--surface-elevated)',
140
+ color: tone === 'danger' ? 'var(--danger)' : 'var(--text-secondary)',
141
+ cursor: 'pointer',
142
+ display: 'inline-flex',
143
+ alignItems: 'center',
144
+ justifyContent: 'center',
145
+ boxShadow: 'var(--shadow-control)',
146
+ });
147
+ const pagerButtonStyle = (disabled) => ({
148
+ width: '36px',
149
+ height: '36px',
150
+ border: '1px solid var(--control-border)',
151
+ background: 'var(--surface-elevated)',
152
+ borderRadius: '10px',
153
+ cursor: disabled ? 'not-allowed' : 'pointer',
154
+ color: disabled ? 'var(--text-tertiary)' : 'var(--text-primary)',
155
+ boxShadow: 'var(--shadow-control)',
156
+ opacity: disabled ? 0.6 : 1,
157
+ display: 'inline-flex',
158
+ alignItems: 'center',
159
+ justifyContent: 'center',
160
+ });
161
+ const modalLabelStyle = { fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)', display: 'block', marginBottom: '6px' };
162
+ const modalInputStyle = (error = false) => ({
163
+ width: '100%',
164
+ padding: '10px 12px',
165
+ border: `1px solid ${error ? 'var(--danger)' : 'var(--control-border)'}`,
166
+ borderRadius: '10px',
167
+ background: 'var(--surface-elevated)',
168
+ color: 'var(--text-primary)',
169
+ fontSize: '14px',
170
+ outline: 'none',
171
+ boxSizing: 'border-box',
172
+ });
173
+
174
+ return (
175
+ <div>
176
+ <div style={headerPanelStyle}>
177
+ <div style={{ display: 'grid', gap: '4px' }}>
178
+ <h2 style={{ fontSize: '26px', fontWeight: 800, color: 'var(--text-primary)', margin: 0, letterSpacing: '-0.03em' }}>类别管理</h2>
179
+ <div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>管理资源分类名称与配色,保持后台和结果页的类别语义一致。</div>
180
+ </div>
181
+ <button onClick={openCreate} style={primaryButtonStyle}>
182
+ <Plus size={15} /> 新增类别
183
+ </button>
184
+ </div>
185
+
186
+ <div style={tableShellStyle}>
187
+ <table style={{ width: '100%', minWidth: '640px', borderCollapse: 'collapse' }}>
188
+ <thead>
189
+ <tr style={{ background: 'var(--surface-muted)', height: '46px' }}>
190
+ <th style={thStyle}>名称</th>
191
+ <th style={thStyle}>颜色</th>
192
+ <th style={thStyle}>资源数</th>
193
+ <th style={{ ...thStyle, width: '120px' }}>操作</th>
194
+ </tr>
195
+ </thead>
196
+ <tbody>
197
+ {pageData.length === 0 ? (
198
+ <tr><td colSpan={4} style={{ ...tdStyle, textAlign: 'center', height: '88px', color: 'var(--text-secondary)' }}>暂无类别</td></tr>
199
+ ) : pageData.map(cat => (
200
+ <tr
201
+ key={cat.id}
202
+ style={{ height: '56px', borderTop: '1px solid color-mix(in srgb, var(--border) 86%, transparent)', background: 'var(--surface-elevated)', transition: 'background 150ms ease' }}
203
+ onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-hover)'; }}
204
+ onMouseLeave={e => { e.currentTarget.style.background = 'var(--surface-elevated)'; }}
205
+ >
206
+ <td style={{ ...tdStyle, fontWeight: 600 }}>{cat.name}</td>
207
+ <td style={tdStyle}>
208
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
209
+ <span
210
+ style={{
211
+ display: 'inline-flex',
212
+ alignItems: 'center',
213
+ gap: '8px',
214
+ minHeight: '30px',
215
+ padding: '0 10px',
216
+ borderRadius: '999px',
217
+ background: getCategoryTone(cat, cat.id).soft,
218
+ border: `1px solid ${getCategoryTone(cat, cat.id).border}`,
219
+ }}
220
+ >
221
+ <span style={{ width: '12px', height: '12px', borderRadius: '50%', background: cat.color, flexShrink: 0 }} />
222
+ <span style={{ fontSize: '12px', color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{cat.color}</span>
223
+ </span>
224
+ </div>
225
+ </td>
226
+ <td style={{ ...tdStyle, fontWeight: 600 }}>{cat.resourceCount ?? 0}</td>
227
+ <td style={tdStyle}>
228
+ <div style={{ display: 'flex', gap: '8px' }}>
229
+ <button onClick={() => openEdit(cat)} style={iconButtonStyle('neutral')}>
230
+ <Edit2 size={14} />
231
+ </button>
232
+ <button onClick={() => setDeleteTarget(cat)} style={iconButtonStyle('danger')}>
233
+ <Trash2 size={14} />
234
+ </button>
235
+ </div>
236
+ </td>
237
+ </tr>
238
+ ))}
239
+ </tbody>
240
+ </table>
241
+ </div>
242
+
243
+ {/* Pagination */}
244
+ {totalPages > 1 && (
245
+ <div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
246
+ <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}
247
+ style={pagerButtonStyle(page === 1)}>
248
+ <lucide.ChevronLeft size={14} />
249
+ </button>
250
+ <span style={{ padding: '6px 12px', fontSize: '14px', color: 'var(--text-secondary)', minWidth: '72px', textAlign: 'center' }}>{page} / {totalPages}</span>
251
+ <button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}
252
+ style={pagerButtonStyle(page === totalPages)}>
253
+ <lucide.ChevronRight size={14} />
254
+ </button>
255
+ </div>
256
+ )}
257
+
258
+ {/* Create/Edit Modal */}
259
+ <window.Modal isOpen={showModal} onClose={() => setShowModal(false)} title={editTarget ? '编辑类别' : '新增类别'} width="480px">
260
+ <div>
261
+ <div style={{ marginBottom: '16px' }}>
262
+ <label style={modalLabelStyle}>类别名称</label>
263
+ <input value={form.name} onChange={e => { setForm(f => ({ ...f, name: e.target.value })); setFormError(''); }}
264
+ style={modalInputStyle(Boolean(formError))} placeholder="输入类别名称" disabled={saving} />
265
+ {formError && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{formError}</div>}
266
+ </div>
267
+ <div style={{ marginBottom: '20px' }}>
268
+ <label style={modalLabelStyle}>颜色</label>
269
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
270
+ {PRESET_COLORS.map(c => (
271
+ <button key={c} onClick={() => setForm(f => ({ ...f, color: c }))} style={{
272
+ width: '30px', height: '30px', borderRadius: '50%', background: c, border: 'none', cursor: 'pointer',
273
+ outline: form.color === c ? `3px solid ${c}` : '3px solid transparent',
274
+ outlineOffset: '2px',
275
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
276
+ }}>
277
+ {form.color === c && <Check size={14} color="#fff" />}
278
+ </button>
279
+ ))}
280
+ </div>
281
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
282
+ <input type="color" value={form.color} onChange={e => setForm(f => ({ ...f, color: e.target.value }))}
283
+ style={{ width: '38px', height: '32px', border: '1px solid var(--control-border)', borderRadius: '8px', cursor: 'pointer', padding: '0', background: 'var(--surface-elevated)' }} />
284
+ <input value={form.color} onChange={e => setForm(f => ({ ...f, color: e.target.value }))}
285
+ style={{ ...modalInputStyle(false), flex: 1, padding: '8px 10px', fontFamily: 'monospace' }} placeholder="#4F46E5" />
286
+ </div>
287
+ </div>
288
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>
289
+ <button onClick={() => setShowModal(false)} disabled={saving} style={secondaryButtonStyle}>取消</button>
290
+ <button onClick={handleSave} disabled={saving} style={{ ...primaryButtonStyle, opacity: saving ? 0.8 : 1, cursor: saving ? 'not-allowed' : 'pointer' }}>
291
+ {saving && <Loader size={14} />} 保存
292
+ </button>
293
+ </div>
294
+ </div>
295
+ </window.Modal>
296
+
297
+ {/* Delete confirm */}
298
+ <window.ConfirmDialog
299
+ isOpen={!!deleteTarget}
300
+ onClose={() => setDeleteTarget(null)}
301
+ onConfirm={handleDelete}
302
+ title="删除类别"
303
+ message={`确认删除类别「${deleteTarget?.name}」?${deleteTarget?.resourceCount > 0 ? `关联的 ${deleteTarget.resourceCount} 个资源将失去类别归属。` : ''}删除后不可恢复。`}
304
+ loading={deleting}
305
+ />
306
+ </div>
307
+ );
308
+ }
309
+
310
+ window.AdminCategories = AdminCategories;
@@ -0,0 +1,254 @@
1
+ function AdminConfig() {
2
+ const dispatch = window.useAppDispatch();
3
+ const { request } = window.useApi();
4
+ const { t } = window.useI18n();
5
+ const { Save, Loader } = lucide;
6
+
7
+ const [form, setForm] = React.useState({
8
+ siteTitle: '',
9
+ siteSubtitle: '',
10
+ logoUrl: '',
11
+ tokenExpiry: 60,
12
+ resetTokenExpiry: 60,
13
+ enableRegister: true,
14
+ restrictEmailDomain: false,
15
+ emailDomainWhitelist: '',
16
+ });
17
+ const [errors, setErrors] = React.useState({});
18
+ const [loading, setLoading] = React.useState(true);
19
+ const [saving, setSaving] = React.useState(false);
20
+ const localizableConfigValues = new Set(['资源导航系统', '统一管理与访问你的资源', '登录以访问全部资源导航']);
21
+ const getLocalizedConfigValue = (value) => (localizableConfigValues.has(value) ? t(value) : value);
22
+
23
+ React.useEffect(() => {
24
+ async function load() {
25
+ const { ok, data } = await request('/api/config/system/full');
26
+ if (ok && data.data) {
27
+ const cfg = data.data;
28
+ setForm({
29
+ siteTitle: cfg.siteTitle || '',
30
+ siteSubtitle: cfg.siteSubtitle || '',
31
+ logoUrl: cfg.logoUrl || '',
32
+ tokenExpiry: cfg.tokenExpiry ?? 60,
33
+ resetTokenExpiry: cfg.resetTokenExpiry ?? 60,
34
+ enableRegister: cfg.enableRegister !== false,
35
+ restrictEmailDomain: cfg.restrictEmailDomain === true,
36
+ emailDomainWhitelist: cfg.emailDomainWhitelist || '',
37
+ });
38
+ }
39
+ setLoading(false);
40
+ }
41
+ load();
42
+ }, []);
43
+
44
+ const handleSave = async () => {
45
+ setSaving(true);
46
+ setErrors({});
47
+ try {
48
+ const payload = {
49
+ siteTitle: form.siteTitle,
50
+ siteSubtitle: form.siteSubtitle,
51
+ logoUrl: form.logoUrl,
52
+ tokenExpiry: Number(form.tokenExpiry),
53
+ resetTokenExpiry: Number(form.resetTokenExpiry),
54
+ enableRegister: form.enableRegister,
55
+ restrictEmailDomain: form.restrictEmailDomain,
56
+ emailDomainWhitelist: form.restrictEmailDomain ? form.emailDomainWhitelist : '',
57
+ };
58
+
59
+ const { ok, data } = await request('/api/config/system', {
60
+ method: 'PUT',
61
+ body: JSON.stringify(payload),
62
+ });
63
+ if (ok) {
64
+ dispatch({
65
+ type: 'SET_CONFIG',
66
+ config: {
67
+ siteTitle: data.data.siteTitle,
68
+ siteSubtitle: data.data.siteSubtitle,
69
+ logoUrl: data.data.logoUrl,
70
+ enableRegister: data.data.enableRegister,
71
+ },
72
+ });
73
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '系统配置已保存' });
74
+ } else {
75
+ if (data.code === 'VALIDATION_ERROR' && data.fields) {
76
+ setErrors(data.fields);
77
+ }
78
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '保存失败' });
79
+ }
80
+ } finally {
81
+ setSaving(false);
82
+ }
83
+ };
84
+
85
+ const inputStyle = (field) => ({
86
+ width: '100%', padding: '9px 12px', boxSizing: 'border-box',
87
+ border: `1px solid ${errors[field] ? 'var(--danger)' : 'var(--control-border)'}`,
88
+ borderRadius: '10px',
89
+ background: 'var(--surface-elevated)',
90
+ backgroundColor: 'var(--surface-elevated)',
91
+ color: 'var(--text-primary)',
92
+ fontSize: '14px', outline: 'none',
93
+ });
94
+ const labelStyle = { fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)', display: 'block', marginBottom: '6px' };
95
+ const fieldStyle = { marginBottom: '20px' };
96
+ const headerPanelStyle = {
97
+ display: 'flex',
98
+ justifyContent: 'space-between',
99
+ alignItems: 'center',
100
+ gap: '12px',
101
+ flexWrap: 'wrap',
102
+ marginBottom: '18px',
103
+ padding: '18px 20px',
104
+ background: 'var(--surface-elevated)',
105
+ border: '1px solid var(--border)',
106
+ borderRadius: '18px',
107
+ boxShadow: 'var(--shadow-card)',
108
+ };
109
+ const primaryButtonStyle = {
110
+ display: 'inline-flex',
111
+ alignItems: 'center',
112
+ gap: '6px',
113
+ minHeight: '38px',
114
+ padding: '0 18px',
115
+ background: 'var(--brand)',
116
+ color: '#fff',
117
+ border: '1px solid var(--brand)',
118
+ borderRadius: '12px',
119
+ cursor: 'pointer',
120
+ fontSize: '14px',
121
+ fontWeight: 700,
122
+ boxShadow: '0 10px 20px color-mix(in srgb, var(--brand) 16%, transparent)',
123
+ };
124
+ const panelStyle = {
125
+ background: 'var(--surface-elevated)',
126
+ border: '1px solid var(--border)',
127
+ borderRadius: '18px',
128
+ padding: '24px',
129
+ maxWidth: '680px',
130
+ boxShadow: 'var(--shadow-card)',
131
+ };
132
+
133
+ if (loading) {
134
+ return (
135
+ <div>
136
+ <h2 style={{ fontSize: '18px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '20px' }}>系统配置</h2>
137
+ <window.Skeleton rows={6} type="form" />
138
+ </div>
139
+ );
140
+ }
141
+
142
+ return (
143
+ <div>
144
+ <div style={headerPanelStyle}>
145
+ <div style={{ display: 'grid', gap: '4px' }}>
146
+ <h2 style={{ fontSize: '26px', fontWeight: 800, color: 'var(--text-primary)', margin: 0, letterSpacing: '-0.03em' }}>系统配置</h2>
147
+ <div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>维护站点基础信息、注册策略与安全有效期配置。</div>
148
+ </div>
149
+ <button onClick={handleSave} disabled={saving} style={{ ...primaryButtonStyle, opacity: saving ? 0.8 : 1, cursor: saving ? 'not-allowed' : 'pointer' }}>
150
+ {saving ? <Loader size={15} /> : <Save size={15} />}
151
+ 保存配置
152
+ </button>
153
+ </div>
154
+
155
+ <div style={panelStyle}>
156
+ <div style={fieldStyle}>
157
+ <label style={labelStyle}>站点标题</label>
158
+ <input value={getLocalizedConfigValue(form.siteTitle)} onChange={e => setForm(f => ({ ...f, siteTitle: e.target.value }))}
159
+ className="rh-admin-input" style={inputStyle('siteTitle')} placeholder="资源导航系统" disabled={saving} />
160
+ {errors.siteTitle && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.siteTitle}</div>}
161
+ </div>
162
+
163
+ <div style={fieldStyle}>
164
+ <label style={labelStyle}>站点副标题</label>
165
+ <input value={getLocalizedConfigValue(form.siteSubtitle)} onChange={e => setForm(f => ({ ...f, siteSubtitle: e.target.value }))}
166
+ className="rh-admin-input" style={inputStyle('siteSubtitle')} placeholder="统一管理与访问你的资源" disabled={saving} />
167
+ {errors.siteSubtitle && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.siteSubtitle}</div>}
168
+ </div>
169
+
170
+ <div style={fieldStyle}>
171
+ <label style={labelStyle}>站点 Logo URL</label>
172
+ <input value={form.logoUrl} onChange={e => setForm(f => ({ ...f, logoUrl: e.target.value }))}
173
+ className="rh-admin-input" style={inputStyle('logoUrl')} placeholder="留空则使用默认图标" disabled={saving} />
174
+ {errors.logoUrl && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.logoUrl}</div>}
175
+ <div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginTop: '4px' }}>留空时显示品牌色首字母图标</div>
176
+ </div>
177
+
178
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '16px', ...fieldStyle }}>
179
+ <div>
180
+ <label style={labelStyle}>Token 有效期(分钟)</label>
181
+ <input type="number" value={form.tokenExpiry} min={5} max={43200}
182
+ onChange={e => setForm(f => ({ ...f, tokenExpiry: e.target.value }))}
183
+ className="rh-admin-input" style={inputStyle('tokenExpiry')} placeholder="60" disabled={saving} />
184
+ {errors.tokenExpiry && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.tokenExpiry}</div>}
185
+ </div>
186
+ <div>
187
+ <label style={labelStyle}>重置链接有效期(分钟)</label>
188
+ <input type="number" value={form.resetTokenExpiry} min={5} max={43200}
189
+ onChange={e => setForm(f => ({ ...f, resetTokenExpiry: e.target.value }))}
190
+ className="rh-admin-input" style={inputStyle('resetTokenExpiry')} placeholder="60" disabled={saving} />
191
+ {errors.resetTokenExpiry && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.resetTokenExpiry}</div>}
192
+ </div>
193
+ </div>
194
+
195
+ <div style={{ ...fieldStyle, display: 'flex', alignItems: 'center', gap: '12px' }}>
196
+ <label style={{ ...labelStyle, marginBottom: 0, flex: 1 }}>开放注册</label>
197
+ <button
198
+ onClick={() => setForm(f => ({ ...f, enableRegister: !f.enableRegister }))}
199
+ disabled={saving}
200
+ style={{
201
+ width: '44px', height: '24px', borderRadius: '12px', border: 'none',
202
+ cursor: saving ? 'not-allowed' : 'pointer',
203
+ background: form.enableRegister ? 'var(--brand)' : 'var(--bg-tertiary)',
204
+ position: 'relative', transition: 'background 200ms', flexShrink: 0,
205
+ }}>
206
+ <div style={{
207
+ position: 'absolute', top: '2px', width: '20px', height: '20px',
208
+ borderRadius: '50%', background: '#fff',
209
+ left: form.enableRegister ? '22px' : '2px', transition: 'left 200ms',
210
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
211
+ }} />
212
+ </button>
213
+ <span style={{ fontSize: '13px', color: 'var(--text-secondary)', flexShrink: 0 }}>
214
+ {form.enableRegister ? '已开启' : '已关闭'}
215
+ </span>
216
+ </div>
217
+
218
+ <div style={{ ...fieldStyle, display: 'flex', alignItems: 'center', gap: '12px' }}>
219
+ <label style={{ ...labelStyle, marginBottom: 0, flex: 1 }}>限制注册邮箱域名</label>
220
+ <button
221
+ onClick={() => setForm(f => ({ ...f, restrictEmailDomain: !f.restrictEmailDomain }))}
222
+ disabled={saving}
223
+ style={{
224
+ width: '44px', height: '24px', borderRadius: '12px', border: 'none',
225
+ cursor: saving ? 'not-allowed' : 'pointer',
226
+ background: form.restrictEmailDomain ? 'var(--brand)' : 'var(--bg-tertiary)',
227
+ position: 'relative', transition: 'background 200ms', flexShrink: 0,
228
+ }}>
229
+ <div style={{
230
+ position: 'absolute', top: '2px', width: '20px', height: '20px',
231
+ borderRadius: '50%', background: '#fff',
232
+ left: form.restrictEmailDomain ? '22px' : '2px', transition: 'left 200ms',
233
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
234
+ }} />
235
+ </button>
236
+ <span style={{ fontSize: '13px', color: 'var(--text-secondary)', flexShrink: 0 }}>
237
+ {form.restrictEmailDomain ? '已开启' : '已关闭'}
238
+ </span>
239
+ </div>
240
+
241
+ <div style={fieldStyle}>
242
+ <label style={labelStyle}>邮箱域名白名单</label>
243
+ <input value={form.emailDomainWhitelist}
244
+ onChange={e => setForm(f => ({ ...f, emailDomainWhitelist: e.target.value }))}
245
+ className="rh-admin-input" style={inputStyle('emailDomainWhitelist')} placeholder="example.com,corp.com" disabled={saving || !form.restrictEmailDomain} />
246
+ {errors.emailDomainWhitelist && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.emailDomainWhitelist}</div>}
247
+ <div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginTop: '4px' }}>多个域名用英文逗号分隔,留空则不限制</div>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ window.AdminConfig = AdminConfig;