chore: merge delta-force-guide-web into web/ (main)

git-subtree-dir: web
git-subtree-mainline: 694bebe2c8
git-subtree-split: b000336d22
This commit is contained in:
siujamo committed 2026-08-28 09:38:10 +08:00
commit 8776f45684
43 files changed
+3846

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
README.md
View File
Whitespace-only changes.
+27
View File
@@ -0,0 +1,27 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore: "
groups:
dependency-updates:
patterns: ["*"]
update-types: ["minor", "patch"]
ignore:
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+288
View File
@@ -0,0 +1,288 @@
### macOS
# General
.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Linux
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# Metadata left by Dolphin file manager, which comes with KDE Plasma
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# Log files created by default by the nohup command
nohup.out
### Windows
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### JetBrains IDE
# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea
# Gradle and Maven with auto-import
*.iml
*.ipr
# File-based project format
*.iws
# IntelliJ
out/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based HTTP Client
http-client.private.env.json
### Gradle
.gradle
**/build/
!**/src/**/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Avoid ignore Gradle wrappper properties
!gradle-wrapper.properties
# Cache of project
.gradletasknamecache
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
### Java
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### Node.js
# Logs
logs
#*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
#.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+24
View File
@@ -0,0 +1,24 @@
printWidth: 100
tabWidth: 2
useTabs: false
semi: false
singleQuote: false
quoteProps: as-needed
jsxSingleQuote: false
trailingComma: es5
bracketSpacing: true
bracketSameLine: true
arrowParens: always
#rangeStart: 0
#rangeEnd: n
#parser:
#filepath:
#requirePragma: false
#insertPragma: false
#proseWrap: preserve
htmlWhitespaceSensitivity: css
#vueIndentScriptAndStyle: false
endOfLine: lf
#embeddedLanguageFormatting: auto
# Enforce single attribute per line in HTML, Vue and JSX
#singleAttributePerLine: false
+22
View File
@@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"request": "launch",
"name": "pnpm dev",
"command": "pnpm dev",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "build",
"command": "pnpm build",
"cwd": "${workspaceFolder}"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
FROM node:20-alpine as builder
WORKDIR /app
COPY . .
RUN corepack enable pnpm
RUN pnpm install
RUN pnpm build
FROM nginx:1.27 as runner
COPY --from=builder /app/dist/ /usr/share/nginx/html/
+21
View File
@@ -0,0 +1,21 @@
MIT Licence
Copyright (c) 2026 Zihlu Wang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+96
View File
@@ -0,0 +1,96 @@
# Delta Force Firearm Modification Codes
Delta Force Firearm Modification Codes is a lightweight web app for browsing and filtering firearm modification codes for Delta Force.
The site is built with Vite, React, TypeScript, Tailwind CSS, and React Router. It presents a searchable code library with filtering by weapon, mode, and tag, and includes quick copy support for each modification code.
## Features
- Browse a curated list of firearm modification codes.
- Filter results by weapon, mode, and tag.
- Copy modification codes directly from the interface.
- Render large lists efficiently with window virtualisation.
- Deploy as a static site.
## Tech Stack
- Vite
- React 19
- TypeScript
- Tailwind CSS 4
- React Router 7
- @tanstack/react-virtual
- Day.js
## Getting Started
### Prerequisites
- Node.js 20 or later is recommended.
- pnpm is required for dependency management and scripts.
### Install dependencies
```bash
pnpm install
```
### Start the development server
```bash
pnpm dev
```
### Build for production
```bash
pnpm build
```
### Preview the production build locally
```bash
pnpm preview
```
## Available Scripts
- `pnpm dev`: start the Vite development server.
- `pnpm build`: run TypeScript compilation and create a production build.
- `pnpm preview`: preview the production bundle locally.
- `pnpm lint`: run project linting.
- `pnpm deploy`: build and publish the site with `gh-pages`.
## Project Structure
```text
src/
components/ Shared UI components
data/ Modification code dataset
init/ Application initialisation
layout/ Route layouts
page/ Route pages
router/ Router configuration
```
The current dataset is stored in `src/data/modification-codes.json`.
## Deployment
The repository is configured for static deployment. The `public/CNAME` file indicates the site is intended to be served on `onixbyte.dev`.
To deploy:
```bash
pnpm deploy
```
## Contributing
Contributions are welcome. If you want to improve the dataset, refine the filtering experience, or fix UI issues, open an issue or submit a pull request.
When contributing, please keep documentation and user-facing copy in British English.
## Licence
This project is released under the MIT Licence. See `LICENCE` for details.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/onixbyte.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>三角洲行动改枪码库</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
{
"name": "delta-force-guide-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:tar": "pnpm build && tar -czf dist.tar.gz dist",
"lint": "eslint .",
"preview": "vite preview",
"deploy": "pnpm build && gh-pages -d dist",
"predeploy": "pnpm build"
},
"dependencies": {
"@ant-design/cssinjs": "^2.1.2",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-virtual": "^3.13.23",
"antd": "^6.3.5",
"axios": "^1.14.0",
"dayjs": "^1.11.20",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-redux": "^9.2.0",
"react-router": "^7.14.0",
"react-router-dom": "^7.14.0",
"redux-persist": "^6.0.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vite": "^8.0.8"
},
"pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"@tailwindcss/oxide"
]
}
}
+2362
View File
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
onixbyte.dev
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/></defs><rect x="-37.5" width="450" fill="#ffffff" y="-37.499999" height="449.999989" fill-opacity="1"/><rect x="-37.5" width="450" fill="#324370" y="-37.499999" height="449.999989" fill-opacity="1"/><g fill="#ffffff" fill-opacity="1"><g transform="translate(27.506677, 211.874986)"><g><path d="M 12.359375 -0.453125 C 8.003906 -1.816406 5.003906 -4.3125 3.359375 -7.9375 C 1.710938 -11.5625 1.550781 -16.253906 2.875 -22.015625 C 4.195312 -27.773438 6.519531 -32.46875 9.84375 -36.09375 C 13.175781 -39.71875 17.34375 -42.210938 22.34375 -43.578125 Z M 27.578125 -34.109375 L 30.015625 -44.796875 L 31.875 -44.796875 C 39.46875 -44.796875 44.882812 -42.851562 48.125 -38.96875 C 51.363281 -35.09375 52.128906 -29.441406 50.421875 -22.015625 C 48.722656 -14.585938 45.351562 -8.929688 40.3125 -5.046875 C 35.28125 -1.171875 28.96875 0.765625 21.375 0.765625 C 20.519531 0.765625 19.898438 0.742188 19.515625 0.703125 L 21.953125 -9.921875 C 22.722656 -9.835938 23.34375 -9.796875 23.8125 -9.796875 C 26.84375 -9.796875 29.367188 -10.710938 31.390625 -12.546875 C 33.421875 -14.378906 34.796875 -16.851562 35.515625 -19.96875 L 36.421875 -24.0625 C 37.140625 -27.175781 36.910156 -29.648438 35.734375 -31.484375 C 34.566406 -33.316406 32.46875 -34.234375 29.4375 -34.234375 C 28.96875 -34.234375 28.347656 -34.191406 27.578125 -34.109375 Z M 27.578125 -34.109375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(80.815405, 211.874986)"><g><path d="M 31.171875 -34.5625 C 28.816406 -34.5625 26.363281 -34.003906 23.8125 -32.890625 L 21.953125 -24.890625 C 22.460938 -25.066406 23.0625 -25.15625 23.75 -25.15625 C 25.15625 -25.15625 26.125 -24.71875 26.65625 -23.84375 C 27.1875 -22.96875 27.28125 -21.804688 26.9375 -20.359375 L 22.265625 0 L 35 0 L 40.0625 -21.890625 C 41 -25.984375 40.726562 -29.117188 39.25 -31.296875 C 37.78125 -33.472656 35.085938 -34.5625 31.171875 -34.5625 Z M 17.28125 -19.96875 L 17.796875 -28.671875 L 18.171875 -33.796875 L 7.75 -33.796875 L -0.0625 0 L 12.671875 0 L 17.21875 -19.84375 Z M 17.28125 -19.96875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(123.500788, 211.874986)"><g><path d="M 19.515625 -33.796875 L 11.703125 0 L -1.03125 0 L 6.78125 -33.796875 Z M 22.46875 -46.40625 L 20.484375 -37.765625 L 7.75 -37.765625 L 9.71875 -46.40625 Z M 22.46875 -46.40625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(144.811483, 211.874986)"><g><path d="M 45.5625 -33.796875 L 29.953125 -17.59375 L 38.53125 0 L 23.671875 0 L 19.703125 -10.625 L 19.453125 -10.625 L 10.5625 0 L -3.578125 0 L 13.5625 -17.921875 L 5.5 -33.796875 L 20.484375 -33.796875 L 23.8125 -24.828125 L 24.0625 -24.828125 L 31.484375 -33.796875 Z M 45.5625 -33.796875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(187.496867, 211.874986)"><g><path d="M 24 -44.03125 L 13.890625 0 L -0.25 0 L 9.859375 -44.03125 Z M 29.25 -34.375 L 31.421875 -44.03125 L 39.171875 -44.03125 C 42.578125 -44.03125 45.21875 -43.003906 47.09375 -40.953125 C 48.976562 -38.910156 49.554688 -36.332031 48.828125 -33.21875 C 47.585938 -27.800781 44.347656 -24.382812 39.109375 -22.96875 L 39.109375 -22.71875 C 44.390625 -21.4375 46.328125 -17.769531 44.921875 -11.71875 C 44.109375 -8.257812 42.21875 -5.441406 39.25 -3.265625 C 36.289062 -1.085938 33.019531 0 29.4375 0 L 21.3125 0 L 23.671875 -10.234375 L 27 -10.234375 C 27.601562 -10.234375 28.179688 -10.382812 28.734375 -10.6875 C 29.285156 -10.988281 29.765625 -11.40625 30.171875 -11.9375 C 30.578125 -12.46875 30.863281 -13.054688 31.03125 -13.703125 L 31.171875 -14.34375 C 31.421875 -15.320312 31.300781 -16.140625 30.8125 -16.796875 C 30.320312 -17.460938 29.628906 -17.796875 28.734375 -17.796875 L 25.40625 -17.796875 L 27.515625 -26.875 L 29.828125 -26.875 C 30.722656 -26.875 31.5625 -27.203125 32.34375 -27.859375 C 33.132812 -28.523438 33.640625 -29.351562 33.859375 -30.34375 L 33.984375 -30.96875 C 34.191406 -31.914062 34.0625 -32.71875 33.59375 -33.375 C 33.125 -34.039062 32.441406 -34.375 31.546875 -34.375 Z M 29.25 -34.375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(237.285815, 211.874986)"><g><path d="M 44.609375 -33.796875 L 25.015625 -1.03125 C 24.847656 -0.769531 24.703125 -0.507812 24.578125 -0.25 L 23.671875 -16.890625 L 32.0625 -33.796875 Z M 21.25 4.609375 C 19.03125 7.460938 16.585938 9.644531 13.921875 11.15625 C 11.253906 12.675781 8.148438 13.4375 4.609375 13.4375 C 1.835938 13.4375 -0.53125 13.050781 -2.5 12.28125 L -0.765625 4.671875 L 4.34375 4.671875 C 7.550781 4.671875 9.816406 3.113281 11.140625 0 L 5.5 -33.796875 L 19.203125 -33.796875 Z M 21.25 4.609375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(276.387414, 211.874986)"><g><pathLine truncated

After

Width:  |  Height:  |  Size: 6.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+38
View File
@@ -0,0 +1,38 @@
import { Direction, Firearm, FirearmType, Page, PageQueryParams } from "@/types"
import { WebClient } from "@/shared/web-client"
import { asUrlSearchParam } from "@/utils/query-param-utils.ts"
interface FirearmParams extends PageQueryParams {
type?: FirearmType
}
/**
* 查询武器列表
*
* @param params 分页查询参数¬
*/
export async function getFirearms(params?: FirearmParams): Promise<Page<Firearm>> {
let uri = "/firearms"
const urlSearchParam = asUrlSearchParam(params)
if (params?.type) {
urlSearchParam.append("type", params.type)
}
if (urlSearchParam.size > 0) {
uri = uri.concat("?", urlSearchParam.toString())
}
const { data } = await WebClient.get<Page<Firearm>>(uri)
return data
}
/**
* 根据 ID 查询武器
*
* @param id 武器 ID
*/
export async function getFirearm(id: number): Promise<Firearm> {
const { data } = await WebClient.get<Firearm>(`/firearms/${id}`)
return data
}
+3
View File
@@ -0,0 +1,3 @@
export * as FirearmApi from "./firearm-api"
export * as ModificationApi from "./modification-api"
export * as TagApi from "./tag-api"
+33
View File
@@ -0,0 +1,33 @@
import { Modification, Page, PageQueryParams } from "@/types"
import { WebClient } from "@/shared/web-client"
import { asUrlSearchParam } from "@/utils/query-param-utils.ts"
interface ModificationParams extends PageQueryParams {
firearmId?: string
tags?: string[]
}
export async function getModifications(params?: ModificationParams): Promise<Page<Modification>> {
let uri = "/modifications"
const urlSearchParams = asUrlSearchParam(params)
if (params?.firearmId) {
urlSearchParams.append("firearmId", "" + params.firearmId)
}
if (params?.tags) {
params.tags.forEach((tag) => urlSearchParams.append("tags", tag))
}
if (urlSearchParams.size > 0) {
uri = uri.concat("?", urlSearchParams.toString())
}
const { data } = await WebClient.get<Page<Modification>>(uri)
return data
}
export async function getModification(id: number): Promise<Modification> {
const { data } = await WebClient.get<Modification>(`/modifications/${id}`)
return data
}
+18
View File
@@ -0,0 +1,18 @@
import { WebClient } from "@/shared/web-client"
export async function getTags(firearmId?: number): Promise<string[]> {
let uri = "/tags"
const urlSearchParam = new URLSearchParams()
if (firearmId) {
urlSearchParam.append("firearmId", "" + firearmId)
}
if (urlSearchParam.size > 0) {
uri = uri.concat("?", urlSearchParam.toString())
}
const { data } = await WebClient.get<string[]>(uri)
return data
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/></defs><rect x="-37.5" width="450" fill="#ffffff" y="-37.499999" height="449.999989" fill-opacity="1"/><rect x="-37.5" width="450" fill="#324370" y="-37.499999" height="449.999989" fill-opacity="1"/><g fill="#ffffff" fill-opacity="1"><g transform="translate(27.506677, 211.874986)"><g><path d="M 12.359375 -0.453125 C 8.003906 -1.816406 5.003906 -4.3125 3.359375 -7.9375 C 1.710938 -11.5625 1.550781 -16.253906 2.875 -22.015625 C 4.195312 -27.773438 6.519531 -32.46875 9.84375 -36.09375 C 13.175781 -39.71875 17.34375 -42.210938 22.34375 -43.578125 Z M 27.578125 -34.109375 L 30.015625 -44.796875 L 31.875 -44.796875 C 39.46875 -44.796875 44.882812 -42.851562 48.125 -38.96875 C 51.363281 -35.09375 52.128906 -29.441406 50.421875 -22.015625 C 48.722656 -14.585938 45.351562 -8.929688 40.3125 -5.046875 C 35.28125 -1.171875 28.96875 0.765625 21.375 0.765625 C 20.519531 0.765625 19.898438 0.742188 19.515625 0.703125 L 21.953125 -9.921875 C 22.722656 -9.835938 23.34375 -9.796875 23.8125 -9.796875 C 26.84375 -9.796875 29.367188 -10.710938 31.390625 -12.546875 C 33.421875 -14.378906 34.796875 -16.851562 35.515625 -19.96875 L 36.421875 -24.0625 C 37.140625 -27.175781 36.910156 -29.648438 35.734375 -31.484375 C 34.566406 -33.316406 32.46875 -34.234375 29.4375 -34.234375 C 28.96875 -34.234375 28.347656 -34.191406 27.578125 -34.109375 Z M 27.578125 -34.109375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(80.815405, 211.874986)"><g><path d="M 31.171875 -34.5625 C 28.816406 -34.5625 26.363281 -34.003906 23.8125 -32.890625 L 21.953125 -24.890625 C 22.460938 -25.066406 23.0625 -25.15625 23.75 -25.15625 C 25.15625 -25.15625 26.125 -24.71875 26.65625 -23.84375 C 27.1875 -22.96875 27.28125 -21.804688 26.9375 -20.359375 L 22.265625 0 L 35 0 L 40.0625 -21.890625 C 41 -25.984375 40.726562 -29.117188 39.25 -31.296875 C 37.78125 -33.472656 35.085938 -34.5625 31.171875 -34.5625 Z M 17.28125 -19.96875 L 17.796875 -28.671875 L 18.171875 -33.796875 L 7.75 -33.796875 L -0.0625 0 L 12.671875 0 L 17.21875 -19.84375 Z M 17.28125 -19.96875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(123.500788, 211.874986)"><g><path d="M 19.515625 -33.796875 L 11.703125 0 L -1.03125 0 L 6.78125 -33.796875 Z M 22.46875 -46.40625 L 20.484375 -37.765625 L 7.75 -37.765625 L 9.71875 -46.40625 Z M 22.46875 -46.40625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(144.811483, 211.874986)"><g><path d="M 45.5625 -33.796875 L 29.953125 -17.59375 L 38.53125 0 L 23.671875 0 L 19.703125 -10.625 L 19.453125 -10.625 L 10.5625 0 L -3.578125 0 L 13.5625 -17.921875 L 5.5 -33.796875 L 20.484375 -33.796875 L 23.8125 -24.828125 L 24.0625 -24.828125 L 31.484375 -33.796875 Z M 45.5625 -33.796875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(187.496867, 211.874986)"><g><path d="M 24 -44.03125 L 13.890625 0 L -0.25 0 L 9.859375 -44.03125 Z M 29.25 -34.375 L 31.421875 -44.03125 L 39.171875 -44.03125 C 42.578125 -44.03125 45.21875 -43.003906 47.09375 -40.953125 C 48.976562 -38.910156 49.554688 -36.332031 48.828125 -33.21875 C 47.585938 -27.800781 44.347656 -24.382812 39.109375 -22.96875 L 39.109375 -22.71875 C 44.390625 -21.4375 46.328125 -17.769531 44.921875 -11.71875 C 44.109375 -8.257812 42.21875 -5.441406 39.25 -3.265625 C 36.289062 -1.085938 33.019531 0 29.4375 0 L 21.3125 0 L 23.671875 -10.234375 L 27 -10.234375 C 27.601562 -10.234375 28.179688 -10.382812 28.734375 -10.6875 C 29.285156 -10.988281 29.765625 -11.40625 30.171875 -11.9375 C 30.578125 -12.46875 30.863281 -13.054688 31.03125 -13.703125 L 31.171875 -14.34375 C 31.421875 -15.320312 31.300781 -16.140625 30.8125 -16.796875 C 30.320312 -17.460938 29.628906 -17.796875 28.734375 -17.796875 L 25.40625 -17.796875 L 27.515625 -26.875 L 29.828125 -26.875 C 30.722656 -26.875 31.5625 -27.203125 32.34375 -27.859375 C 33.132812 -28.523438 33.640625 -29.351562 33.859375 -30.34375 L 33.984375 -30.96875 C 34.191406 -31.914062 34.0625 -32.71875 33.59375 -33.375 C 33.125 -34.039062 32.441406 -34.375 31.546875 -34.375 Z M 29.25 -34.375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(237.285815, 211.874986)"><g><path d="M 44.609375 -33.796875 L 25.015625 -1.03125 C 24.847656 -0.769531 24.703125 -0.507812 24.578125 -0.25 L 23.671875 -16.890625 L 32.0625 -33.796875 Z M 21.25 4.609375 C 19.03125 7.460938 16.585938 9.644531 13.921875 11.15625 C 11.253906 12.675781 8.148438 13.4375 4.609375 13.4375 C 1.835938 13.4375 -0.53125 13.050781 -2.5 12.28125 L -0.765625 4.671875 L 4.34375 4.671875 C 7.550781 4.671875 9.816406 3.113281 11.140625 0 L 5.5 -33.796875 L 19.203125 -33.796875 Z M 21.25 4.609375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(276.387414, 211.874986)"><g><pathLine truncated

After

Width:  |  Height:  |  Size: 6.6 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32"
preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228">
<path fill="#00D8FF"
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+69
View File
@@ -0,0 +1,69 @@
import { useRouteError, Link } from "react-router-dom"
/**
* Error page component that displays when routing errors occur.
* Uses React Router's useRouteError hook to get error information.
*/
export default function ErrorPage() {
const error = useRouteError() as Error & { statusText?: string; status?: number }
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div className="text-center">
{/* Error Icon */}
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<svg
className="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
{/* Error Title */}
<h1 className="mt-4 text-2xl font-bold text-gray-900">
页面出现错误
</h1>
{/* Error Message */}
<div className="mt-4">
<p className="text-sm text-gray-600">
{error?.statusText ?? error?.message ?? "发生了未预期的错误"}
</p>
{error?.status && (
<p className="text-xs text-gray-500 mt-1">
错误代码:{error.status}
</p>
)}
</div>
{/* Action Buttons */}
<div className="mt-6 flex flex-col space-y-3">
<Link
to="/"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
返回首页
</Link>
<button
onClick={() => window.history.back()}
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
返回上一页
</button>
</div>
</div>
</div>
</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
@layer theme, base, antd, components, utilities;
@import 'tailwindcss';
html, body {
margin: 0;
padding: 0;
width: 100%;
}
#root {
width: 100%;
}
.mod-codes-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@media (min-width: 640px) {
.mod-codes-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.mod-codes-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.mod-codes-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
+6
View File
@@ -0,0 +1,6 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
dayjs.extend(duration)
console.log("Global Dayjs plugins initialised.")
+1
View File
@@ -0,0 +1 @@
import "./dayjs"
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom"
/**
* Empty layout component that provides minimal structure.
* Useful for pages that need full control over their layout.
*/
export default function EmptyLayout() {
return (
<div className="min-h-screen">
<Outlet />
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { Outlet, Link } from "react-router-dom"
import { useMemo } from "react"
import dayjs from "dayjs"
/**
* Main application component that serves as the root layout.
* Uses React Router's Outlet to render child routes.
*/
export default function HeroLayout() {
const today = useMemo(() => dayjs(), [])
return (
<div className="bg-gray-50">
{/* Navigation Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold text-gray-900">
三角洲行动改枪码库
</h1>
</div>
<nav className="flex space-x-8">
<Link
to="/firearms"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
武器列表
</Link>
<Link
to="/mod-codes"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
改枪码
</Link>
<a
href="https://github.com/zihluwang/delta-force-firearm-modification-codes"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
GitHub
</a>
</nav>
</div>
</div>
</header>
{/* Main Content Area */}
<main className="max-w-screen-2xl mx-auto py-6 sm:px-6 lg:px-10">
<div className="px-4 py-6 sm:px-0">
<Outlet />
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t">
<div className="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-10">
<p className="text-center text-sm text-gray-500">
© 2024-{today.year()} Zihlu Wang 和 OnixByte。使用 React 与 TypeScript 构建。
</p>
</div>
</footer>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { Provider } from "react-redux"
import { PersistGate } from "redux-persist/integration/react"
import { App as AntApp, ConfigProvider as AntConfigProvider } from "antd"
import { StyleProvider as AntStyleProvider } from "@ant-design/cssinjs"
import AntSimplifiedChinese from "antd/locale/zh_CN"
import "@/init"
import router from "@/router"
import store, { persistor } from "@/store"
import "./index.css"
/**
* Main application entry point.
* Sets up the React app with React Router.
*/
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<AntStyleProvider layer>
<AntConfigProvider
locale={AntSimplifiedChinese}
button={{
autoInsertSpace: false,
}}>
<AntApp className="h-full w-full">
<RouterProvider router={router} />
</AntApp>
</AntConfigProvider>
</AntStyleProvider>
</PersistGate>
</Provider>
</StrictMode>
)
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { FirearmApi } from "@/api"
import { Firearm, FirearmType } from "@/types"
import { Button, Card, Col, Pagination, Row, Select, Tag, Typography } from "antd"
const firearmTypeText: Record<FirearmType, string> = {
RIFLE: "步枪",
SUB_MACHINE_GUN: "冲锋枪",
SHOTGUN: "霰弹枪",
LIGHT_MACHINE_GUN: "轻机枪",
DESIGNATED_MARKSMAN_RIFLE: "射手步枪",
SNIPER_RIFLE: "狙击步枪",
PISTOL: "手枪",
SPECIAL: "特殊",
}
const allTypeValue = "ALL"
type FirearmTypeFilter = FirearmType | typeof allTypeValue
function asDps(fireRate: number, damage: number) {
return ((fireRate / 60) * damage).toFixed(2)
}
export default function FirearmsPage() {
const [page, setPage] = useState<number>(1)
const [typeFilter, setTypeFilter] = useState<FirearmTypeFilter>(allTypeValue)
const [firearms, setFirearms] = useState<Firearm[]>([])
const [total, setTotal] = useState<number>(0)
useEffect(() => {
FirearmApi.getFirearms({
page: page - 1,
size: 12,
sortBy: "id",
direction: "ASC",
type: typeFilter === allTypeValue ? undefined : typeFilter,
}).then((pagedData) => {
setFirearms(pagedData.items)
setTotal(pagedData.totalElements)
})
}, [page, typeFilter])
return (
<>
<div className="mb-4 flex justify-end">
<Select<FirearmTypeFilter>
className="w-full sm:w-64"
value={typeFilter}
options={[
{ value: allTypeValue, label: "全部类型" },
...Object.entries(firearmTypeText).map(([value, label]) => ({
value,
label,
})),
]}
onChange={(nextType) => {
setPage(1)
setTypeFilter(nextType)
}}
/>
</div>
<div className="mb-6">
<Row gutter={[16, 16]}>
{firearms.map((firearm) => (
<Col key={firearm.id} xs={24} md={12} lg={8}>
<Card
title={firearm.name}
variant="outlined"
styles={{
header: { minHeight: 56 },
}}
actions={[
<Link key={`mod-codes-${firearm.id}`} to={`/mod-codes?firearmId=${firearm.id}`}>
<Button type="link">查看改枪码</Button>
</Link>,
]}>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Tag color="blue">{firearmTypeText[firearm.type]}</Tag>
</div>
<Typography.Text>
<strong>武器输出等级:</strong>
{firearm.level}
</Typography.Text>
<Typography.Text>
<strong>子弹口径:</strong>
{firearm.calibre}
</Typography.Text>
<Typography.Text>
<strong>每秒甲伤:</strong>
{asDps(firearm.fireRate, firearm.armourDamage)}
</Typography.Text>
<Typography.Text>
<strong>每秒肉伤:</strong>
{asDps(firearm.fireRate, firearm.bodyDamage)}
</Typography.Text>
<Typography.Paragraph
style={{ marginBottom: 0 }}
type="secondary"
ellipsis={{ rows: 3 }}
className="whitespace-pre-line">
{firearm.review || "暂无描述"}
</Typography.Paragraph>
</div>
</Card>
</Col>
))}
{firearms.length === 0 && (
<Col span={24}>
<Card>
<Typography.Text type="secondary">暂无武器数据</Typography.Text>
</Card>
</Col>
)}
</Row>
</div>
<div className="flex justify-end">
<Pagination
align="end"
current={page}
pageSize={12}
total={total}
onChange={(nextPage) => {
setPage(nextPage)
}}
showSizeChanger={false}
/>
</div>
</>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useMemo, useState } from "react"
import { Link, useSearchParams } from "react-router-dom"
import { Button, Card, Col, Pagination, Row, Select, Space, Tag, Typography } from "antd"
import { ModificationApi, TagApi } from "@/api"
import { Modification } from "@/types"
const pageSize = 12
export default function ModCodesPage() {
const [searchParams] = useSearchParams()
const firearmId = useMemo(() => searchParams.get("firearmId") || undefined, [searchParams])
const [page, setPage] = useState<number>(1)
const [modifications, setModifications] = useState<Modification[]>([])
const [tagOptions, setTagOptions] = useState<string[]>([])
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [total, setTotal] = useState<number>(0)
useEffect(() => {
const _firearmId = firearmId ? +firearmId : (void 0)
TagApi.getTags(_firearmId).then((tags) => {
setTagOptions(tags)
})
}, [])
useEffect(() => {
ModificationApi.getModifications({
page: page - 1,
size: pageSize,
sortBy: "id",
direction: "ASC",
firearmId,
tags: selectedTags,
}).then((pagedData) => {
setModifications(pagedData.items)
setTotal(pagedData.totalElements)
})
}, [page, firearmId, selectedTags])
useEffect(() => {
setPage(1)
}, [firearmId])
useEffect(() => {
setPage(1)
}, [selectedTags])
return (
<>
<div className="mb-4 flex items-center justify-between gap-4">
<Typography.Title level={4} className="mb-0!">
改枪码列表
</Typography.Title>
<Space wrap>
<span>标签:</span>
<Select<string[]>
mode="multiple"
allowClear
placeholder="请选择标签"
className="w-64"
value={selectedTags}
options={tagOptions.map((tag) => ({ value: tag, label: tag }))}
onChange={(values) => {
setSelectedTags(values)
}}
/>
{firearmId && <Tag color="geekblue">武器 ID: {firearmId}</Tag>}
{(firearmId || selectedTags.length > 0) && (
<Link to="/mod-codes">
<Button
type="link"
onClick={() => {
setSelectedTags([])
setPage(1)
}}
>
清除筛选
</Button>
</Link>
)}
</Space>
</div>
<div className="mb-6">
<Row gutter={[16, 16]}>
{modifications.map((modification) => (
<Col key={modification.id} xs={24} md={12} lg={8}>
<Card
title={modification.name}
variant="outlined"
styles={{
header: { minHeight: 56 },
}}>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<span>
<strong>改枪码:</strong>
<code className="bg-gray-400 px-2 py-1 rounded text-sm text-white">
{modification.code}
</code>
</span>
<Button
type="text"
size="small"
onClick={() => navigator.clipboard.writeText(modification.code)}>
复制
</Button>
</div>
<Typography.Text>
<strong>作者:</strong>
{modification.author || "未知"}
</Typography.Text>
{modification.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{modification.tags.map((tag) => (
<Tag key={`${modification.id}-${tag}`}>{tag}</Tag>
))}
</div>
)}
<Typography.Paragraph
style={{ marginBottom: 0 }}
type="secondary"
ellipsis={{ rows: 3 }}>
{modification.note || "暂无备注"}
</Typography.Paragraph>
{modification.videoUrl && (
<div>
<a href={modification.videoUrl} target="_blank" rel="noopener noreferrer">
查看视频
</a>
</div>
)}
</div>
</Card>
</Col>
))}
{modifications.length === 0 && (
<Col span={24}>
<Card>
<Typography.Text type="secondary">暂无改枪码数据</Typography.Text>
</Card>
</Col>
)}
</Row>
</div>
<div className="flex justify-end">
<Pagination
align="end"
current={page}
pageSize={pageSize}
total={total}
onChange={(nextPage) => {
setPage(nextPage)
}}
showSizeChanger={false}
/>
</div>
</>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { ComponentType } from "react"
import { createBrowserRouter } from "react-router-dom"
import ErrorPage from "@/components/error-page"
import HeroLayout from "@/layout/hero-layout"
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
return async () => {
const module = await importer()
return {
Component: module.default,
}
}
}
/**
* Main application router configuration using React Router v6.
* Defines all routes and their corresponding components.
*/
const router = createBrowserRouter(
[
{
path: "/",
element: <HeroLayout />,
errorElement: <ErrorPage />,
children: [
{
index: true,
lazy: lazy(() => import("@/page/firearms")),
},
{
path: "firearms",
lazy: lazy(() => import("@/page/firearms")),
},
{
path: "mod-codes",
lazy: lazy(() => import("@/page/mod-codes")),
},
],
},
],
{
basename: "/",
}
)
export default router
+9
View File
@@ -0,0 +1,9 @@
import axios from "axios"
import dayjs from "dayjs"
const WebClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
})
export { WebClient }
+42
View File
@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import { Firearm, Page } from "@/types"
interface FirearmsState {
items: Firearm[]
page: number
size: number
totalPages: number
totalElements: number
}
const initialState: FirearmsState = {
items: [],
page: 0,
size: 12,
totalPages: 0,
totalElements: 0,
}
const firearmsSlice = createSlice({
name: "firearms",
initialState,
reducers: {
setFirearmsPage(state, action: PayloadAction<Page<Firearm>>) {
state.items = action.payload.items
state.page = action.payload.page
state.size = action.payload.size
state.totalPages = action.payload.totalPages
state.totalElements = action.payload.totalElements
},
clearFirearms(state) {
state.items = []
state.page = 0
state.size = 12
state.totalPages = 0
state.totalElements = 0
},
},
})
export const { setFirearmsPage, clearFirearms } = firearmsSlice.actions
export const firearmsReducer = firearmsSlice.reducer
+48
View File
@@ -0,0 +1,48 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit"
import { useDispatch, useSelector } from "react-redux"
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist"
import createWebStorage from "redux-persist/es/storage/createWebStorage"
import { firearmsReducer } from "./firearms-slice"
const storage = createWebStorage(import.meta.env.VITE_REDUX_STORAGE ?? "local")
const persistConfig = {
key: "root",
storage,
whitelist: ["firearms"],
}
const rootReducer = combineReducers({
firearms: firearmsReducer
})
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
})
export const persistor = persistStore(store)
export default store
export type RootState = ReturnType<typeof rootReducer>
export type AppDispatch = typeof store.dispatch
export type AppStore = typeof store
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+49
View File
@@ -0,0 +1,49 @@
export type Direction = "ASC" | "DESC"
export type FirearmType =
| "RIFLE"
| "SUB_MACHINE_GUN"
| "SHOTGUN"
| "LIGHT_MACHINE_GUN"
| "DESIGNATED_MARKSMAN_RIFLE"
| "SNIPER_RIFLE"
| "PISTOL"
| "SPECIAL"
export interface Page<T> {
items: T[]
page: number
size: number
totalElements: number
totalPages: number
}
export interface Firearm {
id: number
name: string
type: FirearmType
level: string
calibre: string
fireRate: number
armourDamage: number
bodyDamage: number
review: string
}
export interface Modification {
id: number
firearmId: number
name: string
code: string
tags: string[]
note: string
author: string
videoUrl: string
}
export interface PageQueryParams {
page?: number
size?: number
sortBy?: string
direction?: Direction
}
+1
View File
@@ -0,0 +1 @@
export * as QueryParamUtils from "./query-param-utils"
+23
View File
@@ -0,0 +1,23 @@
import { PageQueryParams } from "@/types"
export function asUrlSearchParam(pageQueryParams?: PageQueryParams) {
const urlSearchParams = new URLSearchParams()
if (pageQueryParams?.page) {
urlSearchParams.append("page", "" + pageQueryParams.page)
}
if (pageQueryParams?.size) {
urlSearchParams.append("size", "" + pageQueryParams.size)
}
if (pageQueryParams?.sortBy) {
urlSearchParams.append("sortBy", pageQueryParams.sortBy)
}
if (pageQueryParams?.direction) {
urlSearchParams.append("direction", pageQueryParams.direction)
}
return urlSearchParams
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_REDUX_STORAGE: "local" | "session"
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": [
"ES2023"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"vite.config.ts"
]
}
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath, URL } from "node:url"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
base: "/",
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
})