完善项目工作台入口与账户兑换码

修复 DirectProject 提交时的项目快照空值类型错误

将运行中项目列表移入窗口标题栏并支持展开查看

增加项目内打开项目目录按钮

在账户资产条中直接显示兑换码入口并保留兑换弹窗

补充相关测试、类型检查与产品文档
This commit is contained in:
Git Hooks Test
2026-09-16 01:54:29 +08:00
parent 9722442b44
commit d650bf9b74
18 changed files with 712 additions and 34 deletions
+9 -2
View File
@@ -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 }),
},
'兑换失败',
);
}
+230 -3
View File
@@ -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');
});