完善 H5 鉴权与登录流程
This commit is contained in:
@@ -13,6 +13,7 @@ OSS_BUCKET_NAME="one-feel-bucket"
|
||||
# 可选:OSS_REGION,默认从 OSS_ENDPOINT 推导
|
||||
# OSS_REGION="oss-cn-guangzhou"
|
||||
VITE_API_BASE_URL="http://localhost:4000"
|
||||
CORS_ORIGINS="https://wanderq.nianxx.com"
|
||||
VITE_MEDIA_PUBLIC_BASE_URL=""
|
||||
VITE_WECOM_CUSTOMER_SERVICE_URL="https://work.weixin.qq.com/kfid/your-customer-service-link"
|
||||
VITE_WECOM_CORP_ID="ww_your_corp_id"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "H5AuthTicket" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "H5AuthTicket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "H5AuthTicket_tokenHash_key" ON "H5AuthTicket"("tokenHash");
|
||||
CREATE INDEX "H5AuthTicket_expiresAt_idx" ON "H5AuthTicket"("expiresAt");
|
||||
CREATE INDEX "H5AuthTicket_userId_createdAt_idx" ON "H5AuthTicket"("userId", "createdAt");
|
||||
|
||||
ALTER TABLE "H5AuthTicket"
|
||||
ADD CONSTRAINT "H5AuthTicket_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "MiniProgramUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -264,12 +264,26 @@ model MiniProgramUser {
|
||||
favorites MiniProgramFavorite[]
|
||||
histories MiniProgramHistory[]
|
||||
wecomContacts WecomExternalContact[]
|
||||
h5AuthTickets H5AuthTicket[]
|
||||
|
||||
@@unique([appId, openId])
|
||||
@@index([phone])
|
||||
@@index([status, lastLoginAt])
|
||||
}
|
||||
|
||||
model H5AuthTicket {
|
||||
id String @id @default(uuid())
|
||||
tokenHash String @unique
|
||||
userId String
|
||||
user MiniProgramUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([expiresAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model MiniProgramFavorite {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
|
||||
@@ -1,12 +1,69 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
type JwtPrincipal = {
|
||||
export type JwtPrincipal = {
|
||||
sub?: string;
|
||||
kind?: "admin" | "mini_program_user";
|
||||
role?: string;
|
||||
appId?: string;
|
||||
channel?: "mini_program" | "h5";
|
||||
};
|
||||
|
||||
export const H5_SESSION_COOKIE = "wanqu_h5_session";
|
||||
const H5_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
function readCookie(request: FastifyRequest, name: string) {
|
||||
const raw = request.headers.cookie;
|
||||
if (!raw) return undefined;
|
||||
for (const item of raw.split(";")) {
|
||||
const separator = item.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
if (key !== name) continue;
|
||||
const value = item.slice(separator + 1).trim();
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPublicSessionToken(request: FastifyRequest) {
|
||||
const authorization = request.headers.authorization;
|
||||
if (authorization?.startsWith("Bearer ")) return authorization.slice("Bearer ".length).trim();
|
||||
return readCookie(request, H5_SESSION_COOKIE);
|
||||
}
|
||||
|
||||
function verifyPublicSession(request: FastifyRequest) {
|
||||
const token = readPublicSessionToken(request);
|
||||
if (!token) throw new Error("Missing public session");
|
||||
const principal = request.server.jwt.verify<JwtPrincipal>(token);
|
||||
request.user = principal;
|
||||
return principal;
|
||||
}
|
||||
|
||||
export function setH5SessionCookie(reply: FastifyReply, token: string) {
|
||||
const secure = process.env.NODE_ENV === "production" || process.env.H5_COOKIE_SECURE === "1";
|
||||
const sameSite = secure ? "None" : "Lax";
|
||||
const attributes = [
|
||||
H5_SESSION_COOKIE + "=" + encodeURIComponent(token),
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"Max-Age=" + H5_SESSION_MAX_AGE_SECONDS,
|
||||
"SameSite=" + sameSite,
|
||||
];
|
||||
if (secure) attributes.push("Secure");
|
||||
reply.header("Set-Cookie", attributes.join("; "));
|
||||
}
|
||||
|
||||
export function clearH5SessionCookie(reply: FastifyReply) {
|
||||
reply.header(
|
||||
"Set-Cookie",
|
||||
H5_SESSION_COOKIE + "=; Path=/; HttpOnly; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax",
|
||||
);
|
||||
}
|
||||
|
||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
@@ -27,15 +84,13 @@ export function getActorId(request: FastifyRequest) {
|
||||
|
||||
export async function requireMiniProgramUser(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
return reply.status(401).send({ message: "请先登录小程序" });
|
||||
}
|
||||
|
||||
const user = request.user as JwtPrincipal | undefined;
|
||||
const user = verifyPublicSession(request);
|
||||
if (!user?.sub || user.kind !== "mini_program_user") {
|
||||
return reply.status(401).send({ message: "小程序登录状态无效" });
|
||||
}
|
||||
} catch {
|
||||
return reply.status(401).send({ message: "请先登录小程序" });
|
||||
}
|
||||
}
|
||||
|
||||
export function getMiniProgramUserId(request: FastifyRequest) {
|
||||
@@ -44,11 +99,10 @@ export function getMiniProgramUserId(request: FastifyRequest) {
|
||||
}
|
||||
|
||||
export async function getOptionalMiniProgramUserId(request: FastifyRequest) {
|
||||
if (!request.headers.authorization) return undefined;
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const user = verifyPublicSession(request);
|
||||
return user?.kind === "mini_program_user" ? user.sub : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return getMiniProgramUserId(request);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
import { getMiniProgramUserId, getOptionalMiniProgramUserId, requireAdmin, requireMiniProgramUser } from "../auth.js";
|
||||
import {
|
||||
clearH5SessionCookie,
|
||||
getMiniProgramUserId,
|
||||
getOptionalMiniProgramUserId,
|
||||
requireAdmin,
|
||||
requireMiniProgramUser,
|
||||
setH5SessionCookie,
|
||||
} from "../auth.js";
|
||||
import { DESTINATION_RECOMMENDATION_LIMIT, normalizeDestinationRecommendationIds } from "../destination-page.js";
|
||||
import {
|
||||
cloneHomeModuleContent,
|
||||
@@ -31,6 +38,7 @@ import {
|
||||
homeModuleContentSchema,
|
||||
homeModuleCreateSchema,
|
||||
homeModuleUpdateSchema,
|
||||
h5TicketExchangeSchema,
|
||||
leadAdminNoteSchema,
|
||||
leadCreateSchema,
|
||||
leadFollowupCreateSchema,
|
||||
@@ -40,6 +48,7 @@ import {
|
||||
miniProgramActivitySyncSchema,
|
||||
miniProgramHistorySchema,
|
||||
miniProgramLoginSchema,
|
||||
miniProgramPhoneLoginSchema,
|
||||
miniProgramPhoneSchema,
|
||||
miniProgramProfileUpdateSchema,
|
||||
miniProgramUserPatchSchema,
|
||||
@@ -222,6 +231,13 @@ type MemoryHistory = {
|
||||
lastViewedAt: string;
|
||||
};
|
||||
|
||||
type MemoryH5Ticket = {
|
||||
tokenHash: string;
|
||||
userId: string;
|
||||
expiresAt: string;
|
||||
consumedAt?: string | null;
|
||||
};
|
||||
|
||||
type SourceProduct = {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -464,6 +480,7 @@ const mediaAssets: Array<{ id: string; url: string; name: string; mimeType: stri
|
||||
const miniProgramUsers: MemoryMiniProgramUser[] = [];
|
||||
const memoryFavorites: MemoryFavorite[] = [];
|
||||
const memoryHistories: MemoryHistory[] = [];
|
||||
const memoryH5Tickets: MemoryH5Ticket[] = [];
|
||||
|
||||
function memoryMaskPhone(phone?: string | null) {
|
||||
if (!phone) return null;
|
||||
@@ -497,6 +514,20 @@ function serializeMemoryUser(user: MemoryMiniProgramUser) {
|
||||
};
|
||||
}
|
||||
|
||||
function issueMemoryH5Ticket(userId: string) {
|
||||
const now = Date.now();
|
||||
const expiresAt = new Date(now + 2 * 60 * 1000).toISOString();
|
||||
const ticket = "memory-" + randomUUID();
|
||||
const tokenHash = createHash("sha256").update(ticket).digest("hex");
|
||||
for (let index = memoryH5Tickets.length - 1; index >= 0; index -= 1) {
|
||||
if (memoryH5Tickets[index].consumedAt || new Date(memoryH5Tickets[index].expiresAt).getTime() <= now) {
|
||||
memoryH5Tickets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
memoryH5Tickets.push({ tokenHash, userId, expiresAt, consumedAt: null });
|
||||
return { ticket, expiresAt };
|
||||
}
|
||||
|
||||
function findMemoryProduct(id: string) {
|
||||
return products.find((product) => product.id === id || String(product.sourceId) === id);
|
||||
}
|
||||
@@ -1112,6 +1143,47 @@ export async function registerMemoryRoutes(app: FastifyInstance) {
|
||||
return { ...lead, phone: lead.phone };
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/wechat-phone-login", async (request, reply) => {
|
||||
const body = miniProgramPhoneLoginSchema.parse(request.body);
|
||||
const now = new Date().toISOString();
|
||||
let user = miniProgramUsers.find((item) => item.openId === "memory-openid");
|
||||
const phone = /^1\d{10}$/.test(body.phoneCode) ? body.phoneCode : "13800000000";
|
||||
if (user?.status === "disabled") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
|
||||
if (user) {
|
||||
user.nickname = user.nickname ?? body.nickname ?? null;
|
||||
user.avatarUrl = user.avatarUrl ?? body.avatarUrl ?? null;
|
||||
user.phone = phone;
|
||||
user.status = user.status === "anonymized" ? "active" : user.status;
|
||||
user.lastLoginAt = now;
|
||||
} else {
|
||||
user = {
|
||||
id: randomUUID(),
|
||||
appId: "mock-mini-program",
|
||||
openId: "memory-openid",
|
||||
nickname: body.nickname ?? null,
|
||||
avatarUrl: body.avatarUrl ?? null,
|
||||
phone,
|
||||
status: "active",
|
||||
source: body.source ?? "mini_program",
|
||||
firstSeenAt: now,
|
||||
lastLoginAt: now,
|
||||
createdAt: now,
|
||||
};
|
||||
miniProgramUsers.push(user);
|
||||
}
|
||||
const linkedLeads = leads.filter((lead) => !lead.userId && lead.phone.replace(/\D/g, "") === phone);
|
||||
linkedLeads.forEach((lead) => { lead.userId = user?.id; });
|
||||
const token = app.jwt.sign({ kind: "mini_program_user", sub: user.id, appId: user.appId, channel: "mini_program" }, { expiresIn: "30d" });
|
||||
const h5Ticket = issueMemoryH5Ticket(user.id);
|
||||
return {
|
||||
token,
|
||||
h5Ticket: h5Ticket.ticket,
|
||||
h5TicketExpiresAt: h5Ticket.expiresAt,
|
||||
linkedLeadCount: linkedLeads.length,
|
||||
user: serializeMemoryUser(user),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/wechat-login", async (request, reply) => {
|
||||
const body = miniProgramLoginSchema.parse(request.body);
|
||||
const now = new Date().toISOString();
|
||||
@@ -1141,6 +1213,32 @@ export async function registerMemoryRoutes(app: FastifyInstance) {
|
||||
return { token, user: serializeMemoryUser(user) };
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/h5-ticket", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
||||
const user = await requireActiveMemoryUser(request, reply);
|
||||
if (!user) return;
|
||||
return issueMemoryH5Ticket(user.id);
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/h5-exchange", async (request, reply) => {
|
||||
const body = h5TicketExchangeSchema.parse(request.body);
|
||||
const tokenHash = createHash("sha256").update(body.ticket).digest("hex");
|
||||
const ticket = memoryH5Tickets.find((item) => item.tokenHash === tokenHash);
|
||||
if (!ticket || ticket.consumedAt || new Date(ticket.expiresAt).getTime() <= Date.now()) {
|
||||
return reply.status(401).send({ message: "登录票据已失效,请重新登录" });
|
||||
}
|
||||
const user = miniProgramUsers.find((item) => item.id === ticket.userId);
|
||||
if (!user || user.status !== "active") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
|
||||
ticket.consumedAt = new Date().toISOString();
|
||||
const sessionToken = app.jwt.sign({ kind: "mini_program_user", sub: user.id, appId: user.appId, channel: "h5" }, { expiresIn: "30d" });
|
||||
setH5SessionCookie(reply, sessionToken);
|
||||
return { user: serializeMemoryUser(user) };
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/logout", async (_request, reply) => {
|
||||
clearH5SessionCookie(reply);
|
||||
return { loggedOut: true };
|
||||
});
|
||||
|
||||
app.get("/api/public/users/me", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
||||
const user = await requireActiveMemoryUser(request, reply);
|
||||
return user ? serializeMemoryUser(user) : undefined;
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { getMiniProgramUserId, getOptionalMiniProgramUserId, requireMiniProgramUser } from "../auth.js";
|
||||
import {
|
||||
clearH5SessionCookie,
|
||||
getMiniProgramUserId,
|
||||
getOptionalMiniProgramUserId,
|
||||
requireMiniProgramUser,
|
||||
setH5SessionCookie,
|
||||
} from "../auth.js";
|
||||
import { DESTINATION_PAGE_CONFIG_ID, normalizeDestinationRecommendationIds } from "../destination-page.js";
|
||||
import { listHomeModules } from "../home-modules.js";
|
||||
import { prisma } from "../prisma.js";
|
||||
import { normalizeSearchPageConfig, publicSearchPageConfig, SEARCH_PAGE_CONFIG_ID } from "../search-page.js";
|
||||
import {
|
||||
bookingCreateSchema,
|
||||
h5TicketExchangeSchema,
|
||||
leadCreateSchema,
|
||||
miniProgramActivitySyncSchema,
|
||||
miniProgramHistorySchema,
|
||||
miniProgramLoginSchema,
|
||||
miniProgramPhoneLoginSchema,
|
||||
miniProgramPhoneSchema,
|
||||
miniProgramProfileUpdateSchema,
|
||||
} from "../schemas.js";
|
||||
@@ -25,6 +35,7 @@ const productQuerySchema = z.object({
|
||||
|
||||
const publicUserProfileSelect = {
|
||||
id: true,
|
||||
appId: true,
|
||||
nickname: true,
|
||||
avatarUrl: true,
|
||||
phone: true,
|
||||
@@ -94,41 +105,14 @@ async function linkHistoricalLeads(userId: string, phone: string) {
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
async function buildActivity(userId: string) {
|
||||
const [favorites, history] = await Promise.all([
|
||||
prisma.miniProgramFavorite.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { destination: true, images: { orderBy: { sortOrder: "asc" } } } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.miniProgramHistory.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { destination: true, images: { orderBy: { sortOrder: "asc" } } } } },
|
||||
orderBy: { lastViewedAt: "desc" },
|
||||
take: 50,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
favorites: favorites.map((item) => ({ ...item.product, savedAt: item.createdAt })),
|
||||
history: history.map((item) => ({ ...item.product, viewedAt: item.lastViewedAt, viewCount: item.viewCount })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
app.post("/api/public/auth/wechat-login", async (request, reply) => {
|
||||
const body = miniProgramLoginSchema.parse(request.body);
|
||||
let session: Awaited<ReturnType<typeof exchangeWechatLoginCode>>;
|
||||
try {
|
||||
session = await exchangeWechatLoginCode(body.code);
|
||||
} catch (error) {
|
||||
return reply.status(502).send({ message: error instanceof Error ? error.message : "微信登录失败,请稍后重试" });
|
||||
}
|
||||
|
||||
async function upsertMiniProgramUser(
|
||||
session: Awaited<ReturnType<typeof exchangeWechatLoginCode>>,
|
||||
body: { nickname?: string | null; avatarUrl?: string | null; source?: string },
|
||||
) {
|
||||
const existing = await prisma.miniProgramUser.findUnique({
|
||||
where: { appId_openId: { appId: getWechatAppId(), openId: session.openid } },
|
||||
});
|
||||
if (existing?.status === "disabled") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
|
||||
if (existing?.status === "disabled") return { user: null, disabled: true as const };
|
||||
|
||||
const user = existing
|
||||
? await prisma.miniProgramUser.update({
|
||||
@@ -153,16 +137,329 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
return { user, disabled: false as const };
|
||||
}
|
||||
|
||||
async function mergeMiniProgramUserRecords(transaction: Prisma.TransactionClient, sourceId: string, targetId: string) {
|
||||
if (sourceId === targetId) return;
|
||||
const [source, target] = await Promise.all([
|
||||
transaction.miniProgramUser.findUnique({ where: { id: sourceId } }),
|
||||
transaction.miniProgramUser.findUnique({ where: { id: targetId } }),
|
||||
]);
|
||||
if (!source || !target) return;
|
||||
|
||||
const sourceFavorites = await transaction.miniProgramFavorite.findMany({ where: { userId: sourceId } });
|
||||
if (sourceFavorites.length) {
|
||||
await transaction.miniProgramFavorite.createMany({
|
||||
data: sourceFavorites.map((favorite) => ({
|
||||
userId: targetId,
|
||||
productId: favorite.productId,
|
||||
createdAt: favorite.createdAt,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await transaction.miniProgramFavorite.deleteMany({ where: { userId: sourceId } });
|
||||
}
|
||||
|
||||
const sourceHistories = await transaction.miniProgramHistory.findMany({ where: { userId: sourceId } });
|
||||
for (const history of sourceHistories) {
|
||||
const existing = await transaction.miniProgramHistory.findUnique({
|
||||
where: { userId_productId: { userId: targetId, productId: history.productId } },
|
||||
});
|
||||
if (existing) {
|
||||
await transaction.miniProgramHistory.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
viewCount: existing.viewCount + history.viewCount,
|
||||
lastViewedAt: existing.lastViewedAt > history.lastViewedAt ? existing.lastViewedAt : history.lastViewedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await transaction.miniProgramHistory.create({
|
||||
data: {
|
||||
userId: targetId,
|
||||
productId: history.productId,
|
||||
viewCount: history.viewCount,
|
||||
lastViewedAt: history.lastViewedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (sourceHistories.length) {
|
||||
await transaction.miniProgramHistory.deleteMany({ where: { userId: sourceId } });
|
||||
}
|
||||
|
||||
await transaction.lead.updateMany({ where: { userId: sourceId }, data: { userId: targetId } });
|
||||
await transaction.wecomExternalContact.updateMany({ where: { miniProgramUserId: sourceId }, data: { miniProgramUserId: targetId } });
|
||||
|
||||
if (source.customerId && source.customerId !== target.customerId) {
|
||||
if (target.customerId) {
|
||||
const [sourceCustomer, targetCustomer] = await Promise.all([
|
||||
transaction.customer.findUnique({ where: { id: source.customerId } }),
|
||||
transaction.customer.findUnique({ where: { id: target.customerId } }),
|
||||
]);
|
||||
await transaction.order.updateMany({ where: { customerId: source.customerId }, data: { customerId: target.customerId } });
|
||||
await transaction.wecomExternalContact.updateMany({
|
||||
where: { customerId: source.customerId },
|
||||
data: { customerId: target.customerId },
|
||||
});
|
||||
if (sourceCustomer && targetCustomer) {
|
||||
await transaction.customer.update({
|
||||
where: { id: target.customerId },
|
||||
data: {
|
||||
name: targetCustomer.name ?? sourceCustomer.name,
|
||||
wechat: targetCustomer.wechat ?? sourceCustomer.wechat,
|
||||
note: targetCustomer.note ?? sourceCustomer.note,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await transaction.miniProgramUser.update({
|
||||
where: { id: targetId },
|
||||
data: { customerId: source.customerId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.miniProgramUser.update({
|
||||
where: { id: targetId },
|
||||
data: {
|
||||
nickname: target.nickname ?? source.nickname,
|
||||
avatarUrl: target.avatarUrl ?? source.avatarUrl,
|
||||
unionId: target.unionId ?? source.unionId,
|
||||
},
|
||||
});
|
||||
await transaction.miniProgramUser.update({
|
||||
where: { id: sourceId },
|
||||
data: {
|
||||
nickname: null,
|
||||
avatarUrl: null,
|
||||
phone: null,
|
||||
phoneAuthorizedAt: null,
|
||||
note: null,
|
||||
customerId: null,
|
||||
status: "anonymized",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function attachVerifiedPhone(userId: string, phone: string) {
|
||||
const normalizedPhone = normalizePhone(phone);
|
||||
const phoneVariants = [normalizedPhone, "86" + normalizedPhone];
|
||||
const result = await prisma.$transaction(async (transaction) => {
|
||||
const current = await transaction.miniProgramUser.findUnique({ where: { id: userId } });
|
||||
if (!current) return { userId, disabled: false, missing: true };
|
||||
|
||||
const phoneOwner = await transaction.miniProgramUser.findFirst({
|
||||
where: {
|
||||
id: { not: userId },
|
||||
OR: [
|
||||
{ phone: { in: phoneVariants } },
|
||||
{ customer: { phone: normalizedPhone } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (phoneOwner?.status === "disabled") return { userId, disabled: true, missing: false };
|
||||
|
||||
let canonicalUserId = userId;
|
||||
if (phoneOwner && phoneOwner.id !== userId) {
|
||||
await mergeMiniProgramUserRecords(transaction, userId, phoneOwner.id);
|
||||
canonicalUserId = phoneOwner.id;
|
||||
}
|
||||
|
||||
let customer = await transaction.customer.findUnique({ where: { phone: normalizedPhone } });
|
||||
if (!customer) {
|
||||
customer = await transaction.customer.create({
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
name: current.nickname ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await transaction.miniProgramUser.update({
|
||||
where: { id: canonicalUserId },
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
phoneAuthorizedAt: new Date(),
|
||||
customerId: customer.id,
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
return { userId: updated.id, disabled: false, missing: false };
|
||||
});
|
||||
|
||||
if (result.missing) return { ...result, linkedLeadCount: 0 };
|
||||
if (result.disabled) return { ...result, linkedLeadCount: 0 };
|
||||
const linkedLeadCount = await linkHistoricalLeads(result.userId, normalizedPhone);
|
||||
return { ...result, linkedLeadCount };
|
||||
}
|
||||
|
||||
function hashH5Ticket(ticket: string) {
|
||||
return createHash("sha256").update(ticket).digest("hex");
|
||||
}
|
||||
|
||||
async function issueH5Ticket(userId: string) {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 2 * 60 * 1000);
|
||||
const ticket = randomBytes(32).toString("base64url");
|
||||
await prisma.h5AuthTicket.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ consumedAt: { not: null } },
|
||||
{ expiresAt: { lt: now } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.h5AuthTicket.create({
|
||||
data: {
|
||||
tokenHash: hashH5Ticket(ticket),
|
||||
userId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
return { ticket, expiresAt };
|
||||
}
|
||||
|
||||
async function getPublicUserSummary(userId: string) {
|
||||
const user = await prisma.miniProgramUser.findUnique({ where: { id: userId }, select: publicUserProfileSelect });
|
||||
if (!user) return null;
|
||||
const [leadCount, favoriteCount, historyCount] = await Promise.all([
|
||||
prisma.lead.count({ where: { userId: user.id } }),
|
||||
prisma.miniProgramFavorite.count({ where: { userId: user.id } }),
|
||||
prisma.miniProgramHistory.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
return { user, counts: { leads: leadCount, favorites: favoriteCount, history: historyCount } };
|
||||
}
|
||||
|
||||
async function buildActivity(userId: string) {
|
||||
const [favorites, history] = await Promise.all([
|
||||
prisma.miniProgramFavorite.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { destination: true, images: { orderBy: { sortOrder: "asc" } } } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.miniProgramHistory.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { destination: true, images: { orderBy: { sortOrder: "asc" } } } } },
|
||||
orderBy: { lastViewedAt: "desc" },
|
||||
take: 50,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
favorites: favorites.map((item) => ({ ...item.product, savedAt: item.createdAt })),
|
||||
history: history.map((item) => ({ ...item.product, viewedAt: item.lastViewedAt, viewCount: item.viewCount })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
app.post("/api/public/auth/wechat-phone-login", async (request, reply) => {
|
||||
const body = miniProgramPhoneLoginSchema.parse(request.body);
|
||||
let session: Awaited<ReturnType<typeof exchangeWechatLoginCode>>;
|
||||
let phoneInfo: Awaited<ReturnType<typeof exchangeWechatPhoneCode>>;
|
||||
try {
|
||||
[session, phoneInfo] = await Promise.all([
|
||||
exchangeWechatLoginCode(body.loginCode),
|
||||
exchangeWechatPhoneCode(body.phoneCode),
|
||||
]);
|
||||
} catch (error) {
|
||||
return reply.status(502).send({ message: error instanceof Error ? error.message : "微信手机号登录失败,请稍后重试" });
|
||||
}
|
||||
|
||||
const phone = normalizePhone(phoneInfo.purePhoneNumber || phoneInfo.phoneNumber);
|
||||
if (!/^1\d{10}$/.test(phone)) return reply.status(400).send({ message: "微信未返回有效手机号" });
|
||||
|
||||
const result = await upsertMiniProgramUser(session, body);
|
||||
if (result.disabled || !result.user) return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
|
||||
const linked = await attachVerifiedPhone(result.user.id, phone);
|
||||
if (linked.disabled) return reply.status(403).send({ message: "该手机号关联的账号已停用,请联系服务管家" });
|
||||
if (linked.missing) return reply.status(401).send({ message: "登录状态已失效,请重新授权" });
|
||||
|
||||
const summary = await getPublicUserSummary(linked.userId);
|
||||
if (!summary || summary.user.status !== "active") return reply.status(401).send({ message: "用户状态无效,请重新登录" });
|
||||
const token = app.jwt.sign(
|
||||
{ kind: "mini_program_user", sub: user.id, appId: user.appId },
|
||||
{ kind: "mini_program_user", sub: summary.user.id, appId: summary.user.appId, channel: "mini_program" },
|
||||
{ expiresIn: "30d" },
|
||||
);
|
||||
return { token, user: serializeUser(user, { leads: leadCount, favorites: favoriteCount, history: historyCount }) };
|
||||
const h5Ticket = await issueH5Ticket(summary.user.id);
|
||||
return {
|
||||
token,
|
||||
h5Ticket: h5Ticket.ticket,
|
||||
h5TicketExpiresAt: h5Ticket.expiresAt,
|
||||
linkedLeadCount: linked.linkedLeadCount,
|
||||
user: serializeUser(summary.user, summary.counts),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/wechat-login", async (request, reply) => {
|
||||
const body = miniProgramLoginSchema.parse(request.body);
|
||||
let session: Awaited<ReturnType<typeof exchangeWechatLoginCode>>;
|
||||
try {
|
||||
session = await exchangeWechatLoginCode(body.code);
|
||||
} catch (error) {
|
||||
return reply.status(502).send({ message: error instanceof Error ? error.message : "微信登录失败,请稍后重试" });
|
||||
}
|
||||
|
||||
const result = await upsertMiniProgramUser(session, body);
|
||||
if (result.disabled || !result.user) return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
|
||||
const summary = await getPublicUserSummary(result.user.id);
|
||||
if (!summary) return reply.status(401).send({ message: "用户状态无效,请重新登录" });
|
||||
const token = app.jwt.sign(
|
||||
{ kind: "mini_program_user", sub: summary.user.id, appId: summary.user.appId, channel: "mini_program" },
|
||||
{ expiresIn: "30d" },
|
||||
);
|
||||
return { token, user: serializeUser(summary.user, summary.counts) };
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/h5-ticket", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
||||
const user = await requireActivePublicUser(request, reply);
|
||||
if (!user) return;
|
||||
const h5Ticket = await issueH5Ticket(user.id);
|
||||
return { ticket: h5Ticket.ticket, expiresAt: h5Ticket.expiresAt };
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/h5-exchange", async (request, reply) => {
|
||||
const body = h5TicketExchangeSchema.parse(request.body);
|
||||
const now = new Date();
|
||||
const result = await prisma.$transaction(async (transaction) => {
|
||||
const ticket = await transaction.h5AuthTicket.findUnique({
|
||||
where: { tokenHash: hashH5Ticket(body.ticket) },
|
||||
select: { id: true, userId: true, expiresAt: true, consumedAt: true },
|
||||
});
|
||||
if (!ticket || ticket.consumedAt || ticket.expiresAt <= now) return { status: "invalid" as const };
|
||||
|
||||
const user = await transaction.miniProgramUser.findUnique({
|
||||
where: { id: ticket.userId },
|
||||
select: { id: true, appId: true, status: true },
|
||||
});
|
||||
if (!user || user.status !== "active") return { status: "disabled" as const };
|
||||
|
||||
const claimed = await transaction.h5AuthTicket.updateMany({
|
||||
where: { id: ticket.id, consumedAt: null, expiresAt: { gt: now } },
|
||||
data: { consumedAt: now },
|
||||
});
|
||||
if (claimed.count !== 1) return { status: "invalid" as const };
|
||||
return { status: "ok" as const, userId: user.id, appId: user.appId };
|
||||
});
|
||||
|
||||
if (result.status === "disabled") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
|
||||
if (result.status !== "ok") return reply.status(401).send({ message: "登录票据已失效,请重新登录" });
|
||||
|
||||
const summary = await getPublicUserSummary(result.userId);
|
||||
if (!summary || summary.user.status !== "active") return reply.status(401).send({ message: "用户状态无效,请重新登录" });
|
||||
const token = app.jwt.sign(
|
||||
{ kind: "mini_program_user", sub: summary.user.id, appId: result.appId, channel: "h5" },
|
||||
{ expiresIn: "30d" },
|
||||
);
|
||||
setH5SessionCookie(reply, token);
|
||||
return { user: serializeUser(summary.user, summary.counts) };
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/logout", async (_request, reply) => {
|
||||
clearH5SessionCookie(reply);
|
||||
return { loggedOut: true };
|
||||
});
|
||||
|
||||
app.get("/api/public/users/me", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
||||
@@ -196,29 +493,24 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const phone = phoneInfo.purePhoneNumber || phoneInfo.phoneNumber;
|
||||
const linkedCustomer = await prisma.customer.findUnique({ where: { phone } });
|
||||
const customer = linkedCustomer ?? await prisma.customer.create({ data: { phone, name: user.nickname ?? undefined } });
|
||||
const alreadyLinked = await prisma.miniProgramUser.findFirst({ where: { customerId: customer.id, id: { not: user.id } }, select: { id: true } });
|
||||
const updated = await prisma.miniProgramUser.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
phone,
|
||||
phoneAuthorizedAt: new Date(),
|
||||
customerId: alreadyLinked ? undefined : customer.id,
|
||||
},
|
||||
});
|
||||
const linkedLeadCount = await linkHistoricalLeads(user.id, phone);
|
||||
if (linkedLeadCount > 0) {
|
||||
const normalizedPhone = normalizePhone(phone);
|
||||
if (!/^1\d{10}$/.test(normalizedPhone)) return reply.status(400).send({ message: "微信未返回有效手机号" });
|
||||
const linked = await attachVerifiedPhone(user.id, normalizedPhone);
|
||||
if (linked.disabled) return reply.status(403).send({ message: "该手机号关联的账号已停用,请联系服务管家" });
|
||||
if (linked.missing) return reply.status(401).send({ message: "登录状态已失效,请重新登录" });
|
||||
const summary = await getPublicUserSummary(linked.userId);
|
||||
if (!summary) return reply.status(401).send({ message: "用户状态无效,请重新登录" });
|
||||
if (linked.linkedLeadCount > 0) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: "link_historical_leads",
|
||||
entity: "mini_program_user",
|
||||
entityId: user.id,
|
||||
after: { linkedLeadCount },
|
||||
entityId: linked.userId,
|
||||
after: { linkedLeadCount: linked.linkedLeadCount },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { user: serializeUser(updated), linkedLeadCount };
|
||||
return { user: serializeUser(summary.user, summary.counts), linkedLeadCount: linked.linkedLeadCount };
|
||||
});
|
||||
|
||||
app.post("/api/public/users/me/close", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
||||
|
||||
@@ -18,6 +18,18 @@ export const miniProgramLoginSchema = z.object({
|
||||
source: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
export const miniProgramPhoneLoginSchema = z.object({
|
||||
loginCode: z.string().trim().min(1).max(512),
|
||||
phoneCode: z.string().trim().min(1).max(512),
|
||||
nickname: z.string().trim().max(80).optional().nullable(),
|
||||
avatarUrl: z.string().trim().url().max(500).optional().nullable(),
|
||||
source: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
export const h5TicketExchangeSchema = z.object({
|
||||
ticket: z.string().trim().min(20).max(256),
|
||||
});
|
||||
|
||||
export const miniProgramProfileUpdateSchema = z.object({
|
||||
nickname: z.string().trim().max(80).optional().nullable(),
|
||||
avatarUrl: z.string().trim().url().max(500).optional().nullable(),
|
||||
|
||||
@@ -16,8 +16,18 @@ export async function createServer() {
|
||||
},
|
||||
});
|
||||
|
||||
const configuredCorsOrigins = (process.env.CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const corsOrigin = configuredCorsOrigins.length
|
||||
? configuredCorsOrigins
|
||||
: process.env.NODE_ENV === "production"
|
||||
? ["https://wanderq.nianxx.com"]
|
||||
: true;
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: corsOrigin,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 万趣微信小程序
|
||||
|
||||
这是唯一的用户端前台,基于 Taro + React + TypeScript,覆盖探索、搜索、线路详情、收藏、最近浏览、微信登录、手机号授权、我的需求和原生企微“联系我”页。
|
||||
这是唯一的用户端前台,基于 Taro + React + TypeScript,覆盖探索、搜索、线路详情、收藏、最近浏览、微信手机号一键登录、H5 登录态同步、我的需求、退出登录和原生企微“联系我”页。
|
||||
|
||||
小程序已声明企微官方插件 `wx104a1a20c3f81ec2`(版本 `1.4.3`)。`pages/contact/index` 内置企业微信后台创建的联系我 `config_id`,直接渲染 `plugin://contactPlugin/cell`;H5 中的“企微联系”入口只负责跳转到该原生页。后端不参与联系我插件的配置、同步或回调。
|
||||
|
||||
@@ -30,6 +30,8 @@ TARO_APP_API_BASE_URL="https://biz.wanderqtrip.com/api"
|
||||
npm run build:miniapp
|
||||
```
|
||||
|
||||
生产 API 需要配置 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`DATABASE_URL` 和 `JWT_SECRET`,并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request`、`downloadFile` 合法域名。联系我插件的 `config_id` 已随小程序代码发布,不需要后端企微密钥。图片统一使用 API 返回的 OSS WebP 公共 URL,不打包本地图片素材。
|
||||
小程序内的 H5 登录通过 `pages/login/index` 原生手机号授权页完成;授权后由首页生成一次性 H5 票据,H5 兑换为 HttpOnly Cookie。直接打开 H5 且未携带票据时保持游客浏览。
|
||||
|
||||
生产 API 需要配置 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`DATABASE_URL`、`JWT_SECRET` 和允许携带 H5 登录 Cookie 的 `CORS_ORIGINS`(例如 `https://wanderq.nianxx.com`),并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request`、`downloadFile` 合法域名。联系我插件的 `config_id` 已随小程序代码发布,不需要后端企微密钥。图片统一使用 API 返回的 OSS WebP 公共 URL,不打包本地图片素材。
|
||||
|
||||
数据库首次部署执行 `npm run db:migrate`,初始化内容执行 `npm run db:seed`。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default defineAppConfig({
|
||||
pages: ["pages/index/index", "pages/contact/index"],
|
||||
pages: ["pages/index/index", "pages/contact/index", "pages/login/index"],
|
||||
plugins: {
|
||||
contactPlugin: {
|
||||
version: "1.4.3",
|
||||
|
||||
@@ -127,6 +127,15 @@ page, view, text, image, input, button, scroll-view, swiper, swiper-item {
|
||||
.contact-fallback { background: #e8f1e9; }
|
||||
.contact-back { width: 100%; color: #19766a; background: transparent; border: 1px solid #bcd3c5; border-radius: 999px; font-size: 22px; }
|
||||
|
||||
.login-page { display: grid; align-content: start; gap: 22px; min-height: 100vh; padding: 64px 32px 100px; background: #f6f4ee; }
|
||||
.login-hero { display: grid; gap: 14px; padding: 16px 4px 30px; }
|
||||
.login-title { display: block; color: #182522; font-size: 42px; font-weight: 800; line-height: 1.3; }
|
||||
.login-note { display: block; color: #718079; font-size: 22px; line-height: 1.6; }
|
||||
.phone-login-button { width: 100%; color: #fff; background: #19766a; border-radius: 999px; font-size: 25px; }
|
||||
.phone-login-button[disabled] { opacity: .6; }
|
||||
.login-message { display: block; color: #b15842; text-align: center; font-size: 20px; line-height: 1.5; }
|
||||
.login-privacy { display: block; color: #87938c; text-align: center; font-size: 17px; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.product-grid { grid-template-columns: 1fr; }
|
||||
.hero-copy > text:nth-child(2), .hero-fallback > text:nth-of-type(1) { font-size: 34px; }
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
import { WebView } from "@tarojs/components";
|
||||
import { useState } from "react";
|
||||
import { useDidShow } from "@tarojs/taro";
|
||||
import { createH5Ticket, getToken } from "../../services/api";
|
||||
|
||||
const H5_URL = String(process.env.TARO_APP_H5_URL ?? "https://wanderq.nianxx.com");
|
||||
const H5_CACHE_VERSION = "ui-20260723-tab-seam-v7";
|
||||
const H5_SRC = `${H5_URL}${H5_URL.includes("?") ? "&" : "?"}v=${H5_CACHE_VERSION}`;
|
||||
const H5_CACHE_VERSION = "auth-20260724-v1";
|
||||
|
||||
function buildH5Src(ticket?: string) {
|
||||
const separator = H5_URL.includes("?") ? "&" : "?";
|
||||
return H5_URL
|
||||
+ separator
|
||||
+ "v="
|
||||
+ encodeURIComponent(H5_CACHE_VERSION)
|
||||
+ (ticket ? "&h5_ticket=" + encodeURIComponent(ticket) : "");
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
return <WebView src={H5_SRC} />;
|
||||
const [h5Src, setH5Src] = useState(buildH5Src);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [lastToken, setLastToken] = useState<string | null>(null);
|
||||
|
||||
useDidShow(() => {
|
||||
const token = getToken();
|
||||
if (initialized && token === lastToken) return;
|
||||
setInitialized(true);
|
||||
setLastToken(token);
|
||||
|
||||
if (!token) {
|
||||
setH5Src(buildH5Src());
|
||||
return;
|
||||
}
|
||||
|
||||
createH5Ticket()
|
||||
.then(({ ticket }) => setH5Src(buildH5Src(ticket)))
|
||||
.catch(() => setH5Src(buildH5Src()));
|
||||
});
|
||||
|
||||
return <WebView src={h5Src} />;
|
||||
}
|
||||
|
||||
4
apps/miniprogram/src/pages/login/index.config.ts
Normal file
4
apps/miniprogram/src/pages/login/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: "微信手机号登录",
|
||||
backgroundTextStyle: "light",
|
||||
});
|
||||
63
apps/miniprogram/src/pages/login/index.tsx
Normal file
63
apps/miniprogram/src/pages/login/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Button, Text, View } from "@tarojs/components";
|
||||
import { useState } from "react";
|
||||
import Taro, { useLoad } from "@tarojs/taro";
|
||||
import { clearToken, loginMiniProgramWithPhone } from "../../services/api";
|
||||
|
||||
type LoginPageParams = {
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<"login" | "logout">("login");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useLoad<LoginPageParams>((params) => {
|
||||
if (params.mode === "logout") {
|
||||
clearToken();
|
||||
setMode("logout");
|
||||
void Taro.navigateBack();
|
||||
}
|
||||
});
|
||||
|
||||
const handlePhoneNumber = async (event: { detail: { code?: string; errMsg?: string } }) => {
|
||||
const phoneCode = event.detail.code;
|
||||
if (!phoneCode) {
|
||||
setMessage(event.detail.errMsg?.includes("deny") ? "你取消了手机号授权" : "未获取到手机号授权,请重试");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await loginMiniProgramWithPhone(phoneCode);
|
||||
await Taro.navigateBack();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "登录失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === "logout") return null;
|
||||
|
||||
return (
|
||||
<View className="login-page">
|
||||
<View className="login-hero">
|
||||
<Text className="eyebrow">万趣账号</Text>
|
||||
<Text className="login-title">登录后,行程在小程序和 H5 之间同步</Text>
|
||||
<Text className="login-note">手机号仅用于识别账号和联系服务管家,不会公开展示。</Text>
|
||||
</View>
|
||||
<Button
|
||||
className="phone-login-button"
|
||||
openType="getPhoneNumber"
|
||||
onGetPhoneNumber={handlePhoneNumber}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "登录中..." : "微信手机号一键登录"}
|
||||
</Button>
|
||||
{message ? <Text className="login-message">{message}</Text> : null}
|
||||
<Text className="login-privacy">登录即表示你同意万趣为本次服务保存必要的账号信息。</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -165,6 +165,33 @@ export async function loginMiniProgram() {
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function loginMiniProgramWithPhone(phoneCode: string) {
|
||||
const result = await Taro.login();
|
||||
const response = await request<{
|
||||
token: string;
|
||||
h5Ticket: string;
|
||||
h5TicketExpiresAt: string;
|
||||
linkedLeadCount: number;
|
||||
user: UserProfile;
|
||||
}>("/api/public/auth/wechat-phone-login", {
|
||||
method: "POST",
|
||||
data: {
|
||||
loginCode: result.code,
|
||||
phoneCode,
|
||||
source: "mini_program",
|
||||
},
|
||||
});
|
||||
setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function createH5Ticket() {
|
||||
return request<{ ticket: string; expiresAt: string }>("/api/public/auth/h5-ticket", {
|
||||
method: "POST",
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchContent() {
|
||||
const [site, products] = await Promise.all([
|
||||
request<SiteConfig>("/api/public/site-config"),
|
||||
|
||||
258
src/App.tsx
258
src/App.tsx
@@ -37,7 +37,14 @@ import {
|
||||
import {
|
||||
createPublicBooking,
|
||||
createPublicLead,
|
||||
clearPublicHistory,
|
||||
exchangeH5Ticket,
|
||||
fetchCurrentUser,
|
||||
fetchPublicContent,
|
||||
logoutPublicSession,
|
||||
recordPublicHistory,
|
||||
savePublicFavorite,
|
||||
syncPublicActivity,
|
||||
type ProductContentBlock,
|
||||
type ProductDetailSection,
|
||||
type ProductKeyFact,
|
||||
@@ -46,6 +53,7 @@ import {
|
||||
type PublicProduct,
|
||||
type PublicSearchPageConfig,
|
||||
type PublicSiteConfig,
|
||||
type PublicUserProfile,
|
||||
} from "./api";
|
||||
import {
|
||||
bottomCtas as fallbackBottomCtas,
|
||||
@@ -237,6 +245,7 @@ declare global {
|
||||
wx?: {
|
||||
miniProgram?: {
|
||||
navigateTo?: (options: { url: string; success?: (result: unknown) => void; fail?: (error: unknown) => void; complete?: (result: unknown) => void }) => void;
|
||||
postMessage?: (options: { data: unknown; success?: (result: unknown) => void; fail?: (error: unknown) => void; complete?: (result: unknown) => void }) => void;
|
||||
};
|
||||
openCustomerServiceChat?: (options: WechatCustomerServiceOptions) => void;
|
||||
};
|
||||
@@ -285,6 +294,28 @@ function openWecomCustomerService() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function openMiniProgramLogin() {
|
||||
if (!window.wx?.miniProgram?.navigateTo) return false;
|
||||
window.wx.miniProgram.navigateTo({
|
||||
url: "/pages/login/index",
|
||||
fail(error) {
|
||||
console.warn("Failed to open the native login page.", error);
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function openMiniProgramLogout() {
|
||||
if (!window.wx?.miniProgram?.navigateTo) return false;
|
||||
window.wx.miniProgram.navigateTo({
|
||||
url: "/pages/login/index?mode=logout",
|
||||
fail(error) {
|
||||
console.warn("Failed to open the native logout page.", error);
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeApiProduct(product: PublicProduct, index: number): Product {
|
||||
return {
|
||||
id: product.sourceId ?? fallbackNumberFromApiId(product.id, index),
|
||||
@@ -791,6 +822,29 @@ const FAVORITE_PRODUCTS_KEY = "miniapp:favorite-products";
|
||||
const BROWSING_HISTORY_KEY = "miniapp:browsing-history";
|
||||
const SEARCH_HISTORY_KEY = "miniapp:search-history";
|
||||
const LATEST_DEMAND_KEY = "miniapp:latest-demand";
|
||||
const H5_TICKET_QUERY_KEY = "h5_ticket";
|
||||
|
||||
function readH5TicketFromUrl() {
|
||||
if (typeof window === "undefined") return null;
|
||||
return new URLSearchParams(window.location.search).get(H5_TICKET_QUERY_KEY);
|
||||
}
|
||||
|
||||
function clearH5TicketFromUrl() {
|
||||
if (typeof window === "undefined" || !window.history.replaceState) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(H5_TICKET_QUERY_KEY);
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
}
|
||||
|
||||
function clearStoredAccountData() {
|
||||
try {
|
||||
window.localStorage.removeItem(FAVORITE_PRODUCTS_KEY);
|
||||
window.localStorage.removeItem(BROWSING_HISTORY_KEY);
|
||||
window.localStorage.removeItem(LATEST_DEMAND_KEY);
|
||||
} catch {
|
||||
// Local storage may be unavailable in private browsing contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredProducts(key: string) {
|
||||
try {
|
||||
@@ -2133,7 +2187,58 @@ function CardBenefitsPage({
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSheet({
|
||||
user,
|
||||
canUseMiniProgram,
|
||||
message,
|
||||
busy,
|
||||
onClose,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: {
|
||||
user: PublicUserProfile | null;
|
||||
canUseMiniProgram: boolean;
|
||||
message: string;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onLogin: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="auth-sheet-mask" role="dialog" aria-modal="true" aria-labelledby="auth-sheet-title" onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}>
|
||||
<div className="auth-sheet">
|
||||
<button className="auth-sheet-close" onClick={onClose} aria-label="关闭登录弹窗">
|
||||
<X size={24} />
|
||||
</button>
|
||||
<span className="auth-sheet-kicker">万趣账号</span>
|
||||
<h2 id="auth-sheet-title">{user ? "已登录万趣账号" : "登录后同步你的行程"}</h2>
|
||||
<p>
|
||||
{user
|
||||
? (user.phoneMasked ?? "微信手机号") + " · 小程序与 H5 已同步"
|
||||
: "收藏、浏览记录和出行需求会跟随你的账号。"}
|
||||
</p>
|
||||
{message ? <div className="auth-sheet-message" role="status">{message}</div> : null}
|
||||
{user ? (
|
||||
<button className="auth-sheet-action auth-sheet-action-secondary" onClick={onLogout} disabled={busy}>
|
||||
{busy ? "退出中..." : "退出登录"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="auth-sheet-action" onClick={onLogin} disabled={busy}>
|
||||
{busy ? "正在打开..." : "微信手机号一键登录"}
|
||||
</button>
|
||||
)}
|
||||
{!user && !canUseMiniProgram ? <small className="auth-sheet-footnote">当前 H5 仅支持游客浏览,请从万趣小程序进入并完成登录。</small> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MyCenterPage({
|
||||
user,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onProductSelect,
|
||||
onDemand,
|
||||
onExplore,
|
||||
@@ -2145,6 +2250,9 @@ function MyCenterPage({
|
||||
onToggleFavorite,
|
||||
onClearHistory,
|
||||
}: {
|
||||
user: PublicUserProfile | null;
|
||||
onLogin: () => void;
|
||||
onLogout: () => void;
|
||||
onProductSelect: (product: Product) => void;
|
||||
onDemand: () => void;
|
||||
onExplore: () => void;
|
||||
@@ -2167,9 +2275,14 @@ function MyCenterPage({
|
||||
<User size={31} />
|
||||
</div>
|
||||
<span>
|
||||
<strong>{BRAND_NAME}会员</strong>
|
||||
<small>提交的需求、收藏行程与浏览记录</small>
|
||||
<strong>{user?.nickname || user?.phoneMasked || (BRAND_NAME + "游客")}</strong>
|
||||
<small>{user ? "小程序与 H5 已同步" : "游客浏览,登录后同步行程"}</small>
|
||||
</span>
|
||||
{user ? (
|
||||
<button className="mine-profile-action" onClick={onLogout}>退出登录</button>
|
||||
) : (
|
||||
<button className="mine-profile-action" onClick={onLogin}>登录</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mine-summary-grid">
|
||||
{[
|
||||
@@ -3610,6 +3723,12 @@ export default function App() {
|
||||
const [favoriteProducts, setFavoriteProducts] = useState<Product[]>(() => readStoredProducts(FAVORITE_PRODUCTS_KEY));
|
||||
const [browsingHistory, setBrowsingHistory] = useState<Product[]>(() => readStoredProducts(BROWSING_HISTORY_KEY));
|
||||
const [supportOpen, setSupportOpen] = useState(false);
|
||||
const [authUser, setAuthUser] = useState<PublicUserProfile | null>(null);
|
||||
const [authState, setAuthState] = useState<"loading" | "guest" | "authenticated">("loading");
|
||||
const [authSheetOpen, setAuthSheetOpen] = useState(false);
|
||||
const [authMessage, setAuthMessage] = useState("");
|
||||
const [authBusy, setAuthBusy] = useState(false);
|
||||
const canUseMiniProgram = Boolean(window.wx?.miniProgram?.navigateTo);
|
||||
const isCampaignTheme = view === "home" || view === "trainActivity";
|
||||
const showRootNav = ["home", "destinationHome", "mine"].includes(view);
|
||||
const volumeModule = appData.homeModules.find((module) => (module.templateType ?? module.id) === "themes");
|
||||
@@ -3647,6 +3766,49 @@ export default function App() {
|
||||
};
|
||||
}, [contentReloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const bootstrapAuth = async () => {
|
||||
const ticket = readH5TicketFromUrl();
|
||||
if (ticket) clearH5TicketFromUrl();
|
||||
|
||||
let user: PublicUserProfile;
|
||||
try {
|
||||
user = ticket
|
||||
? (await exchangeH5Ticket(ticket)).user
|
||||
: await fetchCurrentUser();
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setAuthUser(null);
|
||||
setAuthState("guest");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setAuthUser(user);
|
||||
setAuthState("authenticated");
|
||||
|
||||
try {
|
||||
const activity = await syncPublicActivity(
|
||||
favoriteProducts.map((product) => product.apiId).filter((id): id is string => Boolean(id)),
|
||||
browsingHistory.map((product) => product.apiId).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
if (cancelled) return;
|
||||
setFavoriteProducts(activity.favorites.map((product, index) => normalizeApiProduct(product, index)));
|
||||
setBrowsingHistory(activity.history.map((product, index) => normalizeApiProduct(product, index)));
|
||||
} catch (error) {
|
||||
console.info("Authenticated activity sync unavailable.", error);
|
||||
}
|
||||
};
|
||||
|
||||
void bootstrapAuth();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeStoredProducts(FAVORITE_PRODUCTS_KEY, favoriteProducts);
|
||||
}, [favoriteProducts]);
|
||||
@@ -3667,7 +3829,46 @@ export default function App() {
|
||||
writeStoredSearches(recentSearches);
|
||||
}, [recentSearches]);
|
||||
|
||||
const showAuthSheet = (message = "请先完成微信手机号登录。") => {
|
||||
setAuthMessage(message);
|
||||
setAuthSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
setAuthBusy(true);
|
||||
if (openMiniProgramLogin()) {
|
||||
setAuthMessage("正在打开微信手机号授权页,完成后会自动返回 H5。");
|
||||
} else {
|
||||
setAuthMessage("当前 H5 仅支持游客浏览,请从万趣小程序进入并完成登录。");
|
||||
}
|
||||
setAuthBusy(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setAuthBusy(true);
|
||||
try {
|
||||
await logoutPublicSession();
|
||||
} catch (error) {
|
||||
console.warn("Failed to clear the H5 session.", error);
|
||||
}
|
||||
clearStoredAccountData();
|
||||
setAuthUser(null);
|
||||
setAuthState("guest");
|
||||
setFavoriteProducts([]);
|
||||
setBrowsingHistory([]);
|
||||
setLatestDemand(null);
|
||||
setLatestBooking(null);
|
||||
setAuthSheetOpen(false);
|
||||
setAuthMessage("");
|
||||
setAuthBusy(false);
|
||||
openMiniProgramLogout();
|
||||
};
|
||||
|
||||
const changeView = (nextView: AppView) => {
|
||||
if (nextView === "mine" && !authUser) {
|
||||
showAuthSheet("进入“我的”前,请先完成微信手机号登录。");
|
||||
return;
|
||||
}
|
||||
setSelectedProduct(null);
|
||||
setSupportOpen(false);
|
||||
if (nextView !== "booking") {
|
||||
@@ -3737,16 +3938,34 @@ export default function App() {
|
||||
product,
|
||||
...items.filter((item) => productKey(item) !== productKey(product)),
|
||||
].slice(0, 20));
|
||||
if (authUser && product.apiId) {
|
||||
void recordPublicHistory(product.apiId).catch((error) => {
|
||||
console.info("Failed to record authenticated browsing history.", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isProductFavorite = (product: Product) => favoriteProducts.some((item) => productKey(item) === productKey(product));
|
||||
|
||||
const toggleFavoriteProduct = (product: Product) => {
|
||||
setFavoriteProducts((items) =>
|
||||
items.some((item) => productKey(item) === productKey(product))
|
||||
const saved = favoriteProducts.some((item) => productKey(item) === productKey(product));
|
||||
setFavoriteProducts((items) => saved
|
||||
? items.filter((item) => productKey(item) !== productKey(product))
|
||||
: [product, ...items].slice(0, 50),
|
||||
);
|
||||
: [product, ...items].slice(0, 50));
|
||||
if (authUser && product.apiId) {
|
||||
void savePublicFavorite(product.apiId, !saved).catch((error) => {
|
||||
console.info("Failed to update authenticated favorite.", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearBrowsingHistory = () => {
|
||||
setBrowsingHistory([]);
|
||||
if (authUser) {
|
||||
void clearPublicHistory().catch((error) => {
|
||||
console.info("Failed to clear authenticated browsing history.", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openBooking = (product: Product) => {
|
||||
@@ -3789,6 +4008,10 @@ export default function App() {
|
||||
};
|
||||
|
||||
const submitLead = async (payload: PublicLeadPayload, openSupportAfterSubmit = true): Promise<LeadSubmitResult> => {
|
||||
if (!authUser) {
|
||||
showAuthSheet("提交出行需求前,请先完成微信手机号登录。");
|
||||
return { ok: false, message: "请先完成微信手机号登录,登录完成后再提交需求。" };
|
||||
}
|
||||
try {
|
||||
const lead = await createPublicLead(payload);
|
||||
if (openSupportAfterSubmit) {
|
||||
@@ -3802,6 +4025,10 @@ export default function App() {
|
||||
};
|
||||
|
||||
const submitBooking = async (payload: PublicLeadPayload, openSupportAfterSubmit = false): Promise<LeadSubmitResult> => {
|
||||
if (!authUser) {
|
||||
showAuthSheet("提交预订申请前,请先完成微信手机号登录。");
|
||||
return { ok: false, message: "请先完成微信手机号登录,登录完成后再提交预订申请。" };
|
||||
}
|
||||
try {
|
||||
const booking = await createPublicBooking(payload);
|
||||
if (openSupportAfterSubmit) {
|
||||
@@ -3939,6 +4166,9 @@ export default function App() {
|
||||
<CardBenefitsPage onBack={() => changeView("home")} onDemand={() => openDemand()} onSupport={() => setSupportOpen(true)} onProductSelect={openProduct} />
|
||||
) : view === "mine" ? (
|
||||
<MyCenterPage
|
||||
user={authUser}
|
||||
onLogin={() => showAuthSheet()}
|
||||
onLogout={() => void handleLogout()}
|
||||
onProductSelect={openProduct}
|
||||
onDemand={() => openDemand()}
|
||||
onExplore={() => changeView("home")}
|
||||
@@ -3948,7 +4178,7 @@ export default function App() {
|
||||
favoriteProducts={favoriteProducts}
|
||||
browsingHistory={browsingHistory}
|
||||
onToggleFavorite={toggleFavoriteProduct}
|
||||
onClearHistory={() => setBrowsingHistory([])}
|
||||
onClearHistory={clearBrowsingHistory}
|
||||
/>
|
||||
) : view === "booking" && bookingProduct ? (
|
||||
<BookingFlowPage
|
||||
@@ -3991,6 +4221,20 @@ export default function App() {
|
||||
/>
|
||||
) : null}
|
||||
{supportOpen ? <SupportSheet onClose={() => setSupportOpen(false)} onDemand={() => openDemand()} onOnlineContact={openOnlineContact} /> : null}
|
||||
{authSheetOpen ? (
|
||||
<AuthSheet
|
||||
user={authUser}
|
||||
canUseMiniProgram={canUseMiniProgram}
|
||||
message={authMessage}
|
||||
busy={authBusy}
|
||||
onClose={() => {
|
||||
setAuthSheetOpen(false);
|
||||
setAuthMessage("");
|
||||
}}
|
||||
onLogin={handleLogin}
|
||||
onLogout={() => void handleLogout()}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</AppDataContext.Provider>
|
||||
|
||||
65
src/api.ts
65
src/api.ts
@@ -153,6 +153,27 @@ export type PublicSiteConfig = {
|
||||
searchPage?: PublicSearchPageConfig;
|
||||
};
|
||||
|
||||
export type PublicUserProfile = {
|
||||
id: string;
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
phoneMasked: string | null;
|
||||
hasPhone: boolean;
|
||||
status: "active" | "disabled" | "anonymized";
|
||||
source: string;
|
||||
firstSeenAt: string;
|
||||
lastLoginAt: string;
|
||||
createdAt: string;
|
||||
leads?: number;
|
||||
favorites?: number;
|
||||
history?: number;
|
||||
};
|
||||
|
||||
export type PublicActivity = {
|
||||
favorites: Array<PublicProduct & { savedAt: string }>;
|
||||
history: Array<PublicProduct & { viewedAt: string; viewCount: number }>;
|
||||
};
|
||||
|
||||
export type PublicLeadPayload = {
|
||||
destination?: string;
|
||||
phone: string;
|
||||
@@ -175,11 +196,12 @@ export type PublicLeadPayload = {
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -190,6 +212,47 @@ async function request<T>(path: string, init?: RequestInit) {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser() {
|
||||
return request<PublicUserProfile>("/api/public/users/me");
|
||||
}
|
||||
|
||||
export async function exchangeH5Ticket(ticket: string) {
|
||||
return request<{ user: PublicUserProfile }>("/api/public/auth/h5-exchange", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticket }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncPublicActivity(favoriteProductIds: string[], historyProductIds: string[]) {
|
||||
return request<PublicActivity>("/api/public/users/me/activity/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ favoriteProductIds, historyProductIds }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function savePublicFavorite(productId: string, saved: boolean) {
|
||||
const path = "/api/public/users/me/favorites/" + encodeURIComponent(productId);
|
||||
return request<{ saved: boolean }>(path, { method: saved ? "POST" : "DELETE" });
|
||||
}
|
||||
|
||||
export async function recordPublicHistory(productId: string) {
|
||||
return request<{ saved: boolean }>("/api/public/users/me/history", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ productId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearPublicHistory() {
|
||||
return request<{ cleared: boolean }>("/api/public/users/me/history", { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function logoutPublicSession() {
|
||||
return request<{ loggedOut: boolean }>("/api/public/auth/logout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPublicContent() {
|
||||
const [siteConfig, products] = await Promise.all([
|
||||
request<PublicSiteConfig>("/api/public/site-config"),
|
||||
|
||||
110
src/styles.css
110
src/styles.css
@@ -3133,7 +3133,7 @@ img {
|
||||
|
||||
.mine-profile {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
margin: 10px 12px 0;
|
||||
@@ -3175,6 +3175,19 @@ img {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mine-profile-action {
|
||||
align-self: center;
|
||||
min-width: 54px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #c9d9ca;
|
||||
border-radius: 999px;
|
||||
background: #f3f8f2;
|
||||
color: #287642;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mine-summary-grid,
|
||||
.mine-tools {
|
||||
display: grid;
|
||||
@@ -4825,8 +4838,101 @@ img {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-sheet-mask {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(12, 21, 16, 0.62);
|
||||
animation: manager-mask-in 220ms ease both;
|
||||
}
|
||||
|
||||
.auth-sheet {
|
||||
position: relative;
|
||||
width: min(100%, 430px);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 28px 22px 24px;
|
||||
border: 1px solid #dce7dc;
|
||||
border-radius: 24px;
|
||||
background: #f9fbf8;
|
||||
box-shadow: 0 24px 70px rgba(12, 27, 19, 0.24);
|
||||
animation: manager-sheet-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.auth-sheet-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: #eef2ed;
|
||||
color: #56645a;
|
||||
}
|
||||
|
||||
.auth-sheet-kicker {
|
||||
color: #718076;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.auth-sheet h2 {
|
||||
margin: 0;
|
||||
color: #17231d;
|
||||
font-size: 24px;
|
||||
line-height: 31px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.auth-sheet p {
|
||||
margin: 0;
|
||||
color: #6c786f;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.auth-sheet-message {
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
background: #eef5ed;
|
||||
color: #41604a;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.auth-sheet-action {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
margin-top: 5px;
|
||||
border-radius: 999px;
|
||||
background: #287642;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-sheet-action-secondary {
|
||||
background: #fff;
|
||||
border: 1px solid #b9cdbb;
|
||||
color: #287642;
|
||||
}
|
||||
|
||||
.auth-sheet-footnote {
|
||||
color: #89948b;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.manager-sheet button:focus-visible,
|
||||
.manager-sheet a:focus-visible {
|
||||
.manager-sheet a:focus-visible,
|
||||
.auth-sheet button:focus-visible {
|
||||
outline: 2px solid #6f9677;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user