大迁移

This commit is contained in:
taojiacheng committed 2026-05-14 18:51:09 +08:00
1 parent b55836be52
commit e57275b310
991 files changed
+53612 -75053

No files matched your search

+49 -38
View File
@@ -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
-65
View File
@@ -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.
+37
View File
@@ -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 }}
+1
View File
@@ -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*
+7
View File
@@ -0,0 +1,7 @@
{
"default": true,
"MD052": false,
"MD041": false,
"MD033": false,
"MD013": false
}
-3
View File
@@ -1,3 +0,0 @@
# 指定在索引时要忽略的文件或文件夹。请使用逗号分隔条目,支持 my-security/ 和 *.log 等通配符模式。
.agent/
docs/
-45
View File
@@ -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.
-316
View File
@@ -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
View File
@@ -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"]
-89
View File
@@ -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
-35
View File
@@ -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>;
}
-13
View File
@@ -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>;
}
-168
View File
@@ -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>
);
}
-22
View File
@@ -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>
);
}
-260
View File
@@ -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>
);
}
-139
View File
@@ -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>
);
}
-257
View File
@@ -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>
}
/>
);
}
-293
View File
@@ -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>
);
}
-280
View File
@@ -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>
);
}
-371
View File
@@ -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>
);
}
-18
View File
@@ -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,
};
}
-267
View File
@@ -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>
);
}
-36
View File
@@ -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>
);
}
-79
View File
@@ -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>
);
}
-18
View File
@@ -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>;
}
-221
View File
@@ -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>
);
}
-121
View File
@@ -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>
);
}
-31
View File
@@ -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>
);
}
-63
View File
@@ -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>
);
}
-44
View File
@@ -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>
);
}
-563
View File
@@ -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;
}
-10
View File
@@ -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>;
}
-410
View File
@@ -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>
);
}
-131
View File
@@ -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,
});
});
-140
View File
@@ -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,
});
});
-185
View File
@@ -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 },
);
}
},
);
-96
View File
@@ -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 });
},
);
-108
View File
@@ -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 });
},
);
-86
View File
@@ -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 });
});
-51
View File
@@ -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 },
);
}
},
);
-66
View File
@@ -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 },
);
}
},
);
-74
View File
@@ -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 },
);
}
},
);
-68
View File
@@ -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 });
}
}
-122
View File
@@ -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',
},
});
},
);
-46
View File
@@ -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,
});
}
-110
View File
@@ -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,
},
});
}
-46
View File
@@ -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 },
);
}
},
);
-189
View File
@@ -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 },
);
}
},
);
-120
View File
@@ -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 },
);
}
},
);
-47
View File
@@ -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 });
}
}
-56
View File
@@ -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 });
}
},
);
-31
View File
@@ -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 });
}
-122
View File
@@ -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 },
);
}
},
);
-28
View File
@@ -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,
});
},
);
-166
View File
@@ -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 },
);
}
},
);
-36
View File
@@ -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 },
);
}
}
-30
View File
@@ -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 },
);
}
}
-102
View File
@@ -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: '兵解成功,轮回已开' });
});
-32
View File
@@ -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 },
);
}
},
);
-63
View File
@@ -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 });
}
},
);
-195
View File
@@ -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,
},
});
});
-106
View File
@@ -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,
});
},
);
-108
View File
@@ -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 });
},
);
-25
View File
@@ -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,
});
});
-35
View File
@@ -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 });
},
);
-44
View File
@@ -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) });
});
-150
View File
@@ -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