@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,279 @@
1
+ function AdminEmail() {
2
+ const dispatch = window.useAppDispatch();
3
+ const { request } = window.useApi();
4
+ const { t } = window.useI18n();
5
+ const { Save, Loader, Send, Eye, EyeOff } = lucide;
6
+
7
+ const [form, setForm] = React.useState({
8
+ smtpHost: '',
9
+ smtpPort: 465,
10
+ encryption: 'ssl',
11
+ smtpUser: '',
12
+ smtpPassword: '***',
13
+ fromName: '',
14
+ fromEmail: '',
15
+ });
16
+ const [errors, setErrors] = React.useState({});
17
+ const [loading, setLoading] = React.useState(true);
18
+ const [saving, setSaving] = React.useState(false);
19
+ const [testing, setTesting] = React.useState(false);
20
+ const [showPassword, setShowPassword] = React.useState(false);
21
+ const localizableConfigValues = new Set(['资源导航系统']);
22
+ const getLocalizedConfigValue = (value) => (localizableConfigValues.has(value) ? t(value) : value);
23
+
24
+ React.useEffect(() => {
25
+ async function load() {
26
+ const { ok, data } = await request('/api/config/email');
27
+ if (ok && data.data) {
28
+ const cfg = data.data;
29
+ setForm({
30
+ smtpHost: cfg.smtpHost || '',
31
+ smtpPort: cfg.smtpPort ?? 465,
32
+ encryption: cfg.encryption || 'ssl',
33
+ smtpUser: cfg.smtpUser || '',
34
+ smtpPassword: cfg.smtpPassword || '***',
35
+ fromName: cfg.fromName || '',
36
+ fromEmail: cfg.fromEmail || '',
37
+ });
38
+ }
39
+ setLoading(false);
40
+ }
41
+ load();
42
+ }, []);
43
+
44
+ const handleSave = async () => {
45
+ setSaving(true);
46
+ setErrors({});
47
+ try {
48
+ const { ok, data } = await request('/api/config/email', {
49
+ method: 'PUT',
50
+ body: JSON.stringify({
51
+ smtpHost: form.smtpHost,
52
+ smtpPort: Number(form.smtpPort),
53
+ encryption: form.encryption,
54
+ smtpUser: form.smtpUser,
55
+ smtpPassword: form.smtpPassword,
56
+ fromName: form.fromName,
57
+ fromEmail: form.fromEmail,
58
+ }),
59
+ });
60
+ if (ok) {
61
+ setForm(f => ({ ...f, smtpPassword: '***' }));
62
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '邮件配置已保存' });
63
+ } else {
64
+ if (data.code === 'VALIDATION_ERROR' && data.fields) {
65
+ setErrors(data.fields);
66
+ }
67
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '保存失败' });
68
+ }
69
+ } finally {
70
+ setSaving(false);
71
+ }
72
+ };
73
+
74
+ const handleTest = async () => {
75
+ setTesting(true);
76
+ try {
77
+ const { ok, data } = await request('/api/config/email/test', { method: 'POST' });
78
+ if (ok) {
79
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: '测试邮件已发送' });
80
+ if (data.emailPreview) dispatch({ type: 'SET_EMAIL_PREVIEW', emailPreview: data.emailPreview });
81
+ } else {
82
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '发送失败' });
83
+ }
84
+ } finally {
85
+ setTesting(false);
86
+ }
87
+ };
88
+
89
+ const inputStyle = (field) => ({
90
+ width: '100%', padding: '9px 12px', boxSizing: 'border-box',
91
+ border: `1px solid ${errors[field] ? 'var(--danger)' : 'var(--control-border)'}`,
92
+ borderRadius: '10px',
93
+ background: 'var(--surface-elevated)',
94
+ backgroundColor: 'var(--surface-elevated)',
95
+ color: 'var(--text-primary)',
96
+ fontSize: '14px', outline: 'none',
97
+ });
98
+ const labelStyle = { fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)', display: 'block', marginBottom: '6px' };
99
+ const fieldStyle = { marginBottom: '20px' };
100
+ const headerPanelStyle = {
101
+ display: 'flex',
102
+ justifyContent: 'space-between',
103
+ alignItems: 'center',
104
+ marginBottom: '18px',
105
+ gap: '12px',
106
+ flexWrap: 'wrap',
107
+ padding: '18px 20px',
108
+ background: 'var(--surface-elevated)',
109
+ border: '1px solid var(--border)',
110
+ borderRadius: '18px',
111
+ boxShadow: 'var(--shadow-card)',
112
+ };
113
+ const secondaryButtonStyle = {
114
+ display: 'inline-flex',
115
+ alignItems: 'center',
116
+ gap: '6px',
117
+ minHeight: '38px',
118
+ padding: '0 16px',
119
+ border: '1px solid var(--control-border)',
120
+ background: 'var(--surface-elevated)',
121
+ color: 'var(--text-primary)',
122
+ borderRadius: '12px',
123
+ cursor: 'pointer',
124
+ fontSize: '14px',
125
+ fontWeight: 600,
126
+ boxShadow: 'var(--shadow-control)',
127
+ };
128
+ const primaryButtonStyle = {
129
+ display: 'inline-flex',
130
+ alignItems: 'center',
131
+ gap: '6px',
132
+ minHeight: '38px',
133
+ padding: '0 18px',
134
+ background: 'var(--brand)',
135
+ color: '#fff',
136
+ border: '1px solid var(--brand)',
137
+ borderRadius: '12px',
138
+ cursor: 'pointer',
139
+ fontSize: '14px',
140
+ fontWeight: 700,
141
+ boxShadow: '0 10px 20px color-mix(in srgb, var(--brand) 16%, transparent)',
142
+ };
143
+ const panelStyle = {
144
+ background: 'var(--surface-elevated)',
145
+ border: '1px solid var(--border)',
146
+ borderRadius: '18px',
147
+ padding: '24px',
148
+ maxWidth: '720px',
149
+ boxShadow: 'var(--shadow-card)',
150
+ };
151
+
152
+ if (loading) {
153
+ return (
154
+ <div>
155
+ <h2 style={{ fontSize: '18px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '20px' }}>邮件服务</h2>
156
+ <window.Skeleton rows={6} type="form" />
157
+ </div>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
163
+ <div style={headerPanelStyle}>
164
+ <div style={{ display: 'grid', gap: '4px' }}>
165
+ <h2 style={{ fontSize: '26px', fontWeight: 800, color: 'var(--text-primary)', margin: 0, letterSpacing: '-0.03em' }}>邮件服务</h2>
166
+ <div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>管理 SMTP 连接与发件人信息,保持后台配置区的层级和交互一致。</div>
167
+ </div>
168
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
169
+ <button type="button" onClick={handleTest} disabled={testing || saving} style={{ ...secondaryButtonStyle, opacity: (testing || saving) ? 0.7 : 1, cursor: (testing || saving) ? 'not-allowed' : 'pointer' }}>
170
+ {testing ? <Loader size={15} /> : <Send size={15} />}
171
+ 测试连接
172
+ </button>
173
+ <button type="submit" disabled={saving} style={{ ...primaryButtonStyle, opacity: saving ? 0.8 : 1, cursor: saving ? 'not-allowed' : 'pointer' }}>
174
+ {saving ? <Loader size={15} /> : <Save size={15} />}
175
+ 保存配置
176
+ </button>
177
+ </div>
178
+ </div>
179
+
180
+ {!form.smtpHost && (
181
+ <div style={{
182
+ background: 'color-mix(in srgb, var(--brand-soft) 78%, var(--surface-elevated))',
183
+ border: '1px solid color-mix(in srgb, var(--brand) 16%, var(--control-border))',
184
+ borderRadius: '14px',
185
+ padding: '12px 16px',
186
+ marginBottom: '18px',
187
+ fontSize: '13px',
188
+ color: 'var(--text-secondary)',
189
+ boxShadow: 'var(--shadow-control)',
190
+ }}>
191
+ SMTP 主机为空时,系统使用模拟邮件模式:邮件不会真实发送,仅在前端弹窗预览。
192
+ </div>
193
+ )}
194
+
195
+ <div style={panelStyle}>
196
+ <div style={fieldStyle}>
197
+ <label style={labelStyle}>SMTP 主机</label>
198
+ <input className="rh-admin-input" value={form.smtpHost} onChange={e => setForm(f => ({ ...f, smtpHost: e.target.value }))}
199
+ style={inputStyle('smtpHost')} placeholder="smtp.example.com(留空使用模拟模式)" disabled={saving} />
200
+ {errors.smtpHost && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.smtpHost}</div>}
201
+ </div>
202
+
203
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', ...fieldStyle }}>
204
+ <div>
205
+ <label style={labelStyle}>SMTP 端口</label>
206
+ <input className="rh-admin-input" type="number" value={form.smtpPort} min={1} max={65535}
207
+ onChange={e => setForm(f => ({ ...f, smtpPort: e.target.value }))}
208
+ style={inputStyle('smtpPort')} placeholder="465" disabled={saving} />
209
+ {errors.smtpPort && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.smtpPort}</div>}
210
+ </div>
211
+ <div>
212
+ <label style={labelStyle}>加密方式</label>
213
+ <window.DropdownSelect
214
+ value={form.encryption}
215
+ onChange={(value) => setForm(f => ({ ...f, encryption: value }))}
216
+ disabled={saving}
217
+ ariaLabel="加密方式"
218
+ options={[
219
+ { value: 'ssl', label: 'SSL' },
220
+ { value: 'tls', label: 'TLS' },
221
+ { value: 'none', label: '无加密' },
222
+ ]}
223
+ />
224
+ {errors.encryption && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.encryption}</div>}
225
+ </div>
226
+ </div>
227
+
228
+ <div style={fieldStyle}>
229
+ <label style={labelStyle}>SMTP 用户名</label>
230
+ <input className="rh-admin-input" value={form.smtpUser} onChange={e => setForm(f => ({ ...f, smtpUser: e.target.value }))}
231
+ style={inputStyle('smtpUser')} placeholder="your@email.com" disabled={saving} />
232
+ {errors.smtpUser && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.smtpUser}</div>}
233
+ </div>
234
+
235
+ <div style={fieldStyle}>
236
+ <label style={labelStyle}>SMTP 密码</label>
237
+ <div style={{ position: 'relative' }}>
238
+ <input
239
+ className="rh-admin-input"
240
+ type={showPassword ? 'text' : 'password'}
241
+ autoComplete="off"
242
+ value={form.smtpPassword}
243
+ onChange={e => setForm(f => ({ ...f, smtpPassword: e.target.value }))}
244
+ style={{ ...inputStyle('smtpPassword'), paddingRight: '40px' }}
245
+ placeholder="保持 *** 则不更改当前密码"
246
+ disabled={saving}
247
+ />
248
+ <button type="button" onClick={() => setShowPassword(v => !v)}
249
+ style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', display: 'flex' }}>
250
+ {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
251
+ </button>
252
+ </div>
253
+ {errors.smtpPassword && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.smtpPassword}</div>}
254
+ <div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginTop: '4px' }}>填写 `***` 表示不修改当前已保存的密码</div>
255
+ </div>
256
+
257
+ <div style={{ borderTop: '1px solid var(--border)', paddingTop: '20px', marginTop: '4px' }}>
258
+ <div style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', color: 'var(--text-secondary)', letterSpacing: '0.05em', marginBottom: '16px' }}>发件人信息</div>
259
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px' }}>
260
+ <div style={fieldStyle}>
261
+ <label style={labelStyle}>发件人名称</label>
262
+ <input className="rh-admin-input" value={getLocalizedConfigValue(form.fromName)} onChange={e => setForm(f => ({ ...f, fromName: e.target.value }))}
263
+ style={inputStyle('fromName')} placeholder="资源导航系统" disabled={saving} />
264
+ {errors.fromName && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.fromName}</div>}
265
+ </div>
266
+ <div style={fieldStyle}>
267
+ <label style={labelStyle}>发件人邮箱</label>
268
+ <input className="rh-admin-input" value={form.fromEmail} onChange={e => setForm(f => ({ ...f, fromEmail: e.target.value }))}
269
+ style={inputStyle('fromEmail')} placeholder="noreply@example.com" disabled={saving} />
270
+ {errors.fromEmail && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: '4px' }}>{errors.fromEmail}</div>}
271
+ </div>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ </form>
276
+ );
277
+ }
278
+
279
+ window.AdminEmail = AdminEmail;
@@ -0,0 +1,263 @@
1
+ function AdminTags() {
2
+ const state = window.useAppState();
3
+ const dispatch = window.useAppDispatch();
4
+ const { request } = window.useApi();
5
+ const { Trash2, Loader } = lucide;
6
+
7
+ const [tags, setTags] = React.useState([]); // { tag, count }[]
8
+ const [loadingTags, setLoadingTags] = React.useState(true);
9
+ const [deleteTarget, setDeleteTarget] = React.useState(null);
10
+ const [deleting, setDeleting] = React.useState(false);
11
+ const [page, setPage] = React.useState(1);
12
+ const PAGE_SIZE = 20;
13
+ const [checked, setChecked] = React.useState(new Set()); // selected tag names for batch delete
14
+ const [batchDeleting, setBatchDeleting] = React.useState(false);
15
+ const [showBatchConfirm, setShowBatchConfirm] = React.useState(false);
16
+
17
+ const loadTags = async () => {
18
+ setLoadingTags(true);
19
+ try {
20
+ const { ok, data } = await request('/api/tags');
21
+ if (ok) setTags(data.data || []);
22
+ } finally {
23
+ setLoadingTags(false);
24
+ }
25
+ };
26
+
27
+ React.useEffect(() => { loadTags(); }, []);
28
+
29
+ const handleDelete = async () => {
30
+ if (!deleteTarget) return;
31
+ setDeleting(true);
32
+ try {
33
+ const { ok, data } = await request(`/api/tags/${encodeURIComponent(deleteTarget.tag)}`, { method: 'DELETE' });
34
+ if (ok) {
35
+ setTags(prev => prev.filter(t => t.tag !== deleteTarget.tag));
36
+ setChecked(prev => { const s = new Set(prev); s.delete(deleteTarget.tag); return s; });
37
+ dispatch({ type: 'SET_TAGS', tags: state.tags.filter(t => t !== deleteTarget.tag) });
38
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: `标签「${deleteTarget.tag}」已删除` });
39
+ setDeleteTarget(null);
40
+ } else {
41
+ dispatch({ type: 'ADD_TOAST', toastType: 'error', message: data.error || '删除失败' });
42
+ }
43
+ } finally {
44
+ setDeleting(false);
45
+ }
46
+ };
47
+
48
+ const handleBatchDelete = async () => {
49
+ setBatchDeleting(true);
50
+ const toDelete = [...checked];
51
+ let successCount = 0;
52
+ for (const tag of toDelete) {
53
+ const { ok } = await request(`/api/tags/${encodeURIComponent(tag)}`, { method: 'DELETE' });
54
+ if (ok) successCount++;
55
+ }
56
+ setTags(prev => prev.filter(t => !checked.has(t.tag)));
57
+ dispatch({ type: 'SET_TAGS', tags: state.tags.filter(t => !checked.has(t)) });
58
+ setChecked(new Set());
59
+ setShowBatchConfirm(false);
60
+ setBatchDeleting(false);
61
+ dispatch({ type: 'ADD_TOAST', toastType: 'success', message: `已删除 ${successCount} 个标签` });
62
+ };
63
+
64
+ const totalPages = Math.ceil(tags.length / PAGE_SIZE);
65
+ const pageData = tags.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
66
+
67
+ const headerPanelStyle = {
68
+ display: 'flex',
69
+ justifyContent: 'space-between',
70
+ alignItems: 'center',
71
+ gap: '12px',
72
+ flexWrap: 'wrap',
73
+ marginBottom: '18px',
74
+ padding: '18px 20px',
75
+ background: 'var(--surface-elevated)',
76
+ border: '1px solid var(--border)',
77
+ borderRadius: '18px',
78
+ boxShadow: 'var(--shadow-card)',
79
+ };
80
+ const secondaryButtonStyle = {
81
+ display: 'inline-flex',
82
+ alignItems: 'center',
83
+ gap: '6px',
84
+ minHeight: '38px',
85
+ padding: '0 14px',
86
+ border: '1px solid var(--control-border)',
87
+ background: 'var(--surface-elevated)',
88
+ color: 'var(--text-primary)',
89
+ borderRadius: '12px',
90
+ cursor: 'pointer',
91
+ fontSize: '14px',
92
+ fontWeight: 600,
93
+ boxShadow: 'var(--shadow-control)',
94
+ };
95
+ const dangerButtonStyle = {
96
+ ...secondaryButtonStyle,
97
+ border: '1px solid color-mix(in srgb, var(--danger) 20%, var(--control-border))',
98
+ background: 'color-mix(in srgb, var(--danger) 8%, var(--surface-elevated))',
99
+ color: 'var(--danger)',
100
+ };
101
+ const tableShellStyle = {
102
+ background: 'var(--surface-elevated)',
103
+ border: '1px solid var(--border)',
104
+ borderRadius: '18px',
105
+ overflowX: 'auto',
106
+ boxShadow: 'var(--shadow-card)',
107
+ };
108
+ const thStyle = { padding: '0 18px', textAlign: 'left', fontSize: '12px', fontWeight: 700, color: 'var(--text-secondary)', letterSpacing: '0.02em' };
109
+ const tdStyle = { padding: '0 18px', fontSize: '14px', color: 'var(--text-primary)' };
110
+ const iconButtonStyle = (tone = 'neutral') => ({
111
+ width: '32px',
112
+ height: '32px',
113
+ borderRadius: '10px',
114
+ border: `1px solid ${tone === 'danger' ? 'color-mix(in srgb, var(--danger) 20%, var(--control-border))' : 'var(--control-border)'}`,
115
+ background: tone === 'danger'
116
+ ? 'color-mix(in srgb, var(--danger) 8%, var(--surface-elevated))'
117
+ : 'var(--surface-elevated)',
118
+ color: tone === 'danger' ? 'var(--danger)' : 'var(--text-secondary)',
119
+ cursor: 'pointer',
120
+ display: 'inline-flex',
121
+ alignItems: 'center',
122
+ justifyContent: 'center',
123
+ boxShadow: 'var(--shadow-control)',
124
+ });
125
+ const pagerButtonStyle = (disabled) => ({
126
+ width: '36px',
127
+ height: '36px',
128
+ border: '1px solid var(--control-border)',
129
+ background: 'var(--surface-elevated)',
130
+ borderRadius: '10px',
131
+ cursor: disabled ? 'not-allowed' : 'pointer',
132
+ color: disabled ? 'var(--text-tertiary)' : 'var(--text-primary)',
133
+ boxShadow: 'var(--shadow-control)',
134
+ opacity: disabled ? 0.6 : 1,
135
+ display: 'inline-flex',
136
+ alignItems: 'center',
137
+ justifyContent: 'center',
138
+ });
139
+
140
+ const allPageChecked = pageData.length > 0 && pageData.every(item => checked.has(item.tag));
141
+
142
+ const toggleAll = () => {
143
+ if (allPageChecked) {
144
+ setChecked(prev => { const s = new Set(prev); pageData.forEach(item => s.delete(item.tag)); return s; });
145
+ } else {
146
+ setChecked(prev => { const s = new Set(prev); pageData.forEach(item => s.add(item.tag)); return s; });
147
+ }
148
+ };
149
+
150
+ return (
151
+ <div>
152
+ <div style={headerPanelStyle}>
153
+ <div style={{ display: 'grid', gap: '4px' }}>
154
+ <h2 style={{ fontSize: '26px', fontWeight: 800, color: 'var(--text-primary)', margin: 0, letterSpacing: '-0.03em' }}>标签管理</h2>
155
+ <div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>集中管理资源标签,保持筛选体系简洁、可读且一致。</div>
156
+ </div>
157
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
158
+ {checked.size > 0 && (
159
+ <button onClick={() => setShowBatchConfirm(true)} style={dangerButtonStyle}>
160
+ <Trash2 size={14} /> 批量删除 ({checked.size})
161
+ </button>
162
+ )}
163
+ <button onClick={loadTags} style={secondaryButtonStyle}>
164
+ <lucide.RefreshCw size={14} /> 刷新
165
+ </button>
166
+ </div>
167
+ </div>
168
+
169
+ {loadingTags ? (
170
+ <window.Skeleton rows={5} type="row" />
171
+ ) : (
172
+ <div style={tableShellStyle}>
173
+ <table style={{ width: '100%', minWidth: '520px', borderCollapse: 'collapse' }}>
174
+ <thead>
175
+ <tr style={{ background: 'var(--surface-muted)', height: '46px' }}>
176
+ <th style={{ ...thStyle, width: '44px' }}>
177
+ <input type="checkbox" checked={allPageChecked} onChange={toggleAll}
178
+ style={{ cursor: 'pointer', accentColor: 'var(--brand)' }} />
179
+ </th>
180
+ <th style={thStyle}>标签名</th>
181
+ <th style={thStyle}>使用次数</th>
182
+ <th style={{ ...thStyle, width: '80px' }}>操作</th>
183
+ </tr>
184
+ </thead>
185
+ <tbody>
186
+ {pageData.length === 0 ? (
187
+ <tr><td colSpan={4} style={{ ...tdStyle, textAlign: 'center', height: '88px', color: 'var(--text-secondary)' }}>暂无标签</td></tr>
188
+ ) : pageData.map(item => (
189
+ <tr
190
+ key={item.tag}
191
+ style={{ height: '56px', borderTop: '1px solid color-mix(in srgb, var(--border) 86%, transparent)', background: 'var(--surface-elevated)', transition: 'background 150ms ease' }}
192
+ onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-hover)'; }}
193
+ onMouseLeave={e => { e.currentTarget.style.background = 'var(--surface-elevated)'; }}
194
+ >
195
+ <td style={{ ...tdStyle, width: '44px' }}>
196
+ <input type="checkbox" checked={checked.has(item.tag)}
197
+ onChange={() => setChecked(prev => { const s = new Set(prev); s.has(item.tag) ? s.delete(item.tag) : s.add(item.tag); return s; })}
198
+ style={{ cursor: 'pointer', accentColor: 'var(--brand)' }} />
199
+ </td>
200
+ <td style={tdStyle}>
201
+ <span style={{
202
+ display: 'inline-flex',
203
+ alignItems: 'center',
204
+ minHeight: '28px',
205
+ background: 'var(--surface-muted)',
206
+ color: 'var(--text-secondary)',
207
+ border: '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)',
208
+ borderRadius: '999px',
209
+ padding: '0 10px',
210
+ fontSize: '13px',
211
+ }}>{item.tag}</span>
212
+ </td>
213
+ <td style={{ ...tdStyle, fontWeight: 600 }}>{item.count}</td>
214
+ <td style={tdStyle}>
215
+ <button onClick={() => setDeleteTarget(item)} style={iconButtonStyle('danger')}>
216
+ <Trash2 size={14} />
217
+ </button>
218
+ </td>
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ </div>
224
+ )}
225
+
226
+ {totalPages > 1 && (
227
+ <div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
228
+ <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}
229
+ style={pagerButtonStyle(page === 1)}>
230
+ <lucide.ChevronLeft size={14} />
231
+ </button>
232
+ <span style={{ padding: '6px 12px', fontSize: '14px', color: 'var(--text-secondary)', minWidth: '72px', textAlign: 'center' }}>{page} / {totalPages}</span>
233
+ <button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}
234
+ style={pagerButtonStyle(page === totalPages)}>
235
+ <lucide.ChevronRight size={14} />
236
+ </button>
237
+ </div>
238
+ )}
239
+
240
+ <window.ConfirmDialog
241
+ isOpen={!!deleteTarget}
242
+ onClose={() => setDeleteTarget(null)}
243
+ onConfirm={handleDelete}
244
+ title="删除标签"
245
+ message={`确认删除标签「${deleteTarget?.tag}」?该标签将从所有资源中移除。`}
246
+ confirmText="确认删除"
247
+ loading={deleting}
248
+ />
249
+
250
+ <window.ConfirmDialog
251
+ isOpen={showBatchConfirm}
252
+ onClose={() => setShowBatchConfirm(false)}
253
+ onConfirm={handleBatchDelete}
254
+ title="批量删除标签"
255
+ message={`确认删除选中的 ${checked.size} 个标签?这些标签将从所有资源中移除。`}
256
+ confirmText="确认删除"
257
+ loading={batchDeleting}
258
+ />
259
+ </div>
260
+ );
261
+ }
262
+
263
+ window.AdminTags = AdminTags;