@zhang_libo/resource-hub 1.0.2 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +13 -3
- package/README.ja.md +12 -2
- package/README.md +12 -2
- package/README.zh-TW.md +12 -2
- package/dist/app.js +4 -1
- package/dist/app.js.map +1 -1
- package/dist/db/migrate.js +136 -111
- package/dist/db/migrate.js.map +1 -1
- package/package.json +70 -73
- package/public/app.jsx +174 -174
- package/public/features/ChangePasswordModal.jsx +198 -187
- package/public/features/ResourceFormModal.jsx +915 -915
- package/public/layout/AppLayout.jsx +119 -119
- package/public/layout/Sidebar.jsx +1 -1
- package/public/pages/LoginPage.jsx +10 -0
- package/public/pages/SetupPage.jsx +1 -1
- package/public/utils/security.jsx +65 -28
- package/public/vendor/forge.min.js +2 -0
- package/dist/app.d.ts +0 -2
- package/dist/app.d.ts.map +0 -1
- package/dist/db/migrate.d.ts +0 -3
- package/dist/db/migrate.d.ts.map +0 -1
- package/dist/db/schema.d.ts +0 -743
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/plugins/admin.d.ts +0 -4
- package/dist/plugins/admin.d.ts.map +0 -1
- package/dist/plugins/auth.d.ts +0 -4
- package/dist/plugins/auth.d.ts.map +0 -1
- package/dist/routes/auth.d.ts +0 -4
- package/dist/routes/auth.d.ts.map +0 -1
- package/dist/routes/categories.d.ts +0 -4
- package/dist/routes/categories.d.ts.map +0 -1
- package/dist/routes/config.d.ts +0 -4
- package/dist/routes/config.d.ts.map +0 -1
- package/dist/routes/resources.d.ts +0 -4
- package/dist/routes/resources.d.ts.map +0 -1
- package/dist/routes/tags.d.ts +0 -4
- package/dist/routes/tags.d.ts.map +0 -1
- package/dist/routes/users.d.ts +0 -4
- package/dist/routes/users.d.ts.map +0 -1
- package/dist/services/crypto.js +0 -49
- package/dist/services/crypto.js.map +0 -1
- package/dist/services/mail.d.ts +0 -16
- package/dist/services/mail.d.ts.map +0 -1
- package/dist/services/token.d.ts +0 -9
- package/dist/services/token.d.ts.map +0 -1
- package/dist/types.d.ts +0 -80
- package/dist/types.d.ts.map +0 -1
package/public/app.jsx
CHANGED
|
@@ -1,186 +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');
|
|
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
13
|
const currentToken = state ? state.token : null;
|
|
14
14
|
const currentUserId = state?.currentUser?.id ?? null;
|
|
15
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)
|
|
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
27
|
React.useEffect(() => {
|
|
28
28
|
if (window.i18n?.applyLocale) window.i18n.applyLocale(currentLocale);
|
|
29
29
|
const theme = state?.theme || window.uiPreferences?.getGuestPreferences?.().theme || 'system';
|
|
30
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();
|
|
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
83
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
84
|
|
|
85
85
|
React.useEffect(() => {
|
|
86
86
|
document.title = state?.config?.siteTitle || window.i18n?.translateText?.('资源导航系统') || '资源导航系统';
|
|
87
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
|
-
);
|
|
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
|
+
);
|