diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..ee6556b9 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,26 @@ +# Secrets & environment +.env +.env.* +!.env.example + +# Build artifacts +bin +.cache +uploads + +# IDE & editor +.idea +.vscode +*.swp +*.swo +*~ + +# Docker config +Dockerfile +.dockerignore + +# Docs & misc +*.md +*.log +.DS_Store +Makefile diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..898014ec --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,27 @@ +# Secrets & environment +.env +.env.* +!.env.example + +# Dependencies & build output +node_modules +dist +coverage +*.tsbuildinfo +.eslintcache + +# IDE & editor +.idea +.vscode +*.swp +*.swo +*~ + +# Docker config +Dockerfile +.dockerignore + +# Docs & misc +*.md +*.log +.DS_Store diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 848fa7f8..d124fbb3 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,57 +1,57 @@ -import eslint from '@eslint/js' -import tseslint from 'typescript-eslint' -import pluginVue from 'eslint-plugin-vue' +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import pluginVue from "eslint-plugin-vue"; export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, - ...pluginVue.configs['flat/recommended'], + ...pluginVue.configs["flat/recommended"], { - files: ['**/*.vue'], + files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tseslint.parser, }, globals: { - HTMLElement: 'readonly', - HTMLStyleElement: 'readonly', - HTMLCanvasElement: 'readonly', - HTMLImageElement: 'readonly', - document: 'readonly', - window: 'readonly', - requestAnimationFrame: 'readonly', - cancelAnimationFrame: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - setInterval: 'readonly', - clearInterval: 'readonly', - console: 'readonly', - Image: 'readonly', + HTMLElement: "readonly", + HTMLStyleElement: "readonly", + HTMLCanvasElement: "readonly", + HTMLImageElement: "readonly", + document: "readonly", + window: "readonly", + requestAnimationFrame: "readonly", + cancelAnimationFrame: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + console: "readonly", + Image: "readonly", }, }, }, { - files: ['**/*.vue'], + files: ["**/*.vue"], rules: { - 'no-undef': 'off', + "no-undef": "off", }, }, { - files: ['env.d.ts'], + files: ["env.d.ts"], rules: { - '@typescript-eslint/no-empty-object-type': 'off', - '@typescript-eslint/no-explicit-any': 'off', + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-explicit-any": "off", }, }, { rules: { - 'vue/max-attributes-per-line': 'off', - 'vue/singleline-html-element-content-newline': 'off', - 'vue/html-self-closing': 'off', - 'vue/attributes-order': 'off', + "vue/max-attributes-per-line": "off", + "vue/singleline-html-element-content-newline": "off", + "vue/html-self-closing": "off", + "vue/attributes-order": "off", }, }, { - ignores: ['dist/', 'node_modules/'], + ignores: ["dist/", "node_modules/", "public/"], }, -) +); diff --git a/frontend/index.html b/frontend/index.html index c9d0907f..90a40b82 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,15 @@ - - Sunset Beach + + + + + + + + + MomShell
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..2e49bee5 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 00000000..3e24fc01 --- /dev/null +++ b/frontend/public/icons/icon-192.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eea6cc229d1ba3abe94d5cf9c463625a624a21dbaa604c159944c231031ebd6d +size 594 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 00000000..7ee17b73 --- /dev/null +++ b/frontend/public/icons/icon-512.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:700d233f5cfac21820e5e46df1dd856f4082ec609cf2b8b86a51e37560e2a935 +size 2201 diff --git a/frontend/public/icons/icon-maskable-512.png b/frontend/public/icons/icon-maskable-512.png new file mode 100644 index 00000000..7ee17b73 --- /dev/null +++ b/frontend/public/icons/icon-maskable-512.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:700d233f5cfac21820e5e46df1dd856f4082ec609cf2b8b86a51e37560e2a935 +size 2201 diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 00000000..bc75f10d --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "MomShell - 贝壳回响", + "short_name": "MomShell", + "description": "亲密关系成长平台", + "start_url": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#f5e0c8", + "theme_color": "#f2b8c8", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..00c26b57 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,31 @@ +/* eslint-disable no-undef */ +const CACHE_NAME = "momshell-v1"; +const PRECACHE_URLS = ["/", "/index.html"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)), + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)), + ), + ), + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request).catch(() => caches.match("/index.html")), + ); + } +}); diff --git a/frontend/src/components/overlay/AiMemoryPanel.vue b/frontend/src/components/overlay/AiMemoryPanel.vue index 03465040..802edc3a 100644 --- a/frontend/src/components/overlay/AiMemoryPanel.vue +++ b/frontend/src/components/overlay/AiMemoryPanel.vue @@ -578,4 +578,33 @@ async function onClearHistory() { background: rgba(255, 255, 255, 0.14); color: var(--text-primary); } + +/* ── Mobile ── */ +@media (max-width: 768px) { + .ai-memory-panel { + padding: 24px 16px 20px; + } + + .panel-title { + font-size: 20px; + } + + .facts-list { + max-height: 55dvh; + max-height: 55vh; + } + + .fact-card { + padding: 12px; + } + + .fact-delete { + min-width: 44px; + min-height: 44px; + } + + .fact-text { + font-size: 13px; + } +} diff --git a/frontend/src/components/overlay/AuthPanel.vue b/frontend/src/components/overlay/AuthPanel.vue index 9e8eecc7..36766847 100644 --- a/frontend/src/components/overlay/AuthPanel.vue +++ b/frontend/src/components/overlay/AuthPanel.vue @@ -271,4 +271,26 @@ async function onRegister() { font-size: 13px; line-height: 1.6; } + +/* ── Mobile ── */ +@media (max-width: 768px) { + .auth-panel { + padding: 24px 16px 20px; + } + + .auth-input { + font-size: 16px; + padding: 14px 16px; + } + + .auth-tab { + font-size: 13px; + min-height: 44px; + } + + .auth-submit { + min-height: 48px; + font-size: 16px; + } +} diff --git a/frontend/src/components/overlay/BarPage.vue b/frontend/src/components/overlay/BarPage.vue index 1f3ecf1f..2449c7df 100644 --- a/frontend/src/components/overlay/BarPage.vue +++ b/frontend/src/components/overlay/BarPage.vue @@ -290,6 +290,7 @@ diff --git a/frontend/src/components/scene/NavBar.vue b/frontend/src/components/scene/NavBar.vue index 9be6effd..b73abf3e 100644 --- a/frontend/src/components/scene/NavBar.vue +++ b/frontend/src/components/scene/NavBar.vue @@ -254,6 +254,28 @@ const showNav = computed(() => { opacity: 0; transform: translateY(-8px) scale(0.95); } + +/* ── Mobile ── */ +@media (max-width: 768px) { + .nav-toggle { + width: 44px; + height: 44px; + } + + .nav-item { + padding: 12px 16px; + min-height: 44px; + } + + .nav-label { + font-size: 14px; + } + + .nav-icon { + width: 22px; + height: 22px; + } +} diff --git a/frontend/src/components/scene/SpritesLayer.vue b/frontend/src/components/scene/SpritesLayer.vue index 8c27b0cb..cc58257a 100644 --- a/frontend/src/components/scene/SpritesLayer.vue +++ b/frontend/src/components/scene/SpritesLayer.vue @@ -5,12 +5,7 @@ :key="s.id" :id="`sprite-${s.id}`" :class="['sprite-wrapper', { clickable: isSpriteClickable(s.id) }]" - :style="{ - left: s.left, - top: s.top, - width: s.width, - zIndex: s.zIndex ?? undefined, - }" + :style="spriteStyle(s)" > - {{ s.label }} + {{ s.label }} @@ -50,9 +42,28 @@ import { LAYERS } from '@/constants/layers' import { SPRITES } from '@/constants/sprites' import { useUiStore } from '@/stores/ui' import { useAuthStore } from '@/stores/auth' +import { useIsMobile } from '@/composables/useIsMobile' import { createSeededRandom } from '@/utils/random' const rand = createSeededRandom(400) +const { isMobile, isLandscape } = useIsMobile() + +function spriteStyle(s: typeof SPRITES[number]) { + const m = isLandscape.value ? s.landscape : isMobile.value ? s.mobile : undefined + return { + left: m?.left ?? s.left, + top: m?.top ?? s.top, + width: m?.width ?? s.width, + zIndex: s.zIndex ?? undefined, + } +} + +function labelStyle(s: typeof SPRITES[number]) { + return { + fontSize: (isMobile.value || isLandscape.value) ? undefined : (s.labelSize ?? undefined), + marginTop: s.labelOffsetY ?? undefined, + } +} const SHARED_HINTS: readonly string[] = [ '想聊聊心事,就点那块石头呀。', @@ -78,7 +89,7 @@ const DAD_HINTS: readonly string[] = [ const layerEl = ref(null) const bubbleLayerEl = ref(null) -const crabSpriteEl = ref(null) +const crabSpriteEl = ref(null) const ctx = inject(PARALLAX_KEY)! const uiStore = useUiStore() const authStore = useAuthStore() @@ -106,7 +117,7 @@ function setSpriteEl(id: string, element: Element | ComponentPublicInstance | nu return } - crabSpriteEl.value = element instanceof HTMLImageElement ? element : null + crabSpriteEl.value = element instanceof HTMLElement ? element : null } function updateCrabBubblePosition() { @@ -247,6 +258,17 @@ onUnmounted(() => { user-select: none; } +@media (max-width: 768px) { + .sprite-wrapper.clickable { + min-width: 44px; + min-height: 44px; + } + + .sprite-label { + font-size: 0.7rem; + } +} + .speech-bubble { position: absolute; background: rgba(255, 252, 246, 0.96); @@ -257,12 +279,22 @@ onUnmounted(() => { border-radius: 1.1em; white-space: nowrap; width: max-content; + max-width: 80vw; pointer-events: none; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); border: 1px solid rgba(255, 214, 170, 0.7); z-index: 1; } +@media (max-width: 768px) { + .speech-bubble { + white-space: normal; + width: auto; + max-width: min(280px, 70vw); + font-size: 0.85rem; + } +} + .crab-bubble { --bubble-shift-x: -10%; --bubble-shift-y: calc(-100% - 0.5rem); diff --git a/frontend/src/composables/useIsMobile.ts b/frontend/src/composables/useIsMobile.ts new file mode 100644 index 00000000..61dfedfa --- /dev/null +++ b/frontend/src/composables/useIsMobile.ts @@ -0,0 +1,39 @@ +import { ref, onMounted, onUnmounted } from "vue"; + +export function useIsMobile() { + const isMobile = ref(false); + const isSmall = ref(false); + const isLandscape = ref(false); + + function update() { + isMobile.value = window.matchMedia("(max-width: 768px)").matches; + isSmall.value = window.matchMedia("(max-width: 480px)").matches; + isLandscape.value = window.matchMedia( + "(max-height: 500px) and (orientation: landscape)", + ).matches; + } + + let mql768: MediaQueryList; + let mql480: MediaQueryList; + let mqlLandscape: MediaQueryList; + + onMounted(() => { + mql768 = window.matchMedia("(max-width: 768px)"); + mql480 = window.matchMedia("(max-width: 480px)"); + mqlLandscape = window.matchMedia( + "(max-height: 500px) and (orientation: landscape)", + ); + update(); + mql768.addEventListener("change", update); + mql480.addEventListener("change", update); + mqlLandscape.addEventListener("change", update); + }); + + onUnmounted(() => { + mql768?.removeEventListener("change", update); + mql480?.removeEventListener("change", update); + mqlLandscape?.removeEventListener("change", update); + }); + + return { isMobile, isSmall, isLandscape }; +} diff --git a/frontend/src/composables/useParallax.ts b/frontend/src/composables/useParallax.ts index 0f1814ba..7a4947d7 100644 --- a/frontend/src/composables/useParallax.ts +++ b/frontend/src/composables/useParallax.ts @@ -41,7 +41,9 @@ export function useParallax() { function recalcParallax() { const vw = window.innerWidth; - maxOffset.value = vw * 1.2; + const isPortraitMobile = + vw <= 768 && window.innerHeight > window.innerWidth; + maxOffset.value = vw * (isPortraitMobile ? 1.5 : 1.2); layerMeta.forEach((m) => { m.centerShift = -(m.el.offsetWidth - vw) / 2; }); @@ -190,6 +192,21 @@ export function useParallax() { } let resizeRaf = 0; + let orientationTimer = 0; + + function onResize() { + cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(recalcParallax); + } + + function onOrientationChange() { + clearTimeout(orientationTimer); + orientationTimer = window.setTimeout(() => { + recalcParallax(); + applyParallax(); + startLoop(); + }, 150); + } onMounted(() => { recalcParallax(); @@ -205,10 +222,8 @@ export function useParallax() { document.addEventListener("touchstart", onTouchStart, { passive: true }); document.addEventListener("touchmove", onTouchMove, { passive: true }); document.addEventListener("wheel", onWheel, { passive: false }); - window.addEventListener("resize", () => { - cancelAnimationFrame(resizeRaf); - resizeRaf = requestAnimationFrame(recalcParallax); - }); + window.addEventListener("resize", onResize); + window.addEventListener("orientationchange", onOrientationChange); setTimeout(() => { hintHidden.value = true; @@ -225,6 +240,9 @@ export function useParallax() { document.removeEventListener("touchstart", onTouchStart); document.removeEventListener("touchmove", onTouchMove); document.removeEventListener("wheel", onWheel); + window.removeEventListener("resize", onResize); + window.removeEventListener("orientationchange", onOrientationChange); + clearTimeout(orientationTimer); }); function wasDrag(): boolean { diff --git a/frontend/src/constants/sprites.ts b/frontend/src/constants/sprites.ts index ad1ac9f2..ac8c8da8 100644 --- a/frontend/src/constants/sprites.ts +++ b/frontend/src/constants/sprites.ts @@ -1,40 +1,127 @@ export interface SpriteData { - id: string - src: string - left: string - top: string - width: string - rotate?: number - scaleX?: number - scaleY?: number - zIndex?: number - label?: string - labelSize?: string // e.g. '1rem', '0.9rem' - labelOffsetY?: string // e.g. '-20%', '4px' + id: string; + src: string; + left: string; + top: string; + width: string; + rotate?: number; + scaleX?: number; + scaleY?: number; + zIndex?: number; + label?: string; + labelSize?: string; // e.g. '1rem', '0.9rem' + labelOffsetY?: string; // e.g. '-20%', '4px' + /** Override position/size on mobile (max-width: 768px) */ + mobile?: { + left?: string; + top?: string; + width?: string; + }; + /** Override position/size on mobile landscape (max-height: 500px, orientation: landscape) */ + landscape?: { + left?: string; + top?: string; + width?: string; + }; } -import carImg from '@/assets/images/car.png' -import barImg from '@/assets/images/bar.png' -import stoneImg from '@/assets/images/stone.png' -import crabImg from '@/assets/images/crab.png' -import shellImg from '@/assets/images/shell.png' -import chairImg from '@/assets/images/chairs.png' -import mailboxImg from '@/assets/images/mailbox.png' +import carImg from "@/assets/images/car.png"; +import barImg from "@/assets/images/bar.png"; +import stoneImg from "@/assets/images/stone.png"; +import crabImg from "@/assets/images/crab.png"; +import shellImg from "@/assets/images/shell.png"; +import chairImg from "@/assets/images/chairs.png"; +import mailboxImg from "@/assets/images/mailbox.png"; export const SPRITES: SpriteData[] = [ + { + id: "car", + src: carImg, + left: "40%", + top: "-2.5%", + width: "50vw", + label: "个人中心", + labelSize: "1.2rem", + labelOffsetY: "-7%", + mobile: { width: "60vw", left: "38%", top: "26%" }, + landscape: { width: "40vw", left: "38%", top: "-8%" }, + }, - { id: 'car', src: carImg, left: '40%', top: '-2.5%', width: '50vw', label: '个人中心', labelSize: '1.2rem', labelOffsetY: '-7%' }, + { + id: "bar", + src: barImg, + left: "24%", + top: "-15%", + width: "60vw", + label: "智育社区", + labelSize: "1.2rem", + labelOffsetY: "-9%", + mobile: { width: "70vw", left: "21%", top: "22%" }, + landscape: { width: "45vw", left: "26%", top: "-15%" }, + }, - { id: 'bar', src: barImg, left: '24%', top: '-15%', width: '60vw', label: '智育社区', labelSize: '1.2rem', labelOffsetY: '-9%' }, + { + id: "stone", + src: stoneImg, + left: "68%", + top: "20%", + width: "30vw", + label: "智聊助手", + labelSize: "1.2rem", + labelOffsetY: "-18%", + mobile: { width: "45vw", top: "26%", left: "71%" }, + landscape: { width: "25vw", left: "66%", top: "5%" }, + }, - { id: 'stone', src: stoneImg, left: '68%', top: '20%', width: '30vw', label: '智聊助手', labelSize: '1.2rem', labelOffsetY: '-18%' }, + { + id: "crab", + src: crabImg, + left: "55%", + top: "12%", + width: "6vw", + zIndex: 10, + mobile: { width: "8vw", left: "49%", top: "18%" }, + landscape: { width: "6vw", left: "53%", top: "10%" }, + }, - { id: 'crab', src: crabImg, left: '55%', top: '12%', width: '6vw', zIndex: 10 }, + { + id: "shell", + src: shellImg, + left: "50.75%", + top: "60%", + width: "5vw", + rotate: 0, + zIndex: 10, + label: "生成相片", + labelSize: "1.2rem", + mobile: { width: "12vw", left: "52%", top: "43%" }, + landscape: { width: "6vw", left: "48%", top: "53%" }, + }, - { id: 'shell', src: shellImg, left: '50.75%', top: '60%', width: '5vw', rotate: 0, zIndex: 10, label: '生成相片', labelSize: '1.2rem' }, + { + id: "chair", + src: chairImg, + left: "56%", + top: "2.5%", + width: "40vw", + rotate: 0, + label: "同频任务", + labelSize: "1.2rem", + labelOffsetY: "-6%", + mobile: { width: "40vw", left: "60%", top: "30%" }, + landscape: { width: "30vw", left: "56%", top: "-3%" }, + }, - { id: 'chair', src: chairImg, left: '56%', top: '2.5%', width: '40vw', rotate: 0, label: '同频任务', labelSize: '1.2rem', labelOffsetY: '-6%' }, - - { id: 'mailbox', src: mailboxImg, left: '51.5%', top: '22%', width: '12vw', rotate: 0, label: '心愿签', labelSize: '1.2rem' }, - -] + { + id: "mailbox", + src: mailboxImg, + left: "51.5%", + top: "22%", + width: "12vw", + rotate: 0, + label: "心愿签", + labelSize: "1.2rem", + mobile: { width: "15vw", left: "56%", top: "31%" }, + landscape: { width: "8vw", left: "50%", top: "15%" }, + }, +]; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 725c0635..67cffa59 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,7 +5,12 @@ import App from "./App.vue"; import "./styles/reset.css"; import "./styles/variables.css"; import "./styles/animations.css"; +import "./styles/mobile.css"; const app = createApp(App); app.use(createPinia()); app.mount("#app"); + +if (import.meta.env.PROD && "serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js").catch(() => {}); +} diff --git a/frontend/src/styles/mobile.css b/frontend/src/styles/mobile.css new file mode 100644 index 00000000..6bb819a3 --- /dev/null +++ b/frontend/src/styles/mobile.css @@ -0,0 +1,41 @@ +/* ── Global mobile overrides ── */ + +@media (max-width: 768px) { + body { + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; + } + + .scene { + touch-action: pan-x; + } + + /* Prevent any accidental overflow causing gray margins */ + html, body, #app { + width: 100%; + height: 100%; + overflow: hidden; + } +} + +/* ── Portrait orientation on mobile ── */ +@media (max-width: 768px) and (orientation: portrait) { + .scene { + height: 100vh; + height: 100dvh; + } +} + +/* ── Landscape orientation on mobile ── */ +@media (max-height: 500px) and (orientation: landscape) { + .scene { + height: 100vh; + height: 100dvh; + } + + /* NavBar needs to be more compact in landscape */ + .nav-wrapper { + top: 8px; + right: 8px; + } +} diff --git a/frontend/src/styles/reset.css b/frontend/src/styles/reset.css index 06ccb246..e811f745 100644 --- a/frontend/src/styles/reset.css +++ b/frontend/src/styles/reset.css @@ -14,6 +14,13 @@ html, body { background: #f5e0c8; } +#app { + width: 100%; + height: 100%; + overflow: hidden; + position: relative; +} + body.dragging { cursor: grabbing; }