Files
ChronicleOfTheImmortalClan/src/renderer/ui/components/MemberModal.tsx
T
Hermes Agent 7191a2bb55 v0.1.11: 全源码审计 + 缺陷歼灭 + UI/UX 打磨
【引擎审计修复】
- P0:大比 once(原整年刷 12 场、奖励通胀);flag 年份键 3 年剪枝(长线膨胀)
- P1:飞升离世累加 feishengCount(飞升之资称号可达);圣者护族接消费(原死代码);
  大比/传薪挂能力卡门控;pending 抢渡劫时不再重设
- P2:version 硬编码统一 0.1.11;yearaxis 飞升归类;MonumentList 挂载

【UI 审计修复】
- P0:已故成员卡显示「已殁」(原秀活动 status);空队迎战禁用+提示(原静默失效)
- P1:指婚候选去截断(滚动列表);终局出口(回卷/主菜单按钮);saveNow 反馈;
  事件来临写入 feed(k-event 使用);遣队「最强 N 人/清空」一键;寻衅确认+联姻原因提示;
  未建建筑视觉 uc 类
- P2:speed 死代码;Modal 分层 z-index;FamilyPanel 在世/先人分隔

【UX 三件套】
- 警讯徽章(瓶颈/重伤/在外/媒缘/孤嗣之忧)顶栏一屏秒答「我该干嘛」
- 首启引导任务条(四步目标 + 完成推进,可跳不强制)
- 顶栏丹药瘦身(4 资源);数字千/万化;瓶颈卡红框高亮

【测试】905→923(urgency/guide/format 三模块 + 审计修复回归 6 项)
金钟罩三档重固化(0.1.11),typecheck/build 通过
2026-08-23 11:30:16 +08:00

323 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useGameStore } from '../store'
import { Character, GameState } from '../../game/types/domain'
import { describeRealm, nextRealm } from '../../game/data/realms'
import { describeRoots, ROOT_GRADE_NAMES } from '../../game/data/elements'
import { TRAITS } from '../../game/data/traits'
import { TECHNIQUES } from '../../game/data/techniques'
import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
import { combatPowerOf } from '../../game/engine/systems/combat'
import { lifespanOf } from '../../game/engine/systems/lifecycle'
import { POSTS, POST_ORDER } from '../../game/data/posts'
import { buildBiography } from '../../game/core/biography'
function aspName(c: Character): string {
const map: Record<string, string> = {
dao: '志向·求道', zhen: '志向·镇族', cheng: '志向·承宗', shang: '志向·商略', bing: '志向·兵略', geng: '志向·耕读', yun: '志向·云游'
}
return map[c.aspiration ?? ''] ?? '志向·未定'
}
function fitTag(c: Character): string | null {
const t = TECHNIQUES.find((x) => x.id === c.techniqueId)
if (!t) return null
return t.element === c.roots.primary ? `本命·${t.element}契合` : null
}
function deadish(c: Character): boolean {
return !c.alive
}
function gradeShort(g: number): string {
const map: Record<number, string> = { 0: '凡', 1: '黄', 2: '玄', 3: '地', 4: '天', 5: '仙' }
return map[g] ?? '?'
}
function pillCount(s: GameState, id: string): number {
return s.family.inventory[id] ?? 0
}
function equipableItems(member: Character, s: GameState): string[] {
const inv = Object.keys(ARTIFACT_POWER).filter((ai) => (s.family.inventory[ai] ?? 0) > 0)
if (member.equipment && !inv.includes(member.equipment)) inv.push(member.equipment)
return inv
}
export function MemberModal({ member }: { member: Character }) {
const world = useGameStore((s) => s.world)
const setSelected = useGameStore((s) => s.setSelected)
const bump = useGameStore((s) => s.bump)
if (!world) return null
const w = world
const s = w.state
const next = nextRealm(member.realm)
const nextName = next ? describeRealm(next) : '已至巅峰'
const tech = TECHNIQUES.find((t) => t.id === member.techniqueId)
const power = member.alive ? combatPowerOf(w, member) : 0
const lifespan = lifespanOf(w, member)
return (
<div className="modal-outer" onClick={() => setSelected(undefined)}>
<div className="modal member-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title" style={{ fontSize: '1.4rem' }}>
{member.name}
{!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · </span>}
</div>
<div className="modal-cat">
{member.generation} · {member.gender === 'male' ? '男' : '女'} · {w.ageOf(member)} · 寿数约{lifespan} · {describeRoots(member.roots)}
</div>
<div className="mm-grid">
<div className="mm-line"><span>境界进度</span><b>{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%</b></div>
<div className="mm-line"><span>下一关</span><b className={member.realmProgress >= 100 ? 'gold' : ''}>{nextName}</b></div>
<div className="mm-line"><span>战力</span><b>{Math.round(power)}</b></div>
<div className="mm-line"><span>状态</span><b>
{member.state === 'meditation'
? '闭关'
: member.state === 'expedition'
? '外出'
: member.state === 'wounded'
? '养伤'
: member.state === 'apprentice'
? '寄读'
: '无事'}
</b></div>
<div className="mm-line"><span>气血</span><b>{Math.round(member.health)}</b></div>
<div className="mm-line"><span>功法</span><b>{tech ? `${tech.name}(${gradeShort(tech.grade)}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
<div className="mm-line"><span>法宝</span><b>{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}</b></div>
<div className="mm-line"><span>姻亲</span><b>{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}</b></div>
</div>
<div className="bar bar-red" style={{ marginTop: 10 }} title="气血">
<div style={{ width: `${member.health}%` }} />
</div>
<div className="mm-sec">
<div className="mm-grid">
<div className="mm-line"><span>悟性</span><b>{member.perception}/10</b></div>
<div className="mm-line"><span>根骨</span><b>{member.physique}/10</b></div>
<div className="mm-line"><span>心性</span><b>{member.mind}/10</b></div>
<div className="mm-line"><span>魅力</span><b>{member.charm}/10</b></div>
<div className="mm-line"><span>气运</span><b>{member.fortune}/12</b></div>
<div className="mm-line"><span>品性</span><b>{member.fortune ? (member.fortune > 7 ? '俊逸' : '端正') : '—'}</b></div>
</div>
</div>
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 4 }}>秉性</div>
{member.traits.map((t) => (
<span key={t} className="tag" style={{ marginRight: 4 }}>
{TRAITS[t]?.name ?? t}
</span>
))}
{member.traits.length === 0 && <span className="dim2">平淡无奇</span>}
</div>
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}>起居</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{member.alive && member.state !== 'expedition' && (
<button
className="btn btn-sm"
onClick={() => {
w.setMeditation(member.id, member.state !== 'meditation')
bump()
}}
>
{member.state === 'meditation' ? '出关' : '闭关'}
</button>
)}
{member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && (
<button
className="btn btn-sm btn-primary"
onClick={() => {
w.assistedBreakthrough(member.id)
bump()
}}
>
冲击{nextName}
</button>
)}
{member.alive && (
<>
<button
className="btn btn-sm"
disabled={pillCount(s, 'pill-qiyuan') <= 0}
title={`聚气丹×${pillCount(s, 'pill-qiyuan')}`}
onClick={() => {
w.takePill(member.id, 'pill-qiyuan')
bump()
}}
>
服聚气丹({pillCount(s, 'pill-qiyuan')})
</button>
{member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
disabled={pillCount(s, 'pill-ningyuan') <= 0}
title={`凝元丹×${pillCount(s, 'pill-ningyuan')}`}
onClick={() => {
w.takePill(member.id, 'pill-ningyuan')
bump()
}}
>
服凝元丹({pillCount(s, 'pill-ningyuan')})
</button>
)}
{member.realmProgress >= 70 && member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
disabled={pillCount(s, 'pill-pojing') <= 0}
title={`破境丹×${pillCount(s, 'pill-pojing')}`}
onClick={() => {
w.takePill(member.id, 'pill-pojing')
bump()
}}
>
服破境丹({pillCount(s, 'pill-pojing')})
</button>
)}
</>
)}
</div>
</div>
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}>传功(藏书阁所录)</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{s.family.techniques.map((tid) => (
<button
key={tid}
className="btn btn-sm"
style={{ borderColor: member.techniqueId === tid ? 'var(--gold)' : 'var(--line)' }}
onClick={() => {
w.giveTechnique(member.id, tid)
bump()
}}
>
{TECHNIQUES.find((t) => t.id === tid)?.name ?? tid}
</button>
))}
{s.family.techniques.length === 0 && <span className="dim2">无典籍存库</span>}
</div>
</div>
)}
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}>法宝装备(府库所列)</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{equipableItems(member, s).length === 0 && <span className="dim2">府库暂无存货(可往坊市购得或于秘境寻获)</span>}
{equipableItems(member, s).map((ai) => (
<button
key={ai}
className="btn btn-sm"
style={{
borderColor: member.equipment === ai ? 'var(--vermilion)' : undefined,
color: member.equipment === ai ? 'var(--vermilion-bright)' : undefined
}}
onClick={() => {
w.equip(member.id, ai)
bump()
}}
>
<span className="s-icon" style={{ marginRight: 4 }}>{ITEMS[ai].icon}</span>
{ITEMS[ai].name}(+{Math.round((ARTIFACT_POWER[ai] ?? 0) * 100)}%){member.equipment === ai ? ' · 已佩' : ''}
</button>
))}
</div>
</div>
)}
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}>职事(族中分工)</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
className={`btn btn-sm ${!member.post ? 'btn-primary' : ''}`}
onClick={() => {
w.assignPost(member.id, undefined)
bump()
}}
>
闲居
</button>
{POST_ORDER.filter((pid) => pid !== 'head').map((pid) => {
const def = POSTS[pid]
const maxed = w.postCount(pid) >= def.max && member.post !== pid
return (
<button
key={pid}
className={`btn btn-sm ${member.post === pid ? 'btn-primary' : ''}`}
disabled={maxed && !!member.post}
style={maxed && !member.post ? { opacity: 0.45 } : undefined}
title={`${def.desc}${w.postCount(pid)}/${def.max} 在任`}
onClick={() => {
w.assignPost(member.id, pid)
bump()
}}
>
{def.name}{w.postCount(pid)}/{def.max}
</button>
)
})}
</div>
</div>
)}
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 4 }}>志向与契合</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="tag">{aspName(member)}</span>
{fitTag(member) && <span className="tag gold-t">{fitTag(member)}</span>}
</div>
</div>
)}
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}>小传(春秋原记录)</div>
<div className="bio-box">
{buildBiography(w.state, member.id).map((l, i) => (
<div key={i} className={deadish(member) && l.label === '墓志' ? 'gold' : ''}>
{l.label && <b className="bio-label">{l.label} · </b>}
{l.text}
</div>
))}
</div>
</div>
{member.alive && w.canMarry(member.id) && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
<div className="marriage-list">
{w.marriageCandidatesOf(member.id).map((cand) => (
<button
key={cand.id}
className="btn btn-sm"
onClick={() => {
w.marryTo(member.id, cand.id)
bump()
}}
>
{cand.name} · {describeRealm(cand.realm)} · {w.ageOf(cand)}
</button>
))}
{w.marriageCandidatesOf(member.id).length === 0 && <span className="dim2">暂无适配人选</span>}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
{member.children.length > 0 && (
<span className="dim2">
子女:{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
</span>
)}
<button className="btn" onClick={() => setSelected(undefined)}>阖上</button>
</div>
</div>
</div>
)
}