Compare commits

...

3 commits

Author SHA1 Message Date
CDN
8f807ac2a8
ci: init deploy ci
Some checks failed
Deploy / Deploy (push) Failing after 13s
2025-02-03 13:21:24 +08:00
CDN
be80282695
feat: cache update index and contents 2025-02-03 13:13:02 +08:00
CDN
2daa1b9879
fix: include data directory during building 2025-02-03 11:39:40 +08:00
8 changed files with 337 additions and 159 deletions

View file

@ -0,0 +1,36 @@
name: Deploy
on:
push:
branches: [ "main" ]
workflow_dispatch:
jobs:
deploy:
name: Deploy
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm run build
- name: Deploy to Remote
run: |
if [ ! -d ~/.ssh ]; then
mkdir -p ~/.ssh
fi
chmod 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
dnf install rsync -y
rsync -av --delete -e "ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes -p ${{ secrets.SSH_PORT }}" dist/ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:${{ secrets.WEB_ROOT }}/sstmirror-homepage
- name: Clean up
run: |
rm -rf ~/.ssh

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.0", "@ant-design/icons": "^5.6.0",
"@tanstack/react-query": "^5.66.0",
"antd": "^5.23.3", "antd": "^5.23.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^23.16.8", "i18next": "^23.16.8",
@ -26,6 +27,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/fs-extra": "^11.0.4",
"@types/glob": "^8.1.0", "@types/glob": "^8.1.0",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
@ -34,6 +36,7 @@
"eslint": "^9.19.0", "eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18", "eslint-plugin-react-refresh": "^0.4.18",
"fs-extra": "^11.3.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"globals": "^15.14.0", "globals": "^15.14.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",

69
pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
'@ant-design/icons': '@ant-design/icons':
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 5.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.66.0
version: 5.66.0(react@18.3.1)
antd: antd:
specifier: ^5.23.3 specifier: ^5.23.3
version: 5.23.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 5.23.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -51,6 +54,9 @@ importers:
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.16 specifier: ^0.5.16
version: 0.5.16(tailwindcss@3.4.17) version: 0.5.16(tailwindcss@3.4.17)
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
'@types/glob': '@types/glob':
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.1.0 version: 8.1.0
@ -75,6 +81,9 @@ importers:
eslint-plugin-react-refresh: eslint-plugin-react-refresh:
specifier: ^0.4.18 specifier: ^0.4.18
version: 0.4.18(eslint@9.19.0(jiti@1.21.7)) version: 0.4.18(eslint@9.19.0(jiti@1.21.7))
fs-extra:
specifier: ^11.3.0
version: 11.3.0
glob: glob:
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.0.1 version: 11.0.1
@ -766,6 +775,14 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/query-core@5.66.0':
resolution: {integrity: sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==}
'@tanstack/react-query@5.66.0':
resolution: {integrity: sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==}
peerDependencies:
react: ^18 || ^19
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -781,12 +798,18 @@ packages:
'@types/estree@1.0.6': '@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
'@types/fs-extra@11.0.4':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
'@types/glob@8.1.0': '@types/glob@8.1.0':
resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonfile@6.1.4':
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
'@types/minimatch@5.1.2': '@types/minimatch@5.1.2':
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
@ -1141,6 +1164,10 @@ packages:
fraction.js@4.3.7: fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
fs-extra@11.3.0:
resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==}
engines: {node: '>=14.14'}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1185,6 +1212,9 @@ packages:
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==} resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
engines: {node: '>=18'} engines: {node: '>=18'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphemer@1.4.0: graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
@ -1284,6 +1314,9 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -1938,6 +1971,10 @@ packages:
undici-types@6.20.0: undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
update-browserslist-db@1.1.2: update-browserslist-db@1.1.2:
resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==}
hasBin: true hasBin: true
@ -2563,6 +2600,13 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 3.4.17 tailwindcss: 3.4.17
'@tanstack/query-core@5.66.0': {}
'@tanstack/react-query@5.66.0(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.66.0
react: 18.3.1
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.26.7 '@babel/parser': 7.26.7
@ -2586,6 +2630,11 @@ snapshots:
'@types/estree@1.0.6': {} '@types/estree@1.0.6': {}
'@types/fs-extra@11.0.4':
dependencies:
'@types/jsonfile': 6.1.4
'@types/node': 22.13.0
'@types/glob@8.1.0': '@types/glob@8.1.0':
dependencies: dependencies:
'@types/minimatch': 5.1.2 '@types/minimatch': 5.1.2
@ -2593,6 +2642,10 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/jsonfile@6.1.4':
dependencies:
'@types/node': 22.13.0
'@types/minimatch@5.1.2': {} '@types/minimatch@5.1.2': {}
'@types/node@22.13.0': '@types/node@22.13.0':
@ -3077,6 +3130,12 @@ snapshots:
fraction.js@4.3.7: {} fraction.js@4.3.7: {}
fs-extra@11.3.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.1
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@ -3120,6 +3179,8 @@ snapshots:
globals@15.14.0: {} globals@15.14.0: {}
graceful-fs@4.2.11: {}
graphemer@1.4.0: {} graphemer@1.4.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
@ -3201,6 +3262,12 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@ -3928,6 +3995,8 @@ snapshots:
undici-types@6.20.0: {} undici-types@6.20.0: {}
universalify@2.0.1: {}
update-browserslist-db@1.1.2(browserslist@4.24.4): update-browserslist-db@1.1.2(browserslist@4.24.4):
dependencies: dependencies:
browserslist: 4.24.4 browserslist: 4.24.4

View file

@ -10,11 +10,25 @@ import UpdateDetailPage from './pages/UpdateDetailPage';
import ContributorsPage from './pages/ContributorsPage'; import ContributorsPage from './pages/ContributorsPage';
import AboutPage from './pages/AboutPage'; import AboutPage from './pages/AboutPage';
import iconMap from './utils/iconMap'; import iconMap from './utils/iconMap';
import { useQueryClient } from '@tanstack/react-query';
function App() { function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const socialLinks = t('social.links', { returnObjects: true }); const socialLinks = t('social.links', { returnObjects: true });
// 在窗口关闭时清除缓存
React.useEffect(() => {
const handleBeforeUnload = () => {
queryClient.clear();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [queryClient]);
return ( return (
<ThemeProvider> <ThemeProvider>
<Router> <Router>

View file

@ -61,52 +61,31 @@ const Pagination: React.FC<PaginationProps> = ({
const Timeline = () => { const Timeline = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [updates, setUpdates] = useState<Update[]>([]);
const [pagination, setPagination] = useState({
currentPage: 1,
totalPages: 1,
totalItems: 0,
hasNextPage: false,
hasPrevPage: false
});
// 获取当前页码和标签筛选 // 获取当前页码和标签筛选
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const selectedTags = searchParams.get('tags')?.split(',').filter(Boolean) || []; const selectedTags = searchParams.get('tags')?.split(',').filter(Boolean) || [];
const { getPaginatedUpdates } = useUpdates(currentPage);
// 使用 React Query hook
const { getPaginatedUpdates, isLoading, error } = useUpdates(currentPage);
// 获取分页数据
const { updates, pagination } = getPaginatedUpdates(selectedTags);
// 获取所有可用的标签 // 获取所有可用的标签
const availableTags = Object.keys(t('updates.tags', { returnObjects: true })); const availableTags = Object.keys(t('updates.tags', { returnObjects: true }));
// 当页码超出范围时自动调整
useEffect(() => { useEffect(() => {
const fetchUpdates = async () => { if (pagination.totalPages > 0 && currentPage > pagination.totalPages) {
try {
const result = await getPaginatedUpdates(selectedTags);
setUpdates(result.updates);
setPagination(result.pagination);
// 如果当前页码超出范围,自动调整到最后一页
if (currentPage > result.pagination.totalPages) {
setSearchParams((prev) => { setSearchParams((prev) => {
prev.set('page', result.pagination.totalPages.toString()); prev.set('page', pagination.totalPages.toString());
return prev; return prev;
}); });
} }
} catch (error) { }, [pagination.totalPages, currentPage, setSearchParams]);
console.error('Failed to fetch updates:', error);
setUpdates([]);
setPagination({
currentPage: 1,
totalPages: 1,
totalItems: 0,
hasNextPage: false,
hasPrevPage: false
});
}
};
fetchUpdates();
}, [currentPage, selectedTags, i18n.language, getPaginatedUpdates, setSearchParams]);
// 处理页码变化
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setSearchParams((prev) => { setSearchParams((prev) => {
prev.set('page', page.toString()); prev.set('page', page.toString());
@ -114,7 +93,8 @@ const Timeline = () => {
}); });
}; };
const handleTagsChange = (tags: string[]) => { // 处理标签变化
const handleTagChange = (tags: string[]) => {
setSearchParams((prev) => { setSearchParams((prev) => {
if (tags.length > 0) { if (tags.length > 0) {
prev.set('tags', tags.join(',')); prev.set('tags', tags.join(','));
@ -126,6 +106,14 @@ const Timeline = () => {
}); });
}; };
if (error) {
return (
<div className="text-center py-20">
<p className="text-red-500">{t('updates.error')}</p>
</div>
);
}
return ( return (
<div className="container mx-auto px-8 md:px-20 lg:px-32 xl:px-48 2xl:px-64"> <div className="container mx-auto px-8 md:px-20 lg:px-32 xl:px-48 2xl:px-64">
<div className="max-w-5xl"> <div className="max-w-5xl">
@ -137,7 +125,7 @@ const Timeline = () => {
<TagFilter <TagFilter
availableTags={availableTags} availableTags={availableTags}
selectedTags={selectedTags} selectedTags={selectedTags}
onTagsChange={handleTagsChange} onChange={handleTagChange}
/> />
</div> </div>
@ -145,6 +133,12 @@ const Timeline = () => {
{/* Timeline line */} {/* Timeline line */}
<div className="absolute left-6 top-0 h-full w-0.5 bg-blue-200 dark:bg-blue-900"></div> <div className="absolute left-6 top-0 h-full w-0.5 bg-blue-200 dark:bg-blue-900"></div>
{isLoading ? (
<div className="text-center py-20">
<p>{t('updates.loading')}</p>
</div>
) : updates.length > 0 ? (
<>
<div className="space-y-8"> <div className="space-y-8">
{updates.map((update) => ( {updates.map((update) => (
<div key={update.id} className="relative flex items-start group"> <div key={update.id} className="relative flex items-start group">
@ -171,7 +165,7 @@ const Timeline = () => {
{update.tags.map((tag) => ( {update.tags.map((tag) => (
<button <button
key={tag} key={tag}
onClick={() => handleTagsChange([...selectedTags, tag])} onClick={() => handleTagChange([...selectedTags, tag])}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${ className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selectedTags.includes(tag) selectedTags.includes(tag)
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
@ -188,7 +182,7 @@ const Timeline = () => {
))} ))}
</div> </div>
{pagination.totalPages > 0 && ( {pagination.totalPages > 1 && (
<Pagination <Pagination
currentPage={pagination.currentPage} currentPage={pagination.currentPage}
totalPages={pagination.totalPages} totalPages={pagination.totalPages}
@ -197,6 +191,12 @@ const Timeline = () => {
hasPrevPage={pagination.hasPrevPage} hasPrevPage={pagination.hasPrevPage}
/> />
)} )}
</>
) : (
<div className="text-center py-20">
<p>{t('updates.no_results')}</p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -3,9 +3,20 @@ import { createRoot } from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import './i18n'; import './i18n';
import './index.css'; import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity, // 数据永不过期,除非手动使其失效
},
},
});
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}>
<App /> <App />
</QueryClientProvider>
</StrictMode> </StrictMode>
); );

View file

@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
export interface Update { export interface Update {
id: string; id: string;
@ -17,13 +18,10 @@ interface UpdatesIndex {
years: string[]; years: string[];
} }
export const useUpdates = (page: number = 1, pageSize: number = 10) => { // 获取更新年份列表
const { i18n } = useTranslation(); const getUpdateYears = async (language: string): Promise<string[]> => {
// 获取更新年份列表
const getUpdateYears = async (): Promise<string[]> => {
try { try {
const response = await fetch(`/data/${i18n.language}/updates.json`); const response = await fetch(`/data/${language}/updates.json`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load updates index, status: ${response.status}`); throw new Error(`Failed to load updates index, status: ${response.status}`);
} }
@ -35,12 +33,12 @@ export const useUpdates = (page: number = 1, pageSize: number = 10) => {
} }
return []; return [];
} }
}; };
// 动态获取指定的年度更新 // 动态获取指定的年度更新
const getYearUpdates = async (yearFile: string): Promise<YearUpdates | null> => { const getYearUpdates = async (yearFile: string, language: string): Promise<YearUpdates | null> => {
try { try {
const response = await fetch(`/data/${i18n.language}/updates/${yearFile}`); const response = await fetch(`/data/${language}/updates/${yearFile}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load updates from ${yearFile}, status: ${response.status}`); throw new Error(`Failed to load updates from ${yearFile}, status: ${response.status}`);
} }
@ -52,18 +50,18 @@ export const useUpdates = (page: number = 1, pageSize: number = 10) => {
} }
return null; return null;
} }
}; };
// 获取所有更新并按日期排序 // 获取所有更新并按日期排序
const getAllUpdates = async (selectedTags: string[] = []): Promise<Update[]> => { const getAllUpdates = async (language: string, selectedTags: string[] = []): Promise<Update[]> => {
const allUpdates: Update[] = []; const allUpdates: Update[] = [];
// 从索引文件获取年份列表 // 从索引文件获取年份列表
const yearFiles = await getUpdateYears(); const yearFiles = await getUpdateYears(language);
// 加载所有年份的更新 // 加载所有年份的更新
for (const yearFile of yearFiles) { for (const yearFile of yearFiles) {
const yearData = await getYearUpdates(yearFile); const yearData = await getYearUpdates(yearFile, language);
if (yearData?.updates?.length) { if (yearData?.updates?.length) {
// 如果指定了标签,只添加包含所有选定标签的更新 // 如果指定了标签,只添加包含所有选定标签的更新
const filteredUpdates = selectedTags.length > 0 const filteredUpdates = selectedTags.length > 0
@ -78,12 +76,26 @@ export const useUpdates = (page: number = 1, pageSize: number = 10) => {
// 按日期降序排序 // 按日期降序排序
return allUpdates.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return allUpdates.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}; };
// 获取分页的更新列表 // 获取分页的更新列表
const getPaginatedUpdates = async (selectedTags: string[] = []) => { export const useUpdates = (page: number = 1, pageSize: number = 10) => {
const allUpdates = await getAllUpdates(selectedTags); const { i18n } = useTranslation();
const totalItems = allUpdates.length;
const { data: allUpdates = [], isLoading, error } = useQuery({
queryKey: ['updates', i18n.language],
queryFn: () => getAllUpdates(i18n.language),
});
const getPaginatedUpdates = (selectedTags: string[] = []) => {
// 过滤标签
const filteredUpdates = selectedTags.length > 0
? allUpdates.filter(update =>
selectedTags.every(tag => update.tags.includes(tag))
)
: allUpdates;
const totalItems = filteredUpdates.length;
// 如果没有更新,返回空结果 // 如果没有更新,返回空结果
if (totalItems === 0) { if (totalItems === 0) {
@ -99,26 +111,29 @@ export const useUpdates = (page: number = 1, pageSize: number = 10) => {
}; };
} }
// 计算分页信息
const totalPages = Math.ceil(totalItems / pageSize); const totalPages = Math.ceil(totalItems / pageSize);
const safeCurrentPage = Math.min(Math.max(1, page), totalPages); const normalizedPage = Math.min(Math.max(1, page), totalPages);
const startIndex = (normalizedPage - 1) * pageSize;
const start = (safeCurrentPage - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, totalItems);
const end = Math.min(start + pageSize, totalItems);
const updates = allUpdates.slice(start, end);
return { return {
updates, updates: filteredUpdates.slice(startIndex, endIndex),
pagination: { pagination: {
currentPage: safeCurrentPage, currentPage: normalizedPage,
totalPages, totalPages,
totalItems, totalItems,
hasNextPage: safeCurrentPage < totalPages, hasNextPage: normalizedPage < totalPages,
hasPrevPage: safeCurrentPage > 1 hasPrevPage: normalizedPage > 1
} }
}; };
}; };
return { getPaginatedUpdates, getAllUpdates }; return {
getPaginatedUpdates,
isLoading,
error
};
}; };
export const getUpdateUrl = (update: Update): string => { export const getUpdateUrl = (update: Update): string => {

View file

@ -1,10 +1,32 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import { Plugin } from 'vite';
import fs from 'fs-extra';
// 复制 data 目录
function copyDataPlugin(): Plugin {
return {
name: 'copy-data',
async closeBundle() {
const source = path.resolve(__dirname, 'data');
const destination = path.resolve(__dirname, 'dist/data');
try {
await fs.copy(source, destination);
console.log('Copied data directory from ' + source + ' to ' + destination);
} catch (error) {
console.error('Error copying data directory:', error);
}
}
};
}
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
copyDataPlugin()
],
optimizeDeps: { optimizeDeps: {
exclude: ['lucide-react'], exclude: ['lucide-react'],
}, },
@ -13,4 +35,12 @@ export default defineConfig({
'@': path.resolve(__dirname, './'), '@': path.resolve(__dirname, './'),
}, },
}, },
build: {
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
},
},
copyPublicDir: true, // 复制 public 目录
},
}); });