This commit was merged in pull request #1.
This commit is contained in:
2026-06-02 20:56:07 +08:00
parent f824177aa5
commit bb3ad71d21
38 changed files with 1304 additions and 532 deletions
+1 -3
View File
@@ -1,5 +1,4 @@
import base64
import hmac
import json
import time
from core.basic.character import Character
@@ -51,7 +50,7 @@ def readToken(token: str, redis) -> AlterState:
info = dict2obj(json.loads(redis.get(f'token:{token}')))
except Exception as ex:
print(ex)
ResponseException('登录过期或无效Token,请刷新后重试')
raise ResponseException('登录过期或无效Token,请刷新后重试') from ex
if info is None:
raise ResponseException('登录过期或无效Token,请刷新后重试')
return info
@@ -67,4 +66,3 @@ def uid(access_uid: str | None = Header(None)):
return int(access_uid)
except Exception:
return -1
+5 -7
View File
@@ -1,17 +1,15 @@
from api.core.Auth import createToken
from api.core.Gzip import register_gzip_request
import json
from fastapi import APIRouter, Body,Request
from api.core.Response import Return, response
from api.core.Redis import get_redis_info
from api.dp import RedisDep,AltersDep
from fastapi import APIRouter, Body
from api.core.Response import response
from api.dp import AltersDep
from core.basic.character import createCharacter
router = APIRouter()
register_gzip_request(router)
@router.post('/calc')
async def calc(request: Request,state:AltersDep,config=Body(None)):
async def calc(state: AltersDep, config=Body(None)):
character = createCharacter(state.alter, state.equVersion)
return response(data=character.calc(config))
+1 -1
View File
@@ -65,7 +65,7 @@ async def get_real_time_gold(redis: RedisDep) -> Response[list[RealTimeGold]]:
resp = response.json().get('data', {})
res.append({'updataTime': get_date_str(resp['x'][-1]), 'region': region_name, 'goldPrice': resp['y'][-1]})
return res
except Exception as e:
except Exception:
# print(f"File not found: {e}")
return response(data=[])
# 设置半小时过期一次
+6 -36
View File
@@ -1,11 +1,8 @@
import asyncio
import re
from api.core.Auth import createToken
from api.core.Gzip import register_gzip_request
import json
from fastapi import APIRouter, Request, Depends, Body
from typing import Annotated
from api.core.Response import Return, response
from fastapi import APIRouter, Body, Request
from api.core.Auth import createToken
from api.core.Response import response
from api.core.Redis import get_redis_info, get_redis_info_async
from api.dp import RedisDep, AltersDep
from core.basic.equipment import get_equipment
@@ -14,25 +11,13 @@ from core.basic.character import createCharacter
from core.open.helper import get_user_info_by_id, get_user_info_by_role_id, get_user_list_by_id, getRoleJobCode
from core.open.skillTree import get_skill_tree_info
from api.core.exception import ResponseException
from api.utils.skill_detail import normalize_skill_detail, skill_detail_for_level
router = APIRouter()
register_gzip_request(router)
def replace_placeholders(template):
# 匹配 <int>、<float>、<float1>、<float2> 等
pattern = re.compile(r'<(int|float\d*)>')
idx = 0
def repl(match):
nonlocal idx
idx += 1
return f'{{value{idx}}}'
return pattern.sub(repl, template)
@router.get('/adventure')
async def get_adventure_info(request: Request, redis: RedisDep, name: str = None):
adventure_info = get_redis_info(redis, 'dcalc:adventure', get_adv_list)
@@ -95,25 +80,10 @@ async def get_skill_info(state: AltersDep, skillId: str, level: int, redis: Redi
# For now, we return a placeholder dictionary
with open(f'./openapi/data/{jobId}/{jobGrowId}/cn/skillDetail/{skillId}.json', encoding='utf-8') as f:
skill_data = json.load(f)
if 'levelInfo' in skill_data and 'optionDesc' in skill_data['levelInfo'] and skill_data['levelInfo']['optionDesc'] is not None:
skill_data['levelInfo']['optionDesc'] = replace_placeholders(skill_data['levelInfo']['optionDesc'])
return skill_data
return normalize_skill_detail(skill_data)
skill_info = get_redis_info(redis, key, get_skill_info)
if level is not None:
level = max(0, min(level, skill_info.get('maxLevel', 0)))
if 'levelInfo' in skill_info and 'rows' in skill_info['levelInfo']:
detail = list(filter(lambda x: x['level'] == level, skill_info['levelInfo']['rows']))
if detail and len(detail) > 0:
skill_info['levelInfo'].update(detail[0])
skill_info['levelInfo']['detail'] = skill_info['levelInfo'].get('optionDesc', '')
if skill_info['levelInfo']['optionValue'] is not None:
for key in skill_info['levelInfo']['optionValue'].keys():
skill_info['levelInfo']['detail'] = skill_info['levelInfo']['detail'].replace(f'{{{key}}}', str(skill_info['levelInfo']['optionValue'][key]))
del skill_info['levelInfo']['rows']
skill_info['attribute'] = {}
skill_info['attribute'].update(skill_info['levelInfo'])
del skill_info['levelInfo']
skill_info = skill_detail_for_level(skill_info, level)
except FileNotFoundError:
return response(code=404, message=f'技能信息未找到: {jobId} {jobGrowId} {skillId}', data=None)
return response(data=skill_info)
+3 -32
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Path, Query
from api.core.Response import response
from api.core.Redis import get_redis_info
from api.dp import RedisDep
from api.utils.skill_detail import normalize_skill_detail, skill_detail_for_level
import re
from typing import Annotated
@@ -23,19 +24,6 @@ router = APIRouter()
register_gzip_request(router)
def replace_placeholders(template):
# 匹配 <int>、<float>、<float1>、<float2> 等
pattern = re.compile(r'<(int|float\d*)>')
idx = 0
def repl(match):
nonlocal idx
idx += 1
return f'{{value{idx}}}'
return pattern.sub(repl, template)
@router.get('/skills/', operation_id='skillsList', response_model=SearchSkillResponse)
async def get_skills_list(
redis: RedisDep,
@@ -151,27 +139,10 @@ async def get_cn_skill_info(
encoding='utf-8',
) as f:
skill_data = json.load(f)
if 'levelInfo' in skill_data and 'optionDesc' in skill_data['levelInfo'] and skill_data['levelInfo']['optionDesc'] is not None:
skill_data['levelInfo']['optionDesc'] = replace_placeholders(skill_data['levelInfo']['optionDesc'])
return skill_data
return normalize_skill_detail(skill_data)
skill_info = get_redis_info(redis, key, get_skill_info)
if level is not None:
level = max(0, min(level, skill_info.get('maxLevel', 0)))
if 'levelInfo' in skill_info and 'rows' in skill_info['levelInfo']:
detail = list(filter(lambda x: x['level'] == level, skill_info['levelInfo']['rows']))
if detail and len(detail) > 0:
skill_info['levelInfo'].update(detail[0])
skill_info['levelInfo']['detail'] = skill_info['levelInfo'].get('optionDesc', '')
for key in skill_info['levelInfo']['optionValue'].keys():
skill_info['levelInfo']['detail'] = skill_info['levelInfo']['detail'].replace(
f'{{{key}}}',
str(skill_info['levelInfo']['optionValue'][key]),
)
del skill_info['levelInfo']['rows']
skill_info['attribute'] = {}
skill_info['attribute'].update(skill_info['levelInfo'])
del skill_info['levelInfo']
skill_info = skill_detail_for_level(skill_info, level)
except FileNotFoundError:
return response(
code=404,
-3
View File
@@ -1,7 +1,4 @@
from enum import Enum
from pydantic import BaseModel, Field
from typing import Generic, TypeVar
from api.core.Response import Return as Response
class RealTimeGold(BaseModel):
+60
View File
@@ -0,0 +1,60 @@
from copy import deepcopy
import re
from typing import Any
PLACEHOLDER_PATTERN = re.compile(r'<(?:int|float\d*)[^>]*>')
VALUE_PATTERN = re.compile(r'\{value\d+\}')
def replace_placeholders(template: str) -> str:
idx = 0
def repl(_: re.Match[str]) -> str:
nonlocal idx
idx += 1
return f'{{value{idx}}}'
return PLACEHOLDER_PATTERN.sub(repl, template)
def normalize_skill_detail(skill_data: dict[str, Any]) -> dict[str, Any]:
level_info = skill_data.get('levelInfo')
if not isinstance(level_info, dict):
return skill_data
option_desc = level_info.get('optionDesc')
if option_desc is not None:
level_info['optionDesc'] = replace_placeholders(option_desc)
return skill_data
def skill_detail_for_level(skill_data: dict[str, Any], level: int | None) -> dict[str, Any]:
result = deepcopy(skill_data)
if level is None:
return result
level = max(0, min(level, result.get('maxLevel', 0)))
level_info = result.get('levelInfo')
if not isinstance(level_info, dict):
return result
rows = level_info.get('rows')
if not isinstance(rows, list):
return result
detail = next((row for row in rows if row.get('level') == level), None)
if not detail:
return result
attribute = {**level_info, **detail}
attribute['detail'] = attribute.get('optionDesc', '')
for key, value in (attribute.get('optionValue') or {}).items():
attribute['detail'] = attribute['detail'].replace(f'{{{key}}}', str(value))
attribute['detail'] = VALUE_PATTERN.sub('-', attribute['detail'])
attribute.pop('rows', None)
result['attribute'] = attribute
result.pop('levelInfo', None)
return result
+5 -4
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Any, Literal, Mapping
from collections.abc import Mapping
from typing import Any, Literal
class CharacterProperty(ABC):
@@ -202,17 +203,17 @@ class CharacterProperty(ABC):
pass
@abstractmethod
def AddSkillLv(self, min: int, max: int, lv: int, type=-1, exceptSkills: list[str] = []) -> None:
def AddSkillLv(self, min: int, max: int, lv: int, type=-1, exceptSkills: tuple[str, ...] = ()) -> None:
"""增加技能等级"""
pass
@abstractmethod
def SetSkillCD(self, min=1, max=100, cd=0, exclude=[50, 85, 100]) -> None:
def SetSkillCD(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None:
"""设置技能CD"""
pass
@abstractmethod
def SetSkillCDRecover(self, min=1, max=100, cd=0, exclude=[50, 85, 100]) -> None:
def SetSkillCDRecover(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None:
"""设置技能CD恢复"""
pass
+19 -13
View File
@@ -1,5 +1,4 @@
from collections.abc import Mapping
import sys
from typing import Any, Literal
from core.abstract.character import CharacterProperty
@@ -15,8 +14,11 @@ class CharacterBase(CharacterProperty):
BindAwake: int
"""绑定觉醒等级"""
skills: list['Skill'] = []
skills_dict: dict[str, 'Skill'] = {}
skills: list['Skill']
skills_dict: dict[str, 'Skill']
skills_id_dict: dict[str, 'Skill']
skills_uuid_dict: dict[str, 'Skill']
skill_names_cache: dict[tuple[str, bool | str], list[str]]
equVersion: str = '0'
"""装备版本"""
charEquipInfo: dict[str, 'CharacterEquipInfo']
@@ -33,9 +35,9 @@ class CharacterBase(CharacterProperty):
"""誓约套装信息"""
oathSkillInfo: list['OathSuitSkill']
"""誓约技能信息"""
equ_effect: list['EquEffect'] = []
equ_effect: list['EquEffect']
"""装备效果列表"""
equ_options: dict[str, int] = {}
equ_options: dict[str, int]
"""装备选项列表"""
EquEffectRatio = 1.0
"""装备效果倍率"""
@@ -148,6 +150,9 @@ class CharacterBase(CharacterProperty):
self.max_point = 0
self.skills = []
self.skills_dict = {}
self.skills_id_dict = {}
self.skills_uuid_dict = {}
self.skill_names_cache = {}
self.load_skills()
# region 角色属性设置
@@ -285,7 +290,7 @@ class CharacterBase(CharacterProperty):
value = int(value * (1 + self.jade_effect.ElementIncrease))
self.ElementDB[element] += value
def AddSkillLv(self, min: int, max: int, lv: int, type=-1, exceptSkills: list[str] = []) -> None:
def AddSkillLv(self, min: int, max: int, lv: int, type=-1, exceptSkills: tuple[str, ...] = ()) -> None:
"""
增加技能等级
type: -1 全部, 0 被动, 1 主动
@@ -296,13 +301,13 @@ class CharacterBase(CharacterProperty):
if (skillType == 'all' or skill.type == skillType) and skill.lv > 0 and skill.name not in exceptSkills:
skill.lv += lv
def SetSkillCD(self, min=1, max=100, cd=0, exclude=[50, 85, 100]) -> None:
def SetSkillCD(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None:
"""设置技能CD"""
for skill in self.skills:
if min <= skill.learnLv <= max and (skill.type == 'active' or skill.damage) and skill.learnLv not in exclude:
skill.cdReduce *= 1 - cd
def SetSkillCDRecover(self, min=1, max=100, cd=0, exclude=[50, 85, 100]) -> None:
def SetSkillCDRecover(self, min=1, max=100, cd=0, exclude: tuple[int, ...] = (50, 85, 100)) -> None:
"""设置技能CD恢复"""
for skill in self.skills:
if min <= skill.learnLv <= max and (skill.type == 'active' or skill.damage) and skill.learnLv not in exclude:
@@ -330,17 +335,18 @@ class CharacterBase(CharacterProperty):
def GetSkillByID(self, id: str) -> 'Skill':
"""通过技能ID获取技能"""
skill = list(filter(lambda x: str(x.id) == id, self.skills))
return skill[0] if len(skill) > 0 else None
return self.skills_id_dict.get(str(id), None)
def GetSkillByUUID(self, uuid: str) -> 'Skill':
"""通过技能UUID获取技能"""
skill = list(filter(lambda x: str(x.uuid) == uuid, self.skills))
return skill[0] if len(skill) > 0 else None
return self.skills_uuid_dict.get(str(uuid), None)
def GetSkillNames(self, type: Literal['active', 'passive', 'all'] = 'all', damage: Literal[True, False, 'all'] = 'all') -> list[str]:
"""获取技能名字"""
return [skill.name for skill in self.skills if (type == 'all' or skill.type == type) and (damage == 'all' or skill.damage == damage)]
key = (type, damage)
if key not in self.skill_names_cache:
self.skill_names_cache[key] = [skill.name for skill in self.skills if (type == 'all' or skill.type == type) and (damage == 'all' or skill.damage == damage)]
return self.skill_names_cache[key]
def GetWeaponType(self) -> tuple[str, str]:
"""获取武器类型"""
+44 -31
View File
@@ -1,4 +1,5 @@
import sys
from functools import cache
from uuid import uuid1
from core.basic.equipment import OathSuit, Suit, get_equipment
@@ -7,6 +8,16 @@ from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, CharacterOathInf
from .base import CharacterBase
@cache
def get_skill_classes(module_name: str) -> tuple[type, ...]:
module = sys.modules[module_name]
return tuple(
memberClass
for name in dir(module)
if isinstance((memberClass := getattr(module, name)), type) and memberClass.__name__.startswith('Skill')
)
class CalcEquipMixin(CharacterBase):
"""装备计算、角色初始化相关方法"""
@@ -22,11 +33,12 @@ class CalcEquipMixin(CharacterBase):
info['emblems'] = {}
if info.get('oaths', None) is None:
info['oaths'] = {}
equs = get_equipment(self.equVersion)
for key in info['equips']:
self.charEquipInfo[key] = CharacterEquipInfo(info['equips'][key], self.equVersion, key)
self.charEquipInfo[key] = CharacterEquipInfo(info['equips'][key], self.equVersion, key, equs)
for key in info['oaths']:
# 导入誓约打造信息、誓约信息
self.charOathInfo.append(CharacterOathInfo(info['oaths'][key], self.equVersion, key))
self.charOathInfo.append(CharacterOathInfo(info['oaths'][key], self.equVersion, key, equs))
try:
# 异常处理 彩色等级自动取最小值
minEmblemLv = min([info['emblems'][key]['lv'] for key in ['红色', '蓝色', '绿色', '黄色']], default=1)
@@ -36,7 +48,7 @@ class CalcEquipMixin(CharacterBase):
info['emblems'][key]['part'] = key
self.charEmblemInfo.append(CharacterEmblemInfo(info['emblems'][key]))
pass
except Exception as e:
except Exception:
pass
self.charOathSkillId = info.get('oathSkill', 1)
@@ -59,12 +71,11 @@ class CalcEquipMixin(CharacterBase):
def load_skills(self) -> None:
"""加载技能"""
module = sys.modules[self.moduleName]
for name in dir(module):
memberClass = getattr(module, name)
if isinstance(memberClass, type) and memberClass.__name__.startswith('Skill'):
for memberClass in get_skill_classes(self.moduleName):
skill = memberClass(char=self)
self.skills_dict[skill.name] = skill
self.skills_id_dict[str(skill.id)] = skill
self.skills_uuid_dict[str(skill.uuid)] = skill
self.skills.append(skill)
# endregion
@@ -77,27 +88,27 @@ class CalcEquipMixin(CharacterBase):
oathSuitInfo = {}
correction = 0
equs = get_equipment(self.equVersion)
for item in filter(lambda x: x.equInfo is not None, self.charEquipInfo.values()):
for item in self.charEquipInfo.values():
partEqu = item.equInfo
if partEqu is None:
continue
# partFusion = item.fusionInfo
if partEqu is not None:
for suit in partEqu.suit:
if suitInfo.get(suit, None) is None:
suitInfo[suit] = {'point': 0, 'count': 0}
suit_item = suitInfo.setdefault(suit, {'point': 0, 'count': 0})
if partEqu.Point == 0:
suitInfo[suit]['count'] += 1
suit_item['count'] += 1
else:
suitInfo[suit]['point'] += partEqu.Point
suit_item['point'] += partEqu.Point
for item in self.charOathInfo:
partOath = item.oathInfo
if partOath is not None:
if partOath is None:
continue
for suit in partOath.suit:
if oathSuitInfo.get(suit, None) is None:
oathSuitInfo[suit] = {'point': 0, 'count': 0}
oath_suit_item = oathSuitInfo.setdefault(suit, {'point': 0, 'count': 0})
if partOath.Point == 0:
oathSuitInfo[suit]['count'] += 1
oath_suit_item['count'] += 1
else:
oathSuitInfo[suit]['point'] += partOath.Point
oath_suit_item['point'] += partOath.Point
max_point_suit = max((suit for suit in suitInfo if suitInfo[suit]['point'] > 0), key=lambda suit: suitInfo[suit]['point'], default=None)
max_point_oath_suit = max((suit for suit in oathSuitInfo if oathSuitInfo[suit]['point'] > 0), key=lambda suit: oathSuitInfo[suit]['point'], default=None)
zero_point_suits = [suit for suit in suitInfo if suitInfo[suit]['point'] == 0]
@@ -140,15 +151,17 @@ class CalcEquipMixin(CharacterBase):
oathSuitList += equs.get_oath_suit_info(suit, oathSuitInfo[suit]['point'], 0)
if len(oathSuitList) == 0:
continue
oath_suit_by_suit_id = {item.suitId: item for item in oathSuitList}
for suit in suitList:
i = suit.id
func = equs.funs.execture(f'suit_{i}')
suit = next((x for x in equs.suits if str(x.id) == str(i)), None)
suit = equs.suit_id_dict.get(str(i), None)
if suit is None:
continue
# 誓约和套装进行匹配
# 如果存在誓约和套装是同一个套装
# 则直接不执行原套装效果,执行誓约套装增强的效果
oathEffect = next((x for x in oathSuitList if x.suitId == suit.suitId), None)
if suit is not None:
oathEffect = oath_suit_by_suit_id.get(suit.suitId, None)
filtered_dict = {k: v for k, v in suit.__dict__.items() if k[0].isupper()}
self.SetStatus(**filtered_dict)
if func is not None and oathEffect is None:
@@ -160,15 +173,15 @@ class CalcEquipMixin(CharacterBase):
self.max_point = 0 if res['suit'] is None else res.get('suit', {}).get('point', 0)
try:
self.oath_max_point = oathSuitInfo.get(max_point_oath_suit, {}).get('point', 0)
except:
except Exception:
self.oath_max_point = 0
self.oathSuitInfo = []
self.oathSkillInfo = []
# 当套装点数达到太初时,誓约技能生效
if self.max_point >= 2550:
oathSuit = next((x for x in oathSuitList if x.suitId == max_point_oath_suit), None)
oathSuit = oath_suit_by_suit_id.get(max_point_oath_suit, None)
if oathSuit is not None:
skills = list(filter(lambda x: x.suitId == oathSuit.suitId, oathSuit.skills))
skills = [x for x in oathSuit.skills if x.suitId == oathSuit.suitId]
skill = next((x for x in oathSuit.skills if str(x.skillId) == str(self.charOathSkillId)), None)
# 誓约套装效果生效
filtered_dict = {k: v for k, v in oathSuit.__dict__.items() if k[0].isupper()}
@@ -214,11 +227,11 @@ class CalcEquipMixin(CharacterBase):
"""计算装备基础效果、附魔"""
effects = get_equipment(self.equVersion).funs
# for detail in [(item.equInfo, item.fusionInfo, item.enchant, item.emblem_0, item.emblem_1) for item in filter(lambda x: x.equInfo is not None, self.charEquipInfo.values())]:
for detail in [(item.equInfo, item.enchant) for item in filter(lambda x: x.equInfo is not None, self.charEquipInfo.values())]:
equ = detail[0]
for item in self.charEquipInfo.values():
equ = item.equInfo
if equ is None or equ.itemType == '副武器':
continue
enchat = detail[1]
enchat = item.enchant
fun = effects.execture(f'equ_{equ.id}')
if fun is None:
continue
@@ -316,20 +329,20 @@ class CalcEquipMixin(CharacterBase):
def calc_basic(self):
"""计算基础属性:防具精通、增幅、强化等"""
char_equip_info = self.charEquipInfo
for part in ['上衣', '头肩', '下装', '腰带', '鞋']:
cur = self.charEquipInfo[part]
cur = char_equip_info[part]
if cur.equInfo is None:
continue
equ = cur.equInfo
reinforce = self.charEquipInfo[part].reinforce
reinforce = cur.reinforce
if equ is None:
continue
for key in self.防具精通属性:
value = 精通计算(115, equ.rarity, reinforce, equ.itemDetailType, self.防具类型, self.buffer, key)
key = get_key_by_value(key)
setattr(self, key, getattr(self, key) + value)
for part in self.charEquipInfo.keys():
cur = self.charEquipInfo[part]
for part, cur in char_equip_info.items():
if cur.equInfo is None:
continue
if cur.reinforceType == 1:
+14 -8
View File
@@ -1,18 +1,21 @@
import importlib
from database.models import EmblemData, OathData, OathSuitData, OathSuitSkillData, PrimerData, Session, EquData, StoneData, SuitData, EnchantData, JadeData
from collections.abc import Sequence
from functools import cache
from sqlalchemy.orm import Session
from database.models import EmblemData, OathData, OathSuitData, OathSuitSkillData, PrimerData, EquData, SuitData, EnchantData, JadeData
from database.connect import get_db_engine as get_engine
def parse_to_number_list(info: str, default: list[float] = [0]) -> list[float]:
return default if not info else [float(i) for i in info.split(',')]
def parse_to_number_list(info: str, default: Sequence[float] = (0,)) -> list[float]:
return list(default) if not info else [float(i) for i in info.split(',')]
def parse_to_string_list(info: str, default: list[str] = ['']) -> list[str]:
return default if not info else [i for i in info.split(',')]
def parse_to_string_list(info: str, default: Sequence[str] = ('',)) -> list[str]:
return list(default) if not info else [i for i in info.split(',')]
def parse_to_int_list(info: str, default: list[int] = [0]) -> list[int]:
return default if not info else [int(i) for i in info.split(',')]
def parse_to_int_list(info: str, default: Sequence[int] = (0,)) -> list[int]:
return list(default) if not info else [int(i) for i in info.split(',')]
class Equ:
@@ -89,6 +92,7 @@ class Equipments:
self.emblems = []
self.suits: list[Suit] = []
self.suit_dict: dict[str, Suit] = {}
self.suit_id_dict: dict[str, Suit] = {}
self.oath_suits: list[OathSuit] = []
self.oath_suit_dict: dict[str, OathSuit] = {}
self.init_equs()
@@ -99,7 +103,6 @@ class Equipments:
self.init_jades()
self.init_oaths()
self.init_oath_suits()
self.engine.dispose()
def init_equs(self):
"""从数据库中获取所有装备信息"""
@@ -166,6 +169,7 @@ class Equipments:
suit_obj = Suit(**suit)
self.suits.append(suit_obj)
self.suit_dict.setdefault(str(suit['suitId']), []).append(suit_obj)
self.suit_id_dict[str(suit_obj.id)] = suit_obj
pass
pass
@@ -279,6 +283,7 @@ class Equipments:
funs = importlib.import_module('core.equipment.version_0')
return funs
@cache
def get_suit_info(self, suitId: str | int, point: int = 0, count: int = 0) -> list[Suit]:
"""根据套装点数返回对应适用的套装属性\n
新套装只需要传入suitID和point即可,count默认为0\n
@@ -295,6 +300,7 @@ class Equipments:
result[suit.count] = suit
return list(result.values())
@cache
def get_oath_suit_info(self, suitId: str | int, point: int = 0, count: int = 0) -> list['OathSuit']:
"""根据套装点数返回对应适用的誓约套装属性\n
新套装只需要传入suitID和point即可,count默认为0\n
+1 -1
View File
@@ -39,7 +39,7 @@ class CharacterInfo(enum.Enum):
AtkI = '独立攻击', '/ui/UI_ProfileIcon_008.png', lambda x: round(x, 0)
"""独立攻击力"""
ElementDB = '攻击属性', '/ui/UI_ProfileIcon_009.png', lambda x: f'火({int(x['火'])})/冰({int(x['冰'])})/光({int(x['光'])})/暗({int(x['暗'])})'
ElementDB = '攻击属性', '/ui/UI_ProfileIcon_009.png', lambda x: f"火({int(x['火'])})/冰({int(x['冰'])})/光({int(x['光'])})/暗({int(x['暗'])})"
"""攻击属性"""
SkillAttack = '技能伤害', '/ui/UI_ProfileIcon_047.png', lambda x: f'{format(x * 100 - 100, ".1f")}%'
+16 -6
View File
@@ -32,12 +32,15 @@ class CharacterEquipInfo:
precision: float
"""秘宝精度"""
def __init__(self, info={}, equVerison=0, part='') -> None:
def __init__(self, info=None, equVerison=0, part='', equipments=None) -> None:
if info is None:
info = {}
# info['fusion'] = info.get('fusion', None)
info['precision'] = info.get('precision', 0)
# info['weaponFusion'] = info.get('weaponFusion', None)
self.__dict__.update(info)
equ = get_equipment(equVerison).equ_dict.get(self.id, None)
equipments = equipments or get_equipment(equVerison)
equ = equipments.equ_dict.get(self.id, None)
# fusion = get_equipment(equVerison).stone_dict.get(self.fusion, None)
if equ is not None and (equ.itemType == part or equ.itemDetailType == part or (equ.itemType == '武器' and part == '副武器')):
self.equInfo = equ.adapt(self.adaptation)
@@ -60,10 +63,13 @@ class CharacterOathInfo:
oathInfo: Union['Oath', None]
"""誓约信息"""
def __init__(self, info={}, equVerison=0, position='') -> None:
def __init__(self, info=None, equVerison=0, position='', equipments=None) -> None:
if info is None:
info = {}
self.__dict__.update(info)
self.id = str(self.id)
oath = get_equipment(equVerison).oath_dict.get(self.id, None)
equipments = equipments or get_equipment(equVerison)
oath = equipments.oath_dict.get(self.id, None)
if oath is not None and position in oath.position:
self.oathInfo = oath.adapt(self.adaptation)
self.adaptation = min(self.adaptation, self.oathInfo.max_adaptation)
@@ -79,7 +85,9 @@ class CharacterEmblemInfo:
lv: int
"""星徽等级"""
def __init__(self, info={}) -> None:
def __init__(self, info=None) -> None:
if info is None:
info = {}
self.__dict__.update(info)
@@ -89,7 +97,9 @@ class CharacterWeaponInfo(CharacterEquipInfo):
upgradeDetail: dict
"""贴膜详情"""
def __init__(self, info={}) -> None:
def __init__(self, info=None) -> None:
if info is None:
info = {}
self.__dict__.update(info)
+81 -42
View File
@@ -1,49 +1,76 @@
from copy import deepcopy
from copy import copy
from collections.abc import Callable
from functools import cache
import json
import re
from typing import Literal, TYPE_CHECKING
from typing import Any, Literal, TYPE_CHECKING
from .formula import 武器冷却惩罚
from database.connect import get_redis
from database.connect import BACKEND_DIR, get_redis_client
from api.core.Redis import get_redis_info
if TYPE_CHECKING:
from core.basic.character import Character
characterLv = 115 + 5
OPENAPI_DIR = BACKEND_DIR / 'openapi'
def _identity(value: Any) -> Any:
return value
@cache
def get_data(prefix: str, key: int | str, func = lambda x: x):
redis = next(get_redis())
def _get_skill_data_suffixes(skill_class: type) -> tuple[str, ...]:
return tuple(key.replace('data', '') for key in dir(skill_class) if key.startswith('data') and not key.startswith('plus'))
@cache
def _get_skill_damage_suffixes(skill_class: type) -> tuple[str, ...]:
return tuple(key.replace('data', '') for key in dir(skill_class) if key.startswith('data') and not key.startswith('dataplus'))
@cache
def _get_skill_dataplus_suffixes(skill_class: type) -> tuple[str, ...]:
return tuple(key.replace('dataplus', '') for key in dir(skill_class) if key.startswith('dataplus'))
@cache
def get_data(prefix: str, key: int | str, func: Callable[[Any], Any] = _identity):
prefix_parts = prefix.split('/')
job = prefix_parts[0]
jobGrow = prefix_parts[1]
skill = prefix_parts[-1]
def get_custom_cn():
with open('./openapi/custom_cn.json', encoding='utf-8') as f:
with (OPENAPI_DIR / 'custom_cn.json').open(encoding='utf-8') as f:
custom_cn = json.load(f)
return custom_cn
def get_skill_info():
# This function should retrieve the skill info based on job and jobGrow
# For now, we return a placeholder dictionary
with open(f'./openapi/data/{prefix}.json', encoding='utf-8') as f:
with (OPENAPI_DIR / 'data' / f'{prefix}.json').open(encoding='utf-8') as f:
skill_data = json.load(f)
custom_cn = get_redis_info(redis, 'dcalc:custom_cn', get_custom_cn, without_gzip=True)
job = prefix.split('/')[0]
jobGrow = prefix.split('/')[1]
skill = prefix.split('/')[-1]
try:
if isinstance(key, int):
data = [0] + list(map(lambda x: x["optionValue"].get("value" + str(key+1), 0), skill_data["levelInfo"]["rows"]))
data = [0] + [row["optionValue"].get("value" + str(key+1), 0) for row in skill_data["levelInfo"]["rows"]]
data = [0 if x is None or x == "-" else x for x in data]
elif key == "vps":
data = [ {"name":x["name"],"desc":x["desc"]} for x in skill_data.get("evolution",[])]
elif key == "custom":
data = next((item.get('custom', 1.0) for item in custom_cn
data = next((item.get('custom', 1.0) for item in custom_cn or []
if item['job'] == job and item['jobGrow'] == jobGrow and item['skillId'] == skill), 1.0)
except Exception as e:
# print(f"get skill {prefix}/{key} data error",e)
except Exception:
# print(f"get skill {prefix}/{key} data error")
data = None
if isinstance(key, int) and not data:
return [0]
if key == 'vps' and data is None:
return []
if key == 'custom' and data is None:
return 1.0
return data
with get_redis_client() as redis:
data = get_redis_info(redis, f'dcalc:skill:{prefix}:{key}', get_skill_info, without_gzip=True)
return func(data)
@@ -174,65 +201,76 @@ class Skill:
self._lv = value
def effect(self, old: int, new: int):
weapon_type = self.char.GetWeaponType()[0]
weapon_options = self.char.武器选项
for assoc in self.associate:
type = assoc.get('type', '*skillDamage')
skills = assoc.get('skills', '*')
data = assoc.get('data', [0] * self.maxLv)
ratio = assoc.get('ratio', 100)
weapon = assoc.get('weapon', self.char.武器选项)
weapon = assoc.get('weapon', weapon_options)
exceptSkills = assoc.get('exceptSkills', [])
if type[0] not in ['*', '+', '=', '$']:
if self.precondition() and (self.char.GetWeaponType()[0] in weapon or len(weapon) == len(self.char.武器选项)):
if self.precondition() and (weapon_type in weapon or len(weapon) == len(weapon_options)):
getattr(self, type)(old, new, data, skills, exceptSkills, ratio, weapon)
continue
if self.precondition():
self.apply_association(type, old, new, data, skills, exceptSkills, ratio, weapon)
self.apply_association(type, old, new, data, skills, exceptSkills, ratio, weapon, weapon_type, weapon_options)
def precondition(self):
"""effect的前置条件"""
return True
def apply_association(self, type, old, new, data, skills, exceptSkills, ratio=100, weapon=[]):
def apply_association(self, type, old, new, data, skills, exceptSkills, ratio=100, weapon=(), weapon_type=None, weapon_options=()):
try:
if (self.char.GetWeaponType()[0] not in weapon) and not (self.char.GetWeaponType()[0] is None and len(weapon) == len(self.char.武器选项)):
if (weapon_type not in weapon) and not (weapon_type is None and len(weapon) == len(weapon_options)):
return
if type.startswith('$*'):
if 'cdReduce' in type:
value = (1 - data[new] / ratio) / (1 - data[old] / ratio)
value = (1 - self.get_data_value(data, new) / ratio) / (1 - self.get_data_value(data, old) / ratio)
else:
value = (1 + data[new] / ratio) / (1 + data[old] / ratio)
eval(f'self.char.SetStatus({type[2:]}={value})')
value = (1 + self.get_data_value(data, new) / ratio) / (1 + self.get_data_value(data, old) / ratio)
self.char.SetStatus(**{type[2:]: value})
elif type.startswith('$+'):
value = data[new] / ratio - data[old] / ratio
eval(f'self.char.SetStatus({type[2:]}={value})')
value = self.get_data_value(data, new) / ratio - self.get_data_value(data, old) / ratio
self.char.SetStatus(**{type[2:]: value})
else:
if skills == '*':
skills = self.char.GetSkillNames('all', True)
exceptSkills = set(exceptSkills)
for name in skills:
if name in exceptSkills:
continue
skill = self.char.GetSkillByName(name)
if skill is not None:
self.update_skill_attribute(skill, type, old, new, data, ratio, weapon)
self.update_skill_attribute(skill, type, old, new, data, ratio, weapon, weapon_type, weapon_options)
except Exception as e:
# print(f"Error applying association {type} for skill {self.name}: {e}")
raise e
def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=[]):
if (self.char.GetWeaponType()[0] not in weapon) and not (self.char.GetWeaponType()[0] is None and len(weapon) == len(self.char.武器选项)):
def update_skill_attribute(self, skill, type, old, new, data, ratio=100, weapon=(), weapon_type=None, weapon_options=()):
if (weapon_type not in weapon) and not (weapon_type is None and len(weapon) == len(weapon_options)):
return
old_value = self.get_data_value(data, old)
new_value = self.get_data_value(data, new)
if type.startswith('*'):
if 'cdReduce' in type or type == 'cd' or 'cdRatio' in type:
setattr(skill, type[1:], getattr(skill, type[1:]) * (1 - data[new] / ratio) / (1 - data[old] / ratio))
setattr(skill, type[1:], getattr(skill, type[1:]) * (1 - new_value / ratio) / (1 - old_value / ratio))
else:
setattr(skill, type[1:], getattr(skill, type[1:]) * (1 + data[new] / ratio) / (1 + data[old] / ratio))
setattr(skill, type[1:], getattr(skill, type[1:]) * (1 + new_value / ratio) / (1 + old_value / ratio))
elif type.startswith('+'):
if type[1:] == 'lv' and self.char.setInfo.get('skills', {}).get(str(skill.id),{}).get('lv',0) == 0:
return
else:
setattr(skill, type[1:], getattr(skill, type[1:]) + data[new] / ratio - data[old] / ratio)
setattr(skill, type[1:], getattr(skill, type[1:]) + new_value / ratio - old_value / ratio)
elif type.startswith('='):
setattr(skill, type[1:], data[new])
setattr(skill, type[1:], new_value)
@staticmethod
def get_data_value(data, index: int):
if 0 <= index < len(data):
return data[index]
return 0
def skillInfo(self, mode: str | None = None):
pass
@@ -252,8 +290,7 @@ class ActiveSkill(Skill):
def __init__(self, char):
super().__init__(char)
keys = [key.replace('data', '') for key in dir(self) if key.startswith('data') and not key.startswith('plus')]
for i in keys:
for i in _get_skill_data_suffixes(self.__class__):
power = getattr(self, f'power{i}', None)
if power is None:
setattr(self, f'power{i}', 1)
@@ -262,7 +299,12 @@ class ActiveSkill(Skill):
setattr(self, f'hit{i}', 0)
def skillInfo(self, mode: str | None = None):
basic = deepcopy(self)
basic = copy(self)
for key, value in self.__dict__.items():
if isinstance(value, list):
setattr(basic, key, value.copy())
elif isinstance(value, dict):
setattr(basic, key, value.copy())
basic.setUP()
if mode is not None:
basic.currentMode = mode
@@ -298,8 +340,7 @@ class ActiveSkill(Skill):
def getSkillData(self, lv: int = 0):
res = 0
keys = [key.replace('data', '') for key in dir(self) if key.startswith('data') and not key.startswith('dataplus')]
for i in keys:
for i in _get_skill_damage_suffixes(self.__class__):
data = getattr(self, f'data{i}', [])
plus = getattr(self, f'plus{i}', 0)
hit = getattr(self, f'hit{i}', 0)
@@ -309,8 +350,7 @@ class ActiveSkill(Skill):
hit = getattr(self, f'hit{i}', 0)
power = getattr(self, f'power{i}', 1)
res += hit * power * (data[lv] + plus)
keys = [key.replace('dataplus', '') for key in dir(self) if key.startswith('dataplus')]
for i in keys:
for i in _get_skill_dataplus_suffixes(self.__class__):
data = getattr(self, f'dataplus{i}', 0)
hit = getattr(self, f'hitplus{i}', 1)
power = getattr(self, f'powerplus{i}', 1)
@@ -397,8 +437,7 @@ class BuffSkill(Skill):
def __init__(self, char):
super().__init__(char)
keys = [key.replace('data', '') for key in dir(self) if key.startswith('data') and not key.startswith('plus')]
for i in keys:
for i in _get_skill_data_suffixes(self.__class__):
power = getattr(self, f'power{i}', None)
if power is None:
setattr(self, f'power{i}', 1)
+3 -5
View File
@@ -1,5 +1,5 @@
# b9cb48777665de22c006fabaf9a560b3
from core.basic.skill import PassiveSkill, ActiveSkill, get_data
from core.basic.skill import PassiveSkill, ActiveSkill, get_data, _get_skill_damage_suffixes, _get_skill_dataplus_suffixes
from core.basic.character import Character
prefix = 'archer/hunter/cn/skillDetail'
@@ -12,8 +12,7 @@ class HunterActiveSkill(ActiveSkill):
# 标记消耗,大于0为消耗,小于0为生成
def getSkillData(self, lv: int = 0):
res = 0
keys = [key.replace('data', '') for key in dir(self) if key.startswith('data') and not key.startswith('dataplus')]
for i in keys:
for i in _get_skill_damage_suffixes(self.__class__):
data = getattr(self, f'data{i}', [])
plus = getattr(self, f'plus{i}', 0)
hit = getattr(self, f'hit{i}', 0)
@@ -24,8 +23,7 @@ class HunterActiveSkill(ActiveSkill):
power = getattr(self, f'power{i}', 1)
cost = getattr(self, f'cost{i}', 0)
res += hit * power * (data[lv] + plus) * ((1 + self.normalRation) if cost <= 0 else (1 + self.markRation * cost))
keys = [key.replace('dataplus', '') for key in dir(self) if key.startswith('dataplus')]
for i in keys:
for i in _get_skill_dataplus_suffixes(self.__class__):
data = getattr(self, f'dataplus{i}', 0)
hit = getattr(self, f'hitplus{i}', 1)
power = getattr(self, f'powerplus{i}', 1)
+3 -5
View File
@@ -1,5 +1,5 @@
# ddc49e9ad1ff72a00b53c6cff5b1e920
from core.basic.skill import PassiveSkill, ActiveSkill, get_data
from core.basic.skill import PassiveSkill, ActiveSkill, get_data, _get_skill_damage_suffixes, _get_skill_dataplus_suffixes
from core.basic.character import Character
prefix = 'thief/rogue/cn/skillDetail'
@@ -14,8 +14,7 @@ class ActiveSkill(ActiveSkill):
def getSkillData(self, lv: int = 0):
res = 0
keys = [key.replace('data', '') for key in dir(self) if key.startswith('data') and not key.startswith('dataplus')]
for i in keys:
for i in _get_skill_damage_suffixes(self.__class__):
data = getattr(self, f'data{i}', [])
plus = getattr(self, f'plus{i}', 0)
if len(data) == 0:
@@ -25,8 +24,7 @@ class ActiveSkill(ActiveSkill):
power = getattr(self, f'power{i}', 1)
group = getattr(self, f'group{i}', 'normal')
res += hit * power * (data[lv] + plus) * (self.endpower if group == 'end' else 1)
keys = [key.replace('dataplus', '') for key in dir(self) if key.startswith('dataplus')]
for i in keys:
for i in _get_skill_dataplus_suffixes(self.__class__):
data = getattr(self, f'dataplus{i}', 0)
hit = getattr(self, f'hitplus{i}', 1)
power = getattr(self, f'powerplus{i}', 1)
-1
View File
@@ -1,4 +1,3 @@
from core.basic.equipment import EquEffect
from core.abstract.character import CharacterProperty
from .register import register
+3 -4
View File
@@ -1,11 +1,10 @@
registry = {}
def register(func):
registry[func.__name__] = func
return func
def execture(name):
if name in registry:
return registry[name]
else:
return None
return registry.get(name, None)
-1
View File
@@ -120,7 +120,6 @@ def trans_to_dcalc_set(detail, alter: AltersDep):
basicAvatars = basic.funs.get_dress_list([])
equips = {}
avatar = {}
avatarDisplay = {}
emblems = {}
oaths = {}
for equ in detail.get('equs', []):
+2 -4
View File
@@ -1,4 +1,3 @@
import os
import platform
import re
@@ -14,9 +13,8 @@ def search_host(hostvalue):
host_path = host_mac
else:
host_path = host_window
hostfile = open(host_path, 'r', errors='ignore')
with open(host_path, errors='ignore') as hostfile:
each_line = hostfile.readlines()
hostfile.close()
findresult = re.findall(hostvalue, ''.join(each_line))
return findresult
@@ -27,10 +25,10 @@ def write_host(hostvalue):
else:
output = open(host_window, 'a')
with output:
for insid in hostvalue:
output.write(insid)
output.write('\n')
output.close()
def host_init():
+14 -8
View File
@@ -1,22 +1,28 @@
import os
import sys
from functools import cache
from pathlib import Path
import redis
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from config.main import config
RedisPool = redis.ConnectionPool.from_url(config.REDIS_URL)
BACKEND_DIR = Path(__file__).resolve().parents[1]
def get_redis_client():
return redis.Redis(connection_pool=RedisPool)
def get_redis():
with redis.Redis(connection_pool=RedisPool) as redis_conn:
with get_redis_client() as redis_conn:
yield redis_conn
@cache
def get_db_engine(version='0'):
try:
os.chdir(os.path.dirname(sys.argv[0]))
except Exception:
pass
return create_engine(f'sqlite:///{os.path.join("./", "dataFiles", f"dcalc_{version}.db")}')
db_path = BACKEND_DIR / 'dataFiles' / f'dcalc_{version}.db'
return create_engine(f'sqlite:///{db_path}', connect_args={'check_same_thread': False})
def get_db():
with Session(get_db_engine()) as session:
+1 -2
View File
@@ -1,8 +1,7 @@
# import os
# import sys
from sqlalchemy import Column, Integer, Text, Float
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, declarative_base
from sqlalchemy.orm import declarative_base
Base = declarative_base()
metadata = Base.metadata
+5
View File
@@ -12,3 +12,8 @@ dependencies = [
"requests>=2.32.3",
"sqlalchemy>=2.0.38",
]
[dependency-groups]
dev = [
"ruff>=0.15.15",
]
+8
View File
@@ -1,7 +1,15 @@
# 限制每行最多20个字符
line-length = 320
extend-exclude = [
"api/core/zipfile.py",
"core/basic/character_old.py",
]
lint.select = ["F","E","W","UP"]
[lint.per-file-ignores]
"core/character/**/*.py" = ["E501", "F401", "W291", "W292", "W293"]
"core/equipment/version_0/suit.py" = ["E501"]
[format]
# 使用单引号
quote-style = "single"
+34 -1
View File
@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -544,6 +544,11 @@ dependencies = [
{ name = "sqlalchemy" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3" },
@@ -554,6 +559,9 @@ requires-dist = [
{ name = "sqlalchemy", specifier = ">=2.0.38" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.15" }]
[[package]]
name = "detect-installer"
version = "0.1.0"
@@ -2354,6 +2362,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
]
[[package]]
name = "ruff"
version = "0.15.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
+9 -1
View File
@@ -1,5 +1,5 @@
import { defineRequest } from "../common";
import type { IAdventureInfo, ICharacterInfo, IHelperInfo, ISkillInfo } from "./type";
import type { IAdventureInfo, IAlterInfo, ICharacterInfo, IHelperInfo, ISkillInfo } from "./type";
export default defineRequest((request) => {
return {
@@ -7,6 +7,10 @@ export default defineRequest((request) => {
const res = await request.get<IAdventureInfo[]>("/adventure");
return res.data;
},
async alterByName(name: string) {
const res = await request.get<IAlterInfo>("/adventure", { params: { name } });
return res.data;
},
async createToken(alter: string, equVersion?: string) {
const res = await request.get<string>(`/token/get/${alter}`, { params: { equVersion } });
return res.data;
@@ -23,6 +27,10 @@ export default defineRequest((request) => {
const res = await request.get(`/dnfhelper/${uid}/`);
return res.data;
},
async getConfigFromHelperAlter(uid: string, alter: string) {
const res = await request.get(`/dnfhelper/${uid}/${alter}/`);
return res.data;
},
async getSkillFromCode(code: string) {
const res = await request.post(`/skillTree/`, { code });
return res.data;
@@ -572,7 +572,7 @@ const leftPanelOpenSections = ["equipped", "stats", "suit", "equipped-list"];
<td class="px-1.5 py-1.5">
<div class="flex items-center gap-2">
<img
:src="skill.icon"
:src="getImageURL(skill.icon)"
:alt="skill.name"
class="size-4 rounded-sm object-cover sm:size-5"
/>
@@ -6,12 +6,21 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
Settings2,
Sparkles,
} from "lucide-vue-next";
import { useConfigStore, useInfoStore } from "@/stores";
import type { IEquipment, ISuit } from "@/api/info/type";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import Tabs from "@/components/ui/tabs/Tabs.vue";
import TabsList from "@/components/ui/tabs/TabsList.vue";
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
@@ -54,7 +63,8 @@ const suitId = ref("");
const oathSuitId = ref("");
const suitRarity = ref<string>("");
const oathSuitRarity = ref<string>("");
const sidePanelTab = ref<SidePanelTab>("detail");
const sidePanelTab = ref<SidePanelTab>("equipped");
const triggerOptionsOpen = ref(false);
const equippedPart = ref("");
const equippedOathPart = ref("0");
const previewEquipKey = ref("");
@@ -75,7 +85,8 @@ const panelLayoutVars = computed(() => ({
const iconWrapStyle = computed(() => ({
...panelLayoutVars.value,
width: "var(--equip-grid-width)",
maxWidth: "var(--equip-grid-width)",
width: "100%",
}));
const isOathEquipment = (equip?: IEquipment) =>
@@ -805,25 +816,29 @@ const toggleOathRarity = (value: unknown) => {
const next = normalizeRarityValue(value);
oathSuitRarity.value = !next || oathSuitRarity.value === next ? "" : next;
};
const setTriggerOption = (id: number, value: string) => {
configStore.config.options[id.toString()] = Number.parseInt(value, 10);
};
</script>
<template>
<div
class="grid min-h-full min-w-0 gap-4 pb-6 md:h-full md:pb-0 md:grid-cols-[minmax(0,1fr)_320px]"
class="grid min-h-full min-w-0 gap-4 pb-6 lg:h-full lg:pb-0 lg:grid-cols-[minmax(0,1fr)_300px] xl:grid-cols-[minmax(0,1fr)_320px]"
>
<div
class="min-h-0 min-w-0 grid items-stretch gap-4 md:h-full [grid-template-columns:repeat(auto-fit,minmax(min(100%,18rem),1fr))]"
class="min-h-0 min-w-0 grid items-stretch gap-4 lg:h-full [grid-template-columns:repeat(auto-fit,minmax(min(100%,18rem),1fr))]"
:style="panelLayoutVars"
>
<div class="min-h-0 min-w-0 md:h-full">
<div class="min-h-0 min-w-0 lg:h-full">
<Card
class="w-full md:flex md:h-full md:flex-col md:overflow-hidden"
class="w-full lg:flex lg:h-full lg:flex-col lg:overflow-hidden"
:style="panelLayoutVars"
>
<!-- <CardHeader class="pb-3">
<CardTitle>武器/称号/宠物/秘宝</CardTitle>
</CardHeader> -->
<CardContent class="space-y-4 md:min-h-0 md:flex-1 md:overflow-y-auto">
<CardContent class="space-y-4 overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<Tabs v-model="weaponTab" class="gap-0" size="sm">
<TabsList>
<TabsTrigger value="weapon">武器</TabsTrigger>
@@ -832,7 +847,7 @@ const toggleOathRarity = (value: unknown) => {
</TabsList>
</Tabs>
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<template v-if="weaponTab === 'weapon'">
<EquipmentCell
@@ -878,6 +893,50 @@ const toggleOathRarity = (value: unknown) => {
</TabsList>
</Tabs>
<Sheet v-model:open="triggerOptionsOpen">
<SheetTrigger as-child>
<Button
v-if="infoStore.options.length"
size="icon-sm"
variant="outline"
title="装备触发条件"
>
<Settings2 class="size-4" />
</Button>
</SheetTrigger>
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>装备触发条件</SheetTitle>
<SheetDescription>配置会立即写回当前角色计算条件。</SheetDescription>
</SheetHeader>
<div class="grid gap-3 px-4">
<label
v-for="option in infoStore.options"
:key="option.id"
class="grid gap-1 text-sm"
>
<span class="text-muted-foreground">{{ option.name }}</span>
<select
class="h-9 rounded-md border border-input bg-background px-2"
:value="String(configStore.config.options[option.id.toString()] ?? 0)"
@change="
(event) =>
setTriggerOption(option.id, (event.target as HTMLSelectElement).value)
"
>
<option
v-for="(item, index) in option.options"
:key="`${option.id}-${index}`"
:value="String(index)"
>
{{ item }}
</option>
</select>
</label>
</div>
</SheetContent>
</Sheet>
<ColorSwatchPickerRoot
v-if="equipTab === 'suit'"
class="ml-auto flex items-center gap-2"
@@ -908,7 +967,7 @@ const toggleOathRarity = (value: unknown) => {
<template v-if="equipTab === 'suit'">
<div
class="mx-auto flex w-max max-w-full flex-nowrap overflow-x-auto pb-1"
class="mx-auto flex flex-wrap content-start gap-1 overflow-x-hidden pb-1"
:style="iconWrapStyle"
>
<button
@@ -941,7 +1000,7 @@ const toggleOathRarity = (value: unknown) => {
</button>
</div>
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in suitGrid"
@@ -966,7 +1025,7 @@ const toggleOathRarity = (value: unknown) => {
>
{{ section.title }}
</div> -->
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in section.items"
@@ -991,21 +1050,21 @@ const toggleOathRarity = (value: unknown) => {
</Card>
</div>
<div class="min-h-0 min-w-0 md:h-full">
<div class="min-h-0 min-w-0 lg:h-full">
<Card
class="w-full md:flex md:h-full md:flex-col md:overflow-hidden"
class="w-full lg:flex lg:h-full lg:flex-col lg:overflow-hidden"
:style="panelLayoutVars"
>
<!-- <CardHeader>
<CardTitle>誓约</CardTitle>
</CardHeader> -->
<CardContent class="space-y-4 md:min-h-0 md:flex-1 md:overflow-y-auto">
<CardContent class="space-y-4 overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<div class="flex items-center gap-2">
<Tabs v-model="oathTab" class="gap-0">
<TabsList>
<TabsTrigger value="suit">誓约</TabsTrigger>
<!-- <TabsTrigger value="universal">通宝</TabsTrigger>
<TabsTrigger value="other">其他</TabsTrigger> -->
<TabsTrigger value="universal">通宝</TabsTrigger>
<TabsTrigger value="other">其他</TabsTrigger>
</TabsList>
</Tabs>
@@ -1039,7 +1098,7 @@ const toggleOathRarity = (value: unknown) => {
<template v-if="oathTab === 'suit'">
<div
class="mx-auto flex w-max max-w-full flex-nowrap overflow-x-auto pb-1"
class="mx-auto flex flex-wrap content-start gap-1 overflow-x-hidden pb-1"
:style="iconWrapStyle"
>
<button
@@ -1084,7 +1143,7 @@ const toggleOathRarity = (value: unknown) => {
>
{{ section.title }}
</div> -->
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in section.items"
@@ -1109,7 +1168,7 @@ const toggleOathRarity = (value: unknown) => {
</template>
<template v-else-if="oathTab === 'universal'">
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in oathUniversalGrid"
@@ -1123,10 +1182,16 @@ const toggleOathRarity = (value: unknown) => {
/>
</div>
</div>
<div
v-if="!oathUniversalGrid.some(Boolean)"
class="py-6 text-center text-xs text-muted-foreground"
>
当前暂无誓约通宝数据
</div>
</template>
<template v-else>
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in oathOtherGrid"
@@ -1140,14 +1205,20 @@ const toggleOathRarity = (value: unknown) => {
/>
</div>
</div>
<div
v-if="!oathOtherGrid.some(Boolean)"
class="py-6 text-center text-xs text-muted-foreground"
>
当前暂无其它誓约数据
</div>
</template>
</CardContent>
</Card>
</div>
</div>
<div class="min-h-0 space-y-4 md:h-full md:self-stretch">
<Card class="md:flex md:h-full md:flex-col md:overflow-hidden">
<div class="min-h-0 space-y-4 lg:h-full lg:self-stretch">
<Card class="lg:flex lg:h-full lg:flex-col lg:overflow-hidden">
<CardHeader>
<CardTitle class="text-base">
<Tabs v-model="sidePanelTab" class="gap-0">
@@ -11,12 +11,13 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import type { IJade } from "@/api/info/type";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
const configStore = useConfigStore();
const infoStore = useInfoStore();
const moduleMode = ref<"equip" | "oath" | "avatar" | "global">("equip");
const moduleMode = ref<"equip" | "oath" | "avatar" | "jade" | "sundry" | "global">("equip");
const forgeViewTab = ref<"config" | "result">("result");
const notice = ref("");
const noticeType = ref<"error" | "info">("info");
@@ -27,6 +28,8 @@ const avatarOptionKeyword = ref("");
const emblemOptionKeyword = ref("");
const onlyConfiguredAvatarParts = ref(false);
const onlyConfiguredEmblemSlots = ref(false);
const equipBatchApply = ref(false);
const oathBatchApply = ref(false);
const noEnchantAvatarParts = ["头部", "帽子", "脸部", "胸部", "上衣", "腰带", "下装", "鞋"];
@@ -49,6 +52,19 @@ const emblemLevels = [
"史诗Lv.1",
];
const jadeKeys = ["0", "1", "2", "3"] as const;
const sundryFields = [
{ key: "fog", label: "迷雾系统", defaultValue: 10 },
{ key: "adventure", label: "冒险团", defaultValue: 30 },
{ key: "marriage_house", label: "结婚房屋", defaultValue: 4 },
{ key: "marriage_ring", label: "结婚戒指", defaultValue: 2 },
{ key: "contract", label: "晶体契约", defaultValue: 1 },
{ key: "collection_type", label: "收集箱类型", defaultValue: 1 },
{ key: "collection_num_0", label: "收集箱数量一", defaultValue: 5 },
{ key: "collection_num_1", label: "收集箱数量二", defaultValue: 5 },
{ key: "costume_card", label: "名称装扮", defaultValue: 1 },
] as const;
const setNotice = (message: string, type: "error" | "info" = "info") => {
notice.value = message;
noticeType.value = type;
@@ -71,12 +87,21 @@ const summary = computed(() => {
const emblemCount = Object.values(configStore.config.emblems || {}).reduce((acc, item) => {
return acc + (item.items?.filter(Boolean).length ?? 0);
}, 0);
const jadeCount = Object.values(configStore.config.jades || {}).filter(
(item) => Number(item.id) >= 0,
).length;
const sundryCount = sundryFields.filter((item) => {
const value = configStore.config.sundry?.[item.key] ?? item.defaultValue;
return value !== 0 && value !== "" && value !== undefined;
}).length;
return {
equipCount,
avatarCount,
oathCount,
emblemCount,
jadeCount,
sundryCount,
emblemSlots: infoStore.emblemParts.length,
};
});
@@ -182,9 +207,127 @@ const emblemMaxCount = computed(() => {
return 4;
});
const setNumberField = (
const setOathAdaptation = (part: string, value: string) => {
const target = configStore.config.oaths[part];
if (!target) return;
const parsed = Number.parseInt(value || "0", 10);
if (Number.isNaN(parsed)) {
setNotice(`誓约槽位 ${part} 的输入无效,请输入数字。`, "error");
return;
}
const partsToApply = oathBatchApply.value ? infoStore.oathParts : [part];
partsToApply.forEach((item) => {
if (!configStore.config.oaths[item]) {
configStore.config.oaths[item] = { ...configStore.defaultOathConfig };
}
configStore.config.oaths[item].adaptation = Math.max(0, parsed);
});
setNotice(oathBatchApply.value ? "已批量更新全部誓约槽位适配值。" : `已更新誓约槽位 ${part} 的适配值。`);
trackSource(oathBatchApply.value ? "誓约适配已批量更新" : `誓约槽位 ${part} 适配已更新`);
};
const equipCfg = (part: string) => configStore.config.equips[part] ?? configStore.defaultEqusConfig;
const oathCfg = (part: string) => configStore.config.oaths[part] ?? configStore.defaultOathConfig;
const avatarCfg = (part: string) =>
configStore.config.avatar[part] ?? configStore.defaultAvatarConfig;
const canUpgradeEquip = (part: string) => !["称号", "宠物"].includes(part);
const canSetSecret = (part: string) => ["武器", "辅助装备", "魔法石", "耳环"].includes(part);
const equipEnchantOptions = (part: string) =>
infoStore.enchants
.filter(
(item) =>
item.position.includes(part) &&
!item.position.includes("武器装扮") &&
!item.position.includes("宠物装备"),
)
.sort((a, b) => (b.fame ?? 0) - (a.fame ?? 0));
const formatFloat = (value: number, digits = 1) => Number(value.toFixed(digits)).toString();
const ensureJade = (key: string) => {
if (!configStore.config.jades[key]) {
configStore.config.jades[key] = { id: -1, value: 0 };
}
return configStore.config.jades[key];
};
const jadeValueOptions = (id: number) => {
if (id < 0) return [];
const current = infoStore.jades.find((item) => item.id === id) as IJade | undefined;
if (!current) return [];
const result: { id: number; value: string }[] = [];
const max = Number(current.max);
const min = Number(current.min);
const step = Number(current.pre);
if (current.unit.includes("等级")) {
result.push({ id: 1, value: current.unit });
} else if (step > 0) {
for (let value = max; value >= min; value = Number((value - step).toFixed(1))) {
if (value !== 0) {
result.push({
id: value,
value: `${value > 0 ? "+" : ""}${formatFloat(value, 1)}${current.unit}`,
});
}
}
}
return result;
};
const setJadeId = (key: string, value: string) => {
const target = ensureJade(key);
const id = Number.parseInt(value, 10);
target.id = Number.isNaN(id) ? -1 : id;
target.value = jadeValueOptions(target.id)[0]?.id ?? 0;
setNotice(`已更新辟邪玉槽位 ${Number(key) + 1}。`);
trackSource(`辟邪玉槽位 ${Number(key) + 1} 已更新`);
};
const setJadeValue = (key: string, value: string) => {
const target = ensureJade(key);
const parsed = Number(value);
target.value = Number.isNaN(parsed) ? 0 : parsed;
setNotice(`已更新辟邪玉槽位 ${Number(key) + 1} 数值。`);
trackSource(`辟邪玉槽位 ${Number(key) + 1} 数值已更新`);
};
const resetJades = () => {
jadeKeys.forEach((key) => {
configStore.config.jades[key] = { id: -1, value: 0 };
});
setNotice("已重置辟邪玉配置。");
trackSource("辟邪玉配置已重置");
};
const sundryOptions = (key: string) => infoStore.sundries[key]?.options ?? [];
const sundryValue = (key: string, defaultValue: number) => {
return String(configStore.config.sundry?.[key] ?? defaultValue);
};
const setSundryValue = (key: string, value: string) => {
const parsed = Number(value);
configStore.config.sundry[key] = Number.isNaN(parsed) ? value : parsed;
setNotice("已更新其它系统配置。");
trackSource(`其它系统 ${key} 已更新`);
};
const resetSundry = () => {
sundryFields.forEach((field) => {
configStore.config.sundry[field.key] = field.defaultValue;
});
setNotice("已重置其它系统配置。");
trackSource("其它系统配置已重置");
};
const setEquipNumberField = (
part: string,
field: "reinforce" | "refine" | "adaptation",
field: "reinforce" | "reinforceType" | "refine" | "adaptation" | "precision",
value: string,
) => {
const target = configStore.config.equips[part];
@@ -196,29 +339,45 @@ const setNumberField = (
return;
}
target[field] = Math.max(0, parsed);
setNotice(`已更新 ${part} 的打造参数。`);
const normalized =
field === "precision" ? Math.max(0, Math.min(parsed, 100)) : Math.max(0, parsed);
const canBatch = equipBatchApply.value && ["reinforceType", "reinforce", "adaptation"].includes(field);
const partsToApply = canBatch ? infoStore.parts.filter(canUpgradeEquip) : [part];
partsToApply.forEach((item) => {
if (!configStore.config.equips[item]) {
configStore.config.equips[item] = { ...configStore.defaultEqusConfig };
}
configStore.config.equips[item][field] = normalized;
});
setNotice(canBatch ? "已批量更新装备打造参数。" : `已更新 ${part} 的打造参数。`);
trackSource(canBatch ? `装备 ${field} 已批量更新` : `装备 ${part} ${field} 已更新`);
};
const setOathAdaptation = (part: string, value: string) => {
const target = configStore.config.oaths[part];
const setEquipEnchant = (part: string, value: string) => {
const target = configStore.config.equips[part];
if (!target) return;
const parsed = Number.parseInt(value || "0", 10);
if (Number.isNaN(parsed)) {
setNotice(`誓约槽位 ${part} 的输入无效,请输入数字。`, "error");
return;
const enchant = infoStore.enchants.find((item) => String(item.id) === value);
const partsToApply =
equipBatchApply.value && enchant?.position.length
? enchant.position.filter((item) => configStore.config.equips[item])
: [part];
partsToApply.forEach((item) => {
if (!configStore.config.equips[item]) {
configStore.config.equips[item] = { ...configStore.defaultEqusConfig };
}
configStore.config.equips[item].enchant = value;
});
target.adaptation = Math.max(0, parsed);
setNotice(`已更新誓约槽位 ${part} 的适配值。`);
setNotice(equipBatchApply.value && partsToApply.length > 1 ? "已批量更新装备附魔。" : `已更新 ${part} 的附魔。`);
trackSource(
equipBatchApply.value && partsToApply.length > 1 ? "装备附魔已批量更新" : `装备 ${part} 附魔已更新`,
);
};
const equipCfg = (part: string) => configStore.config.equips[part] ?? configStore.defaultEqusConfig;
const oathCfg = (part: string) => configStore.config.oaths[part] ?? configStore.defaultOathConfig;
const avatarCfg = (part: string) =>
configStore.config.avatar[part] ?? configStore.defaultAvatarConfig;
const ensureAvatarPart = (part: string) => {
if (!configStore.config.avatar[part]) {
configStore.config.avatar[part] = { ...configStore.defaultAvatarConfig };
@@ -339,6 +498,12 @@ const resetAllForge = () => {
infoStore.emblemParts.forEach((part) => {
configStore.config.emblems[part] = { ...configStore.defaultEmblemConfig };
});
jadeKeys.forEach((key) => {
configStore.config.jades[key] = { id: -1, value: 0 };
});
sundryFields.forEach((field) => {
configStore.config.sundry[field.key] = field.defaultValue;
});
setNotice("已重置当前角色的打造参数。", "info");
trackSource("当前角色 Forge 配置已快速重置");
};
@@ -352,7 +517,8 @@ const resetAllForge = () => {
<span>Forge 面板</span>
<span class="text-xs font-normal text-muted-foreground">
装备 {{ summary.equipCount }} · 时装 {{ summary.avatarCount }} · 誓约
{{ summary.oathCount }} · 徽章 {{ summary.emblemCount }}
{{ summary.oathCount }} · 徽章 {{ summary.emblemCount }} · 辟邪玉
{{ summary.jadeCount }} · 其它 {{ summary.sundryCount }}
</span>
</CardTitle>
<CardDescription>Forge 进阶:子模块可编辑,统一浅色/深色模式。</CardDescription>
@@ -400,6 +566,20 @@ const resetAllForge = () => {
>
时装徽章
</Button>
<Button
size="sm"
:variant="moduleMode === 'jade' ? 'default' : 'ghost'"
@click="moduleMode = 'jade'"
>
辟邪玉
</Button>
<Button
size="sm"
:variant="moduleMode === 'sundry' ? 'default' : 'ghost'"
@click="moduleMode = 'sundry'"
>
其它
</Button>
<Button
size="sm"
:variant="moduleMode === 'global' ? 'default' : 'ghost'"
@@ -433,6 +613,18 @@ const resetAllForge = () => {
>时装、徽章、等级与条目配置。</AccordionContent
>
</AccordionItem>
<AccordionItem value="jade">
<AccordionTrigger @click="moduleMode = 'jade'">辟邪玉</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>4 个辟邪玉词条与数值配置。</AccordionContent
>
</AccordionItem>
<AccordionItem value="sundry">
<AccordionTrigger @click="moduleMode = 'sundry'">其它</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>迷雾、冒险团、结婚、晶体契约等系统配置。</AccordionContent
>
</AccordionItem>
<AccordionItem value="global">
<AccordionTrigger @click="moduleMode = 'global'">全局</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
@@ -483,40 +675,121 @@ const resetAllForge = () => {
<CardDescription>强化、锻造、品级编辑并即时回写配置。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<label
class="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 p-2 text-muted-foreground"
>
<span>批量应用到同类部位</span>
<input v-model="equipBatchApply" type="checkbox" class="size-4" />
</label>
<div
v-for="part in parts"
:key="part"
class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[7rem_1fr_6rem_6rem_6rem_auto] md:items-center md:p-3"
class="space-y-3 rounded-md border p-2 md:p-3"
>
<div class="grid grid-cols-[5rem_minmax(0,1fr)_auto] items-center gap-2">
<div class="font-medium">{{ part }}</div>
<div class="truncate text-muted-foreground">
{{ equipNameById.get(equipCfg(part).id) || "未选择装备(可在装备页选择)" }}
</div>
<Button size="sm" variant="ghost" @click="resetEquipPart(part)">重置</Button>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
<label v-if="canUpgradeEquip(part)" class="space-y-1">
<span class="text-muted-foreground">强化类型</span>
<select
class="h-8 w-full rounded-md border border-input bg-background px-2"
:value="String(equipCfg(part).reinforceType)"
@change="
(event) =>
setEquipNumberField(
part,
'reinforceType',
(event.target as HTMLSelectElement).value,
)
"
>
<option value="0">强化</option>
<option value="1">增幅</option>
</select>
</label>
<label v-if="canUpgradeEquip(part)" class="space-y-1">
<span class="text-muted-foreground">强化/增幅</span>
<Input
:model-value="String(equipCfg(part).reinforce)"
type="number"
min="0"
max="30"
class="h-8 text-xs"
placeholder="强化"
@update:model-value="(value) => setNumberField(part, 'reinforce', String(value))"
@update:model-value="
(value) => setEquipNumberField(part, 'reinforce', String(value))
"
/>
</label>
<label class="space-y-1">
<span class="text-muted-foreground">锻造</span>
<Input
:model-value="String(equipCfg(part).refine)"
type="number"
min="0"
max="8"
class="h-8 text-xs"
placeholder="锻造"
@update:model-value="(value) => setNumberField(part, 'refine', String(value))"
@update:model-value="
(value) => setEquipNumberField(part, 'refine', String(value))
"
/>
</label>
<label v-if="canUpgradeEquip(part)" class="space-y-1">
<span class="text-muted-foreground">调适</span>
<Input
:model-value="String(equipCfg(part).adaptation)"
type="number"
min="0"
max="3"
class="h-8 text-xs"
placeholder="品级"
@update:model-value="(value) => setNumberField(part, 'adaptation', String(value))"
@update:model-value="
(value) => setEquipNumberField(part, 'adaptation', String(value))
"
/>
<Button size="sm" variant="ghost" @click="resetEquipPart(part)">重置</Button>
</label>
<label v-if="equipEnchantOptions(part).length" class="space-y-1 sm:col-span-2">
<span class="text-muted-foreground">附魔</span>
<select
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2"
:value="equipCfg(part).enchant"
@change="
(event) => setEquipEnchant(part, (event.target as HTMLSelectElement).value)
"
>
<option value="">无</option>
<option
v-for="item in equipEnchantOptions(part)"
:key="item.id"
:value="String(item.id)"
>
{{ item.detail }}
</option>
</select>
</label>
<label v-if="canSetSecret(part)" class="space-y-1">
<span class="text-muted-foreground">秘宝精度</span>
<Input
:model-value="String(equipCfg(part).precision)"
type="number"
min="0"
max="100"
class="h-8 text-xs"
@update:model-value="
(value) => setEquipNumberField(part, 'precision', String(value))
"
/>
</label>
</div>
</div>
</CardContent>
</Card>
@@ -532,6 +805,12 @@ const resetAllForge = () => {
<CardDescription>编辑誓约槽位适配值。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<label
class="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 p-2 text-muted-foreground"
>
<span>批量应用到全部誓约槽位</span>
<input v-model="oathBatchApply" type="checkbox" class="size-4" />
</label>
<div
v-for="part in oathParts"
:key="part"
@@ -779,6 +1058,104 @@ const resetAllForge = () => {
</Card>
</div>
<div
v-if="forgeViewTab === 'config' && moduleMode === 'jade'"
class="grid grid-cols-1 gap-4"
>
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Sparkles class="size-4" />
辟邪玉
</CardTitle>
<CardDescription>从旧版 Forge 迁移的 4 个辟邪玉词条与数值配置。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div
v-for="key in jadeKeys"
:key="key"
class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[6rem_1fr_12rem] md:items-center md:p-3"
>
<div class="font-medium">槽位 {{ Number(key) + 1 }}</div>
<select
class="h-8 min-w-0 rounded-md border border-input bg-background px-2"
:value="String(ensureJade(key).id)"
@change="(event) => setJadeId(key, (event.target as HTMLSelectElement).value)"
>
<option value="-1">无</option>
<option v-for="item in infoStore.jades" :key="item.id" :value="String(item.id)">
{{ item.name }}
</option>
</select>
<select
class="h-8 min-w-0 rounded-md border border-input bg-background px-2"
:value="String(ensureJade(key).value)"
:disabled="ensureJade(key).id < 0"
@change="(event) => setJadeValue(key, (event.target as HTMLSelectElement).value)"
>
<option value="0">无</option>
<option
v-for="item in jadeValueOptions(ensureJade(key).id)"
:key="item.id"
:value="String(item.id)"
>
{{ item.value }}
</option>
</select>
</div>
<div class="flex justify-end">
<Button size="sm" variant="outline" @click="resetJades">重置辟邪玉</Button>
</div>
</CardContent>
</Card>
</div>
<div
v-if="forgeViewTab === 'config' && moduleMode === 'sundry'"
class="grid grid-cols-1 gap-4"
>
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<ShieldCheck class="size-4" />
其它系统
</CardTitle>
<CardDescription>迷雾、冒险团、结婚系统、晶体契约、收集箱等配置。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="field in sundryFields"
:key="field.key"
class="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[6rem_1fr] sm:items-center sm:p-3"
>
<div class="font-medium">{{ field.label }}</div>
<select
class="h-8 min-w-0 rounded-md border border-input bg-background px-2"
:value="sundryValue(field.key, field.defaultValue)"
@change="
(event) => setSundryValue(field.key, (event.target as HTMLSelectElement).value)
"
>
<option
v-for="item in sundryOptions(field.key)"
:key="`${field.key}-${item.value}`"
:value="String(item.value)"
>
{{ item.name }}
</option>
</select>
</div>
</div>
<div class="flex justify-end">
<Button size="sm" variant="outline" @click="resetSundry">重置其它系统</Button>
</div>
</CardContent>
</Card>
</div>
<div
v-if="forgeViewTab === 'config' && moduleMode === 'global'"
class="grid grid-cols-1 gap-4 lg:grid-cols-2"
@@ -815,6 +1192,8 @@ const resetAllForge = () => {
<div>誓约槽位: {{ summary.oathCount }}</div>
<div>已配置时装: {{ summary.avatarCount }}</div>
<div>已配置徽章条目: {{ summary.emblemCount }}</div>
<div>已配置辟邪玉: {{ summary.jadeCount }}</div>
<div>其它系统项目: {{ summary.sundryCount }}</div>
</CardContent>
</Card>
</div>
+230 -2
View File
@@ -1,13 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { PanelRight } from "lucide-vue-next";
import { FileInput, PanelRight, WandSparkles } from "lucide-vue-next";
import api from "@/api";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import Tabs from "@/components/ui/tabs/Tabs.vue";
import TabsList from "@/components/ui/tabs/TabsList.vue";
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
import { useConfigStore, useInfoStore } from "@/stores";
import { useDnfHelperStore } from "@/stores/dnfhelper";
import type { IConfigEmblem, IConfigEquip, IConfigOath } from "@/stores/config";
import type { IHelperInfo } from "@/api/info/type";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
const props = defineProps<{
alter: string;
@@ -17,14 +31,50 @@ const route = useRoute();
const router = useRouter();
const infoStore = useInfoStore();
const configStore = useConfigStore();
const helperStore = useDnfHelperStore();
const loading = ref(false);
const loadError = ref("");
const isExecuting = ref(false);
const isMobileLayout = ref(false);
const isResultSheetOpen = ref(false);
const helperImportOpen = ref(false);
const skillImportOpen = ref(false);
const helperRoleImportOpen = ref(false);
const helperUid = ref("");
const skillCode = ref("");
const helperImportLoading = ref(false);
const skillImportLoading = ref(false);
const importStatus = ref("");
const importError = ref("");
const helperRole = ref<IHelperInfo["roleList"][number]>();
let mediaQuery: MediaQueryList | null = null;
type AvatarConfig = Record<
string,
{
id: string;
enchant: string;
emblem_0: string;
emblem_1: string;
option: string;
}
>;
interface HelperConfigPayload {
equips?: Record<string, IConfigEquip>;
avatar?: AvatarConfig;
avatars?: AvatarConfig;
avatarLink?: string;
emblems?: Record<string, IConfigEmblem>;
fogOathLv?: unknown;
oaths?: Record<string, IConfigOath>;
}
interface SkillImportPayload {
skills?: typeof configStore.config.skills;
}
const updateMobileLayout = () => {
isMobileLayout.value = mediaQuery?.matches ?? false;
if (!isMobileLayout.value) {
@@ -55,6 +105,7 @@ const loadCharacter = async () => {
loadError.value = "";
try {
await infoStore.createCharacter(props.alter);
prepareHelperRoleImport();
} catch (error) {
loadError.value = error instanceof Error ? error.message : "角色数据加载失败";
} finally {
@@ -66,6 +117,103 @@ const switchTab = (tab: (typeof tabs)[number]["key"]) => {
router.push(`/dcalc/character/${props.alter}/${tab}`);
};
const resetImportMessage = () => {
importStatus.value = "";
importError.value = "";
};
const applyHelperConfig = (payload: HelperConfigPayload) => {
if (payload.equips) configStore.config.equips = payload.equips;
if (payload.avatars || payload.avatar) {
configStore.config.avatar = payload.avatars ?? payload.avatar ?? configStore.config.avatar;
}
if (payload.avatarLink !== undefined) configStore.config.avatarLink = payload.avatarLink;
if (payload.emblems) configStore.config.emblems = payload.emblems;
if (payload.fogOathLv !== undefined) configStore.config.sundry.fog = payload.fogOathLv;
if (payload.oaths) configStore.config.oaths = payload.oaths;
configStore.pushChangeSource("Import", "助手打造配置已导入");
};
const importHelperConfig = async () => {
resetImportMessage();
const uid = helperUid.value.trim();
if (!uid) {
importError.value = "请输入助手社区 ID。";
return;
}
helperImportLoading.value = true;
try {
const payload = (await api.getConfigFromHelper(uid)) as HelperConfigPayload;
applyHelperConfig(payload);
helperImportOpen.value = false;
helperUid.value = "";
importStatus.value = "打造导入成功。";
} catch (error) {
importError.value = error instanceof Error ? error.message : "打造导入失败。";
} finally {
helperImportLoading.value = false;
}
};
const importHelperRoleConfig = async () => {
resetImportMessage();
const uid = helperStore.helperInfo?.id;
const roleId = helperRole.value?.roleId;
if (!uid || !roleId) {
importError.value = "未找到可导入的助手角色。";
return;
}
helperImportLoading.value = true;
try {
const payload = (await api.getConfigFromHelperAlter(uid, roleId)) as HelperConfigPayload;
applyHelperConfig(payload);
helperRoleImportOpen.value = false;
importStatus.value = "助手角色打造导入成功。";
} catch (error) {
importError.value = error instanceof Error ? error.message : "助手角色打造导入失败。";
} finally {
helperImportLoading.value = false;
}
};
const importSkillConfig = async () => {
resetImportMessage();
const code = skillCode.value.trim();
if (!code) {
importError.value = "请输入技能加点代码。";
return;
}
skillImportLoading.value = true;
try {
const payload = (await api.getSkillFromCode(code)) as SkillImportPayload;
if (payload.skills) {
configStore.config.skills = payload.skills;
configStore.pushChangeSource("Import", "技能加点代码已导入");
}
skillImportOpen.value = false;
skillCode.value = "";
importStatus.value = "技能导入成功。";
} catch (error) {
importError.value = error instanceof Error ? error.message : "技能导入失败。";
} finally {
skillImportLoading.value = false;
}
};
const prepareHelperRoleImport = () => {
const roleId = route.query.roleId;
if (typeof roleId !== "string") return;
const target = helperStore.helperInfo?.roleList.find((item) => item.roleId === roleId);
if (!target) return;
helperRole.value = target;
helperRoleImportOpen.value = true;
};
onMounted(() => {
loadCharacter();
window.addEventListener("beforeunload", configStore.saveConfig);
@@ -81,6 +229,13 @@ watch(
},
);
watch(
() => route.query.roleId,
() => {
prepareHelperRoleImport();
},
);
watch(showGlobalResultPanel, (show) => {
if (!show) {
isResultSheetOpen.value = false;
@@ -125,6 +280,79 @@ onUnmounted(() => {
</TabsList>
</Tabs>
<div class="flex flex-wrap items-center gap-2">
<Sheet v-model:open="helperImportOpen">
<SheetTrigger as-child>
<Button size="sm" variant="outline" @click="resetImportMessage">
<FileInput class="size-4" />
打造导入
</Button>
</SheetTrigger>
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>打造导入</SheetTitle>
<SheetDescription>助手社区 ID</SheetDescription>
</SheetHeader>
<div class="grid gap-3 px-4">
<Input v-model="helperUid" placeholder="社区 ID" @keyup.enter="importHelperConfig" />
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
</div>
<SheetFooter>
<Button :disabled="helperImportLoading" @click="importHelperConfig">
{{ helperImportLoading ? "导入中..." : "导入" }}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<Sheet v-model:open="skillImportOpen">
<SheetTrigger as-child>
<Button size="sm" variant="outline" @click="resetImportMessage">
<WandSparkles class="size-4" />
技能导入
</Button>
</SheetTrigger>
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>技能导入</SheetTitle>
<SheetDescription>技能加点代码</SheetDescription>
</SheetHeader>
<div class="grid gap-3 px-4">
<Textarea v-model="skillCode" class="min-h-28" placeholder="技能代码" />
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
</div>
<SheetFooter>
<Button :disabled="skillImportLoading" @click="importSkillConfig">
{{ skillImportLoading ? "导入中..." : "导入" }}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<p v-if="importStatus" class="text-xs text-muted-foreground">{{ importStatus }}</p>
</div>
<Sheet v-model:open="helperRoleImportOpen">
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>助手角色导入</SheetTitle>
<SheetDescription>{{ helperRole?.roleName || "助手角色" }}</SheetDescription>
</SheetHeader>
<div class="grid gap-2 px-4 text-sm">
<div class="rounded-md border p-3">
<div class="font-medium">{{ helperRole?.roleJob }}</div>
<div class="text-muted-foreground">名望 {{ helperRole?.fame ?? "-" }}</div>
</div>
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
</div>
<SheetFooter>
<Button :disabled="helperImportLoading" @click="importHelperRoleConfig">
{{ helperImportLoading ? "导入中..." : "导入" }}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<div
:class="[
'grid min-h-0 flex-1 grid-cols-1 gap-4',
@@ -1,21 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { IResultSkill } from "@/api/calc/type";
import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import Tabs from "@/components/ui/tabs/Tabs.vue";
import TabsList from "@/components/ui/tabs/TabsList.vue";
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
import EquList from "@/components/dnf/Equipment/List/index.vue";
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
import { useConfigStore, useInfoStore } from "@/stores";
const configStore = useConfigStore();
const infoStore = useInfoStore();
@@ -26,8 +18,6 @@ const equippedOathPart = ref("0");
const resultState = computed(() => configStore.result.state);
const isLoading = computed(() => configStore.result.isLoading);
const isBufferClass = computed(() => !!resultState.value?.buffer);
const standard = computed(() => infoStore.standard);
const resultError = computed(() => {
const error = configStore.result.error;
@@ -35,57 +25,26 @@ const resultError = computed(() => {
return error instanceof Error ? error.message : String(error);
});
const retryCalc = async () => {
await configStore.result.execute();
};
const setStandard = () => {
if (!resultState.value?.uuid) return;
infoStore.setStandard(resultState.value, configStore.skillCountConfig);
};
const clearStandard = () => {
infoStore.setStandard(undefined);
};
const getSkillCountEntry = (skill: IResultSkill) => {
const name = skill.name;
const mode = skill.mode;
const existed = configStore.skillCountConfig.find(
(item) => item.name === name && item.mode === mode,
);
if (existed) return existed;
const created = { name, mode, count: 1 };
configStore.skillCountConfig.push(created);
return created;
};
const getSkillCount = (skill: IResultSkill) =>
Math.max(0, Number(getSkillCountEntry(skill).count) || 0);
const getStandardSkill = (skill: IResultSkill) =>
standard.value?.skills.find((item) => item.name === skill.name && item.mode === skill.mode);
const getStandardSkillCount = (skill: IResultSkill) => {
const count = standard.value?.skillCount.find(
(item) => item.name === skill.name && item.mode === skill.mode,
)?.count;
return Number(count ?? 0);
};
const skills = computed(() => resultState.value?.skills ?? []);
const totalWeightedDamage = computed(() =>
skills.value.reduce((sum, item) => sum + item.damage * getSkillCount(item), 0),
skills.value.reduce((sum, item) => {
const count = configStore.skillCountConfig.find(
(config) => config.name === item.name && config.mode === item.mode,
)?.count;
return sum + item.damage * Math.max(0, Number(count ?? 1) || 0);
}, 0),
);
const totalStandardDamage = computed(() => {
if (!standard.value) return 0;
return skills.value.reduce((sum, item) => {
const base = getStandardSkill(item);
if (!base) return sum;
return sum + base.damage * getStandardSkillCount(item);
}, 0);
const hasVisibleResult = computed(
() => (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0,
);
const statusText = computed(() => {
if (isLoading.value) return "计算中...";
if (resultError.value) return resultError.value;
if (!hasVisibleResult.value) return "暂无结果";
return "";
});
const formatNumber = (value: number) => {
@@ -93,96 +52,38 @@ const formatNumber = (value: number) => {
if (Math.abs(value) >= 100000000) return `${(value / 100000000).toFixed(2)}e`;
return value.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
};
const totalDiffLabel = computed(() => {
if (!totalStandardDamage.value) return formatNumber(totalWeightedDamage.value);
const diff =
((totalWeightedDamage.value - totalStandardDamage.value) / totalStandardDamage.value) * 100;
return `${diff >= 0 ? "+" : ""}${diff.toFixed(2)}%`;
});
const totalDiffClass = computed(() => {
if (!totalStandardDamage.value) return "text-foreground";
return totalWeightedDamage.value >= totalStandardDamage.value
? "text-emerald-600"
: "text-red-600";
});
const hasVisibleResult = computed(
() => (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0,
);
const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
const clearChangeSources = () => {
configStore.clearChangeSource();
};
const defaultOpenSections = ["overview", "equipped"];
const statusText = computed(() => {
if (isLoading.value) return "计算中...";
if (resultError.value) return resultError.value;
if (!hasVisibleResult.value) return "暂无结果,请先编辑配置。";
if (isBufferClass.value) return "请前往计算详情查看。";
return "";
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<Card>
<CardContent class="py-2">
<Accordion type="multiple" :default-value="defaultOpenSections" class="w-full">
<AccordionItem value="overview">
<AccordionTrigger>结果概览</AccordionTrigger>
<AccordionContent>
<div class="space-y-3 px-1">
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" class="h-8 px-2.5" @click="setStandard"
>设为基准</Button
>
<Button size="sm" variant="outline" class="h-8 px-2.5" @click="clearStandard"
>清空基准</Button
>
</div>
<Card class="shrink-0">
<CardContent class="p-3">
<div class="rounded-md border border-border/60 px-3 py-2">
<div class="text-xs text-muted-foreground">总伤害</div>
<div
v-if="isLoading || resultError || !hasVisibleResult || isBufferClass"
class="rounded-md border border-border/60 px-3 py-2 text-center text-sm"
v-if="statusText"
class="mt-1 text-sm"
:class="resultError ? 'text-destructive' : 'text-muted-foreground'"
>
{{ statusText }}
</div>
<div
v-else
class="flex items-center justify-between rounded-md border border-border/60 px-3 py-2"
>
<span class="text-sm text-muted-foreground">合计伤害</span>
<span class="text-sm font-semibold" :class="totalDiffClass">{{
totalDiffLabel
}}</span>
</div>
<div v-if="resultError" class="flex justify-center">
<Button size="sm" variant="outline" @click="retryCalc">重试计算</Button>
<div v-else class="mt-1 truncate text-xl font-semibold tabular-nums">
{{ formatNumber(totalWeightedDamage) }}
</div>
</div>
</AccordionContent>
</AccordionItem>
</CardContent>
</Card>
<AccordionItem value="equipped">
<AccordionTrigger>穿戴信息</AccordionTrigger>
<AccordionContent>
<div class="space-y-2 px-1 text-xs">
<Tabs :model-value="displayTab">
<Card class="min-h-0 flex-1">
<CardContent class="flex h-full min-h-0 flex-col gap-2 p-3 text-xs">
<Tabs :model-value="displayTab" class="shrink-0">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="equ" @click="displayTab = 'equ'">装备</TabsTrigger>
<TabsTrigger value="oath" @click="displayTab = 'oath'">誓约</TabsTrigger>
</TabsList>
</Tabs>
<ScrollArea class="max-h-56 rounded-md border p-2">
<ScrollArea class="min-h-0 flex-1 rounded-md border p-2">
<div v-if="displayTab === 'equ'">
<EquList
v-model:part="equippedPart"
@@ -194,51 +95,11 @@ const statusText = computed(() => {
<div v-else>
<OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" />
</div>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="changes">
<AccordionTrigger>配置变更来源</AccordionTrigger>
<AccordionContent>
<div class="space-y-2 px-1">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>最近触发计算的操作记录</span>
<Button size="sm" variant="ghost" class="h-7 px-2" @click="clearChangeSources"
>清空</Button
>
</div>
<div v-if="changeSources.length === 0" class="text-xs text-muted-foreground">
暂无变更来源记录。
</div>
<ScrollArea v-else class="max-h-40 rounded-md border border-border/60 p-2">
<div class="space-y-1">
<div
v-for="item in changeSources"
:key="item.id"
class="rounded-md border border-border/60 bg-muted/20 px-2 py-1 text-xs"
>
<div class="flex items-center justify-between gap-2">
<span class="font-medium">{{ item.source }}</span>
<span class="text-muted-foreground">{{ item.time }}</span>
</div>
<div class="text-muted-foreground">{{ item.detail }}</div>
</div>
</div>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</div>
@@ -28,7 +28,7 @@ const noticeType = ref<"error" | "info">("info");
const openUpPickerSlot = ref<number | null>(null);
const rightPanelTab = ref<"detail" | "result">("result");
const leftPanelWidthClass = computed(() =>
rightPanelTab.value === "result" ? "xl:w-[460px]" : "xl:w-[550px]",
rightPanelTab.value === "result" ? "xl:w-[360px]" : "xl:w-[550px]",
);
/* ─── Constants ─── */
@@ -193,6 +193,19 @@ const upOptionsForSlot = (slotIdx: number) => {
.sort((a, b) => a.learnLv - b.learnLv);
};
const firstAwakeSkill = computed(() =>
infoStore.skills.find((item) => item.learnLv === 50 && item.type === "active" && item.bind),
);
const secondAwakeSkill = computed(() =>
infoStore.skills.find((item) => item.learnLv === 85 && item.type === "active" && item.bind),
);
const canBindAwake = computed(() => !!firstAwakeSkill.value || !!secondAwakeSkill.value);
const setBindAwake = (level: 50 | 85) => {
configStore.config.bindAwake = level;
track(`觉醒绑定切换为 ${level === 50 ? "一觉" : "二觉"}`);
};
const setUPSlotSkill = (slotIdx: number, nextId: string) => {
const slot = upSlots.value[slotIdx];
if (!slot) return;
@@ -337,6 +350,33 @@ const resetAllSkills = () => {
: '',
]"
/>
<div
v-if="skill.learnLv === 100 && skill.bind && canBindAwake"
class="awake-bind-panel"
@click.stop
>
<button
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
<img :src="getImageURL(firstAwakeSkill.icon)" :alt="firstAwakeSkill.name" />
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
<img
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
</button>
</div>
<div
v-if="activeSkillId === skill.id.toString()"
class="action-row"
@@ -399,6 +439,36 @@ const resetAllSkills = () => {
: '',
]"
/>
<div
v-if="cell.learnLv === 100 && cell.bind && canBindAwake"
class="awake-bind-panel"
@click.stop
>
<button
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
<img
:src="getImageURL(firstAwakeSkill.icon)"
:alt="firstAwakeSkill.name"
/>
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
<img
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
</button>
</div>
<div
v-if="activeSkillId === cell.id.toString()"
class="action-row"
@@ -844,6 +914,41 @@ const resetAllSkills = () => {
border-radius: 2px;
}
.awake-bind-panel {
position: absolute;
top: 10px;
left: 50%;
z-index: 8;
display: flex;
gap: 2px;
transform: translateX(-50%);
border-radius: 4px;
background: color-mix(in oklab, var(--background) 88%, transparent);
padding: 2px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--border) 80%, transparent);
}
.awake-bind-btn {
width: 22px;
height: 22px;
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
border-radius: 3px;
overflow: hidden;
opacity: 0.62;
}
.awake-bind-btn.active {
border-color: var(--primary);
opacity: 1;
box-shadow: 0 0 0 1px var(--primary);
}
.awake-bind-btn img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 4 action buttons at the bottom */
.action-row {
position: absolute;
+10 -1
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import api from "@/api";
import { Button } from "@/components/ui/button";
// import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -26,6 +28,7 @@ const props = defineProps<{
const { list } = toRefs(props);
const helperStore = useDnfHelperStore();
const router = useRouter();
const sortedList = computed(() => {
return [...list.value].sort((left, right) => right.fame - left.fame);
@@ -34,6 +37,12 @@ const sortedList = computed(() => {
const formatFame = (fame: number) => {
return new Intl.NumberFormat("zh-CN").format(fame);
};
const importRole = async (item: (typeof props.list)[number]) => {
const alter = await api.alterByName(item.roleJob);
if (!alter?.value) return;
await router.push(`/dcalc/character/${alter.value}/equipment?roleId=${item.roleId}`);
};
</script>
<template>
@@ -64,7 +73,7 @@ const formatFame = (fame: number) => {
<CardHeader class="pb-3">
<CardTitle class="text-base flex">
{{ item.roleName }}
<Button class="ml-auto" size="sm" variant="outline" @click="$router.push('/dcalc/home')"
<Button class="ml-auto" size="sm" variant="outline" @click="importRole(item)"
>导入</Button
>
</CardTitle>
+2 -2
View File
@@ -102,7 +102,7 @@ onMounted(() => {
:disabled="!child.open"
@click="openAlter(child)"
>
<item class="flex items-center gap-2">
<div class="flex items-center gap-2">
<img
:src="
getImageURL(`characters/${adventure.name}/${child.name}/material/Seicing_3.png`)
@@ -111,7 +111,7 @@ onMounted(() => {
class="size-8 rounded-sm object-cover"
/>
<span class="truncate">{{ child.title }}</span>
</item>
</div>
<ArrowRight class="size-3" />
</Button>
</CardContent>