Merge branch 'master' into codex/game-agent-resource-workbench
This commit is contained in:
@@ -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 = '你好,今天多少号';
|
||||
|
||||
Reference in New Issue
Block a user