diff --git a/src/App.tsx b/src/App.tsx index 02e7204..1853cea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import Home from './pages/home'; import Terms from './pages/terms'; import Privacy from './pages/privacy'; import WechatDemo from './pages/wechat-demo'; +import Photo from './pages/photo'; function App() { return ( @@ -13,6 +14,8 @@ function App() { } /> } /> } /> + {/* 展会二维码落地:/photo?img_id={uid} → CDN {uid}.jpg */} + } /> ); diff --git a/src/lib/exhibition-photo.ts b/src/lib/exhibition-photo.ts new file mode 100644 index 0000000..8f24c94 --- /dev/null +++ b/src/lib/exhibition-photo.ts @@ -0,0 +1,39 @@ +/** + * 展会扫码取图约定: + * - 二维码指向:/photo?img_id={uid} + * - CDN 文件名:{uid}.jpg(可扩展) + * + * 只负责 query → 安全 imgId → CDN URL,不碰 UI。 + */ + +const DEFAULT_CDN_BASE = "https://cdn.bowong.cc/upload/exhibition"; + +/** 仅允许路径安全字符,避免 ../ 等穿越 */ +const SAFE_IMG_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/; + +const DEFAULT_EXT = "jpg"; + +export type PhotoQuery = { + imgId: string | null; + /** 缺参 / 非法 */ + reason: "ok" | "missing" | "invalid"; +}; + +export const getPhotoCdnBase = (): string => { + const fromEnv = import.meta.env.VITE_PHOTO_CDN_BASE as string | undefined; + const base = (fromEnv?.trim() || DEFAULT_CDN_BASE).replace(/\/+$/, ""); + return base; +}; + +export const parsePhotoQuery = (search: string): PhotoQuery => { + const raw = new URLSearchParams(search).get("img_id")?.trim() ?? ""; + if (!raw) return { imgId: null, reason: "missing" }; + if (!SAFE_IMG_ID.test(raw)) return { imgId: null, reason: "invalid" }; + return { imgId: raw, reason: "ok" }; +}; + +/** CDN 最终地址:{base}/{imgId}.jpg */ +export const buildPhotoUrl = (imgId: string, ext: string = DEFAULT_EXT): string => { + const safeExt = /^[a-zA-Z0-9]{1,8}$/.test(ext) ? ext : DEFAULT_EXT; + return `${getPhotoCdnBase()}/${encodeURIComponent(imgId)}.${safeExt}`; +}; \ No newline at end of file diff --git a/src/pages/photo.css b/src/pages/photo.css new file mode 100644 index 0000000..a304a2d --- /dev/null +++ b/src/pages/photo.css @@ -0,0 +1,186 @@ +.photo-page { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + background: #000; + color: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, + sans-serif; + -webkit-font-smoothing: antialiased; +} + +.photo-page__header { + flex-shrink: 0; + padding: 16px 20px 8px; + text-align: center; +} + +.photo-page__brand { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--primary, #fae307); +} + +.photo-page__title { + margin: 6px 0 0; + font-size: 13px; + font-weight: 400; + color: rgba(245, 245, 245, 0.55); +} + +.photo-page__stage { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 12px 16px 8px; + min-height: 0; +} + +.photo-page__frame { + position: relative; + width: 100%; + max-width: 520px; + border-radius: 16px; + overflow: hidden; + background: #111; + border: 1px solid rgba(250, 227, 7, 0.12); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +} + +.photo-page__img { + display: block; + width: 100%; + height: auto; + max-height: min(72vh, 720px); + object-fit: contain; + background: #0a0a0a; + -webkit-touch-callout: default; + user-select: none; +} + +.photo-page__img--hidden { + position: absolute; + opacity: 0; + pointer-events: none; + width: 1px; + height: 1px; +} + +.photo-page__placeholder { + width: 100%; + min-height: min(56vh, 420px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 32px 24px; + text-align: center; +} + +.photo-page__placeholder-icon { + width: 48px; + height: 48px; + border-radius: 50%; + border: 2px solid rgba(250, 227, 7, 0.35); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: var(--primary, #fae307); +} + +.photo-page__placeholder p { + margin: 0; + font-size: 15px; + line-height: 1.55; + color: rgba(245, 245, 245, 0.72); + max-width: 280px; +} + +.photo-page__placeholder p strong { + color: #f5f5f5; + font-weight: 600; +} + +.photo-page__spinner { + width: 28px; + height: 28px; + border: 2px solid rgba(250, 227, 7, 0.2); + border-top-color: var(--primary, #fae307); + border-radius: 50%; + animation: photo-spin 0.7s linear infinite; +} + +@keyframes photo-spin { + to { + transform: rotate(360deg); + } +} + +.photo-page__footer { + flex-shrink: 0; + padding: 12px 20px calc(20px + env(safe-area-inset-bottom, 0px)); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.photo-page__hint { + margin: 0; + font-size: 12px; + color: rgba(245, 245, 245, 0.45); + text-align: center; +} + +.photo-page__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + width: 100%; + max-width: 400px; +} + +.photo-page__btn { + flex: 1 1 140px; + min-height: 44px; + padding: 10px 16px; + border-radius: 999px; + font-size: 14px; + font-weight: 600; + text-align: center; + text-decoration: none; + cursor: pointer; + border: none; + appearance: none; + font-family: inherit; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.photo-page__btn:active { + transform: scale(0.98); +} + +.photo-page__btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.photo-page__btn--primary { + background: var(--primary, #fae307); + color: #111; +} + +.photo-page__btn--ghost { + background: transparent; + color: #f5f5f5; + border: 1px solid rgba(245, 245, 245, 0.28); +} \ No newline at end of file diff --git a/src/pages/photo.tsx b/src/pages/photo.tsx new file mode 100644 index 0000000..99ecbce --- /dev/null +++ b/src/pages/photo.tsx @@ -0,0 +1,145 @@ +import { useCallback, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { buildPhotoUrl, parsePhotoQuery } from "@/lib/exhibition-photo"; +import "./photo.css"; + +type LoadState = "idle" | "loading" | "ready" | "error"; + +/** 与 imgId 绑定的加载结果;id 变了自动视为 loading,无需 effect 重置 */ +type LoadResult = { + imgId: string; + status: "ready" | "error"; +}; + +const reasonCopy = { + missing: "未找到图片编号,请重新扫描二维码。", + invalid: "图片编号无效,请重新扫描二维码。", +} as const; + +const resolveLoadState = ( + imgId: string | null, + result: LoadResult | null, +): LoadState => { + if (!imgId) return "idle"; + if (result?.imgId === imgId) return result.status; + return "loading"; +}; + +const Photo = () => { + const [searchParams] = useSearchParams(); + const query = useMemo( + () => parsePhotoQuery(searchParams.toString()), + [searchParams], + ); + + const photoUrl = useMemo( + () => (query.imgId ? buildPhotoUrl(query.imgId) : null), + [query.imgId], + ); + + const [result, setResult] = useState(null); + const loadState = resolveLoadState(query.imgId, result); + + const onLoad = useCallback(() => { + if (!query.imgId) return; + setResult({ imgId: query.imgId, status: "ready" }); + }, [query.imgId]); + + const onError = useCallback(() => { + if (!query.imgId) return; + setResult({ imgId: query.imgId, status: "error" }); + }, [query.imgId]); + + const openFull = useCallback(() => { + if (photoUrl) window.open(photoUrl, "_blank", "noopener,noreferrer"); + }, [photoUrl]); + + const canShowImage = Boolean(photoUrl) && loadState !== "error"; + + return ( +
+
+

duooomi

+

现场留影

+
+ +
+
+ {/* 缺参 / 非法 */} + {query.reason !== "ok" && ( +
+
+ ! +
+

+ 无法打开照片 +
+ {reasonCopy[query.reason]} +

+
+ )} + + {/* 加载中 */} + {query.reason === "ok" && loadState === "loading" && ( +
+
+

正在加载你的照片…

+
+ )} + + {/* CDN 失败(文件尚未上传 / 路径不对 / 网络) */} + {query.reason === "ok" && loadState === "error" && ( +
+
+ ? +
+

+ 照片还没准备好 +
+ 可能仍在生成或上传中,请稍后再扫一次。 +

+
+ )} + + {/* 真图:长按可保存(微信内尤其依赖这个) */} + {photoUrl && canShowImage && ( + 现场留影 + )} +
+
+ + +
+ ); +}; + +export default Photo; \ No newline at end of file