diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..ed77e02 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/package.json b/package.json index b090947..09cfa03 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@ant-design/icons": "^5.6.0", + "@tanstack/react-query": "^5.66.0", "antd": "^5.23.3", "clsx": "^2.1.1", "i18next": "^23.16.8", @@ -26,6 +27,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@tailwindcss/typography": "^0.5.16", + "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", @@ -34,6 +36,7 @@ "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.18", + "fs-extra": "^11.3.0", "glob": "^11.0.1", "globals": "^15.14.0", "postcss": "^8.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7923a1..16e3736 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@ant-design/icons': specifier: ^5.6.0 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: specifier: ^5.23.3 version: 5.23.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -51,6 +54,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17) + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 '@types/glob': specifier: ^8.1.0 version: 8.1.0 @@ -75,6 +81,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.18 version: 0.4.18(eslint@9.19.0(jiti@1.21.7)) + fs-extra: + specifier: ^11.3.0 + version: 11.3.0 glob: specifier: ^11.0.1 version: 11.0.1 @@ -766,6 +775,14 @@ packages: peerDependencies: 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': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -781,12 +798,18 @@ packages: '@types/estree@1.0.6': 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': resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonfile@6.1.4': + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/minimatch@5.1.2': resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} @@ -1141,6 +1164,10 @@ packages: fraction.js@4.3.7: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1185,6 +1212,9 @@ packages: resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==} engines: {node: '>=18'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1284,6 +1314,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1938,6 +1971,10 @@ packages: undici-types@6.20.0: 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: resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} hasBin: true @@ -2563,6 +2600,13 @@ snapshots: postcss-selector-parser: 6.0.10 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': dependencies: '@babel/parser': 7.26.7 @@ -2586,6 +2630,11 @@ snapshots: '@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': dependencies: '@types/minimatch': 5.1.2 @@ -2593,6 +2642,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonfile@6.1.4': + dependencies: + '@types/node': 22.13.0 + '@types/minimatch@5.1.2': {} '@types/node@22.13.0': @@ -3077,6 +3130,12 @@ snapshots: 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: optional: true @@ -3120,6 +3179,8 @@ snapshots: globals@15.14.0: {} + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-flag@4.0.0: {} @@ -3201,6 +3262,12 @@ snapshots: json5@2.2.3: {} + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3928,6 +3995,8 @@ snapshots: undici-types@6.20.0: {} + universalify@2.0.1: {} + update-browserslist-db@1.1.2(browserslist@4.24.4): dependencies: browserslist: 4.24.4 diff --git a/src/App.tsx b/src/App.tsx index 5b5a642..697f503 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,11 +10,25 @@ import UpdateDetailPage from './pages/UpdateDetailPage'; import ContributorsPage from './pages/ContributorsPage'; import AboutPage from './pages/AboutPage'; import iconMap from './utils/iconMap'; +import { useQueryClient } from '@tanstack/react-query'; function App() { const { t } = useTranslation(); + const queryClient = useQueryClient(); const socialLinks = t('social.links', { returnObjects: true }); + // 在窗口关闭时清除缓存 + React.useEffect(() => { + const handleBeforeUnload = () => { + queryClient.clear(); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [queryClient]); + return ( diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 104aad7..e9157e1 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -61,52 +61,31 @@ const Pagination: React.FC = ({ const Timeline = () => { const { t, i18n } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); - const [updates, setUpdates] = useState([]); - 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 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 })); + // 当页码超出范围时自动调整 useEffect(() => { - const fetchUpdates = async () => { - try { - const result = await getPaginatedUpdates(selectedTags); - setUpdates(result.updates); - setPagination(result.pagination); - - // 如果当前页码超出范围,自动调整到最后一页 - 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]); + if (pagination.totalPages > 0 && currentPage > pagination.totalPages) { + setSearchParams((prev) => { + prev.set('page', pagination.totalPages.toString()); + return prev; + }); + } + }, [pagination.totalPages, currentPage, setSearchParams]); + // 处理页码变化 const handlePageChange = (page: number) => { setSearchParams((prev) => { prev.set('page', page.toString()); @@ -114,7 +93,8 @@ const Timeline = () => { }); }; - const handleTagsChange = (tags: string[]) => { + // 处理标签变化 + const handleTagChange = (tags: string[]) => { setSearchParams((prev) => { if (tags.length > 0) { prev.set('tags', tags.join(',')); @@ -126,6 +106,14 @@ const Timeline = () => { }); }; + if (error) { + return ( +
+

{t('updates.error')}

+
+ ); + } + return (
@@ -137,7 +125,7 @@ const Timeline = () => {
@@ -145,57 +133,69 @@ const Timeline = () => { {/* Timeline line */}
-
- {updates.map((update) => ( -
- {/* Timeline dot */} -
- - {/* Content */} -
- {update.date} - -

- {update.title} - {update.link && ( - - )} -

- -

{update.summary}

-
- {update.tags.map((tag) => ( - - ))} +

+ {update.title} + {update.link && ( + + )} +

+ +

{update.summary}

+
+ {update.tags.map((tag) => ( + + ))} +
+
+
-
-
+ ))}
- ))} -
- {pagination.totalPages > 0 && ( - + {pagination.totalPages > 1 && ( + + )} + + ) : ( +
+

{t('updates.no_results')}

+
)} diff --git a/src/main.tsx b/src/main.tsx index b59339e..679b2c5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,20 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import './i18n'; import './index.css'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, // 数据永不过期,除非手动使其失效 + }, + }, +}); createRoot(document.getElementById('root')!).render( - + + + ); \ No newline at end of file diff --git a/src/utils/updates.ts b/src/utils/updates.ts index e5ab3ef..b292fb6 100644 --- a/src/utils/updates.ts +++ b/src/utils/updates.ts @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; export interface Update { id: string; @@ -17,73 +18,84 @@ interface UpdatesIndex { years: string[]; } +// 获取更新年份列表 +const getUpdateYears = async (language: string): Promise => { + 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 => { + 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 => { + 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) => { const { i18n } = useTranslation(); - // 获取更新年份列表 - const getUpdateYears = async (): Promise => { - try { - 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 { data: allUpdates = [], isLoading, error } = useQuery({ + queryKey: ['updates', i18n.language], + queryFn: () => getAllUpdates(i18n.language), + }); - // 动态获取指定的年度更新 - const getYearUpdates = async (yearFile: string): Promise => { - try { - const response = await fetch(`/data/${i18n.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 getPaginatedUpdates = (selectedTags: string[] = []) => { + // 过滤标签 + const filteredUpdates = selectedTags.length > 0 + ? allUpdates.filter(update => + selectedTags.every(tag => update.tags.includes(tag)) + ) + : allUpdates; - // 获取所有更新并按日期排序 - const getAllUpdates = async (selectedTags: string[] = []): Promise => { - 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; + const totalItems = filteredUpdates.length; // 如果没有更新,返回空结果 if (totalItems === 0) { @@ -99,26 +111,29 @@ export const useUpdates = (page: number = 1, pageSize: number = 10) => { }; } + // 计算分页信息 const totalPages = Math.ceil(totalItems / pageSize); - const safeCurrentPage = Math.min(Math.max(1, page), totalPages); - - const start = (safeCurrentPage - 1) * pageSize; - const end = Math.min(start + pageSize, totalItems); - const updates = allUpdates.slice(start, end); + const normalizedPage = Math.min(Math.max(1, page), totalPages); + const startIndex = (normalizedPage - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, totalItems); return { - updates, + updates: filteredUpdates.slice(startIndex, endIndex), pagination: { - currentPage: safeCurrentPage, + currentPage: normalizedPage, totalPages, totalItems, - hasNextPage: safeCurrentPage < totalPages, - hasPrevPage: safeCurrentPage > 1 + hasNextPage: normalizedPage < totalPages, + hasPrevPage: normalizedPage > 1 } }; }; - return { getPaginatedUpdates, getAllUpdates }; + return { + getPaginatedUpdates, + isLoading, + error + }; }; export const getUpdateUrl = (update: Update): string => { diff --git a/vite.config.ts b/vite.config.ts index 381e84e..755f8de 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,32 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; 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/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + copyDataPlugin() + ], optimizeDeps: { exclude: ['lucide-react'], }, @@ -13,4 +35,12 @@ export default defineConfig({ '@': path.resolve(__dirname, './'), }, }, + build: { + rollupOptions: { + input: { + main: path.resolve(__dirname, 'index.html'), + }, + }, + copyPublicDir: true, // 复制 public 目录 + }, });