落地页
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
39
src/lib/exhibition-photo.ts
Normal file
39
src/lib/exhibition-photo.ts
Normal 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
186
src/pages/photo.css
Normal 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
145
src/pages/photo.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user