refactor: 优化界面样式

This commit is contained in:
2026-07-13 16:47:38 +08:00
parent 754effa2b3
commit 7c69b6cd8a
6 changed files with 240 additions and 80 deletions
@@ -0,0 +1,106 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PrivateRoomMoment } from "@/data/dto/private-room";
import { PrivateRoomPostCard } from "../private-room-post-card";
function makeMoment(
overrides: Partial<Parameters<typeof PrivateRoomMoment.from>[0]> = {},
): PrivateRoomMoment {
return PrivateRoomMoment.from({
momentId: "schedule:91",
author: {
id: "elio",
name: "Elio Silvestri",
avatarUrl: null,
},
publishedAt: "2026-07-01T08:11:35+00:00",
timeText: "7 days ago",
title: "Private morning",
content: "A quiet morning by the water.",
text: "A quiet morning by the water.",
textPreview: "Unlock to view private room photos",
mediaCount: 1,
images: [],
locked: false,
unlockCost: 40,
...overrides,
});
}
function renderCard(moment: PrivateRoomMoment): string {
return renderToStaticMarkup(
<PrivateRoomPostCard
moment={moment}
displayName="Elio Silvestri"
avatarUrl="/images/chat/pic-chat-elio.png"
index={0}
isUnlocking={false}
onUnlock={() => undefined}
/>,
);
}
describe("PrivateRoomPostCard", () => {
it("renders only the first unlocked image and a multi-photo badge", () => {
const html = renderCard(
makeMoment({
mediaCount: 3,
images: [
{
url: "/images/private-room/banner.png",
type: "image",
locked: false,
index: 0,
},
{
url: "/images/splash/pic-bg-home.png",
type: "image",
locked: false,
index: 1,
},
],
}),
);
expect(html).toContain("banner.png");
expect(html).not.toContain("pic-bg-home.png");
expect(html).toContain("3 photos");
expect(html).toContain("A quiet morning by the water.");
});
it("renders a locked cover without attempting to render a missing URL", () => {
const html = renderCard(
makeMoment({
locked: true,
mediaCount: 2,
content: null,
text: null,
images: [
{
url: null,
type: "image",
locked: true,
index: 0,
},
],
}),
);
expect(html).toContain(
'aria-label="Unlock 2 private room photos from Elio Silvestri"',
);
expect(html).toContain("Unlock to view private room photos");
expect(html).toContain("40 credits");
expect(html).toContain("2 photos");
expect(html).not.toContain("No photo available");
});
it("renders an empty cover when an unlocked moment has no first image", () => {
const html = renderCard(makeMoment());
expect(html).toContain('aria-label="No private moment photo available"');
expect(html).toContain("No photo available");
});
});
@@ -23,11 +23,12 @@ export function PrivateRoomPostCard({
isUnlocking, isUnlocking,
onUnlock, onUnlock,
}: PrivateRoomPostCardProps) { }: PrivateRoomPostCardProps) {
const isLocked = moment.locked || moment.lockDetail.locked; const isLocked = moment.locked;
const imageUrls = moment.images const firstImage = moment.images[0];
.filter((image) => !image.locked && image.url) const firstImageUrl =
.map((image) => image.url as string); !isLocked && firstImage?.locked !== true ? firstImage?.url : null;
const photoCount = moment.mediaCount || moment.images.length || 1; const photoCount = moment.mediaCount || moment.images.length || 1;
const postText = moment.text || moment.content;
return ( return (
<article <article
@@ -55,10 +56,6 @@ export function PrivateRoomPostCard({
</time> </time>
</header> </header>
{moment.text || moment.content ? (
<p className={styles.postText}>{moment.text || moment.content}</p>
) : null}
{isLocked ? ( {isLocked ? (
<button <button
type="button" type="button"
@@ -67,33 +64,49 @@ export function PrivateRoomPostCard({
onClick={onUnlock} onClick={onUnlock}
aria-label={`Unlock ${photoCount} private room photos from ${displayName}`} aria-label={`Unlock ${photoCount} private room photos from ${displayName}`}
> >
<div className={styles.lockedPreviewContent}>
<div className={styles.previewIcon}> <div className={styles.previewIcon}>
<LockKeyhole size={18} aria-hidden="true" /> <LockKeyhole size={22} aria-hidden="true" />
</div> </div>
<div className={styles.previewText}> <div className={styles.previewText}>
<strong>{moment.unlockCost} credits</strong>
<span>{moment.textPreview || "Unlock to view"}</span> <span>{moment.textPreview || "Unlock to view"}</span>
<strong>{moment.unlockCost} credits</strong>
<small> <small>
<ImageIcon size={13} aria-hidden="true" /> <ImageIcon size={13} aria-hidden="true" />
{photoCount} {photoCount === 1 ? "photo" : "photos"} {photoCount} {photoCount === 1 ? "photo" : "photos"}
</small> </small>
</div> </div>
</div>
</button> </button>
) : ( ) : firstImageUrl ? (
<div className={styles.imageGrid}> <div className={styles.mediaCover}>
{imageUrls.map((url, imageIndex) => (
<Image <Image
key={`${moment.momentId}-${imageIndex}`} src={firstImageUrl}
src={url} alt={`${moment.title || displayName} private moment`}
alt="" width={720}
width={420} height={900}
height={420} className={styles.momentCoverImage}
className={styles.unlockedImage} sizes="(max-width: 540px) calc(100vw - 36px), 484px"
sizes="(max-width: 540px) 86vw, 460px"
/> />
))} {photoCount > 1 ? (
<span className={styles.mediaCountBadge}>
<ImageIcon size={13} aria-hidden="true" />
{photoCount} photos
</span>
) : null}
</div>
) : (
<div
className={styles.emptyMediaCover}
role="img"
aria-label="No private moment photo available"
>
<ImageIcon size={32} aria-hidden="true" />
<span>No photo available</span>
</div> </div>
)} )}
{postText ? <p className={styles.postText}>{postText}</p> : null}
</article> </article>
); );
} }
@@ -134,27 +134,21 @@
} }
.primaryCta { .primaryCta {
width: fit-content; width: calc(100% - var(--app-safe-left, 0px) - var(--app-safe-right, 0px) - clamp(36px, 10.37vw, 56px));
max-width: calc(100% - var(--app-safe-left, 0px) - var(--app-safe-right, 0px) - clamp(36px, 10.37vw, 56px)); align-self: center;
align-self: flex-start; margin: 0 auto;
margin: min-height: 50px;
0 padding: 0 clamp(20px, 5.185vw, 28px);
calc(var(--app-safe-right, 0px) + clamp(18px, 5.185vw, 28px)) border: 1px solid rgba(255, 255, 255, 0.48);
0
calc(var(--app-safe-left, 0px) + clamp(18px, 5.185vw, 28px));
min-height: 36px;
padding: 0 18px;
border: 1px solid rgba(255, 255, 255, 0.62);
background: background:
linear-gradient(135deg, rgba(129, 125, 119, 0.94), rgba(104, 101, 96, 0.92)), linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 38%),
rgba(116, 112, 106, 0.9); linear-gradient(90deg, #ff67e0, var(--color-accent, #f84d96));
color: #ffffff; color: #ffffff;
font-size: clamp(11px, 2.963vw, 14px); font-size: clamp(14px, 3.333vw, 16px);
line-height: 1.2; line-height: 1.2;
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 10px 22px rgba(47, 35, 40, 0.12); 0 14px 30px rgba(248, 77, 150, 0.26);
backdrop-filter: blur(14px);
} }
.primaryCta span { .primaryCta span {
@@ -291,23 +285,29 @@
} }
.lockedPreview { .lockedPreview {
display: grid; position: relative;
display: flex;
width: 100%; width: 100%;
grid-template-columns: auto 1fr; aspect-ratio: 4 / 5;
align-items: center; align-items: center;
gap: 14px; justify-content: center;
margin-top: 14px; margin-top: 14px;
padding: clamp(16px, 4.074vw, 22px); padding: clamp(16px, 4.074vw, 22px);
border: 1px solid rgba(255, 116, 159, 0.24); border: 1px solid rgba(255, 116, 159, 0.24);
border-radius: clamp(20px, 5.185vw, 28px); border-radius: clamp(20px, 5.185vw, 28px);
background: background:
linear-gradient(135deg, rgba(255, 246, 239, 0.98), rgba(255, 231, 240, 0.9)), radial-gradient(circle at 22% 18%, rgba(255, 255, 255, 0.94), transparent 29%),
radial-gradient(circle at 82% 82%, rgba(255, 103, 224, 0.18), transparent 35%),
linear-gradient(145deg, rgba(255, 246, 239, 0.98), rgba(255, 225, 239, 0.94)),
#ffffff; #ffffff;
color: #21171b; color: #21171b;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
text-align: left; overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 12px 28px rgba(255, 116, 159, 0.12); text-align: center;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 14px 32px rgba(255, 116, 159, 0.14);
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
} }
@@ -316,22 +316,35 @@
opacity: 0.72; opacity: 0.72;
} }
.lockedPreviewContent {
position: relative;
z-index: 1;
display: flex;
max-width: 260px;
flex-direction: column;
align-items: center;
gap: 14px;
}
.previewIcon { .previewIcon {
display: grid; display: grid;
width: 46px; width: 58px;
height: 46px; height: 58px;
place-items: center; place-items: center;
border-radius: 17px; border: 1px solid rgba(255, 255, 255, 0.68);
background: linear-gradient(135deg, #ff7aa9, #ffb36d); border-radius: 20px;
background: linear-gradient(135deg, #ff7ac3, var(--color-accent, #f84d96));
color: #ffffff; color: #ffffff;
box-shadow: 0 12px 24px rgba(255, 104, 146, 0.24); box-shadow: 0 14px 28px rgba(248, 77, 150, 0.26);
} }
.previewText { .previewText {
display: flex; display: flex;
min-width: 0; min-width: 0;
flex-direction: column; flex-direction: column;
gap: 4px; align-items: center;
gap: 8px;
text-align: center;
} }
.previewText strong { .previewText strong {
@@ -350,26 +363,63 @@
.previewText small { .previewText small {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 5px; gap: 5px;
color: #a94c64; color: #a94c64;
font-size: clamp(12px, 2.963vw, 15px); font-size: clamp(12px, 2.963vw, 15px);
font-weight: 760; font-weight: 760;
} }
.imageGrid { .mediaCover {
display: grid; position: relative;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 10px;
margin-top: 14px; margin-top: 14px;
overflow: hidden;
border-radius: clamp(20px, 5.185vw, 28px);
background: #f3eef0;
box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14);
} }
.unlockedImage { .momentCoverImage {
display: block;
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: 1; aspect-ratio: 4 / 5;
border-radius: 22px;
object-fit: cover; object-fit: cover;
box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14); }
.mediaCountBadge {
position: absolute;
top: 12px;
right: 12px;
display: inline-flex;
min-height: 30px;
align-items: center;
gap: 6px;
padding: 0 11px;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 999px;
background: rgba(28, 19, 23, 0.72);
color: #ffffff;
font-size: 12px;
font-weight: 800;
box-shadow: 0 8px 18px rgba(28, 19, 23, 0.18);
backdrop-filter: blur(12px);
}
.emptyMediaCover {
display: grid;
width: 100%;
aspect-ratio: 4 / 5;
place-items: center;
align-content: center;
gap: 10px;
margin-top: 14px;
border: 1px solid rgba(44, 29, 34, 0.08);
border-radius: clamp(20px, 5.185vw, 28px);
background: linear-gradient(145deg, #f7f2ef, #efe7e9);
color: #8d747c;
font-size: 14px;
font-weight: 760;
} }
.statusCard { .statusCard {
@@ -6,11 +6,7 @@ import {
export class PrivateRoomMoment { export class PrivateRoomMoment {
declare readonly momentId: string; declare readonly momentId: string;
declare readonly source: string;
declare readonly sourceId: string;
declare readonly characterId: string;
declare readonly author: PrivateRoomMomentData["author"]; declare readonly author: PrivateRoomMomentData["author"];
declare readonly createdAt: string | null;
declare readonly publishedAt: string | null; declare readonly publishedAt: string | null;
declare readonly timeText: string; declare readonly timeText: string;
declare readonly title: string; declare readonly title: string;
@@ -20,12 +16,7 @@ export class PrivateRoomMoment {
declare readonly mediaCount: number; declare readonly mediaCount: number;
declare readonly images: PrivateRoomMomentData["images"]; declare readonly images: PrivateRoomMomentData["images"];
declare readonly locked: boolean; declare readonly locked: boolean;
declare readonly unlocked: boolean;
declare readonly unlockCost: number; declare readonly unlockCost: number;
declare readonly unlockCostPerImage: number;
declare readonly requiredCredits: number;
declare readonly currency: string;
declare readonly lockDetail: PrivateRoomMomentData["lockDetail"];
protected constructor(input: PrivateRoomMomentInput, freeze = true) { protected constructor(input: PrivateRoomMomentInput, freeze = true) {
const data = PrivateRoomMomentSchema.parse(input); const data = PrivateRoomMomentSchema.parse(input);
@@ -47,5 +38,3 @@ export class PrivateRoomMoment {
} }
export type PrivateRoomProfile = import("@/data/schemas/private-room").PrivateRoomProfileData; export type PrivateRoomProfile = import("@/data/schemas/private-room").PrivateRoomProfileData;
export type PrivateRoomImage = import("@/data/schemas/private-room").PrivateRoomImageData;
export type PrivateRoomLockDetail = import("@/data/schemas/private-room").PrivateRoomLockDetailData;
@@ -8,6 +8,8 @@ import {
import { PrivateRoomMoment } from "../private_room_moment"; import { PrivateRoomMoment } from "../private_room_moment";
export class PrivateRoomUnlockResponse extends PrivateRoomMoment { export class PrivateRoomUnlockResponse extends PrivateRoomMoment {
declare readonly unlocked: boolean;
declare readonly requiredCredits: number;
declare readonly reason: PrivateRoomUnlockReason | string; declare readonly reason: PrivateRoomUnlockReason | string;
declare readonly creditsCharged: number; declare readonly creditsCharged: number;
declare readonly creditBalance: number; declare readonly creditBalance: number;
@@ -186,7 +186,7 @@ describe("privateRoomMachine", () => {
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.items[0]?.unlocked).toBe(true); expect(context.items[0]?.locked).toBe(false);
expect(context.items[0]?.images[0]?.url).toContain("https://"); expect(context.items[0]?.images[0]?.url).toContain("https://");
expect(context.unlockSuccessNonce).toBe(1); expect(context.unlockSuccessNonce).toBe(1);
@@ -219,7 +219,7 @@ describe("privateRoomMachine", () => {
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.items[0]?.unlocked).toBe(false); expect(context.items[0]?.locked).toBe(true);
expect(context.unlockPaywallRequest).toMatchObject({ expect(context.unlockPaywallRequest).toMatchObject({
momentId: "schedule:91", momentId: "schedule:91",
reason: "insufficient_credits", reason: "insufficient_credits",