Compare commits
3 commits
7a27c16d95
...
8f807ac2a8
Author | SHA1 | Date | |
---|---|---|---|
8f807ac2a8 | |||
be80282695 | |||
2daa1b9879 |
8 changed files with 337 additions and 159 deletions
36
.forgejo/workflows/deploy.yml
Normal file
36
.forgejo/workflows/deploy.yml
Normal 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
|
|
@ -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
69
pnpm-lock.yaml
generated
|
@ -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
|
||||||
|
|
14
src/App.tsx
14
src/App.tsx
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
setSearchParams((prev) => {
|
||||||
const result = await getPaginatedUpdates(selectedTags);
|
prev.set('page', pagination.totalPages.toString());
|
||||||
setUpdates(result.updates);
|
return prev;
|
||||||
setPagination(result.pagination);
|
});
|
||||||
|
}
|
||||||
// 如果当前页码超出范围,自动调整到最后一页
|
}, [pagination.totalPages, currentPage, setSearchParams]);
|
||||||
if (currentPage > result.pagination.totalPages) {
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
prev.set('page', result.pagination.totalPages.toString());
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
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,57 +133,69 @@ 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>
|
||||||
|
|
||||||
<div className="space-y-8">
|
{isLoading ? (
|
||||||
{updates.map((update) => (
|
<div className="text-center py-20">
|
||||||
<div key={update.id} className="relative flex items-start group">
|
<p>{t('updates.loading')}</p>
|
||||||
{/* Timeline dot */}
|
</div>
|
||||||
<div className="absolute left-6 transform -translate-x-1/2 w-2.5 h-2.5 bg-blue-600 dark:bg-blue-500 rounded-full mt-2 group-hover:scale-125 transition-transform"></div>
|
) : updates.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{updates.map((update) => (
|
||||||
|
<div key={update.id} className="relative flex items-start group">
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div className="absolute left-6 transform -translate-x-1/2 w-2.5 h-2.5 bg-blue-600 dark:bg-blue-500 rounded-full mt-2 group-hover:scale-125 transition-transform"></div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="ml-16 w-full">
|
<div className="ml-16 w-full">
|
||||||
<span className="text-sm text-blue-600 dark:text-blue-400 font-medium block mb-1">{update.date}</span>
|
<span className="text-sm text-blue-600 dark:text-blue-400 font-medium block mb-1">{update.date}</span>
|
||||||
<Link
|
<Link
|
||||||
to={getUpdateUrl(update)}
|
to={getUpdateUrl(update)}
|
||||||
target={update.link ? "_blank" : undefined}
|
target={update.link ? "_blank" : undefined}
|
||||||
className="group/link"
|
className="group/link"
|
||||||
>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2 hover:text-blue-600 dark:hover:text-blue-400 inline-flex items-center">
|
|
||||||
{update.title}
|
|
||||||
{update.link && (
|
|
||||||
<ExternalLink className="w-4 h-4 ml-1 opacity-0 group-hover/link:opacity-100 transition-opacity" />
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
</Link>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">{update.summary}</p>
|
|
||||||
<div className="mt-4 flex items-center space-x-2">
|
|
||||||
{update.tags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
onClick={() => handleTagsChange([...selectedTags, tag])}
|
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
|
||||||
selectedTags.includes(tag)
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t(`updates.tags.${tag}`)}
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2 hover:text-blue-600 dark:hover:text-blue-400 inline-flex items-center">
|
||||||
</button>
|
{update.title}
|
||||||
))}
|
{update.link && (
|
||||||
|
<ExternalLink className="w-4 h-4 ml-1 opacity-0 group-hover/link:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">{update.summary}</p>
|
||||||
|
<div className="mt-4 flex items-center space-x-2">
|
||||||
|
{update.tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => handleTagChange([...selectedTags, tag])}
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
selectedTags.includes(tag)
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`updates.tags.${tag}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 border-b border-gray-100 dark:border-gray-800"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 border-b border-gray-100 dark:border-gray-800"></div>
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination.totalPages > 0 && (
|
{pagination.totalPages > 1 && (
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={pagination.currentPage}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={pagination.totalPages}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
hasNextPage={pagination.hasNextPage}
|
hasNextPage={pagination.hasNextPage}
|
||||||
hasPrevPage={pagination.hasPrevPage}
|
hasPrevPage={pagination.hasPrevPage}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p>{t('updates.no_results')}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
13
src/main.tsx
13
src/main.tsx
|
@ -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>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
|
@ -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,73 +18,84 @@ interface UpdatesIndex {
|
||||||
years: string[];
|
years: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取更新年份列表
|
||||||
|
const getUpdateYears = async (language: string): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/data/${language}/updates.json`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load updates index, status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const index: UpdatesIndex = await response.json();
|
||||||
|
return index.years || [];
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn(`Failed to load updates index:`, error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 动态获取指定的年度更新
|
||||||
|
const getYearUpdates = async (yearFile: string, language: string): Promise<YearUpdates | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/data/${language}/updates/${yearFile}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load updates from ${yearFile}, status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: YearUpdates = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn(`Failed to load updates from ${yearFile}:`, error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取所有更新并按日期排序
|
||||||
|
const getAllUpdates = async (language: string, selectedTags: string[] = []): Promise<Update[]> => {
|
||||||
|
const allUpdates: Update[] = [];
|
||||||
|
|
||||||
|
// 从索引文件获取年份列表
|
||||||
|
const yearFiles = await getUpdateYears(language);
|
||||||
|
|
||||||
|
// 加载所有年份的更新
|
||||||
|
for (const yearFile of yearFiles) {
|
||||||
|
const yearData = await getYearUpdates(yearFile, language);
|
||||||
|
if (yearData?.updates?.length) {
|
||||||
|
// 如果指定了标签,只添加包含所有选定标签的更新
|
||||||
|
const filteredUpdates = selectedTags.length > 0
|
||||||
|
? yearData.updates.filter(update =>
|
||||||
|
selectedTags.every(tag => update.tags.includes(tag))
|
||||||
|
)
|
||||||
|
: yearData.updates;
|
||||||
|
|
||||||
|
allUpdates.push(...filteredUpdates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期降序排序
|
||||||
|
return allUpdates.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取分页的更新列表
|
||||||
export const useUpdates = (page: number = 1, pageSize: number = 10) => {
|
export const useUpdates = (page: number = 1, pageSize: number = 10) => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
// 获取更新年份列表
|
const { data: allUpdates = [], isLoading, error } = useQuery({
|
||||||
const getUpdateYears = async (): Promise<string[]> => {
|
queryKey: ['updates', i18n.language],
|
||||||
try {
|
queryFn: () => getAllUpdates(i18n.language),
|
||||||
const response = await fetch(`/data/${i18n.language}/updates.json`);
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load updates index, status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const index: UpdatesIndex = await response.json();
|
|
||||||
return index.years || [];
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn(`Failed to load updates index:`, error);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 动态获取指定的年度更新
|
const getPaginatedUpdates = (selectedTags: string[] = []) => {
|
||||||
const getYearUpdates = async (yearFile: string): Promise<YearUpdates | null> => {
|
// 过滤标签
|
||||||
try {
|
const filteredUpdates = selectedTags.length > 0
|
||||||
const response = await fetch(`/data/${i18n.language}/updates/${yearFile}`);
|
? allUpdates.filter(update =>
|
||||||
if (!response.ok) {
|
selectedTags.every(tag => update.tags.includes(tag))
|
||||||
throw new Error(`Failed to load updates from ${yearFile}, status: ${response.status}`);
|
)
|
||||||
}
|
: allUpdates;
|
||||||
const data: YearUpdates = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn(`Failed to load updates from ${yearFile}:`, error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取所有更新并按日期排序
|
const totalItems = filteredUpdates.length;
|
||||||
const getAllUpdates = async (selectedTags: string[] = []): Promise<Update[]> => {
|
|
||||||
const allUpdates: Update[] = [];
|
|
||||||
|
|
||||||
// 从索引文件获取年份列表
|
|
||||||
const yearFiles = await getUpdateYears();
|
|
||||||
|
|
||||||
// 加载所有年份的更新
|
|
||||||
for (const yearFile of yearFiles) {
|
|
||||||
const yearData = await getYearUpdates(yearFile);
|
|
||||||
if (yearData?.updates?.length) {
|
|
||||||
// 如果指定了标签,只添加包含所有选定标签的更新
|
|
||||||
const filteredUpdates = selectedTags.length > 0
|
|
||||||
? yearData.updates.filter(update =>
|
|
||||||
selectedTags.every(tag => update.tags.includes(tag))
|
|
||||||
)
|
|
||||||
: yearData.updates;
|
|
||||||
|
|
||||||
allUpdates.push(...filteredUpdates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按日期降序排序
|
|
||||||
return allUpdates.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取分页的更新列表
|
|
||||||
const getPaginatedUpdates = async (selectedTags: string[] = []) => {
|
|
||||||
const allUpdates = await getAllUpdates(selectedTags);
|
|
||||||
const totalItems = allUpdates.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 => {
|
||||||
|
|
|
@ -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 目录
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue