收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -1,23 +1,21 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import {
|
||||
formatCurrency,
|
||||
getInventoryItemValue,
|
||||
} from '../data/economy';
|
||||
import { formatCurrency, getInventoryItemValue } from '../data/economy';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import {
|
||||
buildInventoryItemDescription,
|
||||
getInventoryTagLabels,
|
||||
} from '../data/itemPresentation';
|
||||
import {
|
||||
getRarityLabel,
|
||||
} from '../data/npcInteractions';
|
||||
import { getRarityLabel } from '../data/npcInteractions';
|
||||
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
|
||||
import {
|
||||
GameState,
|
||||
@@ -25,7 +23,16 @@ import {
|
||||
RuntimeNpcGiftItemView,
|
||||
RuntimeNpcTradeItemView,
|
||||
} from '../types';
|
||||
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
getInventoryItemVisualSrc,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
@@ -43,6 +50,19 @@ function getItemVisualSrc(item: InventoryItem) {
|
||||
return getInventoryItemVisualSrc(item);
|
||||
}
|
||||
|
||||
function NpcModalEmptyState({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PlatformEmptyState
|
||||
surface="editorDark"
|
||||
size="compact"
|
||||
tone="soft"
|
||||
className="py-6"
|
||||
>
|
||||
{children}
|
||||
</PlatformEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTradeUseEffectText(
|
||||
effect: ReturnType<typeof resolveInventoryItemUseEffect> | null,
|
||||
) {
|
||||
@@ -71,30 +91,35 @@ function TradeItemRow({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformDarkOptionCard
|
||||
selected={selected}
|
||||
tone="emerald"
|
||||
padding="md"
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${
|
||||
selected
|
||||
? 'border-emerald-400/45 bg-emerald-500/10'
|
||||
: 'border-white/8 bg-black/20 hover:border-white/15'
|
||||
}`}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
|
||||
<PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">{item.name ?? item.id}</div>
|
||||
<div className="truncate text-sm font-medium text-white">
|
||||
{item.name ?? item.id}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{item.category} / {getRarityLabel(item.rarity)} / 单价 {unitPrice} {currencyName}
|
||||
{item.category} / {getRarityLabel(item.rarity)} / 单价 {unitPrice}{' '}
|
||||
{currencyName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] text-white">
|
||||
<PlatformPillBadge
|
||||
tone="darkNeutral"
|
||||
size="xxs"
|
||||
className="px-2 py-0.5 text-white"
|
||||
>
|
||||
x{item.quantity}
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</button>
|
||||
</PlatformDarkOptionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,9 +134,18 @@ function TradeQuantityStepper({
|
||||
}) {
|
||||
const safeMax = Math.max(1, maxQuantity);
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="flex items-center justify-between"
|
||||
data-testid="npc-trade-quantity-stepper"
|
||||
>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">数量</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
数量
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">最多 {safeMax}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -127,7 +161,9 @@ function TradeQuantityStepper({
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">{quantity}</div>
|
||||
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">
|
||||
{quantity}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(quantity + 1)}
|
||||
@@ -141,7 +177,7 @@ function TradeQuantityStepper({
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,51 +187,66 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
const currencyName = npcInteraction?.currencyName ?? '钱币';
|
||||
const tradeModal = npcUi.tradeModal;
|
||||
const tradeMode = tradeModal?.mode ?? 'buy';
|
||||
const tradeItemViews: RuntimeNpcTradeItemView[] = tradeMode === 'buy'
|
||||
? npcInteraction?.trade.buyItems ?? []
|
||||
: npcInteraction?.trade.sellItems ?? [];
|
||||
const tradeItemViews: RuntimeNpcTradeItemView[] =
|
||||
tradeMode === 'buy'
|
||||
? (npcInteraction?.trade.buyItems ?? [])
|
||||
: (npcInteraction?.trade.sellItems ?? []);
|
||||
const activeTradeView = tradeModal
|
||||
? tradeItemViews.find(view =>
|
||||
view.itemId === (tradeMode === 'buy'
|
||||
? tradeModal.selectedNpcItemId
|
||||
: tradeModal.selectedPlayerItemId),
|
||||
) ?? null
|
||||
? (tradeItemViews.find(
|
||||
(view) =>
|
||||
view.itemId ===
|
||||
(tradeMode === 'buy'
|
||||
? tradeModal.selectedNpcItemId
|
||||
: tradeModal.selectedPlayerItemId),
|
||||
) ?? null)
|
||||
: null;
|
||||
const activeTradeItem = activeTradeView?.item ?? null;
|
||||
const activeTradeUnitPrice = activeTradeView?.unitPrice ?? 0;
|
||||
const activeTradeMaxQuantity = activeTradeView?.maxQuantity ?? 0;
|
||||
const activeTradeQuantity = tradeModal
|
||||
? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity)))
|
||||
? Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
tradeModal.selectedQuantity,
|
||||
Math.max(1, activeTradeMaxQuantity),
|
||||
),
|
||||
)
|
||||
: 1;
|
||||
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
|
||||
const canConfirmTrade = Boolean(
|
||||
activeTradeView &&
|
||||
activeTradeView.canSubmit &&
|
||||
activeTradeQuantity >= 1,
|
||||
activeTradeView && activeTradeView.canSubmit && activeTradeQuantity >= 1,
|
||||
);
|
||||
const tradeItemList = tradeItemViews;
|
||||
const tradeDetailItem = tradeDetail
|
||||
? (tradeDetail.source === 'buy'
|
||||
? npcInteraction?.trade.buyItems ?? []
|
||||
: npcInteraction?.trade.sellItems ?? [])
|
||||
.find(view => view.itemId === tradeDetail.itemId)?.item ?? null
|
||||
? ((tradeDetail.source === 'buy'
|
||||
? (npcInteraction?.trade.buyItems ?? [])
|
||||
: (npcInteraction?.trade.sellItems ?? [])
|
||||
).find((view) => view.itemId === tradeDetail.itemId)?.item ?? null)
|
||||
: null;
|
||||
const tradeDetailView = tradeDetail
|
||||
? (tradeDetail.source === 'buy'
|
||||
? npcInteraction?.trade.buyItems ?? []
|
||||
: npcInteraction?.trade.sellItems ?? [])
|
||||
.find(view => view.itemId === tradeDetail.itemId) ?? null
|
||||
? ((tradeDetail.source === 'buy'
|
||||
? (npcInteraction?.trade.buyItems ?? [])
|
||||
: (npcInteraction?.trade.sellItems ?? [])
|
||||
).find((view) => view.itemId === tradeDetail.itemId) ?? null)
|
||||
: null;
|
||||
const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter
|
||||
? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter)
|
||||
const tradeDetailUseEffect =
|
||||
tradeDetailItem && gameState.playerCharacter
|
||||
? resolveInventoryItemUseEffect(
|
||||
tradeDetailItem,
|
||||
gameState.playerCharacter,
|
||||
)
|
||||
: null;
|
||||
const tradeDetailEquipSlot = tradeDetailItem
|
||||
? getEquipmentSlotFromItem(tradeDetailItem)
|
||||
: null;
|
||||
const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null;
|
||||
const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect);
|
||||
const giftCandidates: RuntimeNpcGiftItemView[] = npcUi.giftModal
|
||||
? npcInteraction?.gift.items ?? []
|
||||
? (npcInteraction?.gift.items ?? [])
|
||||
: [];
|
||||
const activeGiftView =
|
||||
giftCandidates.find(item => item.itemId === npcUi.giftModal?.selectedItemId) ?? null;
|
||||
giftCandidates.find(
|
||||
(item) => item.itemId === npcUi.giftModal?.selectedItemId,
|
||||
) ?? null;
|
||||
|
||||
const handleTradeItemClick = (view: RuntimeNpcTradeItemView) => {
|
||||
if (tradeMode === 'buy') {
|
||||
@@ -224,13 +275,15 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-4xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">交易</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / 你当前{currencyName}:{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
|
||||
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} /
|
||||
你当前{currencyName}:
|
||||
{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
|
||||
</div>
|
||||
</div>
|
||||
<PixelCloseButton
|
||||
@@ -242,70 +295,88 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
|
||||
{tradeModal.introText && (
|
||||
<div className="mb-3 whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||||
<PlatformStatusMessage
|
||||
tone="warning"
|
||||
surface="editorDark"
|
||||
size="xs"
|
||||
className="mb-3 whitespace-pre-line leading-relaxed"
|
||||
>
|
||||
{tradeModal.introText}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||||
<div className="min-h-0 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformDarkOptionCard
|
||||
selected={tradeMode === 'buy'}
|
||||
tone="emerald"
|
||||
onClick={() => npcUi.setTradeMode('buy')}
|
||||
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
||||
tradeMode === 'buy'
|
||||
? 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300'
|
||||
}`}
|
||||
className="text-center text-sm"
|
||||
>
|
||||
购买物品
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</PlatformDarkOptionCard>
|
||||
<PlatformDarkOptionCard
|
||||
selected={tradeMode === 'sell'}
|
||||
tone="sky"
|
||||
onClick={() => npcUi.setTradeMode('sell')}
|
||||
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
||||
tradeMode === 'sell'
|
||||
? 'border-sky-400/45 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300'
|
||||
}`}
|
||||
className="text-center text-sm"
|
||||
>
|
||||
出售物品
|
||||
</button>
|
||||
</PlatformDarkOptionCard>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="flex items-center justify-between text-xs text-zinc-400"
|
||||
data-testid="npc-trade-list-summary"
|
||||
>
|
||||
<span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span>
|
||||
<span>{tradeItemList.length} 件</span>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
|
||||
{tradeItemList.length > 0 ? tradeItemList.map(view => (
|
||||
<div key={view.itemId}>
|
||||
<TradeItemRow
|
||||
item={view.item}
|
||||
selected={tradeMode === 'buy'
|
||||
? tradeModal.selectedNpcItemId === view.itemId
|
||||
: tradeModal.selectedPlayerItemId === view.itemId}
|
||||
unitPrice={view.unitPrice}
|
||||
currencyName={currencyName}
|
||||
onClick={() => handleTradeItemClick(view)}
|
||||
/>
|
||||
{!view.canSubmit && view.reason && (
|
||||
<div className="mt-1 px-1 text-[10px] text-rose-300">
|
||||
{view.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||||
{tradeMode === 'buy' ? '对方暂时没有可出售的物品。' : '你当前没有可出售的物品。'}
|
||||
</div>
|
||||
{tradeItemList.length > 0 ? (
|
||||
tradeItemList.map((view) => (
|
||||
<div key={view.itemId}>
|
||||
<TradeItemRow
|
||||
item={view.item}
|
||||
selected={
|
||||
tradeMode === 'buy'
|
||||
? tradeModal.selectedNpcItemId === view.itemId
|
||||
: tradeModal.selectedPlayerItemId ===
|
||||
view.itemId
|
||||
}
|
||||
unitPrice={view.unitPrice}
|
||||
currencyName={currencyName}
|
||||
onClick={() => handleTradeItemClick(view)}
|
||||
/>
|
||||
{!view.canSubmit && view.reason && (
|
||||
<div className="mt-1 px-1 text-[10px] text-rose-300">
|
||||
{view.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<NpcModalEmptyState>
|
||||
{tradeMode === 'buy'
|
||||
? '对方暂时没有可出售的物品。'
|
||||
: '你当前没有可出售的物品。'}
|
||||
</NpcModalEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 p-3">
|
||||
<PlatformSubpanel
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
data-testid="npc-trade-detail-panel"
|
||||
>
|
||||
{activeTradeItem ? (
|
||||
<div className="space-y-3">
|
||||
<TradeQuantityStepper
|
||||
@@ -314,26 +385,38 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
onChange={npcUi.setTradeQuantity}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm text-zinc-200">
|
||||
<PlatformSubpanel
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="sm"
|
||||
className="text-sm text-zinc-200"
|
||||
data-testid="npc-trade-total-panel"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{tradeMode === 'buy' ? '购买总价' : '出售总价'}</span>
|
||||
<span>
|
||||
{tradeMode === 'buy' ? '购买总价' : '出售总价'}
|
||||
</span>
|
||||
<span className="font-semibold text-white">
|
||||
{formatCurrency(activeTradeTotalPrice, gameState.worldType)}
|
||||
{formatCurrency(
|
||||
activeTradeTotalPrice,
|
||||
gameState.worldType,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!activeTradeView?.canSubmit && activeTradeView?.reason && (
|
||||
<div className="mt-2 text-xs text-rose-300">
|
||||
{activeTradeView.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!activeTradeView?.canSubmit &&
|
||||
activeTradeView?.reason && (
|
||||
<div className="mt-2 text-xs text-rose-300">
|
||||
{activeTradeView.reason}
|
||||
</div>
|
||||
)}
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-8 text-center text-sm text-zinc-500">
|
||||
请选择一件物品,右侧会显示数量、价格与详情。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,7 +426,10 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
type="button"
|
||||
onClick={npcUi.closeTradeModal}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -352,7 +438,10 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
disabled={!canConfirmTrade}
|
||||
onClick={npcUi.confirmTrade}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canConfirmTrade ? 'text-white' : 'text-zinc-600'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
|
||||
</button>
|
||||
@@ -375,7 +464,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
@@ -394,12 +483,18 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
|
||||
<PixelIcon src={getItemVisualSrc(tradeDetailItem)} className="h-12 w-12" />
|
||||
<PixelIcon
|
||||
src={getItemVisualSrc(tradeDetailItem)}
|
||||
className="h-12 w-12"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-white">{tradeDetailItem.name}</div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
{tradeDetailItem.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{tradeDetailItem.category} / {getRarityLabel(tradeDetailItem.rarity)}
|
||||
{tradeDetailItem.category} /{' '}
|
||||
{getRarityLabel(tradeDetailItem.rarity)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-zinc-300">
|
||||
<div>库存: {tradeDetailItem.quantity}</div>
|
||||
@@ -414,25 +509,54 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed text-zinc-300">
|
||||
{buildInventoryItemDescription(tradeDetailItem, tradeDetailUseEffect)}
|
||||
{buildInventoryItemDescription(
|
||||
tradeDetailItem,
|
||||
tradeDetailUseEffect,
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
|
||||
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||||
{tradeDetailEquipSlot ? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}` : '不可装备'}
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||||
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'}
|
||||
</div>
|
||||
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||||
标签:{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') || '无'}
|
||||
</div>
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
>
|
||||
{tradeDetailEquipSlot
|
||||
? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}`
|
||||
: '不可装备'}
|
||||
</PlatformSubpanel>
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
>
|
||||
{isInventoryItemUsable(tradeDetailItem)
|
||||
? '可立即使用'
|
||||
: '不可即时使用'}
|
||||
</PlatformSubpanel>
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="col-span-2"
|
||||
>
|
||||
标签:
|
||||
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') ||
|
||||
'无'}
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
{tradeDetailEffectText && (
|
||||
<div className="rounded-lg border border-emerald-400/15 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100">
|
||||
<PlatformStatusMessage
|
||||
tone="success"
|
||||
surface="editorDark"
|
||||
size="xs"
|
||||
>
|
||||
使用效果:{tradeDetailEffectText}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
@@ -440,7 +564,10 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
type="button"
|
||||
onClick={() => setTradeDetail(null)}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
@@ -464,12 +591,14 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">赠送礼物</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{npcUi.giftModal.encounter.npcName}
|
||||
</div>
|
||||
</div>
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeGiftModal}
|
||||
@@ -480,47 +609,87 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
{npcUi.giftModal.introText && (
|
||||
<div className="whitespace-pre-line rounded-xl border border-rose-300/15 bg-rose-500/10 px-3 py-2 text-xs leading-relaxed text-rose-50/90">
|
||||
{npcUi.giftModal.introText}
|
||||
</div>
|
||||
)}
|
||||
{giftCandidates.length > 0 ? giftCandidates.map(candidate => (
|
||||
<button
|
||||
key={candidate.itemId}
|
||||
type="button"
|
||||
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.itemId ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="editorDark"
|
||||
size="xs"
|
||||
className="whitespace-pre-line leading-relaxed"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<PixelIcon src={getItemVisualSrc(candidate.item)} className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="text-sm text-white">{candidate.item.name}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{candidate.item.category} / {getRarityLabel(candidate.item.rarity)}</div>
|
||||
{!candidate.canSubmit && candidate.reason && (
|
||||
<div className="mt-1 text-[10px] text-rose-200/80">
|
||||
{candidate.reason}
|
||||
{npcUi.giftModal.introText}
|
||||
</PlatformStatusMessage>
|
||||
)}
|
||||
{giftCandidates.length > 0 ? (
|
||||
giftCandidates.map((candidate) => (
|
||||
<PlatformDarkOptionCard
|
||||
key={candidate.itemId}
|
||||
selected={
|
||||
npcUi.giftModal?.selectedItemId === candidate.itemId
|
||||
}
|
||||
tone="rose"
|
||||
radius="sm"
|
||||
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<PixelIcon
|
||||
src={getItemVisualSrc(candidate.item)}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm text-white">
|
||||
{candidate.item.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{candidate.item.category} /{' '}
|
||||
{getRarityLabel(candidate.item.rarity)}
|
||||
</div>
|
||||
{!candidate.canSubmit && candidate.reason && (
|
||||
<div className="mt-1 text-[10px] text-rose-200/80">
|
||||
{candidate.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PlatformPillBadge
|
||||
tone="darkRose"
|
||||
size="xxs"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
好感 +{candidate.affinityGain}
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<div className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] text-rose-100">
|
||||
好感 +{candidate.affinityGain}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||||
</PlatformDarkOptionCard>
|
||||
))
|
||||
) : (
|
||||
<NpcModalEmptyState>
|
||||
当前没有适合送出的礼物。
|
||||
</div>
|
||||
</NpcModalEmptyState>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 px-5 pb-5">
|
||||
<button type="button" onClick={npcUi.closeGiftModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={npcUi.closeGiftModal}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" disabled={!activeGiftView?.canSubmit} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${activeGiftView?.canSubmit ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!activeGiftView?.canSubmit}
|
||||
onClick={npcUi.confirmGift}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${activeGiftView?.canSubmit ? 'text-white' : 'text-zinc-600'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
确认赠礼
|
||||
</button>
|
||||
</div>
|
||||
@@ -542,12 +711,16 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">调整同行位置</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
调整同行位置
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。
|
||||
</div>
|
||||
</div>
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeRecruitModal}
|
||||
@@ -558,36 +731,69 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
{npcUi.recruitModal.introText && (
|
||||
<div className="whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||||
<PlatformStatusMessage
|
||||
tone="warning"
|
||||
surface="editorDark"
|
||||
size="xs"
|
||||
className="whitespace-pre-line leading-relaxed"
|
||||
>
|
||||
{npcUi.recruitModal.introText}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
)}
|
||||
{gameState.companions.length > 0 ? gameState.companions.map(companion => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
return (
|
||||
<button
|
||||
key={companion.npcId}
|
||||
type="button"
|
||||
onClick={() => npcUi.selectRecruitRelease(companion.npcId)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.recruitModal?.selectedReleaseNpcId === companion.npcId ? 'border-amber-400/60 bg-amber-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
|
||||
>
|
||||
<div className="text-sm text-white">{character.name}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{character.title}</div>
|
||||
</button>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||||
{gameState.companions.length > 0 ? (
|
||||
gameState.companions.map((companion) => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
return (
|
||||
<PlatformDarkOptionCard
|
||||
key={companion.npcId}
|
||||
selected={
|
||||
npcUi.recruitModal?.selectedReleaseNpcId ===
|
||||
companion.npcId
|
||||
}
|
||||
tone="amber"
|
||||
radius="sm"
|
||||
onClick={() =>
|
||||
npcUi.selectRecruitRelease(companion.npcId)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="text-sm text-white">{character.name}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{character.title}
|
||||
</div>
|
||||
</PlatformDarkOptionCard>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<NpcModalEmptyState>
|
||||
当前没有可替换的同行角色。
|
||||
</div>
|
||||
</NpcModalEmptyState>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 px-5 pb-5">
|
||||
<button type="button" onClick={npcUi.closeRecruitModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={npcUi.closeRecruitModal}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" disabled={!npcUi.recruitModal.selectedReleaseNpcId} onClick={npcUi.confirmRecruit} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!npcUi.recruitModal.selectedReleaseNpcId}
|
||||
onClick={npcUi.confirmRecruit}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
确认招募
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user