chore: merge delta-force-guide-web into web/

git-subtree-dir: web
git-subtree-mainline: 25da2564a4
git-subtree-split: e4aa5b988b
This commit is contained in:
siujamo committed 2026-08-28 09:37:58 +08:00
commit a2b94dfda5
70 files changed
+9196

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
README.md
+21
View File
@@ -0,0 +1,21 @@
# ENVIRONMENT CONFIGURATION TEMPLATE
#
# This file serves as a template for environment variables.
# DO NOT include any sensitive data (passwords, API keys, etc.) in this file.
#
# INSTRUCTIONS
# 1. Copy this file to `.env`, `.env.development`, or `.env.production` depending on your
# target environment.
# 2. Replace the placeholder values with your actual configuration.
# 3. Alternatively, ensure these variables are defined within your system's environment
# settings before running the application.
# The base URL fot the backend API service.
# Ensure this matches your Spring Boot server address (e.g., http://localhost:8080).
VITE_API_BASE_URL=/api
# Determines where Redux state is persisted on the client side.
# Available options:
# - `local`: Persists data in LocalStorage (remains after closing the browser).
# - `session`: Persists data in SessionStorage (cleared when the tab is closed).
VITE_REDUX_STORAGE=local
+112
View File
@@ -0,0 +1,112 @@
name: Build and Deploy
on:
release:
types: [published]
jobs:
build:
name: Build release archive
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build release archive
run: pnpm build:tar
- name: Clean up previous build artifacts on server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
command: rm -rf /tmp/dist.tar.gz /tmp/dist.tar.gz/
- name: Upload artifact to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "dist.tar.gz"
target: "/tmp/"
upload-release-asset:
name: Upload to Gitea Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact from server
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -e
SSH_DIR="${RUNNER_TEMP}/ssh"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$SSH_DIR/key"
chmod 600 "$SSH_DIR/key"
scp -i "$SSH_DIR/key" \
-P "$DEPLOY_PORT" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="$SSH_DIR/known_hosts" \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/dist.tar.gz" \
./dist.tar.gz
rm -rf "$SSH_DIR"
- name: Upload release asset via Gitea API
run: |
set -e
RELEASE_ID=$(jq -r '.release.id' "$GITEA_EVENT_PATH")
URL="${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=dist.tar.gz"
curl -fsSL \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/gzip" \
--data-binary "@dist.tar.gz" \
"${URL}"
deploy-to-server:
name: Deploy to onixbyte.cn
needs: [build, upload-release-asset]
runs-on: ubuntu-latest
steps:
- name: Extract archive and deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
set -e
DEPLOY_PATH="${{ vars.DEPLOY_PATH }}"
mkdir -p "$DEPLOY_PATH"
rm -rf "$DEPLOY_PATH"/*
tar -xzf /tmp/dist.tar.gz -C "$DEPLOY_PATH" --strip-components=1
chown -R caddy:caddy "$DEPLOY_PATH"
rm -f /tmp/dist.tar.gz
+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-*
+33
View File
@@ -0,0 +1,33 @@
image: node:24.15-trixie-slim
stages:
- build
- deploy
build:
stage: build
rules:
- if: $CI_RELEASE_DESCRIPTION
script:
- corepack enable
- corepack prepare pnpm --activate
- pnpm install --frozen-lockfile
- pnpm build
artifacts:
paths:
- dist/
expire_in: 7 days
deploy:
stage: deploy
rules:
- if: $CI_RELEASE_DESCRIPTION
needs:
- build
script:
- apt-get update -qq && apt-get install -y -qq openssh-client rsync
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY_BASE64" | base64 -d > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
- rsync -avz --delete dist/ "${SSH_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}"
+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
+19
View File
@@ -0,0 +1,19 @@
{
"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}"
}
]
}
+56
View File
@@ -0,0 +1,56 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
pnpm install # Install dependencies (pnpm required)
pnpm dev # Start Vite dev server
pnpm build # TypeScript check + Vite production build
pnpm build:tar # Build + tar.gz archive (used by CI)
pnpm preview # Preview production build locally
pnpm lint # ESLint
```
No test suite exists in this project.
## Architecture
This is a Chinese-language SPA for browsing and managing Delta Force guide ("《三角洲》指南"). The frontend talks to a Spring Boot backend via REST APIs.
**App shell** (`src/main.tsx`): React 19 + React Router 7 + Redux Toolkit + Ant Design 6 + Tailwind CSS 4. Wraps the router in Redux `Provider` → `PersistGate` (Redux Persist) → Ant Design `StyleProvider`/`ConfigProvider` (locale `zh_CN`).
**Routing** (`src/router/index.tsx`): Two layout groups:
- `HeroLayout` (nav header + footer) for `/`, `/firearms`, `/mod-codes`
- `EmptyLayout` (minimal) for `/login`
All page components are lazy-loaded via `createBrowserRouter` + `lazy()`.
**State** (`src/store/`): Redux Toolkit with two slices — `auth` (current user) and `firearms` (paginated firearm list). State is persisted to `localStorage` or `sessionStorage` based on the `VITE_REDUX_STORAGE` env var. Use typed hooks from `src/hooks/store.ts` (`useAppDispatch`, `useAppSelector`).
**API layer** (`src/api/`): Axios instance (`src/shared/web-client/`) with base URL from `VITE_API_BASE_URL`, 10s timeout, and credentials. API modules: `FirearmApi`, `ModificationApi`, `TagApi`, `AuthApi`.
**Pages**:
- `FirearmsPage` — paginated card grid with type filter, create/edit modals (admin-only), delete with popconfirm
- `ModCodesPage` — paginated list with tag multi-select and firearmId query param filter, create/edit modals with nested accessory/tuning form lists
- `LoginPage` — simple username/password form, dispatches `setCurrentUser` on success
**Shared form components**: `FirearmForm` and `ModificationForm` are reused by both create and edit modals. `ModificationForm` fetches all firearms for its weapon selector and supports a `lockFirearmId` prop that disables the selector (used when navigating from a specific firearm).
**Type system** (`src/types/`): `Firearm` with weapon stats, `Modification` with nested `Accessory[]` → `Tuning[]`, `Page<T>` for paginated API responses, `User` for auth.
**Vite config**: Alias `@` → `./src`. Plugins: React, Tailwind CSS 4, port checker. Build uses rolldown with manual chunk splitting for React, Redux, Ant Design, React Router, and rc-* packages.
**Styling**: Tailwind CSS 4 with CSS layers (`theme, base, antd, components, utilities`). Responsive grid for mod code cards (1→2→3→4 columns). Prettier: 100 print width, no semicolons, double quotes, trailing commas ES5.
## Environment variables
```
VITE_API_BASE_URL=/api # Backend API base URL
VITE_REDUX_STORAGE=local # "local" or "session" for Redux persistence
```
## Contributing conventions
- User-facing copy, documentation, and code comments in British English
- Commit messages use `chore:` prefix for dependency updates (per Dependabot config)
+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.
+94
View File
@@ -0,0 +1,94 @@
# 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
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>
+3077
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
{
"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"
},
"dependencies": {
"@ant-design/cssinjs": "^2.1.2",
"@ant-design/icons": "^6.2.2",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-virtual": "^3.13.24",
"antd": "^6.3.7",
"axios": "^1.16.0",
"dayjs": "^1.11.20",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-redux": "^9.2.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"redux-persist": "^6.0.0",
"tailwindcss": "^4.2.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"vite": "^8.0.11",
"vite-plugin-markdown": "^2.2.0",
"vite-plugin-port-checker": "^1.0.1"
},
"pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"@tailwindcss/oxide"
]
}
}
+2290
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false
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

+13
View File
@@ -0,0 +1,13 @@
import { LoginRequest, User } from "@/types"
import { WebClient } from "@/shared/web-client"
export async function login(loginRequest: LoginRequest): Promise<User> {
const { data } = await WebClient.post<User>("/auth/login", {
...loginRequest,
})
return data
}
export async function logout() {
await WebClient.get<void>("/auth/logout")
}
+56
View File
@@ -0,0 +1,56 @@
import { AddFirearmRequest, 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
}
/**
* Fetch firearm list
*
* @param params Paged query parameters
*/
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
}
/**
* Fetch firearm by ID
*
* @param id Firearm ID
*/
export async function getFirearm(id: number): Promise<Firearm> {
const { data } = await WebClient.get<Firearm>(`/firearms/${id}`)
return data
}
/**
* Create firearm
* @param request
*/
export async function addFirearm(request: AddFirearmRequest): Promise<Firearm> {
const { data } = await WebClient.post<Firearm>("/firearms", request)
return data
}
export async function editFirearm(id: number, request: AddFirearmRequest): Promise<Firearm> {
const { data } = await WebClient.put<Firearm>(`/firearms/${id}`, request)
return data
}
export async function removeFirearm(id: number) {
await WebClient.delete<void>(`/firearms/${id}`)
}
+4
View File
@@ -0,0 +1,4 @@
export * as FirearmApi from "./firearm-api"
export * as ModificationApi from "./modification-api"
export * as TagApi from "./tag-api"
export * as AuthApi from "./auth-api"
+67
View File
@@ -0,0 +1,67 @@
import { Modification, ModificationRequest, 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
}
export async function addModification(modification: ModificationRequest): Promise<Modification> {
const { data } = await WebClient.post<Modification>("/modifications", modification)
return data
}
export async function addModifications(
modifications: ModificationRequest[]
): Promise<Modification[]> {
const { data } = await WebClient.post<Modification[]>("/modifications/batch", {
modifications,
})
return data
}
export async function editModification(
id: number,
modification: ModificationRequest
): Promise<Modification> {
const { data } = await WebClient.put<Modification>(`/modifications/${id}`, modification)
return data
}
export async function removeModification(
id: number
): Promise<void> {
await WebClient.delete(`/modifications/${id}`)
}
export async function removeModifications(ids: number[]) {
const urlSearchParams = new URLSearchParams()
ids.forEach((id) => urlSearchParams.append("ids", "" + id))
await WebClient.delete(`/modifications/batch-delete?${urlSearchParams.toString()}`)
}
+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>
)
}
@@ -0,0 +1,58 @@
import { useState } from "react"
import { App, Form, Modal } from "antd"
import { FirearmApi } from "@/api"
import FirearmForm from "@/components/firearm-form"
import { AddFirearmRequest, Firearm } from "@/types"
interface FirearmCreateModalProps {
open: boolean
onCancel: () => void
onSuccess: (firearm: Firearm) => void
}
function normalizeRequest(values: AddFirearmRequest): AddFirearmRequest {
return {
...values,
review: values.review?.trim() || null,
}
}
export default function FirearmCreateModal({ open, onCancel, onSuccess }: FirearmCreateModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<AddFirearmRequest>()
const [loading, setLoading] = useState(false)
async function onFinish(values: AddFirearmRequest) {
setLoading(true)
try {
const firearm = await FirearmApi.addFirearm(normalizeRequest(values))
message.success("武器创建成功")
form.resetFields()
onSuccess(firearm)
} catch {
message.error("武器创建失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="新建武器"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="创建"
cancelText="取消"
confirmLoading={loading}
destroyOnHidden
afterOpenChange={(visible) => {
if (!visible) {
form.resetFields()
}
}}>
<FirearmForm form={form} onFinish={onFinish} />
</Modal>
)
}
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react"
import { App, Form, Modal } from "antd"
import { FirearmApi } from "@/api"
import FirearmForm from "@/components/firearm-form"
import { AddFirearmRequest, Firearm } from "@/types"
interface FirearmEditModalProps {
open: boolean
firearm: Firearm | null
onCancel: () => void
onSuccess: (firearm: Firearm) => void
}
function normalizeRequest(values: AddFirearmRequest): AddFirearmRequest {
return {
...values,
review: values.review?.trim() || null,
}
}
export default function FirearmEditModal({ open, firearm, onCancel, onSuccess }: FirearmEditModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<AddFirearmRequest>()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open || !firearm) {
return
}
const { id: _id, ...editableValues } = firearm
form.setFieldsValue(editableValues)
}, [open, firearm, form])
async function onFinish(values: AddFirearmRequest) {
if (!firearm) {
return
}
setLoading(true)
try {
const updated = await FirearmApi.editFirearm(firearm.id, normalizeRequest(values))
message.success("武器更新成功")
onSuccess(updated)
} catch {
message.error("武器更新失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="编辑武器"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="保存"
cancelText="取消"
confirmLoading={loading}
destroyOnHidden
>
<FirearmForm form={form} onFinish={onFinish} />
</Modal>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { Form, Input, InputNumber, Select } from "antd"
import { AddFirearmRequest, FirearmType } from "@/types"
import calibres from "@/constant/calibres.json"
const firearmTypeText: Record<FirearmType, string> = {
RIFLE: "步枪",
SUB_MACHINE_GUN: "冲锋枪",
SHOTGUN: "霰弹枪",
LIGHT_MACHINE_GUN: "轻机枪",
DESIGNATED_MARKSMAN_RIFLE: "射手步枪",
SNIPER_RIFLE: "狙击步枪",
PISTOL: "手枪",
SPECIAL: "特殊",
}
const calibreOptions = calibres.map((calibre) => ({
value: calibre,
label: calibre,
}))
interface FirearmFormProps {
form: ReturnType<typeof Form.useForm<AddFirearmRequest>>[0]
onFinish: (values: AddFirearmRequest) => void
}
export default function FirearmForm({ form, onFinish }: FirearmFormProps) {
return (
<Form<AddFirearmRequest> form={form} layout="vertical" onFinish={onFinish} requiredMark={false}>
<Form.Item<AddFirearmRequest>
name="name"
label="武器名称"
rules={[{ required: true, message: "请输入武器名称" }]}
>
<Input placeholder="请输入武器名称" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="type"
label="武器类型"
rules={[{ required: true, message: "请选择武器类型" }]}
>
<Select
placeholder="请选择武器类型"
options={Object.entries(firearmTypeText).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item<AddFirearmRequest>
name="level"
label="武器输出等级"
rules={[{ required: true, message: "请输入武器输出等级" }]}
>
<Input placeholder="例如:T0" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="calibre"
label="子弹口径"
rules={[{ required: true, message: "请选择子弹口径" }]}
>
<Select
placeholder="请选择子弹口径"
showSearch
optionFilterProp="label"
options={calibreOptions}
/>
</Form.Item>
<Form.Item<AddFirearmRequest>
name="fireRate"
label="射速(每分钟发数)"
rules={[{ required: true, message: "请输入射速" }]}
>
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入射速" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="armourDamage"
label="甲伤"
rules={[{ required: true, message: "请输入甲伤" }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入甲伤" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="bodyDamage"
label="肉伤"
rules={[{ required: true, message: "请输入肉伤" }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入肉伤" />
</Form.Item>
<Form.Item<AddFirearmRequest> name="review" label="描述">
<Input.TextArea rows={4} placeholder="可选:补充武器特点或使用建议" />
</Form.Item>
</Form>
)
}
@@ -0,0 +1,17 @@
import React from "react"
interface MarkdownRendererProps {
/** HTML string processed by vite-plugin-markdown */
html: string
/** Optional custom class name */
className?: string
}
export default function MarkdownRenderer({ html, className = "" }: MarkdownRendererProps) {
return (
<article
className={`prose prose-slate max-w-none dark:prose-invert ${className}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
+357
View File
@@ -0,0 +1,357 @@
// ModCodes.tsx
import { useEffect, useState, useCallback, useMemo } from "react";
import { Card, Col, Pagination, Row, Tag, Typography, Button, Popconfirm, Space, Select, App } from "antd";
import { Link } from "react-router-dom";
import { ModificationApi, TagApi } from "@/api";
import { Modification } from "@/types";
import ModificationCreateModal from "@/components/modification-create-modal";
import ModificationEditModal from "@/components/modification-edit-modal";
import { useAppSelector } from "@/hooks/store";
const pageSize = 10; // 常量,不需要 useState
interface ModCodesProps {
firearmId: string; // 从父组件传入
}
export default function ModCodes({ firearmId }: ModCodesProps) {
const { message } = App.useApp();
const user = useAppSelector((state) => state.auth.user);
// ✅ 所有 useState 必须放在组件函数内部
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editingModification, setEditingModification] = useState<Modification | null>(null);
const [loading, setLoading] = useState(false);
const [modifications, setModifications] = useState<Modification[]>([]);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [deletingId, setDeletingId] = useState<number | null>(null);
// 获取标签选项
useEffect(() => {
const _firearmId = firearmId ? Number(firearmId) : undefined;
if (_firearmId) {
TagApi.getTags(_firearmId).then(setTagOptions);
}
}, [firearmId]);
// 加载改枪码列表
const loadModifications = useCallback(async () => {
const numericId = firearmId
if (!numericId) return;
setLoading(true);
try {
const pagedData = await ModificationApi.getModifications({
page: page - 1,
size: pageSize,
sortBy: "id",
direction: "ASC",
firearmId: numericId, // 使用数字类型
tags: selectedTags,
});
setModifications(pagedData.items);
setTotal(pagedData.totalElements);
} finally {
setLoading(false);
}
}, [firearmId, page, selectedTags]);
useEffect(() => {
loadModifications();
}, [loadModifications]);
const handleDelete = async (modification: Modification) => {
setDeletingId(modification.id);
try {
await ModificationApi.removeModification(modification.id);
message.success("删除成功");
if (modifications.length === 1 && page > 1) {
setPage(page - 1);
} else {
await loadModifications();
}
} catch {
message.error("删除失败");
} finally {
setDeletingId(null);
}
};
// 当 firearmId 或标签改变时重置分页
useEffect(() => {
setPage(1);
}, [firearmId, selectedTags]);
const parsedFirearmId = useMemo(() => {
const value = Number(firearmId);
return isNaN(value) ? undefined : value;
}, [firearmId]);
if (!parsedFirearmId) {
return <Typography.Text type="secondary">无效的武器 ID</Typography.Text>;
}
const tagColors = [
'#e28010', // 青绿
'#0EA5E9', // 天蓝
'#8B5CF6', // 紫色
'#F59E0B', // 琥珀
'#EF4444', // 红色
'#EC4899', // 粉红
];
return (
<div className="bg-#1e1e1e">
<div className="mb-4 flex items-start justify-between gap-4 bg-#1e1e1e">
<div className="flex flex-wrap items-center justify-end gap-3 bg-#1e1e1e">
<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>
{user && (
<Button type="primary" onClick={() => setCreateModalOpen(true)}>
添加改装
</Button>
)}
</div>
</div>
<div className="mb-6">
<Row gutter={[16, 16]}>
{modifications.map((modification) => (
<Col key={modification.id} span={24}>
<Card
title={modification.name}
extra={
user ? (
<div className="flex items-center gap-1">
<Button type="link" size="small" onClick={() => setEditingModification(modification)}>
编辑
</Button>
<Popconfirm
title="确认删除改枪码"
description={`确定要删除 ${modification.name} 吗?该操作不可撤销。`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deletingId === modification.id }}
onConfirm={() => handleDelete(modification)}
>
<Button type="link" danger size="small" loading={deletingId === modification.id}>
删除
</Button>
</Popconfirm>
</div>
) : null
}
variant="outlined"
styles={{
root: {
background: '#35333385'
},
header: { minHeight: 56 },
}}
>
<div className="flex flex-col gap-3">
{/* 改枪码行 */}
<div className="flex items-center justify-between gap-2">
<span>
<strong style={{
color: '#10E28C',
fontWeight:800
}}>改枪码:</strong>
<code className="rounded" style={{
color: '#10E28C',
fontWeight:600
}}>
{modification.code}
</code>
</span>
<Button
type="text"
size="small"
onClick={() => navigator.clipboard.writeText(modification.code)}
>
复制
</Button>
</div>
{/* 作者 */}
<div className="flex items-center justify-between gap-2">
<Typography.Text style={{
color:'#B59728',
fontWeight:800
}}>
<strong>作者:</strong>
{modification.author || "未知"}
</Typography.Text>
</div>
{/* 标签列表 */}
{(modification.tags?.length || 0) > 0 && (
<div className="flex flex-wrap gap-2">
{(modification.tags || []).map((tag,idx) => (
<Tag key={`${modification.id}-${tag}`} style={{
background: tagColors[idx % tagColors.length],
font: '800'
}}>{tag}</Tag>
))}
</div>
)}
{/* 配件配置区域 */}
<div>
<div className="flex items-center justify-between gap-2">
<Typography.Text strong>配件配置:</Typography.Text></div>
{(modification.accessories?.length || 0) > 0 ? (
<div className="mt-2 overflow-x-auto">
<div className="grid min-w-[275px] grid-cols-6 gap-2">
{(modification.accessories || []).map((accessory, accessoryIndex) => (
<div
key={`${modification.id}-accessory-${accessoryIndex}`}
className="" style={{
borderRadius: '0.25rem',
border: '2px solid #10E28C',
padding: '0.5rem',
background: '#16343b96'
}}
>
<div className="flex items-center justify-between gap-2 rounded px-2 py-1">
<Typography.Text className="mr-0 text-cyan-400" style={{
color: '#10E28C',
}}>
{accessory.slotName || "未填写槽位"}
</Typography.Text>
<Typography.Text className="mr-0 text-cyan-300" style={{
color: '#ffffff',
}}>
{accessory.accessoryName || "未填写配件"}
</Typography.Text>
</div>
{(accessory.tunings?.length || 0) > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{accessory.tunings.map((tuning, tuningIndex) => (
<Tag
key={`${modification.id}-${accessoryIndex}-tuning-${tuningIndex}`}
style={{
background: '#10E28C',
}}
>
{tuning.tuningName || "未命名"}: {tuning.tuningValue ?? "-"}
</Tag>
))}
</div>
) : null}
</div>
))}
</div>
</div>
) : (
<Typography.Text type="secondary" className="mt-1 block">
暂无配件信息
</Typography.Text>
)}
</div>
{/* 备注 */}
<div className="flex items-center justify-between gap-2">
<Typography.Paragraph
style={{ marginBottom: 0 }}
type="secondary"
ellipsis={{ rows: 3 }}
>
{modification.note || "暂无备注"}
</Typography.Paragraph>
</div>
{/* 视频链接 */}
<div className="flex items-center justify-between gap-2">
{modification.videoUrl && (
<div>
<a
href={modification.videoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300"
>
查看视频
</a>
</div>
)}
</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>
<ModificationCreateModal
open={createModalOpen}
defaultFirearmId={parsedFirearmId}
lockedFirearmId={parsedFirearmId}
onCancel={() => setCreateModalOpen(false)}
onSuccess={() => {
setCreateModalOpen(false);
void loadModifications();
}}
/>
<ModificationEditModal
open={!!editingModification}
modification={editingModification}
lockedFirearmId={parsedFirearmId}
onCancel={() => setEditingModification(null)}
onSuccess={() => {
setEditingModification(null);
void loadModifications();
}}
/>
</div>
);
}
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react"
import { App, Form, Modal } from "antd"
import { ModificationApi } from "@/api"
import ModificationForm from "@/components/modification-form"
import { Modification, ModificationRequest } from "@/types"
interface ModificationCreateModalProps {
open: boolean
defaultFirearmId?: number
lockedFirearmId?: number
onCancel: () => void
onSuccess: (modification: Modification) => void
}
function normalizeRequest(values: ModificationRequest): ModificationRequest {
return {
firearmId: values.firearmId,
name: values.name.trim(),
code: values.code.trim(),
tags: values.tags?.map((tag) => tag.trim()).filter(Boolean) || [],
note: values.note?.trim() || undefined,
author: values.author?.trim() || undefined,
videoUrl: values.videoUrl?.trim() || undefined,
accessories: (values.accessories || []).map((accessory) => ({
slotName: accessory.slotName.trim(),
accessoryName: accessory.accessoryName.trim(),
tunings: (accessory.tunings || []).map((tuning) => ({
tuningName: tuning.tuningName.trim(),
tuningValue: tuning.tuningValue,
})),
})),
}
}
export default function ModificationCreateModal({
open,
defaultFirearmId,
lockedFirearmId,
onCancel,
onSuccess,
}: ModificationCreateModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<ModificationRequest>()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) {
return
}
form.setFieldsValue({
firearmId: lockedFirearmId ?? defaultFirearmId,
accessories: [],
tags: [],
})
}, [open, defaultFirearmId, lockedFirearmId, form])
async function onFinish(values: ModificationRequest) {
setLoading(true)
try {
const modification = await ModificationApi.addModification(
normalizeRequest({
...values,
firearmId: lockedFirearmId ?? values.firearmId,
})
)
message.success("改枪码创建成功")
form.resetFields()
onSuccess(modification)
} catch {
message.error("改枪码创建失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="新建改枪"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="创建"
cancelText="取消"
confirmLoading={loading}
width={820}
destroyOnHidden
afterOpenChange={(visible) => {
if (!visible) {
form.resetFields()
}
}}
>
<ModificationForm form={form} onFinish={onFinish} lockFirearmId={lockedFirearmId} />
</Modal>
)
}
@@ -0,0 +1,100 @@
import { useEffect, useState } from "react"
import { App, Form, Modal } from "antd"
import { ModificationApi } from "@/api"
import ModificationForm from "@/components/modification-form"
import { Modification, ModificationRequest } from "@/types"
interface ModificationEditModalProps {
open: boolean
modification: Modification | null
lockedFirearmId?: number
onCancel: () => void
onSuccess: (modification: Modification) => void
}
function normalizeRequest(values: ModificationRequest): ModificationRequest {
return {
firearmId: values.firearmId,
name: values.name.trim(),
code: values.code.trim(),
tags: values.tags?.map((tag) => tag.trim()).filter(Boolean) || [],
note: values.note?.trim() || undefined,
author: values.author?.trim() || undefined,
videoUrl: values.videoUrl?.trim() || undefined,
accessories: (values.accessories || []).map((accessory) => ({
slotName: accessory.slotName.trim(),
accessoryName: accessory.accessoryName.trim(),
tunings: (accessory.tunings || []).map((tuning) => ({
tuningName: tuning.tuningName.trim(),
tuningValue: tuning.tuningValue,
})),
})),
}
}
export default function ModificationEditModal({
open,
modification,
lockedFirearmId,
onCancel,
onSuccess,
}: ModificationEditModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<ModificationRequest>()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open || !modification) {
return
}
const { id: _id, ...editableValues } = modification
form.setFieldsValue({
...editableValues,
firearmId: lockedFirearmId ?? editableValues.firearmId,
tags: editableValues.tags || [],
accessories: editableValues.accessories || [],
})
}, [open, modification, lockedFirearmId, form])
async function onFinish(values: ModificationRequest) {
if (!modification) {
return
}
setLoading(true)
try {
const updated = await ModificationApi.editModification(
modification.id,
normalizeRequest({
...values,
firearmId: lockedFirearmId ?? values.firearmId,
})
)
message.success("改枪码更新成功")
onSuccess(updated)
} catch {
message.error("改枪码更新失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="编辑改枪"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="保存"
cancelText="取消"
confirmLoading={loading}
width={820}
destroyOnHidden
>
<ModificationForm form={form} onFinish={onFinish} lockFirearmId={lockedFirearmId} />
</Modal>
)
}
@@ -0,0 +1,217 @@
import { useEffect, useMemo, useState } from "react"
import { FirearmApi } from "@/api"
import slotNames from "@/constant/slots.json"
import tuningNames from "@/constant/tunings.json"
import { Firearm, ModificationRequest } from "@/types"
import { AutoComplete, Button, Card, Form, Input, InputNumber, Select, Space } from "antd"
interface ModificationFormProps {
form: ReturnType<typeof Form.useForm<ModificationRequest>>[0]
onFinish: (values: ModificationRequest) => void
lockFirearmId?: number
}
const slotOptions = slotNames.map((slotName) => ({ value: slotName }))
const tuningOptions = tuningNames.map((tuningName) => ({ value: tuningName }))
export default function ModificationForm({ form, onFinish, lockFirearmId }: ModificationFormProps) {
const [firearmOptions, setFirearmOptions] = useState<Array<{ value: number; label: string }>>([])
const [firearmLoading, setFirearmLoading] = useState(false)
useEffect(() => {
let active = true
async function loadAllFirearms() {
setFirearmLoading(true)
try {
const allFirearms: Firearm[] = []
let page = 0
let totalPages = 1
while (page < totalPages) {
const paged = await FirearmApi.getFirearms({
page,
size: 100,
sortBy: "id",
direction: "ASC",
})
allFirearms.push(...paged.items)
totalPages = paged.totalPages
page += 1
}
if (!active) {
return
}
setFirearmOptions(
allFirearms.map((firearm) => ({
value: firearm.id,
label: `${firearm.name}`,
}))
)
} finally {
if (active) {
setFirearmLoading(false)
}
}
}
void loadAllFirearms()
return () => {
active = false
}
}, [])
const mergedFirearmOptions = useMemo(() => {
if (
lockFirearmId === undefined ||
firearmOptions.some((option) => option.value === lockFirearmId)
) {
return firearmOptions
}
return [{ value: lockFirearmId, label: `武器 ID: ${lockFirearmId}` }, ...firearmOptions]
}, [firearmOptions, lockFirearmId])
return (
<Form<ModificationRequest>
form={form}
layout="vertical"
onFinish={onFinish}
requiredMark={false}>
<Form.Item<ModificationRequest>
name="firearmId"
label="武器"
rules={[{ required: true, message: "请输入武器" }]}>
<Select<number>
className="w-full"
placeholder="请选择武器"
options={mergedFirearmOptions}
loading={firearmLoading}
disabled={lockFirearmId !== undefined}
showSearch={{
filterOption: (input, option) => {
const labelText = String(option?.label ?? "")
return labelText.toLowerCase().includes(input.toLowerCase())
},
}}
/>
</Form.Item>
<Form.Item<ModificationRequest>
name="name"
label="改装名称"
rules={[{ required: true, message: "请输入改装名称" }]}>
<Input placeholder="请输入改装名称" />
</Form.Item>
<Form.Item<ModificationRequest>
name="code"
label="改枪码"
rules={[{ required: true, message: "请输入改枪码" }]}>
<Input placeholder="请输入改枪码" />
</Form.Item>
<Form.Item<ModificationRequest> name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", " "]} placeholder="可选:输入后回车" />
</Form.Item>
<Form.Item<ModificationRequest> name="author" label="作者">
<Input placeholder="可选:请输入作者" />
</Form.Item>
<Form.Item<ModificationRequest> name="videoUrl" label="视频链接">
<Input placeholder="可选:请输入视频链接" />
</Form.Item>
<Form.Item<ModificationRequest> name="note" label="备注">
<Input.TextArea rows={3} placeholder="可选:补充说明" />
</Form.Item>
<Form.List name="accessories">
{(accessoryFields, { add: addAccessory, remove: removeAccessory }) => (
<div className="flex flex-col gap-4">
{accessoryFields.map((accessoryField) => (
<Card
key={accessoryField.key}
title={`配件 ${accessoryField.name + 1}`}
size="small"
extra={
<Button
danger
type="link"
size="small"
onClick={() => removeAccessory(accessoryField.name)}>
删除配件
</Button>
}>
<Form.Item
name={[accessoryField.name, "slotName"]}
label="槽位"
rules={[{ required: true, message: "请选择或输入槽位" }]}>
<AutoComplete options={slotOptions} placeholder="请选择或输入槽位" />
</Form.Item>
<Form.Item
name={[accessoryField.name, "accessoryName"]}
label="配件名称"
rules={[{ required: true, message: "请输入配件名称" }]}>
<Input placeholder="请输入配件名称" />
</Form.Item>
<Form.List name={[accessoryField.name, "tunings"]}>
{(tuningFields, { add: addTuning, remove: removeTuning }) => (
<div className="flex flex-col gap-3">
{tuningFields.map((tuningField) => (
<Space key={tuningField.key} align="start" className="w-full" wrap>
<Form.Item
name={[tuningField.name, "tuningName"]}
label="精校属性"
rules={[{ required: true, message: "请选择或输入精校属性" }]}>
<AutoComplete
options={tuningOptions}
placeholder="例如:后坐控制"
className="w-44"
/>
</Form.Item>
<Form.Item
name={[tuningField.name, "tuningValue"]}
label="精校值"
rules={[{ required: true, message: "请输入精校值" }]}>
<InputNumber className="w-32" placeholder="例如:0.35" />
</Form.Item>
<Button
type="link"
danger
className="mt-8"
onClick={() => removeTuning(tuningField.name)}>
删除
</Button>
</Space>
))}
<Button
type="dashed"
disabled={tuningFields.length >= 2}
onClick={() => addTuning({ tuningName: "", tuningValue: 0 })}>
添加精校
</Button>
</div>
)}
</Form.List>
</Card>
))}
<Button
variant="solid"
color="lime"
onClick={() => addAccessory({ slotName: "", accessoryName: "", tunings: [] })}>
添加配件
</Button>
</div>
)}
</Form.List>
</Form>
)
}
+23
View File
@@ -0,0 +1,23 @@
[
".338 Lap Mag",
".357 Magnum",
".45 ACP",
".50 AE",
".50 BMG",
"12 Gauge",
"12.7x55mm",
"5.45x39mm",
"5.56x45mm",
"5.7x28mm",
"5.8x42mm",
"6.8x51mm",
"7.62x39mm",
"7.62x51mm",
"7.62x54mm",
"9x19mm",
"9x39mm",
"4.6x30mm",
".300 BLK",
"箭矢",
"45-70 Govt"
]
+19
View File
@@ -0,0 +1,19 @@
[
"枪口",
"枪管",
"贴片",
"瞄准镜",
"战术设备",
"增高座瞄具",
"侧瞄具",
"枪托",
"托腮板",
"枪托套件",
"导轨脚架",
"前握把",
"后握把",
"后握贴片",
"握把座",
"弹匣",
"弹匣座"
]
+1
View File
@@ -0,0 +1 @@
["安装位置", "厚度", "缩放倍率", "长度", "瞳距", "配重", "托腮板安装位置"]
+29
View File
@@ -0,0 +1,29 @@
# 《三角洲行动游戏指南》最终用户许可协议
> 最近更新日期: 2026年5月6日
感谢您访问《三角洲行动游戏指南》(以下简称“本站”)。在访问或使用本站前,请您务必仔细阅读本协议。
## 第一条:项目性质与声明
本站是由《三角洲行动》游戏玩家开发的兴趣爱好项目,与游戏官方(包括但不限于腾讯、琳恩工作室等)不存在任何形式的关联、授权或代理关系。
本站仅旨在为玩家提供游戏资讯、攻略及技术性指南,不保证资讯的绝对实时性与准确性。
## 第二条:内容版权说明
本站部分指南、攻略及图片素材提取、整理自哔哩哔哩、抖音等视频平台的公开视频,本站均会标注原作者信息或来源。相关内容的知识产权归原作者所有。
由本站开发者原创或邀请的高手提供的特约稿件,其版权归本站或原提供者所有。
严禁任何个人或组织在未经本站或原作者授权的情况下,将本站内容用于商业牟利。
## 第三条:免责声明
游戏机制可能随版本更新而变动,因参考本站指南而产生的任何游戏结果(如战绩波动、游戏内损失等),本站概不负责。
来源标注中可能包含指向第三方平台的链接,本站不对第三方平台内容的合法性或安全性负责。
## 第四条:协议变更
本站保留随时修改本协议的权利。重大变更将通过站点公告形式发布。
+33
View File
@@ -0,0 +1,33 @@
# 《三角洲行动游戏指南》隐私政策
本隐私政策旨在向您说明在无需登录的情况下,本站如何处理与您相关的信息。
## 第一条:信息收集情况
1. **个人信息:** 由于本站采用无需登录的模式,我们不会收集您的姓名、手机号、身份证号或精确地理位置等个人敏感数据。
2. **服务器日志:** 本站未集成任何第三方流量统计工具(如 Google Analytics 或百度统计),不会主动记录您的 IP 地址或用户代理(User-agent)数据。
## 第二条:Cookie 的使用
1. **必要性 Cookie:** 本站仅使用必要的 Cookie 用于保存与后端交互所需的功能性数据(如临时会话标识或您的本地设置偏好)。
2. **非追踪性:** 这些 Cookie 不用于追踪您的个人跨站行为,也不会用于构建您的个人画像。
## 第三条:第三方广告与赞助
1. **现状说明:** 本站目前不包含任何商业广告,亦未开通打赏或赞助入口。
2. **未来规划:** 考虑到运营成本,本站保留未来接入广告服务或赞助链接的权利。届时,广告商可能会根据其自身的隐私政策使用 Cookie。一旦此类功能上线,我们将同步更新本政策。
## 第四条:数据安全
我们致力于保护站点的运行安全,防止现有数据被非法篡改或泄露。
## 第五条:联系我们
若您对本协议或内容版权有任何疑问,请通过以下方式联系:
开发者: Zihlu Wang、Xingyao Fan
联系渠道: [GitHub](https://github.com/zihluwang/delta-force-guide-web)
+5
View File
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from "react-redux"
import type { AppDispatch, RootState } from "@/store"
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+204
View File
@@ -0,0 +1,204 @@
@layer theme, base, antd, components, utilities;
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
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));
}
}
.nav-item {
position: relative;
overflow: hidden;
z-index: 1;
}
.nav-item::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 0;
background: linear-gradient(to top, #22ffa7, transparent);
transition: height 0.2s ease-in-out;
opacity: 0.35;
pointer-events: none;
z-index: -1;
}
.nav-item:hover::after {
height: 30%;
}
/* 激活状态效果 */
.nav-item.active::after {
height: 30%;
}
/* 可选:激活+悬停效果 */
.nav-item.active:hover::after {
opacity: 0.35;
}
.custom-btn-outlined {
--ant-color-solid: #10E28C;
/* 边框和文字颜色 */
--ant-color-solid-hover: #2ee59d;
--ant-color-solid-active: #0cb878;
}
.hex-bg {
background-color: #1E1E1E;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' opacity='0.3' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='fadeDown' x1='0%25' y1='0%25' x2='0%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2339ff14' stop-opacity='1' /%3E%3Cstop offset='100%25' stop-color='%2339ff14' stop-opacity='0' /%3E%3C/linearGradient%3E%3C/defs%3E%3Cg fill='none' stroke='url(%23fadeDown)' stroke-width='0.5'%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546041,-0.85081805,0.85081805,0.52546041,-203.01888079,130.22116071)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-190.65208214,130.43446239)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-197.04868859,141.09544261)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-184.68192833,141.30866893)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-178.2853524,130.6476887)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-172.3151986,141.52187999)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-190.86530846,152.18286021)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-178.49857872,152.39608653)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-203.44526451,151.88976939)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-165.94457787,152.72932323)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-197.30770653,163.1089619)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-184.99694603,163.40205272)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-172.3930642,163.69517406)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-165.94457787,130.86648448)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-178.84158104,174.24726756)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-166.23769921,174.68691905)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-160.08231896,163.69518931)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-159.78921288,141.71168407)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-160.37542504,185.6786793)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-172.68620079,185.53211864)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-209.32536095,140.83235057)'/%3E%3Cpath d='M225 131.704L230.452 134.852 230.452 141.148 225 144.296 219.548 141.148 219.548 134.852Z' transform='matrix(0.52546006,-0.85081827,0.85081827,0.52546006,-215.48072594,129.98715098)'/%3E%3C/g%3E%3C/svg%3E");
background-size: 350px 350px;
background-position: right -90px top -40px;
background-repeat: no-repeat;
margin: 0;
}
.nav-item:hover,
.nav-item.active {
color: white;
}
/* LeftMiddleRightBox.css */
.lmr-container {
display: flex;
width: 100%;
/* 可根据需要调整 */
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
background: '#555555';
color: 'white';
padding: '4px 12px';
border-radius: '4px';
font-size: '14px';
font-weight: '500';
letter-spacing: '0.5px';
}
.lmr-left,
.lmr-middle,
.lmr-right {
padding: 20px;
transition: all 0.2s ease;
height: 100%;
}
.lmr-left {
flex: 1;
background-color: '#555555';
color: 'white';
padding: '4px 12px';
border-radius: '4px';
font-size: '14px';
font-weight: '500';
letter-spacing: '0.5px';
margin: auto;
}
.lmr-middle {
flex: 3;
background-color: '#555555';
color: 'white';
padding: '4px 12px';
border-radius: '4px';
font-size: '14px';
font-weight: '500';
letter-spacing: '0.5px';
}
.lmr-right {
flex: 2;
background-color: '#555555';
color: 'white';
padding: '4px 12px';
border-radius: '4px';
font-size: '14px';
font-weight: '500';
letter-spacing: '0.5px';
}
.content-placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
justify-content: center;
height: 100%;
border-radius: 12px;
padding: 16px;
}
@media (max-width: 768px) {
.lmr-container {
flex-direction: column;
min-height: auto;
}
.lmr-left,
.lmr-middle,
.lmr-right {
border-right: none;
}
.lmr-right {
border-bottom: none;
}
.content-placeholder {
min-height: 100px;
}
}
.css-var-_r_1_.ant-collapse{
--ant-collapse-header-bg:#1e1e1e;
width: 90%;
border: 0;
margin-left: auto;
margin-right: auto;
}
.ant-collapse-body{
background: #1e1e1e;
}
+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>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { Outlet, Link, NavLink } from "react-router-dom"
import { useMemo } from "react"
import dayjs from "dayjs"
import { Dropdown } from "antd"
import {
FileTextOutlined,
GithubOutlined,
LockOutlined,
LoginOutlined,
} from "@ant-design/icons"
import { AuthApi } from "@/api"
import { useAppDispatch, useAppSelector } from "@/hooks/store"
import { clearCurrentUser } from "@/store/auth-slice"
import { useState } from "react"
/**
* 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(), [])
const user = useAppSelector((state) => state.auth.user)
const dispatch = useAppDispatch()
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
async function handleLogout() {
try {
await AuthApi.logout()
} finally {
dispatch(clearCurrentUser())
}
}
return (
<div className="bg-[#1b252a] ">
{/* Navigation Header */}
<header className="bg-[#0b0f14] shadow-sm border-b">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10 h-full">
<div className="flex justify-between items-center h-20">
<div className="flex items-center">
<h1 className="text-xl font-semibold text-white">《三角洲》指南</h1>
</div>
<nav className="flex h-full">
<NavLink
to="/firearms"
className={({ isActive }) =>
`nav-item inline-flex items-center px-10 h-full text-base font-medium transition-all duration-200 ${
isActive ? "active" : ""
} text-gray-500 hover:text-white`
}>
武器列表
</NavLink>
{/* <NavLink
to="/mod-codes"
className={({ isActive }) =>
`nav-item inline-flex items-center px-10 h-full text-base font-medium transition-all duration-200 ${
isActive ? "active" : ""
} text-gray-500 hover:text-white`
}>
改枪码
</NavLink> */}
</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-black border-t border-gray-800">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10 py-8">
<div className="flex flex-wrap justify-center items-center gap-x-6 gap-y-10 text-sm">
<div
className="relative"
onMouseEnter={() => setIsDropdownOpen(true)}
onMouseLeave={() => setIsDropdownOpen(false)}>
<button
className="flex items-center gap-1.5 text-gray-400 hover:text-white transition-colors duration-200 focus:outline-none"
aria-label="GitHub 仓库">
<GithubOutlined className="text-base opacity-80" />
<span>GitHub</span>
<svg
className={`w-3 h-3 transition-transform duration-200 ${isDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isDropdownOpen && (
<div className="absolute top-full left-2/3 -translate-x-1/2 w-18 bg-gray-950 border border-gray-700 rounded-lg shadow-xl py-1 z-20 opacity-60">
<a
href="https://github.com/zihluwang/delta-force-guide-web"
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors">
Web
</a>
<a
href="https://github.com/zihluwang/delta-force-guide-server"
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors">
Server
</a>
</div>
)}
</div>
<span className="text-gray-700 select-none">•</span>
<Link
to="/legal?tab=eula"
className="flex items-center gap-1.5 text-gray-400 hover:text-white transition-colors duration-200">
<FileTextOutlined className="text-lg opacity-80" />
EULA
</Link>
<span className="text-gray-700 select-none">•</span>
<Link
to="/legal?tab=privacy"
className="flex items-center gap-1.5 text-gray-400 hover:text-white transition-colors duration-200">
<LockOutlined className="text-lg opacity-80" />
隐私政策
</Link>
<span className="text-gray-700 select-none">•</span>
{user ? (
<Dropdown
trigger={["hover"]}
menu={{
items: [
{
key: "logout",
label: "退出登录",
danger: true,
onClick: handleLogout,
},
],
}}>
<span className="nav-item inline-flex items-center px-10 h-full text-base font-medium text-gray-500 hover:text-white cursor-pointer">
{user.username}
</span>
</Dropdown>
) : (
<Link
to="/login"
className="flex items-center gap-1.5 text-gray-400 hover:text-white transition-colors duration-200">
<LoginOutlined className="text-lg opacity-80" />
登录
</Link>
)}
</div>
<div className="border-t border-gray-800 my-6" />
<div className="text-center text-xs text-gray-500">
<p>© 2024-{today.year()} Zihlu Wang 和 OnixByte。使用 React 与 TypeScript 构建。</p>
</div>
</div>
</footer>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
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 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>
)
+6
View File
@@ -0,0 +1,6 @@
declare module "*.md" {
const attributes: Record<string, any>
const html: string
const toc: { level: string; content: string; slug: string }[]
export { attributes, html, toc }
}
+437
View File
@@ -0,0 +1,437 @@
import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { FirearmApi } from "@/api"
import FirearmCreateModal from "@/components/firearm-create-modal"
import FirearmEditModal from "@/components/firearm-edit-modal"
import ModCodes from "@/components/mod-codes"
import { useAppSelector } from "@/hooks/store"
import { Firearm, FirearmType } from "@/types"
import { Button, Card, Col, Pagination, Popconfirm, Row, Select, Tag, Typography, App } from "antd"
import { ConfigProvider, theme } from 'antd';
import type { CollapseProps } from 'antd';
import { Collapse } from 'antd';
const firearmTypeText: Record<FirearmType, string> = {
RIFLE: "步枪",
SUB_MACHINE_GUN: "冲锋枪",
SHOTGUN: "霰弹枪",
LIGHT_MACHINE_GUN: "轻机枪",
DESIGNATED_MARKSMAN_RIFLE: "射手步枪",
SNIPER_RIFLE: "狙击步枪",
PISTOL: "手枪",
SPECIAL: "特殊",
}
const darkTheme = {
algorithm: theme.darkAlgorithm,
// token: { colorPrimary: '#00b96b' },
};
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 user = useAppSelector((state) => state.auth.user)
const { message } = App.useApp()
const [page, setPage] = useState<number>(1)
const [typeFilter, setTypeFilter] = useState<FirearmTypeFilter>(allTypeValue)
const [firearms, setFirearms] = useState<Firearm[]>([])
const [total, setTotal] = useState<number>(0)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editingFirearm, setEditingFirearm] = useState<Firearm | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const loadFirearms = useCallback(async () => {
const pagedData = await FirearmApi.getFirearms({
page: page - 1,
size: 12,
sortBy: "id",
direction: "ASC",
type: typeFilter === allTypeValue ? undefined : typeFilter,
})
setFirearms(pagedData.items)
setTotal(pagedData.totalElements)
}, [page, typeFilter])
const [expandedId, setExpandedId] = useState<number | null>(null);
useEffect(() => {
void loadFirearms()
}, [loadFirearms])
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [page]);
async function handleDelete(firearm: Firearm) {
setDeletingId(firearm.id)
try {
await FirearmApi.removeFirearm(firearm.id)
message.success("武器删除成功")
if (firearms.length === 1 && page > 1) {
setPage(page - 1)
} else {
void loadFirearms()
}
} catch {
message.error("武器删除失败,请稍后重试")
} finally {
setDeletingId(null)
}
}
return (
<>
<ConfigProvider theme={darkTheme}>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
{user && (
<Button type="primary" onClick={() => setCreateModalOpen(true)}>
新建武器
</Button>
)}
</div>
<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={24} lg={24}>
<Card
className="hex-bg border-5px-#142c38"
extra={
user ? (
<div className="flex items-center gap-1">
<Button type="link" size="small" onClick={() => setEditingFirearm(firearm)}>
编辑
</Button>
<Popconfirm
title="确认删除武器"
description={`确定要删除 ${firearm.name} 吗?该操作不可撤销。`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deletingId === firearm.id }}
onConfirm={() => handleDelete(firearm)}>
<Button type="link" danger size="small" loading={deletingId === firearm.id}>
删除
</Button>
</Popconfirm>
</div>
) : null
}
variant="outlined"
style={{
height: '100%', display: 'flex', flexDirection: 'column'
}}
styles={{
root: {
border: '1px solid #313131'
},
header: {
borderBottom: '1px solid #303030',
},
body: {
flex: 1,
overflow: 'auto',
padding: '12px',
},
actions: {
flexShrink: 0,
background: '#1e1e1e',
display: 'flex'
}
}}
actions={[
<div>
<Collapse
expandIcon={() => null}
styles={
{
root: {
background: '#1e1e1e',
}
}
}
items={[{
key: '1',
label: (
<Button
variant="outlined"
styles={{
root: {
color: '#10E28C',
border: '1px solid #10E28C',
background: '#16343b96',
width: '20%',
}
}}
>
查看改枪码
</Button>
),
children: <ModCodes firearmId={String(firearm.id)} />
}]}
/>
</div>,
]}>
<div className="flex flex-col gap-3">
<div className="lmr-container">
<div className="lmr-left">
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%'
}}>
<span style={{
display: 'inline-block',
backgroundColor: '#555555',
color: 'white',
padding: '4px 12px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: '500',
letterSpacing: '0.5px'
}}>
{firearm.name}
</span>
<span className="flex items-center justify-between"
style={{
display: 'inline-block',
backgroundColor: '#2d4f5796',
color: 'white',
padding: '4px 12px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: '500',
letterSpacing: '0.5px'
}}>
{firearmTypeText[firearm.type]}
</span></div>
</div>
<div className="lmr-middle">
<div style={{
color: 'white',
padding: '4px 12px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: '500',
letterSpacing: '0.5px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', // 最小宽度160px
gap: '12px',
}}>
{/* 武器输出等级 */}
<div style={{
border: '2px solid #10E28C',
backgroundColor: '#16343b96',
padding: '12px 16px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{
padding: '4px 0px',
borderRadius: '4px',
color: '#10E28C',
fontSize: '13px',
fontWeight: 500,
marginBottom: '6px',
whiteSpace: 'nowrap',
textAlign: 'center'
}}>
武器输出等级
</div>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: '#ffffff'
}}>
{firearm.level}
</div>
</div>
{/* 子弹口径 */}
<div style={{
border: '2px solid #10E28C',
backgroundColor: '#16343b96',
padding: '12px 16px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{
padding: '4px 12px',
borderRadius: '4px',
color: '#10E28C',
fontSize: '13px',
fontWeight: 500,
marginBottom: '6px',
whiteSpace: 'nowrap'
}}>
子弹口径
</div>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: '#ffffff'
}}>
{firearm.calibre}
</div>
</div>
{/* 每秒甲伤 */}
<div style={{
border: '2px solid #10E28C',
backgroundColor: '#16343b96',
padding: '12px 16px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{
padding: '4px 12px',
borderRadius: '4px',
color: '#10E28C',
fontSize: '13px',
fontWeight: 500,
marginBottom: '6px',
whiteSpace: 'nowrap'
}}>
每秒甲伤
</div>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: '#ffffff'
}}>
{asDps(firearm.fireRate, firearm.armourDamage)}
</div>
</div>
{/* 每秒肉伤 */}
<div style={{
border: '2px solid #10E28C',
backgroundColor: '#16343b96',
padding: '12px 16px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{
padding: '4px 12px',
borderRadius: '4px',
color: '#10E28C',
fontSize: '13px',
fontWeight: 500,
marginBottom: '6px',
whiteSpace: 'nowrap'
}}>
每秒肉伤
</div>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: '#ffffff'
}}>
{asDps(firearm.fireRate, firearm.bodyDamage)}
</div>
</div>
</div>
</div>
</div>
<div className="lmr-right">
<Typography.Paragraph
style={{ marginBottom: 0 }}
type="secondary"
ellipsis={{
rows: 3,
tooltip: firearm.review
? {
title: <div style={{ whiteSpace: "pre-line" }}>{firearm.review}</div>,
placement: "topLeft",
}
: false,
}}
className="whitespace-pre-line">
{firearm.review || "暂无描述"}
</Typography.Paragraph>
</div>
</div>
</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>
<FirearmCreateModal
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onSuccess={() => {
setCreateModalOpen(false)
void loadFirearms()
}}
/>
<FirearmEditModal
open={!!editingFirearm}
firearm={editingFirearm}
onCancel={() => setEditingFirearm(null)}
onSuccess={() => {
setEditingFirearm(null)
void loadFirearms()
}}
/>
</ConfigProvider>
</>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { Tabs } from "antd"
import { useSearchParams } from "react-router-dom"
import MarkdownRenderer from "@/components/markdown-renderer"
import { html as EulaHtml } from "@/docs/EULA.md"
import { html as PrivacyHtml } from "@/docs/PrivacyPolicy.md"
const tabKeys = new Set(["eula", "privacy"])
export default function LegalPage() {
const [searchParams, setSearchParams] = useSearchParams()
const rawTab = searchParams.get("tab")
const activeTab = rawTab && tabKeys.has(rawTab) ? rawTab : "eula"
return (
<div className="mx-auto max-w-4xl">
<Tabs
activeKey={activeTab}
onChange={(key) => setSearchParams({ tab: key })}
items={[
{
key: "eula",
label: "最终用户许可协议",
children: <MarkdownRenderer html={EulaHtml} />,
},
{
key: "privacy",
label: "隐私政策",
children: <MarkdownRenderer html={PrivacyHtml} />,
},
]}
/>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { App, Button, Card, Form, Input, Typography } from "antd"
import { AuthApi } from "@/api"
import { useAppDispatch } from "@/hooks/store"
import { setCurrentUser } from "@/store/auth-slice"
import { LoginRequest } from "@/types"
export default function LoginPage() {
const navigate = useNavigate()
const { message } = App.useApp()
const dispatch = useAppDispatch()
const [loading, setLoading] = useState(false)
async function onFinish(values: LoginRequest) {
setLoading(true)
try {
const user = await AuthApi.login(values)
dispatch(setCurrentUser(user))
message.success(`欢迎回来,${user.username}`)
navigate("/firearms")
} catch {
message.error("登录失败,请检查帐号或密码")
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-100 px-4 py-10 sm:py-16">
<div className="mx-auto max-w-md">
<Card variant="borderless" className="shadow-sm">
<Typography.Title level={3} className="mb-2! text-center">
登录
</Typography.Title>
<Typography.Paragraph className="mb-6! text-center text-gray-500!">
使用你的帐号登录后即可继续操作
</Typography.Paragraph>
<Form<LoginRequest> layout="vertical" onFinish={onFinish} requiredMark={false}>
<Form.Item<LoginRequest>
name="principle"
label="帐号"
rules={[{ required: true, message: "请输入帐号" }]}
>
<Input autoComplete="username" placeholder="请输入帐号" />
</Form.Item>
<Form.Item<LoginRequest>
name="credential"
label="密码"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password autoComplete="current-password" placeholder="请输入密码" />
</Form.Item>
<Form.Item className="mb-0!">
<Button type="primary" htmlType="submit" loading={loading} block>
登录
</Button>
</Form.Item>
</Form>
</Card>
</div>
</div>
)
}
+313
View File
@@ -0,0 +1,313 @@
import {
App,
Button,
Card,
Col,
Pagination,
Popconfirm,
Row,
Select,
Space,
Tag,
Typography,
} from "antd"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useSearchParams } from "react-router-dom"
import { ModificationApi, TagApi } from "@/api"
import ModificationCreateModal from "@/components/modification-create-modal"
import ModificationEditModal from "@/components/modification-edit-modal"
import { useAppSelector } from "@/hooks/store"
import { Modification } from "@/types"
const pageSize = 10
export default function ModCodesPage() {
const user = useAppSelector((state) => state.auth.user)
const { message } = App.useApp()
const [searchParams] = useSearchParams()
const firearmId = useMemo(() => searchParams.get("firearmId") || undefined, [searchParams])
const parsedFirearmId = useMemo(() => {
if (!firearmId) {
return undefined
}
const value = Number(firearmId)
return Number.isFinite(value) ? value : undefined
}, [firearmId])
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)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editingModification, setEditingModification] = useState<Modification | null>(null)
useEffect(() => {
const _firearmId = firearmId ? +firearmId : void 0
TagApi.getTags(_firearmId).then((tags) => {
setTagOptions(tags)
})
}, [firearmId])
const loadModifications = useCallback(async () => {
return ModificationApi.getModifications({
page: page - 1,
size: pageSize,
sortBy: "id",
direction: "DESC",
firearmId,
tags: selectedTags,
}).then((pagedData) => {
setModifications(pagedData.items)
setTotal(pagedData.totalElements)
})
}, [page, firearmId, selectedTags])
useEffect(() => {
void loadModifications()
}, [loadModifications])
async function handleDelete(modification: Modification) {
if (!user) {
return
}
setDeletingId(modification.id)
try {
await ModificationApi.removeModification(modification.id)
message.success("改枪码删除成功")
if (modifications.length === 1 && page > 1) {
setPage(page - 1)
} else {
void loadModifications()
}
} catch {
message.error("改枪码删除失败,请稍后重试")
} finally {
setDeletingId(null)
}
}
useEffect(() => {
setPage(1)
}, [firearmId])
useEffect(() => {
setPage(1)
}, [selectedTags])
return (
<>
<div className="mb-4 flex items-start justify-between gap-4">
<Typography.Title level={4} className="mb-0!">
改枪码列表
</Typography.Title>
<div className="flex flex-wrap items-center justify-end gap-3">
<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>
{user && (
<Button type="primary" onClick={() => setCreateModalOpen(true)}>
添加改装
</Button>
)}
</div>
</div>
<div className="mb-6">
<Row gutter={[16, 16]}>
{modifications.map((modification) => (
<Col key={modification.id} span={24}>
<Card
title={modification.name}
extra={
user ? (
<div className="flex items-center gap-1">
<Button
type="link"
size="small"
onClick={() => setEditingModification(modification)}>
编辑
</Button>
<Popconfirm
title="确认删除改枪码"
description={`确定要删除 ${modification.name} 吗?该操作不可撤销。`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deletingId === modification.id }}
onConfirm={() => handleDelete(modification)}>
<Button
type="link"
danger
size="small"
loading={deletingId === modification.id}>
删除
</Button>
</Popconfirm>
</div>
) : null
}
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) > 0 && (
<div className="flex flex-wrap gap-2">
{(modification.tags || []).map((tag) => (
<Tag key={`${modification.id}-${tag}`}>{tag}</Tag>
))}
</div>
)}
<div>
<Typography.Text strong>配件配置:</Typography.Text>
{(modification.accessories?.length || 0) > 0 ? (
<div className="mt-2 overflow-x-auto">
<div className="grid min-w-275 grid-cols-5 gap-2">
{(modification.accessories || []).map((accessory, accessoryIndex) => (
<div
key={`${modification.id}-accessory-${accessoryIndex}`}
className="rounded border border-gray-100 p-2">
<div className="flex items-center justify-between gap-2 rounded bg-gray-50 px-2 py-1">
<Typography color="blue" className="mr-0">
{accessory.slotName || "未填写槽位"}
</Typography>
<Typography className="mr-0 text-[#4C1D95]">
{accessory.accessoryName || "未填写配件"}
</Typography>
</div>
{(accessory.tunings?.length || 0) > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{accessory.tunings.map((tuning, tuningIndex) => (
<Tag
key={`${modification.id}-${accessoryIndex}-tuning-${tuningIndex}`}
color="geekblue">
{tuning.tuningName || "未命名"}: {tuning.tuningValue ?? "-"}
</Tag>
))}
</div>
) : null}
</div>
))}
</div>
</div>
) : (
<Typography.Text type="secondary" className="block mt-1">
暂无配件信息
</Typography.Text>
)}
</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>
<ModificationCreateModal
open={createModalOpen}
defaultFirearmId={parsedFirearmId}
lockedFirearmId={parsedFirearmId}
onCancel={() => setCreateModalOpen(false)}
onSuccess={() => {
setCreateModalOpen(false)
void loadModifications()
}}
/>
<ModificationEditModal
open={!!editingModification}
modification={editingModification}
lockedFirearmId={parsedFirearmId}
onCancel={() => setEditingModification(null)}
onSuccess={() => {
setEditingModification(null)
void loadModifications()
}}
/>
</>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { ComponentType } from "react"
import { createBrowserRouter } from "react-router-dom"
import ErrorPage from "@/components/error-page"
import EmptyLayout from "@/layout/empty-layout"
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,
}
}
}
const hydrateFallbackElement = <div className="px-4 py-6 text-gray-500">页面加载中...</div>
/**
* Main application router configuration using React Router v6.
* Defines all routes and their corresponding components.
*/
const router = createBrowserRouter(
[
{
path: "/",
element: <HeroLayout />,
hydrateFallbackElement,
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")),
},
{
path: "legal",
lazy: lazy(() => import("@/page/legal"))
}
],
},
{
element: <EmptyLayout />,
hydrateFallbackElement,
errorElement: <ErrorPage />,
children: [
{
path: "login",
lazy: lazy(() => import("@/page/login")),
},
],
},
],
{
basename: "/",
}
)
export default router
+6
View File
@@ -0,0 +1,6 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
dayjs.extend(duration)
export default dayjs
+10
View File
@@ -0,0 +1,10 @@
import axios from "axios"
import dayjs from "@/shared/dayjs"
const WebClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
withCredentials: true
})
export { WebClient }
+26
View File
@@ -0,0 +1,26 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import { User } from "@/types"
interface AuthState {
user: User | null
}
const initialState: AuthState = {
user: null,
}
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setCurrentUser(state, action: PayloadAction<User>) {
state.user = action.payload
},
clearCurrentUser(state) {
state.user = null
},
},
})
export const { setCurrentUser, clearCurrentUser } = authSlice.actions
export const authReducer = authSlice.reducer
+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
+47
View File
@@ -0,0 +1,47 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit"
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist"
import createWebStorage from "redux-persist/es/storage/createWebStorage"
import { authReducer } from "./auth-slice"
import { firearmsReducer } from "./firearms-slice"
const storage = createWebStorage(import.meta.env.VITE_REDUX_STORAGE ?? "local")
const persistConfig = {
key: "root",
storage,
whitelist: ["auth", "firearms"],
}
const rootReducer = combineReducers({
auth: authReducer,
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
+11
View File
@@ -0,0 +1,11 @@
export interface LoginRequest {
principle: string
credential: string
}
export interface User {
id: number
username: string
email: string
}
+17
View File
@@ -0,0 +1,17 @@
export type Direction = "ASC" | "DESC"
export interface Page<T> {
items: T[]
page: number
size: number
totalElements: number
totalPages: number
}
export interface PageQueryParams {
page?: number
size?: number
sortBy?: string
direction?: Direction
}
+24
View File
@@ -0,0 +1,24 @@
export type FirearmType =
| "RIFLE"
| "SUB_MACHINE_GUN"
| "SHOTGUN"
| "LIGHT_MACHINE_GUN"
| "DESIGNATED_MARKSMAN_RIFLE"
| "SNIPER_RIFLE"
| "PISTOL"
| "SPECIAL"
export interface Firearm {
id: number
name: string
type: FirearmType
level: string
calibre: string
fireRate: number
armourDamage: number
bodyDamage: number
review: string | null
}
export interface AddFirearmRequest extends Omit<Firearm, "id"> {}
+4
View File
@@ -0,0 +1,4 @@
export * from "./common"
export * from "./firearm"
export * from "./modification"
export * from "./auth"
+24
View File
@@ -0,0 +1,24 @@
export interface Tuning {
tuningName: string
tuningValue: number
}
export interface Accessory {
slotName: string
accessoryName: string
tunings: Tuning[]
}
export interface Modification {
id: number
firearmId: number
name: string
code: string
tags?: string[]
note?: string
author?: string
videoUrl?: string,
accessories: Accessory[]
}
export interface ModificationRequest extends Omit<Modification, "id"> {}
+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
}
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/**
* Redux persistent storage location, use `local` for local storage and `session` for
* session storage
*/
readonly VITE_REDUX_STORAGE: "local" | "session"
/**
* Backend API Base URL
*/
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+10
View File
@@ -0,0 +1,10 @@
import type { Config } from "tailwindcss"
import typography from "@tailwindcss/typography"
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [typography],
} satisfies Config
+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"
]
}
+52
View File
@@ -0,0 +1,52 @@
import { fileURLToPath, URL } from "node:url"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import portChecker from "vite-plugin-port-checker"
import { Mode, plugin as markdown } from "vite-plugin-markdown"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), portChecker(), markdown({ mode: [Mode.HTML, Mode.TOC] })],
base: "/",
build: {
rolldownOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) {
return
}
if (id.includes("react-router")) {
return "router-vendor"
}
if (id.includes("redux") || id.includes("immer")) {
return "redux-vendor"
}
if (id.includes("/node_modules/@ant-design/")) {
return "ant-design-vendor"
}
if (id.includes("/node_modules/rc-")) {
return "antd-rc-vendor"
}
if (
id.includes("/node_modules/react/") ||
id.includes("/node_modules/react-dom/") ||
id.includes("/node_modules/scheduler/")
) {
return "react-vendor"
}
},
},
},
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
})