完善项目工作台入口与账户兑换码
修复 DirectProject 提交时的项目快照空值类型错误 将运行中项目列表移入窗口标题栏并支持展开查看 增加项目内打开项目目录按钮 在账户资产条中直接显示兑换码入口并保留兑换弹窗 补充相关测试、类型检查与产品文档
This commit is contained in:
@@ -6528,8 +6528,15 @@ export function App({
|
||||
// The legacy Supervisor/harness path remains below for rollback and tests.
|
||||
if (directCodexProductRuntime) {
|
||||
const directInvoke = resolveTauriInvoke();
|
||||
const directProjectPath = resolveChatProjectPath(localProject);
|
||||
if (directProjectPath && directInvoke) {
|
||||
// Capture the project snapshot before any asynchronous policy/session work.
|
||||
// `resolveChatProjectPath` only returns a path and TypeScript cannot infer
|
||||
// that the source project is still non-null after an await; keeping the
|
||||
// immutable snapshot also prevents a project switch from changing the
|
||||
// projectId used by this turn halfway through submission.
|
||||
const directProject = localProject;
|
||||
const directProjectPath = resolveChatProjectPath(directProject);
|
||||
const directProjectId = directProject?.manifest.projectId;
|
||||
if (directProjectPath && directProjectId && directInvoke) {
|
||||
const clientTurnId =
|
||||
directConversationTurnId ?? createDirectCodexConversationTurnId();
|
||||
const effectiveUserItem =
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Copy, Minus, Square, X } from 'lucide-react';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
|
||||
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
|
||||
import { AppUpdateNotice } from './AppUpdateNotice';
|
||||
import {
|
||||
WINDOW_CHROME_DEFAULT_TITLE,
|
||||
WindowChromeContext,
|
||||
type WindowChromeContextValue,
|
||||
type WindowChromeActiveProjectRuns,
|
||||
} from './windowChromeContext';
|
||||
|
||||
type WindowChromeProps = {
|
||||
@@ -39,6 +41,8 @@ function getNativeWindow() {
|
||||
export function WindowChrome({ children }: WindowChromeProps) {
|
||||
const [title, setTitleState] = useState(WINDOW_CHROME_DEFAULT_TITLE);
|
||||
const [walletSlot, setWalletSlot] = useState<HTMLDivElement | null>(null);
|
||||
const [activeProjectRuns, setActiveProjectRuns] =
|
||||
useState<WindowChromeActiveProjectRuns | null>(null);
|
||||
|
||||
const setTitle = useCallback((nextTitle: string | null | undefined) => {
|
||||
const normalizedTitle = nextTitle?.trim();
|
||||
@@ -50,6 +54,8 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
title,
|
||||
setTitle,
|
||||
walletSlot,
|
||||
activeProjectRuns,
|
||||
setActiveProjectRuns,
|
||||
};
|
||||
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
@@ -142,19 +148,33 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
</div>
|
||||
|
||||
<div className="window-chrome__drag-region" data-tauri-drag-region>
|
||||
<span className="window-chrome__title-wrap">
|
||||
<span
|
||||
className="window-chrome__workspace-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="window-chrome__title"
|
||||
title={title}
|
||||
aria-label={`当前工作区:${title}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<div className="window-chrome__title-wrap">
|
||||
{activeProjectRuns &&
|
||||
(activeProjectRuns.activeTurns.length > 0 ||
|
||||
activeProjectRuns.readFailed) ? (
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeProjectRuns.activeTurns}
|
||||
currentProjectPath={activeProjectRuns.currentProjectPath}
|
||||
readFailed={activeProjectRuns.readFailed}
|
||||
onOpenProject={activeProjectRuns.onOpenProject}
|
||||
placement="titlebar"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="window-chrome__workspace-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="window-chrome__title"
|
||||
title={title}
|
||||
aria-label={`当前工作区:${title}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-chrome__trailing">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn } from '../app/types';
|
||||
|
||||
export const WINDOW_CHROME_DEFAULT_TITLE = '创作工作台';
|
||||
|
||||
export type WindowChromeContextValue = {
|
||||
@@ -7,6 +9,17 @@ export type WindowChromeContextValue = {
|
||||
title: string;
|
||||
setTitle: (title: string | null | undefined) => void;
|
||||
walletSlot: HTMLElement | null;
|
||||
activeProjectRuns: WindowChromeActiveProjectRuns | null;
|
||||
setActiveProjectRuns: (
|
||||
activeProjectRuns: WindowChromeActiveProjectRuns | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type WindowChromeActiveProjectRuns = {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectPath?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
};
|
||||
|
||||
export const WindowChromeContext = createContext<WindowChromeContextValue>({
|
||||
@@ -14,6 +27,8 @@ export const WindowChromeContext = createContext<WindowChromeContextValue>({
|
||||
title: WINDOW_CHROME_DEFAULT_TITLE,
|
||||
setTitle: () => undefined,
|
||||
walletSlot: null,
|
||||
activeProjectRuns: null,
|
||||
setActiveProjectRuns: () => undefined,
|
||||
});
|
||||
|
||||
export function useWindowChrome() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PlatformMudPointWalletEntry } from '../../../../../packages/shared/src/components/PlatformMudPointWalletEntry';
|
||||
import { PlatformProfileRechargeModal } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import type { AccountWalletController } from './useAccountWallet';
|
||||
|
||||
export function AccountWalletBar({
|
||||
@@ -21,6 +22,7 @@ export function AccountWalletBar({
|
||||
onRequestDetails={() => void controller.onWalletBalanceMayHaveChanged()}
|
||||
onRecharge={controller.openRecharge}
|
||||
onOpenLedger={controller.openWalletLedger}
|
||||
onRedeemCode={controller.openRedeemCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -63,6 +65,37 @@ export function AccountWalletDialogs({
|
||||
onRetry={() => void controller.loadWalletLedger()}
|
||||
/>
|
||||
) : null}
|
||||
<ThemedModal
|
||||
open={controller.redeemCodeOpen}
|
||||
ariaLabel="兑换码"
|
||||
onClose={controller.closeRedeemCode}
|
||||
panelClassName="launcher-redeem-modal"
|
||||
>
|
||||
<header className="launcher-redeem-modal-header">
|
||||
<strong>兑换码</strong>
|
||||
<button type="button" onClick={controller.closeRedeemCode} aria-label="关闭兑换码">×</button>
|
||||
</header>
|
||||
<form
|
||||
className="launcher-redeem-modal-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void controller.redeemCode();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={controller.redeemCodeInput}
|
||||
onChange={(event) => controller.setRedeemCodeInput(event.target.value)}
|
||||
placeholder="输入兑换码"
|
||||
aria-label="兑换码"
|
||||
autoFocus
|
||||
/>
|
||||
{controller.redeemCodeError ? <p role="alert">{controller.redeemCodeError}</p> : null}
|
||||
{controller.redeemCodeSuccess ? <p role="status">{controller.redeemCodeSuccess}</p> : null}
|
||||
<button type="submit" disabled={controller.redeemCodeLoading || !controller.redeemCodeInput.trim()}>
|
||||
{controller.redeemCodeLoading ? '兑换中' : '兑换'}
|
||||
</button>
|
||||
</form>
|
||||
</ThemedModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn } from '../../app/types';
|
||||
import { projectNameFromPath } from '../agent-runtime';
|
||||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||||
|
||||
/**
|
||||
* 左上角的"正在运行的项目"面板。
|
||||
* 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用。
|
||||
*
|
||||
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
|
||||
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
|
||||
@@ -14,6 +17,7 @@ export type ActiveProjectRunsPanelProps = {
|
||||
currentProjectPath?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
placement?: 'panel' | 'titlebar';
|
||||
};
|
||||
|
||||
const ACTIVE_TURN_STATUS_LABELS: Record<string, string> = {
|
||||
@@ -56,11 +60,47 @@ export function ActiveProjectRunsPanel({
|
||||
currentProjectPath = null,
|
||||
readFailed = false,
|
||||
onOpenProject,
|
||||
placement = 'panel',
|
||||
}: ActiveProjectRunsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || placement !== 'titlebar') {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, placement]);
|
||||
|
||||
if (activeTurns.length === 0) {
|
||||
if (!readFailed) {
|
||||
return null;
|
||||
}
|
||||
if (placement === 'titlebar') {
|
||||
return (
|
||||
<span
|
||||
className="launcher-runs-titlebar launcher-runs-titlebar--error"
|
||||
role="status"
|
||||
>
|
||||
正在运行的项目读取失败
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
@@ -75,6 +115,91 @@ export function ActiveProjectRunsPanel({
|
||||
const orderedTurns = [...activeTurns].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
if (placement === 'titlebar') {
|
||||
const latestTurn = orderedTurns[orderedTurns.length - 1];
|
||||
if (!latestTurn) {
|
||||
return null;
|
||||
}
|
||||
const latestName = activeTurnDisplayName(latestTurn);
|
||||
const openProject = (projectPath: string) => {
|
||||
setOpen(false);
|
||||
onOpenProject?.(projectPath);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="launcher-runs-titlebar"
|
||||
data-active-project-count={orderedTurns.length}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-runs-titlebar-trigger"
|
||||
aria-label={`正在运行的项目:${latestName}`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span className="launcher-runs-panel-dot" aria-hidden="true" />
|
||||
<span className="launcher-runs-titlebar-name" title={latestName}>
|
||||
{latestName}
|
||||
</span>
|
||||
{orderedTurns.length > 1 ? (
|
||||
<span className="launcher-runs-titlebar-count">
|
||||
{orderedTurns.length}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className={`launcher-runs-titlebar-chevron${open ? ' is-open' : ''}`}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="launcher-runs-titlebar-menu" role="menu">
|
||||
<div className="launcher-runs-titlebar-menu-header">
|
||||
<strong>正在运行的项目</strong>
|
||||
<span>{orderedTurns.length} 个</span>
|
||||
</div>
|
||||
<ul className="launcher-runs-titlebar-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const name = activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
turn.projectPath,
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-runs-titlebar-item"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
disabled={!onOpenProject}
|
||||
onClick={() => openProject(turn.projectPath)}
|
||||
>
|
||||
<span className="launcher-runs-titlebar-item-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-titlebar-item-meta">
|
||||
{[activeTurnStatusLabel(turn.status), elapsed]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<header className="launcher-runs-panel-header">
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel';
|
||||
import {
|
||||
DeveloperAgentDialogs,
|
||||
DeveloperAgentPanel,
|
||||
@@ -50,6 +49,7 @@ export function WorkspaceLauncherShell({
|
||||
isWindowChrome,
|
||||
setTitle: setWindowTitle,
|
||||
walletSlot,
|
||||
setActiveProjectRuns,
|
||||
} = useWindowChrome();
|
||||
const accountWallet = useAccountWallet(currentUser.id);
|
||||
const [status, setStatus] = useState('');
|
||||
@@ -197,6 +197,31 @@ export function WorkspaceLauncherShell({
|
||||
* 清掉就等于这条提示时有时无。所以只有真的从 A 项目切到 B 项目(或关掉项目)才清。
|
||||
*/
|
||||
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
|
||||
|
||||
const openActiveProject = useCallback(
|
||||
(nextProjectPath: string) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProject(nextProjectPath, 'open');
|
||||
},
|
||||
[openProject, setProjectPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveProjectRuns({
|
||||
activeTurns,
|
||||
currentProjectPath: currentProjectContext?.projectPath ?? null,
|
||||
readFailed: snapshotReadFailed,
|
||||
onOpenProject: openActiveProject,
|
||||
});
|
||||
return () => setActiveProjectRuns(null);
|
||||
}, [
|
||||
activeTurns,
|
||||
currentProjectContext?.projectPath,
|
||||
openActiveProject,
|
||||
setActiveProjectRuns,
|
||||
snapshotReadFailed,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const projectPath = currentProjectContext?.projectPath ?? null;
|
||||
const previousProjectPath = manifestMergeNoticeScopeRef.current;
|
||||
@@ -524,16 +549,6 @@ export function WorkspaceLauncherShell({
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeTurns}
|
||||
currentProjectPath={currentProjectContext?.projectPath ?? null}
|
||||
readFailed={snapshotReadFailed}
|
||||
onOpenProject={(nextProjectPath) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProject(nextProjectPath, 'open');
|
||||
}}
|
||||
/>
|
||||
|
||||
{launcherView === 'home' ? (
|
||||
<HomeView
|
||||
hasPromo={launcherNotifications.length > 0}
|
||||
@@ -631,6 +646,11 @@ export function WorkspaceLauncherShell({
|
||||
onMakeGame={() =>
|
||||
void switchToGameRuntime(currentProjectContext.projectPath)
|
||||
}
|
||||
onRevealProjectDirectory={() =>
|
||||
recentProjects.handleRevealProjectDirectory(
|
||||
currentProjectContext.projectPath,
|
||||
)
|
||||
}
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onHomeOpen={() => setLauncherView('home')}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createClientProfileRechargeOrder,
|
||||
getClientProfileRechargeCenter,
|
||||
getClientProfileWalletLedger,
|
||||
redeemClientProfileRewardCode,
|
||||
} from '../../services/clientApi';
|
||||
import { useWalletStore } from '../../stores/useWalletStore';
|
||||
|
||||
@@ -45,8 +46,14 @@ export function useAccountWallet(currentUserId: string) {
|
||||
useState<string | null>(null);
|
||||
const [nativeRechargePayment, setNativeRechargePayment] =
|
||||
useState<PlatformProfileRechargeNativePaymentState | null>(null);
|
||||
const [redeemCodeOpen, setRedeemCodeOpen] = useState(false);
|
||||
const [redeemCodeInput, setRedeemCodeInput] = useState('');
|
||||
const [redeemCodeLoading, setRedeemCodeLoading] = useState(false);
|
||||
const [redeemCodeError, setRedeemCodeError] = useState<string | null>(null);
|
||||
const [redeemCodeSuccess, setRedeemCodeSuccess] = useState<string | null>(null);
|
||||
const rechargeLifecycleRef = useRef(0);
|
||||
const walletLedgerLifecycleRef = useRef(0);
|
||||
const redeemLifecycleRef = useRef(0);
|
||||
const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId);
|
||||
const currentUserIdRef = useRef(currentUserId);
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
@@ -105,6 +112,12 @@ export function useAccountWallet(currentUserId: string) {
|
||||
setRechargeError(null);
|
||||
setSubmittingRechargeProductId(null);
|
||||
setNativeRechargePayment(null);
|
||||
redeemLifecycleRef.current += 1;
|
||||
setRedeemCodeOpen(false);
|
||||
setRedeemCodeInput('');
|
||||
setRedeemCodeLoading(false);
|
||||
setRedeemCodeError(null);
|
||||
setRedeemCodeSuccess(null);
|
||||
}, [currentUserId]);
|
||||
|
||||
async function loadWalletLedger() {
|
||||
@@ -214,6 +227,45 @@ export function useAccountWallet(currentUserId: string) {
|
||||
setSubmittingRechargeProductId(null);
|
||||
}
|
||||
|
||||
function openRedeemCode() {
|
||||
redeemLifecycleRef.current += 1;
|
||||
setRedeemCodeOpen(true);
|
||||
setRedeemCodeInput('');
|
||||
setRedeemCodeError(null);
|
||||
setRedeemCodeSuccess(null);
|
||||
}
|
||||
|
||||
function closeRedeemCode() {
|
||||
redeemLifecycleRef.current += 1;
|
||||
setRedeemCodeOpen(false);
|
||||
setRedeemCodeLoading(false);
|
||||
}
|
||||
|
||||
async function redeemCode() {
|
||||
const code = redeemCodeInput.trim();
|
||||
if (!code || redeemCodeLoading) return;
|
||||
const lifecycle = redeemLifecycleRef.current;
|
||||
const owner = currentUserId;
|
||||
setRedeemCodeLoading(true);
|
||||
setRedeemCodeError(null);
|
||||
setRedeemCodeSuccess(null);
|
||||
try {
|
||||
const response = await redeemClientProfileRewardCode(code);
|
||||
if (redeemLifecycleRef.current !== lifecycle || currentUserIdRef.current !== owner) return;
|
||||
setRedeemCodeSuccess(`兑换成功,已到账 ${response.amountGranted} 泥点`);
|
||||
setRedeemCodeInput('');
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
} catch (error) {
|
||||
if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) {
|
||||
setRedeemCodeError(error instanceof Error ? error.message : '兑换失败');
|
||||
}
|
||||
} finally {
|
||||
if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) {
|
||||
setRedeemCodeLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buyRechargeProduct(product: ProfileRechargeProduct) {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
@@ -359,6 +411,15 @@ export function useAccountWallet(currentUserId: string) {
|
||||
closeRecharge,
|
||||
buyRechargeProduct,
|
||||
confirmNativeRechargePayment,
|
||||
redeemCodeOpen: walletUiIsVisible && redeemCodeOpen,
|
||||
redeemCodeInput,
|
||||
redeemCodeLoading: walletUiIsVisible && redeemCodeLoading,
|
||||
redeemCodeError: walletUiIsVisible ? redeemCodeError : null,
|
||||
redeemCodeSuccess: walletUiIsVisible ? redeemCodeSuccess : null,
|
||||
setRedeemCodeInput,
|
||||
openRedeemCode,
|
||||
closeRedeemCode,
|
||||
redeemCode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ProfileDashboardSummary,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src';
|
||||
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
|
||||
@@ -303,3 +304,15 @@ export function getClientProfileWalletLedger() {
|
||||
'读取泥点账单失败',
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemClientProfileRewardCode(code: string) {
|
||||
return requestClientApi<RedeemProfileRewardCodeResponse>(
|
||||
'/api/profile/redeem-codes/redeem',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
},
|
||||
'兑换失败',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,7 +358,9 @@ body {
|
||||
|
||||
.window-chrome__title-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: min(42vw, 460px, 100%);
|
||||
}
|
||||
@@ -384,6 +386,162 @@ body {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
max-width: min(42vw, 460px, 100%);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 7px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base, #6f5848);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
border-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-trigger:hover,
|
||||
.launcher-runs-titlebar-trigger:focus-visible,
|
||||
.launcher-runs-titlebar-trigger[aria-expanded='true'] {
|
||||
border-color: var(--platform-surface-border, #ead8cb);
|
||||
background: rgb(255 255 255 / 72%);
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-count {
|
||||
display: inline-grid;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: rgb(199 101 61 / 12%);
|
||||
color: var(--platform-accent, #c7653d);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-chevron {
|
||||
flex: 0 0 auto;
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
z-index: 40;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 10px;
|
||||
border: 1px solid var(--platform-subpanel-border, #ead8cb);
|
||||
border-radius: 12px;
|
||||
background: var(--platform-subpanel-fill, #fffaf5);
|
||||
box-shadow: 0 14px 36px rgb(31 24 16 / 18%);
|
||||
color: var(--platform-text-base, #6f5848);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 5px 8px;
|
||||
border-bottom: 1px solid var(--platform-surface-border, #ead8cb);
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-menu-header span {
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-list {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
max-height: min(52vh, 360px);
|
||||
margin: 7px 0 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 3px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item:hover,
|
||||
.launcher-runs-titlebar-item:focus-visible,
|
||||
.launcher-runs-titlebar-item[aria-current='true'] {
|
||||
background: rgb(199 101 61 / 10%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item-name {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item-meta,
|
||||
.launcher-runs-titlebar--error {
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar--error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgb(199 101 61 / 8%);
|
||||
}
|
||||
|
||||
.window-chrome__trailing {
|
||||
grid-column: 3;
|
||||
position: relative;
|
||||
@@ -530,8 +688,8 @@ body {
|
||||
}
|
||||
|
||||
.window-chrome__drag-region {
|
||||
display: none;
|
||||
padding-inline: 0;
|
||||
display: grid;
|
||||
padding-inline: 48px;
|
||||
}
|
||||
|
||||
.window-chrome__leading {
|
||||
@@ -1026,6 +1184,75 @@ textarea {
|
||||
top: 44px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal {
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--platform-surface-border);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 10px;
|
||||
background: var(--platform-body-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form p[role='alert'] {
|
||||
color: #b5432e;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form p[role='status'] {
|
||||
color: #34804b;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form > button {
|
||||
padding: 11px 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--platform-accent, #c7653d);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form > button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.launcher-project-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AtSign,
|
||||
Crosshair,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
FolderTree,
|
||||
Gamepad2,
|
||||
Image,
|
||||
@@ -586,6 +587,7 @@ export type ProjectDevelopmentViewProps = {
|
||||
onProjectsOpen: () => void;
|
||||
onPlay?: () => void;
|
||||
onMakeGame?: () => void;
|
||||
onRevealProjectDirectory?: () => void | Promise<void>;
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
manifest: GameCreationAppManifest,
|
||||
@@ -1446,6 +1448,7 @@ export default function ProjectDevelopmentView({
|
||||
onManifestChange,
|
||||
onPlay,
|
||||
onMakeGame,
|
||||
onRevealProjectDirectory,
|
||||
}: ProjectDevelopmentViewProps) {
|
||||
const professionalDagVisible = orchestrationMode === 'professional-dag';
|
||||
const [mode, setMode] = useState<WorkbenchMode>('resources');
|
||||
@@ -7633,6 +7636,17 @@ export default function ProjectDevelopmentView({
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-workbench-view-actions">
|
||||
{onRevealProjectDirectory ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-label="打开项目目录"
|
||||
onClick={() => void onRevealProjectDirectory()}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
打开项目目录
|
||||
</button>
|
||||
) : null}
|
||||
{mode === 'run' && embeddedPreviewUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
|
||||
import { WindowChrome } from '../src/components/WindowChrome';
|
||||
import { useWindowChrome } from '../src/components/windowChromeContext';
|
||||
|
||||
@@ -16,6 +17,18 @@ function TitleSetter({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveRunsSetter({ activeTurns }: { activeTurns: GameCreatorDirectActiveTurn[] }) {
|
||||
const { setActiveProjectRuns } = useWindowChrome();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveProjectRuns({ activeTurns, onOpenProject: () => undefined })}
|
||||
>
|
||||
显示运行项目
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('WindowChrome', () => {
|
||||
it('renders the陶泥儿 brand, default title, and controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -82,4 +95,40 @@ describe('WindowChrome', () => {
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: '页面按钮' }));
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the latest active project in the title bar and expands the full list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WindowChrome>
|
||||
<ActiveRunsSetter
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/first',
|
||||
projectName: '先开始',
|
||||
turnId: 'turn-first',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
projectPath: 'C:/projects/later',
|
||||
projectName: '后开始',
|
||||
turnId: 'turn-later',
|
||||
startedAt: 200,
|
||||
status: 'streaming',
|
||||
updatedAt: 220,
|
||||
sequence: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</WindowChrome>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '显示运行项目' }));
|
||||
expect(screen.getByRole('button', { name: /正在运行的项目:后开始/ })).toBeTruthy();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: /正在运行的项目:后开始/ }));
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,41 @@ it('读取失败时保留明确的读取提示,不伪装成没有运行项目'
|
||||
|
||||
expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
|
||||
});
|
||||
|
||||
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
||||
const onOpenProject = vi.fn();
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/first',
|
||||
projectName: '先开始',
|
||||
turnId: 'turn-first',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
projectPath: 'C:/projects/later',
|
||||
projectName: '后开始',
|
||||
turnId: 'turn-later',
|
||||
startedAt: 200,
|
||||
status: 'streaming',
|
||||
updatedAt: 220,
|
||||
sequence: 2,
|
||||
},
|
||||
]}
|
||||
onOpenProject={onOpenProject}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
|
||||
expect(screen.getByRole('menu')).toBeTruthy();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
|
||||
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
||||
});
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
|
||||
2026-08-23:项目开发工作台取消顶部账户资产预留空间,资源管理主视窗与 Agent 对话从窗口顶端铺开;泥点余额 / 充值入口复用既有账户组件并放置在 Agent 对话标题栏右上角,对话标题在该视窗顶部居中。
|
||||
|
||||
2026-09-16:项目内工具栏增加“打开项目目录”入口,资源管理与运行页签均可使用;入口复用现有本地项目目录打开命令,不新增项目状态或文件访问链路。
|
||||
|
||||
2026-09-16:客户端标题栏账户资产条在传入兑换能力时直接显示“兑换码”按钮;兑换弹窗和账号生命周期校验继续复用现有实现。
|
||||
|
||||
- 项目工作台继续保留左侧平台导航、中央主视窗、右侧 Project Supervisor 和底部专业 Agent 状态栏四区结构;主站图片编辑器只作为视觉语言和共享画布组件的事实源,不把其素材库侧栏、账号业务或云端项目外壳整体搬入客户端。
|
||||
- 平台主题事实源固定为 `packages/shared/src/theme.css`。画布通用 chrome 固定落在 `@genarrative/image-canvas-react`,主站与 Tauri 必须直接 import 同一组件和作用域样式;客户端不得复制 `src/components/image-editor/`,也不得导入主站完整 `src/index.css`。
|
||||
- 第一批共享 chrome 固定覆盖画布动作按钮、工具栏、工具分组和分隔符。按钮的默认、悬停、键盘焦点、选中、禁用和主次色语义由共享层表达;宿主只提供图标、文案、事件与业务禁用条件。
|
||||
|
||||
@@ -29,7 +29,7 @@ Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目
|
||||
|
||||
1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。
|
||||
2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。
|
||||
3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。
|
||||
3. 前端:在窗口标题栏挂载“正在运行的项目”下拉入口;标题栏只显示最后开始的项目,展开后按开始时间列出全部项目,复用既有组件与设计 token。
|
||||
4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。
|
||||
5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施
|
||||
|
||||
## 目标
|
||||
|
||||
离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。
|
||||
离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层窗口标题栏提供“正在运行的项目”下拉入口,常态显示最后开始的项目,展开后列出当前确有在跑回合的全部项目并可点击进入。
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -22,7 +22,7 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施
|
||||
- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。
|
||||
- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。
|
||||
- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。
|
||||
- 左上角面板:列出所有在跑项目,按 `startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳。
|
||||
- 窗口标题栏入口:常态只显示最后开始的项目,点击后按 `startedAt` 升序列出所有在跑项目,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染入口。
|
||||
- 快照读取失败不得阻断发送、不得显示成业务失败。
|
||||
- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:<command>` 仍显示审批提示。
|
||||
|
||||
|
||||
@@ -1407,6 +1407,6 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执
|
||||
|
||||
Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
|
||||
|
||||
壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
|
||||
壳层窗口标题栏的“正在运行”下拉入口只呈现活动 Direct 回合快照:常态只显示最后开始的项目,展开后按开始时间排序,显示全部项目的项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;入口不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
|
||||
|
||||
活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。
|
||||
|
||||
@@ -60,6 +60,25 @@ test('shows only permanent and daily free points in the shared wallet panel', as
|
||||
expect(onRecharge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows the redeem-code entry directly in the wallet bar when provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRedeemCode = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={207}
|
||||
breakdown={breakdown}
|
||||
onRequestDetails={vi.fn()}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenLedger={vi.fn()}
|
||||
onRedeemCode={onRedeemCode}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '兑换码' }));
|
||||
expect(onRedeemCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps the chip on the live balance when cached details are stale', () => {
|
||||
const props = {
|
||||
breakdown,
|
||||
|
||||
@@ -22,6 +22,7 @@ export type PlatformMudPointWalletEntryProps = {
|
||||
onRequestDetails: () => void;
|
||||
onRecharge: () => void;
|
||||
onOpenLedger: () => void;
|
||||
onRedeemCode?: () => void;
|
||||
};
|
||||
|
||||
function MudPointBalanceRow({
|
||||
@@ -62,6 +63,7 @@ export function PlatformMudPointWalletEntry({
|
||||
onRequestDetails,
|
||||
onRecharge,
|
||||
onOpenLedger,
|
||||
onRedeemCode,
|
||||
}: PlatformMudPointWalletEntryProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const isOpenRef = useRef(false);
|
||||
@@ -224,6 +226,24 @@ export function PlatformMudPointWalletEntry({
|
||||
>
|
||||
充值
|
||||
</button>
|
||||
{onRedeemCode ? (
|
||||
<>
|
||||
<span
|
||||
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.36)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 px-2.5 font-black outline-none transition-colors hover:bg-white/75 focus-visible:bg-white/80"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
onRedeemCode();
|
||||
}}
|
||||
>
|
||||
兑换码
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
@@ -281,6 +301,19 @@ export function PlatformMudPointWalletEntry({
|
||||
使用详情
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{onRedeemCode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-2 border-t border-[rgba(194,145,111,0.28)] px-4 py-3 text-[13px] font-black text-[var(--platform-text-strong)] outline-none transition-colors hover:bg-white/55 focus-visible:bg-white/65"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
onRedeemCode();
|
||||
}}
|
||||
>
|
||||
兑换码
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user