From 457e1147e4457903276d11b391b94d1bfcfba7a6 Mon Sep 17 00:00:00 2001 From: suyx Date: Mon, 3 Aug 2026 14:11:40 +0800 Subject: [PATCH] feat: complete TASK-WP4-04 color and dynamic stickers --- apps/api/src/amap-adapter.ts | 15 ++ apps/api/src/app.ts | 41 +++ apps/api/src/main.ts | 2 + apps/web/src/color-card-panel.tsx | 47 ++++ apps/web/src/dynamic-inspector.tsx | 14 ++ apps/web/src/dynamic-provider.ts | 178 +++++++++++++ apps/web/src/dynamic-render-models.ts | 196 +++++++++++++++ apps/web/src/dynamic-sticker-panel.tsx | 67 +++++ apps/web/src/editor-elements.ts | 19 ++ apps/web/src/editor-page.css | 51 +++- apps/web/src/editor-page.tsx | 185 ++++++++++++-- apps/web/src/editor-stage.tsx | 147 ++++++++--- apps/web/src/generated/api/sdk.gen.ts | 11 +- apps/web/src/generated/api/types.gen.ts | 11 + apps/web/src/palette-provider.ts | 197 +++++++++++++++ openapi/openapi.json | 107 ++++++++ package.json | 6 +- packages/shared-contracts/src/index.ts | 1 + packages/shared-contracts/src/location.ts | 15 ++ scripts/capture-wp4-04-comparisons.mjs | 35 +++ scripts/render-wp4-04-source-references.py | 130 ++++++++++ scripts/run-wp4-04-manual-preview.mjs | 112 +++++++++ scripts/run-wp4-04-validation.mjs | 142 +++++++++++ scripts/verify-wp4-04-source.mjs | 61 +++++ tests/api/wp4-04-location.test.ts | 52 ++++ tests/e2e/wp4-01-editor-background.spec.ts | 7 +- tests/e2e/wp4-02-editor-elements.spec.ts | 18 +- tests/e2e/wp4-04-color-dynamic.spec.ts | 278 +++++++++++++++++++++ tests/unit/wp4-04-palette-dynamic.test.ts | 150 +++++++++++ 29 files changed, 2234 insertions(+), 61 deletions(-) create mode 100644 apps/api/src/amap-adapter.ts create mode 100644 apps/web/src/color-card-panel.tsx create mode 100644 apps/web/src/dynamic-inspector.tsx create mode 100644 apps/web/src/dynamic-provider.ts create mode 100644 apps/web/src/dynamic-render-models.ts create mode 100644 apps/web/src/dynamic-sticker-panel.tsx create mode 100644 apps/web/src/palette-provider.ts create mode 100644 packages/shared-contracts/src/location.ts create mode 100644 scripts/capture-wp4-04-comparisons.mjs create mode 100644 scripts/render-wp4-04-source-references.py create mode 100644 scripts/run-wp4-04-manual-preview.mjs create mode 100644 scripts/run-wp4-04-validation.mjs create mode 100644 scripts/verify-wp4-04-source.mjs create mode 100644 tests/api/wp4-04-location.test.ts create mode 100644 tests/e2e/wp4-04-color-dynamic.spec.ts create mode 100644 tests/unit/wp4-04-palette-dynamic.test.ts diff --git a/apps/api/src/amap-adapter.ts b/apps/api/src/amap-adapter.ts new file mode 100644 index 0000000..17ae82c --- /dev/null +++ b/apps/api/src/amap-adapter.ts @@ -0,0 +1,15 @@ +export interface AmapAdapter { + reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>; +} + +export class MockAmapAdapter implements AmapAdapter { + readonly calls: Array<{ latitude: number; longitude: number }> = []; + + async reverseGeocode(coordinates: { latitude: number; longitude: number }) { + this.calls.push({ ...coordinates }); + return { + formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`, + serviceMode: "mock" as const, + }; + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fe97b05..6329002 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -93,6 +93,8 @@ import { RecentAssetQuerySchema, RecentAssetRecordRequestSchema, RecentAssetRecordResponseSchema, + ReverseGeocodeRequestSchema, + ReverseGeocodeResponseSchema, RegistrationCompleteHeadersSchema, RegistrationCompleteRequestSchema, RegistrationCompleteResponseSchema, @@ -128,6 +130,7 @@ import { type ProjectStateSaveHeaders, type RecentAssetQuery, type RecentAssetRecordRequest, + type ReverseGeocodeRequest, type RegistrationCompleteRequest, type RegistrationSendRequest, } from "@dada/shared-contracts"; @@ -171,6 +174,7 @@ import { } from "./registration-errors.js"; import type { RegistrationService } from "./registration.js"; import type { RecentAssetService } from "./recent-assets.js"; +import type { AmapAdapter } from "./amap-adapter.js"; import { ModelConfigurationError } from "./model-configuration.js"; import type { ModelConfigurationService } from "./model-configuration.js"; @@ -187,6 +191,7 @@ const defaultBootstrap: BootstrapResponse = { }; export interface CreateAppOptions { + amap?: AmapAdapter; bootstrap?: () => BootstrapResponse | Promise; browserGate?: boolean; browserSupportRelease?: BrowserSupportRelease; @@ -742,6 +747,8 @@ export async function createApp(options: CreateAppOptions = {}) { RecentAssetListResponseSchema, RecentAssetRecordRequestSchema, RecentAssetRecordResponseSchema, + ReverseGeocodeRequestSchema, + ReverseGeocodeResponseSchema, FailedEmptyTrashRequestSchema, FailedEmptyTrashResponseSchema, ModelIdSchema, @@ -896,6 +903,40 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.post( + "/api/v1/location/reverse-geocode", + { + attachValidation: true, + schema: { + body: Type.Ref(ReverseGeocodeRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "reverseGeocodeLocation", + response: { + 200: Type.Ref(ReverseGeocodeResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Location"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.amap) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest); + return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const }; + } catch (error) { + if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error); + return reply.code(503).send(null); + } + }, + ); + app.post( "/api/v1/admin-auth/login/send", { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 9072872..1629249 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -17,6 +17,7 @@ import { readSecureConfigCandidate } from "./secure-config.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; import { ModelConfigurationService } from "./model-configuration.js"; +import { MockAmapAdapter } from "./amap-adapter.js"; const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); let registration: RegistrationService | undefined; @@ -70,6 +71,7 @@ if (credentialChannelEnabled) { const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); const app = await createApp({ + amap: new MockAmapAdapter(), ...(browserSupportRelease ? { browserSupportRelease } : {}), ...(credits ? { credits } : {}), ...(latestExports ? { latestExports } : {}), diff --git a/apps/web/src/color-card-panel.tsx b/apps/web/src/color-card-panel.tsx new file mode 100644 index 0000000..828f3af --- /dev/null +++ b/apps/web/src/color-card-panel.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; + +import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js"; + +const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const; +const previewHalfSize = { + style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 }, + style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 }, +} as const; + +function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) { + const ref = useRef(null); + useEffect(() => { + const canvas = ref.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "#30343b"; + context.fillRect(0, 0, canvas.width, canvas.height); + const half = previewHalfSize[definition.styleId]; + const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2)); + context.translate(canvas.width / 2, canvas.height / 2); + context.scale(scale, scale); + drawColorCard(context, createColorCardElement(definition, previewPalette, { + createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000001", + }, 0)); + }, [definition]); + return