落地页

This commit is contained in:
2026-07-21 14:20:00 +08:00
parent 8cf4b6846c
commit a7c060c53f
4 changed files with 373 additions and 0 deletions

View File

@@ -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() {
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/wechat-demo" element={<WechatDemo />} />
{/* 展会二维码落地:/photo?img_id={uid} → CDN {uid}.jpg */}
<Route path="/photo" element={<Photo />} />
</Routes>
</div>
);

View File

@@ -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}`;
};

186
src/pages/photo.css Normal file
View File

@@ -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);
}

145
src/pages/photo.tsx Normal file
View File

@@ -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<LoadResult | null>(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 (
<div className="photo-page">
<header className="photo-page__header">
<p className="photo-page__brand">duooomi</p>
<h1 className="photo-page__title"></h1>
</header>
<main className="photo-page__stage">
<div className="photo-page__frame">
{/* 缺参 / 非法 */}
{query.reason !== "ok" && (
<div className="photo-page__placeholder">
<div className="photo-page__placeholder-icon" aria-hidden>
!
</div>
<p>
<strong></strong>
<br />
{reasonCopy[query.reason]}
</p>
</div>
)}
{/* 加载中 */}
{query.reason === "ok" && loadState === "loading" && (
<div className="photo-page__placeholder">
<div className="photo-page__spinner" aria-hidden />
<p></p>
</div>
)}
{/* CDN 失败(文件尚未上传 / 路径不对 / 网络) */}
{query.reason === "ok" && loadState === "error" && (
<div className="photo-page__placeholder">
<div className="photo-page__placeholder-icon" aria-hidden>
?
</div>
<p>
<strong></strong>
<br />
</p>
</div>
)}
{/* 真图:长按可保存(微信内尤其依赖这个) */}
{photoUrl && canShowImage && (
<img
key={query.imgId ?? "none"}
className={
loadState === "ready"
? "photo-page__img"
: "photo-page__img photo-page__img--hidden"
}
src={photoUrl}
alt="现场留影"
decoding="async"
onLoad={onLoad}
onError={onError}
/>
)}
</div>
</main>
<footer className="photo-page__footer">
{loadState === "ready" && (
<p className="photo-page__hint"></p>
)}
<div className="photo-page__actions">
<button
type="button"
className="photo-page__btn photo-page__btn--primary"
disabled={loadState !== "ready" || !photoUrl}
onClick={openFull}
>
</button>
<a className="photo-page__btn photo-page__btn--ghost" href="/">
duooomi
</a>
</div>
</footer>
</div>
);
};
export default Photo;