From 1f8e48ef26fc8f248001b5d4138d77dd92daad99 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 6 Aug 2026 16:52:31 +0800 Subject: [PATCH 01/15] feat(shared-api): expose the current user id boundary Most backend write endpoints carry user_id in the request body instead of deriving it from the token, so pages need the signed-in user id on its own. Register and read it through the same lazy provider pattern already used for the access token, returning null when no login module is mounted. Callers can now require a real user id before writing, without falling back to a constant placeholder. --- frontend/src/shared/api/index.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index 06f8ad8c..d892eb0c 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -48,6 +48,32 @@ export function getApiAccessToken(): string | null | undefined { return accessTokenProviders.at(-1)?.() } +/** + * 当前登录用户的 ID 读取函数。 + * 后端多数写接口把 user_id 放在请求体里而不是从 token 推导,所以前端除了 token + * 之外还要能单独拿到这个 ID;两者都由登录模块持有,这里只保存读取函数。 + */ +export type CurrentUserIdProvider = () => string | null | undefined + +const currentUserIdProviders: CurrentUserIdProvider[] = [] + +/** 注册当前用户 ID 的读取函数;返回值用于模块卸载或测试结束时撤销本次注册。 */ +export function registerCurrentUserIdProvider(provider: CurrentUserIdProvider): () => void { + currentUserIdProviders.push(provider) + return () => { + const index = currentUserIdProviders.lastIndexOf(provider) + if (index >= 0) currentUserIdProviders.splice(index, 1) + } +} + +/** + * 读取当前登录用户 ID;登录模块尚未接入时返回 null。 + * 调用方必须按「拿不到就不让写」处理,不允许退回到任何常量 ID。 + */ +export function getCurrentUserId(): string | null { + return currentUserIdProviders.at(-1)?.() ?? null +} + export type ApiErrorKind = 'business' | 'http' | 'invalid-response' | 'network' /** 后端业务错误与传输错误统一进入这一种前端错误。 */ From 76a11cb7baa52d5043e07e939bf39c2132679c0b Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 6 Aug 2026 16:52:45 +0800 Subject: [PATCH 02/15] feat(project-create): add the project creation page Nothing in the app could create a project: the entry existed only as a disabled button and a link to the project list. Add /projects/new with the fields the Project contract defines, submitting through ProjectApis and reporting business failures with the reason the backend gives. Users reach a real creation form, and the page keeps itself closed while no signed-in user is available. --- frontend/src/app/app.tsx | 2 + frontend/src/index.css | 146 +++++++++- frontend/src/pages/project-create/index.tsx | 254 ++++++++++++++++++ .../src/pages/project-create/pixel-mark.tsx | 59 ++++ 4 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/project-create/index.tsx create mode 100644 frontend/src/pages/project-create/pixel-mark.tsx diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index a7c1c323..9b5c47d8 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -6,6 +6,7 @@ import { HomePage } from '@/pages/home' import { NotFoundPage } from '@/pages/not-found' import { PlaytestPage } from '@/pages/playtest' import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectCreatePage } from '@/pages/project-create' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' @@ -34,6 +35,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/index.css b/frontend/src/index.css index 42c544ae..fd6020e9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -104,7 +104,151 @@ body, .projects-dialog-backdrop, .projects-dialog-panel, .route-transition, - .action-reveal { + .action-reveal, + .project-pixel-mark i { animation: none; } } + +@keyframes project-pixel-field { + 0%, + 58%, + 100% { + opacity: 0.5; + transform: scale(0.84); + } + + 16% { + opacity: 0.96; + transform: scale(1.08); + } +} + +@keyframes project-pixel-frame { + 0%, + 100% { + opacity: 0.72; + transform: scale(0.86); + } + + 38% { + opacity: 1; + transform: scale(1.08); + } +} + +@keyframes project-pixel-handle { + 0%, + 24%, + 100% { + transform: scale(0.82); + } + + 42%, + 58% { + transform: scale(1.18); + } +} + +@keyframes project-pixel-cursor { + 0%, + 14%, + 100% { + opacity: 0.28; + transform: translate(-5px, -5px) scale(0.86); + } + + 38% { + opacity: 1; + transform: translate(0, 0) scale(1); + } +} + +/* + 创建页左侧的装饰点阵。289 个格子共用四组动画,靠 animation-delay 错峰: + pixel-ring-N 是格子到中心的距离环,管底噪;pixel-phase-N 是 (x+y)%4,管实心笔画。 + 两组延迟都是负值,页面一进来就处在动画中段,不会先静止再启动。 +*/ +.project-pixel-mark { + display: grid; + grid-template-columns: repeat(17, 5px); + grid-template-rows: repeat(17, 5px); + gap: 8px; + place-content: center; + width: 248px; + height: 248px; +} + +.project-pixel-mark i { + width: 5px; + height: 5px; + border-radius: 1px; + background: #bfc6bf; + opacity: 0.62; + box-shadow: 0 0 0 1px rgb(255 255 255 / 18%); + animation: project-pixel-field 4.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.project-pixel-mark .pixel-ring-1 { + animation-delay: -0.14s; +} + +.project-pixel-mark .pixel-ring-2 { + animation-delay: -0.28s; +} + +.project-pixel-mark .pixel-ring-3 { + animation-delay: -0.42s; +} + +.project-pixel-mark .pixel-ring-4 { + animation-delay: -0.56s; +} + +.project-pixel-mark .pixel-ring-5 { + animation-delay: -0.7s; +} + +.project-pixel-mark .pixel-ring-6 { + animation-delay: -0.84s; +} + +.project-pixel-mark .pixel-ring-7 { + animation-delay: -0.98s; +} + +.project-pixel-mark .pixel-ring-8 { + animation-delay: -1.12s; +} + +.project-pixel-mark i.is-active { + background: #202120; + opacity: 0.9; + box-shadow: none; + animation: project-pixel-frame 3.4s ease-in-out infinite; +} + +.project-pixel-mark i.is-active.pixel-phase-1 { + animation-delay: -0.18s; +} + +.project-pixel-mark i.is-active.pixel-phase-2 { + animation-delay: -0.36s; +} + +.project-pixel-mark i.is-active.pixel-phase-3 { + animation-delay: -0.54s; +} + +.project-pixel-mark i.is-handle { + background: #050505; + opacity: 1; + box-shadow: 0 0 0 4px rgb(17 18 17 / 9%); + animation: project-pixel-handle 3.8s ease-in-out infinite; +} + +.project-pixel-mark i.is-cursor { + background: #090a09; + opacity: 1; + animation: project-pixel-cursor 2.8s cubic-bezier(0.22, 1, 0.36, 1) infinite; +} diff --git a/frontend/src/pages/project-create/index.tsx b/frontend/src/pages/project-create/index.tsx new file mode 100644 index 00000000..cba8de61 --- /dev/null +++ b/frontend/src/pages/project-create/index.tsx @@ -0,0 +1,254 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router' + +import { + CHARACTER_PERSPECTIVE, + DIRECTIONAL_MOVEMENT, + projectApis, + type CharacterPerspective, + type DirectionalMovement, +} from '@/entities' +import { ApiError, getCurrentUserId } from '@/shared/api' +import { ProjectCreatePixelMark } from './pixel-mark' + +/** + * 项目名称上限跟随后端 `windup_project.project_name` 的 String(20)。 + * 后端 PR #126 想放宽到 64,这里按更严的一边取值,两种契约下都能提交成功。 + */ +const NAME_MAX_LENGTH = 20 + +/** 精灵宽高的合法区间由后端 ProjectCreate 的 Field(ge=32, le=2048) 决定。 */ +const SPRITE_MIN = 32 +const SPRITE_MAX = 2048 + +/** 常用档位只是快捷填充,用户仍可以填这三档之外的任意合法宽高。 */ +const SPRITE_PRESETS = [128, 256, 512] + +const GAME_STYLE_MAX_LENGTH = 240 + +/** 创建真实项目;首页画布入口与项目中心的新建按钮共用这一页。 */ +export function ProjectCreatePage() { + const navigate = useNavigate() + const ownerId = getCurrentUserId() + + const [name, setName] = useState('') + const [perspective, setPerspective] = useState('side') + const [directionalMovement, setDirectionalMovement] = useState('single') + const [spriteWidth, setSpriteWidth] = useState('256') + const [spriteHeight, setSpriteHeight] = useState('256') + const [gameStyle, setGameStyle] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function submit(event: FormEvent) { + event.preventDefault() + if (submitting || !ownerId) return + + const trimmedName = name.trim() + const width = Number(spriteWidth) + const height = Number(spriteHeight) + if (!trimmedName) return setError('请填写项目名称') + if (trimmedName.length > NAME_MAX_LENGTH) + return setError(`项目名称最多 ${NAME_MAX_LENGTH} 个字`) + if (![width, height].every(isLegalSpriteLength)) { + return setError(`精灵宽高需要是 ${SPRITE_MIN} 到 ${SPRITE_MAX} 之间的整数`) + } + + setSubmitting(true) + setError(null) + try { + const project = await projectApis.create({ + ownerId, + name: trimmedName, + perspective, + directionalMovement, + spriteSize: { width, height }, + gameStyle: gameStyle.trim() || null, + }) + navigate(`/projects/${project.id}`) + } catch (cause) { + // 业务错误(如重名)由后端给出具体原因,原样转达;传输错误对用户没有信息量,收敛成一句。 + setError( + cause instanceof ApiError && cause.kind === 'business' ? cause.message : '项目暂时无法创建', + ) + setSubmitting(false) + } + } + + return ( +
+ + +
+
+
+

+ PROJECT SETUP +

+

新建项目

+

+ 只确定项目级的题材与规格;角色和动作在项目内再逐个创建。 +

+
+ +
+ + setName(event.target.value)} + className="rounded-xl border border-[#d5d9d2] bg-[#f5f5f2] px-4 py-3 text-sm outline-none focus-visible:border-[#8f978d]" + /> + + 最多 {NAME_MAX_LENGTH} 个字,同一账号下不能重名。 + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ 精灵尺寸 +
+ {SPRITE_PRESETS.map((preset) => ( + + ))} +
+
+
+ + setSpriteWidth(event.target.value)} + className="rounded-xl border border-[#d5d9d2] bg-[#f5f5f2] px-4 py-3 text-sm outline-none focus-visible:border-[#8f978d]" + /> +
+
+ + setSpriteHeight(event.target.value)} + className="rounded-xl border border-[#d5d9d2] bg-[#f5f5f2] px-4 py-3 text-sm outline-none focus-visible:border-[#8f978d]" + /> +
+
+
+ +
+ +