18 Commits

Author SHA1 Message Date
zihluwang 9dfd52eb2d feat: integrate Ant Design components and configuration for improved UI 2026-04-07 11:06:34 +08:00
zihluwang 4294495ffa chore: add @ant-design/cssinjs 2026-04-07 11:06:21 +08:00
zihluwang a98902b08b fix: correct className syntax for consistent styling in firearms and mod codes pages 2026-04-07 01:28:34 +08:00
zihluwang bad31c6653 feat: implement modifications page with pagination and detail display 2026-04-06 21:02:16 +08:00
zihluwang ae912050b6 feat: add link to view modification codes for firearms 2026-04-06 20:53:13 +08:00
zihluwang feeb44bf6a feat: add firearm type filter to firearms page 2026-04-06 20:47:44 +08:00
zihluwang 452b807b62 feat: add firearm page for display firearms 2026-04-06 20:40:42 +08:00
zihluwang 38b25099de chore: add antd@6 for better UI and UX 2026-04-06 20:40:19 +08:00
zihluwang a0a5c835aa feat: add firearm management features
- Implemented API for fetching firearms and firearm details.
- Created a new page for displaying the list of firearms with search and filter options.
- Added Redux slice for managing firearms state.
- Integrated Redux Persist for state persistence.
- Updated routing to include firearms page.
- Removed obsolete modification codes data.
- Enhanced UI with responsive grid layout for firearms display.
- Added utility functions for handling URL query parameters.
2026-04-06 17:57:25 +08:00
zihluwang 864895d932 feat: add new weapon modifications for AWM, MCX LT, and KC17 2026-04-02 15:31:00 +08:00
zihluwang a4e5891189 feat: update README and add MIT Licence 2026-04-02 15:20:57 +08:00
zihluwang 335de44487 feat: add mode filtering to ModCodes component 2026-04-02 15:06:47 +08:00
zihluwang 3573a23acf Merge pull request #5 from zihluwang:dependabot/npm_and_yarn/globals-17.4.0
chore: bump globals from 16.5.0 to 17.4.0
2026-04-02 15:01:43 +08:00
zihluwang 6858e07603 Merge pull request #6 from zihluwang/dependabot/npm_and_yarn/typescript-6.0.2
chore: bump typescript from 5.9.3 to 6.0.2
2026-04-02 15:00:27 +08:00
dependabot[bot] aa2415a0cb chore: bump typescript from 5.9.3 to 6.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 06:54:37 +00:00
dependabot[bot] f9ff917360 chore: bump globals from 16.5.0 to 17.4.0
Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.4.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.4.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 06:54:36 +00:00
zihluwang 405c520cab Merge pull request #7 from zihluwang:dependabot/npm_and_yarn/dependency-updates-0c07818b49
chore: bump the dependency-updates group across 1 directory with 3 updates
2026-04-02 14:53:27 +08:00
dependabot[bot] 026843c6f0 chore: bump the dependency-updates group across 1 directory with 3 updates
Bumps the dependency-updates group with 3 updates in the / directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router), [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `react-router` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.13.2/packages/react-router)

Updates `react-router-dom` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.2/packages/react-router-dom)

Updates `vite` from 8.0.1 to 8.0.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 02:44:23 +00:00
22 changed files with 1950 additions and 497 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
}
+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.
+95 -2
View File
@@ -1,3 +1,96 @@
# React + TypeScript + Vite
# Delta Force Firearm Modification Codes
This template provides a minimal setup to get React working in Vite.
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.
+12 -6
View File
@@ -1,5 +1,5 @@
{
"name": "react-template",
"name": "delta-force-guide-web",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -12,13 +12,19 @@
"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.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"react-redux": "^9.2.0",
"react-router": "^7.13.2",
"react-router-dom": "^7.13.2",
"redux-persist": "^6.0.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
@@ -26,10 +32,10 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"globals": "^16.5.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"vite": "^8.0.1"
"typescript": "~6.0.2",
"vite": "^8.0.3"
},
"pnpm": {
"ignoredBuiltDependencies": [
+1305 -140
View File
File diff suppressed because it is too large Load Diff
+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
}
+2
View File
@@ -0,0 +1,2 @@
export * as FirearmApi from "./firearm-api"
export * as ModificationApi from "./modification-api"
+25
View File
@@ -0,0 +1,25 @@
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
}
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 (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
}
-90
View File
@@ -1,90 +0,0 @@
[
{
"weapon": "SCAR-H战斗步枪",
"modification-code": "6JJE3180BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["精锐制式券", "突击步枪", "稳定"],
"note": "精锐火力券提供的 SCAR-H",
"price": 445295
},
{
"weapon": "M4A1突击步枪",
"modification-code": "6JAS9U80BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["稳定", "突击步枪", "倍镜"],
"note": "带有三倍镜的稳定版 M4A1,全套价格左右。",
"price": 400000
},
{
"weapon": "M4A1突击步枪",
"modification-code": "6J9D8SO0BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["突击步枪", "性价比"],
"note": "性价比改装稳定版 M4A1。",
"price": 242638
},
{
"weapon": "MK4冲锋枪",
"modification-code": "6IVR0N40BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["超绝腰射", "三连发"],
"note": "超绝三连发腰射 MK4,近距离火力十足",
"price": 538686
},
{
"weapon": "MK4冲锋枪",
"modification-code": "6J7283O0BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["超绝腰射", "全自动"],
"note": "超绝全自动腰射 MK4,见到人瞄准好之后左键按到死",
"price": 466836
},
{
"weapon": "M7战斗步枪",
"modification-code": "6IVH3HC0BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["半改", "蓝管", "稳定"],
"note": "半改稳定 M7",
"price": 620597
},
{
"weapon": "M7战斗步枪",
"modification-code": "6J2QBQK0BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["倍镜", "职业选手", "消音"],
"note": "需要较高控枪水平的满改 M7",
"price": 891030
},
{
"weapon": "M7战斗步枪",
"modification-code": "6J2QC080BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["稳定"],
"note": "S8 赛季幻影版满改稳定 M751操控",
"price": 1039234
},
{
"weapon": "M7战斗步枪",
"modification-code": "6J2QC4O0BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["稳定"],
"note": "S8 赛季隐袭版满改稳定 M749操控",
"price": 1064418
},
{
"weapon": "MK47突击步枪",
"modification-code": "6JAV6A80BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["突袭", "大口径", "消音"],
"note": "S8 赛季余烬MK47,携带消音器",
"price": 862384
},
{
"weapon": "MK47突击步枪",
"modification-code": "6JAV6SS0BB9B3KKCCH41C",
"mode": "烽火地带",
"tags": ["突袭", "稳定"],
"note": "幻影余烬主宰者MK47",
"price": 864227
}
]
+26 -1
View File
@@ -1,4 +1,6 @@
@import "tailwindcss";
@layer theme, base, antd, components, utilities;
@import 'tailwindcss';
html, body {
margin: 0;
@@ -8,4 +10,27 @@ html, body {
#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
@@ -21,6 +21,12 @@ export default function HeroLayout() {
</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"
+22 -2
View File
@@ -1,8 +1,14 @@
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"
/**
@@ -11,6 +17,20 @@ import "./index.css"
*/
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</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>
)
+116
View File
@@ -0,0 +1,116 @@
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
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.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>
</>
)
}
+108 -250
View File
@@ -1,262 +1,120 @@
import { useMemo, useState, useEffect, useLayoutEffect, useRef } from "react"
import { useWindowVirtualizer } from "@tanstack/react-virtual"
import rawModCodes from "@/data/modification-codes.json"
import { useEffect, useMemo, useState } from "react"
import { Link, useSearchParams } from "react-router-dom"
import { Button, Card, Col, Pagination, Row, Space, Tag, Typography } from "antd"
import { ModificationApi } 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 [total, setTotal] = useState<number>(0)
function useColumnCount() {
const getCount = () => {
if (window.innerWidth >= 1024) return 4
if (window.innerWidth >= 768) return 3
if (window.innerWidth >= 640) return 2
return 1
}
const [cols, setCols] = useState(getCount)
useEffect(() => {
const handler = () => setCols(getCount())
window.addEventListener("resize", handler)
return () => window.removeEventListener("resize", handler)
}, [])
return cols
}
type ModCodeSource = {
weapon: string
"modification-code": string
mode?: string
tags?: string[]
note?: string
price?: number
}
type ModCode = {
id: string
weapon: string
code: string
mode?: string
tags: string[]
note?: string
price?: number
}
const MOD_CODES: ModCode[] = (rawModCodes as ModCodeSource[]).map((item, index) => ({
id: `mod-${index + 1}`,
weapon: item.weapon,
code: item["modification-code"],
mode: item.mode,
tags: item.tags ?? [],
note: item.note,
price: item.price,
}))
export default function ModCodes() {
const [keyword, setKeyword] = useState("")
const [activeTag, setActiveTag] = useState<string>("全部")
const [activeWeapon, setActiveWeapon] = useState<string>("全部")
const [copiedId, setCopiedId] = useState<string | null>(null)
const [copyErrorId, setCopyErrorId] = useState<string | null>(null)
const handleCopy = async (item: ModCode) => {
try {
await navigator.clipboard.writeText(item.code)
setCopyErrorId(null)
setCopiedId(item.id)
window.setTimeout(() => {
setCopiedId((current) => (current === item.id ? null : current))
}, 1500)
} catch {
setCopiedId(null)
setCopyErrorId(item.id)
window.setTimeout(() => {
setCopyErrorId((current) => (current === item.id ? null : current))
}, 1500)
}
}
const allWeapons = useMemo(() => {
const weapons = new Set<string>()
MOD_CODES.forEach((item) => weapons.add(item.weapon))
return ["全部", ...Array.from(weapons)]
}, [])
const allTags = useMemo(() => {
const tags = new Set<string>()
MOD_CODES.forEach((item) => {
item.tags.forEach((tag) => tags.add(tag))
ModificationApi.getModifications({
page: page - 1,
size: pageSize,
sortBy: "id",
direction: "ASC",
firearmId,
}).then((pagedData) => {
setModifications(pagedData.items)
setTotal(pagedData.totalElements)
})
return ["全部", ...Array.from(tags)]
}, [])
}, [page, firearmId])
const filtered = useMemo(() => {
const q = keyword.trim().toLowerCase()
return MOD_CODES.filter((item) => {
const matchWeapon = activeWeapon === "全部" || item.weapon === activeWeapon
const matchTag = activeTag === "全部" || item.tags.includes(activeTag)
const matchKeyword =
q.length === 0 ||
item.weapon.toLowerCase().includes(q) ||
item.code.toLowerCase().includes(q) ||
(item.mode?.toLowerCase().includes(q) ?? false) ||
item.tags.some((tag) => tag.toLowerCase().includes(q))
return matchWeapon && matchTag && matchKeyword
})
}, [activeWeapon, activeTag, keyword])
const colCount = useColumnCount()
const rows = useMemo<ModCode[][]>(() => {
const result: ModCode[][] = []
for (let i = 0; i < filtered.length; i += colCount) {
result.push(filtered.slice(i, i + colCount))
}
return result
}, [filtered, colCount])
const listRef = useRef<HTMLDivElement>(null)
const scrollMarginRef = useRef(0)
useLayoutEffect(() => {
scrollMarginRef.current = listRef.current?.offsetTop ?? 0
})
const rowVirtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => 220,
overscan: 3,
scrollMargin: scrollMarginRef.current,
})
useEffect(() => {
setPage(1)
}, [firearmId])
return (
<section className="space-y-6">
<div className="space-y-4 px-1">
<p className="text-sm text-gray-600">
tag tag
</p>
<>
<div className="mb-4 flex items-center justify-between gap-4">
<Typography.Title level={4} className="mb-0!">
</Typography.Title>
{firearmId && (
<Space>
<Tag color="geekblue"> ID: {firearmId}</Tag>
<Link to="/mod-codes">
<Button type="link"></Button>
</Link>
</Space>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block">
<span className="block text-sm font-medium text-gray-700 mb-1"></span>
<select
value={activeWeapon}
onChange={(event) => setActiveWeapon(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-100 bg-white"
>
{allWeapons.map((weapon) => (
<option key={weapon} value={weapon}>{weapon}</option>
))}
</select>
</label>
<label className="block">
<span className="block text-sm font-medium text-gray-700 mb-1"></span>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="例如:M4、近战、DF-"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
/>
</label>
<div className="flex items-end">
<button
type="button"
onClick={() => {
setKeyword("")
setActiveTag("全部")
setActiveWeapon("全部")
}}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const selected = tag === activeTag
return (
<button
key={tag}
type="button"
onClick={() => setActiveTag(tag)}
className={`rounded-full px-3 py-1 text-sm border transition ${
selected
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
}`}
<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 },
}}
>
#{tag}
</button>
)
})}
</div>
<div className="flex flex-col gap-3">
<Typography.Paragraph className="mb-0!" copyable={{ text: modification.code }}>
<strong></strong>
{modification.code}
</Typography.Paragraph>
<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
ref={listRef}
style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
}}
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 pb-4">
{rows[virtualRow.index].map((item) => (
<article key={item.id} className="bg-white border rounded-xl p-4 shadow-sm space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-gray-900">{item.weapon}</h2>
{item.mode ? (
<span className="text-xs rounded-full bg-gray-100 text-gray-700 px-2 py-1">
{item.mode}
</span>
) : (
<span className="text-xs text-gray-500">ID: {item.id}</span>
)}
</div>
<div className="rounded-lg bg-gray-900 text-green-300 px-3 py-2 font-mono text-sm break-all flex items-center justify-between gap-3">
<span>{item.code}</span>
<button
type="button"
onClick={() => handleCopy(item)}
className="shrink-0 rounded-md bg-green-400/10 border border-green-400/40 text-green-200 px-2 py-1 text-xs hover:bg-green-400/20"
>
{copiedId === item.id ? "已复制" : "复制改枪码"}
</button>
</div>
{copyErrorId === item.id ? (
<p className="text-xs text-red-600"></p>
) : null}
{item.price ? <p className="text-sm text-gray-900 font-medium">$ {item.price.toLocaleString()}</p> : null}
{item.note ? <p className="text-sm text-gray-600">{item.note}</p> : null}
<div className="flex flex-wrap gap-2">
{item.tags.map((tag) => (
<button
key={`${item.id}-${tag}`}
type="button"
onClick={() => setActiveTag(tag)}
className="text-xs rounded-full bg-blue-50 text-blue-700 px-2 py-1 hover:bg-blue-100"
>
#{tag}
</button>
))}
</div>
</article>
))}
</div>
</div>
))}
<div className="flex justify-end">
<Pagination
align="end"
current={page}
pageSize={pageSize}
total={total}
onChange={(nextPage) => {
setPage(nextPage)
}}
showSizeChanger={false}
/>
</div>
{filtered.length === 0 ? (
<div className="bg-white border rounded-xl p-6 text-center text-gray-600">
tag
</div>
) : null}
</section>
</>
)
}
}
+5 -1
View File
@@ -25,7 +25,11 @@ const router = createBrowserRouter(
children: [
{
index: true,
lazy: lazy(() => import("@/page/mod-codes")),
lazy: lazy(() => import("@/page/firearms")),
},
{
path: "firearms",
lazy: lazy(() => import("@/page/firearms")),
},
{
path: "mod-codes",
+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>()
+45
View File
@@ -0,0 +1,45 @@
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
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
}
+1
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_REDUX_STORAGE: "local" | "session"
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {