大迁移
This commit is contained in:
1 parent
b55836be52
commit
e57275b310
991 files changed
+53612
-75053
No files matched your search
+49
-38
@@ -1,57 +1,68 @@
|
||||
# Server runtime environment file example.
|
||||
# Use this file as a template for /opt/daoyou/.env.production or local .env.
|
||||
|
||||
PORT=3000
|
||||
|
||||
# AI底座选择
|
||||
PROVIDER_CHOOSE=ark
|
||||
# AI provider
|
||||
PROVIDER_CHOOSE=deepseek
|
||||
DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_MODEL_USE=deepseek-v4-flash
|
||||
DEEPSEEK_MODEL_FAST_USE=deepseek-v4-flash
|
||||
|
||||
# 硅基流动 ==============
|
||||
# 必需:OpenAI API Key
|
||||
# Optional providers
|
||||
ARK_API_KEY=
|
||||
ARK_BASE_URL=
|
||||
ARK_MODEL_USE=
|
||||
ARK_MODEL_FAST_USE=
|
||||
KIMI_API_KEY=
|
||||
KIMI_BASE_URL=
|
||||
KIMI_MODEL_USE=
|
||||
KIMI_MODEL_FAST_USE=
|
||||
ALIBABA_API_KEY=
|
||||
ALIBABA_BASE_URL=
|
||||
ALIBABA_MODEL_USE=
|
||||
ALIBABA_MODEL_FAST_USE=
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_MODEL_USE=
|
||||
OPENROUTER_MODEL_FAST_USE=
|
||||
OPENAI_API_KEY=
|
||||
# 可选:自定义 API 地址(用于 OpenRouter、DeepSeek 等)
|
||||
OPENAI_BASE_URL=
|
||||
OPENAI_MODEL=
|
||||
FAST_MODEL=
|
||||
|
||||
# 火山引擎配置 =============
|
||||
ARK_API_KEY=
|
||||
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
ARK_MODEL_USE=
|
||||
ARK_MODEL_FAST_USE=
|
||||
# Persistence and jobs
|
||||
DATABASE_URL=postgresql://user:password@db-host:5432/daoyou
|
||||
REDIS_URL=redis://:password@redis-host:6379/0
|
||||
CRON_SECRET=replace-with-a-random-secret
|
||||
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
SUPABASE_AUTH_SERVICE_KEY=
|
||||
# Optional in local dev. When empty, client-side Turnstile check is skipped.
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=replace-with-a-random-secret
|
||||
BETTER_AUTH_URL=https://daoyou.example.com
|
||||
BETTER_AUTH_DB_SCHEMA=better_auth
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Captcha
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
DATABASE_URL=
|
||||
# Database runtime profile: node (Vercel) | worker (Cloudflare Workers)
|
||||
DB_RUNTIME=node
|
||||
# Optional overrides
|
||||
# DB_POOL_MAX=10
|
||||
# DB_IDLE_TIMEOUT=20
|
||||
# DB_PREPARE=false
|
||||
# DB_DEBUG=false
|
||||
# DB_QUERY_CONCURRENCY=4
|
||||
# DB_BATCH_ASSEMBLY=1
|
||||
|
||||
REDIS_URL=
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
|
||||
CRON_SECRET=
|
||||
|
||||
# Admin dashboard config
|
||||
# Admin
|
||||
ADMIN_EMAILS=
|
||||
ADMIN_BROADCAST_BATCH_SIZE=20
|
||||
|
||||
# Email provider (for /admin email broadcast)
|
||||
# Mail
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
MAIL_FROM=
|
||||
MAIL_FROM="万界道友官方 <noreply@example.com>"
|
||||
|
||||
# Admin broadcast config
|
||||
ADMIN_BROADCAST_BATCH_SIZE=20
|
||||
# Feature flags
|
||||
ENABLE_LLM_NAMING=
|
||||
DISABLE_LLM_NAMING=
|
||||
|
||||
# Frontend public build variables
|
||||
# Configure these in GitHub Actions Environment/Repository vars, not in the
|
||||
# server runtime env file passed to `docker run --env-file`.
|
||||
# VITE_TURNSTILE_SITE_KEY=your-public-turnstile-site-key
|
||||
@@ -1,65 +0,0 @@
|
||||
# copilot-instructions.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ] # 按需修改分支
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/daoyou-hono # 改成你的镜像名
|
||||
IMAGE_TAG: latest
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 登录 Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# 构建并推送镜像
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
VITE_TURNSTILE_SITE_KEY=${{ vars.VITE_TURNSTILE_SITE_KEY }}
|
||||
@@ -30,6 +30,7 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
.claude
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD052": false,
|
||||
"MD041": false,
|
||||
"MD033": false,
|
||||
"MD013": false
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# 指定在索引时要忽略的文件或文件夹。请使用逗号分隔条目,支持 my-security/ 和 *.log 等通配符模式。
|
||||
.agent/
|
||||
docs/
|
||||
@@ -1,45 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `app/`: Next.js App Router pages, layouts, and route handlers (`app/api/*`).
|
||||
- `components/`: React components, including the Ink-style UI library under `components/ui/`.
|
||||
- `engine/`: Core game logic (battle, effects, buff, creation, cultivator). This layer is framework-agnostic.
|
||||
- `lib/`: Services, repositories, and infrastructure helpers (Drizzle, Supabase, Redis integrations).
|
||||
- `config/`: System configuration such as buff templates.
|
||||
- `types/` and `utils/`: Shared types and utility functions.
|
||||
- `drizzle/`: Migration artifacts; schema lives under `lib/drizzle/`.
|
||||
- `public/`: Static assets.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm run dev`: Start the local Next.js dev server.
|
||||
- `npm run build`: Production build.
|
||||
- `npm run start`: Run the production server after build.
|
||||
- `npm run lint`: ESLint checks.
|
||||
- `npm run format`: Prettier formatting for code and docs.
|
||||
- `npm test`: Run Jest tests (Node environment).
|
||||
- `npx drizzle-kit push`: Push schema changes to the database.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- TypeScript everywhere; prefer explicit, descriptive names.
|
||||
- Prettier defaults: 2-space indent, single quotes, semicolons, 80-char print width.
|
||||
- ESLint config is Next.js-based; fix lint warnings before PRs.
|
||||
- Use `@/` path alias for root imports.
|
||||
- Code comments and identifiers are English; UI content is Simplified Chinese.
|
||||
|
||||
## Testing Guidelines
|
||||
- Jest is the test runner; tests live alongside modules.
|
||||
- Naming: `*.test.ts` (examples in `engine/`, `utils/`, `lib/`).
|
||||
- Add tests for engine logic and utility changes; keep test runtime under the global 10s timeout.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commit history suggests conventional style with optional emoji: `type(scope): summary`.
|
||||
- Keep subjects short and imperative (e.g., `fix(battle): handle empty log`).
|
||||
- PRs should include: a clear summary, linked issue (if any), and screenshots for UI changes.
|
||||
- Note any schema changes and include migration steps when applicable.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Copy `.env.example` to `.env.local` and populate secrets locally.
|
||||
- Never commit API keys or database credentials.
|
||||
|
||||
## Agent-Specific Instructions
|
||||
- See `CLAUDE.md` and `GEMINI.md` for deeper architecture and workflow notes.
|
||||
@@ -1,316 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
**万界道友(daoyou.org)** 是一个 AIGC 驱动的文字修仙游戏,采用开源协议 GPL-3.0。项目结合 AI 生成内容与严谨的数值系统,提供高自由度的角色塑造体验。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
npm run dev # 启动开发服务器(Next.js)
|
||||
|
||||
# 构建
|
||||
npm run build # 生产环境构建
|
||||
npm run start # 启动生产服务器
|
||||
|
||||
# 代码质量
|
||||
npm run lint # ESLint 检查
|
||||
npm run format # Prettier 格式化
|
||||
|
||||
# 测试
|
||||
npm test # 运行 Jest 测试
|
||||
|
||||
# 数据库
|
||||
npx drizzle-kit push # 推送 schema 到数据库
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: Next.js 16.1.4 (App Router), React 19.2.3, TypeScript 5
|
||||
- **样式**: Tailwind CSS 4
|
||||
- **数据库**: PostgreSQL (Supabase), Drizzle ORM
|
||||
- **缓存**: Upstash Redis
|
||||
- **AI**: DeepSeek / OpenAI (通过 @ai-sdk)
|
||||
- **测试**: Jest 30.2.0, ts-jest 29.4.6
|
||||
|
||||
## 架构概览
|
||||
|
||||
项目采用**三层架构**,职责清晰分离:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ (Next.js Pages + React Components) │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ API Layer (Route Handlers) │
|
||||
│ (app/api/*) │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ Service Layer │
|
||||
│ (lib/services/* + lib/repositories/*) │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ Engine Layer (Pure Logic) │
|
||||
│ (engine/*) - 独立于UI的纯游戏逻辑 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 关键设计原则
|
||||
|
||||
1. **引擎层纯逻辑**: `engine/` 目录完全独立于 UI 和框架,可直接在 Node 环境运行和测试
|
||||
2. **效果系统驱动**: 游戏中大部分机制通过 Effect 系统实现,包含 19 个触发时机和 20 种效果类型
|
||||
3. **AI 与规则结合**: AI 负责内容生成(战报、背景、描述),引擎负责数值计算
|
||||
|
||||
## 核心目录结构
|
||||
|
||||
```
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (website)/ # 网站展示页面
|
||||
│ ├── (game)/ # 游戏主界面
|
||||
│ └── api/ # API 路由
|
||||
├── components/ # React 组件
|
||||
│ └── ui/ # Ink 组件库(水墨风格基础组件)
|
||||
├── engine/ # 核心游戏引擎(纯逻辑)
|
||||
│ ├── battle/ # 战斗引擎(BattleEngine.v2.ts)
|
||||
│ ├── buff/ # Buff 系统(BuffManager, BuffTemplateRegistry)
|
||||
│ ├── effect/ # 效果系统(EffectEngine, 20 种效果类型)
|
||||
│ ├── creation/ # 创建引擎(角色/技能/法宝,策略模式)
|
||||
│ └── cultivator/ # 角色引擎
|
||||
├── lib/ # 基础设施层
|
||||
│ ├── drizzle/ # 数据库 schema 和连接
|
||||
│ ├── repositories/ # 数据仓储层
|
||||
│ ├── services/ # 业务服务层
|
||||
│ └── utils/ # 工具函数
|
||||
├── types/ # TypeScript 类型定义
|
||||
│ ├── constants.ts # 游戏常量(元素、境界、状态效果等)
|
||||
│ ├── dictionaries.ts # 显示信息映射
|
||||
│ └── cultivator.ts # 角色数据模型
|
||||
├── utils/ # 工具函数
|
||||
│ ├── aiClient.ts # AI 客户端封装(text, stream_text, object, tool)
|
||||
│ └── prompts.ts # AI 提示词
|
||||
└── config/ # 配置文件
|
||||
└── buffTemplates.ts # Buff 模板配置(20+ 模板)
|
||||
```
|
||||
|
||||
## 数据库 Schema
|
||||
|
||||
**表前缀**: `wanjiedaoyou_`
|
||||
|
||||
### 核心表
|
||||
|
||||
- **cultivators**: 角色主表,包含基本信息、境界、属性、经济、状态
|
||||
- **spiritual_roots**: 灵根表(1对多)
|
||||
- **pre_heaven_fates**: 先天命格(1对多)
|
||||
- **cultivation_techniques**: 功法(1对多)
|
||||
- **skills**: 技能(1对多)
|
||||
- **artifacts**: 法宝(1对多)
|
||||
- **consumables**: 消耗品(1对多,目前类型只有"丹药")
|
||||
- **materials**: 材料(1对多)
|
||||
- **equipped_items**: 装备状态(1对1)
|
||||
- **battle_records**: 战斗记录(完整 BattleEngineResult 快照 + AIGC 战报)
|
||||
- **mails**: 邮件系统
|
||||
- **dungeon_histories**: 副本历史
|
||||
- **retreat_records**: 闭关记录
|
||||
- **breakthrough_history**: 突破历史
|
||||
|
||||
### JSONB 字段
|
||||
|
||||
大部分表使用 JSONB 存储复杂数据:
|
||||
- `effects`: EffectConfig[] - 效果配置数组
|
||||
- `persistent_statuses`: 持久状态
|
||||
- `cultivation_progress`: 修为进度
|
||||
- `battle_result`: 完整战斗结果快照
|
||||
|
||||
## 游戏常量系统
|
||||
|
||||
**文件**: `types/constants.ts`
|
||||
|
||||
### 元素系统
|
||||
8 种元素:金木水火土风雷冰
|
||||
|
||||
### 境界系统
|
||||
9 个境界:炼气→筑基→金丹→元婴→化神→炼虚→合体→大乘→渡劫
|
||||
每个境界 4 个阶段:初期、中期、后期、圆满
|
||||
|
||||
### 属性上限
|
||||
`REALM_STAGE_CAPS`: 境界+阶段对应的属性上限(圆满与后期共享上限)
|
||||
|
||||
### 灵石产出
|
||||
- `REALM_YIELD_RATES`: 境界历练收益基数
|
||||
- `RANKING_REWARDS`: 排行榜每日结算奖励
|
||||
|
||||
### 状态效果
|
||||
24 种状态效果,分为三类:
|
||||
- 战斗状态:Buff/Debuff/Control/DOT
|
||||
- 持久状态:weakness, enlightenment 等
|
||||
- 环境状态:scorching, freezing 等
|
||||
|
||||
## 效果系统
|
||||
|
||||
**文件**: `engine/effect/types.ts`
|
||||
|
||||
### 触发时机 (EffectTrigger)
|
||||
19 个触发时机,包括:
|
||||
- 属性计算:ON_STAT_CALC
|
||||
- 战斗流程:ON_TURN_START, ON_TURN_END, ON_BATTLE_START, ON_BATTLE_END
|
||||
- 命中相关:ON_CALC_HIT_RATE, ON_DODGE, ON_CRITICAL_HIT, ON_BEING_HIT
|
||||
- 伤害相关:ON_BEFORE_DAMAGE, ON_AFTER_DAMAGE, ON_SKILL_HIT, ON_KILL
|
||||
- 系统事件:ON_BREAKTHROUGH, ON_HEAL, ON_CONSUME, ON_RETREAT, ON_BREAKTHROUGH_CHECK
|
||||
|
||||
### 效果类型 (EffectType)
|
||||
20 种效果类型,包括:
|
||||
- 基础:StatModifier, Damage, Heal, AddBuff, Shield, LifeSteal 等
|
||||
- P0(核心):ElementDamageBonus, HealAmplify, ManaRegen, ManaDrain, Dispel
|
||||
- P1(进阶):ExecuteDamage, TrueDamage, CounterAttack, BonusDamage
|
||||
|
||||
### 属性修正阶段 (StatModifierType)
|
||||
4 个阶段,决定属性计算顺序:
|
||||
1. BASE(基础值)
|
||||
2. FIXED(固定值加成)
|
||||
3. PERCENT(百分比加成)
|
||||
4. FINAL(最终修正)
|
||||
|
||||
### 效果日志系统
|
||||
- **EffectLogCollector**: 统一收集 Effect 执行过程中的日志,用于生成战报和调试
|
||||
|
||||
## Buff 系统
|
||||
|
||||
**文件**: `engine/buff/`, `config/buffTemplates.ts`
|
||||
|
||||
- **BuffManager**: Buff 管理器,处理添加/移除/触发
|
||||
- **BuffMaterializer**: Buff 实体化器,将模板转为可执行的 Buff
|
||||
- **BuffTemplateRegistry**: Buff 模板注册表
|
||||
- **20+ Buff 模板**: 包括属性加成、状态效果、特殊机制等(带 emoji 图标)
|
||||
|
||||
## 战斗引擎
|
||||
|
||||
**文件**: `engine/battle/BattleEngine.v2.ts`
|
||||
|
||||
- 基于时间轴的回合制战斗
|
||||
- 支持 SSE 流式输出
|
||||
- 完整的技能执行器(SkillExecutor)
|
||||
- 伤害管道(DamagePipeline)
|
||||
- 属性计算系统
|
||||
|
||||
## 创建系统
|
||||
|
||||
**文件**: `engine/creation/`
|
||||
|
||||
策略模式实现:
|
||||
- **CreationEngine**: 创建引擎主控
|
||||
- **EffectMaterializer**: 效果实体化器
|
||||
- **SkillCreationStrategy**: 技能创建策略
|
||||
- **GongFaCreationStrategy**: 功法创建策略
|
||||
- **RefiningStrategy**: 炼器策略
|
||||
- **AlchemyStrategy**: 炼丹策略
|
||||
- **CraftCostCalculator**: 资源消耗计算器(灵石/道心感悟)
|
||||
|
||||
## AI 集成
|
||||
|
||||
**文件**: `utils/aiClient.ts`
|
||||
|
||||
支持的 Provider:
|
||||
- DeepSeek
|
||||
- 火山引擎 ARK
|
||||
- Kimi
|
||||
|
||||
主要函数:
|
||||
- `text()`: 直接生成文本
|
||||
- `stream_text()`: 流式生成
|
||||
- `object()`: 生成结构化数据
|
||||
- `objectArray()`: 生成结构化数组
|
||||
- `tool()`: 工具调用
|
||||
|
||||
AI 应用场景:
|
||||
- 角色背景生成
|
||||
- 战斗播报生成(带 HTML 标签)
|
||||
- 突破故事生成
|
||||
- 奇遇场景生成
|
||||
- 物品描述生成
|
||||
|
||||
## 消耗品系统
|
||||
|
||||
当前消耗品类型(`types/constants.ts`):
|
||||
- `CONSUMABLE_TYPE_VALUES = ['丹药']`
|
||||
|
||||
**注意**: 用户计划添加"符箓"类型,用于承载系统性能力(如重塑先天命格、随机抽取功法/神通)。
|
||||
|
||||
## 代码规范
|
||||
|
||||
### 语言使用
|
||||
- 代码:英文变量名和注释
|
||||
- UI/内容:简体中文
|
||||
- 数据库:表前缀 `wanjiedaoyou_`,级联删除 `onDelete: 'cascade'`
|
||||
|
||||
### 路径别名
|
||||
- `@/`: 项目根目录
|
||||
|
||||
### 代码风格
|
||||
- ESLint:Next.js 配置
|
||||
- Prettier:单引号、分号、Tab 宽度 2
|
||||
- 插件:prettier-plugin-tailwindcss, prettier-plugin-organize-imports
|
||||
|
||||
## 测试策略
|
||||
|
||||
- **引擎层**: 12 个测试文件,覆盖战斗、效果、创建系统
|
||||
- **工具层**: 4 个测试文件,覆盖修炼、排行榜、命格、材料生成
|
||||
- 测试环境:Node 环境,超时 10 秒
|
||||
|
||||
## 各模块 CLAUDE.md
|
||||
|
||||
项目包含多个子模块的详细文档:
|
||||
- `engine/battle/CLAUDE.md`: 战斗系统详解
|
||||
- `engine/creation/CLAUDE.md`: 创建系统详解
|
||||
- `engine/effect/CLAUDE.md`: 效果系统详解
|
||||
- `config/CLAUDE.md`: 配置系统详解
|
||||
- `types/CLAUDE.md`: 类型定义详解
|
||||
- `lib/repositories/CLAUDE.md`: 数据仓储层详解
|
||||
- `lib/utils/CLAUDE.md`: 工具函数详解
|
||||
- `lib/drizzle/CLAUDE.md`: 数据库 Schema 详解
|
||||
|
||||
## 环境变量
|
||||
|
||||
必需的环境变量(参考 `.env.example`):
|
||||
|
||||
```bash
|
||||
# AI 配置
|
||||
PROVIDER_CHOOSE=ark|deepseek|kimi
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
OPENAI_MODEL=
|
||||
ARK_API_KEY=
|
||||
ARK_BASE_URL=
|
||||
ARK_MODEL_USE=
|
||||
ARK_MODEL_FAST_USE=
|
||||
KIMI_API_KEY=
|
||||
KIMI_BASE_URL=
|
||||
KIMI_MODEL_USE=
|
||||
KIMI_MODEL_FAST_USE=
|
||||
|
||||
# Supabase 配置
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
DATABASE_URL=
|
||||
|
||||
# Redis 配置
|
||||
REDIS_URL=
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
|
||||
# Cron 任务
|
||||
CRON_SECRET=
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
- **平台**: Vercel
|
||||
- **Cron 任务**:
|
||||
- 每日 01:00:刷新坊市
|
||||
- 每日 00:00:发放排行榜奖励
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM oven/bun:1.3.13 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_TURNSTILE_SITE_KEY
|
||||
|
||||
# 1) 先拷贝依赖描述文件以利用 Docker layer 缓存
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# 2) 拷贝源码并构建
|
||||
COPY . .
|
||||
RUN if [ -n "$VITE_TURNSTILE_SITE_KEY" ]; then \
|
||||
export VITE_TURNSTILE_SITE_KEY; \
|
||||
fi; \
|
||||
bun run build
|
||||
|
||||
FROM oven/bun:1.3.13 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "dist/index.js"]
|
||||
@@ -1,89 +0,0 @@
|
||||
# WanJieDaoYou Project Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
**万界道友 (daoyou.org)** is an open-source, AIGC-driven, text-based cultivation game framework. It aims to provide a "cultivation universe skeleton" that allows for high-freedom gameplay combined with strict numerical and battle models.
|
||||
|
||||
- **Type:** Web Application (Next.js)
|
||||
- **Core Concept:** Text-based interface (`Ink` style), AI-generated content (stories, battles), and a rigorous cultivation system.
|
||||
- **Goal:** To create a platform where players can shape characters via text and AI provides immersive feedback, all grounded in a balanced game engine.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
- **Language:** TypeScript
|
||||
- **Styling:** Tailwind CSS + PostCSS
|
||||
- **Database:** PostgreSQL (Supabase)
|
||||
- **ORM:** Drizzle ORM
|
||||
- **Cache/Queue:** Upstash Redis
|
||||
- **AI Integration:** `@ai-sdk/deepseek`, `@ai-sdk/openai`, `ai` (Vercel AI SDK)
|
||||
- **Testing:** Jest
|
||||
|
||||
## Project Structure & Architecture
|
||||
|
||||
### Key Directories
|
||||
|
||||
- **`app/`**: Next.js App Router structure. Contains pages, layouts, and API routes.
|
||||
- `(auth)/`, `(main)/`, `(info)/`: Route groups for different layouts.
|
||||
- `api/`: Backend API endpoints.
|
||||
- **`components/`**: UI components.
|
||||
- `ui/`: Reusable basic UI elements.
|
||||
- `cultivator/`, `dungeon/`, `battle/`: Feature-specific components.
|
||||
- **`engine/`**: Core game logic (Independent of UI).
|
||||
- `battle/`: Battle engine implementation.
|
||||
- `cultivation/`: Cultivation mechanics.
|
||||
- `creation/`: Character creation logic.
|
||||
- **`lib/`**: Utilities and infrastructure.
|
||||
- `drizzle/`: Database connection (`db.ts`) and schema definition (`schema.ts`).
|
||||
- `services/`: Business logic services.
|
||||
- `utils/`: General helper functions.
|
||||
- **`drizzle/`**: Database migration files.
|
||||
- **`.qoder/`**: Project documentation and knowledge base.
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
- **Separation of Concerns:**
|
||||
- **Engine:** Pure logic, handles complex calculations (battles, breakthroughs).
|
||||
- **Service:** Connects API/UI to Engine and Data layers.
|
||||
- **Repository (inferred):** Data access layer (Drizzle queries).
|
||||
- **AI Integration:** Used for generating dynamic content (stories, battle logs) but constrained by game rules.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Key Commands
|
||||
|
||||
- **Start Development Server:** `npm run dev`
|
||||
- **Build for Production:** `npm run build`
|
||||
- **Start Production Server:** `npm run start`
|
||||
- **Run Tests:** `npm test`
|
||||
- **Lint Code:** `npm run lint`
|
||||
- **Format Code:** `npm run format` (Prettier)
|
||||
- **Database Schema Push:** `npx drizzle-kit push` (Updates DB schema)
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Requires a `.env.local` file with configuration for:
|
||||
|
||||
- OpenAI / DeepSeek API keys.
|
||||
- Supabase (PostgreSQL) URL and Anon Key.
|
||||
- Upstash Redis credentials. _Refer to `ENV_SETUP.md` for details._
|
||||
|
||||
## Development Conventions
|
||||
|
||||
- **Path Aliases:** Use `@/` to refer to the project root (e.g., `@/lib/utils`).
|
||||
- **Styling:** Use Tailwind CSS utility classes. Follow the existing "Ink" (water-ink) aesthetic.
|
||||
- **Database:**
|
||||
- Define schema in `lib/drizzle/schema.ts`.
|
||||
- Tables are prefixed with `wanjiedaoyou_` (configured in `drizzle.config.ts`).
|
||||
- Use Drizzle Kit for migrations.
|
||||
- **AI Usage:** AI is used to enhance the narrative, not to determine game mechanics (combat results are calculated by the engine).
|
||||
- **Language:** The codebase uses English for variable names/comments but the game content and UI are in Simplified Chinese.
|
||||
|
||||
## Documentation
|
||||
|
||||
Detailed internal documentation is located in `.qoder/repowiki`, covering:
|
||||
|
||||
- Battle System
|
||||
- Character System
|
||||
- Database Schema
|
||||
- API Design
|
||||
@@ -1,35 +0,0 @@
|
||||
import { isAdminEmail } from '@/lib/api/adminAuth';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AdminShell } from './_components/AdminShell';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '运营后台 | 万界道友',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
if (!isAdminEmail(user.email)) {
|
||||
redirect('/game');
|
||||
}
|
||||
|
||||
return <AdminShell adminEmail={user.email ?? ''}>{children}</AdminShell>;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Admin route group layout
|
||||
* Keeps admin pages isolated from website/info route groups.
|
||||
*/
|
||||
export default function AdminRouteGroupLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <div className="bg-paper min-h-screen">{children}</div>;
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import TurnstileCaptcha, {
|
||||
type TurnstileCaptchaHandle,
|
||||
} from '@/components/auth/TurnstileCaptcha';
|
||||
import { InkButton } from '@/components/ui/InkButton';
|
||||
import { InkNotice } from '@/components/ui/InkNotice';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function EnterPage() {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, createAnonymousUser } = useAuth();
|
||||
const turnstileRef = useRef<TurnstileCaptchaHandle | null>(null);
|
||||
const turnstileEnabled = Boolean(process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY);
|
||||
|
||||
const [started, setStarted] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
router.replace('/game');
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
const handleEnter = async () => {
|
||||
if (turnstileEnabled && !captchaToken) {
|
||||
setErrorMessage('请先完成人机验证');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
const { error } = await createAnonymousUser(
|
||||
turnstileEnabled ? captchaToken ?? undefined : undefined,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setErrorMessage('创建会话失败,请重试');
|
||||
turnstileRef.current?.reset();
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace('/game');
|
||||
};
|
||||
|
||||
if (isLoading || user) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">正在进入道界……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const steps = turnstileEnabled
|
||||
? ([
|
||||
{ id: 1, label: '唤醒界门' },
|
||||
{ id: 2, label: '人机验证' },
|
||||
{ id: 3, label: '进入道界' },
|
||||
] as const)
|
||||
: ([
|
||||
{ id: 1, label: '唤醒界门' },
|
||||
{ id: 2, label: '进入道界' },
|
||||
] as const);
|
||||
|
||||
const currentStep = !started ? 1 : turnstileEnabled ? (captchaToken ? 3 : 2) : 2;
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-2xl border border-black/10 bg-[#f8f3e6]/95 p-6 shadow-[0_10px_40px_rgba(44,24,16,0.12)]">
|
||||
<div className="pointer-events-none absolute -top-18 -left-20 h-48 w-48 rounded-full bg-amber-700/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -right-20 -bottom-18 h-48 w-48 rounded-full bg-amber-500/10 blur-3xl" />
|
||||
|
||||
<div className="relative z-10 space-y-5">
|
||||
<div className="space-y-2 text-center">
|
||||
<p className="text-ink-secondary text-xs tracking-[0.28em]">
|
||||
BOUNDARY RITE
|
||||
</p>
|
||||
<h1 className="font-heading text-ink text-4xl">入界仪式</h1>
|
||||
<p className="text-ink-secondary text-sm leading-relaxed">
|
||||
你正站在凡尘与道界交界处。
|
||||
<br />
|
||||
完成仪式后,将以匿名神识踏入万界道友录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
steps.length === 3 ? 'grid-cols-3' : 'grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
{steps.map((step) => {
|
||||
const done = step.id < currentStep;
|
||||
const active = step.id === currentStep;
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-md border px-2 py-2 text-center text-xs transition-all ${
|
||||
active
|
||||
? 'border-amber-700/40 bg-amber-700/10 text-amber-800'
|
||||
: done
|
||||
? 'border-teal/30 bg-teal/10 text-teal'
|
||||
: 'bg-paper/40 text-ink-secondary border-black/10'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">第 {step.id} 步</div>
|
||||
<div className="mt-1">{step.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{turnstileEnabled ? (
|
||||
<InkNotice className="my-0 text-sm">
|
||||
此步骤仅用于防止脚本滥用,不会收集你的隐私数据。
|
||||
</InkNotice>
|
||||
) : null}
|
||||
|
||||
{!started ? (
|
||||
<div className="space-y-3">
|
||||
<InkButton
|
||||
onClick={() => setStarted(true)}
|
||||
variant="primary"
|
||||
className="w-full text-base"
|
||||
>
|
||||
点燃界门
|
||||
</InkButton>
|
||||
<InkButton href="/" variant="secondary" className="w-full">
|
||||
返回官网
|
||||
</InkButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{turnstileEnabled ? (
|
||||
<TurnstileCaptcha
|
||||
ref={turnstileRef}
|
||||
onTokenChange={setCaptchaToken}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<InkButton
|
||||
onClick={handleEnter}
|
||||
variant="primary"
|
||||
disabled={submitting}
|
||||
className="w-full text-base"
|
||||
>
|
||||
{submitting ? '界门开启中…' : '踏入道界'}
|
||||
</InkButton>
|
||||
|
||||
<InkButton href="/" variant="secondary" className="w-full">
|
||||
返回官网
|
||||
</InkButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="rounded-md border border-red-400/30 bg-red-500/5 px-3 py-2 text-center text-sm text-red-700/90">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 认证区布局
|
||||
* - 简洁居中布局
|
||||
* - 无底部导航
|
||||
*/
|
||||
export default function AuthLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-md px-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import TurnstileCaptcha, {
|
||||
type TurnstileCaptchaHandle,
|
||||
} from '@/components/auth/TurnstileCaptcha';
|
||||
import { InkPageShell } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import { InkButton } from '@/components/ui/InkButton';
|
||||
import { InkInput } from '@/components/ui/InkInput';
|
||||
import { InkNotice } from '@/components/ui/InkNotice';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { pushToast } = useInkUI();
|
||||
const { user, isLoading } = useAuth();
|
||||
const supabase = createClient();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [step, setStep] = useState<'email' | 'otp'>('email');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
const turnstileRef = useRef<TurnstileCaptchaHandle | null>(null);
|
||||
const turnstileEnabled = Boolean(process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY);
|
||||
|
||||
// Check if user came back from magic link (keeping as fallback)
|
||||
useEffect(() => {
|
||||
const handleMagicLinkCallback = async () => {
|
||||
const code = searchParams.get('code');
|
||||
if (code && !processing) {
|
||||
setProcessing(true);
|
||||
}
|
||||
};
|
||||
handleMagicLinkCallback();
|
||||
}, [searchParams, processing]);
|
||||
|
||||
// Redirect if user is already logged in
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !user.is_anonymous) {
|
||||
pushToast({ message: '真身已召回成功' });
|
||||
router.push('/game');
|
||||
}
|
||||
}, [user, isLoading, router, pushToast]);
|
||||
|
||||
const handleSendOtp = async () => {
|
||||
if (!email.trim()) {
|
||||
pushToast({ message: '请输入飞鸽传书地址', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email.trim())) {
|
||||
pushToast({ message: '飞鸽传书地址格式有误', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && !captchaToken) {
|
||||
pushToast({ message: '请先完成人机验证', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email: email.trim().toLowerCase(),
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
captchaToken: turnstileEnabled ? captchaToken ?? undefined : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.message.includes('rate limit')) {
|
||||
throw new Error('请求过于频繁,请一个时辰后再试');
|
||||
}
|
||||
if (error.message.includes('not found')) {
|
||||
throw new Error('未找到此真身,请先进行神识认主');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
setStep('otp');
|
||||
pushToast({
|
||||
message: '召唤符已发往你的飞鸽传书地址',
|
||||
tone: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : '发送失败,请稍后重试';
|
||||
pushToast({ message: errorMessage, tone: 'danger' });
|
||||
} finally {
|
||||
turnstileRef.current?.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
if (!otp.trim()) {
|
||||
pushToast({ message: '请输入召唤符', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
email: email.trim().toLowerCase(),
|
||||
token: otp.trim(),
|
||||
type: 'email',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
pushToast({ message: '口令验证通过,真身归位', tone: 'success' });
|
||||
// Redirect will happen via useEffect when user state updates
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pushToast({ message: '召唤符有误或已失效', tone: 'danger' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || processing) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">
|
||||
{processing ? '召回真身中……' : '神识感应中……'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【召回真身】"
|
||||
subtitle="已有真身,重归修仙之路"
|
||||
backHref="/game"
|
||||
currentPath="/login"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{step === 'email' ? (
|
||||
<>
|
||||
<InkNotice>
|
||||
若你曾在此修炼,真身已与神识绑定。
|
||||
<br />
|
||||
留下飞鸽传书地址,系统将发送召唤符助你真身归位。
|
||||
</InkNotice>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm opacity-70">
|
||||
飞鸽传书地址(邮箱)
|
||||
</label>
|
||||
<InkInput
|
||||
value={email}
|
||||
onChange={(value) => setEmail(value)}
|
||||
placeholder="例:daoyou@xiuxian.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InkButton
|
||||
onClick={handleSendOtp}
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? '发送中…' : '发送召唤符'}
|
||||
</InkButton>
|
||||
|
||||
{turnstileEnabled ? (
|
||||
<TurnstileCaptcha
|
||||
ref={turnstileRef}
|
||||
onTokenChange={setCaptchaToken}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InkNotice>
|
||||
✓ 召唤符已发送!
|
||||
<br />
|
||||
请查收发送至 <strong>{email}</strong> 的邮件,并填入下方的口令。
|
||||
</InkNotice>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm opacity-70">
|
||||
召唤符(验证码)
|
||||
</label>
|
||||
<InkInput
|
||||
value={otp}
|
||||
onChange={(value) => setOtp(value)}
|
||||
placeholder="请输入8位召唤符"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InkButton
|
||||
onClick={handleVerifyOtp}
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? '验证中…' : '口令认证'}
|
||||
</InkButton>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={() => setStep('email')}
|
||||
className="text-sm opacity-60 transition-opacity hover:opacity-100"
|
||||
disabled={loading}
|
||||
>
|
||||
← 修改地址
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendOtp}
|
||||
className="text-sm opacity-60 transition-opacity hover:opacity-100"
|
||||
disabled={loading}
|
||||
>
|
||||
重发口令
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InkNotice tone="info">
|
||||
未收到?
|
||||
<br />
|
||||
• 请检查垃圾邮件文件夹
|
||||
<br />• 稍等片刻后点击重发
|
||||
</InkNotice>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">神识感应中……</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { BattleReplayViewer } from '@/components/feature/battle/BattleReplayViewer';
|
||||
import type { BattleEngineResult, TurnSnapshot } from '@/engine/battle';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type BattleRecord = {
|
||||
id: string;
|
||||
createdAt: string | null;
|
||||
battleResult: BattleEngineResult;
|
||||
battleReport?: string | null;
|
||||
};
|
||||
|
||||
type BattleRecordResponse = {
|
||||
success: boolean;
|
||||
data: BattleRecord;
|
||||
};
|
||||
|
||||
export default function BattleReplayPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params.id;
|
||||
const { cultivator } = useCultivator();
|
||||
|
||||
const [record, setRecord] = useState<BattleRecord | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchBattleRecord = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/battles/${id}`, { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as BattleRecordResponse;
|
||||
if (data.success) {
|
||||
setRecord(data.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取战斗记录失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBattleRecord();
|
||||
}, [id]);
|
||||
|
||||
if (!record && !loading) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-ink mb-4">未找到该战斗记录</p>
|
||||
<Link href="/game" className="text-ink hover:text-crimson">
|
||||
[返回主页]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const battleResult = record?.battleResult;
|
||||
const currentCultivatorId = cultivator?.id;
|
||||
|
||||
const selfIsOpponent =
|
||||
!!battleResult &&
|
||||
!!currentCultivatorId &&
|
||||
battleResult.opponent === currentCultivatorId;
|
||||
|
||||
const getCultivatorNameById = (
|
||||
result: BattleEngineResult,
|
||||
cultivatorId: string,
|
||||
) => {
|
||||
if (result.winner?.id === cultivatorId) return result.winner.name;
|
||||
if (result.loser?.id === cultivatorId) return result.loser.name;
|
||||
return cultivatorId === currentCultivatorId ? '道友' : '对手';
|
||||
};
|
||||
|
||||
const playerName = battleResult
|
||||
? selfIsOpponent
|
||||
? getCultivatorNameById(battleResult, battleResult.opponent)
|
||||
: getCultivatorNameById(battleResult, battleResult.player)
|
||||
: '道友';
|
||||
const opponentName = battleResult
|
||||
? selfIsOpponent
|
||||
? getCultivatorNameById(battleResult, battleResult.player)
|
||||
: getCultivatorNameById(battleResult, battleResult.opponent)
|
||||
: '对手';
|
||||
|
||||
let timeline: TurnSnapshot[] = battleResult?.timeline ?? [];
|
||||
if (selfIsOpponent && battleResult?.timeline?.length) {
|
||||
// 回放组件固定以 player/opponent 渲染,这里按“当前角色视角”交换左右两侧。
|
||||
timeline = battleResult.timeline.map((snap) => ({
|
||||
...snap,
|
||||
player: snap.opponent,
|
||||
opponent: snap.player,
|
||||
}));
|
||||
}
|
||||
|
||||
const turns = record?.battleResult.turns;
|
||||
const isWin = currentCultivatorId
|
||||
? record?.battleResult.winner?.id === currentCultivatorId
|
||||
: !!record?.battleResult.winner?.name;
|
||||
|
||||
return (
|
||||
<div className="bg-paper min-h-screen">
|
||||
<div className="main-content mx-auto flex max-w-xl flex-col px-4 pt-8 pb-16">
|
||||
<Link href="/game" className="text-ink hover:text-crimson mb-4">
|
||||
[← 返回]
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="font-ma-shan-zheng text-ink text-2xl">
|
||||
【战报回放 · {playerName} vs {opponentName}】
|
||||
</h1>
|
||||
{record?.createdAt && (
|
||||
<p className="text-ink/60 mt-1 text-xs">
|
||||
{new Date(record.createdAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{record && (
|
||||
<BattleReplayViewer
|
||||
playerName={playerName}
|
||||
opponentName={opponentName}
|
||||
timeline={timeline}
|
||||
battleReport={record.battleReport}
|
||||
turns={turns}
|
||||
isWin={isWin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { BattlePageLayout } from '@/components/feature/battle/BattlePageLayout';
|
||||
import { BattleReportViewer } from '@/components/feature/battle/BattleReportViewer';
|
||||
import { BattleTimelineViewer } from '@/components/feature/battle/BattleTimelineViewer';
|
||||
import { InkButton } from '@/components/ui/InkButton';
|
||||
import type { BattleEngineResult } from '@/engine/battle';
|
||||
import type { Cultivator } from '@/types/cultivator';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 挑战战斗播报页内容组件
|
||||
*/
|
||||
function ChallengeBattlePageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [player, setPlayer] = useState<Cultivator | null>(null);
|
||||
const [opponent, setOpponent] = useState<Cultivator | null>(null);
|
||||
const [battleResult, setBattleResult] = useState<BattleEngineResult>();
|
||||
const [streamingReport, setStreamingReport] = useState<string>('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [rankingUpdate, setRankingUpdate] = useState<{
|
||||
isWin: boolean;
|
||||
challengerRank: number | null;
|
||||
targetRank: number | null;
|
||||
remainingChallenges: number;
|
||||
} | null>(null);
|
||||
const [directEntry, setDirectEntry] = useState<{
|
||||
rank: number;
|
||||
} | null>(null);
|
||||
const [battleEnd, setBattleEnd] = useState(false);
|
||||
|
||||
// 防止 React Strict Mode 重复调用战斗 API
|
||||
const hasBattleStarted = useRef(false);
|
||||
|
||||
const targetId = searchParams.get('targetId');
|
||||
|
||||
// 初始化 & 自动开始战斗
|
||||
useEffect(() => {
|
||||
// 防止重复调用
|
||||
if (hasBattleStarted.current) return;
|
||||
|
||||
// 并行执行:获取玩家信息 和 开始战斗
|
||||
const init = async () => {
|
||||
// 2. 自动开始战斗
|
||||
const startBattlePromise = async () => {
|
||||
hasBattleStarted.current = true;
|
||||
await handleChallengeBattle();
|
||||
};
|
||||
|
||||
await startBattlePromise();
|
||||
};
|
||||
|
||||
init();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 执行挑战战斗
|
||||
const handleChallengeBattle = async () => {
|
||||
setLoading(true);
|
||||
setIsStreaming(true);
|
||||
setStreamingReport('');
|
||||
setBattleResult(undefined);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
// 调用挑战战斗接口
|
||||
const response = await fetch('/api/rankings/challenge-battle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
targetId: targetId || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '挑战失败');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流');
|
||||
}
|
||||
|
||||
let fullReport = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.type === 'direct_entry') {
|
||||
// 直接上榜
|
||||
setDirectEntry({ rank: data.rank });
|
||||
setIsStreaming(false);
|
||||
} else if (data.type === 'battle_result') {
|
||||
// 接收战斗结果数据
|
||||
const result = data.data;
|
||||
setBattleResult({
|
||||
winner: result.winner,
|
||||
loser: result.loser,
|
||||
log: result.log,
|
||||
turns: result.turns,
|
||||
playerHp: result.playerHp,
|
||||
opponentHp: result.opponentHp,
|
||||
timeline: result.timeline ?? [],
|
||||
player: result.player,
|
||||
opponent: result.opponent,
|
||||
});
|
||||
|
||||
const isPlayerWin = result.winner.id === result.player;
|
||||
const playerInfo = isPlayerWin ? result.winner : result.loser;
|
||||
const opponentInfo = isPlayerWin ? result.loser : result.winner;
|
||||
setPlayer(playerInfo);
|
||||
setOpponent(opponentInfo);
|
||||
} else if (data.type === 'chunk') {
|
||||
// 接收播报内容块
|
||||
fullReport += data.content;
|
||||
setStreamingReport(fullReport);
|
||||
} else if (data.type === 'ranking_update') {
|
||||
// 接收排名更新信息
|
||||
setRankingUpdate(data);
|
||||
} else if (data.type === 'done') {
|
||||
// 播报生成完成
|
||||
setIsStreaming(false);
|
||||
setStreamingReport(fullReport);
|
||||
setBattleEnd(true);
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error || '挑战失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('挑战战斗失败:', error);
|
||||
setIsStreaming(false);
|
||||
setStreamingReport('');
|
||||
setError(error instanceof Error ? error.message : '挑战失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-crimson mb-4">{error}</p>
|
||||
<InkButton onClick={() => router.push('/rankings')}>
|
||||
返回排行榜
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (directEntry) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="font-ma-shan-zheng text-ink mb-4 text-2xl">
|
||||
成功上榜!
|
||||
</h1>
|
||||
<p className="text-ink mb-6">
|
||||
你已占据万界金榜第 {directEntry.rank} 名
|
||||
</p>
|
||||
<InkButton onClick={() => router.push('/rankings')} variant="primary">
|
||||
返回排行榜
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWin = battleResult?.winner.id === player?.id;
|
||||
const displayReport = streamingReport;
|
||||
const opponentName = opponent?.name ?? '神秘对手';
|
||||
|
||||
return (
|
||||
<BattlePageLayout
|
||||
title={`【挑战战报 · ${!player ? '加载中' : `${player?.name} vs ${opponentName}`}】`}
|
||||
backHref="/game/rankings"
|
||||
backLabel="返回排行榜"
|
||||
error={error}
|
||||
loading={loading}
|
||||
battleResult={battleResult}
|
||||
isStreaming={isStreaming}
|
||||
actions={{
|
||||
primary: {
|
||||
label: '返回排行榜',
|
||||
onClick: () => router.push('/game/rankings'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 数值战斗回放 */}
|
||||
{battleResult?.timeline &&
|
||||
battleResult.timeline.length > 0 &&
|
||||
opponent &&
|
||||
player &&
|
||||
(isStreaming || battleEnd) && (
|
||||
<BattleTimelineViewer battleResult={battleResult} />
|
||||
)}
|
||||
|
||||
{/* 战斗播报 */}
|
||||
<BattleReportViewer
|
||||
displayReport={displayReport}
|
||||
isStreaming={isStreaming}
|
||||
battleResult={battleResult}
|
||||
player={player}
|
||||
isWin={isWin}
|
||||
rankingUpdate={rankingUpdate}
|
||||
/>
|
||||
</BattlePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 挑战战斗播报页
|
||||
*/
|
||||
export default function ChallengeBattlePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-ink">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChallengeBattlePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { BattlePageLayout } from '@/components/feature/battle/BattlePageLayout';
|
||||
import { BattleReportViewer } from '@/components/feature/battle/BattleReportViewer';
|
||||
import { BattleTimelineViewer } from '@/components/feature/battle/BattleTimelineViewer';
|
||||
|
||||
import { useBattleViewModel } from '../hooks/useBattleViewModel';
|
||||
|
||||
/**
|
||||
* 战斗主视图组件
|
||||
*/
|
||||
export function BattleView() {
|
||||
const {
|
||||
player,
|
||||
opponent,
|
||||
battleResult,
|
||||
isStreaming,
|
||||
loading,
|
||||
battleEnd,
|
||||
isWin,
|
||||
displayReport,
|
||||
opponentName,
|
||||
handleBattleAgain,
|
||||
} = useBattleViewModel();
|
||||
|
||||
// 加载中
|
||||
if (!player || !opponent) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-ink">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BattlePageLayout
|
||||
title={`【战报 · ${player?.name} vs ${opponentName}】`}
|
||||
backHref="/game"
|
||||
loading={loading}
|
||||
battleResult={battleResult}
|
||||
isStreaming={isStreaming}
|
||||
actions={{
|
||||
primary: {
|
||||
label: '返回主界',
|
||||
href: '/',
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
label: '再战',
|
||||
onClick: handleBattleAgain,
|
||||
},
|
||||
{
|
||||
label: '分享战报',
|
||||
onClick: () => {
|
||||
alert('分享功能开发中...');
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{/* 数值战斗回放 */}
|
||||
{battleResult?.timeline &&
|
||||
battleResult.timeline.length > 0 &&
|
||||
opponent &&
|
||||
player &&
|
||||
(isStreaming || battleEnd) && (
|
||||
<BattleTimelineViewer battleResult={battleResult} />
|
||||
)}
|
||||
|
||||
{/* 战斗播报 */}
|
||||
<BattleReportViewer
|
||||
displayReport={displayReport}
|
||||
isStreaming={isStreaming}
|
||||
battleResult={battleResult}
|
||||
player={player}
|
||||
isWin={isWin}
|
||||
/>
|
||||
</BattlePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { BattleEngineResult } from '@/engine/battle';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { Cultivator } from '@/types/cultivator';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 敌人数据类型(简化版)
|
||||
*/
|
||||
export type EnemyData = {
|
||||
id: string;
|
||||
name: string;
|
||||
realm: string;
|
||||
realm_stage: string;
|
||||
spiritual_roots: Array<{ element: string; strength: number }>;
|
||||
background?: string;
|
||||
combatRating: number;
|
||||
};
|
||||
|
||||
export interface UseBattleViewModelReturn {
|
||||
// 数据
|
||||
player: Cultivator | null;
|
||||
opponent: EnemyData | null;
|
||||
battleResult?: BattleEngineResult;
|
||||
|
||||
// 状态
|
||||
streamingReport: string;
|
||||
isStreaming: boolean;
|
||||
loading: boolean;
|
||||
battleEnd: boolean;
|
||||
|
||||
// 计算属性
|
||||
isWin: boolean;
|
||||
displayReport: string;
|
||||
opponentName: string;
|
||||
|
||||
// 操作
|
||||
handleBattleAgain: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 战斗页面 ViewModel
|
||||
*/
|
||||
export function useBattleViewModel(): UseBattleViewModelReturn {
|
||||
const searchParams = useSearchParams();
|
||||
const { cultivator } = useCultivator();
|
||||
|
||||
const [player, setPlayer] = useState<Cultivator | null>(null);
|
||||
const [opponent, setOpponent] = useState<EnemyData | null>(null);
|
||||
const [battleResult, setBattleResult] = useState<BattleEngineResult>();
|
||||
const [streamingReport, setStreamingReport] = useState<string>('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [battleEnd, setBattleEnd] = useState(false);
|
||||
|
||||
// 使用 ref 来跟踪最新的 player 和 opponent,避免依赖变化
|
||||
const playerRef = useRef(player);
|
||||
const opponentRef = useRef(opponent);
|
||||
|
||||
// 同步 ref 值
|
||||
useEffect(() => {
|
||||
playerRef.current = player;
|
||||
}, [player]);
|
||||
|
||||
useEffect(() => {
|
||||
opponentRef.current = opponent;
|
||||
}, [opponent]);
|
||||
|
||||
// 执行战斗 - 使用 ref 延迟读取,依赖为空数组
|
||||
const handleBattle = useCallback(
|
||||
async (pId?: string, oId?: string) => {
|
||||
// 延迟读取 ref 中的最新值
|
||||
const currentPid = pId || playerRef.current?.id;
|
||||
const currentOid = oId || opponentRef.current?.id;
|
||||
|
||||
if (!currentPid || !currentOid) return;
|
||||
|
||||
setLoading(true);
|
||||
setIsStreaming(true);
|
||||
setStreamingReport('');
|
||||
setBattleResult(undefined);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/battle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
opponentId: currentOid,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '战斗失败');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
if (!reader) throw new Error('无法读取响应流');
|
||||
|
||||
let fullReport = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.type === 'battle_result') {
|
||||
const result = data.data;
|
||||
setBattleResult({
|
||||
winner: result.winner,
|
||||
loser: result.loser,
|
||||
log: result.log,
|
||||
turns: result.turns,
|
||||
playerHp: result.playerHp,
|
||||
opponentHp: result.opponentHp,
|
||||
timeline: result.timeline ?? [],
|
||||
player: result.player,
|
||||
opponent: result.opponent,
|
||||
});
|
||||
const playerId = result.player;
|
||||
const isPlayerWin = result.winner.id === playerId;
|
||||
const playerInfo = isPlayerWin ? result.winner : result.loser;
|
||||
const opponentInfo = isPlayerWin
|
||||
? result.loser
|
||||
: result.winner;
|
||||
setPlayer(playerInfo);
|
||||
setOpponent(opponentInfo);
|
||||
} else if (data.type === 'chunk') {
|
||||
fullReport += data.content;
|
||||
setStreamingReport(fullReport);
|
||||
} else if (data.type === 'done') {
|
||||
setIsStreaming(false);
|
||||
setStreamingReport(fullReport);
|
||||
setBattleEnd(true);
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error || '战斗失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('战斗失败:', error);
|
||||
setIsStreaming(false);
|
||||
setStreamingReport('');
|
||||
alert(error instanceof Error ? error.message : '战斗失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[], // 空依赖数组,使用 ref 延迟读取
|
||||
);
|
||||
|
||||
// 初始化 & 自动开始战斗
|
||||
useEffect(() => {
|
||||
const opponentId = searchParams.get('opponent');
|
||||
|
||||
const init = async () => {
|
||||
if (!cultivator || !opponentId) return;
|
||||
handleBattle(cultivator.id, opponentId);
|
||||
};
|
||||
|
||||
init();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// 再战一次
|
||||
const handleBattleAgain = useCallback(() => {
|
||||
setBattleResult(undefined);
|
||||
setStreamingReport('');
|
||||
setIsStreaming(false);
|
||||
handleBattle();
|
||||
}, [handleBattle]);
|
||||
|
||||
const isWin = battleResult?.winner.id === player?.id;
|
||||
const displayReport = streamingReport;
|
||||
const opponentName = opponent?.name ?? '神秘对手';
|
||||
|
||||
return {
|
||||
player,
|
||||
opponent,
|
||||
battleResult,
|
||||
streamingReport,
|
||||
isStreaming,
|
||||
loading,
|
||||
battleEnd,
|
||||
isWin,
|
||||
displayReport,
|
||||
opponentName,
|
||||
handleBattleAgain,
|
||||
};
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { BattlePageLayout } from '@/components/feature/battle/BattlePageLayout';
|
||||
import { BattleReportViewer } from '@/components/feature/battle/BattleReportViewer';
|
||||
import { BattleTimelineViewer } from '@/components/feature/battle/BattleTimelineViewer';
|
||||
import { InkButton } from '@/components/ui/InkButton';
|
||||
import type { BattleEngineResult } from '@/engine/battle';
|
||||
import type { Cultivator } from '@/types/cultivator';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type SettlementState = {
|
||||
isWin: boolean;
|
||||
winnerId: string;
|
||||
battleId: string;
|
||||
battleRecordId: string;
|
||||
resultMessage: string;
|
||||
};
|
||||
|
||||
function isBattleResultPayload(
|
||||
data: unknown,
|
||||
): data is { type: 'battle_result'; data: BattleEngineResult } {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'type' in data &&
|
||||
data.type === 'battle_result' &&
|
||||
'data' in data
|
||||
);
|
||||
}
|
||||
|
||||
function isSettlementPayload(
|
||||
data: unknown,
|
||||
): data is { type: 'bet_battle_settlement' } & SettlementState {
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
data === null ||
|
||||
!('type' in data) ||
|
||||
data.type !== 'bet_battle_settlement'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
'isWin' in data &&
|
||||
typeof data.isWin === 'boolean' &&
|
||||
'winnerId' in data &&
|
||||
typeof data.winnerId === 'string' &&
|
||||
'battleId' in data &&
|
||||
typeof data.battleId === 'string' &&
|
||||
'battleRecordId' in data &&
|
||||
typeof data.battleRecordId === 'string' &&
|
||||
'resultMessage' in data &&
|
||||
typeof data.resultMessage === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function BetBattleChallengePageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [player, setPlayer] = useState<Cultivator | null>(null);
|
||||
const [opponent, setOpponent] = useState<Cultivator | null>(null);
|
||||
const [battleResult, setBattleResult] = useState<BattleEngineResult>();
|
||||
const [streamingReport, setStreamingReport] = useState<string>('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [battleEnd, setBattleEnd] = useState(false);
|
||||
const [settlement, setSettlement] = useState<SettlementState | null>(null);
|
||||
|
||||
const hasBattleStarted = useRef(false);
|
||||
|
||||
const battleId = searchParams.get('battleId');
|
||||
const stakeType = searchParams.get('stakeType');
|
||||
const spiritStones = Number(searchParams.get('spiritStones') ?? '0');
|
||||
const itemType = searchParams.get('itemType');
|
||||
const itemId = searchParams.get('itemId');
|
||||
const quantity = Number(searchParams.get('quantity') ?? '1');
|
||||
|
||||
useEffect(() => {
|
||||
if (hasBattleStarted.current) return;
|
||||
hasBattleStarted.current = true;
|
||||
void handleChallengeBattle();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleChallengeBattle = async () => {
|
||||
if (!battleId) {
|
||||
setError('缺少赌战ID,无法应战');
|
||||
return;
|
||||
}
|
||||
|
||||
if (stakeType !== 'spirit_stones' && stakeType !== 'item') {
|
||||
setError('押注类型无效');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stakeType === 'item' &&
|
||||
(!itemType || !itemId || !Number.isFinite(quantity) || quantity < 1)
|
||||
) {
|
||||
setError('应战押注参数不完整');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setIsStreaming(true);
|
||||
setStreamingReport('');
|
||||
setBattleResult(undefined);
|
||||
setError(undefined);
|
||||
setSettlement(null);
|
||||
setBattleEnd(false);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/bet-battles/${battleId}/challenge`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stakeType,
|
||||
spiritStones: stakeType === 'spirit_stones' ? spiritStones : 0,
|
||||
stakeItem:
|
||||
stakeType === 'item' && itemType && itemId
|
||||
? {
|
||||
itemType,
|
||||
itemId,
|
||||
quantity,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '应战失败');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流');
|
||||
}
|
||||
|
||||
let fullReport = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(line.slice(6));
|
||||
} catch (parseError) {
|
||||
console.error('解析 SSE 数据失败:', parseError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
data === null ||
|
||||
!('type' in data) ||
|
||||
typeof data.type !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBattleResultPayload(data)) {
|
||||
const result = data.data;
|
||||
setBattleResult({
|
||||
winner: result.winner,
|
||||
loser: result.loser,
|
||||
log: result.log,
|
||||
turns: result.turns,
|
||||
playerHp: result.playerHp,
|
||||
opponentHp: result.opponentHp,
|
||||
timeline: result.timeline ?? [],
|
||||
player: result.player,
|
||||
opponent: result.opponent,
|
||||
});
|
||||
|
||||
const isPlayerWin = result.winner.id === result.player;
|
||||
const playerInfo = isPlayerWin ? result.winner : result.loser;
|
||||
const opponentInfo = isPlayerWin ? result.loser : result.winner;
|
||||
setPlayer(playerInfo);
|
||||
setOpponent(opponentInfo);
|
||||
} else if (data.type === 'chunk') {
|
||||
const chunk = (data as { content?: string }).content;
|
||||
if (chunk) {
|
||||
fullReport += chunk;
|
||||
setStreamingReport(fullReport);
|
||||
}
|
||||
} else if (isSettlementPayload(data)) {
|
||||
setSettlement({
|
||||
isWin: data.isWin,
|
||||
winnerId: data.winnerId,
|
||||
battleId: data.battleId,
|
||||
battleRecordId: data.battleRecordId,
|
||||
resultMessage: data.resultMessage,
|
||||
});
|
||||
} else if (data.type === 'done') {
|
||||
setIsStreaming(false);
|
||||
setStreamingReport(fullReport);
|
||||
setBattleEnd(true);
|
||||
} else if (data.type === 'error') {
|
||||
const message =
|
||||
(data as { error?: string }).error || '应战失败,请稍后重试';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (requestError) {
|
||||
console.error('应战赌战失败:', requestError);
|
||||
setIsStreaming(false);
|
||||
setStreamingReport('');
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: '应战失败,请稍后重试',
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-crimson mb-4">{error}</p>
|
||||
<InkButton onClick={() => router.push('/game/bet-battle')}>
|
||||
返回赌战台
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWin = battleResult?.winner.id === player?.id;
|
||||
const displayReport = streamingReport;
|
||||
const opponentName = opponent?.name ?? '神秘对手';
|
||||
|
||||
return (
|
||||
<BattlePageLayout
|
||||
title={`【赌战战报 · ${!player ? '加载中' : `${player?.name} vs ${opponentName}`}】`}
|
||||
backHref="/game/bet-battle"
|
||||
backLabel="返回赌战台"
|
||||
error={error}
|
||||
loading={loading}
|
||||
battleResult={battleResult}
|
||||
isStreaming={isStreaming}
|
||||
actions={{
|
||||
primary: {
|
||||
label: '返回赌战台',
|
||||
onClick: () => router.push('/game/bet-battle'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{battleResult?.timeline &&
|
||||
battleResult.timeline.length > 0 &&
|
||||
opponent &&
|
||||
player &&
|
||||
(isStreaming || battleEnd) && (
|
||||
<BattleTimelineViewer battleResult={battleResult} />
|
||||
)}
|
||||
|
||||
<BattleReportViewer
|
||||
displayReport={displayReport}
|
||||
isStreaming={isStreaming}
|
||||
battleResult={battleResult}
|
||||
player={player}
|
||||
isWin={isWin}
|
||||
/>
|
||||
|
||||
{settlement && !isStreaming && (
|
||||
<p
|
||||
className={`mb-6 text-center text-sm ${
|
||||
settlement.isWin ? 'text-crimson' : 'text-ink-secondary'
|
||||
}`}
|
||||
>
|
||||
{settlement.resultMessage}
|
||||
</p>
|
||||
)}
|
||||
</BattlePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BetBattleChallengePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-ink">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BetBattleChallengePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { InkButton } from '@/components/ui/InkButton';
|
||||
import { InkListItem } from '@/components/ui/InkList';
|
||||
import {
|
||||
buffHasAction,
|
||||
getBuffDisplayConfig,
|
||||
} from '@/lib/config/persistentBuffDisplay';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import { fetchJsonCached } from '@/lib/client/requestCache';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface PersistentBuffStatus {
|
||||
id: string;
|
||||
instanceId: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
usesRemaining?: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format remaining time for display
|
||||
* @param expiresAt - Expiration timestamp in milliseconds
|
||||
* @returns Formatted time string (e.g., "3日", "12时", "30分", "已过期")
|
||||
*/
|
||||
function formatRemainingTime(expiresAt: number | undefined): string {
|
||||
if (!expiresAt || expiresAt <= 0) return '永久';
|
||||
|
||||
const now = Date.now();
|
||||
const remaining = expiresAt - now;
|
||||
|
||||
if (remaining <= 0) return '已过期';
|
||||
|
||||
const days = Math.floor(remaining / (24 * 60 * 60 * 1000));
|
||||
const hours = Math.floor(remaining / (60 * 60 * 1000));
|
||||
const minutes = Math.floor(remaining / (60 * 1000));
|
||||
|
||||
if (days >= 1) return `${days}日`;
|
||||
if (hours >= 1) return `${hours}时`;
|
||||
return `${minutes}分`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活的持久Buff状态
|
||||
* 在首页显示当前激活的持久效果(符箓、丹药、伤势等),参考 LifespanStatusCard 的紧凑设计
|
||||
*/
|
||||
export function ActivePersistentBuffs() {
|
||||
const [buffs, setBuffs] = useState<PersistentBuffStatus[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { cultivator } = useCultivator();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchBuffs = async () => {
|
||||
if (!cultivator) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchJsonCached<{ buffs?: PersistentBuffStatus[] }>(
|
||||
'/api/cultivator/persistent-buffs',
|
||||
{
|
||||
key: `home:persistent-buffs:${cultivator.id}`,
|
||||
ttlMs: 30 * 1000,
|
||||
},
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (data.buffs) {
|
||||
setBuffs(data.buffs);
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
console.error(e);
|
||||
} finally {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBuffs();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cultivator]);
|
||||
|
||||
if (loading && buffs.length === 0) {
|
||||
return (
|
||||
<InkListItem
|
||||
title="✨ 激活道韵"
|
||||
description={
|
||||
<div className="py-2 text-center text-sm opacity-60">
|
||||
正在探查道韵...
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (buffs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<InkListItem
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>✨ 激活道韵</span>
|
||||
<span className="text-sm opacity-60">{buffs.length}个效果生效中</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className="mt-2 space-y-2">
|
||||
{buffs.map((buff) => {
|
||||
const config = getBuffDisplayConfig(buff.id);
|
||||
const hasAction = buffHasAction(buff.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={buff.instanceId}
|
||||
className="bg-ink/5 hover:bg-ink/10 flex items-center justify-between rounded p-2 transition-colors"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<span className="text-xl">{buff.icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{buff.name}</span>
|
||||
<span className="text-xs opacity-60">
|
||||
{config?.shortDesc || buff.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{/* Show remaining time if has expiry */}
|
||||
{(config?.showExpiry !== false || buff.expiresAt) &&
|
||||
buff.expiresAt !== undefined && (
|
||||
<div className="text-right">
|
||||
<div className="opacity-60">剩余</div>
|
||||
<div className="text-ink-primary font-bold">
|
||||
{formatRemainingTime(buff.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show uses remaining if has uses */}
|
||||
{(config?.showUses !== false || buff.usesRemaining) &&
|
||||
buff.usesRemaining !== undefined &&
|
||||
buff.usesRemaining > 0 && (
|
||||
<div className="text-right">
|
||||
<div className="opacity-60">机缘</div>
|
||||
<div className="text-ink-primary font-bold">
|
||||
{buff.usesRemaining}次
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show action button if configured */}
|
||||
{hasAction && config?.action && config.path && (
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => router.push(config.path!)}
|
||||
>
|
||||
{config.action}
|
||||
</InkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { MaterialSelector } from '@/app/(game)/game/components/MaterialSelector';
|
||||
import { InkPageShell, InkSection } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import {
|
||||
InkActionGroup,
|
||||
InkButton,
|
||||
InkInput,
|
||||
InkList,
|
||||
InkListItem,
|
||||
InkNotice,
|
||||
} from '@/components/ui';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { Material } from '@/types/cultivator';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type CostEstimate = {
|
||||
spiritStones?: number;
|
||||
comprehension?: number;
|
||||
};
|
||||
|
||||
type CostResponse = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
cost: CostEstimate;
|
||||
canAfford: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function RefinePage() {
|
||||
const { cultivator, note, isLoading } = useCultivator();
|
||||
const [prompt, setPrompt] = useState<string>('');
|
||||
const [selectedMaterialIds, setSelectedMaterialIds] = useState<string[]>([]);
|
||||
const [selectedMaterialMap, setSelectedMaterialMap] = useState<
|
||||
Record<string, Material>
|
||||
>({});
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [materialsRefreshKey, setMaterialsRefreshKey] = useState(0);
|
||||
const [estimatedCost, setEstimatedCost] = useState<CostEstimate | null>(null);
|
||||
const [canAfford, setCanAfford] = useState(true);
|
||||
const { pushToast } = useInkUI();
|
||||
const pathname = usePathname();
|
||||
|
||||
const MAX_MATERIALS = 5;
|
||||
|
||||
// Fetch cost estimate when materials change
|
||||
useEffect(() => {
|
||||
if (selectedMaterialIds.length > 0) {
|
||||
fetchCostEstimate('refine', selectedMaterialIds);
|
||||
} else {
|
||||
setEstimatedCost(null);
|
||||
setCanAfford(true);
|
||||
}
|
||||
}, [selectedMaterialIds]);
|
||||
|
||||
const fetchCostEstimate = async (
|
||||
craftType: string,
|
||||
materialIds: string[],
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/craft?craftType=${craftType}&materialIds=${materialIds.join(',')}`,
|
||||
);
|
||||
const result: CostResponse = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setEstimatedCost(result.data.cost);
|
||||
setCanAfford(result.data.canAfford);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cost estimate:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaterial = (id: string, material?: Material) => {
|
||||
setSelectedMaterialIds((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
setSelectedMaterialMap((map) => {
|
||||
const next = { ...map };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
return prev.filter((mid) => mid !== id);
|
||||
}
|
||||
if (prev.length >= MAX_MATERIALS) {
|
||||
pushToast({
|
||||
message: `炼器炉量力有限,最多投入 ${MAX_MATERIALS} 种灵材`,
|
||||
tone: 'warning',
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
if (material) {
|
||||
setSelectedMaterialMap((map) => ({
|
||||
...map,
|
||||
[id]: material,
|
||||
}));
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!cultivator) {
|
||||
pushToast({ message: '请先在首页觉醒灵根。', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prompt.trim()) {
|
||||
pushToast({
|
||||
message: '请注入神念,描述法宝雏形。',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMaterialIds.length === 0) {
|
||||
pushToast({ message: '巧妇难为无米之炊,请投入灵材。', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setStatus('炉火纯青,真火锤锻……');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/craft', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
materialIds: selectedMaterialIds,
|
||||
prompt: prompt,
|
||||
craftType: 'refine',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || '炼制失败');
|
||||
}
|
||||
|
||||
const successMessage = `【${result.data.name}】出世!`;
|
||||
setStatus(successMessage);
|
||||
pushToast({ message: successMessage, tone: 'success' });
|
||||
setPrompt('');
|
||||
setSelectedMaterialIds([]);
|
||||
setSelectedMaterialMap({});
|
||||
setMaterialsRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const failMessage =
|
||||
error instanceof Error
|
||||
? `炸炉了:${error.message}`
|
||||
: '炼制失败,请稍后再试。';
|
||||
setStatus(failMessage);
|
||||
pushToast({ message: failMessage, tone: 'danger' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !cultivator) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">地火引动中……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【炼器室】"
|
||||
subtitle="千锤百炼,法宝天成"
|
||||
backHref="/game/craft"
|
||||
note={note}
|
||||
currentPath={pathname}
|
||||
footer={
|
||||
<InkActionGroup align="between">
|
||||
<InkButton href="/game/craft">返回</InkButton>
|
||||
<span className="text-ink-secondary text-xs">
|
||||
{selectedMaterialIds.length > 0
|
||||
? `已投入 ${selectedMaterialIds.length} 种灵材`
|
||||
: '请投入灵材开始炼制'}
|
||||
</span>
|
||||
</InkActionGroup>
|
||||
}
|
||||
>
|
||||
<InkSection title="1. 甄选灵材">
|
||||
<MaterialSelector
|
||||
cultivatorId={cultivator?.id}
|
||||
selectedMaterialIds={selectedMaterialIds}
|
||||
onToggleMaterial={toggleMaterial}
|
||||
selectedMaterialMap={selectedMaterialMap}
|
||||
isSubmitting={isSubmitting}
|
||||
pageSize={20}
|
||||
excludeMaterialTypes={[
|
||||
'herb',
|
||||
'gongfa_manual',
|
||||
'skill_manual',
|
||||
'manual',
|
||||
]}
|
||||
refreshKey={materialsRefreshKey}
|
||||
showSelectedMaterialsPanel
|
||||
loadingText="正在检索储物袋中的灵材,请稍候……"
|
||||
emptyNoticeText="暂无可用于炼器的灵材。"
|
||||
totalText={(total) => `共 ${total} 条灵材记录`}
|
||||
/>
|
||||
<p className="text-ink-secondary mt-1 text-right text-xs">
|
||||
{selectedMaterialIds.length}/{MAX_MATERIALS}
|
||||
</p>
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="预计消耗">
|
||||
{estimatedCost ? (
|
||||
<div className="bg-ink/5 border-ink/10 flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm">
|
||||
灵石:
|
||||
<span className="font-bold text-amber-600">
|
||||
{estimatedCost.spiritStones}
|
||||
</span>{' '}
|
||||
枚
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${canAfford ? 'text-emerald-600' : 'text-red-600'}`}
|
||||
>
|
||||
{canAfford ? '✓ 资源充足' : '✗ 灵石不足'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<InkNotice>请先选择材料以查看消耗</InkNotice>
|
||||
)}
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="2. 注入神识">
|
||||
<div className="mb-4">
|
||||
<InkList dense>
|
||||
<InkListItem
|
||||
title="提示"
|
||||
description="描述你期望的法宝类型(如剑、印、塔)、属性偏向甚至名字。"
|
||||
/>
|
||||
<InkListItem
|
||||
title="示例"
|
||||
description="“我想炼制一把带有雷电之力的飞剑,剑身要轻盈。”"
|
||||
/>
|
||||
</InkList>
|
||||
</div>
|
||||
|
||||
<InkInput
|
||||
multiline
|
||||
rows={6}
|
||||
placeholder="请在此注入你的神念……"
|
||||
value={prompt}
|
||||
onChange={(value) => setPrompt(value)}
|
||||
disabled={isSubmitting}
|
||||
hint="💡 灵材特性与神念越契合,成品品质越高。"
|
||||
/>
|
||||
|
||||
<InkActionGroup align="right">
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setPrompt('');
|
||||
setStatus('');
|
||||
setSelectedMaterialIds([]);
|
||||
setSelectedMaterialMap({});
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
重置
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!prompt.trim() ||
|
||||
selectedMaterialIds.length === 0 ||
|
||||
!canAfford
|
||||
}
|
||||
>
|
||||
{isSubmitting ? '真火炼中……' : '开炉炼器'}
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
</InkSection>
|
||||
|
||||
{status && (
|
||||
<div className="mt-4">
|
||||
<InkNotice tone="info">{status}</InkNotice>
|
||||
</div>
|
||||
)}
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { GongFa, LingGen, ShenTong } from '@/components/func';
|
||||
import { InkPageShell, InkSection } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import {
|
||||
InkActionGroup,
|
||||
InkBadge,
|
||||
InkButton,
|
||||
InkDialog,
|
||||
type InkDialogState,
|
||||
InkList,
|
||||
InkListItem,
|
||||
InkNotice,
|
||||
InkStatRow,
|
||||
InkStatusBar,
|
||||
} from '@/components/ui';
|
||||
import { EffectCard } from '@/components/ui/EffectCard';
|
||||
import { CultivatorUnit } from '@/engine/cultivator';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { Attributes } from '@/types/cultivator';
|
||||
import { getAttributeInfo, getEquipmentSlotInfo } from '@/types/dictionaries';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function CultivatorPage() {
|
||||
const { cultivator, inventory, skills, equipped, isLoading } =
|
||||
useCultivator();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { pushToast } = useInkUI();
|
||||
const [dialog, setDialog] = useState<InkDialogState | null>(null);
|
||||
|
||||
const handleReincarnate = async () => {
|
||||
if (!cultivator) return;
|
||||
try {
|
||||
const res = await fetch('/api/cultivator/active-reincarnate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '兵解失败');
|
||||
|
||||
// 成功后跳转到转世页
|
||||
router.push('/reincarnate');
|
||||
} catch (err) {
|
||||
pushToast({
|
||||
message: err instanceof Error ? err.message : '兵解失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !cultivator) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">道友真形尚在凝聚……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cultivator) {
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【道我真形】"
|
||||
subtitle="需先觉醒方可照鉴真形"
|
||||
backHref="/game"
|
||||
actions={
|
||||
<InkButton href="/game/create" variant="primary">
|
||||
觉醒灵根
|
||||
</InkButton>
|
||||
}
|
||||
>
|
||||
<div className="text-center">
|
||||
尚无角色资料,先去觉醒灵根,再来凝视真形。
|
||||
</div>
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算最终属性
|
||||
const unit = new CultivatorUnit(cultivator);
|
||||
const finalAttrs = unit.getFinalAttributes();
|
||||
const maxHp = unit.getMaxHp();
|
||||
const maxMp = unit.getMaxMp();
|
||||
|
||||
const equippedItems = inventory.artifacts.filter(
|
||||
(item) =>
|
||||
item.id &&
|
||||
(equipped.weapon === item.id ||
|
||||
equipped.armor === item.id ||
|
||||
equipped.accessory === item.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title={`道我真形`}
|
||||
subtitle="大道五十,我遁其一"
|
||||
backHref="/game"
|
||||
currentPath={pathname}
|
||||
footer={
|
||||
<InkActionGroup align="between">
|
||||
<InkButton href="/game">返回</InkButton>
|
||||
<InkButton
|
||||
onClick={() =>
|
||||
setDialog({
|
||||
id: 'reincarnate-confirm',
|
||||
title: '轮回重修',
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
<p className="text-crimson text-lg font-bold">
|
||||
道友当真要轮回重修?
|
||||
</p>
|
||||
<p>
|
||||
轮回后,当前修为将尽数散去,
|
||||
<span className="text-crimson">
|
||||
角色状态变为「已陨落」
|
||||
</span>
|
||||
。
|
||||
</p>
|
||||
<p>
|
||||
但可保留部分前世记忆(名字、故事)进入轮回,开启新的一世。
|
||||
</p>
|
||||
<p className="text-sm opacity-60">此操作不可撤销。</p>
|
||||
</div>
|
||||
),
|
||||
confirmLabel: '轮回',
|
||||
cancelLabel: '不可',
|
||||
onConfirm: handleReincarnate,
|
||||
})
|
||||
}
|
||||
variant="secondary"
|
||||
>
|
||||
转世重修
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
}
|
||||
>
|
||||
<InkSection title="【道身】">
|
||||
<InkList dense>
|
||||
<InkListItem
|
||||
title={
|
||||
<span>
|
||||
☯ 姓名:{cultivator.name}
|
||||
<InkBadge tier={cultivator.realm} className="ml-2">
|
||||
{cultivator.realm_stage}
|
||||
</InkBadge>
|
||||
</span>
|
||||
}
|
||||
meta={
|
||||
<div className="py-1">
|
||||
<p>身世:{cultivator.origin || '散修'}</p>
|
||||
<p>性格:{cultivator.personality}</p>
|
||||
<p>背景:{cultivator.background}</p>
|
||||
{cultivator?.balance_notes && (
|
||||
<p>天道评语:{cultivator.balance_notes}</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<InkStatusBar
|
||||
className="mt-2 grid! grid-cols-3! gap-2"
|
||||
items={[
|
||||
{ label: '年龄:', value: cultivator.age, icon: '⏳' },
|
||||
{ label: '寿元:', value: cultivator.lifespan, icon: '🔮' },
|
||||
{
|
||||
label: '性别:',
|
||||
value: cultivator.gender,
|
||||
icon: cultivator.gender === '男' ? '♂' : '♀',
|
||||
},
|
||||
{ label: '气血:', value: maxHp, icon: '❤️' },
|
||||
{ label: '灵力:', value: maxMp, icon: '⚡️' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</InkList>
|
||||
</InkSection>
|
||||
|
||||
<LingGen spiritualRoots={cultivator.spiritual_roots || []} />
|
||||
|
||||
{cultivator.pre_heaven_fates?.length > 0 && (
|
||||
<InkSection title="【先天命格】">
|
||||
<InkList>
|
||||
{cultivator.pre_heaven_fates.map((fate, idx) => (
|
||||
<EffectCard
|
||||
key={fate.name + idx}
|
||||
name={fate.name}
|
||||
quality={fate.quality}
|
||||
effects={fate.effects}
|
||||
description={fate.description}
|
||||
/>
|
||||
))}
|
||||
</InkList>
|
||||
</InkSection>
|
||||
)}
|
||||
|
||||
<InkSection title="【根基属性】">
|
||||
{Object.entries(cultivator.attributes).map(([key, baseValue]) => {
|
||||
const attrKey = key as keyof Attributes;
|
||||
const attrInfo = getAttributeInfo(attrKey);
|
||||
const finalValue = finalAttrs[attrKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() =>
|
||||
setDialog({
|
||||
id: `attr-help-${key}`,
|
||||
title: `${attrInfo.icon} ${attrInfo.label}`,
|
||||
content: attrInfo.description,
|
||||
confirmLabel: '明悟',
|
||||
})
|
||||
}
|
||||
className="hover:bg-ink/5 -mx-2 cursor-pointer rounded px-2 transition-colors"
|
||||
>
|
||||
<InkStatRow
|
||||
label={`${attrInfo.icon} ${attrInfo.label}`}
|
||||
base={baseValue}
|
||||
final={finalValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-ink-secondary mt-2 text-xs">
|
||||
当前境界:{cultivator.realm}(点击属性可查看详情)
|
||||
</p>
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="【当前所御法宝】">
|
||||
{equippedItems.length > 0 ? (
|
||||
<InkList>
|
||||
{equippedItems.map((item) => {
|
||||
const slotInfo = getEquipmentSlotInfo(item.slot);
|
||||
|
||||
return (
|
||||
<EffectCard
|
||||
key={item.id}
|
||||
icon={slotInfo.icon}
|
||||
name={item.name}
|
||||
quality={item.quality}
|
||||
badgeExtra={
|
||||
<InkBadge tone="default">{`${item.element} · ${slotInfo.label}`}</InkBadge>
|
||||
}
|
||||
effects={item.effects}
|
||||
description={item.description}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</InkList>
|
||||
) : (
|
||||
<InkNotice>尚未佩戴法宝</InkNotice>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<InkButton href="/game/inventory" className="text-sm">
|
||||
前往储物袋更换装备 →
|
||||
</InkButton>
|
||||
</div>
|
||||
</InkSection>
|
||||
|
||||
<GongFa cultivations={cultivator.cultivations || []} />
|
||||
|
||||
<ShenTong
|
||||
skills={skills}
|
||||
footer={
|
||||
skills.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<InkButton href="/game/skills" className="text-sm">
|
||||
所有神通一览 →
|
||||
</InkButton>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<InkDialog dialog={dialog} onClose={() => setDialog(null)} />
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { BattlePageLayout } from '@/components/feature/battle/BattlePageLayout';
|
||||
import { BattleReportViewer } from '@/components/feature/battle/BattleReportViewer';
|
||||
import { BattleTimelineViewer } from '@/components/feature/battle/BattleTimelineViewer';
|
||||
import { BattleEngineResult } from '@/engine/battle';
|
||||
import type { ResourceOperation } from '@/engine/resource/types';
|
||||
import {
|
||||
DungeonRound,
|
||||
DungeonSettlement,
|
||||
DungeonState,
|
||||
} from '@/lib/dungeon/types';
|
||||
import { useBattle } from '@/lib/hooks/dungeon/useBattle';
|
||||
import { Cultivator } from '@/types/cultivator';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface BattleCallbackData {
|
||||
isFinished: boolean;
|
||||
settlement?: DungeonSettlement;
|
||||
realGains?: ResourceOperation[];
|
||||
dungeonState?: DungeonState;
|
||||
roundData?: DungeonRound;
|
||||
}
|
||||
|
||||
interface DungeonBattleProps {
|
||||
battleId: string;
|
||||
player: Cultivator;
|
||||
onBattleComplete: (data: BattleCallbackData | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 副本战斗组件
|
||||
* 处理战斗执行和展示
|
||||
*/
|
||||
export function DungeonBattle({
|
||||
battleId,
|
||||
player,
|
||||
onBattleComplete,
|
||||
}: DungeonBattleProps) {
|
||||
const {
|
||||
battleResult,
|
||||
streamingReport,
|
||||
isStreaming,
|
||||
battleEnd,
|
||||
executeBattle,
|
||||
} = useBattle();
|
||||
const [battleSettlement, setBattleSettlement] =
|
||||
useState<BattleCallbackData | null>(null);
|
||||
const hasExecuted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExecuted.current) return;
|
||||
hasExecuted.current = true;
|
||||
|
||||
const runBattle = async () => {
|
||||
const result = await executeBattle(battleId);
|
||||
if (result?.callbackData) {
|
||||
if (result.callbackData.isFinished) {
|
||||
setBattleSettlement({
|
||||
isFinished: true,
|
||||
settlement: result.callbackData.settlement,
|
||||
realGains: result.callbackData.realGains,
|
||||
});
|
||||
} else {
|
||||
setBattleSettlement(result.callbackData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runBattle();
|
||||
}, [battleId, executeBattle, onBattleComplete]);
|
||||
|
||||
return (
|
||||
<BattlePageLayout
|
||||
title={`【激战 · 副本探索】`}
|
||||
backHref="#"
|
||||
loading={!battleResult && isStreaming}
|
||||
battleResult={battleResult}
|
||||
isStreaming={isStreaming}
|
||||
actions={{
|
||||
primary: {
|
||||
label: battleSettlement?.isFinished
|
||||
? '查看结算'
|
||||
: battleEnd
|
||||
? '继续探险'
|
||||
: '战斗中...',
|
||||
onClick: () => {
|
||||
if (battleSettlement) {
|
||||
onBattleComplete(battleSettlement);
|
||||
} else if (battleEnd) {
|
||||
onBattleComplete(null);
|
||||
}
|
||||
},
|
||||
disabled: !battleEnd && !battleSettlement,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Timeline */}
|
||||
{battleResult?.timeline && battleResult.timeline.length > 0 && (
|
||||
<BattleTimelineViewer
|
||||
battleResult={battleResult as BattleEngineResult}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Report */}
|
||||
<BattleReportViewer
|
||||
displayReport={streamingReport}
|
||||
isStreaming={isStreaming}
|
||||
battleResult={battleResult}
|
||||
player={player}
|
||||
isWin={battleResult?.winner.id === player?.id}
|
||||
/>
|
||||
</BattlePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { MaterialSelector } from '@/app/(game)/game/components/MaterialSelector';
|
||||
import { InkPageShell, InkSection } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import {
|
||||
InkActionGroup,
|
||||
InkBadge,
|
||||
InkButton,
|
||||
InkInput,
|
||||
InkList,
|
||||
InkListItem,
|
||||
InkNotice,
|
||||
} from '@/components/ui';
|
||||
import { EffectDetailModal } from '@/components/ui/EffectDetailModal';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { CultivationTechnique, Material } from '@/types/cultivator';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MAX_MATERIALS = 5;
|
||||
|
||||
type CostEstimate = {
|
||||
spiritStones?: number;
|
||||
comprehension?: number;
|
||||
};
|
||||
|
||||
type CostResponse = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
cost: CostEstimate;
|
||||
canAfford: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function GongfaCreationPage() {
|
||||
const router = useRouter();
|
||||
const { cultivator, refreshCultivator, note, isLoading } = useCultivator();
|
||||
const [prompt, setPrompt] = useState<string>('');
|
||||
const [selectedMaterialIds, setSelectedMaterialIds] = useState<string[]>([]);
|
||||
const [selectedMaterialMap, setSelectedMaterialMap] = useState<
|
||||
Record<string, Material>
|
||||
>({});
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [createdGongfa, setCreatedGongfa] =
|
||||
useState<CultivationTechnique | null>(null);
|
||||
const [materialsRefreshKey, setMaterialsRefreshKey] = useState(0);
|
||||
const [estimatedCost, setEstimatedCost] = useState<CostEstimate | null>(null);
|
||||
const [canAfford, setCanAfford] = useState(true);
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const checkPending = async () => {
|
||||
if (!cultivator) return;
|
||||
try {
|
||||
const res = await fetch('/api/craft/pending?type=create_gongfa');
|
||||
const data = await res.json();
|
||||
if (data.success && data.hasPending) {
|
||||
openDialog({
|
||||
title: '感应天机',
|
||||
content: (
|
||||
<p className="py-2">
|
||||
系统感应到道友先前参悟了一门功法,但尚未将其纳入道基。是否立即前往处理?
|
||||
</p>
|
||||
),
|
||||
confirmLabel: '继续参悟',
|
||||
cancelLabel: '暂不处理',
|
||||
onConfirm: () => {
|
||||
router.push('/game/enlightenment/replace?type=create_gongfa');
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查待定失败:', e);
|
||||
}
|
||||
};
|
||||
checkPending();
|
||||
}, [cultivator, openDialog, router]);
|
||||
|
||||
// Fetch cost estimate when materials change
|
||||
useEffect(() => {
|
||||
if (selectedMaterialIds.length > 0) {
|
||||
fetchCostEstimate('create_gongfa', selectedMaterialIds);
|
||||
} else {
|
||||
setEstimatedCost(null);
|
||||
setCanAfford(true);
|
||||
}
|
||||
}, [selectedMaterialIds]);
|
||||
|
||||
const fetchCostEstimate = async (
|
||||
craftType: string,
|
||||
materialIds: string[],
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/craft?craftType=${craftType}&materialIds=${materialIds.join(',')}`,
|
||||
);
|
||||
const result: CostResponse = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setEstimatedCost(result.data.cost);
|
||||
setCanAfford(result.data.canAfford);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cost estimate:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaterial = (id: string, material?: Material) => {
|
||||
setSelectedMaterialIds((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
setSelectedMaterialMap((map) => {
|
||||
const next = { ...map };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
return prev.filter((mid) => mid !== id);
|
||||
}
|
||||
if (prev.length >= MAX_MATERIALS) {
|
||||
pushToast({
|
||||
message: `悟道精力有限,最多参悟 ${MAX_MATERIALS} 种典籍`,
|
||||
tone: 'warning',
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
if (material) {
|
||||
setSelectedMaterialMap((map) => ({
|
||||
...map,
|
||||
[id]: material,
|
||||
}));
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!cultivator) {
|
||||
pushToast({ message: '请先在首页觉醒灵根。', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prompt.trim()) {
|
||||
pushToast({
|
||||
message: '请注入神念,描述功法理念。',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMaterialIds.length === 0) {
|
||||
pushToast({ message: '请选择要参悟的功法典籍。', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setStatus('感悟天地,参悟大道……');
|
||||
setCreatedGongfa(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/craft', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
materialIds: selectedMaterialIds,
|
||||
prompt: prompt,
|
||||
craftType: 'create_gongfa',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || '参悟失败');
|
||||
}
|
||||
|
||||
const gongfa = result.data;
|
||||
|
||||
// 检查是否需要替换
|
||||
if (gongfa.needs_replace) {
|
||||
pushToast({
|
||||
message: '功法已达上限,请选择一个进行替换',
|
||||
tone: 'default',
|
||||
});
|
||||
router.push('/game/enlightenment/replace?type=create_gongfa');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatedGongfa(gongfa);
|
||||
|
||||
const successMessage = `功法【${gongfa.name}】参悟成功!`;
|
||||
setStatus(successMessage);
|
||||
pushToast({ message: successMessage, tone: 'success' });
|
||||
setPrompt('');
|
||||
setSelectedMaterialIds([]);
|
||||
setSelectedMaterialMap({});
|
||||
await refreshCultivator();
|
||||
setMaterialsRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const failMessage =
|
||||
error instanceof Error
|
||||
? `走火入魔:${error.message}`
|
||||
: '参悟失败,灵感中断。';
|
||||
setStatus(failMessage);
|
||||
pushToast({ message: failMessage, tone: 'danger' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !cultivator) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">布置静室中……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderGongfaExtraInfo = (gongfa: CultivationTechnique) => (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="border-ink/50 flex justify-between border-b pb-1">
|
||||
<span className="opacity-70">需求境界</span>
|
||||
<span>{gongfa.required_realm}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【功法参悟】"
|
||||
subtitle="万法归宗,神念通玄"
|
||||
backHref="/game/enlightenment"
|
||||
note={note}
|
||||
currentPath={pathname}
|
||||
footer={
|
||||
<InkActionGroup align="between">
|
||||
<InkButton href="/game/enlightenment">返回</InkButton>
|
||||
<span className="text-ink-secondary text-xs">
|
||||
{selectedMaterialIds.length > 0
|
||||
? `已选 ${selectedMaterialIds.length} 种典籍`
|
||||
: '请选择典籍开始参悟'}
|
||||
</span>
|
||||
</InkActionGroup>
|
||||
}
|
||||
>
|
||||
<InkSection title="1. 甄选典籍">
|
||||
<MaterialSelector
|
||||
cultivatorId={cultivator?.id}
|
||||
selectedMaterialIds={selectedMaterialIds}
|
||||
onToggleMaterial={toggleMaterial}
|
||||
selectedMaterialMap={selectedMaterialMap}
|
||||
isSubmitting={isSubmitting}
|
||||
pageSize={20}
|
||||
includeMaterialTypes={['gongfa_manual', 'manual']}
|
||||
refreshKey={materialsRefreshKey}
|
||||
showSelectedMaterialsPanel
|
||||
loadingText="正在检索可参悟典籍,请稍候……"
|
||||
emptyNoticeText="暂无可用于参悟功法的典籍。"
|
||||
totalText={(total) => `共 ${total} 部可参悟典籍`}
|
||||
/>
|
||||
<p className="text-ink-secondary mt-1 text-right text-xs">
|
||||
{selectedMaterialIds.length}/{MAX_MATERIALS}
|
||||
</p>
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="预计消耗">
|
||||
{estimatedCost ? (
|
||||
<div className="bg-ink/5 border-ink/10 flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm">
|
||||
道心感悟:
|
||||
<span className="font-bold text-purple-600">
|
||||
{estimatedCost.comprehension}
|
||||
</span>{' '}
|
||||
点
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${canAfford ? 'text-emerald-600' : 'text-red-600'}`}
|
||||
>
|
||||
{canAfford ? '✓ 感悟充足' : '✗ 感悟不足'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<InkNotice>请先选择典籍以查看消耗</InkNotice>
|
||||
)}
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="2. 注入感悟">
|
||||
<div className="mb-4">
|
||||
<InkList dense>
|
||||
<InkListItem
|
||||
title="提示"
|
||||
description="描述你对该功法的理解,或希望获得的效果方向。"
|
||||
/>
|
||||
<InkListItem
|
||||
title="示例"
|
||||
description="“我想创造一门能提升灵力恢复速度,并在突破时增加成功率的功法。”"
|
||||
/>
|
||||
</InkList>
|
||||
</div>
|
||||
|
||||
<InkInput
|
||||
multiline
|
||||
rows={6}
|
||||
placeholder="请在此注入你的感悟……"
|
||||
value={prompt}
|
||||
onChange={(value) => setPrompt(value)}
|
||||
disabled={isSubmitting}
|
||||
hint="💡 典籍品质决定下限,感悟深度决定上限。"
|
||||
/>
|
||||
|
||||
<InkActionGroup align="right">
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setPrompt('');
|
||||
setStatus('');
|
||||
setSelectedMaterialIds([]);
|
||||
setSelectedMaterialMap({});
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
重置
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!prompt.trim() ||
|
||||
selectedMaterialIds.length === 0 ||
|
||||
!canAfford
|
||||
}
|
||||
>
|
||||
{isSubmitting ? '参悟中……' : '开始参悟'}
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
</InkSection>
|
||||
|
||||
{status && (
|
||||
<div className="mt-4">
|
||||
<InkNotice tone="info">{status}</InkNotice>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Modal */}
|
||||
{createdGongfa && (
|
||||
<EffectDetailModal
|
||||
isOpen={!!createdGongfa}
|
||||
onClose={() => setCreatedGongfa(null)}
|
||||
icon="📖"
|
||||
name={createdGongfa.name}
|
||||
badges={[
|
||||
createdGongfa.grade && (
|
||||
<InkBadge key="g" tier={createdGongfa.grade}>
|
||||
{createdGongfa.grade}
|
||||
</InkBadge>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
extraInfo={renderGongfaExtraInfo(createdGongfa)}
|
||||
effects={createdGongfa.effects}
|
||||
description={createdGongfa.description}
|
||||
effectTitle="功法效果"
|
||||
descriptionTitle="功法详述"
|
||||
footer={
|
||||
<InkButton
|
||||
onClick={() => setCreatedGongfa(null)}
|
||||
className="w-full"
|
||||
>
|
||||
了然于胸
|
||||
</InkButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { InkPageShell, InkSection } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import { InkActionGroup, InkButton, InkList, InkNotice } from '@/components/ui';
|
||||
import { EffectCard } from '@/components/ui/EffectCard';
|
||||
import {
|
||||
getSkillDisplayInfo,
|
||||
getSkillElementInfo,
|
||||
} from '@/lib/utils/effectDisplay';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { CultivationTechnique, Skill } from '@/types/cultivator';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function ReplaceContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const craftType = searchParams.get('type') as
|
||||
| 'create_skill'
|
||||
| 'create_gongfa';
|
||||
const { cultivator, refreshCultivator } = useCultivator();
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [pendingItem, setPendingItem] = useState<
|
||||
Skill | CultivationTechnique | null
|
||||
>(null);
|
||||
const [selectedOldId, setSelectedOldId] = useState<string | null>(null);
|
||||
const [acceptNew, setAcceptNew] = useState(true);
|
||||
|
||||
const isSkill = craftType === 'create_skill';
|
||||
|
||||
const fetchPendingStatus = useCallback(async () => {
|
||||
if (!craftType) return;
|
||||
try {
|
||||
const res = await fetch(`/api/craft/pending?type=${craftType}`);
|
||||
const data = await res.json();
|
||||
if (data.success && data.hasPending) {
|
||||
setPendingItem(data.item);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取待定状态失败:', e);
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
}, [craftType, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cultivator) {
|
||||
fetchPendingStatus();
|
||||
}
|
||||
}, [cultivator, fetchPendingStatus]);
|
||||
|
||||
const handleConfirm = async (isAbandon: boolean) => {
|
||||
if (!isAbandon && !selectedOldId) {
|
||||
pushToast({ message: '请勾选需要舍弃的旧法门', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/craft/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
craftType,
|
||||
replaceId: isAbandon ? null : selectedOldId,
|
||||
abandon: isAbandon,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || '确认失败');
|
||||
|
||||
openDialog({
|
||||
title: isAbandon ? '尘缘尽散' : '领悟成功',
|
||||
content: <p>{data.message}</p>,
|
||||
onConfirm: async () => {
|
||||
await refreshCultivator();
|
||||
router.push(isSkill ? '/game/skills' : '/game/techniques');
|
||||
},
|
||||
confirmLabel: '善哉',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : '操作失败';
|
||||
pushToast({ message: msg, tone: 'danger' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initializing || !cultivator) return null;
|
||||
if (!pendingItem) return <InkNotice>无可领悟之法</InkNotice>;
|
||||
|
||||
const existingItems = isSkill ? cultivator.skills : cultivator.cultivations;
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title={isSkill ? '神通突围' : '功法破障'}
|
||||
subtitle="万法随心,取舍有道"
|
||||
backHref="/game/enlightenment"
|
||||
>
|
||||
<div className="space-y-6 pb-12">
|
||||
<InkNotice>
|
||||
请勾选需要<b>舍弃的旧法门</b>和需要<b>承接的新机缘</b>。<br />
|
||||
确认后,未选之新法将消散归于虚无,未选之旧法将固守道身。
|
||||
</InkNotice>
|
||||
|
||||
<InkSection title={`【现有${isSkill ? '神通' : '功法'}】(勾选以舍弃)`}>
|
||||
<InkList>
|
||||
{existingItems.map((item) => {
|
||||
const isSelected = selectedOldId === item.id;
|
||||
const { icon: typeIcon } = isSkill
|
||||
? getSkillElementInfo(item as Skill)
|
||||
: { icon: '📜' };
|
||||
const displayInfo = isSkill
|
||||
? getSkillDisplayInfo(item as Skill)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<EffectCard
|
||||
key={item.id}
|
||||
highlight={isSelected}
|
||||
icon={typeIcon}
|
||||
name={item.name}
|
||||
quality={item.grade}
|
||||
effects={item.effects}
|
||||
meta={
|
||||
isSkill && displayInfo
|
||||
? `威力:${displayInfo.power}|冷却:${(item as Skill).cooldown}回合${
|
||||
(item as Skill).cost
|
||||
? `|消耗:${(item as Skill).cost} 灵力`
|
||||
: ''
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
badgeExtra={
|
||||
!isSkill ? (
|
||||
<span className="text-ink-secondary border-ink/20 bg-ink/5 rounded-xs border px-1 text-xs">
|
||||
{(item as CultivationTechnique).required_realm}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
description={item.description}
|
||||
actions={
|
||||
<InkButton
|
||||
variant={isSelected ? 'primary' : 'secondary'}
|
||||
onClick={() =>
|
||||
setSelectedOldId(isSelected ? null : item.id || null)
|
||||
}
|
||||
>
|
||||
{isSelected ? '将舍弃' : '固守'}
|
||||
</InkButton>
|
||||
}
|
||||
layout="col"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</InkList>
|
||||
</InkSection>
|
||||
|
||||
<InkSection title={`【新领悟】(勾选以承接)`}>
|
||||
<InkList>
|
||||
{(() => {
|
||||
const { icon: typeIcon } = isSkill
|
||||
? getSkillElementInfo(pendingItem as Skill)
|
||||
: { icon: '📜' };
|
||||
const displayInfo = isSkill
|
||||
? getSkillDisplayInfo(pendingItem as Skill)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<EffectCard
|
||||
highlight={acceptNew}
|
||||
icon={typeIcon}
|
||||
name={pendingItem.name}
|
||||
quality={pendingItem.grade}
|
||||
effects={pendingItem.effects}
|
||||
meta={
|
||||
isSkill && displayInfo
|
||||
? `威力:${displayInfo.power}|冷却:${(pendingItem as Skill).cooldown}回合${
|
||||
(pendingItem as Skill).cost
|
||||
? `|消耗:${(pendingItem as Skill).cost} 灵力`
|
||||
: ''
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
badgeExtra={
|
||||
!isSkill ? (
|
||||
<span className="text-ink-secondary border-ink/20 bg-ink/5 rounded-xs border px-1 text-xs">
|
||||
{(pendingItem as CultivationTechnique).required_realm}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
description={pendingItem.description}
|
||||
actions={
|
||||
<InkButton
|
||||
variant={acceptNew ? 'primary' : 'outline'}
|
||||
onClick={() => setAcceptNew(!acceptNew)}
|
||||
>
|
||||
{acceptNew ? '已定' : '契合'}
|
||||
</InkButton>
|
||||
}
|
||||
layout="col"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</InkList>
|
||||
</InkSection>
|
||||
|
||||
<InkActionGroup>
|
||||
<InkButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: '确认放弃',
|
||||
content: (
|
||||
<p>
|
||||
道友当真要放弃此次造化之机?
|
||||
<br />
|
||||
一旦放弃,灵感将消散归于虚无。
|
||||
</p>
|
||||
),
|
||||
onConfirm: () => handleConfirm(true),
|
||||
confirmLabel: '确认放弃',
|
||||
cancelLabel: '再想想',
|
||||
});
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
放弃领悟
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => handleConfirm(false)}
|
||||
disabled={loading || !selectedOldId || !acceptNew}
|
||||
>
|
||||
{loading ? '演化中...' : '确认替换'}
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
</div>
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReplacePage() {
|
||||
return (
|
||||
<Suspense fallback={<InkNotice>感知天机中...</InkNotice>}>
|
||||
<ReplaceContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { MaterialSelector } from '@/app/(game)/game/components/MaterialSelector';
|
||||
import { InkPageShell, InkSection } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import {
|
||||
InkActionGroup,
|
||||
InkBadge,
|
||||
InkButton,
|
||||
InkInput,
|
||||
InkList,
|
||||
InkListItem,
|
||||
InkNotice,
|
||||
} from '@/components/ui';
|
||||
import { EffectDetailModal } from '@/components/ui/EffectDetailModal';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import {
|
||||
getSkillDisplayInfo,
|
||||
getSkillElementInfo,
|
||||
} from '@/lib/utils/effectDisplay';
|
||||
import type { Material, Skill } from '@/types/cultivator';
|
||||
import { getElementInfo } from '@/types/dictionaries';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MAX_MATERIALS = 5;
|
||||
|
||||
type CostEstimate = {
|
||||
spiritStones?: number;
|
||||
comprehension?: number;
|
||||
};
|
||||
|
||||
type CostResponse = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
cost: CostEstimate;
|
||||
canAfford: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SkillCreationPage() {
|
||||
const router = useRouter();
|
||||
const { cultivator, refreshCultivator, note, isLoading } = useCultivator();
|
||||
const [prompt, setPrompt] = useState<string>('');
|
||||
const [selectedMaterialIds, setSelectedMaterialIds] = useState<string[]>([]);
|
||||
const [selectedMaterialMap, setSelectedMaterialMap] = useState<
|
||||
Record<string, Material>
|
||||
>({});
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [createdSkill, setCreatedSkill] = useState<Skill | null>(null);
|
||||
const [materialsRefreshKey, setMaterialsRefreshKey] = useState(0);
|
||||
const [estimatedCost, setEstimatedCost] = useState<CostEstimate | null>(null);
|
||||
const [canAfford, setCanAfford] = useState(true);
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const checkPending = async () => {
|
||||
if (!cultivator) return;
|
||||
try {
|
||||
const res = await fetch('/api/craft/pending?type=create_skill');
|
||||
const data = await res.json();
|
||||
if (data.success && data.hasPending) {
|
||||
openDialog({
|
||||
title: '感应天机',
|
||||
content: (
|
||||
<p className="py-2">
|
||||
系统感应到道友先前推演了一门神通,但尚未将其纳入道基。是否立即前往处理?
|
||||
</p>
|
||||
),
|
||||
confirmLabel: '继续推演',
|
||||
cancelLabel: '暂不处理',
|
||||
onConfirm: () => {
|
||||
router.push('/game/enlightenment/replace?type=create_skill');
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查待定失败:', e);
|
||||
}
|
||||
};
|
||||
checkPending();
|
||||
}, [cultivator, openDialog, router]);
|
||||
|
||||
// Fetch cost estimate when materials change
|
||||
useEffect(() => {
|
||||
if (selectedMaterialIds.length > 0) {
|
||||
fetchCostEstimate('create_skill', selectedMaterialIds);
|
||||
} else {
|
||||
setEstimatedCost(null);
|
||||
setCanAfford(true);
|
||||
}
|
||||
}, [selectedMaterialIds]);
|
||||
|
||||
const fetchCostEstimate = async (
|
||||
craftType: string,
|
||||
materialIds: string[],
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/craft?craftType=${craftType}&materialIds=${materialIds.join(',')}`,
|
||||
);
|
||||
const result: CostResponse = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setEstimatedCost(result.data.cost);
|
||||
setCanAfford(result.data.canAfford);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cost estimate:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaterial = (id: string, material?: Material) => {
|
||||
setSelectedMaterialIds((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
setSelectedMaterialMap((map) => {
|
||||
const next = { ...map };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
return prev.filter((mid) => mid !== id);
|
||||
}
|
||||
if (prev.length >= MAX_MATERIALS) {
|
||||
pushToast({
|
||||
message: `悟道精力有限,最多参悟 ${MAX_MATERIALS} 种典籍`,
|
||||
tone: 'warning',
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
if (material) {
|
||||
setSelectedMaterialMap((map) => ({
|
||||
...map,
|
||||
[id]: material,
|
||||
}));
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!cultivator) {
|
||||
pushToast({ message: '请先在首页觉醒灵根。', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prompt.trim()) {
|
||||
pushToast({
|
||||
message: '请注入神念,描述神通法门。',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMaterialIds.length === 0) {
|
||||
pushToast({ message: '请选择要参悟的功法典籍。', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setStatus('感悟天地,推演法则……');
|
||||
setCreatedSkill(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/craft', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
materialIds: selectedMaterialIds,
|
||||
prompt: prompt,
|
||||
craftType: 'create_skill',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || '推演失败');
|
||||
}
|
||||
|
||||
const skill = result.data;
|
||||
|
||||
// 检查是否需要替换
|
||||
if (skill.needs_replace) {
|
||||
pushToast({
|
||||
message: '神通已达上限,请选择一个进行替换',
|
||||
tone: 'default',
|
||||
});
|
||||
router.push('/game/enlightenment/replace?type=create_skill');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatedSkill(skill);
|
||||
|
||||
const successMessage = `神通【${skill.name}】推演成功!`;
|
||||
setStatus(successMessage);
|
||||
pushToast({ message: successMessage, tone: 'success' });
|
||||
setPrompt('');
|
||||
setSelectedMaterialIds([]);
|
||||
setSelectedMaterialMap({});
|
||||
await refreshCultivator();
|
||||
setMaterialsRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const failMessage =
|
||||
error instanceof Error
|
||||
? `走火入魔:${error.message}`
|
||||
: '推演失败,灵感中断。';
|
||||
setStatus(failMessage);
|
||||
pushToast({ message: failMessage, tone: 'danger' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !cultivator) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">入定冥想中……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSkillExtraInfo = (skill: Skill) => {
|
||||
const elementInfo = getElementInfo(skill.element);
|
||||
const displayInfo = getSkillDisplayInfo(skill);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="border-ink/50 flex justify-between border-b pb-1">
|
||||
<span className="opacity-70">元素</span>
|
||||
<span>
|
||||
{elementInfo.icon} {elementInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-ink/50 flex justify-between border-b pb-1">
|
||||
<span className="opacity-70">目标</span>
|
||||
<span>{skill.target_self ? '自身' : '敌方'}</span>
|
||||
</div>
|
||||
<div className="border-ink/50 flex justify-between border-b pb-1">
|
||||
<span className="opacity-70">威力</span>
|
||||
<span>{displayInfo.power}%</span>
|
||||
</div>
|
||||
{displayInfo.healPercent !== undefined &&
|
||||
displayInfo.healPercent > 0 && (
|
||||
<div className="border-ink/50 flex justify-between border-b pb-1">
|
||||
<span className="opacity-70">治疗</span>
|
||||
<span>{displayInfo.healPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-ink/50 flex justify-between border-b pb-1">
|
||||
<span className="opacity-70">消耗</span>
|
||||
<span>{skill.cost || 0} 灵力</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="opacity-70">冷却</span>
|
||||
<span>{skill.cooldown || 0} 回合</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【神通推演】"
|
||||
subtitle="神念所至,万法皆生"
|
||||
backHref="/game/enlightenment"
|
||||
note={note}
|
||||
currentPath={pathname}
|
||||
footer={
|
||||
<InkActionGroup align="between">
|
||||
<InkButton href="/game/enlightenment">返回</InkButton>
|
||||
<span className="text-ink-secondary text-xs">
|
||||
{selectedMaterialIds.length > 0
|
||||
? `已选 ${selectedMaterialIds.length} 种典籍`
|
||||
: '请选择典籍开始参悟'}
|
||||
</span>
|
||||
</InkActionGroup>
|
||||
}
|
||||
>
|
||||
<InkSection title="1. 甄选典籍">
|
||||
<MaterialSelector
|
||||
cultivatorId={cultivator?.id}
|
||||
selectedMaterialIds={selectedMaterialIds}
|
||||
onToggleMaterial={toggleMaterial}
|
||||
selectedMaterialMap={selectedMaterialMap}
|
||||
isSubmitting={isSubmitting}
|
||||
pageSize={20}
|
||||
includeMaterialTypes={['skill_manual', 'manual']}
|
||||
refreshKey={materialsRefreshKey}
|
||||
showSelectedMaterialsPanel
|
||||
loadingText="正在检索可参悟典籍,请稍候……"
|
||||
emptyNoticeText="暂无可用于推演神通的典籍。"
|
||||
totalText={(total) => `共 ${total} 部可参悟典籍`}
|
||||
/>
|
||||
<p className="text-ink-secondary mt-1 text-right text-xs">
|
||||
{selectedMaterialIds.length}/{MAX_MATERIALS}
|
||||
</p>
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="预计消耗">
|
||||
{estimatedCost ? (
|
||||
<div className="bg-ink/5 border-ink/10 flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm">
|
||||
道心感悟:
|
||||
<span className="font-bold text-purple-600">
|
||||
{estimatedCost.comprehension}
|
||||
</span>{' '}
|
||||
点
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${canAfford ? 'text-emerald-600' : 'text-red-600'}`}
|
||||
>
|
||||
{canAfford ? '✓ 感悟充足' : '✗ 感悟不足'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<InkNotice>请先选择典籍以查看消耗</InkNotice>
|
||||
)}
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="2. 注入神念">
|
||||
<div className="mb-4">
|
||||
<InkList dense>
|
||||
<InkListItem
|
||||
title="提示"
|
||||
description="描述你期望的神通形态,如“漫天剑雨”、“护身火罩”。"
|
||||
/>
|
||||
<InkListItem
|
||||
title="示例"
|
||||
description="“我手持离火剑,想创造一门能召唤九条火龙护体并反击敌人的防御剑阵。”"
|
||||
/>
|
||||
</InkList>
|
||||
</div>
|
||||
|
||||
<InkInput
|
||||
multiline
|
||||
rows={6}
|
||||
placeholder="请在此注入你的神念……"
|
||||
value={prompt}
|
||||
onChange={(value) => setPrompt(value)}
|
||||
disabled={isSubmitting}
|
||||
hint="💡 描述越具体、越符合自身条件,成功率越高。"
|
||||
/>
|
||||
|
||||
<InkActionGroup align="right">
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setPrompt('');
|
||||
setStatus('');
|
||||
setSelectedMaterialIds([]);
|
||||
setSelectedMaterialMap({});
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
重置
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!prompt.trim() ||
|
||||
selectedMaterialIds.length === 0 ||
|
||||
!canAfford
|
||||
}
|
||||
>
|
||||
{isSubmitting ? '推演中……' : '开始推演'}
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
</InkSection>
|
||||
|
||||
{status && (
|
||||
<div className="mt-4">
|
||||
<InkNotice tone="info">{status}</InkNotice>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Modal */}
|
||||
{createdSkill && (
|
||||
<EffectDetailModal
|
||||
isOpen={!!createdSkill}
|
||||
onClose={() => setCreatedSkill(null)}
|
||||
icon={getSkillElementInfo(createdSkill).icon}
|
||||
name={createdSkill.name}
|
||||
badges={[
|
||||
createdSkill.grade && (
|
||||
<InkBadge key="g" tier={createdSkill.grade}>
|
||||
{getSkillElementInfo(createdSkill).typeName}
|
||||
</InkBadge>
|
||||
),
|
||||
<InkBadge key="e" tone="default">
|
||||
{getElementInfo(createdSkill.element).label}
|
||||
</InkBadge>,
|
||||
].filter(Boolean)}
|
||||
extraInfo={renderSkillExtraInfo(createdSkill)}
|
||||
effects={createdSkill.effects}
|
||||
description={createdSkill.description}
|
||||
effectTitle="神通效果"
|
||||
descriptionTitle="神通描述"
|
||||
footer={
|
||||
<InkButton onClick={() => setCreatedSkill(null)} className="w-full">
|
||||
了然于胸
|
||||
</InkButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { InkPageShell, InkSection } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import { InkActionGroup, InkButton, InkList, InkNotice } from '@/components/ui';
|
||||
import { EffectCard } from '@/components/ui/EffectCard';
|
||||
import type { GeneratedFate } from '@/engine/fate/creation/types';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { PreHeavenFate } from '@/types/cultivator';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export default function FateReshapePage() {
|
||||
const router = useRouter();
|
||||
const { cultivator, refresh } = useCultivator();
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [previewFates, setPreviewFates] = useState<GeneratedFate[] | null>(
|
||||
null,
|
||||
);
|
||||
const [currentUses, setCurrentUses] = useState<number>(0);
|
||||
const [checkingBuff, setCheckingBuff] = useState(false);
|
||||
const [hasBuff, setHasBuff] = useState(false);
|
||||
|
||||
// Selection states
|
||||
const [selectedOldIndices, setSelectedOldIndices] = useState<number[]>([]);
|
||||
const [selectedNewIndices, setSelectedNewIndices] = useState<number[]>([]);
|
||||
|
||||
// 从 API 获取当前 buff 状态
|
||||
const checkBuffStatus = useCallback(async () => {
|
||||
setCheckingBuff(true);
|
||||
try {
|
||||
const res = await fetch('/api/cultivator/persistent-buffs');
|
||||
const data = await res.json();
|
||||
if (data.buffs) {
|
||||
const reshapeBuff = data.buffs.find(
|
||||
(t: { id: string }) => t.id === 'reshape_fate_talisman',
|
||||
);
|
||||
setCurrentUses(reshapeBuff?.usesRemaining ?? 0);
|
||||
return reshapeBuff;
|
||||
}
|
||||
setCurrentUses(0);
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('获取符箓状态失败:', e);
|
||||
setCurrentUses(0);
|
||||
return null;
|
||||
} finally {
|
||||
setCheckingBuff(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取重塑状态并恢复缓存的数据
|
||||
const fetchReshapeStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/cultivator/fate/reshape/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasBuff(data.hasBuff);
|
||||
setCurrentUses(data.usesRemaining ?? 0);
|
||||
|
||||
// 如果有缓存的命格,恢复显示
|
||||
if (data.hasPendingFates && data.fates) {
|
||||
setPreviewFates(data.fates);
|
||||
// 恢复时清空选择状态,让用户重新选择
|
||||
setSelectedOldIndices([]);
|
||||
setSelectedNewIndices([]);
|
||||
}
|
||||
|
||||
// 如果Buff已过期,提示用户
|
||||
if (data.buffExpired) {
|
||||
pushToast({
|
||||
message: '逆天改命符箓已过期',
|
||||
tone: 'warning',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取重塑状态失败:', e);
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
}, [pushToast]);
|
||||
|
||||
// 初始化时检查 buff 状态和缓存的命格
|
||||
useEffect(() => {
|
||||
if (cultivator) {
|
||||
checkBuffStatus();
|
||||
fetchReshapeStatus();
|
||||
}
|
||||
}, [cultivator, checkBuffStatus, fetchReshapeStatus]);
|
||||
|
||||
const handlePreview = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/cultivator/fate/reshape/preview');
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '推演失败');
|
||||
}
|
||||
|
||||
setPreviewFates(data.fates);
|
||||
setCurrentUses(data.usesRemaining);
|
||||
// Reset selections
|
||||
setSelectedOldIndices([]);
|
||||
setSelectedNewIndices([]);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : '未知错误';
|
||||
openDialog({
|
||||
title: '推演受阻',
|
||||
content: <p>{msg}</p>,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
// 校验提示
|
||||
const hasNoChanges =
|
||||
selectedOldIndices.length === 0 && selectedNewIndices.length === 0;
|
||||
if (hasNoChanges) {
|
||||
pushToast({
|
||||
message: '未作任何更改,无法逆转乾坤',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 校验命格数量限制:最多3个
|
||||
const currentFatesCount = cultivator?.pre_heaven_fates?.length || 0;
|
||||
const newFatesCount = selectedNewIndices.length;
|
||||
const discardFatesCount = selectedOldIndices.length;
|
||||
const finalFatesCount =
|
||||
currentFatesCount + newFatesCount - discardFatesCount;
|
||||
|
||||
if (finalFatesCount > 3) {
|
||||
pushToast({
|
||||
message: `命数过多,肉身难承其重,至多可持三道先天命格(将拥有${finalFatesCount}道)`,
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/cultivator/fate/reshape/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
selectedIndices: selectedNewIndices,
|
||||
replaceIndices: selectedOldIndices,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '重塑失败');
|
||||
}
|
||||
|
||||
openDialog({
|
||||
title: '逆天改命成功',
|
||||
content: <p>{data.message}</p>,
|
||||
onConfirm: async () => {
|
||||
refresh();
|
||||
router.push('/game');
|
||||
},
|
||||
confirmLabel: '善哉',
|
||||
});
|
||||
setLoading(false);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : '未知错误';
|
||||
openDialog({
|
||||
title: '逆天失败',
|
||||
content: <p>{msg}</p>,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbandon = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/cultivator/fate/reshape/abandon', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '放弃失败');
|
||||
}
|
||||
pushToast({
|
||||
message: data.message,
|
||||
tone: 'success',
|
||||
});
|
||||
fetchReshapeStatus();
|
||||
setLoading(false);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : '未知错误';
|
||||
openDialog({
|
||||
title: '放弃失败',
|
||||
content: <p>{msg}</p>,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOldSelection = (index: number) => {
|
||||
if (selectedOldIndices.includes(index)) {
|
||||
setSelectedOldIndices((prev) => prev.filter((i) => i !== index));
|
||||
} else {
|
||||
setSelectedOldIndices((prev) => [...prev, index]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNewSelection = (index: number) => {
|
||||
if (selectedNewIndices.includes(index)) {
|
||||
setSelectedNewIndices((prev) => prev.filter((i) => i !== index));
|
||||
} else {
|
||||
setSelectedNewIndices((prev) => [...prev, index]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!cultivator || initializing) return null;
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="逆天改命"
|
||||
subtitle={`天机推演次数:${currentUses}`}
|
||||
backHref="/game"
|
||||
>
|
||||
{!previewFates ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 py-12">
|
||||
<div className="mb-4 text-6xl">🔮</div>
|
||||
<p className="max-w-xs text-center text-lg opacity-80">
|
||||
燃烧一次天机逆命符之力,可窥探三条未来命数。
|
||||
<br />
|
||||
道友可从中择选合意者,替换现有命格,以此逆天改命。
|
||||
</p>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handlePreview}
|
||||
disabled={loading || checkingBuff || currentUses <= 0 || !hasBuff}
|
||||
>
|
||||
{loading
|
||||
? '推演天机中...'
|
||||
: checkingBuff
|
||||
? '检查道韵中...'
|
||||
: '燃符推演'}
|
||||
</InkButton>
|
||||
{!hasBuff ? (
|
||||
<InkNotice>未激活逆天改命符箓</InkNotice>
|
||||
) : currentUses <= 0 ? (
|
||||
<InkNotice>符箓之力已尽,请提交替换或放弃。</InkNotice>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<InkNotice>
|
||||
请勾选需要<b>舍弃的旧命数</b>和需要<b>承接的新机缘</b>。<br />
|
||||
确认后,未选之新命将消散归于虚无,未选之旧命将固守道身。
|
||||
</InkNotice>
|
||||
|
||||
<InkSection title="【现有命数】(勾选以舍弃)">
|
||||
<InkList>
|
||||
{cultivator.pre_heaven_fates.map(
|
||||
(fate: PreHeavenFate, idx: number) => (
|
||||
<EffectCard
|
||||
key={idx}
|
||||
name={fate.name}
|
||||
quality={fate.quality}
|
||||
effects={fate.effects}
|
||||
description={fate.description}
|
||||
actions={
|
||||
<InkButton
|
||||
variant={
|
||||
selectedOldIndices.includes(idx)
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
}
|
||||
onClick={() => toggleOldSelection(idx)}
|
||||
>
|
||||
{selectedOldIndices.includes(idx) ? '将舍弃' : '固守'}
|
||||
</InkButton>
|
||||
}
|
||||
layout="col"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</InkList>
|
||||
</InkSection>
|
||||
|
||||
<InkSection title="【推演结果】(勾选以承接)">
|
||||
<InkList>
|
||||
{previewFates.map((fate, idx) => (
|
||||
<EffectCard
|
||||
key={idx}
|
||||
name={fate.name}
|
||||
quality={fate.quality}
|
||||
effects={fate.effects}
|
||||
description={fate.description}
|
||||
actions={
|
||||
<InkButton
|
||||
variant={
|
||||
selectedNewIndices.includes(idx) ? 'primary' : 'outline'
|
||||
}
|
||||
onClick={() => toggleNewSelection(idx)}
|
||||
>
|
||||
{selectedNewIndices.includes(idx) ? '已定' : '契合'}
|
||||
</InkButton>
|
||||
}
|
||||
layout="col"
|
||||
/>
|
||||
))}
|
||||
</InkList>
|
||||
</InkSection>
|
||||
|
||||
<InkActionGroup>
|
||||
{currentUses > 0 && (
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
setSelectedNewIndices([]);
|
||||
setSelectedOldIndices([]);
|
||||
await handlePreview();
|
||||
}}
|
||||
disabled={loading || currentUses < 1}
|
||||
>
|
||||
{loading ? '推演天机中...' : `重新推演(剩余${currentUses}次)`}
|
||||
</InkButton>
|
||||
)}
|
||||
{currentUses <= 0 && (
|
||||
<InkButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: '确认放弃',
|
||||
content: (
|
||||
<p>
|
||||
道友当真要放弃此次逆天改命之机?
|
||||
<br />
|
||||
一旦放弃,符箓之力将消散,已推演的命数亦将归于虚无。
|
||||
</p>
|
||||
),
|
||||
onConfirm: handleAbandon,
|
||||
confirmLabel: '确认放弃',
|
||||
cancelLabel: '再想想',
|
||||
});
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
放弃改命
|
||||
</InkButton>
|
||||
)}
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handleCommit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '逆天改命中...' : '逆转乾坤'}
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
</div>
|
||||
)}
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { InkBadge, InkButton, InkList, InkNotice } from '@/components/ui';
|
||||
import { EffectCard } from '@/components/ui/EffectCard';
|
||||
import type { Consumable } from '@/types/cultivator';
|
||||
|
||||
interface ConsumablesTabProps {
|
||||
consumables: Consumable[];
|
||||
isLoading?: boolean;
|
||||
pendingId: string | null;
|
||||
onShowDetails: (item: Consumable) => void;
|
||||
onConsume: (item: Consumable) => void;
|
||||
onDiscard: (item: Consumable) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消耗品 Tab 组件
|
||||
*/
|
||||
export function ConsumablesTab({
|
||||
consumables,
|
||||
isLoading = false,
|
||||
pendingId,
|
||||
onShowDetails,
|
||||
onConsume,
|
||||
onDiscard,
|
||||
}: ConsumablesTabProps) {
|
||||
if (isLoading) {
|
||||
return <InkNotice>正在检索消耗品记录,请稍候……</InkNotice>;
|
||||
}
|
||||
|
||||
if (!consumables || consumables.length === 0) {
|
||||
return <InkNotice>暂无消耗品。</InkNotice>;
|
||||
}
|
||||
|
||||
// 按类型排序:符箓在前,丹药在后
|
||||
const sortedItems = [...consumables].sort((a, b) => {
|
||||
if (a.type === '符箓' && b.type !== '符箓') return -1;
|
||||
if (a.type !== '符箓' && b.type === '符箓') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<InkList>
|
||||
{sortedItems.map((item, idx) => {
|
||||
const isTalisman = item.type === '符箓';
|
||||
|
||||
return (
|
||||
<EffectCard
|
||||
key={item.id || idx}
|
||||
layout="col"
|
||||
name={item.name}
|
||||
quality={item.quality}
|
||||
badgeExtra={
|
||||
<>
|
||||
<InkBadge tone="default">
|
||||
{isTalisman ? '符箓' : '丹药'}
|
||||
</InkBadge>
|
||||
<span className="text-ink-secondary text-sm">
|
||||
x{item.quantity}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
meta={
|
||||
isTalisman ? (
|
||||
<div className="text-ink-primary text-xs">
|
||||
【使用后获得特殊增益】
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
effects={item.effects}
|
||||
description={item.description}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => onShowDetails(item)}
|
||||
>
|
||||
详情
|
||||
</InkButton>
|
||||
<InkButton
|
||||
disabled={!item.id || pendingId === item.id}
|
||||
onClick={() => onConsume(item)}
|
||||
variant="primary"
|
||||
>
|
||||
{pendingId === item.id
|
||||
? isTalisman
|
||||
? '祭炼中…'
|
||||
: '服用中…'
|
||||
: isTalisman
|
||||
? '祭炼'
|
||||
: '服用'}
|
||||
</InkButton>
|
||||
<InkButton variant="primary" onClick={() => onDiscard(item)}>
|
||||
销毁
|
||||
</InkButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</InkList>
|
||||
);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { EffectDetailModal } from '@/components/ui/EffectDetailModal';
|
||||
import { InkBadge } from '@/components/ui/InkBadge';
|
||||
import type {
|
||||
Artifact,
|
||||
Consumable,
|
||||
CultivationTechnique,
|
||||
Material,
|
||||
Skill,
|
||||
} from '@/types/cultivator';
|
||||
import {
|
||||
CONSUMABLE_TYPE_DISPLAY_MAP,
|
||||
getEquipmentSlotInfo,
|
||||
getMaterialTypeInfo,
|
||||
} from '@/types/dictionaries';
|
||||
|
||||
type InventoryItem = Artifact | Consumable | Material;
|
||||
type DetailItem = InventoryItem | Skill | CultivationTechnique;
|
||||
|
||||
interface ItemDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
item: DetailItem | null;
|
||||
}
|
||||
|
||||
// 持有数量信息组件
|
||||
function QuantityInfo({ quantity }: { quantity: number }) {
|
||||
return (
|
||||
<div className="border-border/50 flex justify-between border-b pb-2">
|
||||
<span className="opacity-70">持有数量</span>
|
||||
<span className="font-bold">{quantity}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品详情弹窗
|
||||
*/
|
||||
export function ItemDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
item,
|
||||
}: ItemDetailModalProps) {
|
||||
if (!item || !isOpen) return null;
|
||||
|
||||
// 法宝(有 slot 属性)
|
||||
if ('slot' in item) {
|
||||
const slotInfo = getEquipmentSlotInfo(item.slot);
|
||||
const extraInfo = item.required_realm ? (
|
||||
<div className="border-ink/50 flex justify-between border-b pb-2">
|
||||
<span className="opacity-70">境界要求</span>
|
||||
<span>{item.required_realm}</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<EffectDetailModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
icon={slotInfo.icon}
|
||||
name={item.name}
|
||||
badges={[
|
||||
item.quality && (
|
||||
<InkBadge key="q" tier={item.quality}>
|
||||
{slotInfo.label}
|
||||
</InkBadge>
|
||||
),
|
||||
<InkBadge key="e" tone="default">
|
||||
{item.element}
|
||||
</InkBadge>,
|
||||
].filter(Boolean)}
|
||||
extraInfo={extraInfo}
|
||||
effects={item.effects}
|
||||
description={item.description}
|
||||
effectTitle="法宝效果"
|
||||
descriptionTitle="法宝说明"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 功法(有 required_realm,无 slot)
|
||||
if ('required_realm' in item && !('slot' in item)) {
|
||||
const technique = item as CultivationTechnique;
|
||||
return (
|
||||
<EffectDetailModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
icon="📘"
|
||||
name={technique.name}
|
||||
badges={[
|
||||
technique.grade && (
|
||||
<InkBadge key="g" tier={technique.grade}>
|
||||
功法
|
||||
</InkBadge>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
extraInfo={
|
||||
<div className="border-border/50 flex justify-between border-b pb-2">
|
||||
<span className="opacity-70">境界要求</span>
|
||||
<span>{technique.required_realm}</span>
|
||||
</div>
|
||||
}
|
||||
effects={technique.effects}
|
||||
description={technique.description}
|
||||
effectTitle="功法效果"
|
||||
descriptionTitle="功法详述"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 神通(有 cost、cooldown 和 element)
|
||||
if ('cooldown' in item && 'element' in item && !('type' in item)) {
|
||||
const skill = item as Skill;
|
||||
return (
|
||||
<EffectDetailModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
icon="📜"
|
||||
name={skill.name}
|
||||
badges={[
|
||||
skill.grade && (
|
||||
<InkBadge key="g" tier={skill.grade}>
|
||||
神通
|
||||
</InkBadge>
|
||||
),
|
||||
<InkBadge key="e" tone="default">
|
||||
{skill.element}
|
||||
</InkBadge>,
|
||||
].filter(Boolean)}
|
||||
extraInfo={
|
||||
<div className="space-y-2">
|
||||
<div className="border-border/50 flex justify-between border-b pb-2">
|
||||
<span className="opacity-70">灵力消耗</span>
|
||||
<span>{skill.cost ?? 0}</span>
|
||||
</div>
|
||||
<div className="border-border/50 flex justify-between border-b pb-2">
|
||||
<span className="opacity-70">冷却回合</span>
|
||||
<span>{skill.cooldown}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
effects={skill.effects}
|
||||
description={skill.description}
|
||||
effectTitle="神通效果"
|
||||
descriptionTitle="神通详述"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 丹药/符箓(有 quality + effects)
|
||||
if ('quality' in item && 'effects' in item) {
|
||||
const typeInfo = CONSUMABLE_TYPE_DISPLAY_MAP[item.type];
|
||||
return (
|
||||
<EffectDetailModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
icon={typeInfo.icon}
|
||||
name={item.name}
|
||||
badges={[
|
||||
item.quality && (
|
||||
<InkBadge key="q" tier={item.quality}>
|
||||
{typeInfo.label}
|
||||
</InkBadge>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
extraInfo={<QuantityInfo quantity={item.quantity} />}
|
||||
effects={item.effects}
|
||||
description={item.description}
|
||||
effectTitle="药效"
|
||||
descriptionTitle="丹药详述"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 材料
|
||||
const material = item as Material;
|
||||
const typeInfo = getMaterialTypeInfo(material.type);
|
||||
const badges = [
|
||||
<InkBadge key="r" tier={material.rank}>
|
||||
{typeInfo.label}
|
||||
</InkBadge>,
|
||||
];
|
||||
if (material.element) {
|
||||
badges.push(
|
||||
<InkBadge key="e" tone="default">
|
||||
{material.element}
|
||||
</InkBadge>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EffectDetailModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
icon={typeInfo.icon}
|
||||
name={material.name}
|
||||
badges={badges}
|
||||
extraInfo={<QuantityInfo quantity={material.quantity} />}
|
||||
description={material.description}
|
||||
descriptionTitle="物品说明"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { InkPageShell } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import { InkBadge, InkButton, InkCard, InkNotice } from '@/components/ui';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { Material } from '@/types/cultivator';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export function ManualDrawContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const type = searchParams.get('type');
|
||||
const router = useRouter();
|
||||
const { cultivator, refresh } = useCultivator();
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<Material | null>(null);
|
||||
const [hasBuff, setHasBuff] = useState(false);
|
||||
const [checkingBuff, setCheckingBuff] = useState(false);
|
||||
|
||||
const isSkill = type === 'skill';
|
||||
const typeName = isSkill ? '神通' : '功法';
|
||||
const pageTitle = isSkill ? '神通衍化' : '悟道演法';
|
||||
const buffId = isSkill ? 'draw_skill_talisman' : 'draw_gongfa_talisman';
|
||||
const talismanName = isSkill ? '神通衍化符' : '悟道演法符';
|
||||
|
||||
// 从 API 获取当前 buff 状态
|
||||
const checkBuffStatus = useCallback(async () => {
|
||||
setCheckingBuff(true);
|
||||
try {
|
||||
const res = await fetch('/api/cultivator/persistent-buffs');
|
||||
const data = await res.json();
|
||||
if (data.buffs) {
|
||||
const buff = data.buffs.some((t: { id: string }) => t.id === buffId);
|
||||
setHasBuff(buff);
|
||||
return buff;
|
||||
}
|
||||
setHasBuff(false);
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('获取符箓状态失败:', e);
|
||||
setHasBuff(false);
|
||||
return false;
|
||||
} finally {
|
||||
setCheckingBuff(false);
|
||||
}
|
||||
}, [buffId]);
|
||||
|
||||
// 初始化时检查 buff 状态
|
||||
useEffect(() => {
|
||||
if (cultivator) {
|
||||
checkBuffStatus();
|
||||
}
|
||||
}, [cultivator, checkBuffStatus]);
|
||||
|
||||
const handleDraw = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/cultivator/manual/draw?type=${type}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '感悟失败');
|
||||
}
|
||||
|
||||
setResult(data.manual);
|
||||
refresh();
|
||||
pushToast({
|
||||
message: '福至心灵,机缘已至!',
|
||||
tone: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : '未知错误';
|
||||
openDialog({
|
||||
title: '道心不稳',
|
||||
content: <p>{msg}</p>,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
router.push('/game');
|
||||
};
|
||||
|
||||
if (!cultivator) return null;
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title={pageTitle}
|
||||
subtitle="天道垂青,机缘所至"
|
||||
backHref="/game"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-8 py-12">
|
||||
{!result ? (
|
||||
<>
|
||||
<div className="animate-pulse text-8xl opacity-80">
|
||||
{isSkill ? '⚡' : '📜'}
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<p className="text-lg font-bold">燃烧【{talismanName}】</p>
|
||||
<p className="text-sm opacity-70">
|
||||
神游太虚,感应天地法则。
|
||||
<br />
|
||||
可获天道赐福,得一部玄品以上{typeName}典籍。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasBuff ? (
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handleDraw}
|
||||
disabled={loading || checkingBuff}
|
||||
className="w-48"
|
||||
>
|
||||
{loading
|
||||
? '感应天机中...'
|
||||
: checkingBuff
|
||||
? '检查道韵中...'
|
||||
: isSkill
|
||||
? '衍化神通'
|
||||
: '感悟天道'}
|
||||
</InkButton>
|
||||
) : (
|
||||
<InkNotice className="border-amber-600/30 bg-amber-600/10 text-amber-600">
|
||||
你当前未拥有{talismanName},无法{isSkill ? '衍化' : '感悟'}。
|
||||
</InkNotice>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="animate-in fade-in zoom-in w-full max-w-md space-y-6 duration-500">
|
||||
<InkCard>
|
||||
<div className="flex flex-col items-center space-y-4 p-6">
|
||||
<div className="text-6xl">📚</div>
|
||||
<div className="text-ink-primary text-2xl font-bold">
|
||||
{result.name}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="bg-ink/10 rounded px-2 py-0.5 text-sm">
|
||||
<InkBadge tier={result.rank} />
|
||||
</span>
|
||||
<span className="bg-ink/10 rounded px-2 py-0.5 text-sm">
|
||||
{result.element}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center leading-relaxed opacity-80">
|
||||
{result.description}
|
||||
</p>
|
||||
</div>
|
||||
</InkCard>
|
||||
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleClose}
|
||||
>
|
||||
收纳于心(返回)
|
||||
</InkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { ManualDrawContent } from './ManualDrawContent';
|
||||
|
||||
export default function ManualDrawPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
加载中...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ManualDrawContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { EffectDetailModal } from '@/components/ui/EffectDetailModal';
|
||||
import { InkBadge } from '@/components/ui/InkBadge';
|
||||
import {
|
||||
getSkillDisplayInfo,
|
||||
getSkillElementInfo,
|
||||
} from '@/lib/utils/effectDisplay';
|
||||
import { StatusEffect } from '@/types/constants';
|
||||
import type { Skill } from '@/types/cultivator';
|
||||
|
||||
interface SkillDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
skill: Skill | null;
|
||||
onShowEffectHelp?: (effectName: StatusEffect) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 神通详情弹窗
|
||||
*/
|
||||
export function SkillDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
skill,
|
||||
onShowEffectHelp,
|
||||
}: SkillDetailModalProps) {
|
||||
if (!skill) return null;
|
||||
|
||||
const displayInfo = getSkillDisplayInfo(skill);
|
||||
const { icon: skillIcon } = getSkillElementInfo(skill);
|
||||
|
||||
// 神通威能信息
|
||||
const extraInfo = (
|
||||
<div className="pt-2">
|
||||
<span className="mb-1 block opacity-70">神通威能</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-ink/5 rounded px-2 py-1">
|
||||
威力:{displayInfo.power}
|
||||
</div>
|
||||
<div className="bg-ink/5 rounded px-2 py-1">
|
||||
冷却:{skill.cooldown} 回合
|
||||
</div>
|
||||
<div className="bg-ink/5 rounded px-2 py-1">
|
||||
消耗:{skill.cost || 0} 灵力
|
||||
</div>
|
||||
<div className="bg-ink/5 rounded px-2 py-1">
|
||||
目标:{skill.target_self ? '自身' : '敌方'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 特殊效果 (Buff,可点击) */}
|
||||
{displayInfo.buffName && (
|
||||
<div className="pt-2">
|
||||
<span className="text-ink-primary mb-1 block font-bold opacity-70">
|
||||
特殊效果 (点击可了解详情)
|
||||
</span>
|
||||
<div
|
||||
className="bg-paper-2 flex cursor-pointer items-center gap-2 rounded p-2"
|
||||
onClick={() =>
|
||||
onShowEffectHelp?.(displayInfo.buffId! as StatusEffect)
|
||||
}
|
||||
>
|
||||
<span className="font-bold">{displayInfo.buffName}</span>
|
||||
{displayInfo.buffDuration && (
|
||||
<span className="text-ink-secondary text-xs">
|
||||
(持续 {displayInfo.buffDuration} 回合)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<EffectDetailModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
icon={skillIcon}
|
||||
name={skill.name}
|
||||
badges={[
|
||||
skill.grade && <InkBadge key="grade" tier={skill.grade} />,
|
||||
<InkBadge key="element" tone="default">
|
||||
{skill.element}
|
||||
</InkBadge>,
|
||||
].filter(Boolean)}
|
||||
extraInfo={extraInfo}
|
||||
effects={skill.effects}
|
||||
description={skill.description}
|
||||
effectTitle="神通效果"
|
||||
descriptionTitle="神通说明"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ShenTong } from '@/components/func';
|
||||
import { InkPageShell } from '@/components/layout';
|
||||
import {
|
||||
InkActionGroup,
|
||||
InkButton,
|
||||
InkDialog,
|
||||
InkNotice,
|
||||
} from '@/components/ui';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { useSkillsViewModel } from '../hooks/useSkillsViewModel';
|
||||
import { SkillDetailModal } from './SkillDetailModal';
|
||||
|
||||
/**
|
||||
* 神通主视图组件
|
||||
*/
|
||||
export function SkillsView() {
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
cultivator,
|
||||
skills,
|
||||
isLoading,
|
||||
note,
|
||||
maxSkills,
|
||||
dialog,
|
||||
closeDialog,
|
||||
selectedSkill,
|
||||
isModalOpen,
|
||||
openSkillDetail,
|
||||
closeSkillDetail,
|
||||
openForgetConfirm,
|
||||
showEffectHelp,
|
||||
} = useSkillsViewModel();
|
||||
|
||||
// 加载状态
|
||||
if (isLoading && !cultivator) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">神通卷轴徐徐展开……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【所修神通】"
|
||||
subtitle={`共 ${skills.length}/${maxSkills}`}
|
||||
backHref="/game"
|
||||
note={note}
|
||||
currentPath={pathname}
|
||||
footer={
|
||||
<InkActionGroup align="between">
|
||||
<InkButton href="/game">返回</InkButton>
|
||||
<InkButton href="/game/genlightenment" variant="primary">
|
||||
藏经阁 →
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
}
|
||||
>
|
||||
{!cultivator ? (
|
||||
<InkNotice>还未觉醒道身,何谈神通?先去首页觉醒吧。</InkNotice>
|
||||
) : (
|
||||
<>
|
||||
<ShenTong
|
||||
skills={skills}
|
||||
showSection={false}
|
||||
highlightLast={true}
|
||||
markLastAsNew={true}
|
||||
showActions={true}
|
||||
renderAction={(skill) => (
|
||||
<div className="flex gap-2">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
onClick={() => openSkillDetail(skill)}
|
||||
>
|
||||
详情
|
||||
</InkButton>
|
||||
<InkButton
|
||||
className="px-2 text-sm"
|
||||
onClick={() => openForgetConfirm(skill)}
|
||||
>
|
||||
遗忘
|
||||
</InkButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<InkDialog dialog={dialog} onClose={closeDialog} />
|
||||
|
||||
{/* 神通详情弹窗 */}
|
||||
<SkillDetailModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeSkillDetail}
|
||||
skill={selectedSkill}
|
||||
onShowEffectHelp={showEffectHelp}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import type { InkDialogState } from '@/components/ui/InkDialog';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import { StatusEffect } from '@/types/constants';
|
||||
import type { Skill } from '@/types/cultivator';
|
||||
import { getStatusEffectInfo } from '@/types/dictionaries';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface UseSkillsViewModelReturn {
|
||||
// 数据
|
||||
cultivator: ReturnType<typeof useCultivator>['cultivator'];
|
||||
skills: ReturnType<typeof useCultivator>['skills'];
|
||||
isLoading: boolean;
|
||||
note: string | undefined;
|
||||
maxSkills: number;
|
||||
|
||||
// Dialog 状态
|
||||
dialog: InkDialogState | null;
|
||||
closeDialog: () => void;
|
||||
|
||||
// 详情 Modal 状态
|
||||
selectedSkill: Skill | null;
|
||||
isModalOpen: boolean;
|
||||
openSkillDetail: (skill: Skill) => void;
|
||||
closeSkillDetail: () => void;
|
||||
|
||||
// 业务操作
|
||||
openForgetConfirm: (skill: Skill) => void;
|
||||
showEffectHelp: (effect: StatusEffect) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 神通页面 ViewModel
|
||||
*/
|
||||
export function useSkillsViewModel(): UseSkillsViewModelReturn {
|
||||
const { cultivator, skills, isLoading, note, refresh } = useCultivator();
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
|
||||
// Dialog 状态
|
||||
const [dialog, setDialog] = useState<InkDialogState | null>(null);
|
||||
|
||||
// 详情 Modal 状态
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const maxSkills = cultivator?.max_skills ?? 3;
|
||||
|
||||
// 关闭 Dialog
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialog(null);
|
||||
}, []);
|
||||
|
||||
// 打开神通详情
|
||||
const openSkillDetail = useCallback((skill: Skill) => {
|
||||
setSelectedSkill(skill);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 关闭神通详情
|
||||
const closeSkillDetail = useCallback(() => {
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
// 遗忘神通
|
||||
const handleForget = useCallback(
|
||||
async (skill: Skill) => {
|
||||
if (!cultivator) return;
|
||||
|
||||
try {
|
||||
setDialog((prev) => ({
|
||||
...prev!,
|
||||
loading: true,
|
||||
}));
|
||||
|
||||
const response = await fetch('/api/cultivator/skills/forget', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
skillId: skill.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || '遗忘失败');
|
||||
}
|
||||
|
||||
pushToast({ message: `已将【${skill.name}】遗忘`, tone: 'default' });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '操作失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
} finally {
|
||||
setDialog((prev) => ({
|
||||
...prev!,
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[cultivator, pushToast, refresh],
|
||||
);
|
||||
|
||||
// 打开遗忘确认
|
||||
const openForgetConfirm = useCallback(
|
||||
(skill: Skill) => {
|
||||
setDialog({
|
||||
id: 'forget-confirm',
|
||||
title: '遗忘神通',
|
||||
content: (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<p>
|
||||
确定要自废{' '}
|
||||
<span className="text-ink-primary font-bold">{skill.name}</span>{' '}
|
||||
吗?
|
||||
</p>
|
||||
<p className="text-ink-secondary text-xs">
|
||||
此乃逆天之举,遗忘后将无法找回该神通的感悟。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmLabel: '自废神通',
|
||||
cancelLabel: '不可',
|
||||
loadingLabel: '遗忘中...',
|
||||
onConfirm: async () => await handleForget(skill),
|
||||
});
|
||||
},
|
||||
[handleForget],
|
||||
);
|
||||
|
||||
// 显示效果帮助
|
||||
const showEffectHelp = useCallback(
|
||||
(effect: StatusEffect) => {
|
||||
openDialog({
|
||||
title: '神通效果说明',
|
||||
content: (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<p>{getStatusEffectInfo(effect).description}</p>
|
||||
</div>
|
||||
),
|
||||
confirmLabel: '了然',
|
||||
});
|
||||
},
|
||||
[openDialog],
|
||||
);
|
||||
|
||||
return {
|
||||
cultivator,
|
||||
skills,
|
||||
isLoading,
|
||||
note,
|
||||
maxSkills,
|
||||
dialog,
|
||||
closeDialog,
|
||||
selectedSkill,
|
||||
isModalOpen,
|
||||
openSkillDetail,
|
||||
closeSkillDetail,
|
||||
openForgetConfirm,
|
||||
showEffectHelp,
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { EffectDetailModal } from '@/components/ui/EffectDetailModal';
|
||||
import { InkBadge } from '@/components/ui/InkBadge';
|
||||
import type { CultivationTechnique } from '@/types/cultivator';
|
||||
|
||||
interface TechniqueDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
technique: CultivationTechnique | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 功法详情弹窗
|
||||
*/
|
||||
export function TechniqueDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
technique,
|
||||
}: TechniqueDetailModalProps) {
|
||||
if (!technique) return null;
|
||||
|
||||
// 功法额外信息
|
||||
const extraInfo = (
|
||||
<div className="pt-2">
|
||||
<span className="mb-1 block opacity-70">功法限制</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-ink/5 rounded px-2 py-1">
|
||||
境界需求:{technique.required_realm}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<EffectDetailModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
icon="📜"
|
||||
name={technique.name}
|
||||
badges={[
|
||||
technique.grade && <InkBadge key="grade" tier={technique.grade} />,
|
||||
].filter(Boolean)}
|
||||
extraInfo={extraInfo}
|
||||
effects={technique.effects}
|
||||
description={technique.description}
|
||||
effectTitle="功法效果"
|
||||
descriptionTitle="功法说明"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { GongFa } from '@/components/func';
|
||||
import { InkPageShell } from '@/components/layout';
|
||||
import {
|
||||
InkActionGroup,
|
||||
InkButton,
|
||||
InkDialog,
|
||||
InkNotice,
|
||||
} from '@/components/ui';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { useTechniquesViewModel } from '../hooks/useTechniquesViewModel';
|
||||
import { TechniqueDetailModal } from './TechniqueDetailModal';
|
||||
|
||||
/**
|
||||
* 功法主视图组件
|
||||
*/
|
||||
export function TechniquesView() {
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
cultivator,
|
||||
techniques,
|
||||
isLoading,
|
||||
note,
|
||||
dialog,
|
||||
closeDialog,
|
||||
selectedTechnique,
|
||||
isModalOpen,
|
||||
openTechniqueDetail,
|
||||
closeTechniqueDetail,
|
||||
openForgetConfirm,
|
||||
showEffectHelp,
|
||||
} = useTechniquesViewModel();
|
||||
|
||||
// 加载状态
|
||||
if (isLoading && !cultivator) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">功法卷轴徐徐展开……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【所修功法】"
|
||||
subtitle={`共 ${techniques.length} 部`}
|
||||
backHref="/game"
|
||||
note={note}
|
||||
currentPath={pathname}
|
||||
footer={
|
||||
<InkActionGroup align="between">
|
||||
<InkButton href="/game">返回</InkButton>
|
||||
<InkButton href="/game/genlightenment" variant="primary">
|
||||
藏经阁 →
|
||||
</InkButton>
|
||||
</InkActionGroup>
|
||||
}
|
||||
>
|
||||
{!cultivator ? (
|
||||
<InkNotice>还未觉醒道身,何谈功法?先去首页觉醒吧。</InkNotice>
|
||||
) : (
|
||||
<>
|
||||
<GongFa
|
||||
cultivations={techniques}
|
||||
showSection={false}
|
||||
showActions={true}
|
||||
renderAction={(technique) => (
|
||||
<div className="flex gap-2">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
onClick={() => openTechniqueDetail(technique)}
|
||||
>
|
||||
详情
|
||||
</InkButton>
|
||||
<InkButton
|
||||
className="px-2 text-sm"
|
||||
onClick={() => openForgetConfirm(technique)}
|
||||
>
|
||||
遗忘
|
||||
</InkButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<InkDialog dialog={dialog} onClose={closeDialog} />
|
||||
|
||||
{/* 功法详情弹窗 */}
|
||||
<TechniqueDetailModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeTechniqueDetail}
|
||||
technique={selectedTechnique}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import type { InkDialogState } from '@/components/ui/InkDialog';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import { StatusEffect } from '@/types/constants';
|
||||
import type { CultivationTechnique } from '@/types/cultivator';
|
||||
import { getStatusEffectInfo } from '@/types/dictionaries';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface UseTechniquesViewModelReturn {
|
||||
// 数据
|
||||
cultivator: ReturnType<typeof useCultivator>['cultivator'];
|
||||
techniques: CultivationTechnique[];
|
||||
isLoading: boolean;
|
||||
note: string | undefined;
|
||||
|
||||
// Dialog 状态
|
||||
dialog: InkDialogState | null;
|
||||
closeDialog: () => void;
|
||||
|
||||
// 详情 Modal 状态
|
||||
selectedTechnique: CultivationTechnique | null;
|
||||
isModalOpen: boolean;
|
||||
openTechniqueDetail: (technique: CultivationTechnique) => void;
|
||||
closeTechniqueDetail: () => void;
|
||||
|
||||
// 业务操作
|
||||
openForgetConfirm: (technique: CultivationTechnique) => void;
|
||||
showEffectHelp: (effect: StatusEffect) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 功法页面 ViewModel
|
||||
*/
|
||||
export function useTechniquesViewModel(): UseTechniquesViewModelReturn {
|
||||
const { cultivator, isLoading, note, refreshCultivator } = useCultivator();
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
|
||||
// Dialog 状态
|
||||
const [dialog, setDialog] = useState<InkDialogState | null>(null);
|
||||
|
||||
// 详情 Modal 状态
|
||||
const [selectedTechnique, setSelectedTechnique] =
|
||||
useState<CultivationTechnique | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// 关闭 Dialog
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialog(null);
|
||||
}, []);
|
||||
|
||||
// 打开功法详情
|
||||
const openTechniqueDetail = useCallback((technique: CultivationTechnique) => {
|
||||
setSelectedTechnique(technique);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 关闭功法详情
|
||||
const closeTechniqueDetail = useCallback(() => {
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
// 遗忘功法
|
||||
const handleForget = useCallback(
|
||||
async (technique: CultivationTechnique) => {
|
||||
if (!cultivator) return;
|
||||
if (!technique.id) {
|
||||
pushToast({
|
||||
message: '无法遗忘该功法(缺少ID)',
|
||||
tone: 'danger',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDialog((prev) => ({
|
||||
...prev!,
|
||||
loading: true,
|
||||
}));
|
||||
|
||||
const response = await fetch('/api/cultivator/techniques/forget', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
techniqueId: technique.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || '遗忘失败');
|
||||
}
|
||||
|
||||
pushToast({
|
||||
message: `已将【${technique.name}】遗忘`,
|
||||
tone: 'default',
|
||||
});
|
||||
// 功法属于核心数据,需要完整刷新
|
||||
await refreshCultivator();
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '操作失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
} finally {
|
||||
setDialog((prev) => ({
|
||||
...prev!,
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[cultivator, pushToast, refreshCultivator],
|
||||
);
|
||||
|
||||
// 打开遗忘确认
|
||||
const openForgetConfirm = useCallback(
|
||||
(technique: CultivationTechnique) => {
|
||||
setDialog({
|
||||
id: 'forget-confirm',
|
||||
title: '遗忘功法',
|
||||
content: (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<p>
|
||||
确定要废除{' '}
|
||||
<span className="text-ink-primary font-bold">
|
||||
{technique.name}
|
||||
</span>{' '}
|
||||
吗?
|
||||
</p>
|
||||
<p className="text-ink-secondary text-xs">
|
||||
自废功法乃大忌,需谨慎行事。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmLabel: '自废功法',
|
||||
cancelLabel: '不可',
|
||||
loadingLabel: '遗忘中...',
|
||||
onConfirm: async () => await handleForget(technique),
|
||||
});
|
||||
},
|
||||
[handleForget],
|
||||
);
|
||||
|
||||
// 显示效果帮助
|
||||
const showEffectHelp = useCallback(
|
||||
(effect: StatusEffect) => {
|
||||
openDialog({
|
||||
title: '效果说明',
|
||||
content: (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<p>{getStatusEffectInfo(effect).description}</p>
|
||||
</div>
|
||||
),
|
||||
confirmLabel: '了然',
|
||||
});
|
||||
},
|
||||
[openDialog],
|
||||
);
|
||||
|
||||
return {
|
||||
cultivator,
|
||||
techniques: cultivator?.cultivations || [],
|
||||
isLoading,
|
||||
note,
|
||||
dialog,
|
||||
closeDialog,
|
||||
selectedTechnique,
|
||||
isModalOpen,
|
||||
openTechniqueDetail,
|
||||
closeTechniqueDetail,
|
||||
openForgetConfirm,
|
||||
showEffectHelp,
|
||||
};
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { InkButton } from '@/components/ui/InkButton';
|
||||
import { InkCard } from '@/components/ui/InkCard';
|
||||
import { InkDivider } from '@/components/ui/InkDivider';
|
||||
import { BattleEngineV2 } from '@/engine/battle/BattleEngine.v2';
|
||||
import type { TurnSnapshot } from '@/engine/battle/types';
|
||||
import { useCultivator } from '@/lib/contexts/CultivatorContext';
|
||||
import type { Cultivator } from '@/types/cultivator';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 练功房页面 - 修复版
|
||||
*/
|
||||
const DUMMY_HP = 1000000000;
|
||||
|
||||
export default function TrainingRoomPage() {
|
||||
const router = useRouter();
|
||||
const { cultivator, isLoading } = useCultivator();
|
||||
const [isFighting, setIsFighting] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([
|
||||
'[系统] 你走进了练功房,四周空旷静谧。',
|
||||
]);
|
||||
const [currentSnapshot, setCurrentSnapshot] = useState<TurnSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动日志
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
const handleStartTraining = async () => {
|
||||
if (!cultivator || isFighting) return;
|
||||
|
||||
setIsFighting(true);
|
||||
setLogs(['[系统] 演武开始。你屏息凝神,望向前方。']);
|
||||
|
||||
// 1. 定义 100 亿血量木桩 (Dummy)
|
||||
const mockDummy: Cultivator = {
|
||||
id: 'dummy',
|
||||
name: '木桩',
|
||||
age: 0,
|
||||
lifespan: 9999,
|
||||
attributes: {
|
||||
vitality: 10,
|
||||
spirit: 10,
|
||||
wisdom: 10,
|
||||
speed: 10,
|
||||
willpower: 10,
|
||||
},
|
||||
spiritual_roots: [],
|
||||
pre_heaven_fates: [],
|
||||
cultivations: [],
|
||||
skills: [],
|
||||
inventory: { artifacts: [], consumables: [], materials: [] },
|
||||
equipped: { weapon: null, armor: null, accessory: null },
|
||||
max_skills: 0,
|
||||
spirit_stones: 0,
|
||||
gender: '男',
|
||||
realm: '炼气',
|
||||
realm_stage: '初期',
|
||||
};
|
||||
|
||||
// 2. 执行战斗模拟
|
||||
const engine = new BattleEngineV2();
|
||||
const result = engine.simulateBattle(cultivator, mockDummy, {
|
||||
isTraining: true,
|
||||
opponentMaxHpOverride: DUMMY_HP,
|
||||
});
|
||||
|
||||
// 3. 初始快照同步
|
||||
if (result.timeline.length > 0) {
|
||||
setCurrentSnapshot(result.timeline[0]);
|
||||
}
|
||||
|
||||
// 4. 逐行展示日志与状态同步
|
||||
let currentLogIndex = 0;
|
||||
let currentTurn = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (currentLogIndex < result.log.length) {
|
||||
const nextLog = result.log[currentLogIndex];
|
||||
setLogs((prev) => [...prev, nextLog]);
|
||||
|
||||
// 检查是否进入新回合,同步快照
|
||||
if (nextLog.startsWith('[第') && nextLog.includes('回合]')) {
|
||||
const turnMatch = nextLog.match(/第(\d+)回合/);
|
||||
if (turnMatch) {
|
||||
currentTurn = parseInt(turnMatch[1], 10);
|
||||
const snap = result.timeline.find((s) => s.turn === currentTurn);
|
||||
if (snap) {
|
||||
setCurrentSnapshot(snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentLogIndex++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setIsFighting(false);
|
||||
// 战斗结束,显示最后的快照并输出总伤害
|
||||
if (result.timeline.length > 0) {
|
||||
const lastSnap = result.timeline[result.timeline.length - 1];
|
||||
setCurrentSnapshot(lastSnap);
|
||||
|
||||
const totalDamage = DUMMY_HP - lastSnap.opponent.hp;
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`[系统] 演武结束。本次演武共对木桩造成伤害:${totalDamage.toLocaleString()}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="border-ink/20 border-t-crimson h-8 w-8 animate-spin rounded-full border-2" />
|
||||
<p className="text-ink/40 text-sm italic">识海构筑中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
|
||||
if (isFighting) {
|
||||
if (!confirm('演武尚未结束,确定要强行离去吗?')) return;
|
||||
}
|
||||
router.push('/game');
|
||||
};
|
||||
|
||||
// 进度条辅助组件
|
||||
const ProgressBar = ({
|
||||
value,
|
||||
max,
|
||||
color = 'bg-crimson',
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
color?: string;
|
||||
}) => {
|
||||
const percent = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
return (
|
||||
<div className="bg-ink/5 h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full ${color} transition-all duration-300 ease-out`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-[calc(100vh-80px)] max-w-2xl flex-col space-y-6 overflow-hidden p-4">
|
||||
{/* 头部 */}
|
||||
<section className="flex shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-ink text-xl font-bold">练功房</h1>
|
||||
<p className="text-ink/50 mt-1 text-xs">原始战斗数值测试中</p>
|
||||
</div>
|
||||
<InkButton onClick={handleLeave} variant="ghost" className="text-sm">
|
||||
离开
|
||||
</InkButton>
|
||||
</section>
|
||||
|
||||
{/* 战斗数值面板 (双血条) */}
|
||||
<InkCard variant="elevated" padding="lg" className="shrink-0 space-y-6">
|
||||
<div className="flex items-start justify-between gap-8">
|
||||
{/* 玩家状态 */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-ink text-sm font-bold">
|
||||
{cultivator?.name || '道友'}
|
||||
</span>
|
||||
<span className="text-ink/60 text-[10px]">
|
||||
{currentSnapshot?.player.hp ?? 0} /{' '}
|
||||
{currentSnapshot?.player.maxHp ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={currentSnapshot?.player.hp ?? 0}
|
||||
max={currentSnapshot?.player.maxHp ?? 0}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-ink/40 text-[10px] italic">灵力</span>
|
||||
<span className="text-ink/60 text-[10px]">
|
||||
{currentSnapshot?.player.mp ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-ink/20 self-center font-serif text-lg italic">
|
||||
VS
|
||||
</div>
|
||||
|
||||
{/* 木桩状态 */}
|
||||
<div className="flex-1 space-y-2 text-right">
|
||||
<div className="flex flex-row-reverse items-end justify-between">
|
||||
<span className="text-ink text-sm font-bold">木桩</span>
|
||||
<span className="text-ink/60 text-[10px]">
|
||||
{currentSnapshot?.opponent.hp ?? DUMMY_HP} / 10亿
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={currentSnapshot?.opponent.hp ?? DUMMY_HP}
|
||||
max={DUMMY_HP}
|
||||
color="bg-ink/40"
|
||||
/>
|
||||
<div className="flex flex-row-reverse items-center justify-between">
|
||||
<span className="text-ink/40 text-[10px] italic">灵力</span>
|
||||
<span className="text-ink/60 text-[10px]">
|
||||
{currentSnapshot?.opponent.mp ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 开始按钮 */}
|
||||
{!isFighting && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<InkButton
|
||||
onClick={handleStartTraining}
|
||||
variant="primary"
|
||||
className="px-8 py-2"
|
||||
>
|
||||
开始演武
|
||||
</InkButton>
|
||||
</div>
|
||||
)}
|
||||
</InkCard>
|
||||
|
||||
{/* 日志展示区 */}
|
||||
<InkCard variant="plain" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between px-1">
|
||||
<h3 className="text-ink/70 text-sm font-bold">演武日志</h3>
|
||||
{isFighting && (
|
||||
<span className="text-crimson animate-pulse text-base tracking-tighter">
|
||||
真元运行中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<InkDivider className="shrink-0 opacity-10" />
|
||||
<div className="text-ink/80 flex-1 space-y-3 overflow-y-auto scroll-smooth px-1 py-4 text-[13px] leading-relaxed">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={
|
||||
log.startsWith('[系统]')
|
||||
? 'text-ink/40 italic'
|
||||
: 'border-ink/5 border-l pl-3'
|
||||
}
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</InkCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { CultivatorProvider } from '@/lib/contexts/CultivatorContext';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 主游戏区布局
|
||||
* - 提供修仙者数据上下文
|
||||
*/
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace('/enter');
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading || !user) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">正在进入道界……</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CultivatorProvider>
|
||||
<div className="bg-paper min-h-screen pb-20">{children}</div>
|
||||
</CultivatorProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { InkModal, InkPageShell, InkSection } from '@/components/layout';
|
||||
import { InkBadge } from '@/components/ui/InkBadge';
|
||||
import { InkList, InkListItem } from '@/components/ui/InkList';
|
||||
import { InkNotice } from '@/components/ui/InkNotice';
|
||||
import { changelogs, type ChangelogItem } from '@/data/changelog';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const [selectedVersion, setSelectedVersion] = useState<ChangelogItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="版本志"
|
||||
subtitle="记载天地间每一次瞬息万变"
|
||||
backHref="/game"
|
||||
>
|
||||
<InkSection title="【历史记录】">
|
||||
<InkList>
|
||||
{changelogs.map((log) => (
|
||||
<div
|
||||
key={log.version}
|
||||
onClick={() => setSelectedVersion(log)}
|
||||
className="hover:bg-ink/5 cursor-pointer rounded-lg transition-colors"
|
||||
>
|
||||
<InkListItem
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-ink font-bold">{log.version}</span>
|
||||
{log.type === 'major' && (
|
||||
<InkBadge tone="danger">重大更新</InkBadge>
|
||||
)}
|
||||
{log.type === 'minor' && (
|
||||
<InkBadge tone="accent">功能</InkBadge>
|
||||
)}
|
||||
{log.type === 'patch' && (
|
||||
<InkBadge tone="default">修复</InkBadge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
meta={<span className="text-sm opacity-60">{log.date}</span>}
|
||||
description={log.title || '日常维护更新'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</InkList>
|
||||
</InkSection>
|
||||
|
||||
<InkNotice>
|
||||
<div className="text-center text-sm opacity-60">
|
||||
版本更迭乃天道常理,道友且行且珍惜。
|
||||
</div>
|
||||
</InkNotice>
|
||||
|
||||
<InkModal
|
||||
isOpen={!!selectedVersion}
|
||||
onClose={() => setSelectedVersion(null)}
|
||||
title={selectedVersion ? `${selectedVersion.version} 更新详情` : ''}
|
||||
>
|
||||
{selectedVersion && (
|
||||
<div className="mt-2 space-y-4">
|
||||
<div className="border-ink/10 flex items-center justify-between border-b pb-2 text-sm opacity-60">
|
||||
<span>{selectedVersion.date}</span>
|
||||
<span>{selectedVersion.title}</span>
|
||||
</div>
|
||||
<ul className="text-ink/90 list-inside list-disc space-y-2">
|
||||
{selectedVersion.changes.map((change, index) => (
|
||||
<li key={index}>{change}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</InkModal>
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 信息展示区布局
|
||||
* - 简洁布局
|
||||
* - 无底部导航
|
||||
*/
|
||||
export default function InfoLayout({ children }: { children: ReactNode }) {
|
||||
return <div className="bg-paper min-h-screen">{children}</div>;
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import TurnstileCaptcha, {
|
||||
type TurnstileCaptchaHandle,
|
||||
} from '@/components/auth/TurnstileCaptcha';
|
||||
import { InkPageShell } from '@/components/layout';
|
||||
import { useInkUI } from '@/components/providers/InkUIProvider';
|
||||
import { InkButton } from '@/components/ui/InkButton';
|
||||
import { InkInput } from '@/components/ui/InkInput';
|
||||
import { InkNotice } from '@/components/ui/InkNotice';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
function ShenShiRenZhuContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { pushToast } = useInkUI();
|
||||
const { user, isLoading, createAnonymousUser } = useAuth();
|
||||
const supabase = createClient();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [sendingEmail, setSendingEmail] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
const turnstileRef = useRef<TurnstileCaptchaHandle | null>(null);
|
||||
const turnstileEnabled = Boolean(process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY);
|
||||
|
||||
// Check if user came back from email confirmation link
|
||||
useEffect(() => {
|
||||
const handleEmailConfirmation = async () => {
|
||||
const code = searchParams.get('code');
|
||||
if (code && !processing) {
|
||||
setProcessing(true);
|
||||
}
|
||||
};
|
||||
|
||||
handleEmailConfirmation();
|
||||
}, [searchParams, processing]);
|
||||
|
||||
// Redirect if user is already bound (not anonymous)
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !user.is_anonymous) {
|
||||
pushToast({ message: '你的真身已与神识绑定成功' });
|
||||
router.push('/game');
|
||||
}
|
||||
}, [user, isLoading, router, pushToast]);
|
||||
|
||||
const handleSendConfirmation = async () => {
|
||||
if (!email.trim()) {
|
||||
pushToast({ message: '请输入飞鸽传书地址', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email.trim())) {
|
||||
pushToast({ message: '飞鸽传书地址格式有误', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && !captchaToken) {
|
||||
pushToast({ message: '请先完成人机验证', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingEmail(true);
|
||||
try {
|
||||
// 全局 CAPTCHA 开启后,匿名会话必须带 token 创建。
|
||||
if (!user) {
|
||||
const { error: createError } = await createAnonymousUser(
|
||||
turnstileEnabled ? captchaToken ?? undefined : undefined,
|
||||
);
|
||||
if (createError) {
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
|
||||
// Use updateUser to send email confirmation
|
||||
// This will send an email with a confirmation link
|
||||
const { error } = await supabase.auth.updateUser(
|
||||
{
|
||||
email: email.trim().toLowerCase(),
|
||||
},
|
||||
{ emailRedirectTo: `${window.location.origin}/shenshi-renzhu` },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
// Check for specific error cases
|
||||
if (error.message.includes('rate limit')) {
|
||||
throw new Error('请求过于频繁,请一个时辰后再试');
|
||||
}
|
||||
if (error.message.includes('already registered')) {
|
||||
throw new Error('此飞鸽传书地址已被他人占用');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
setEmailSent(true);
|
||||
pushToast({
|
||||
message: '天机印已发往你的飞鸽传书地址,请查收完成认主',
|
||||
tone: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : '发送失败,请稍后重试';
|
||||
pushToast({ message: errorMessage, tone: 'danger' });
|
||||
} finally {
|
||||
turnstileRef.current?.reset();
|
||||
setSendingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || processing) {
|
||||
return (
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">
|
||||
{processing ? '验证天机印中……' : '神识感应中……'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InkPageShell
|
||||
title="【神识认主】"
|
||||
subtitle="绑定飞鸽传书,永存真身"
|
||||
backHref="/game"
|
||||
currentPath="/shenshi-renzhu"
|
||||
footer={
|
||||
<div className="flex justify-between">
|
||||
<InkButton href="/game">返回</InkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{!emailSent ? (
|
||||
<>
|
||||
<InkNotice>游客真身易逝,绑定神识方可长存。</InkNotice>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm opacity-70">
|
||||
飞鸽传书地址(邮箱)
|
||||
</label>
|
||||
<InkInput
|
||||
value={email}
|
||||
onChange={(value) => setEmail(value)}
|
||||
placeholder="例:daoyou@xiuxian.com"
|
||||
disabled={sendingEmail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InkButton
|
||||
onClick={handleSendConfirmation}
|
||||
variant="primary"
|
||||
disabled={sendingEmail}
|
||||
className="w-full"
|
||||
>
|
||||
{sendingEmail ? '发送中…' : '发送天机印'}
|
||||
</InkButton>
|
||||
|
||||
{turnstileEnabled ? (
|
||||
<TurnstileCaptcha
|
||||
ref={turnstileRef}
|
||||
onTokenChange={setCaptchaToken}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InkNotice>
|
||||
✓ 天机印已发送!
|
||||
<br />
|
||||
<br />
|
||||
请查收发送至 <strong>{email}</strong> 的邮件。
|
||||
<br />
|
||||
点击邮件中的链接即可完成神识认主。
|
||||
</InkNotice>
|
||||
|
||||
<InkNotice>
|
||||
未收到邮件?
|
||||
<br />
|
||||
• 请检查垃圾邮件文件夹
|
||||
<br />
|
||||
• 等待片刻后重试
|
||||
<br />• 确认邮箱地址正确
|
||||
</InkNotice>
|
||||
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setEmailSent(false);
|
||||
setEmail('');
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
重新输入邮箱
|
||||
</InkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</InkPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShenShiRenZhuPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-paper flex min-h-screen items-center justify-center">
|
||||
<p className="loading-tip">神识感应中……</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ShenShiRenZhuContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function IconMenu({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 50);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
{ name: '特色', href: '#features' },
|
||||
{ name: '玩法', href: '#gameplay' },
|
||||
{ name: '公告', href: '#updates' },
|
||||
{ name: 'GitHub', href: 'https://github.com/ChurchTao/wanjiedaoyou' },
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 right-0 left-0 z-50 transition-all duration-300 ${
|
||||
scrolled ? 'nav-glass py-2' : 'bg-transparent py-4'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4">
|
||||
{/* Logo Text */}
|
||||
<Link href="/" className="group flex items-center gap-2 no-underline">
|
||||
<div className="relative h-8 w-8 transition-transform group-hover:rotate-12 md:h-10 md:w-10">
|
||||
<img
|
||||
src="/assets/daoyou_logo.png"
|
||||
alt="Logo"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`font-heading text-ink text-xl transition-opacity md:text-2xl ${
|
||||
scrolled ? 'opacity-100' : 'opacity-80'
|
||||
}`}
|
||||
>
|
||||
万界道友
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden items-center gap-8 md:flex">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
target={link.href.startsWith('http') ? '_blank' : undefined}
|
||||
className="nav-link text-ink hover:text-crimson"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
<Link
|
||||
href="/game/create"
|
||||
className="border-ink text-ink hover:bg-ink hover:text-paper rounded border px-4 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
开启修行
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="text-ink p-2 md:hidden"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<IconMenu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Dropdown */}
|
||||
{menuOpen && (
|
||||
<div className="bg-paper/95 border-ink/10 animate-in slide-in-from-top-2 absolute top-full right-0 left-0 flex flex-col gap-4 border-b p-4 shadow-lg backdrop-blur-md md:hidden">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className="font-heading text-ink py-2 text-center text-lg"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
<Link
|
||||
href="/game/create"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="bg-ink text-paper block rounded py-3 text-center"
|
||||
>
|
||||
开启修行
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client';
|
||||
|
||||
export function ScrollHint() {
|
||||
return (
|
||||
<div
|
||||
className="scroll-hint absolute bottom-8 left-1/2 -translate-x-1/2 transform cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById('features')
|
||||
?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
>
|
||||
<p className="text-ink-muted mb-2 text-xs opacity-60 md:text-sm">
|
||||
向下探索
|
||||
</p>
|
||||
<svg
|
||||
className="text-ink-secondary mx-auto h-6 w-6 opacity-60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// 预生成的粒子数据(静态值避免随机性问题)
|
||||
const STATIC_PARTICLES = [
|
||||
{ id: 0, left: 5, delay: 0.2, duration: 7 },
|
||||
{ id: 1, left: 12, delay: 1.5, duration: 8 },
|
||||
{ id: 2, left: 20, delay: 0.8, duration: 6.5 },
|
||||
{ id: 3, left: 28, delay: 2.1, duration: 9 },
|
||||
{ id: 4, left: 35, delay: 0.5, duration: 7.5 },
|
||||
{ id: 5, left: 42, delay: 3.2, duration: 8.5 },
|
||||
{ id: 6, left: 50, delay: 1.8, duration: 6 },
|
||||
{ id: 7, left: 58, delay: 4.1, duration: 9.5 },
|
||||
{ id: 8, left: 65, delay: 0.3, duration: 7.2 },
|
||||
{ id: 9, left: 72, delay: 2.8, duration: 8.8 },
|
||||
{ id: 10, left: 78, delay: 1.2, duration: 6.8 },
|
||||
{ id: 11, left: 85, delay: 3.5, duration: 7.8 },
|
||||
{ id: 12, left: 92, delay: 0.9, duration: 9.2 },
|
||||
{ id: 13, left: 8, delay: 4.5, duration: 6.2 },
|
||||
{ id: 14, left: 25, delay: 2.5, duration: 8.2 },
|
||||
{ id: 15, left: 48, delay: 1.0, duration: 7.3 },
|
||||
{ id: 16, left: 62, delay: 3.8, duration: 6.7 },
|
||||
{ id: 17, left: 75, delay: 0.6, duration: 9.8 },
|
||||
{ id: 18, left: 88, delay: 2.3, duration: 7.7 },
|
||||
{ id: 19, left: 95, delay: 4.0, duration: 8.3 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 灵气粒子组件
|
||||
* 客户端组件,处理动画效果
|
||||
*/
|
||||
export function SpiritParticles() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 延迟挂载以确保动画正确播放
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="spirit-particles" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="spirit-particles" aria-hidden="true">
|
||||
{STATIC_PARTICLES.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="spirit-particle"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ScreenshotCategory } from '@/data/screenshot';
|
||||
|
||||
interface CategoryTabsProps {
|
||||
categories: ScreenshotCategory[];
|
||||
activeCategory: string;
|
||||
counts: Record<string, number>;
|
||||
onCategoryChange: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryTabs({
|
||||
categories,
|
||||
activeCategory,
|
||||
counts,
|
||||
onCategoryChange,
|
||||
}: CategoryTabsProps) {
|
||||
return (
|
||||
<div className="category-tabs-container mb-10">
|
||||
<div className="category-tabs">
|
||||
{categories.map((category) => {
|
||||
const count = counts[category.id] || 0;
|
||||
const isActive = activeCategory === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
className={`category-tab ${isActive ? 'category-tab-active' : ''}`}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`筛选 ${category.label} 截图`}
|
||||
>
|
||||
<span className="category-tab-icon" aria-hidden="true">
|
||||
{category.icon}
|
||||
</span>
|
||||
<span className="category-tab-label">{category.label}</span>
|
||||
<span className="category-tab-count" aria-hidden="true">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Screenshot } from '@/data/screenshot';
|
||||
|
||||
interface ScreenshotCardProps {
|
||||
screenshot: Screenshot;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function ScreenshotCard({ screenshot, index }: ScreenshotCardProps) {
|
||||
return (
|
||||
<figure
|
||||
className="screenshot-card-enhanced ancient-border group"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="bg-ink/5 relative aspect-[9/16] w-full overflow-hidden">
|
||||
<img
|
||||
src={screenshot.url}
|
||||
alt={screenshot.alt}
|
||||
className="screenshot-image-enhanced absolute inset-0 h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Hover Overlay */}
|
||||
<div className="screenshot-hover-overlay absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300">
|
||||
<svg
|
||||
className="text-paper h-12 w-12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{(screenshot.alt || screenshot.description) && (
|
||||
<figcaption className="screenshot-caption-enhanced">
|
||||
<span className="text-ink font-medium">{screenshot.alt}</span>
|
||||
{screenshot.description && (
|
||||
<span className="text-ink-muted mt-1 block text-xs">
|
||||
{screenshot.description}
|
||||
</span>
|
||||
)}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
screenshotCategories,
|
||||
screenshots as screenshotGroups,
|
||||
type Screenshot,
|
||||
} from '@/data/screenshot';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CategoryTabs } from './CategoryTabs';
|
||||
import { ScreenshotGrid } from './ScreenshotGrid';
|
||||
|
||||
export function ScreenshotGallery() {
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
|
||||
// Flatten all screenshots with explicit type
|
||||
const allScreenshots: Screenshot[] = useMemo(() => {
|
||||
return screenshotGroups.flatMap((group) => group.screenshots);
|
||||
}, []);
|
||||
|
||||
// Filter screenshots by category
|
||||
const filteredScreenshots = useMemo(() => {
|
||||
if (activeCategory === 'all') {
|
||||
return allScreenshots;
|
||||
}
|
||||
return allScreenshots.filter((s) => s.category === activeCategory);
|
||||
}, [activeCategory, allScreenshots]);
|
||||
|
||||
// Calculate counts for each category
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {
|
||||
all: allScreenshots.length,
|
||||
};
|
||||
|
||||
screenshotCategories.forEach((cat) => {
|
||||
if (cat.id !== 'all') {
|
||||
counts[cat.id] = allScreenshots.filter(
|
||||
(s) => s.category === cat.id,
|
||||
).length;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [allScreenshots]);
|
||||
|
||||
return (
|
||||
<div className="screenshot-gallery">
|
||||
<CategoryTabs
|
||||
categories={screenshotCategories}
|
||||
activeCategory={activeCategory}
|
||||
counts={categoryCounts}
|
||||
onCategoryChange={setActiveCategory}
|
||||
/>
|
||||
|
||||
<ScreenshotGrid screenshots={filteredScreenshots} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Screenshot } from '@/data/screenshot';
|
||||
import { ScreenshotCard } from './ScreenshotCard';
|
||||
|
||||
interface ScreenshotGridProps {
|
||||
screenshots: Screenshot[];
|
||||
}
|
||||
|
||||
export function ScreenshotGrid({ screenshots }: ScreenshotGridProps) {
|
||||
return (
|
||||
<div className="screenshot-masonry">
|
||||
{screenshots.map((screenshot, index) => (
|
||||
<ScreenshotCard
|
||||
key={screenshot.url}
|
||||
screenshot={screenshot}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
/* Landing Page Styles - 古籍水墨风格宣传页 */
|
||||
|
||||
/* --- 基础动画与背景 --- */
|
||||
|
||||
/* 灵气粒子动画容器 */
|
||||
.spirit-particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.spirit-particle {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(239, 191, 4, 0.8) 0%,
|
||||
rgba(239, 191, 4, 0.4) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
border-radius: 50%;
|
||||
animation: float-particle 8s ease-in-out infinite;
|
||||
box-shadow: 0 0 10px rgba(239, 191, 4, 0.5);
|
||||
}
|
||||
|
||||
@keyframes float-particle {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.5);
|
||||
}
|
||||
10% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-10vh) scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: translateY(-50vh) scale(1);
|
||||
}
|
||||
90% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(-90vh) scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100vh) scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* 水墨渐变背景 */
|
||||
.ink-wash-bg {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(44, 24, 16, 0.03) 30%,
|
||||
rgba(44, 24, 16, 0.08) 70%,
|
||||
rgba(44, 24, 16, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Hero 区域山水装饰 */
|
||||
.hero-decoration {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 200px;
|
||||
background: linear-gradient(to top, var(--color-paper) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- 文本效果 --- */
|
||||
|
||||
/* 标题光晕效果 */
|
||||
.title-glow {
|
||||
text-shadow:
|
||||
0 0 20px rgba(239, 191, 4, 0.3),
|
||||
0 0 40px rgba(239, 191, 4, 0.2),
|
||||
0 0 60px rgba(239, 191, 4, 0.1);
|
||||
}
|
||||
|
||||
/* Section 标题装饰线 */
|
||||
.section-title-decorated {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.section-title-decorated::before,
|
||||
.section-title-decorated::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 60px;
|
||||
height: 2px; /* 加粗一点 */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(44, 24, 16, 0.5),
|
||||
transparent
|
||||
);
|
||||
border-radius: 50%; /* 模拟笔触圆头 */
|
||||
}
|
||||
|
||||
.section-title-decorated::before {
|
||||
right: 100%;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.section-title-decorated::after {
|
||||
left: 100%;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
/* --- 交互组件 --- */
|
||||
|
||||
/* 导航栏玻璃态 */
|
||||
.nav-glass {
|
||||
background: rgba(248, 243, 230, 0.85); /* var(--color-paper) alpha */
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(44, 24, 16, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-ma-shan-zheng);
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-crimson);
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scaleX(0);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: var(--color-crimson);
|
||||
transition: transform 0.3s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.nav-link:hover::after {
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
/* CTA 按钮增强 */
|
||||
.cta-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 40px;
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-paper);
|
||||
background: linear-gradient(135deg, var(--color-crimson) 0%, #a01018 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(193, 18, 31, 0.3);
|
||||
font-family: var(--font-ma-shan-zheng);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(193, 18, 31, 0.4);
|
||||
}
|
||||
|
||||
.cta-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border: 1px solid rgba(193, 18, 31, 0.3);
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 次要按钮 */
|
||||
.cta-button-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 36px;
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-ink);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-ink);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-family: var(--font-ma-shan-zheng);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.cta-button-secondary:hover {
|
||||
color: var(--color-crimson);
|
||||
border-color: var(--color-crimson);
|
||||
background: rgba(193, 18, 31, 0.05);
|
||||
}
|
||||
|
||||
/* 滚动提示动画 */
|
||||
.scroll-hint {
|
||||
animation: bounce-down 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce-down {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 卡片与布局 --- */
|
||||
|
||||
/* 特性卡片 */
|
||||
.feature-card {
|
||||
position: relative;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 40px rgba(44, 24, 16, 0.15);
|
||||
}
|
||||
|
||||
/* 古籍边框装饰 - "文武框"风格 */
|
||||
.ancient-border {
|
||||
position: relative;
|
||||
border: 2px solid rgba(44, 24, 16, 0.6);
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
inset 0 0 0 4px var(--color-paper),
|
||||
inset 0 0 0 5px rgba(44, 24, 16, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 边角装饰 */
|
||||
.ancient-border::before,
|
||||
.ancient-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.ancient-border::before {
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
border-top-color: var(--color-ink);
|
||||
border-left-color: var(--color-ink);
|
||||
}
|
||||
|
||||
.ancient-border::after {
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
border-bottom-color: var(--color-ink);
|
||||
border-right-color: var(--color-ink);
|
||||
}
|
||||
|
||||
.ancient-border:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: var(--color-ink);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
inset 0 0 0 4px var(--color-paper),
|
||||
inset 0 0 0 5px var(--color-crimson),
|
||||
0 10px 30px rgba(44, 24, 16, 0.1);
|
||||
}
|
||||
|
||||
.ancient-border:hover::before,
|
||||
.ancient-border:hover::after {
|
||||
border-color: var(--color-crimson);
|
||||
}
|
||||
|
||||
/* --- 公告/更新日志时间轴 --- */
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
border-left: 2px solid rgba(44, 24, 16, 0.1);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: -5px; /* (2px border / 2) - (8px dot / 2) ≈ -3px, 调整为视觉居中 */
|
||||
top: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-ink-secondary);
|
||||
border: 2px solid var(--color-paper);
|
||||
box-shadow: 0 0 0 2px rgba(44, 24, 16, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-item:hover .timeline-dot {
|
||||
background-color: var(--color-crimson);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-ink-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* --- 笔触分割线 --- */
|
||||
.ink-divider {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 10 Q 50 20 100 10' stroke='rgba(44,24,16,0.1)' stroke-width='2' fill='none'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
opacity: 0.6;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* --- 动画工具类 --- */
|
||||
.fade-in-up {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.delay-100 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.delay-300 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.delay-400 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.section-title-decorated::before,
|
||||
.section-title-decorated::after {
|
||||
width: 30px;
|
||||
}
|
||||
.section-title-decorated::before {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.section-title-decorated::after {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
padding: 14px 32px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Screenshot Gallery --- */
|
||||
|
||||
/* Category Tabs */
|
||||
.category-tabs-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 8px 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.category-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.category-tabs {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(248, 243, 230, 0.8);
|
||||
border: 2px solid rgba(44, 24, 16, 0.2);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-ma-shan-zheng);
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
white-space: nowrap;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.category-tab:hover {
|
||||
border-color: var(--color-crimson);
|
||||
background: rgba(248, 243, 230, 1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.category-tab:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.category-tab-icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.category-tab-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: rgba(44, 24, 16, 0.1);
|
||||
border-radius: 10px;
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
|
||||
.category-tab-active {
|
||||
background: var(--color-crimson);
|
||||
border-color: var(--color-crimson);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
|
||||
.category-tab-active .category-tab-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
|
||||
/* Screenshot Masonry Grid */
|
||||
.screenshot-masonry {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-flow: dense;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screenshot-masonry {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.screenshot-masonry {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Screenshot Card */
|
||||
.screenshot-card-enhanced {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: var(--color-paper);
|
||||
box-shadow: 0 4px 16px rgba(44, 24, 16, 0.1);
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-card-enhanced:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(44, 24, 16, 0.18);
|
||||
}
|
||||
|
||||
.screenshot-image-enhanced {
|
||||
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.screenshot-card-enhanced:hover .screenshot-image-enhanced {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Hover overlay */
|
||||
.screenshot-hover-overlay {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(44, 24, 16, 0.3) 0%,
|
||||
rgba(44, 24, 16, 0.5) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.screenshot-card-enhanced:hover .screenshot-hover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.screenshot-caption-enhanced {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid rgba(44, 24, 16, 0.08);
|
||||
background: rgba(248, 243, 230, 0.9);
|
||||
}
|
||||
|
||||
/* Screenshot Gallery container */
|
||||
.screenshot-gallery {
|
||||
opacity: 0;
|
||||
animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 信息展示区布局
|
||||
* - 简洁布局
|
||||
* - 无底部导航
|
||||
*/
|
||||
export default function InfoLayout({ children }: { children: ReactNode }) {
|
||||
return <div className="bg-paper min-h-screen">{children}</div>;
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import { changelogs } from '@/data/changelog';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Navbar } from './Navbar';
|
||||
import { ScrollHint } from './ScrollHint';
|
||||
import { SpiritParticles } from './SpiritParticles';
|
||||
import { ScreenshotGallery } from './components/ScreenshotGallery';
|
||||
import './landing.css';
|
||||
|
||||
// --- Icons (Inline SVG) ---
|
||||
|
||||
function IconRoots({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 22v-9" />
|
||||
<path d="M12 13a5 5 0 0 0-5-5" />
|
||||
<path d="M12 13a5 5 0 0 1 5-5" />
|
||||
<path d="M12 22a5 5 0 0 0-5-5" />
|
||||
<path d="M12 22a5 5 0 0 1 5-5" />
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconAI({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z" />
|
||||
<path d="M10 12h4" />
|
||||
<path d="M12 12v3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCombat({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M14.5 17.5L3 6V3h3l11.5 11.5" />
|
||||
<path d="M13 19l6-6" />
|
||||
<path d="M16 16l4 4" />
|
||||
<path d="M19 21l2-2" />
|
||||
<path d="m22 22-5-5" />
|
||||
<path d="m2 2 5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconWorld({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <IconRoots className="text-ink-secondary h-10 w-10" />,
|
||||
title: '灵根命数',
|
||||
description:
|
||||
'独特的角色生成系统,五行灵根、先天命数,铸就你独一无二的修仙根基。',
|
||||
},
|
||||
{
|
||||
icon: <IconAI className="text-ink-secondary h-10 w-10" />,
|
||||
title: 'AI 驱动',
|
||||
description:
|
||||
'基于 AIGC 技术,智能生成剧情对话、奇遇事件,每次游玩都是全新体验。',
|
||||
},
|
||||
{
|
||||
icon: <IconCombat className="text-ink-secondary h-10 w-10" />,
|
||||
title: '自由战斗',
|
||||
description:
|
||||
'丰富的功法神通、法宝灵器,策略性回合制战斗,挑战各路天骄修士。',
|
||||
},
|
||||
{
|
||||
icon: <IconWorld className="text-ink-secondary h-10 w-10" />,
|
||||
title: '开放世界',
|
||||
description:
|
||||
'广袤的修仙世界,秘境副本、天机奇遇,等你探索未知的机缘与危险。',
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
const latestUpdates = changelogs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="bg-paper selection:bg-crimson/20 selection:text-ink min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* Hero 区域 */}
|
||||
<section className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 pt-16">
|
||||
<SpiritParticles />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl text-center">
|
||||
{/* Logo */}
|
||||
<div className="fade-in-up mb-8">
|
||||
<div className="relative mx-auto h-32 w-32 drop-shadow-2xl transition-transform duration-700 ease-in-out hover:scale-105 md:h-48 md:w-48">
|
||||
<img
|
||||
src="/assets/daoyou_logo.png"
|
||||
alt="万界道友 Logo"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="font-heading text-ink title-glow fade-in-up mb-6 text-5xl leading-tight delay-100 md:text-8xl">
|
||||
万界道友录
|
||||
</h1>
|
||||
|
||||
<p className="text-ink-secondary fade-in-up mb-3 text-xl font-light tracking-wide delay-200 md:text-3xl">
|
||||
以 AIGC 驱动的
|
||||
<span className="text-crimson mx-1 font-medium">高自由度</span>
|
||||
文字修仙
|
||||
</p>
|
||||
|
||||
<div className="text-ink-muted fade-in-up mb-10 flex items-center justify-center gap-4 text-sm opacity-80 delay-300 md:text-base">
|
||||
<span>开源免费</span>
|
||||
<span className="bg-ink/30 h-1 w-1 rounded-full" />
|
||||
<span>无限剧情</span>
|
||||
<span className="bg-ink/30 h-1 w-1 rounded-full" />
|
||||
<span>独创功法</span>
|
||||
</div>
|
||||
|
||||
<div className="fade-in-up flex flex-col items-center justify-center gap-5 delay-400 sm:flex-row">
|
||||
<Link href="/enter" className="cta-button group">
|
||||
<span className="relative z-10">进入道界</span>
|
||||
<div className="absolute inset-0 origin-left scale-x-0 rounded bg-white/20 transition-transform duration-300 group-hover:scale-x-100" />
|
||||
</Link>
|
||||
<Link href="/login" className="cta-button-secondary group">
|
||||
<span>召回真身</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollHint />
|
||||
|
||||
<div className="hero-decoration" />
|
||||
</section>
|
||||
|
||||
{/* 简介区 */}
|
||||
<section className="ink-wash-bg relative px-4 py-20 md:py-28">
|
||||
<div className="relative z-10 mx-auto max-w-3xl text-center">
|
||||
<div className="mb-6 inline-block">
|
||||
<h2 className="font-heading text-ink section-title-decorated text-3xl md:text-5xl">
|
||||
大道三千,只取一瓢
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="text-ink space-y-6 text-lg leading-relaxed opacity-90 md:text-xl">
|
||||
<p>
|
||||
《万界道友》不仅仅是一款游戏,更是一个
|
||||
<br className="md:hidden" />
|
||||
<strong className="text-crimson font-medium">
|
||||
由 AI 构建的动态修仙世界
|
||||
</strong>
|
||||
。
|
||||
</p>
|
||||
<p>
|
||||
告别千篇一律的剧本,每一次开局都是全新的命运。
|
||||
<br />
|
||||
这里的奇遇由算法生成,这里的功法由你来命名。
|
||||
</p>
|
||||
<p className="text-ink-secondary italic">
|
||||
天地不仁,以万物为刍狗;
|
||||
<br />
|
||||
唯有修仙,方可逆天改命。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 核心特性区 */}
|
||||
<section id="features" className="scroll-mt-20 px-4 py-20 md:py-28">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="font-heading text-ink section-title-decorated text-3xl md:text-4xl">
|
||||
核心特色
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="features-grid">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className={`feature-card bg-paper ancient-border group p-8`}
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="text-ink group-hover:text-crimson mb-6 origin-center transform transition-transform duration-500 group-hover:scale-110">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className="font-heading text-ink group-hover:text-crimson mb-4 text-2xl transition-colors">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-ink-secondary text-sm leading-loose md:text-base">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 游戏截图区 */}
|
||||
<section
|
||||
id="gameplay"
|
||||
className="ink-wash-bg scroll-mt-20 px-4 py-20 md:py-28"
|
||||
>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-12 text-center">
|
||||
<h2 className="font-heading text-ink section-title-decorated text-3xl md:text-4xl">
|
||||
游戏一览
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ScreenshotGallery />
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<Link
|
||||
href="/game/create"
|
||||
className="text-crimson hover:text-ink font-heading border-crimson/30 hover:border-ink inline-flex items-center gap-2 border-b pb-0.5 text-lg transition-colors"
|
||||
>
|
||||
<span>查看更多玩法说明</span>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 仙界传书 (公告) */}
|
||||
<section id="updates" className="scroll-mt-20 px-4 py-20 md:py-28">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="font-heading text-ink section-title-decorated text-3xl md:text-4xl">
|
||||
仙界传书
|
||||
</h2>
|
||||
<p className="text-ink-muted mt-4 text-sm">LATEST UPDATES</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-paper border-ink/10 relative overflow-hidden rounded-lg border p-8 shadow-sm">
|
||||
{/* 装饰水印 */}
|
||||
<div className="pointer-events-none absolute top-0 right-0 rotate-12 transform p-16 opacity-[0.03]">
|
||||
<IconRoots className="text-ink h-64 w-64" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 space-y-2">
|
||||
{latestUpdates.map((log) => (
|
||||
<div key={log.version} className="timeline-item group">
|
||||
<div className="timeline-dot group-hover:bg-crimson group-hover:scale-125"></div>
|
||||
<div className="timeline-date">
|
||||
{log.date} · {log.version}
|
||||
</div>
|
||||
<h3 className="text-ink group-hover:text-crimson mb-2 text-xl font-bold transition-colors">
|
||||
{log.title}
|
||||
</h3>
|
||||
<ul className="list-none space-y-1">
|
||||
{log.changes.slice(0, 2).map((change, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-ink-secondary hover:border-ink/20 border-l-2 border-transparent pl-2 text-sm md:text-base"
|
||||
>
|
||||
{change}
|
||||
</li>
|
||||
))}
|
||||
{log.changes.length > 2 && (
|
||||
<li className="text-ink-muted pt-1 text-xs">
|
||||
... 以及更多优化
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-ink/5 mt-8 border-t pt-8 text-center">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="cta-button-secondary px-6 py-2 text-sm"
|
||||
>
|
||||
查看全部更新日志
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 底部 CTA */}
|
||||
<section className="ink-wash-bg px-4 py-20 text-center md:py-28">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h2 className="font-heading text-ink mb-6 text-3xl md:text-5xl">
|
||||
即刻踏入仙途
|
||||
</h2>
|
||||
<p className="text-ink-secondary mb-10 text-lg">
|
||||
无需下载,点击即玩。
|
||||
<br />
|
||||
你的修仙传说,从此刻开始书写。
|
||||
</p>
|
||||
<Link
|
||||
href="/game/create"
|
||||
className="cta-button px-12 py-4 text-xl shadow-xl hover:-translate-y-1 hover:shadow-2xl"
|
||||
>
|
||||
立即开始
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="border-ink/10 bg-paper border-t px-4 py-10">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 md:flex-row">
|
||||
<div className="text-center md:text-left">
|
||||
<div className="mb-2 flex items-center justify-center gap-2 md:justify-start">
|
||||
<img
|
||||
src="/assets/daoyou_logo.png"
|
||||
alt="Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span className="font-heading text-lg">万界道友</span>
|
||||
</div>
|
||||
<p className="text-ink-muted text-xs">
|
||||
© {new Date().getFullYear()} daoyou.org. GPL-3.0 Licensed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-ink-secondary flex gap-6 text-sm">
|
||||
<Link
|
||||
href="https://github.com/ChurchTao/wanjiedaoyou"
|
||||
className="hover:text-crimson transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="hover:text-crimson transition-colors"
|
||||
>
|
||||
更新日志
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="hover:text-crimson transition-colors"
|
||||
>
|
||||
关于我们
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { resolveEmailRecipients } from '@/lib/admin/recipient-resolver';
|
||||
import { sendViaSmtp } from '@/lib/admin/smtp';
|
||||
import { normalizeTemplatePayload, renderTemplate } from '@/lib/admin/template';
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { adminMessageTemplates } from '@/lib/drizzle/schema';
|
||||
import { REALM_VALUES } from '@/types/constants';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const EmailBroadcastSchema = z
|
||||
.object({
|
||||
templateId: z.string().uuid().optional(),
|
||||
subject: z.string().trim().min(1).max(200).optional(),
|
||||
content: z.string().trim().min(1).max(10000).optional(),
|
||||
payload: z
|
||||
.record(z.string(), z.union([z.string(), z.number()]))
|
||||
.default({}),
|
||||
filters: z
|
||||
.object({
|
||||
registeredFrom: z.string().optional(),
|
||||
registeredTo: z.string().optional(),
|
||||
hasActiveCultivator: z.boolean().optional(),
|
||||
realmMin: z.enum(REALM_VALUES).optional(),
|
||||
realmMax: z.enum(REALM_VALUES).optional(),
|
||||
})
|
||||
.default({}),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.templateId && (!value.subject || !value.content)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['subject'],
|
||||
message: '未使用模板时,subject/content 必填',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const POST = withAdminAuth(async (request: NextRequest) => {
|
||||
const q = getExecutor();
|
||||
const body = await request.json();
|
||||
const parsed = EmailBroadcastSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { templateId, payload, filters, dryRun } = parsed.data;
|
||||
const resolvedRecipients = await resolveEmailRecipients(filters);
|
||||
|
||||
if (dryRun) {
|
||||
return NextResponse.json({
|
||||
dryRun: true,
|
||||
totalRecipients: resolvedRecipients.totalCount,
|
||||
sampleRecipients: resolvedRecipients.sampleRecipients,
|
||||
});
|
||||
}
|
||||
|
||||
let finalSubject = parsed.data.subject ?? '';
|
||||
let finalContent = parsed.data.content ?? '';
|
||||
|
||||
if (templateId) {
|
||||
const template = await q.query.adminMessageTemplates.findFirst({
|
||||
where: eq(adminMessageTemplates.id, templateId),
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json({ error: '模板不存在' }, { status: 404 });
|
||||
}
|
||||
if (template.channel !== 'email') {
|
||||
return NextResponse.json({ error: '模板频道不匹配' }, { status: 400 });
|
||||
}
|
||||
if (template.status !== 'active') {
|
||||
return NextResponse.json({ error: '模板已停用' }, { status: 400 });
|
||||
}
|
||||
if (!template.subjectTemplate) {
|
||||
return NextResponse.json(
|
||||
{ error: 'email 模板缺少 subjectTemplate' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const mergedPayload = normalizeTemplatePayload(
|
||||
template.defaultPayload,
|
||||
payload,
|
||||
);
|
||||
finalSubject = renderTemplate(template.subjectTemplate, mergedPayload);
|
||||
finalContent = renderTemplate(template.contentTemplate, mergedPayload);
|
||||
}
|
||||
|
||||
const recipients = resolvedRecipients.recipients.map(
|
||||
(item) => item.recipientKey,
|
||||
);
|
||||
const batchSize = Number(process.env.ADMIN_BROADCAST_BATCH_SIZE ?? 20);
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < recipients.length; i += batchSize) {
|
||||
const batch = recipients.slice(i, i + batchSize);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map((email) => sendViaSmtp(email, finalSubject, finalContent)),
|
||||
);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
sent += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
if (errors.length < 20) {
|
||||
errors.push(
|
||||
`${batch[index]}: ${result.reason?.message ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: failed === 0,
|
||||
totalRecipients: recipients.length,
|
||||
sent,
|
||||
failed,
|
||||
errors,
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { resolveGameMailRecipients } from '@/lib/admin/recipient-resolver';
|
||||
import { normalizeTemplatePayload, renderTemplate } from '@/lib/admin/template';
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { adminMessageTemplates, mails } from '@/lib/drizzle/schema';
|
||||
import { MailAttachment } from '@/lib/services/MailService';
|
||||
import { REALM_VALUES } from '@/types/constants';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const GameMailBroadcastSchema = z
|
||||
.object({
|
||||
templateId: z.string().uuid().optional(),
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
content: z.string().trim().min(1).max(10000).optional(),
|
||||
rewardSpiritStones: z.number().int().min(0).max(100000000).optional(),
|
||||
payload: z
|
||||
.record(z.string(), z.union([z.string(), z.number()]))
|
||||
.default({}),
|
||||
filters: z
|
||||
.object({
|
||||
cultivatorCreatedFrom: z.string().optional(),
|
||||
cultivatorCreatedTo: z.string().optional(),
|
||||
realmMin: z.enum(REALM_VALUES).optional(),
|
||||
realmMax: z.enum(REALM_VALUES).optional(),
|
||||
})
|
||||
.default({}),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.templateId && (!value.title || !value.content)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['title'],
|
||||
message: '未使用模板时,title/content 必填',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const POST = withAdminAuth(async (request: NextRequest) => {
|
||||
const q = getExecutor();
|
||||
const body = await request.json();
|
||||
const parsed = GameMailBroadcastSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { templateId, filters, payload, dryRun } = parsed.data;
|
||||
const resolvedRecipients = await resolveGameMailRecipients(filters);
|
||||
|
||||
if (dryRun) {
|
||||
return NextResponse.json({
|
||||
dryRun: true,
|
||||
totalRecipients: resolvedRecipients.totalCount,
|
||||
sampleRecipients: resolvedRecipients.sampleRecipients,
|
||||
});
|
||||
}
|
||||
|
||||
let finalTitle = parsed.data.title ?? '';
|
||||
let finalContent = parsed.data.content ?? '';
|
||||
let finalReward = parsed.data.rewardSpiritStones ?? 0;
|
||||
|
||||
if (templateId) {
|
||||
const template = await q.query.adminMessageTemplates.findFirst({
|
||||
where: eq(adminMessageTemplates.id, templateId),
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json({ error: '模板不存在' }, { status: 404 });
|
||||
}
|
||||
if (template.channel !== 'game_mail') {
|
||||
return NextResponse.json({ error: '模板频道不匹配' }, { status: 400 });
|
||||
}
|
||||
if (template.status !== 'active') {
|
||||
return NextResponse.json({ error: '模板已停用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const mergedPayload = normalizeTemplatePayload(
|
||||
template.defaultPayload,
|
||||
payload,
|
||||
);
|
||||
finalContent = renderTemplate(template.contentTemplate, mergedPayload);
|
||||
|
||||
if (template.subjectTemplate) {
|
||||
finalTitle = renderTemplate(template.subjectTemplate, mergedPayload);
|
||||
} else if (!finalTitle) {
|
||||
return NextResponse.json(
|
||||
{ error: '模板缺少标题,请填写 title 或配置 subjectTemplate' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.rewardSpiritStones === undefined) {
|
||||
const maybeReward = mergedPayload.rewardSpiritStones;
|
||||
if (typeof maybeReward === 'number') {
|
||||
finalReward = Math.max(0, Math.floor(maybeReward));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attachments: MailAttachment[] =
|
||||
finalReward > 0
|
||||
? [
|
||||
{
|
||||
type: 'spirit_stones',
|
||||
name: '灵石',
|
||||
quantity: finalReward,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const type = attachments.length > 0 ? 'reward' : 'system';
|
||||
const rows = resolvedRecipients.recipients.map((recipient) => ({
|
||||
cultivatorId: recipient.recipientKey,
|
||||
title: finalTitle,
|
||||
content: finalContent,
|
||||
type,
|
||||
attachments,
|
||||
isRead: false,
|
||||
isClaimed: false,
|
||||
}));
|
||||
|
||||
const batchSize = Number(process.env.ADMIN_BROADCAST_BATCH_SIZE ?? 500);
|
||||
for (let i = 0; i < rows.length; i += batchSize) {
|
||||
await q
|
||||
.insert(mails)
|
||||
.values(rows.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
totalRecipients: rows.length,
|
||||
mailType: type,
|
||||
rewardSpiritStones: finalReward,
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { cultivators } from '@/lib/drizzle/schema';
|
||||
import {
|
||||
findFeedbackById,
|
||||
updateFeedbackStatus,
|
||||
type FeedbackStatus,
|
||||
type FeedbackType,
|
||||
} from '@/lib/repositories/feedbackRepository';
|
||||
import { SPECIAL_TALISMAN_CONFIG } from '@/lib/repositories/talismanRepository';
|
||||
import {
|
||||
MailAttachment,
|
||||
MailAttachmentType,
|
||||
MailService,
|
||||
} from '@/lib/services/MailService';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const VALID_STATUSES: FeedbackStatus[] = [
|
||||
'pending',
|
||||
'processing',
|
||||
'resolved',
|
||||
'closed',
|
||||
];
|
||||
|
||||
const STATUS_LABELS: Record<FeedbackStatus, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已解决',
|
||||
closed: '已关闭',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<FeedbackType, string> = {
|
||||
bug: 'Bug 反馈',
|
||||
feature: '功能建议',
|
||||
balance: '游戏平衡',
|
||||
other: '其他意见',
|
||||
};
|
||||
|
||||
const attachments: MailAttachment[] = [
|
||||
{
|
||||
type: 'consumable' as MailAttachmentType,
|
||||
name: '悟道演法符',
|
||||
quantity: 1,
|
||||
data: SPECIAL_TALISMAN_CONFIG.悟道演法符,
|
||||
},
|
||||
{
|
||||
type: 'consumable' as MailAttachmentType,
|
||||
name: '神通衍化符',
|
||||
quantity: 1,
|
||||
data: SPECIAL_TALISMAN_CONFIG.神通衍化符,
|
||||
},
|
||||
];
|
||||
|
||||
function buildFeedbackStatusMailContent(params: {
|
||||
feedbackType: FeedbackType;
|
||||
status: FeedbackStatus;
|
||||
adminMessage?: string;
|
||||
feedbackContent: string;
|
||||
}) {
|
||||
const summary =
|
||||
params.feedbackContent.length > 80
|
||||
? `${params.feedbackContent.slice(0, 80)}...`
|
||||
: params.feedbackContent;
|
||||
|
||||
const message = params.adminMessage?.trim();
|
||||
|
||||
return [
|
||||
'你提交的反馈工单状态已更新。',
|
||||
'',
|
||||
`反馈类型:${TYPE_LABELS[params.feedbackType]}`,
|
||||
`当前状态:${STATUS_LABELS[params.status]}`,
|
||||
`反馈摘要:${summary}`,
|
||||
'',
|
||||
`管理员留言:${message || '感谢你的反馈,我们会持续跟进。'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/feedback/[id]/status
|
||||
* 更新反馈状态
|
||||
*/
|
||||
export const PATCH = withAdminAuth<{ id: string }>(
|
||||
async (request: NextRequest, _context, params) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少反馈 ID' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
const adminMessage =
|
||||
typeof body.adminMessage === 'string' ? body.adminMessage.trim() : '';
|
||||
|
||||
if (!status || !VALID_STATUSES.includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的状态值' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (adminMessage.length > 1000) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '管理员留言过长(最多 1000 字)' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 检查反馈是否存在
|
||||
const existing = await findFeedbackById(id);
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '反馈不存在' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const hasStatusChanged = existing.status !== status;
|
||||
|
||||
// 更新状态
|
||||
const updated = await updateFeedbackStatus(id, status);
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 状态变更后,给反馈提交者发送游戏内邮件通知
|
||||
let notifiedUser = false;
|
||||
if (hasStatusChanged) {
|
||||
const q = getExecutor();
|
||||
const fallbackCultivator = existing.cultivatorId
|
||||
? null
|
||||
: await q.query.cultivators.findFirst({
|
||||
where: and(
|
||||
eq(cultivators.userId, existing.userId),
|
||||
eq(cultivators.status, 'active'),
|
||||
),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
const recipientCultivatorId =
|
||||
existing.cultivatorId ?? fallbackCultivator?.id;
|
||||
|
||||
if (recipientCultivatorId) {
|
||||
await MailService.sendMail(
|
||||
recipientCultivatorId,
|
||||
'反馈工单状态更新',
|
||||
buildFeedbackStatusMailContent({
|
||||
feedbackType: existing.type as FeedbackType,
|
||||
status,
|
||||
adminMessage,
|
||||
feedbackContent: existing.content,
|
||||
}),
|
||||
attachments,
|
||||
);
|
||||
notifiedUser = true;
|
||||
} else {
|
||||
console.warn(
|
||||
`Feedback ${id} status updated but no cultivator found for user ${existing.userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
feedback: updated,
|
||||
statusChanged: hasStatusChanged,
|
||||
notifiedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update feedback status error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,96 +0,0 @@
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { cultivators } from '@/lib/drizzle/schema';
|
||||
import {
|
||||
findFeedbacks,
|
||||
type FeedbackStatus,
|
||||
type FeedbackType,
|
||||
} from '@/lib/repositories/feedbackRepository';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/admin/feedback
|
||||
* 获取反馈列表(分页 + 筛选)
|
||||
*/
|
||||
export const GET = withAdminAuth(async (request: NextRequest) => {
|
||||
const q = getExecutor();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const status = searchParams.get('status') as FeedbackStatus | null;
|
||||
const type = searchParams.get('type') as FeedbackType | null;
|
||||
const search = searchParams.get('search')?.trim() || undefined;
|
||||
|
||||
const validStatuses: FeedbackStatus[] = [
|
||||
'pending',
|
||||
'processing',
|
||||
'resolved',
|
||||
'closed',
|
||||
];
|
||||
const validTypes: FeedbackType[] = ['bug', 'feature', 'balance', 'other'];
|
||||
|
||||
const { feedbacks, total } = await findFeedbacks({
|
||||
status: status && validStatuses.includes(status) ? status : undefined,
|
||||
type: type && validTypes.includes(type) ? type : undefined,
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
// 批量获取关联角色信息,避免循环单次查询
|
||||
const cultivatorIds = Array.from(
|
||||
new Set(
|
||||
feedbacks
|
||||
.map((feedback) => feedback.cultivatorId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
);
|
||||
|
||||
const cultivatorMap = new Map<
|
||||
string,
|
||||
{ name: string | null; realm: string | null }
|
||||
>();
|
||||
|
||||
if (cultivatorIds.length > 0) {
|
||||
const cultivatorRows = await q
|
||||
.select({
|
||||
id: cultivators.id,
|
||||
name: cultivators.name,
|
||||
realm: cultivators.realm,
|
||||
})
|
||||
.from(cultivators)
|
||||
.where(inArray(cultivators.id, cultivatorIds));
|
||||
|
||||
for (const row of cultivatorRows) {
|
||||
cultivatorMap.set(row.id, {
|
||||
name: row.name,
|
||||
realm: row.realm,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const enrichFeedbacks: Array<
|
||||
(typeof feedbacks)[number] & {
|
||||
cultivatorName: string | null;
|
||||
cultivatorRealm: string | null;
|
||||
}
|
||||
> = feedbacks.map((feedback) => {
|
||||
const cultivator = feedback.cultivatorId
|
||||
? cultivatorMap.get(feedback.cultivatorId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...feedback,
|
||||
cultivatorName: cultivator?.name ?? null,
|
||||
cultivatorRealm: cultivator?.realm ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
feedbacks: enrichFeedbacks,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { redeemCodes } from '@/lib/drizzle/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const POST = withAdminAuth<{ id: string }>(
|
||||
async (_request, { user }, params) => {
|
||||
const q = getExecutor();
|
||||
const item = await q.query.redeemCodes.findFirst({
|
||||
where: eq(redeemCodes.id, params.id),
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return NextResponse.json({ error: '兑换码不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const nextStatus = item.status === 'active' ? 'disabled' : 'active';
|
||||
const [updated] = await q
|
||||
.update(redeemCodes)
|
||||
.set({
|
||||
status: nextStatus,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
.where(eq(redeemCodes.id, item.id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, redeemCode: updated });
|
||||
},
|
||||
);
|
||||
@@ -1,108 +0,0 @@
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { adminMessageTemplates } from '@/lib/drizzle/schema';
|
||||
import { AdminChannel, TemplateStatus } from '@/types/admin-broadcast';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const UpdateTemplateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
channel: z.enum(['email', 'game_mail']).optional(),
|
||||
subjectTemplate: z.string().trim().max(300).optional(),
|
||||
contentTemplate: z.string().trim().min(1).max(20000).optional(),
|
||||
defaultPayload: z
|
||||
.record(z.string(), z.union([z.string(), z.number()]))
|
||||
.optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
});
|
||||
|
||||
function validateEmailSubject(
|
||||
channel: AdminChannel,
|
||||
subjectTemplate?: string | null,
|
||||
): string | null {
|
||||
if (channel === 'email' && !subjectTemplate?.trim()) {
|
||||
return 'email 模板必须提供 subjectTemplate';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth<{ id: string }>(
|
||||
async (_request: NextRequest, _ctx, params) => {
|
||||
const q = getExecutor();
|
||||
const template = await q.query.adminMessageTemplates.findFirst({
|
||||
where: eq(adminMessageTemplates.id, params.id),
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json({ error: '模板不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ template });
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = withAdminAuth<{ id: string }>(
|
||||
async (request: NextRequest, { user }, params) => {
|
||||
const q = getExecutor();
|
||||
const template = await q.query.adminMessageTemplates.findFirst({
|
||||
where: eq(adminMessageTemplates.id, params.id),
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json({ error: '模板不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = UpdateTemplateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const nextChannel = (parsed.data.channel ??
|
||||
template.channel) as AdminChannel;
|
||||
const nextSubjectTemplate =
|
||||
parsed.data.subjectTemplate ?? template.subjectTemplate;
|
||||
|
||||
const subjectError = validateEmailSubject(nextChannel, nextSubjectTemplate);
|
||||
if (subjectError) {
|
||||
return NextResponse.json({ error: subjectError }, { status: 400 });
|
||||
}
|
||||
|
||||
const patch: {
|
||||
name?: string;
|
||||
channel?: AdminChannel;
|
||||
subjectTemplate?: string;
|
||||
contentTemplate?: string;
|
||||
defaultPayload?: Record<string, string | number>;
|
||||
status?: TemplateStatus;
|
||||
updatedBy: string;
|
||||
} = {
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
if (parsed.data.name !== undefined) patch.name = parsed.data.name;
|
||||
if (parsed.data.channel !== undefined) patch.channel = parsed.data.channel;
|
||||
if (parsed.data.subjectTemplate !== undefined) {
|
||||
patch.subjectTemplate = parsed.data.subjectTemplate;
|
||||
}
|
||||
if (parsed.data.contentTemplate !== undefined) {
|
||||
patch.contentTemplate = parsed.data.contentTemplate;
|
||||
}
|
||||
if (parsed.data.defaultPayload !== undefined) {
|
||||
patch.defaultPayload = parsed.data.defaultPayload;
|
||||
}
|
||||
if (parsed.data.status !== undefined) patch.status = parsed.data.status;
|
||||
|
||||
const [updated] = await q
|
||||
.update(adminMessageTemplates)
|
||||
.set(patch)
|
||||
.where(eq(adminMessageTemplates.id, params.id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, template: updated });
|
||||
},
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { adminMessageTemplates } from '@/lib/drizzle/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const POST = withAdminAuth<{ id: string }>(
|
||||
async (_request: NextRequest, { user }, params) => {
|
||||
const q = getExecutor();
|
||||
const template = await q.query.adminMessageTemplates.findFirst({
|
||||
where: eq(adminMessageTemplates.id, params.id),
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json({ error: '模板不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const nextStatus = template.status === 'active' ? 'disabled' : 'active';
|
||||
|
||||
const [updated] = await q
|
||||
.update(adminMessageTemplates)
|
||||
.set({
|
||||
status: nextStatus,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
.where(eq(adminMessageTemplates.id, params.id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, template: updated });
|
||||
},
|
||||
);
|
||||
@@ -1,86 +0,0 @@
|
||||
import { withAdminAuth } from '@/lib/api/adminAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { adminMessageTemplates } from '@/lib/drizzle/schema';
|
||||
import { AdminChannel, TemplateStatus } from '@/types/admin-broadcast';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateTemplateSchema = z.object({
|
||||
channel: z.enum(['email', 'game_mail']),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
subjectTemplate: z.string().trim().max(300).optional(),
|
||||
contentTemplate: z.string().trim().min(1).max(20000),
|
||||
defaultPayload: z
|
||||
.record(z.string(), z.union([z.string(), z.number()]))
|
||||
.default({}),
|
||||
status: z.enum(['active', 'disabled']).default('active'),
|
||||
});
|
||||
|
||||
function validateEmailSubject(
|
||||
channel: AdminChannel,
|
||||
subjectTemplate?: string,
|
||||
): string | null {
|
||||
if (channel === 'email' && !subjectTemplate?.trim()) {
|
||||
return 'email 模板必须提供 subjectTemplate';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request: NextRequest) => {
|
||||
const q = getExecutor();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const channel = searchParams.get('channel') as AdminChannel | null;
|
||||
const status = searchParams.get('status') as TemplateStatus | null;
|
||||
|
||||
const whereConditions = [];
|
||||
if (channel === 'email' || channel === 'game_mail') {
|
||||
whereConditions.push(eq(adminMessageTemplates.channel, channel));
|
||||
}
|
||||
if (status === 'active' || status === 'disabled') {
|
||||
whereConditions.push(eq(adminMessageTemplates.status, status));
|
||||
}
|
||||
|
||||
const templates = await q.query.adminMessageTemplates.findMany({
|
||||
where: whereConditions.length > 0 ? and(...whereConditions) : undefined,
|
||||
orderBy: [desc(adminMessageTemplates.createdAt)],
|
||||
});
|
||||
|
||||
return NextResponse.json({ templates });
|
||||
});
|
||||
|
||||
export const POST = withAdminAuth(async (request: NextRequest, { user }) => {
|
||||
const q = getExecutor();
|
||||
const body = await request.json();
|
||||
const parsed = CreateTemplateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const subjectError = validateEmailSubject(
|
||||
parsed.data.channel,
|
||||
parsed.data.subjectTemplate,
|
||||
);
|
||||
if (subjectError) {
|
||||
return NextResponse.json({ error: subjectError }, { status: 400 });
|
||||
}
|
||||
|
||||
const [template] = await q
|
||||
.insert(adminMessageTemplates)
|
||||
.values({
|
||||
channel: parsed.data.channel,
|
||||
name: parsed.data.name,
|
||||
subjectTemplate: parsed.data.subjectTemplate,
|
||||
contentTemplate: parsed.data.contentTemplate,
|
||||
defaultPayload: parsed.data.defaultPayload,
|
||||
status: parsed.data.status,
|
||||
createdBy: user.id,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, template });
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import {
|
||||
AuctionServiceError,
|
||||
cancelListing,
|
||||
} from '@/lib/services/AuctionService';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* DELETE /api/auction/:id
|
||||
* 下架拍卖物品
|
||||
*/
|
||||
export const DELETE = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }, params) => {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
await cancelListing(id, cultivator.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '物品已下架,将通过邮件返还',
|
||||
});
|
||||
} catch (error) {
|
||||
// 处理业务错误
|
||||
if (error instanceof AuctionServiceError) {
|
||||
const statusMap: Record<string, number> = {
|
||||
INSUFFICIENT_FUNDS: 400,
|
||||
LISTING_NOT_FOUND: 404,
|
||||
LISTING_EXPIRED: 400,
|
||||
NOT_OWNER: 403,
|
||||
MAX_LISTINGS: 400,
|
||||
ITEM_NOT_FOUND: 404,
|
||||
CONCURRENT_PURCHASE: 429,
|
||||
INVALID_ITEM_TYPE: 400,
|
||||
INVALID_PRICE: 400,
|
||||
INVALID_QUANTITY: 400,
|
||||
};
|
||||
|
||||
const status = statusMap[error.code] || 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
|
||||
// 处理其他错误
|
||||
console.error('Auction Cancel API Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '下架失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,66 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { AuctionServiceError, buyItem } from '@/lib/services/AuctionService';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const BuySchema = z.object({
|
||||
listingId: z.string().uuid(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auction/buy
|
||||
* 购买拍卖行物品
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { listingId } = BuySchema.parse(body);
|
||||
|
||||
await buyItem({
|
||||
listingId,
|
||||
buyerCultivatorId: cultivator.id,
|
||||
buyerCultivatorName: cultivator.name,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '成功购入物品,请查收邮件',
|
||||
});
|
||||
} catch (error) {
|
||||
// 处理 Zod 验证错误
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 处理业务错误
|
||||
if (error instanceof AuctionServiceError) {
|
||||
const statusMap: Record<string, number> = {
|
||||
INSUFFICIENT_FUNDS: 400,
|
||||
LISTING_NOT_FOUND: 404,
|
||||
LISTING_EXPIRED: 400,
|
||||
NOT_OWNER: 403,
|
||||
MAX_LISTINGS: 400,
|
||||
ITEM_NOT_FOUND: 404,
|
||||
CONCURRENT_PURCHASE: 429,
|
||||
INVALID_ITEM_TYPE: 400,
|
||||
INVALID_PRICE: 400,
|
||||
INVALID_QUANTITY: 400,
|
||||
};
|
||||
|
||||
const status = statusMap[error.code] || 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
|
||||
// 处理其他错误
|
||||
console.error('Auction Buy API Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '购买失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,74 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { AuctionServiceError, listItem } from '@/lib/services/AuctionService';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ListSchema = z.object({
|
||||
itemType: z.enum(['material', 'artifact', 'consumable']),
|
||||
itemId: z.string().uuid(),
|
||||
price: z.number().int().min(1),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auction/list
|
||||
* 上架物品到拍卖行
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { itemType, itemId, price, quantity } = ListSchema.parse(body);
|
||||
|
||||
const result = await listItem({
|
||||
cultivatorId: cultivator.id,
|
||||
cultivatorName: cultivator.name,
|
||||
itemType,
|
||||
itemId,
|
||||
price,
|
||||
quantity,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
// 处理 Zod 验证错误
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 处理业务错误
|
||||
if (error instanceof AuctionServiceError) {
|
||||
const statusMap: Record<string, number> = {
|
||||
INSUFFICIENT_FUNDS: 400,
|
||||
LISTING_NOT_FOUND: 404,
|
||||
LISTING_EXPIRED: 400,
|
||||
NOT_OWNER: 403,
|
||||
MAX_LISTINGS: 400,
|
||||
ITEM_NOT_FOUND: 404,
|
||||
CONCURRENT_PURCHASE: 429,
|
||||
INVALID_ITEM_TYPE: 400,
|
||||
INVALID_PRICE: 400,
|
||||
INVALID_QUANTITY: 400,
|
||||
INVALID_ITEM_QUALITY: 400,
|
||||
CONSUMABLE_LISTING_DISABLED: 400,
|
||||
};
|
||||
|
||||
const status = statusMap[error.code] || 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
|
||||
// 处理其他错误
|
||||
console.error('Auction List API Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '上架失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
import * as auctionRepository from '@/lib/repositories/auctionRepository';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ListingsSchema = z.object({
|
||||
itemType: z.enum(['material', 'artifact', 'consumable']).optional(),
|
||||
minPrice: z.number().int().min(0).optional(),
|
||||
maxPrice: z.number().int().min(0).optional(),
|
||||
sortBy: z.enum(['price_asc', 'price_desc', 'latest']).optional(),
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auction/listings
|
||||
* 获取拍卖列表(公开接口)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 解析查询参数
|
||||
const params = ListingsSchema.parse({
|
||||
itemType: searchParams.get('itemType') || undefined,
|
||||
minPrice: searchParams.get('minPrice')
|
||||
? Number(searchParams.get('minPrice'))
|
||||
: undefined,
|
||||
maxPrice: searchParams.get('maxPrice')
|
||||
? Number(searchParams.get('maxPrice'))
|
||||
: undefined,
|
||||
sortBy: searchParams.get('sortBy') || undefined,
|
||||
page: searchParams.get('page')
|
||||
? Number(searchParams.get('page'))
|
||||
: undefined,
|
||||
limit: searchParams.get('limit')
|
||||
? Number(searchParams.get('limit'))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const result = await auctionRepository.findActiveListings(params);
|
||||
|
||||
const page = params.page || 1;
|
||||
const limit = params.limit || 20;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
listings: result.listings,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasMore: page < totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 处理 Zod 验证错误
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Auction Listings API Error:', error);
|
||||
return NextResponse.json({ error: '获取拍卖列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { simulateBattle } from '@/engine/battle';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { battleRecords } from '@/lib/drizzle/schema';
|
||||
import { getCultivatorByIdUnsafe } from '@/lib/services/cultivatorService';
|
||||
import { stream_text } from '@/utils/aiClient';
|
||||
import { getBattleReportPrompt } from '@/utils/prompts';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const BattleSchema = z.object({
|
||||
opponentId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/battle
|
||||
* 合并的战斗接口:执行战斗并生成战斗播报(SSE 流式输出)
|
||||
* 接收对手ID,直接返回战斗结果和播报
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }) => {
|
||||
const body = await request.json();
|
||||
const { opponentId } = BattleSchema.parse(body);
|
||||
|
||||
// 创建 SSE 流
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// 发送开始标记
|
||||
controller.enqueue(encoder.encode('data: {"type":"start"}\n\n'));
|
||||
|
||||
const playerBundle = await getCultivatorByIdUnsafe(cultivator.id);
|
||||
if (!playerBundle?.cultivator) {
|
||||
throw new Error('玩家角色不存在');
|
||||
}
|
||||
const player = playerBundle.cultivator;
|
||||
|
||||
// 1. 获取对手角色信息
|
||||
const opponentBundle = await getCultivatorByIdUnsafe(opponentId);
|
||||
if (!opponentBundle?.cultivator) {
|
||||
throw new Error('对手角色不存在');
|
||||
}
|
||||
const opponent = opponentBundle.cultivator;
|
||||
|
||||
// 2. 执行战斗引擎
|
||||
const battleResult = simulateBattle(player, opponent);
|
||||
|
||||
// 3. 发送战斗结果数据
|
||||
const battleData = JSON.stringify({
|
||||
type: 'battle_result',
|
||||
data: battleResult,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${battleData}\n\n`));
|
||||
|
||||
// 4. 生成战斗播报 prompt
|
||||
const [prompt, userPrompt] = getBattleReportPrompt({
|
||||
player,
|
||||
opponent,
|
||||
battleResult: {
|
||||
winnerId: battleResult.winner.id || '',
|
||||
log: battleResult.log ?? [],
|
||||
turns: battleResult.turns,
|
||||
playerHp: battleResult.playerHp,
|
||||
opponentHp: battleResult.opponentHp,
|
||||
},
|
||||
});
|
||||
|
||||
// 5. 流式生成战斗播报,并在服务端累积完整文本
|
||||
let fullReport = '';
|
||||
const { textStream } = stream_text(prompt, userPrompt);
|
||||
for await (const chunk of textStream) {
|
||||
// 发送内容块
|
||||
const data = JSON.stringify({ type: 'chunk', content: chunk });
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||
fullReport += chunk;
|
||||
}
|
||||
|
||||
// 6. 将本次战斗结果以快照方式写入数据库
|
||||
try {
|
||||
await getExecutor().insert(battleRecords).values({
|
||||
userId: user.id,
|
||||
cultivatorId: cultivator.id,
|
||||
battleResult,
|
||||
battleReport: fullReport,
|
||||
});
|
||||
} catch (e) {
|
||||
// 写入战斗记录失败不应影响前端体验,仅记录日志
|
||||
console.error('写入战斗记录失败:', e);
|
||||
}
|
||||
|
||||
// 7. 发送结束标记
|
||||
controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
console.error('战斗流程错误:', error);
|
||||
// 安全处理错误信息
|
||||
const errorMessage =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? error instanceof Error
|
||||
? error.message
|
||||
: '战斗失败'
|
||||
: '战斗失败';
|
||||
const errorData = JSON.stringify({
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${errorData}\n\n`));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { battleRecords } from '@/lib/drizzle/schema';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(req: Request, ctx: Params) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (error || !user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未授权' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const rows = await getExecutor()
|
||||
.select()
|
||||
.from(battleRecords)
|
||||
.where(eq(battleRecords.id, id))
|
||||
.limit(1);
|
||||
|
||||
const record = rows[0];
|
||||
|
||||
if (!record) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '记录不存在' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: record,
|
||||
});
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { BattleEngineResult } from '@/engine/battle';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { battleRecords } from '@/lib/drizzle/schema';
|
||||
import { getUserAliveCultivatorId } from '@/lib/services/cultivatorService';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { and, desc, eq, or, sql } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (error || !user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未授权' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = Math.max(1, Number(searchParams.get('page') ?? '1'));
|
||||
const pageSize = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(searchParams.get('pageSize') ?? '10')),
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const type = searchParams.get('type'); // 'challenge' | 'challenged' | null (全部)
|
||||
const cultivatorId = await getUserAliveCultivatorId(user.id);
|
||||
|
||||
if (!cultivatorId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到角色' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// 基础条件:我参与的战斗
|
||||
const participantCondition = or(
|
||||
eq(battleRecords.cultivatorId, cultivatorId),
|
||||
eq(battleRecords.opponentCultivatorId, cultivatorId),
|
||||
);
|
||||
|
||||
// 按“当前角色视角”筛选挑战类型
|
||||
let whereCondition = participantCondition;
|
||||
if (type === 'challenge') {
|
||||
whereCondition = and(
|
||||
participantCondition,
|
||||
eq(battleRecords.cultivatorId, cultivatorId),
|
||||
)!;
|
||||
} else if (type === 'challenged') {
|
||||
whereCondition = and(
|
||||
participantCondition,
|
||||
eq(battleRecords.opponentCultivatorId, cultivatorId),
|
||||
)!;
|
||||
}
|
||||
|
||||
// 总数用于分页信息
|
||||
const [countRow] = await getExecutor()
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(battleRecords)
|
||||
.where(whereCondition);
|
||||
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
const totalPages = total === 0 ? 0 : Math.ceil(total / pageSize);
|
||||
|
||||
const records = await getExecutor()
|
||||
.select()
|
||||
.from(battleRecords)
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(battleRecords.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
// 仅返回列表页需要的精简数据
|
||||
const data = records.map((r) => {
|
||||
const result = r.battleResult as BattleEngineResult;
|
||||
const isChallenger = r.cultivatorId === cultivatorId;
|
||||
const hasOpponent = !!r.opponentCultivatorId;
|
||||
|
||||
const challengeType = hasOpponent
|
||||
? isChallenger
|
||||
? 'challenge'
|
||||
: 'challenged'
|
||||
: 'normal';
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
winner: result.winner,
|
||||
loser: result.loser,
|
||||
turns: result.turns,
|
||||
challengeType,
|
||||
opponentCultivatorId: r.opponentCultivatorId,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import {
|
||||
BetBattleServiceError,
|
||||
cancelBetBattle,
|
||||
} from '@/lib/services/BetBattleService';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const statusMap: Record<string, number> = {
|
||||
INVALID_STAKE: 400,
|
||||
INVALID_REALM_RANGE: 400,
|
||||
MAX_ACTIVE_BATTLE: 400,
|
||||
BATTLE_NOT_FOUND: 404,
|
||||
BATTLE_EXPIRED: 400,
|
||||
BATTLE_NOT_PENDING: 400,
|
||||
NOT_CREATOR: 403,
|
||||
CHALLENGE_SELF: 400,
|
||||
CHALLENGER_REALM_MISMATCH: 400,
|
||||
CHALLENGER_STAKE_MISMATCH: 400,
|
||||
ITEM_NOT_FOUND: 404,
|
||||
INVALID_QUANTITY: 400,
|
||||
INSUFFICIENT_SPIRIT_STONES: 400,
|
||||
CONCURRENT_OPERATION: 429,
|
||||
};
|
||||
|
||||
export const POST = withActiveCultivator(
|
||||
async (_request: NextRequest, { cultivator }, params) => {
|
||||
try {
|
||||
await cancelBetBattle(params.id, cultivator.id);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '赌战已取消,押注将通过邮件返还',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BetBattleServiceError) {
|
||||
const status = statusMap[error.code] || 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
|
||||
console.error('Cancel bet battle API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '取消赌战失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,189 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { battleRecords } from '@/lib/drizzle/schema';
|
||||
import {
|
||||
BetBattleServiceError,
|
||||
challengeBetBattle,
|
||||
} from '@/lib/services/BetBattleService';
|
||||
import { stream_text } from '@/utils/aiClient';
|
||||
import { getBattleReportPrompt } from '@/utils/prompts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ChallengeBetBattleSchema = z.object({
|
||||
stakeType: z.enum(['spirit_stones', 'item']),
|
||||
spiritStones: z.number().int().min(0).optional(),
|
||||
stakeItem: z
|
||||
.object({
|
||||
itemType: z.enum(['material', 'artifact', 'consumable']),
|
||||
itemId: z.string().uuid(),
|
||||
quantity: z.number().int().min(1),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const statusMap: Record<string, number> = {
|
||||
INVALID_STAKE: 400,
|
||||
INVALID_REALM_RANGE: 400,
|
||||
MAX_ACTIVE_BATTLE: 400,
|
||||
BATTLE_NOT_FOUND: 404,
|
||||
BATTLE_EXPIRED: 400,
|
||||
BATTLE_NOT_PENDING: 400,
|
||||
NOT_CREATOR: 403,
|
||||
CHALLENGE_SELF: 400,
|
||||
CHALLENGER_REALM_MISMATCH: 400,
|
||||
CHALLENGER_STAKE_MISMATCH: 400,
|
||||
ITEM_NOT_FOUND: 404,
|
||||
INVALID_QUANTITY: 400,
|
||||
INSUFFICIENT_SPIRIT_STONES: 400,
|
||||
CONCURRENT_OPERATION: 429,
|
||||
CONSUMABLE_STAKE_DISABLED: 400,
|
||||
};
|
||||
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }, params) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { stakeType, spiritStones, stakeItem } =
|
||||
ChallengeBetBattleSchema.parse(body);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode('data: {"type":"start"}\n\n'));
|
||||
|
||||
const result = await challengeBetBattle({
|
||||
battleId: params.id,
|
||||
challengerId: cultivator.id,
|
||||
challengerName: cultivator.name,
|
||||
challengerUserId: user.id,
|
||||
stakeType,
|
||||
spiritStones,
|
||||
stakeItem,
|
||||
});
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'battle_result',
|
||||
data: result.battleResult,
|
||||
})}\n\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const [prompt, userPrompt] = getBattleReportPrompt({
|
||||
player: result.challenger.cultivator,
|
||||
opponent: result.creator.cultivator,
|
||||
battleResult: {
|
||||
winnerId: result.battleResult.winner.id || '',
|
||||
log: result.battleResult.log ?? [],
|
||||
turns: result.battleResult.turns,
|
||||
playerHp: result.battleResult.playerHp,
|
||||
opponentHp: result.battleResult.opponentHp,
|
||||
},
|
||||
});
|
||||
|
||||
let fullReport = '';
|
||||
try {
|
||||
const { textStream } = stream_text(prompt, userPrompt);
|
||||
for await (const chunk of textStream) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'chunk', content: chunk })}\n\n`,
|
||||
),
|
||||
);
|
||||
fullReport += chunk;
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.error('Bet battle report stream failed:', streamError);
|
||||
}
|
||||
|
||||
if (fullReport.trim()) {
|
||||
try {
|
||||
await getExecutor()
|
||||
.update(battleRecords)
|
||||
.set({ battleReport: fullReport })
|
||||
.where(eq(battleRecords.id, result.battleRecordId));
|
||||
} catch (saveError) {
|
||||
console.error('写入赌战战报失败:', saveError);
|
||||
}
|
||||
}
|
||||
|
||||
const isWin = result.winnerId === cultivator.id;
|
||||
const resultMessage = isWin
|
||||
? '你力压对手,赢得赌战押注,奖励已发放邮件。'
|
||||
: '你此战失利,押注归对方所有,下次再战。';
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'bet_battle_settlement',
|
||||
isWin,
|
||||
winnerId: result.winnerId,
|
||||
battleId: result.battleId,
|
||||
battleRecordId: result.battleRecordId,
|
||||
resultMessage,
|
||||
})}\n\n`,
|
||||
),
|
||||
);
|
||||
|
||||
controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
if (error instanceof BetBattleServiceError) {
|
||||
const errorData = JSON.stringify({
|
||||
type: 'error',
|
||||
error: error.message,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${errorData}\n\n`));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Challenge bet battle API error:', error);
|
||||
const errorMessage =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? error instanceof Error
|
||||
? error.message
|
||||
: '应战失败'
|
||||
: '应战失败,请稍后重试';
|
||||
const errorData = JSON.stringify({
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${errorData}\n\n`));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof BetBattleServiceError) {
|
||||
const status = statusMap[error.code] || 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
|
||||
console.error('Challenge bet battle API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '应战失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,120 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { createMessage } from '@/lib/repositories/worldChatRepository';
|
||||
import {
|
||||
BetBattleServiceError,
|
||||
createBetBattle,
|
||||
} from '@/lib/services/BetBattleService';
|
||||
import { REALM_VALUES } from '@/types/constants';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateBetBattleSchema = z.object({
|
||||
minRealm: z.enum(REALM_VALUES),
|
||||
maxRealm: z.enum(REALM_VALUES),
|
||||
taunt: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => Array.from(value).length <= 20, {
|
||||
message: '狠话最多20字',
|
||||
})
|
||||
.optional(),
|
||||
stakeType: z.enum(['spirit_stones', 'item']),
|
||||
spiritStones: z.number().int().min(0).optional(),
|
||||
stakeItem: z
|
||||
.object({
|
||||
itemType: z.enum(['material', 'artifact', 'consumable']),
|
||||
itemId: z.string().uuid(),
|
||||
quantity: z.number().int().min(1),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const statusMap: Record<string, number> = {
|
||||
INVALID_STAKE: 400,
|
||||
INVALID_REALM_RANGE: 400,
|
||||
MAX_ACTIVE_BATTLE: 400,
|
||||
BATTLE_NOT_FOUND: 404,
|
||||
BATTLE_EXPIRED: 400,
|
||||
BATTLE_NOT_PENDING: 400,
|
||||
NOT_CREATOR: 403,
|
||||
CHALLENGE_SELF: 400,
|
||||
CHALLENGER_REALM_MISMATCH: 400,
|
||||
CHALLENGER_STAKE_MISMATCH: 400,
|
||||
ITEM_NOT_FOUND: 404,
|
||||
INVALID_QUANTITY: 400,
|
||||
INSUFFICIENT_SPIRIT_STONES: 400,
|
||||
CONCURRENT_OPERATION: 429,
|
||||
CONSUMABLE_STAKE_DISABLED: 400,
|
||||
};
|
||||
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { minRealm, maxRealm, taunt, stakeType, spiritStones, stakeItem } =
|
||||
CreateBetBattleSchema.parse(body);
|
||||
|
||||
const result = await createBetBattle({
|
||||
creatorId: cultivator.id,
|
||||
creatorName: cultivator.name,
|
||||
minRealm,
|
||||
maxRealm,
|
||||
taunt,
|
||||
stakeType,
|
||||
spiritStones,
|
||||
stakeItem,
|
||||
});
|
||||
|
||||
const rumor = taunt?.trim()
|
||||
? `${cultivator.name}在赌战台放话:${taunt.trim()} 有胆便来应战!`
|
||||
: `${cultivator.name}在赌战台摆下战帖,静候各路道友应战!`;
|
||||
try {
|
||||
await createMessage({
|
||||
senderUserId: user.id,
|
||||
senderCultivatorId: null,
|
||||
senderName: '修仙界传闻',
|
||||
senderRealm: '炼气',
|
||||
senderRealmStage: '系统',
|
||||
messageType: 'duel_invite',
|
||||
textContent: rumor,
|
||||
payload: {
|
||||
battleId: result.battleId,
|
||||
routePath: '/game/bet-battle',
|
||||
taunt: taunt?.trim() || undefined,
|
||||
expiresAt: undefined,
|
||||
},
|
||||
});
|
||||
} catch (chatError) {
|
||||
console.error(
|
||||
'Bet battle created but world chat broadcast failed:',
|
||||
chatError,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
battleId: result.battleId,
|
||||
message: '赌战发起成功,等待道友应战',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof BetBattleServiceError) {
|
||||
const status = statusMap[error.code] || 400;
|
||||
return NextResponse.json({ error: error.message }, { status });
|
||||
}
|
||||
|
||||
console.error('Create bet battle API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '发起赌战失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as betBattleRepository from '@/lib/repositories/betBattleRepository';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ListingsSchema = z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const params = ListingsSchema.parse({
|
||||
page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined,
|
||||
limit: searchParams.get('limit')
|
||||
? Number(searchParams.get('limit'))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const result = await betBattleRepository.findPendingBetBattles(params);
|
||||
const page = params.page || 1;
|
||||
const limit = params.limit || 20;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
listings: result.listings,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasMore: page < totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Bet battle listings API error:', error);
|
||||
return NextResponse.json({ error: '获取赌战列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import * as betBattleRepository from '@/lib/repositories/betBattleRepository';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MyListingsSchema = z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export const GET = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const params = MyListingsSchema.parse({
|
||||
page: searchParams.get('page')
|
||||
? Number(searchParams.get('page'))
|
||||
: undefined,
|
||||
limit: searchParams.get('limit')
|
||||
? Number(searchParams.get('limit'))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const result = await betBattleRepository.findMyBetBattles(
|
||||
cultivator.id,
|
||||
params,
|
||||
);
|
||||
|
||||
const page = params.page || 1;
|
||||
const limit = params.limit || 20;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
listings: result.listings,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasMore: page < totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error('My bet battles API error:', error);
|
||||
return NextResponse.json({ error: '获取我的赌战失败' }, { status: 500 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
const COMMUNITY_QR_CODE_SOURCE =
|
||||
'https://page-r2.daoyou.org/index/%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87_20260318172027_212_37.jpg';
|
||||
|
||||
/**
|
||||
* 代理 README 中的群二维码,避免跨域下载受限。
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const shouldDownload = searchParams.get('download') === '1';
|
||||
|
||||
const upstream = await fetch(COMMUNITY_QR_CODE_SOURCE, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
return Response.json(
|
||||
{ success: false, error: '二维码加载失败,请稍后重试' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const image = await upstream.arrayBuffer();
|
||||
const contentType = upstream.headers.get('content-type') ?? 'image/jpeg';
|
||||
const headers = new Headers({
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
'Content-Disposition': `${shouldDownload ? 'attachment' : 'inline'}; filename="daoyou-community-qrcode.jpg"`,
|
||||
});
|
||||
|
||||
return new Response(image, { status: 200, headers });
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { cultivationTechniques, skills } from '@/lib/drizzle/schema';
|
||||
import { redis } from '@/lib/redis';
|
||||
import {
|
||||
calculateSingleSkillScore,
|
||||
calculateSingleTechniqueScore,
|
||||
} from '@/utils/rankingUtils';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* POST /api/craft/confirm
|
||||
* 确认替换或放弃新生成的功法/神通
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
try {
|
||||
const { craftType, replaceId, abandon } = await request.json();
|
||||
|
||||
if (!['create_skill', 'create_gongfa'].includes(craftType)) {
|
||||
return NextResponse.json({ error: '无效的造物类型' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pendingKey = `creation_pending:${cultivator.id}:${craftType}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pendingItem = await redis.get<any>(pendingKey);
|
||||
|
||||
if (!pendingItem) {
|
||||
return NextResponse.json(
|
||||
{ error: '未找到待确认的造物结果,可能已过期' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (abandon) {
|
||||
await redis.del(pendingKey);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '已放弃新生成的感悟',
|
||||
});
|
||||
}
|
||||
|
||||
// 执行替换持久化
|
||||
await getExecutor().transaction(async (tx) => {
|
||||
if (craftType === 'create_skill') {
|
||||
if (replaceId) {
|
||||
// 删除旧技能
|
||||
await tx
|
||||
.delete(skills)
|
||||
.where(
|
||||
and(
|
||||
eq(skills.id, replaceId),
|
||||
eq(skills.cultivatorId, cultivator.id!),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 插入新技能
|
||||
const score = calculateSingleSkillScore(pendingItem);
|
||||
// 移除不需要存入数据库的临时字段
|
||||
const {
|
||||
needs_replace: _n,
|
||||
currentCount: _c,
|
||||
maxCount: _m,
|
||||
...skillData
|
||||
} = pendingItem as Record<string, unknown>;
|
||||
|
||||
await tx.insert(skills).values({
|
||||
cultivatorId: cultivator.id!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(skillData as any),
|
||||
target_self: skillData.target_self ? 1 : 0,
|
||||
score,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
effects: (skillData.effects as any) || [],
|
||||
});
|
||||
} else if (craftType === 'create_gongfa') {
|
||||
if (replaceId) {
|
||||
// 删除旧功法
|
||||
await tx
|
||||
.delete(cultivationTechniques)
|
||||
.where(
|
||||
and(
|
||||
eq(cultivationTechniques.id, replaceId),
|
||||
eq(cultivationTechniques.cultivatorId, cultivator.id!),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 插入新功法
|
||||
const score = calculateSingleTechniqueScore(pendingItem);
|
||||
const {
|
||||
needs_replace: _n,
|
||||
currentCount: _c,
|
||||
maxCount: _m,
|
||||
...techniqueData
|
||||
} = pendingItem as Record<string, unknown>;
|
||||
|
||||
await tx.insert(cultivationTechniques).values({
|
||||
cultivatorId: cultivator.id!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(techniqueData as any),
|
||||
score,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
effects: (techniqueData.effects as any) || [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await redis.del(pendingKey);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '领悟成功,已纳入道基',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('确认替换失败:', e);
|
||||
return NextResponse.json(
|
||||
{ error: '确认失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/craft/pending
|
||||
* 获取当前待确定的造物结果
|
||||
*/
|
||||
export const GET = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
const url = new URL(request.url);
|
||||
const craftType = url.searchParams.get('type'); // 'create_skill' | 'create_gongfa'
|
||||
|
||||
if (!craftType || !['create_skill', 'create_gongfa'].includes(craftType)) {
|
||||
return NextResponse.json({ error: '无效的造物类型' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pendingKey = `creation_pending:${cultivator.id}:${craftType}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pendingItem = await redis.get<any>(pendingKey);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
hasPending: !!pendingItem,
|
||||
item: pendingItem || null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,166 +0,0 @@
|
||||
import {
|
||||
calculateMaxQuality,
|
||||
getCostDescription,
|
||||
} from '@/engine/creation/CraftCostCalculator';
|
||||
import {
|
||||
CreationEngine,
|
||||
CreationEngineError,
|
||||
} from '@/engine/creation/CreationEngine';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { materials } from '@/lib/drizzle/schema';
|
||||
import { Quality } from '@/types/constants';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const creationEngine = new CreationEngine();
|
||||
|
||||
const CraftSchema = z.object({
|
||||
materialIds: z.array(z.string()).optional(),
|
||||
prompt: z.string().optional(),
|
||||
craftType: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/craft?craftType=alchemy&materialIds=id1,id2,id3
|
||||
* 查询造物消耗预估
|
||||
*/
|
||||
export const GET = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const materialIdsParam = searchParams.get('materialIds');
|
||||
const craftType = searchParams.get('craftType');
|
||||
|
||||
if (!craftType) {
|
||||
return NextResponse.json({ error: '请指定造物类型' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
craftType !== 'create_skill' &&
|
||||
craftType !== 'create_gongfa' &&
|
||||
(!materialIdsParam || materialIdsParam.length === 0)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '请选择材料以查询消耗' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let cost: { spiritStones?: number; comprehension?: number };
|
||||
let canAfford = true;
|
||||
|
||||
if (materialIdsParam && materialIdsParam.length > 0) {
|
||||
const materialIds = materialIdsParam.split(',');
|
||||
const materialsData = await getExecutor()
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(inArray(materials.id, materialIds));
|
||||
|
||||
if (materialsData.length === 0) {
|
||||
return NextResponse.json({ error: '未找到有效材料' }, { status: 400 });
|
||||
}
|
||||
|
||||
const maxQuality = calculateMaxQuality(
|
||||
materialsData as Array<{ rank: Quality }>,
|
||||
);
|
||||
cost = getCostDescription(maxQuality, craftType);
|
||||
} else {
|
||||
// 神通/功法在无选材预览时,回退到凡品基础消耗。
|
||||
cost = getCostDescription('凡品', craftType);
|
||||
}
|
||||
|
||||
// 检查是否负担得起
|
||||
if (cost.spiritStones !== undefined) {
|
||||
canAfford = (cultivator.spirit_stones || 0) >= cost.spiritStones;
|
||||
} else if (cost.comprehension !== undefined) {
|
||||
const progress = cultivator.cultivation_progress as {
|
||||
comprehension_insight?: number;
|
||||
} | null;
|
||||
canAfford = (progress?.comprehension_insight || 0) >= cost.comprehension;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
cost,
|
||||
canAfford,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/craft
|
||||
* 炼制物品/创建技能
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = CraftSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message || '请求参数格式错误' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { materialIds, prompt, craftType } = parsed.data;
|
||||
|
||||
if (craftType === 'create_skill') {
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: '请注入神念,描述神通法门。' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (prompt.trim().length < 5 || prompt.trim().length > 200) {
|
||||
return NextResponse.json(
|
||||
{ error: '神念长度应在5-200字之间。' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
!materialIds ||
|
||||
!Array.isArray(materialIds) ||
|
||||
materialIds.length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数缺失,无法开炉' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await creationEngine.processRequest(
|
||||
user.id,
|
||||
cultivator.id,
|
||||
materialIds || [],
|
||||
prompt || '',
|
||||
craftType,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
if (error instanceof CreationEngineError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message || '请求参数格式错误' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: '造物失败,请稍后再试。' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expireListings } from '@/lib/services/AuctionService';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
function isAuthorizedCronRequest(request: Request): boolean {
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
if (!cronSecret) return true;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
return authHeader === `Bearer ${cronSecret}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron Job: 处理过期的拍卖物品
|
||||
* 执行频率:每小时
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (!isAuthorizedCronRequest(request)) {
|
||||
return new NextResponse('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await expireListings();
|
||||
console.log(`Processed ${processed} expired auctions`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed,
|
||||
message: `已处理 ${processed} 个过期拍卖`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auction Expire Cron Error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理过期拍卖失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { expireBetBattles } from '@/lib/services/BetBattleService';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
function isAuthorizedCronRequest(request: Request): boolean {
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
if (!cronSecret) return true;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
return authHeader === `Bearer ${cronSecret}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAuthorizedCronRequest(request)) {
|
||||
return new NextResponse('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await expireBetBattles();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed,
|
||||
message: `已处理 ${processed} 个过期赌战`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Bet battle expire cron error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理过期赌战失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { getTopRankingCultivatorIds } from '@/lib/redis/rankings';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { MailService } from '@/lib/services/MailService';
|
||||
import { RANKING_REWARDS } from '@/types/constants';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const REWARD_LOCK_KEY = 'golden_rank:rewards:lock';
|
||||
const REWARD_SETTLED_PREFIX = 'golden_rank:rewards:settled:';
|
||||
const LOCK_TTL_SECONDS = 15 * 60;
|
||||
const SETTLED_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
function getRewardByRank(rank: number): number {
|
||||
if (rank === 1) return RANKING_REWARDS[1];
|
||||
if (rank === 2) return RANKING_REWARDS[2];
|
||||
if (rank === 3) return RANKING_REWARDS[3];
|
||||
if (rank <= 10) return RANKING_REWARDS['4-10'];
|
||||
if (rank <= 50) return RANKING_REWARDS['11-50'];
|
||||
return RANKING_REWARDS['51-100'];
|
||||
}
|
||||
|
||||
function getSettlementDateCN(now = new Date()): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
}
|
||||
|
||||
function isAuthorizedCronRequest(request: Request): boolean {
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
if (!cronSecret) return true;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
return authHeader === `Bearer ${cronSecret}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAuthorizedCronRequest(request)) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const lockResult = await redis.set(REWARD_LOCK_KEY, Date.now().toString(), {
|
||||
nx: true,
|
||||
ex: LOCK_TTL_SECONDS,
|
||||
});
|
||||
if (lockResult !== 'OK') {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'settlement_in_progress',
|
||||
});
|
||||
}
|
||||
|
||||
const settlementDate = getSettlementDateCN();
|
||||
const settledKey = `${REWARD_SETTLED_PREFIX}${settlementDate}`;
|
||||
const alreadySettled = await redis.exists(settledKey);
|
||||
if (alreadySettled === 1) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'already_settled_today',
|
||||
settlementDate,
|
||||
});
|
||||
}
|
||||
|
||||
const topCultivatorIds = await getTopRankingCultivatorIds(100);
|
||||
const logs: string[] = [];
|
||||
|
||||
for (let i = 0; i < topCultivatorIds.length; i++) {
|
||||
const rank = i + 1;
|
||||
const reward = getRewardByRank(rank);
|
||||
|
||||
await MailService.sendMail(
|
||||
topCultivatorIds[i],
|
||||
'万界金榜结算奖励',
|
||||
`恭喜你在本期万界金榜中位列第${rank}名,奖励已随邮件发放,请及时领取。`,
|
||||
[{ type: 'spirit_stones', name: '灵石', quantity: reward }],
|
||||
'reward',
|
||||
);
|
||||
|
||||
logs.push(`Rank ${rank}: +${reward}`);
|
||||
}
|
||||
|
||||
await redis.setex(settledKey, SETTLED_TTL_SECONDS, Date.now().toString());
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
settlementDate,
|
||||
processed: topCultivatorIds.length,
|
||||
logs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Rank rewards error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to settle rewards' },
|
||||
{ status: 500 },
|
||||
);
|
||||
} finally {
|
||||
await redis.del(REWARD_LOCK_KEY);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { breakthroughHistory, cultivators } from '@/lib/drizzle/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/active-reincarnate
|
||||
* 主动兵解(放弃当前角色,开始新轮回)
|
||||
*/
|
||||
export const POST = withActiveCultivator(async (_req, { cultivator }) => {
|
||||
if (cultivator.status === 'dead') {
|
||||
return NextResponse.json({ error: '该角色已身死道消' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Perform Reincarnation Logic
|
||||
await getExecutor().transaction(async (tx) => {
|
||||
// 1. Mark as dead
|
||||
await tx
|
||||
.update(cultivators)
|
||||
.set({
|
||||
status: 'dead',
|
||||
diedAt: new Date(),
|
||||
})
|
||||
.where(eq(cultivators.id, cultivator.id));
|
||||
|
||||
// 2. Add a breakthrough history entry specifically for this event
|
||||
await tx.insert(breakthroughHistory).values({
|
||||
cultivatorId: cultivator.id,
|
||||
from_realm: cultivator.realm,
|
||||
from_stage: cultivator.realm_stage,
|
||||
to_realm: '轮回',
|
||||
to_stage: '转世',
|
||||
age: cultivator.age,
|
||||
years_spent: 0,
|
||||
story: `道友${cultivator.name}感悟天道无常,寿元虽未尽,然道心已决。遂于今日自行兵解,散去一身修为,只求来世再踏仙途,重证大道。天地为之动容,降下祥云送行。`,
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, message: '兵解成功,轮回已开' });
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { consumeItem } from '@/lib/services/cultivatorService';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConsumeSchema = z.object({
|
||||
consumableId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/consume
|
||||
* 使用消耗品
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }) => {
|
||||
const body = await request.json();
|
||||
const { consumableId } = ConsumeSchema.parse(body);
|
||||
|
||||
try {
|
||||
const result = await consumeItem(user.id, cultivator.id, consumableId);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,63 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import * as schema from '@/lib/drizzle/schema';
|
||||
import { equipEquipment } from '@/lib/services/cultivatorService';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const EquipSchema = z.object({
|
||||
artifactId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/equip
|
||||
* 获取当前活跃角色装备状态
|
||||
*/
|
||||
export const GET = withActiveCultivator(async (_req, { cultivator }) => {
|
||||
// 获取装备状态
|
||||
const equippedItems = await getExecutor()
|
||||
.select()
|
||||
.from(schema.equippedItems)
|
||||
.where(eq(schema.equippedItems.cultivatorId, cultivator.id));
|
||||
|
||||
if (equippedItems.length === 0) {
|
||||
// 如果没有装备状态记录,返回空状态
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { weapon: null, armor: null, accessory: null },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
weapon: equippedItems[0].weapon_id || null,
|
||||
armor: equippedItems[0].armor_id || null,
|
||||
accessory: equippedItems[0].accessory_id || null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/equip
|
||||
* 装备/卸下装备
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }) => {
|
||||
const body = await request.json();
|
||||
const { artifactId } = EquipSchema.parse(body);
|
||||
|
||||
// 装备或卸下装备
|
||||
const equippedItems = await equipEquipment(
|
||||
user.id,
|
||||
cultivator.id,
|
||||
artifactId,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: equippedItems,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { BuffInstanceState } from '@/engine/buff/types';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { cultivators } from '@/lib/drizzle/schema';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/fate/reshape/abandon
|
||||
* 放弃当前重塑(清除缓存和Buff)
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (_request: NextRequest, { cultivator }) => {
|
||||
// 查找重塑命格Buff
|
||||
const persistentStatuses = (cultivator.persistent_statuses ||
|
||||
[]) as BuffInstanceState[];
|
||||
const reshapeBuff = persistentStatuses.find(
|
||||
(s) => s.configId === 'reshape_fate_talisman',
|
||||
);
|
||||
|
||||
if (!reshapeBuff) {
|
||||
return NextResponse.json(
|
||||
{ error: '未激活重塑先天命格符' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 清除Redis缓存的命格
|
||||
await redis.del(`fate_reshape_pending:${cultivator.id}`);
|
||||
|
||||
// 移除Buff
|
||||
const updatedStatuses = persistentStatuses.filter(
|
||||
(s) => s.instanceId !== reshapeBuff.instanceId,
|
||||
);
|
||||
|
||||
await getExecutor()
|
||||
.update(cultivators)
|
||||
.set({ persistent_statuses: updatedStatuses })
|
||||
.where(eq(cultivators.id, cultivator.id!));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '已放弃逆天改命,符箓之力消散于天地间。',
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { BuffInstanceState } from '@/engine/buff/types';
|
||||
import type { EffectConfig } from '@/engine/effect/types';
|
||||
import type { GeneratedFate } from '@/engine/fate/creation/types';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { cultivators, preHeavenFates } from '@/lib/drizzle/schema';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CommitSchema = z.object({
|
||||
selectedIndices: z.array(z.number()), // 用户选择的预览命格索引
|
||||
replaceIndices: z.array(z.number()).optional(), // 要替换的旧命格索引,不传表示不替换
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/fate/reshape/commit
|
||||
* 提交命格替换
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
const persistentStatuses = (cultivator.persistent_statuses ||
|
||||
[]) as BuffInstanceState[];
|
||||
const reshapeBuff = persistentStatuses.find(
|
||||
(s) => s.configId === 'reshape_fate_talisman',
|
||||
);
|
||||
|
||||
if (!reshapeBuff) {
|
||||
return NextResponse.json(
|
||||
{ error: '未激活重塑先天命格符' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { selectedIndices, replaceIndices } = CommitSchema.parse(body);
|
||||
|
||||
// 校验:无修改不允许提交
|
||||
if (
|
||||
(!replaceIndices || replaceIndices.length === 0) &&
|
||||
selectedIndices.length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '未作任何更改,无法逆转乾坤' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 校验:一次最多舍弃3个旧命数
|
||||
if (replaceIndices && replaceIndices.length > 3) {
|
||||
return NextResponse.json(
|
||||
{ error: `一次最多舍弃3个命数,当前已选${replaceIndices.length}个` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 从Redis获取暂存的命格
|
||||
const cachedFates = await redis.get<GeneratedFate[]>(
|
||||
`fate_reshape_pending:${cultivator.id}`,
|
||||
);
|
||||
|
||||
if (!cachedFates) {
|
||||
return NextResponse.json(
|
||||
{ error: '预览已过期或未进行预览,请重新预览' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 验证并获取选中的新命格
|
||||
const newFates = selectedIndices
|
||||
.map((idx) => cachedFates[idx])
|
||||
.filter(Boolean);
|
||||
|
||||
if (newFates.length !== selectedIndices.length) {
|
||||
return NextResponse.json(
|
||||
{ error: '选择的命格索引无效' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 从数据库查询当前命格
|
||||
const currentFates = await getExecutor()
|
||||
.select()
|
||||
.from(preHeavenFates)
|
||||
.where(eq(preHeavenFates.cultivatorId, cultivator.id!));
|
||||
|
||||
// 删除旧命格(如果指定了替换索引)
|
||||
if (replaceIndices && replaceIndices.length > 0) {
|
||||
// 删除旧命格(根据索引)
|
||||
for (const index of replaceIndices) {
|
||||
if (index >= 0 && index < currentFates.length) {
|
||||
const oldFate = currentFates[index];
|
||||
await getExecutor()
|
||||
.delete(preHeavenFates)
|
||||
.where(eq(preHeavenFates.id, oldFate.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查命格总数不能超过3个
|
||||
const deleteCount = replaceIndices?.length ?? 0;
|
||||
const finalFateCount = currentFates.length - deleteCount + newFates.length;
|
||||
if (finalFateCount > 3) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `先天命格总数不能超过3个。当前${currentFates.length}个,删除${deleteCount}个,新增${newFates.length}个,最终${finalFateCount}个`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 添加新命格(独立于删除操作)
|
||||
for (const fate of newFates) {
|
||||
await getExecutor()
|
||||
.insert(preHeavenFates)
|
||||
.values({
|
||||
cultivatorId: cultivator.id!,
|
||||
name: fate.name,
|
||||
quality: fate.quality,
|
||||
effects: fate.effects as EffectConfig[],
|
||||
description: fate.description,
|
||||
});
|
||||
}
|
||||
|
||||
// 移除Redis缓存
|
||||
await redis.del(`fate_reshape_pending:${cultivator.id}`);
|
||||
|
||||
// 移除Buff(无论是否替换,提交后Buff都会消失)
|
||||
const updatedStatuses = persistentStatuses.filter(
|
||||
(s) => s.instanceId !== reshapeBuff.instanceId,
|
||||
);
|
||||
|
||||
await getExecutor()
|
||||
.update(cultivators)
|
||||
.set({ persistent_statuses: updatedStatuses })
|
||||
.where(eq(cultivators.id, cultivator.id!));
|
||||
|
||||
const addCount = newFates.length;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message:
|
||||
deleteCount > 0
|
||||
? `成功重塑 ${deleteCount} 个先天命格(新增 ${addCount} 个)`
|
||||
: addCount > 0
|
||||
? `成功获得 ${addCount} 个新命格`
|
||||
: '已放弃重塑',
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { BuffInstanceState } from '@/engine/buff/types';
|
||||
import { FateGenerator } from '@/engine/fate/creation/FateGenerator';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { cultivators } from '@/lib/drizzle/schema';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/fate/reshape/preview
|
||||
* 预览随机命格(消耗1次机会)
|
||||
*/
|
||||
export const GET = withActiveCultivator(
|
||||
async (_request: NextRequest, { cultivator }) => {
|
||||
// 查找重塑命格Buff
|
||||
const persistentStatuses = (cultivator.persistent_statuses ||
|
||||
[]) as BuffInstanceState[];
|
||||
const reshapeBuff = persistentStatuses.find(
|
||||
(s) => s.configId === 'reshape_fate_talisman',
|
||||
);
|
||||
|
||||
if (!reshapeBuff) {
|
||||
return NextResponse.json(
|
||||
{ error: '未激活重塑先天命格符' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 检查Buff是否过期
|
||||
const expiresAt = reshapeBuff.metadata?.expiresAt as number | undefined;
|
||||
if (expiresAt && Date.now() > expiresAt) {
|
||||
// 移除过期Buff
|
||||
const updatedStatuses = persistentStatuses.filter(
|
||||
(s) => s.instanceId !== reshapeBuff.instanceId,
|
||||
);
|
||||
|
||||
await getExecutor()
|
||||
.update(cultivators)
|
||||
.set({ persistent_statuses: updatedStatuses })
|
||||
.where(eq(cultivators.id, cultivator.id!));
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: '重塑先天命格符已过期' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const usesRemaining = (reshapeBuff.metadata?.usesRemaining as number) ?? 0;
|
||||
if (usesRemaining <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '重塑次数已用尽,请提交替换或放弃' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 生成3个随机命格
|
||||
const fates = await FateGenerator.generate({ count: 3 });
|
||||
|
||||
// 存入Redis暂存,防止篡改
|
||||
await redis.set(`fate_reshape_pending:${cultivator.id}`, fates, {
|
||||
ex: 86400, // 24小时
|
||||
});
|
||||
|
||||
// 扣除1次机会
|
||||
const updatedStatuses = persistentStatuses.map((s) => {
|
||||
if (s.instanceId === reshapeBuff.instanceId) {
|
||||
return {
|
||||
...s,
|
||||
metadata: {
|
||||
...s.metadata,
|
||||
usesRemaining: usesRemaining - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
await getExecutor()
|
||||
.update(cultivators)
|
||||
.set({ persistent_statuses: updatedStatuses })
|
||||
.where(eq(cultivators.id, cultivator.id!));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fates,
|
||||
usesRemaining: usesRemaining - 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { BuffInstanceState } from '@/engine/buff/types';
|
||||
import type { GeneratedFate } from '@/engine/fate/creation/types';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/fate/reshape/status
|
||||
* 获取当前重塑状态(是否有缓存的命格、剩余次数)
|
||||
*/
|
||||
export const GET = withActiveCultivator(
|
||||
async (_request: NextRequest, { cultivator }) => {
|
||||
// 查找重塑命格Buff
|
||||
const persistentStatuses = (cultivator.persistent_statuses ||
|
||||
[]) as BuffInstanceState[];
|
||||
const reshapeBuff = persistentStatuses.find(
|
||||
(s) => s.configId === 'reshape_fate_talisman',
|
||||
);
|
||||
|
||||
// 没有激活的Buff,返回空状态
|
||||
if (!reshapeBuff) {
|
||||
return NextResponse.json({
|
||||
hasBuff: false,
|
||||
hasPendingFates: false,
|
||||
usesRemaining: 0,
|
||||
fates: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查Buff是否过期
|
||||
const expiresAt = reshapeBuff.metadata?.expiresAt as number | undefined;
|
||||
if (expiresAt && Date.now() > expiresAt) {
|
||||
return NextResponse.json({
|
||||
hasBuff: false,
|
||||
hasPendingFates: false,
|
||||
usesRemaining: 0,
|
||||
fates: null,
|
||||
buffExpired: true,
|
||||
});
|
||||
}
|
||||
|
||||
const usesRemaining = (reshapeBuff.metadata?.usesRemaining as number) ?? 0;
|
||||
|
||||
// 检查是否有缓存的命格
|
||||
const cachedFates = await redis.get<GeneratedFate[]>(
|
||||
`fate_reshape_pending:${cultivator.id}`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
hasBuff: true,
|
||||
hasPendingFates: !!cachedFates,
|
||||
usesRemaining,
|
||||
fates: cachedFates ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import * as schema from '@/lib/drizzle/schema';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DiscardSchema = z.object({
|
||||
itemId: z.string(),
|
||||
itemType: z.enum(['artifact', 'consumable', 'material']),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/inventory/discard
|
||||
* 丢弃物品
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (req: NextRequest, { cultivator }) => {
|
||||
const body = await req.json();
|
||||
const { itemId, itemType } = DiscardSchema.parse(body);
|
||||
|
||||
// Delete item based on type
|
||||
let deleted = false;
|
||||
if (itemType === 'artifact') {
|
||||
const result = await getExecutor()
|
||||
.delete(schema.artifacts)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.artifacts.id, itemId),
|
||||
eq(schema.artifacts.cultivatorId, cultivator.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
deleted = result.length > 0;
|
||||
} else if (itemType === 'consumable') {
|
||||
const result = await getExecutor()
|
||||
.delete(schema.consumables)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.consumables.id, itemId),
|
||||
eq(schema.consumables.cultivatorId, cultivator.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
deleted = result.length > 0;
|
||||
} else if (itemType === 'material') {
|
||||
const result = await getExecutor()
|
||||
.delete(schema.materials)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.materials.id, itemId),
|
||||
eq(schema.materials.cultivatorId, cultivator.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
deleted = result.length > 0;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '物品未找到或无法删除' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: '物品已丢弃' });
|
||||
},
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import {
|
||||
identifyMysteryMaterial,
|
||||
MarketServiceError,
|
||||
} from '@/lib/services/MarketService';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const IdentifySchema = z.object({
|
||||
materialId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { materialId } = IdentifySchema.parse(body);
|
||||
const result = await identifyMysteryMaterial({
|
||||
materialId,
|
||||
cultivatorId: cultivator.id,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof MarketServiceError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message || '参数错误' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
console.error('Identify API error:', error);
|
||||
return NextResponse.json({ error: '鉴定失败' }, { status: 500 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,195 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import {
|
||||
getPaginatedInventoryByType,
|
||||
getCultivatorArtifacts,
|
||||
getCultivatorConsumables,
|
||||
getCultivatorMaterials,
|
||||
} from '@/lib/services/cultivatorService';
|
||||
import {
|
||||
ELEMENT_VALUES,
|
||||
MATERIAL_TYPE_VALUES,
|
||||
QUALITY_VALUES,
|
||||
type ElementType,
|
||||
type MaterialType,
|
||||
type Quality,
|
||||
} from '@/types/constants';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/inventory
|
||||
* 获取当前活跃角色的背包数据
|
||||
*
|
||||
* Query Params:
|
||||
* - type: artifacts | materials | consumables(可选)
|
||||
* - page: 页码(type 存在时生效,默认 1)
|
||||
* - pageSize: 每页数量(type 存在时生效,默认 20,最大 100)
|
||||
*/
|
||||
export const GET = withActiveCultivator(async (req: NextRequest, { user, cultivator }) => {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const type = searchParams.get('type');
|
||||
|
||||
const parseMaterialTypes = (
|
||||
raw: string | null,
|
||||
): MaterialType[] | undefined => {
|
||||
if (!raw) return undefined;
|
||||
const values = raw
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean) as MaterialType[];
|
||||
if (values.length === 0) return undefined;
|
||||
const validSet = new Set<MaterialType>(MATERIAL_TYPE_VALUES);
|
||||
if (values.some((v) => !validSet.has(v))) {
|
||||
throw new Error(
|
||||
`无效的材料类型,支持:${MATERIAL_TYPE_VALUES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const parseMaterialRanks = (raw: string | null): Quality[] | undefined => {
|
||||
if (!raw) return undefined;
|
||||
const values = raw
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean) as Quality[];
|
||||
if (values.length === 0) return undefined;
|
||||
const validSet = new Set<Quality>(QUALITY_VALUES);
|
||||
if (values.some((v) => !validSet.has(v))) {
|
||||
throw new Error(`无效的材料品级,支持:${QUALITY_VALUES.join(', ')}`);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const parseMaterialElements = (
|
||||
raw: string | null,
|
||||
): ElementType[] | undefined => {
|
||||
if (!raw) return undefined;
|
||||
const values = raw
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean) as ElementType[];
|
||||
if (values.length === 0) return undefined;
|
||||
const validSet = new Set<ElementType>(ELEMENT_VALUES);
|
||||
if (values.some((v) => !validSet.has(v))) {
|
||||
throw new Error(`无效的材料属性,支持:${ELEMENT_VALUES.join(', ')}`);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
if (type) {
|
||||
if (!['artifacts', 'materials', 'consumables'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '无效的背包类型,仅支持 artifacts | materials | consumables',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||
const pageSize = Math.min(
|
||||
100,
|
||||
Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10)),
|
||||
);
|
||||
let materialTypes: MaterialType[] | undefined;
|
||||
let excludeMaterialTypes: MaterialType[] | undefined;
|
||||
let materialRanks: Quality[] | undefined;
|
||||
let materialElements: ElementType[] | undefined;
|
||||
const materialSortBy = searchParams.get('materialSortBy');
|
||||
const materialSortOrder = searchParams.get('materialSortOrder');
|
||||
try {
|
||||
materialTypes = parseMaterialTypes(searchParams.get('materialTypes'));
|
||||
excludeMaterialTypes = parseMaterialTypes(
|
||||
searchParams.get('excludeMaterialTypes'),
|
||||
);
|
||||
materialRanks = parseMaterialRanks(searchParams.get('materialRanks'));
|
||||
materialElements = parseMaterialElements(
|
||||
searchParams.get('materialElements'),
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : '材料类型参数解析失败',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const validSortBy = [
|
||||
'createdAt',
|
||||
'rank',
|
||||
'type',
|
||||
'element',
|
||||
'quantity',
|
||||
'name',
|
||||
] as const;
|
||||
const validSortOrder = ['asc', 'desc'] as const;
|
||||
if (
|
||||
materialSortBy &&
|
||||
!validSortBy.includes(materialSortBy as (typeof validSortBy)[number])
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `无效的排序字段,支持:${validSortBy.join(', ')}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
materialSortOrder &&
|
||||
!validSortOrder.includes(
|
||||
materialSortOrder as (typeof validSortOrder)[number],
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `无效的排序方向,支持:${validSortOrder.join(', ')}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await getPaginatedInventoryByType(user.id, cultivator.id, {
|
||||
type: type as 'artifacts' | 'materials' | 'consumables',
|
||||
page,
|
||||
pageSize,
|
||||
materialTypes,
|
||||
excludeMaterialTypes,
|
||||
materialRanks,
|
||||
materialElements,
|
||||
materialSortBy: materialSortBy as
|
||||
| 'createdAt'
|
||||
| 'rank'
|
||||
| 'type'
|
||||
| 'element'
|
||||
| 'quantity'
|
||||
| 'name'
|
||||
| undefined,
|
||||
materialSortOrder: materialSortOrder as 'asc' | 'desc' | undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
|
||||
const consumables = await getCultivatorConsumables(user.id, cultivator.id);
|
||||
const materials = await getCultivatorMaterials(user.id, cultivator.id);
|
||||
const artifacts = await getCultivatorArtifacts(user.id, cultivator.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
consumables,
|
||||
materials,
|
||||
artifacts,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getLifespanLimiter } from '@/lib/redis/lifespanLimiter';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/lifespan-status
|
||||
* 获取当前活跃角色今日寿元消耗状态
|
||||
*/
|
||||
export const GET = withActiveCultivator(async (_req, { cultivator }) => {
|
||||
const limiter = getLifespanLimiter();
|
||||
const consumed = await limiter.getConsumedLifespan(cultivator.id);
|
||||
const remaining = await limiter.getRemainingLifespan(cultivator.id);
|
||||
const isLocked = await limiter.isRetreatLocked(cultivator.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
cultivatorId: cultivator.id,
|
||||
dailyLimit: 200,
|
||||
consumed,
|
||||
remaining,
|
||||
isInRetreat: isLocked,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { resourceEngine } from '@/engine/resource/ResourceEngine';
|
||||
import { ResourceOperation } from '@/engine/resource/types';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor, type DbTransaction } from '@/lib/drizzle/db';
|
||||
import { mails } from '@/lib/drizzle/schema';
|
||||
import { MailAttachment } from '@/lib/services/MailService';
|
||||
import { Artifact, Consumable, Material } from '@/types/cultivator';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/mail/claim-all
|
||||
* 一键领取所有可领取附件
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (_request, { user, cultivator }) => {
|
||||
const q = getExecutor();
|
||||
|
||||
const pendingMails = await q.query.mails.findMany({
|
||||
where: and(
|
||||
eq(mails.type, 'reward'),
|
||||
eq(mails.cultivatorId, cultivator.id),
|
||||
eq(mails.isClaimed, false),
|
||||
),
|
||||
});
|
||||
|
||||
const claimableMails = pendingMails.filter((mail) => {
|
||||
const attachments = (mail.attachments as MailAttachment[]) || [];
|
||||
return attachments.length > 0;
|
||||
});
|
||||
|
||||
if (claimableMails.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
claimedCount: 0,
|
||||
claimedMailIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
const gains: ResourceOperation[] = [];
|
||||
const claimedMailIds = claimableMails.map((mail) => mail.id);
|
||||
|
||||
for (const mail of claimableMails) {
|
||||
const attachments = (mail.attachments as MailAttachment[]) || [];
|
||||
for (const item of attachments) {
|
||||
switch (item.type) {
|
||||
case 'spirit_stones':
|
||||
gains.push({
|
||||
type: 'spirit_stones',
|
||||
value: item.quantity,
|
||||
});
|
||||
break;
|
||||
case 'material':
|
||||
gains.push({
|
||||
type: 'material',
|
||||
value: item.quantity,
|
||||
data: item.data as Material,
|
||||
});
|
||||
break;
|
||||
case 'consumable':
|
||||
gains.push({
|
||||
type: 'consumable',
|
||||
value: item.quantity,
|
||||
data: item.data as Consumable,
|
||||
});
|
||||
break;
|
||||
case 'artifact':
|
||||
// Artifact 领取以单件入包,按数量展开操作
|
||||
for (let i = 0; i < (item.quantity || 1); i++) {
|
||||
gains.push({
|
||||
type: 'artifact',
|
||||
value: 1,
|
||||
data: item.data as Artifact,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await resourceEngine.gain(
|
||||
user.id,
|
||||
cultivator.id,
|
||||
gains,
|
||||
async (tx: DbTransaction) => {
|
||||
await tx
|
||||
.update(mails)
|
||||
.set({ isClaimed: true, isRead: true })
|
||||
.where(inArray(mails.id, claimedMailIds));
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.errors?.[0] || '一键领取失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
claimedCount: claimedMailIds.length,
|
||||
claimedMailIds,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,108 +0,0 @@
|
||||
import { resourceEngine } from '@/engine/resource/ResourceEngine';
|
||||
import { ResourceOperation } from '@/engine/resource/types';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor, type DbTransaction } from '@/lib/drizzle/db';
|
||||
import { mails } from '@/lib/drizzle/schema';
|
||||
import { MailAttachment } from '@/lib/services/MailService';
|
||||
import { Artifact, Consumable, Material } from '@/types/cultivator';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ClaimMailSchema = z.object({
|
||||
mailId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/mail/claim
|
||||
* 领取邮件附件
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }) => {
|
||||
const q = getExecutor();
|
||||
const body = await request.json();
|
||||
const { mailId } = ClaimMailSchema.parse(body);
|
||||
|
||||
// Verify mail belongs to this cultivator
|
||||
const mail = await q.query.mails.findFirst({
|
||||
where: and(eq(mails.id, mailId), eq(mails.cultivatorId, cultivator.id)),
|
||||
});
|
||||
|
||||
if (!mail) {
|
||||
return NextResponse.json({ error: 'Mail not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (mail.isClaimed) {
|
||||
return NextResponse.json({ error: 'Already claimed' }, { status: 400 });
|
||||
}
|
||||
|
||||
const attachments = (mail.attachments as MailAttachment[]) || [];
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return NextResponse.json({ message: 'No attachments' });
|
||||
}
|
||||
|
||||
// Convert attachments to resource operations
|
||||
const gains: ResourceOperation[] = [];
|
||||
|
||||
for (const item of attachments) {
|
||||
switch (item.type) {
|
||||
case 'spirit_stones':
|
||||
gains.push({
|
||||
type: 'spirit_stones',
|
||||
value: item.quantity,
|
||||
});
|
||||
break;
|
||||
case 'material':
|
||||
gains.push({
|
||||
type: 'material',
|
||||
value: item.quantity,
|
||||
data: item.data as Material,
|
||||
});
|
||||
break;
|
||||
case 'consumable':
|
||||
gains.push({
|
||||
type: 'consumable',
|
||||
value: item.quantity,
|
||||
data: item.data as Consumable,
|
||||
});
|
||||
break;
|
||||
case 'artifact':
|
||||
// Artifacts are typically 1 per attachment, but handle quantity just in case
|
||||
// ResourceEngine.gain() treats artifact value as 1 implicitly (no loop), so we add multiple ops if needed
|
||||
const qty = item.quantity || 1;
|
||||
for (let i = 0; i < qty; i++) {
|
||||
gains.push({
|
||||
type: 'artifact',
|
||||
value: 1,
|
||||
data: item.data as Artifact,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use ResourceEngine to handle gains transactionally
|
||||
const result = await resourceEngine.gain(
|
||||
user.id,
|
||||
cultivator.id,
|
||||
gains,
|
||||
async (tx: DbTransaction) => {
|
||||
// Mark as claimed
|
||||
await tx
|
||||
.update(mails)
|
||||
.set({ isClaimed: true, isRead: true })
|
||||
.where(eq(mails.id, mailId));
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.errors?.[0] || '领取失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -1,25 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { mails } from '@/lib/drizzle/schema';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/mail/read-all
|
||||
* 全部标记为已读
|
||||
*/
|
||||
export const POST = withActiveCultivator(async (_request, { cultivator }) => {
|
||||
const q = getExecutor();
|
||||
|
||||
const updatedRows = await q
|
||||
.update(mails)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(mails.cultivatorId, cultivator.id), eq(mails.isRead, false)))
|
||||
.returning({ id: mails.id });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updatedCount: updatedRows.length,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { mails } from '@/lib/drizzle/schema';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ReadMailSchema = z.object({
|
||||
mailId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/mail/read
|
||||
* 标记邮件为已读
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
const q = getExecutor();
|
||||
const body = await request.json();
|
||||
const { mailId } = ReadMailSchema.parse(body);
|
||||
|
||||
// Verify mail belongs to this cultivator
|
||||
const mail = await q.query.mails.findFirst({
|
||||
where: and(eq(mails.id, mailId), eq(mails.cultivatorId, cultivator.id)),
|
||||
});
|
||||
|
||||
if (!mail) {
|
||||
return NextResponse.json({ error: 'Mail not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await q.update(mails).set({ isRead: true }).where(eq(mails.id, mailId));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { mails } from '@/lib/drizzle/schema';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/mail
|
||||
* 获取当前活跃角色的邮件列表(分页)
|
||||
*
|
||||
* Query Params:
|
||||
* - page: 页码(默认 1)
|
||||
* - pageSize: 每页数量(默认 20,最大 100)
|
||||
*/
|
||||
export const GET = withActiveCultivator(async (req: NextRequest, { cultivator }) => {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const pageRaw = parseInt(searchParams.get('page') || '1', 10);
|
||||
const pageSizeRaw = parseInt(searchParams.get('pageSize') || '20', 10);
|
||||
const page = Number.isNaN(pageRaw) ? 1 : Math.max(1, pageRaw);
|
||||
const pageSize = Number.isNaN(pageSizeRaw)
|
||||
? 20
|
||||
: Math.min(100, Math.max(1, pageSizeRaw));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const q = getExecutor();
|
||||
const userMails = await q.query.mails.findMany({
|
||||
where: eq(mails.cultivatorId, cultivator.id),
|
||||
orderBy: [desc(mails.createdAt)],
|
||||
limit: pageSize + 1,
|
||||
offset,
|
||||
});
|
||||
const hasMore = userMails.length > pageSize;
|
||||
const pagedMails = hasMore ? userMails.slice(0, pageSize) : userMails;
|
||||
|
||||
return NextResponse.json({
|
||||
mails: pagedMails,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
hasMore,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { mails } from '@/lib/drizzle/schema';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/mail/unread-count
|
||||
* 获取当前活跃角色的未读邮件数量
|
||||
*/
|
||||
export const GET = withActiveCultivator(async (_req, { cultivator }) => {
|
||||
const q = getExecutor();
|
||||
const result = await q
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(mails)
|
||||
.where(and(eq(mails.cultivatorId, cultivator.id), eq(mails.isRead, false)));
|
||||
|
||||
return NextResponse.json({ count: Number(result[0].count) });
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import type { BuffInstanceState } from '@/engine/buff/types';
|
||||
import { MaterialGenerator } from '@/engine/material/creation/MaterialGenerator';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor, type DbTransaction } from '@/lib/drizzle/db';
|
||||
import { cultivators, materials } from '@/lib/drizzle/schema';
|
||||
import { MaterialType, QUALITY_VALUES, Quality } from '@/types/constants';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* POST /api/cultivator/manual/draw
|
||||
* 抽取功法/神通典籍
|
||||
*/
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { cultivator }) => {
|
||||
const url = new URL(request.url);
|
||||
const drawType = url.searchParams.get('type'); // 'gongfa' | 'skill'
|
||||
|
||||
// 查找抽取Buff
|
||||
const persistentStatuses = (cultivator.persistent_statuses ||
|
||||
[]) as BuffInstanceState[];
|
||||
|
||||
let targetBuffId: string | undefined;
|
||||
if (drawType === 'gongfa') targetBuffId = 'draw_gongfa_talisman';
|
||||
else if (drawType === 'skill') targetBuffId = 'draw_skill_talisman';
|
||||
|
||||
const drawBuff = persistentStatuses.find((s) => {
|
||||
if (targetBuffId) return s.configId === targetBuffId;
|
||||
return ['draw_gongfa_talisman', 'draw_skill_talisman'].includes(
|
||||
s.configId,
|
||||
);
|
||||
});
|
||||
|
||||
if (!drawBuff) {
|
||||
return NextResponse.json(
|
||||
{ error: targetBuffId ? '未激活对应的抽取符' : '未激活抽取符' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = drawBuff.metadata?.expiresAt as number | undefined;
|
||||
if (expiresAt && Date.now() > expiresAt) {
|
||||
// 移除过期Buff
|
||||
const updatedStatuses = persistentStatuses.filter(
|
||||
(s) => s.instanceId !== drawBuff.instanceId,
|
||||
);
|
||||
|
||||
await getExecutor()
|
||||
.update(cultivators)
|
||||
.set({ persistent_statuses: updatedStatuses })
|
||||
.where(eq(cultivators.id, cultivator.id!));
|
||||
|
||||
return NextResponse.json({ error: '抽取符已过期' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 随机品质(玄品及以上)
|
||||
const qualityWeights: Record<string, number> = {
|
||||
玄品: 0.6,
|
||||
真品: 0.25,
|
||||
地品: 0.1,
|
||||
天品: 0.04,
|
||||
仙品: 0.009,
|
||||
神品: 0.001,
|
||||
};
|
||||
const allowedQualities = QUALITY_VALUES.slice(
|
||||
QUALITY_VALUES.indexOf('玄品'),
|
||||
);
|
||||
|
||||
const randomRoll = Math.random();
|
||||
let accumulated = 0;
|
||||
let selectedQuality: Quality = '玄品';
|
||||
for (const q of allowedQualities) {
|
||||
accumulated += qualityWeights[q] || 0;
|
||||
if (randomRoll <= accumulated) {
|
||||
selectedQuality = q;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成典籍(按场景定向产出新类型,兼容 legacy 无 type 场景)
|
||||
const manualType: MaterialType =
|
||||
drawType === 'skill'
|
||||
? 'skill_manual'
|
||||
: drawType === 'gongfa'
|
||||
? 'gongfa_manual'
|
||||
: targetBuffId === 'draw_skill_talisman'
|
||||
? 'skill_manual'
|
||||
: 'gongfa_manual';
|
||||
|
||||
const skeletons = [
|
||||
{ type: manualType, rank: selectedQuality, quantity: 1 },
|
||||
];
|
||||
const generated = await MaterialGenerator.generateFromSkeletons(skeletons);
|
||||
|
||||
if (generated.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '天道感应模糊,典籍生成失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const manual = generated[0];
|
||||
|
||||
// 添加到背包并移除Buff
|
||||
await getExecutor().transaction(async (transaction: DbTransaction) => {
|
||||
const existing = await transaction
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(
|
||||
and(
|
||||
eq(materials.cultivatorId, cultivator.id!),
|
||||
eq(materials.name, manual.name),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await transaction
|
||||
.update(materials)
|
||||
.set({ quantity: sql`${materials.quantity} + 1` })
|
||||
.where(eq(materials.id, existing[0].id));
|
||||
} else {
|
||||
await transaction.insert(materials).values({
|
||||
cultivatorId: cultivator.id!,
|
||||
name: manual.name,
|
||||
type: manual.type,
|
||||
rank: manual.rank,
|
||||
element: manual.element,
|
||||
description: manual.description,
|
||||
quantity: manual.quantity,
|
||||
details: { price: manual.price },
|
||||
});
|
||||
}
|
||||
|
||||
const updatedStatuses = persistentStatuses.filter(
|
||||
(s) => s.instanceId !== drawBuff.instanceId,
|
||||
);
|
||||
await transaction
|
||||
.update(cultivators)
|
||||
.set({ persistent_statuses: updatedStatuses })
|
||||
.where(eq(cultivators.id, cultivator.id!));
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `成功抽取【${manual.name}】`,
|
||||
manual,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,66 +0,0 @@
|
||||
import { buffTemplateRegistry } from '@/engine/buff/BuffTemplateRegistry';
|
||||
import { BuffTag } from '@/engine/buff/types';
|
||||
import { CultivatorUnit } from '@/engine/cultivator/CultivatorUnit';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { cultivators } from '@/lib/drizzle/schema';
|
||||
import { createMinimalCultivator } from '@/lib/services/cultivatorService';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/cultivator/persistent-buffs
|
||||
* 获取当前激活的持久Buff列表(包括符箓、丹药、伤势等)
|
||||
*
|
||||
* 优化:自动清理过期的持久化状态
|
||||
*/
|
||||
export const GET = withActiveCultivator(
|
||||
async (_request: NextRequest, { cultivator }) => {
|
||||
// 将数据库记录转换为 Cultivator 对象
|
||||
const cultivatorData = createMinimalCultivator(cultivator);
|
||||
|
||||
// 使用 CultivatorUnit 统一管理持久化状态
|
||||
const unit = new CultivatorUnit(cultivatorData);
|
||||
|
||||
// 获取清理后的有效状态
|
||||
const validStatuses = unit.getValidPersistentStatuses();
|
||||
|
||||
// 如果有过期状态需要清理,更新数据库
|
||||
if (unit.hasDirtyPersistentStatuses()) {
|
||||
await getExecutor()
|
||||
.update(cultivators)
|
||||
.set({ persistent_statuses: validStatuses })
|
||||
.where(eq(cultivators.id, cultivator.id!));
|
||||
}
|
||||
|
||||
const persistentBuffIds = buffTemplateRegistry
|
||||
.getByTag(BuffTag.PERSISTENT)
|
||||
.map((template) => template.id);
|
||||
|
||||
// Filter and enrich buff instances with template info
|
||||
const persistentBuffs = validStatuses
|
||||
.filter((s) => persistentBuffIds.includes(s.configId))
|
||||
.map((buff) => {
|
||||
const template = buffTemplateRegistry.get(buff.configId);
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt = (buff.metadata?.expiresAt as number | undefined) || 0;
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
instanceId: buff.instanceId,
|
||||
name: template.name,
|
||||
icon: template.icon,
|
||||
description: template.descriptionTemplate,
|
||||
usesRemaining:
|
||||
(buff.metadata?.usesRemaining as number | undefined) ?? undefined,
|
||||
expiresAt: expiresAt > 0 ? expiresAt : undefined,
|
||||
};
|
||||
})
|
||||
.filter((buff): buff is NonNullable<typeof buff> => buff !== null);
|
||||
|
||||
return NextResponse.json({ buffs: persistentBuffs });
|
||||
},
|
||||
);
|
||||
@@ -1,143 +0,0 @@
|
||||
import { getRedeemPresetById } from '@/config/redeemRewardPresets';
|
||||
import { withActiveCultivator } from '@/lib/api/withAuth';
|
||||
import { getExecutor } from '@/lib/drizzle/db';
|
||||
import { redeemCodeClaims, redeemCodes } from '@/lib/drizzle/schema';
|
||||
import { isValidRedeemCodeFormat, normalizeRedeemCode } from '@/lib/redeem/code';
|
||||
import { MailService } from '@/lib/services/MailService';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ClaimRedeemCodeSchema = z.object({
|
||||
code: z.string().trim().min(1).max(64),
|
||||
});
|
||||
|
||||
class RedeemClaimError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const maybe = error as { code?: string };
|
||||
return maybe.code === '23505';
|
||||
}
|
||||
|
||||
export const POST = withActiveCultivator(
|
||||
async (request: NextRequest, { user, cultivator }) => {
|
||||
const q = getExecutor();
|
||||
const body = await request.json();
|
||||
const parsed = ClaimRedeemCodeSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedCode = normalizeRedeemCode(parsed.data.code);
|
||||
if (!isValidRedeemCodeFormat(normalizedCode)) {
|
||||
return NextResponse.json(
|
||||
{ error: '兑换码格式错误,仅支持 6-64 位大写字母数字' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let mailId = '';
|
||||
|
||||
try {
|
||||
await q.transaction(async (tx) => {
|
||||
const redeemCode = await tx.query.redeemCodes.findFirst({
|
||||
where: eq(redeemCodes.code, normalizedCode),
|
||||
});
|
||||
|
||||
if (!redeemCode) {
|
||||
throw new RedeemClaimError('兑换码不存在', 404);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (redeemCode.status !== 'active') {
|
||||
throw new RedeemClaimError('兑换码已停用');
|
||||
}
|
||||
if (redeemCode.startsAt && redeemCode.startsAt > now) {
|
||||
throw new RedeemClaimError('兑换码尚未生效');
|
||||
}
|
||||
if (redeemCode.endsAt && redeemCode.endsAt < now) {
|
||||
throw new RedeemClaimError('兑换码已过期');
|
||||
}
|
||||
|
||||
const claimed = await tx.query.redeemCodeClaims.findFirst({
|
||||
where: and(
|
||||
eq(redeemCodeClaims.redeemCodeId, redeemCode.id),
|
||||
eq(redeemCodeClaims.userId, user.id),
|
||||
),
|
||||
});
|
||||
if (claimed) {
|
||||
throw new RedeemClaimError('该兑换码你已使用过');
|
||||
}
|
||||
|
||||
const preset = getRedeemPresetById(redeemCode.rewardPresetId);
|
||||
if (!preset) {
|
||||
throw new RedeemClaimError('兑换奖励配置缺失,请联系管理员', 500);
|
||||
}
|
||||
|
||||
const [reservedCode] = await tx
|
||||
.update(redeemCodes)
|
||||
.set({
|
||||
claimedCount: sql`${redeemCodes.claimedCount} + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(redeemCodes.id, redeemCode.id),
|
||||
eq(redeemCodes.status, 'active'),
|
||||
sql`(${redeemCodes.startsAt} IS NULL OR ${redeemCodes.startsAt} <= NOW())`,
|
||||
sql`(${redeemCodes.endsAt} IS NULL OR ${redeemCodes.endsAt} >= NOW())`,
|
||||
sql`(${redeemCodes.totalLimit} IS NULL OR ${redeemCodes.claimedCount} < ${redeemCodes.totalLimit})`,
|
||||
),
|
||||
)
|
||||
.returning({ id: redeemCodes.id });
|
||||
|
||||
if (!reservedCode) {
|
||||
throw new RedeemClaimError('兑换码已被领完或失效');
|
||||
}
|
||||
|
||||
const mail = await MailService.sendMail(
|
||||
cultivator.id,
|
||||
redeemCode.mailTitle,
|
||||
redeemCode.mailContent,
|
||||
preset.attachments,
|
||||
'reward',
|
||||
tx,
|
||||
);
|
||||
mailId = mail.id;
|
||||
|
||||
await tx.insert(redeemCodeClaims).values({
|
||||
redeemCodeId: redeemCode.id,
|
||||
userId: user.id,
|
||||
cultivatorId: cultivator.id,
|
||||
mailId: mail.id,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
return NextResponse.json({ error: '该兑换码你已使用过' }, { status: 400 });
|
||||
}
|
||||
if (error instanceof RedeemClaimError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
console.error('Redeem claim error:', error);
|
||||
return NextResponse.json({ error: '兑换失败,请稍后重试' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '兑换成功,奖励已通过传音玉简发放',
|
||||
mailId,
|
||||
});
|
||||
},
|
||||
);
|
||||
Loaded 100 of 991 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user