Merge branch 'master' into codex/game-agent-resource-workbench
Project CI / Frontend tests (pull_request) Successful in 4m35s
Project CI / Repository checks (pull_request) Successful in 4m38s
Project CI / Backend tests (pull_request) Successful in 5m37s
Project CI / Native shell tests (pull_request) Successful in 14m12s

This commit is contained in:
2026-08-23 18:05:07 +08:00
11 changed files with 155 additions and 69 deletions
@@ -393,12 +393,23 @@ pub(crate) fn inspect_local_project_directory(
is_godot_project: godot_project_root.is_some(),
godot_project_root,
project_name: game_creator_project_name(root),
modified_at: project_directory_modified_at(root),
manifest_error: game_creator_project_manifest_error(root),
recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()),
recent_run_stop_reason: recent_run_trace.map(|trace| trace.stop_reason),
})
}
fn project_directory_modified_at(root: &Path) -> Option<u64> {
root.metadata()
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
}
pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool {
if !root.is_dir() {
return false;
@@ -179,6 +179,7 @@ struct LocalProjectDirectoryStatus {
is_godot_project: bool,
godot_project_root: Option<String>,
project_name: Option<String>,
modified_at: Option<u64>,
manifest_error: Option<String>,
recent_run_status: Option<String>,
recent_run_stop_reason: Option<String>,
@@ -1131,6 +1131,25 @@ fn validate_generation_draft_active(
Ok(())
}
fn validate_generation_draft_for_public_phase(
ledger: &AssetCanvasGenerationLedger,
draft: &AssetCanvasDraft,
) -> Result<(), String> {
if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted
&& draft.status == AssetCanvasDraftStatus::Committed
{
if draft.project_id != ledger.project_id
|| draft.draft_id != ledger.draft_id
|| draft.intent != ledger.intent
|| draft.source_asset_id != ledger.source_asset_id
{
return Err("素材画布生成账本与草稿身份不一致".to_string());
}
return Ok(());
}
validate_generation_draft_active(ledger, draft)
}
fn ensure_generation_draft_active(
root: &Path,
ledger: &AssetCanvasGenerationLedger,
@@ -1150,7 +1169,7 @@ fn upsert_public_generation_record(
let _lock = acquire_asset_canvas_draft_lock(root)?;
let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
validate_generation_draft_active(ledger, &draft)?;
validate_generation_draft_for_public_phase(ledger, &draft)?;
let now = asset_canvas_now();
let output_asset_id = ledger
.commit_result
@@ -1397,6 +1397,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
is_godot_project: false,
godot_project_root: None,
project_name: None,
modified_at: None,
manifest_error: None,
recent_run_status: None,
recent_run_stop_reason: None,
@@ -1412,6 +1413,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
assert!(!file_status.is_godot_project);
assert_eq!(file_status.godot_project_root, None);
assert_eq!(file_status.project_name, None);
assert!(file_status.modified_at.is_some());
assert_eq!(file_status.manifest_error, None);
assert_eq!(file_status.recent_run_status, None);
fs::remove_file(&root).expect("remove file");
@@ -96,6 +96,7 @@ export interface LocalProjectDirectoryStatus {
isGodotProject: boolean;
godotProjectRoot: string | null;
projectName: string | null;
modifiedAt?: number | null;
manifestError?: string | null;
recentRunStatus: string | null;
recentRunStopReason: string | null;
@@ -61,6 +61,7 @@ export type RecentProjectRow = {
status: string;
projectKind: 'web' | 'godot' | 'unknown';
godotProjectRoot: string | null;
modifiedAt: number | null;
recentRunStatus: string | null;
recentRunStopReason: string | null;
canReveal: boolean;
@@ -262,6 +263,7 @@ export function buildRecentProjectRows(
? 'web'
: 'unknown',
godotProjectRoot: directoryStatus?.godotProjectRoot ?? null,
modifiedAt: directoryStatus?.modifiedAt ?? null,
recentRunStatus: directoryStatus?.recentRunStatus ?? null,
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
canReveal,
@@ -1,11 +1,11 @@
import {
ArrowUp,
FileText,
FolderKanban,
FolderOpen,
Gamepad2,
Image,
type LucideIcon,
Plus,
Sparkles,
} from 'lucide-react';
import type { FormEvent } from 'react';
@@ -65,9 +65,30 @@ export type HomeProjectRow = {
path: string;
name: string;
status: string;
projectKind: 'web' | 'godot' | 'unknown';
modifiedAt: number | null;
canOpen: boolean;
};
function formatProjectUpdatedAt(modifiedAt: number | null) {
if (!modifiedAt || !Number.isFinite(modifiedAt) || modifiedAt <= 0) {
return '未记录';
}
const date = new Date(modifiedAt);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
}
function projectTypeLabel(projectKind: HomeProjectRow['projectKind']) {
return projectKind === 'godot'
? 'Godot 游戏'
: projectKind === 'web'
? 'Web 游戏'
: '本地项目';
}
type HomeViewProps = {
hasPromo: boolean;
status: string;
@@ -143,26 +164,30 @@ export default function HomeView({
return (
<div className="platform-theme platform-theme--light min-h-screen bg-(image:--platform-body-fill) text-(--platform-text-strong)">
<section
className={`grid min-h-93.5 content-start justify-items-center gap-3.25 px-6 pb-4.5 pt-25.75 max-[760px]:min-h-0 max-[760px]:gap-4 max-[760px]:px-3.5 max-[760px]:pb-8.5 max-[760px]:pt-21 ${
hasPromo ? 'pt-33.75 max-[760px]:pt-21' : ''
className={`grid content-start justify-items-center gap-3.5 px-6 pb-4.5 pt-20 max-[760px]:gap-4 max-[760px]:px-3.5 max-[760px]:pb-8.5 max-[760px]:pt-21 ${
hasPromo ? 'pt-28 max-[760px]:pt-21' : ''
}`}
aria-label="首页输入"
>
<div className="flex items-center gap-3 text-center max-[760px]:flex-col max-[760px]:gap-2.5">
<span className="grid size-32 shrink-0 place-items-center rounded-full shadow-(--platform-profile-action-shadow)">
<img src={BRAND_ICON} alt="logo" className="size-32" />
<div className="flex w-[min(814px,calc(100vw-122px))] items-center gap-4 text-left max-[760px]:w-[min(100%,calc(100vw-76px))] max-[760px]:flex-col max-[760px]:items-start max-[760px]:gap-2.5">
<span className="grid size-30 shrink-0 place-items-center rounded-full bg-(--platform-subpanel-fill) shadow-(--platform-profile-action-shadow) max-[760px]:size-27">
<img
src={BRAND_ICON}
alt="logo"
className="size-30 max-[760px]:size-27"
/>
</span>
<div>
<h1 className="m-0 text-[27px] font-[750] tracking-normal text-(--platform-brand-logo-title) max-[760px]:text-[22px]">
<h1 className="m-0 text-[36px] font-[750] tracking-[-0.03em] text-(--platform-brand-logo-title) max-[760px]:text-[33px]">
陶泥儿
</h1>
<p className="m-0 mt-3 text-[13px] text-(--platform-text-soft)">
{activeCreationType.placeholder}
<p className="m-0 mt-1.5 text-[19.5px] font-medium text-(--platform-warm-text)">
你的游戏创作管家
</p>
</div>
</div>
<div
className="flex w-[min(488px,calc(100vw-110px))] flex-wrap justify-center gap-2.25 max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="flex w-[min(814px,calc(100vw-122px))] flex-wrap justify-start gap-3 max-[760px]:w-[min(100%,calc(100vw-76px))]"
role="group"
aria-label="创作类型"
>
@@ -171,10 +196,10 @@ export default function HomeView({
const isActive = item.creationType === homeCreationType;
return (
<button
className={`inline-flex min-h-6.75 cursor-pointer items-center gap-1.25 rounded-full border px-3 text-[12px] ${
className={`inline-flex min-h-7 cursor-pointer items-center gap-1.25 rounded-full border px-4 text-[12px] font-medium transition-colors ${
isActive
? 'border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-warm-text) shadow-(--platform-nav-active-shadow)'
: 'border-(--platform-subpanel-border) bg-(image:--platform-subpanel-fill) text-(--platform-neutral-text)'
? 'border-(--platform-button-primary-border) bg-(image:--platform-button-primary-fill) text-(--platform-button-primary-text) shadow-(--platform-nav-active-shadow)'
: 'border-(--platform-subpanel-border) bg-(image:--platform-subpanel-fill) text-(--platform-neutral-text) hover:border-(--platform-nav-active-border) hover:text-(--platform-warm-text)'
}`}
type="button"
key={item.creationType}
@@ -187,11 +212,8 @@ export default function HomeView({
);
})}
</div>
<span className="text-[12px] text-(--platform-text-soft)">
{status}
</span>
<form
className="grid min-h-20 w-[min(1054px,calc(100vw-122px))] grid-rows-[minmax(42px,auto)_auto] rounded-[18px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 pb-2 pt-3.25 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="grid min-h-26 w-[min(814px,calc(100vw-122px))] grid-rows-[minmax(62px,auto)_auto] rounded-[16px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3.5 pb-2.5 pt-3.5 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
onSubmit={handleHomeSubmit}
>
<RichInputArea
@@ -205,20 +227,25 @@ export default function HomeView({
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<UploadButton />
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-55"
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
type="submit"
aria-label="开启创作"
disabled={homeCreationBusy}
>
<Plus size={16} aria-hidden="true" />
<ArrowUp size={16} aria-hidden="true" />
</button>
</div>
</RichInputArea>
</form>
{status ? (
<span className="w-[min(814px,calc(100vw-122px))] text-left text-[12px] text-(--platform-text-soft) max-[760px]:w-[min(100%,calc(100vw-76px))]">
{status}
</span>
) : null}
</section>
<section
className="mx-auto grid w-[min(1054px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="mx-auto grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="最近项目"
>
<header className="flex items-center justify-between gap-3">
@@ -249,27 +276,37 @@ export default function HomeView({
</div>
</header>
{recentProjectRows.length > 0 ? (
<div className="grid grid-cols-5 gap-3 overflow-hidden max-[760px]:grid-cols-1 max-[760px]:overflow-visible">
<div className="grid grid-cols-3 gap-4 overflow-hidden max-[760px]:grid-cols-1 max-[760px]:overflow-visible">
{recentProjectRows.map((project) => (
<article className="grid min-w-0" key={project.path}>
<button
className="grid h-auto w-full cursor-pointer content-start justify-items-start gap-1.5 overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3 text-left shadow-(--platform-panel-shadow) transition hover:border-(--platform-surface-hover-border) disabled:cursor-not-allowed disabled:opacity-[0.58] max-[760px]:h-33"
className="grid min-h-34 w-full cursor-pointer grid-cols-[108px_minmax(0,1fr)] items-stretch gap-3 overflow-hidden rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-2 text-left shadow-(--platform-panel-shadow) transition hover:border-(--platform-surface-hover-border) hover:shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-[0.58] max-[760px]:grid-cols-[120px_minmax(0,1fr)]"
type="button"
disabled={!project.canOpen}
onClick={() => onProjectOpen(project.path)}
>
<span className="grid size-10.5 place-items-center rounded-xl bg-(--platform-warm-bg) text-[16px] font-bold text-(--platform-warm-text)">
<span className="grid min-h-29 place-items-center rounded-lg bg-[linear-gradient(145deg,#f9bf7d,#e96831_52%,#853c2c)] text-[27px] font-bold tracking-[-0.08em] text-white shadow-inner">
{project.name.slice(0, 2)}
</span>
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-medium text-(--platform-text-strong)">
{project.name}
</strong>
<small className="-mt-1.25 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-(--platform-text-soft)">
{project.path}
</small>
<small className="-mt-1.25 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-(--platform-text-soft)">
{project.status}
</small>
<span className="grid min-w-0 content-center gap-2.25 py-1">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[14px] font-semibold leading-4.5 text-(--platform-text-strong)">
{project.name}
</strong>
<small className="grid grid-cols-[36px_minmax(0,1fr)] gap-x-3 gap-y-1 text-[10px] leading-3.5 text-(--platform-text-soft)">
<span className="whitespace-nowrap">类型</span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
{projectTypeLabel(project.projectKind)}
</span>
<span className="whitespace-nowrap">更新时间</span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
{formatProjectUpdatedAt(project.modifiedAt)}
</span>
<span className="whitespace-nowrap">进度</span>
<span className="truncate whitespace-nowrap text-(--platform-warm-text)">
{project.status}
</span>
</small>
</span>
</button>
</article>
))}
@@ -296,29 +333,23 @@ export default function HomeView({
</section>
<section
className="mx-auto mt-5.5 grid w-[min(1054px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="灵感推荐"
>
<header className="flex items-center justify-start gap-3">
<div className="grid gap-1">
<span className="inline-flex items-center gap-1.5">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
灵感推荐
</h2>
<Sparkles
className="text-(--platform-warm-text)"
size={15}
aria-hidden="true"
/>
</span>
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
暂无灵感
</p>
</div>
<span className="inline-flex items-baseline gap-1.5">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
灵感推荐
</h2>
<Sparkles
className="text-(--platform-warm-text)"
size={15}
aria-hidden="true"
style={{ transform: 'translateY(1px)' }}
/>
</span>
</header>
<div className="grid min-h-24 place-items-center overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) text-[11px] text-(--platform-text-soft)">
暂无灵感
</div>
<div className="min-h-24 overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill)" />
</section>
</div>
);
@@ -26,12 +26,12 @@ import {
} from './harness';
export function registerClientHomeTests() {
it('keeps the empty inspiration section without reading the main-site showcase', async () => {
it('keeps the empty inspiration module without requesting the retired feed', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
renderLauncherAt('/?launcher');
const inspirationSection = screen.getByLabelText('灵感推荐');
expect(within(inspirationSection).getAllByText('暂无灵感')).toHaveLength(2);
expect(screen.getByLabelText('灵感推荐')).not.toBeNull();
expect(screen.queryByText('暂无灵感')).toBeNull();
await act(async () => {
await Promise.resolve();
});
@@ -1316,19 +1316,22 @@ export function registerHomeProjectCreationTests() {
expect(gameType.getAttribute('aria-pressed')).toBe('true');
expect(artType.getAttribute('aria-pressed')).toBe('false');
expect(documentType.getAttribute('aria-pressed')).toBe('false');
expect(screen.getAllByText('今天想把什么灵感做成游戏')).toHaveLength(2);
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天想把什么灵感做成游戏')).toHaveLength(1);
fireEvent.click(documentType);
expect(documentType.getAttribute('aria-pressed')).toBe('true');
expect(screen.getAllByText('今天有什么设计需要帮你整理')).toHaveLength(2);
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天有什么设计需要帮你整理')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
expect(await screen.findByText('请输入方案需求或上传资料')).not.toBeNull();
expect(screen.getByText('请输入方案需求或上传资料')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'create_automatic_local_game_project',
);
fireEvent.click(artType);
expect(gameType.getAttribute('aria-pressed')).toBe('false');
expect(artType.getAttribute('aria-pressed')).toBe('true');
expect(screen.getAllByText('今天想做什么样的美术素材')).toHaveLength(2);
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天想做什么样的美术素材')).toHaveLength(1);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '你好,今天多少号';