@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,452 @@
1
+ function AdminUsers() {
2
+ const dispatch = window.useAppDispatch();
3
+ const { request } = window.useApi();
4
+ const { Plus, Edit2, Trash2, Key, Loader } = lucide;
5
+
6
+ const [users, setUsers] = React.useState([]);
7
+ const [loadingUsers, setLoadingUsers] = React.useState(true);
8
+ const [page, setPage] = React.useState(1);
9
+ const PAGE_SIZE = 20;
10
+
11
+ // Create/Edit modal state
12
+ const [showModal, setShowModal] = React.useState(false);
13
+ const [editTarget, setEditTarget] = React.useState(null);
14
+ const [form, setForm] = React.useState({ username: '', displayName: '', email: '', password: '', role: 'user', enabled: true });
15
+ const [formErrors, setFormErrors] = React.useState({});
16
+ const [saving, setSaving] = React.useState(false);
17
+
18
+ // Reset password modal state
19
+ const [resetTarget, setResetTarget] = React.useState(null);
20
+ const [resetLoading, setResetLoading] = React.useState(false);
21
+ const [resetError, setResetError] = React.useState('');
22
+
23
+ // Delete confirm state
24
+ const [deleteTarget, setDeleteTarget] = React.useState(null);
25
+ const [deleting, setDeleting] = React.useState(false);
26
+
27
+ const loadUsers = async () => {
28
+ setLoadingUsers(true);
29
+ try {
30
+ const { ok, data } = await request('/api/users');
31
+ if (ok) setUsers(data.data || []);
32
+ } finally {
33
+ setLoadingUsers(false);
34
+ }
35
+ };
36
+
37
+ React.useEffect(() => { loadUsers(); }, []);
38
+
39
+ const openCreate = () => {
40
+ setEditTarget(null);
41
+ setForm({ username: '', displayName: '', email: '', password: '', role: 'user', enabled: true });
42
+ setFormErrors({});
43
+ setShowModal(true);
44
+ };
45
+
46
+ const openEdit = (user) => {
47
+ setEditTarget(user);
48
+ setForm({ username: user.username, displayName: user.displayName, email: user.email, password: '', role: user.role, enabled: user.status === 'active' });
49
+ setFormErrors({});
50
+ setShowModal(true);
51
+ };
52
+
53
+ const validateForm = () => {
54
+ const errs = {};
55
+ if (!editTarget) {
56
+ if (!/^[a-zA-Z0-9_]{3,20}$/.test(form.username) || /^\d+$/.test(form.username))
57
+ errs.username = '用户名3-20位,仅字母数字下划线,不能纯数字';
58
+ if (!form.password || form.password.length < 8 || !/[a-zA-Z]/.test(form.password) || !/[0-9]/.test(form.password))
59
+ errs.password = '密码至少8位,需包含字母和数字';
60
+ }
61
+ if (!form.displayName || form.displayName.length > 30) errs.displayName = '显示名称不能为空(最多30字符)';
62
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errs.email = '请输入有效的邮箱地址';
63
+ return errs;
64
+ };
65
+
66
+ const handleSave = async () => {
67
+ const errs = validateForm();
68
+ if (Object.keys(errs).length > 0) { setFormErrors(errs); return; }
69
+ setSaving(true);
70
+ try {
71
+ if (editTarget) {
72
+ const body = { displayName: form.displayName, email: form.email, role: form.role, status: form.enabled ? 'active' : 'disabled' };
73
+ const { ok, data } = await request(`/api/users/${editTarget.id}`, { method: 'PUT', body: JSON.stringify(body) });
74
+ if (ok) {
75
+ setUsers(prev => prev.map(u => u.id === editTarget.id ? data.data : u));
76
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '用户已更新' });
77
+ setShowModal(false);
78
+ } else {
79
+ setFormErrors({ general: data.error || '更新失败' });
80
+ }
81
+ } else {
82
+ const body = { username: form.username, displayName: form.displayName, email: form.email, password: form.password, role: form.role };
83
+ const { ok, data } = await request('/api/users', { method: 'POST', body: JSON.stringify(body) });
84
+ if (ok) {
85
+ setUsers(prev => [...prev, data.data]);
86
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '用户已创建' });
87
+ if (data.emailPreview) dispatch({ type: 'SET_EMAIL_PREVIEW', emailPreview: data.emailPreview });
88
+ setShowModal(false);
89
+ } else {
90
+ setFormErrors({ general: data.error || '创建失败' });
91
+ }
92
+ }
93
+ } finally {
94
+ setSaving(false);
95
+ }
96
+ };
97
+
98
+ const handleResetPassword = async () => {
99
+ setResetLoading(true);
100
+ try {
101
+ const { ok, data } = await request(`/api/users/${resetTarget.id}/reset-password`, { method: 'POST' });
102
+ if (ok) {
103
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: `已重置 ${resetTarget.displayName} 的密码,临时密码已发送至邮箱` });
104
+ if (data.emailPreview) dispatch({ type: 'SET_EMAIL_PREVIEW', emailPreview: data.emailPreview });
105
+ setResetTarget(null);
106
+ setResetError('');
107
+ } else {
108
+ setResetError(data.error || '重置失败');
109
+ }
110
+ } finally {
111
+ setResetLoading(false);
112
+ }
113
+ };
114
+
115
+ const handleDelete = async () => {
116
+ if (!deleteTarget) return;
117
+ setDeleting(true);
118
+ try {
119
+ const { ok, data } = await request(`/api/users/${deleteTarget.id}`, { method: 'DELETE' });
120
+ if (ok) {
121
+ setUsers(prev => prev.filter(u => u.id !== deleteTarget.id));
122
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '用户已删除' });
123
+ setDeleteTarget(null);
124
+ } else {
125
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '删除失败' });
126
+ }
127
+ } finally {
128
+ setDeleting(false);
129
+ }
130
+ };
131
+
132
+ const totalPages = Math.ceil(users.length / PAGE_SIZE);
133
+ const pageData = users.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
134
+
135
+ const headerPanelStyle = {
136
+ display: 'flex',
137
+ justifyContent: 'space-between',
138
+ alignItems: 'center',
139
+ gap: '12px',
140
+ flexWrap: 'wrap',
141
+ marginBottom: '18px',
142
+ padding: '18px 20px',
143
+ background: 'var(--surface-elevated)',
144
+ border: '1px solid var(--border)',
145
+ borderRadius: '18px',
146
+ boxShadow: 'var(--shadow-card)',
147
+ };
148
+ const primaryButtonStyle = {
149
+ display: 'inline-flex',
150
+ alignItems: 'center',
151
+ gap: '6px',
152
+ minHeight: '38px',
153
+ padding: '0 16px',
154
+ background: 'var(--brand)',
155
+ color: '#fff',
156
+ border: '1px solid var(--brand)',
157
+ borderRadius: '12px',
158
+ cursor: 'pointer',
159
+ fontSize: '14px',
160
+ fontWeight: 700,
161
+ boxShadow: '0 10px 20px color-mix(in srgb, var(--brand) 16%, transparent)',
162
+ };
163
+ const secondaryButtonStyle = {
164
+ minHeight: '38px',
165
+ padding: '0 16px',
166
+ border: '1px solid var(--control-border)',
167
+ background: 'var(--surface-elevated)',
168
+ color: 'var(--text-primary)',
169
+ borderRadius: '12px',
170
+ cursor: 'pointer',
171
+ fontSize: '14px',
172
+ fontWeight: 600,
173
+ boxShadow: 'var(--shadow-control)',
174
+ };
175
+ const tableShellStyle = {
176
+ background: 'var(--surface-elevated)',
177
+ border: '1px solid var(--border)',
178
+ borderRadius: '18px',
179
+ overflowX: 'auto',
180
+ boxShadow: 'var(--shadow-card)',
181
+ };
182
+ const thStyle = { padding: '0 18px', textAlign: 'left', fontSize: '12px', fontWeight: 700, color: 'var(--text-secondary)', letterSpacing: '0.02em' };
183
+ const tdStyle = { padding: '0 18px', fontSize: '14px', color: 'var(--text-primary)' };
184
+ const iconButtonStyle = (tone = 'neutral') => ({
185
+ width: '32px',
186
+ height: '32px',
187
+ borderRadius: '10px',
188
+ border: `1px solid ${tone === 'danger' ? 'color-mix(in srgb, var(--danger) 20%, var(--control-border))' : 'var(--control-border)'}`,
189
+ background: tone === 'danger'
190
+ ? 'color-mix(in srgb, var(--danger) 8%, var(--surface-elevated))'
191
+ : 'var(--surface-elevated)',
192
+ color: tone === 'danger' ? 'var(--danger)' : 'var(--text-secondary)',
193
+ cursor: 'pointer',
194
+ display: 'inline-flex',
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ boxShadow: 'var(--shadow-control)',
198
+ });
199
+ const pagerButtonStyle = (disabled) => ({
200
+ width: '36px',
201
+ height: '36px',
202
+ border: '1px solid var(--control-border)',
203
+ background: 'var(--surface-elevated)',
204
+ borderRadius: '10px',
205
+ cursor: disabled ? 'not-allowed' : 'pointer',
206
+ color: disabled ? 'var(--text-tertiary)' : 'var(--text-primary)',
207
+ boxShadow: 'var(--shadow-control)',
208
+ opacity: disabled ? 0.6 : 1,
209
+ display: 'inline-flex',
210
+ alignItems: 'center',
211
+ justifyContent: 'center',
212
+ });
213
+ const inputStyle = (field) => ({
214
+ width: '100%', padding: '10px 12px', boxSizing: 'border-box',
215
+ border: `1px solid ${formErrors[field] ? 'var(--danger)' : 'var(--control-border)'}`,
216
+ borderRadius: '10px', background: 'var(--surface-elevated)', color: 'var(--text-primary)',
217
+ fontSize: '14px', outline: 'none',
218
+ });
219
+ const labelStyle = { fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)', display: 'block', marginBottom: '6px' };
220
+
221
+ return (
222
+ <div>
223
+ <div style={headerPanelStyle}>
224
+ <div style={{ display: 'grid', gap: '4px' }}>
225
+ <h2 style={{ fontSize: '26px', fontWeight: 800, color: 'var(--text-primary)', margin: 0, letterSpacing: '-0.03em' }}>用户管理</h2>
226
+ <div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>管理账号、角色与状态,确保后台操作语义清晰而克制。</div>
227
+ </div>
228
+ <button onClick={openCreate} style={primaryButtonStyle}>
229
+ <Plus size={15} /> 新增用户
230
+ </button>
231
+ </div>
232
+
233
+ {loadingUsers ? (
234
+ <window.Skeleton rows={5} type="row" />
235
+ ) : (
236
+ <div style={tableShellStyle}>
237
+ <table style={{ width: '100%', minWidth: '780px', borderCollapse: 'collapse' }}>
238
+ <thead>
239
+ <tr style={{ background: 'var(--surface-muted)', height: '46px' }}>
240
+ <th style={thStyle}>显示名称</th>
241
+ <th style={thStyle}>用户名</th>
242
+ <th style={thStyle}>邮箱</th>
243
+ <th style={thStyle}>角色</th>
244
+ <th style={thStyle}>状态</th>
245
+ <th style={{ ...thStyle, width: '120px' }}>操作</th>
246
+ </tr>
247
+ </thead>
248
+ <tbody>
249
+ {pageData.length === 0 ? (
250
+ <tr><td colSpan={6} style={{ ...tdStyle, textAlign: 'center', height: '88px', color: 'var(--text-secondary)' }}>暂无用户</td></tr>
251
+ ) : pageData.map(user => (
252
+ <tr
253
+ key={user.id}
254
+ style={{ height: '56px', borderTop: '1px solid color-mix(in srgb, var(--border) 86%, transparent)', background: 'var(--surface-elevated)', transition: 'background 150ms ease' }}
255
+ onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-hover)'; }}
256
+ onMouseLeave={e => { e.currentTarget.style.background = 'var(--surface-elevated)'; }}
257
+ >
258
+ <td style={{ ...tdStyle, fontWeight: 600 }}>{user.displayName}</td>
259
+ <td style={{ ...tdStyle, color: 'var(--text-secondary)', fontFamily: 'monospace', fontSize: '13px' }}>{user.username}</td>
260
+ <td style={{ ...tdStyle, color: 'var(--text-tertiary)', fontSize: '13px' }}>{user.email}</td>
261
+ <td style={tdStyle}>
262
+ <span style={{
263
+ display: 'inline-flex',
264
+ alignItems: 'center',
265
+ minHeight: '28px',
266
+ padding: '0 10px',
267
+ borderRadius: '999px',
268
+ border: user.role === 'admin'
269
+ ? '1px solid color-mix(in srgb, var(--brand) 18%, var(--control-border))'
270
+ : '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)',
271
+ background: user.role === 'admin' ? 'var(--brand-soft)' : 'var(--surface-muted)',
272
+ color: user.role === 'admin' ? 'var(--brand-strong)' : 'var(--text-secondary)',
273
+ fontSize: '12px',
274
+ fontWeight: user.role === 'admin' ? 700 : 600,
275
+ }}>
276
+ {user.role === 'admin' ? '管理员' : '普通用户'}
277
+ </span>
278
+ </td>
279
+ <td style={tdStyle}>
280
+ <span style={{
281
+ display: 'inline-flex',
282
+ alignItems: 'center',
283
+ minHeight: '28px',
284
+ padding: '0 10px',
285
+ borderRadius: '999px',
286
+ border: user.status === 'active'
287
+ ? '1px solid color-mix(in srgb, var(--success) 20%, var(--control-border))'
288
+ : '1px solid color-mix(in srgb, var(--danger) 20%, var(--control-border))',
289
+ background: user.status === 'active'
290
+ ? 'color-mix(in srgb, var(--success) 10%, var(--surface-elevated))'
291
+ : 'color-mix(in srgb, var(--danger) 8%, var(--surface-elevated))',
292
+ color: user.status === 'active' ? 'var(--success)' : 'var(--danger)',
293
+ fontSize: '12px',
294
+ fontWeight: 700,
295
+ }}>
296
+ {user.status === 'active' ? '启用' : '禁用'}
297
+ </span>
298
+ </td>
299
+ <td style={tdStyle}>
300
+ <div style={{ display: 'flex', gap: '4px' }}>
301
+ <button onClick={() => openEdit(user)} title="编辑" style={iconButtonStyle('neutral')}>
302
+ <Edit2 size={14} />
303
+ </button>
304
+ <button onClick={() => { setResetTarget(user); setResetError(''); }} title="重置密码" style={iconButtonStyle('neutral')}>
305
+ <Key size={14} />
306
+ </button>
307
+ <button onClick={() => setDeleteTarget(user)} title="删除" style={iconButtonStyle('danger')}>
308
+ <Trash2 size={14} />
309
+ </button>
310
+ </div>
311
+ </td>
312
+ </tr>
313
+ ))}
314
+ </tbody>
315
+ </table>
316
+ </div>
317
+ )}
318
+
319
+ {totalPages > 1 && (
320
+ <div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
321
+ <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}
322
+ style={pagerButtonStyle(page === 1)}>
323
+ <lucide.ChevronLeft size={14} />
324
+ </button>
325
+ <span style={{ padding: '6px 12px', fontSize: '14px', color: 'var(--text-secondary)', minWidth: '72px', textAlign: 'center' }}>{page} / {totalPages}</span>
326
+ <button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}
327
+ style={pagerButtonStyle(page === totalPages)}>
328
+ <lucide.ChevronRight size={14} />
329
+ </button>
330
+ </div>
331
+ )}
332
+
333
+ {/* Create/Edit Modal */}
334
+ <window.Modal isOpen={showModal} onClose={() => setShowModal(false)} title={editTarget ? '编辑用户' : '新增用户'} width="480px">
335
+ <form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
336
+ {formErrors.general && (
337
+ <div style={{ background: 'color-mix(in srgb, var(--danger) 8%, var(--surface-elevated))', border: '1px solid color-mix(in srgb, var(--danger) 22%, var(--control-border))', borderRadius: '10px', padding: '10px 14px', marginBottom: '14px', fontSize: '13px', color: 'var(--danger)' }}>
338
+ {formErrors.general}
339
+ </div>
340
+ )}
341
+ {!editTarget && (
342
+ <div style={{ marginBottom: '14px' }}>
343
+ <label style={labelStyle}>用户名</label>
344
+ <input value={form.username} disabled={saving}
345
+ name="username"
346
+ autoComplete="username"
347
+ onChange={e => { setForm(f => ({ ...f, username: e.target.value })); setFormErrors(p => ({ ...p, username: null })); }}
348
+ style={inputStyle('username')} placeholder="仅字母数字下划线,3-20位" />
349
+ {formErrors.username && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{formErrors.username}</div>}
350
+ </div>
351
+ )}
352
+ <div style={{ marginBottom: '14px' }}>
353
+ <label style={labelStyle}>显示名称</label>
354
+ <input value={form.displayName} disabled={saving}
355
+ name="displayName"
356
+ autoComplete="name"
357
+ onChange={e => { setForm(f => ({ ...f, displayName: e.target.value })); setFormErrors(p => ({ ...p, displayName: null })); }}
358
+ style={inputStyle('displayName')} placeholder="用户的显示名称" />
359
+ {formErrors.displayName && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{formErrors.displayName}</div>}
360
+ </div>
361
+ <div style={{ marginBottom: '14px' }}>
362
+ <label style={labelStyle}>邮箱</label>
363
+ <input value={form.email} disabled={saving} type="email" name="email"
364
+ autoComplete="email"
365
+ onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFormErrors(p => ({ ...p, email: null })); }}
366
+ style={inputStyle('email')} placeholder="user@example.com" />
367
+ {formErrors.email && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{formErrors.email}</div>}
368
+ </div>
369
+ {!editTarget && (
370
+ <div style={{ marginBottom: '14px' }}>
371
+ <label style={labelStyle}>初始密码</label>
372
+ <input value={form.password} type="password" name="new-password" autoComplete="new-password" disabled={saving}
373
+ onChange={e => { setForm(f => ({ ...f, password: e.target.value })); setFormErrors(p => ({ ...p, password: null })); }}
374
+ style={inputStyle('password')} placeholder="至少8位,含字母和数字" />
375
+ {formErrors.password && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{formErrors.password}</div>}
376
+ </div>
377
+ )}
378
+ <div style={{ marginBottom: '14px' }}>
379
+ <label style={labelStyle}>角色</label>
380
+ <window.DropdownSelect
381
+ value={form.role}
382
+ onChange={(value) => setForm(f => ({ ...f, role: value }))}
383
+ disabled={saving}
384
+ ariaLabel="用户角色"
385
+ options={[
386
+ { value: 'user', label: '普通用户' },
387
+ { value: 'admin', label: '管理员' },
388
+ ]}
389
+ />
390
+ </div>
391
+ {editTarget && (
392
+ <div style={{ marginBottom: '14px', display: 'flex', alignItems: 'center', gap: '10px' }}>
393
+ <label style={{ ...labelStyle, marginBottom: 0 }}>账号状态</label>
394
+ <button
395
+ type="button"
396
+ onClick={() => setForm(f => ({ ...f, enabled: !f.enabled }))}
397
+ style={{
398
+ width: '44px', height: '24px', borderRadius: '12px', border: 'none', cursor: 'pointer',
399
+ background: form.enabled ? 'var(--brand)' : 'var(--bg-tertiary)',
400
+ position: 'relative', transition: 'background 200ms',
401
+ }}>
402
+ <div style={{
403
+ position: 'absolute', top: '2px', width: '20px', height: '20px',
404
+ borderRadius: '50%', background: '#fff',
405
+ left: form.enabled ? '22px' : '2px', transition: 'left 200ms',
406
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
407
+ }} />
408
+ </button>
409
+ <span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{form.enabled ? '启用' : '禁用'}</span>
410
+ </div>
411
+ )}
412
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>
413
+ <button type="button" onClick={() => setShowModal(false)} disabled={saving} style={secondaryButtonStyle}>取消</button>
414
+ <button type="submit" disabled={saving} style={{ ...primaryButtonStyle, opacity: saving ? 0.8 : 1, cursor: saving ? 'not-allowed' : 'pointer' }}>
415
+ {saving && <Loader size={14} />} 保存
416
+ </button>
417
+ </div>
418
+ </form>
419
+ </window.Modal>
420
+
421
+ {/* Reset Password Modal */}
422
+ <window.Modal isOpen={!!resetTarget} onClose={() => { setResetTarget(null); setResetError(''); }}
423
+ title={`重置密码 — ${resetTarget?.displayName || ''}`} width="400px">
424
+ <div>
425
+ <p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '14px' }}>
426
+ 系统将自动生成临时密码并发送至用户邮箱,用户可凭临时密码登录后自行修改。
427
+ </p>
428
+ {resetError && <div style={{ background: 'color-mix(in srgb, var(--danger) 8%, var(--surface-elevated))', border: '1px solid color-mix(in srgb, var(--danger) 22%, var(--control-border))', borderRadius: '10px', padding: '10px 14px', marginBottom: '14px', fontSize: '13px', color: 'var(--danger)' }}>{resetError}</div>}
429
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>
430
+ <button onClick={() => { setResetTarget(null); setResetError(''); }} disabled={resetLoading} style={secondaryButtonStyle}>取消</button>
431
+ <button onClick={handleResetPassword} disabled={resetLoading} style={{ ...primaryButtonStyle, opacity: resetLoading ? 0.8 : 1, cursor: resetLoading ? 'not-allowed' : 'pointer' }}>
432
+ {resetLoading && <Loader size={14} />} 确认重置
433
+ </button>
434
+ </div>
435
+ </div>
436
+ </window.Modal>
437
+
438
+ {/* Delete Confirm */}
439
+ <window.ConfirmDialog
440
+ isOpen={!!deleteTarget}
441
+ onClose={() => setDeleteTarget(null)}
442
+ onConfirm={handleDelete}
443
+ title="删除用户"
444
+ message={`确认删除用户「${deleteTarget?.displayName}」?该操作不可撤销,其名下资源将转移给当前管理员。`}
445
+ confirmText="确认删除"
446
+ loading={deleting}
447
+ />
448
+ </div>
449
+ );
450
+ }
451
+
452
+ window.AdminUsers = AdminUsers;
package/public/app.jsx ADDED
@@ -0,0 +1,186 @@
1
+ // app.jsx — Entry point: initialization + routing
2
+
3
+ function App() {
4
+ const state = window.useAppState();
5
+ const dispatch = window.useAppDispatch();
6
+ const { request } = window.useApi();
7
+ const { route, navigate } = window.useRouter();
8
+ const [initializing, setInitializing] = React.useState(true);
9
+
10
+ // Extract route info at top level (needed for useEffect below)
11
+ const { path } = route;
12
+ const isAdmin = !!(state && state.currentUser && state.currentUser.role === 'admin');
13
+ const currentToken = state ? state.token : null;
14
+ const currentUserId = state?.currentUser?.id ?? null;
15
+ const currentLocale = state?.locale || window.i18n?.getCurrentLocale?.() || 'zh-Hans';
16
+
17
+ const hydrateAuthenticatedPreferences = React.useCallback((user) => {
18
+ if (!user || !window.uiPreferences?.getUserPreferences) return;
19
+ dispatch({
20
+ type: 'HYDRATE_UI_PREFERENCES',
21
+ user,
22
+ preferences: window.uiPreferences.getUserPreferences(user.id),
23
+ });
24
+ }, [dispatch]);
25
+
26
+ // 1. App initialization (runs once on mount)
27
+ React.useEffect(() => {
28
+ if (window.i18n?.applyLocale) window.i18n.applyLocale(currentLocale);
29
+ const theme = state?.theme || window.uiPreferences?.getGuestPreferences?.().theme || 'system';
30
+ if (window.applyTheme) window.applyTheme(theme);
31
+
32
+ async function init() {
33
+ try {
34
+ // Check init status
35
+ const initRes = await request('/api/auth/init-status');
36
+ if (!initRes.ok || !initRes.data.data.initialized) {
37
+ setInitializing(false);
38
+ if (window.location.hash !== '#/setup') navigate('#/setup');
39
+ return;
40
+ }
41
+
42
+ // Load public config
43
+ const cfgRes = await request('/api/config/system');
44
+ if (cfgRes.ok) dispatch({ type: 'SET_CONFIG', config: cfgRes.data.data });
45
+
46
+ // Load categories + tags in parallel
47
+ const [catsRes, tagsRes] = await Promise.allSettled([
48
+ request('/api/categories'),
49
+ request('/api/tags'),
50
+ ]);
51
+ if (catsRes.status === 'fulfilled' && catsRes.value.ok)
52
+ dispatch({ type: 'SET_CATEGORIES', categories: catsRes.value.data.data || [] });
53
+ if (tagsRes.status === 'fulfilled' && tagsRes.value.ok)
54
+ dispatch({ type: 'SET_TAGS', tags: (tagsRes.value.data.data || []).map(t => t.tag || t) });
55
+
56
+ // Restore session
57
+ const token = sessionStorage.getItem('rh_token');
58
+ let restoredAuthenticatedSession = false;
59
+ if (token) {
60
+ const meRes = await request('/api/auth/me');
61
+ if (meRes.ok) {
62
+ dispatch({ type: 'LOGIN', user: meRes.data.data, token });
63
+ restoredAuthenticatedSession = true;
64
+ } else {
65
+ dispatch({ type: 'LOGOUT' });
66
+ }
67
+ }
68
+
69
+ if (!restoredAuthenticatedSession) {
70
+ const resourcesRes = await request('/api/resources');
71
+ if (resourcesRes.ok) {
72
+ dispatch({ type: 'SET_RESOURCES', resources: resourcesRes.data.data || [] });
73
+ }
74
+ }
75
+
76
+ } catch (e) {
77
+ console.error('[ResourceHub] Init error:', e);
78
+ }
79
+ setInitializing(false);
80
+ }
81
+
82
+ init();
83
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
84
+
85
+ React.useEffect(() => {
86
+ document.title = state?.config?.siteTitle || window.i18n?.translateText?.('资源导航系统') || '资源导航系统';
87
+ }, [currentLocale, state?.config?.siteTitle]);
88
+
89
+ // 2. Reload user data when token changes (after login/logout)
90
+ React.useEffect(() => {
91
+ if (initializing) return;
92
+ if (currentToken && currentUserId) {
93
+ // Logged in: reload resources + user-specific lists in parallel
94
+ Promise.allSettled([
95
+ request('/api/resources'),
96
+ request('/api/resources/favorites'),
97
+ request('/api/resources/history'),
98
+ request('/api/resources/mine'),
99
+ ]).then(([resRes, favRes, histRes, mineRes]) => {
100
+ if (resRes.status === 'fulfilled' && resRes.value.ok)
101
+ dispatch({ type: 'SET_RESOURCES', resources: resRes.value.data.data || [] });
102
+ if (favRes.status === 'fulfilled' && favRes.value.ok)
103
+ dispatch({ type: 'SET_FAVORITES', favorites: favRes.value.data.data || [] });
104
+ if (histRes.status === 'fulfilled' && histRes.value.ok)
105
+ dispatch({ type: 'SET_HISTORY', history: histRes.value.data.data || [] });
106
+ if (mineRes.status === 'fulfilled' && mineRes.value.ok)
107
+ dispatch({ type: 'SET_MINE', mine: mineRes.value.data.data || [] });
108
+ hydrateAuthenticatedPreferences({ id: currentUserId });
109
+ }).catch(() => {});
110
+ } else if (currentToken && !currentUserId) {
111
+ return;
112
+ } else {
113
+ // Logged out: reload public resources only; LOGOUT reducer clears favorites/history/mine
114
+ request('/api/resources').then(res => {
115
+ if (res.ok) dispatch({ type: 'SET_RESOURCES', resources: res.data.data || [] });
116
+ }).catch(() => {});
117
+ }
118
+ }, [currentToken, currentUserId, hydrateAuthenticatedPreferences, initializing]); // eslint-disable-line react-hooks/exhaustive-deps
119
+
120
+ // 3. Admin route guard(所有 /admin、/admin/* 均需管理员)
121
+ React.useEffect(() => {
122
+ if (!initializing && path.startsWith('/admin') && !isAdmin) {
123
+ navigate('#/');
124
+ }
125
+ }, [initializing, path, isAdmin, navigate]);
126
+
127
+ // 4. /admin 无子路径时重定向到类别管理
128
+ React.useEffect(() => {
129
+ if (!initializing && isAdmin && (path === '/admin' || path === '/admin/')) {
130
+ navigate('#/admin/categories');
131
+ }
132
+ }, [initializing, isAdmin, path, navigate]);
133
+
134
+ React.useEffect(() => {
135
+ if (!initializing && path === '/login' && currentToken && currentUserId) {
136
+ navigate('#/');
137
+ }
138
+ }, [initializing, path, currentToken, currentUserId, navigate]);
139
+
140
+ // Loading screen
141
+ if (initializing) {
142
+ return (
143
+ <div style={{
144
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
145
+ height: '100vh', background: 'var(--bg-primary)',
146
+ }}>
147
+ <div style={{ textAlign: 'center' }}>
148
+ <div style={{
149
+ width: '52px', height: '52px', borderRadius: '14px',
150
+ background: 'var(--brand)', color: '#fff',
151
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
152
+ fontSize: '22px', fontWeight: 700, margin: '0 auto 16px',
153
+ }}>R</div>
154
+ <p style={{ color: 'var(--text-secondary)', fontSize: '14px', margin: 0 }}>加载中…</p>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ // Router
161
+ if (path === '/setup') return <window.SetupPage />;
162
+ if (path === '/login') {
163
+ if (currentToken && currentUserId) return null;
164
+ return <window.LoginPage />;
165
+ }
166
+ if (path === '/register') return <window.RegisterPage />;
167
+ if (path === '/forgot-password') return <window.ForgotPasswordPage />;
168
+ if (path === '/reset-password') return <window.ResetPasswordPage />;
169
+ if (path.startsWith('/admin')) {
170
+ if (!isAdmin) return null;
171
+ if (path === '/admin' || path === '/admin/') return null;
172
+ return <window.AdminPage />;
173
+ }
174
+ if (path === '/resources') return <window.HomePage pageType="results" />;
175
+
176
+ return <window.HomePage pageType="overview" />;
177
+ }
178
+
179
+ // Mount React root
180
+ const rootEl = document.getElementById('root');
181
+ const root = ReactDOM.createRoot(rootEl);
182
+ root.render(
183
+ <window.AppProvider>
184
+ <App />
185
+ </window.AppProvider>
186
+ );