feat(web): 构建漂流瓶移动端 PWA

- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
This commit is contained in:
root
2026-09-17 22:19:24 +08:00
parent fa0fa78312
commit 7ca855f19c
42 changed files with 8498 additions and 2900 deletions
+53
View File
@@ -0,0 +1,53 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, useAuthStore } from "./auth";
const me = {
accountId: "a",
publicId: "p",
nickname: null,
avatarColor: null,
bio: null,
profileReviewStatus: null,
};
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
});
describe("auth store", () => {
it("becomes authenticated reactively after login", async () => {
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
expect(auth.isAuthenticated).toBe(false);
await auth.login("13800000000", "123456", "device");
expect(auth.isAuthenticated).toBe(true);
});
it("restores authentication during bootstrap refresh", async () => {
vi.spyOn(api, "refresh").mockResolvedValue("restored");
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
await auth.bootstrap();
expect(auth.ready).toBe(true);
expect(auth.isAuthenticated).toBe(true);
});
it("clears the local session even when the server logout fails", async () => {
vi.spyOn(api, "post").mockImplementation((path: string) => {
if (path.includes("/auth/logout")) throw new Error("network down");
return Promise.resolve({ accessToken: "fresh" });
});
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
await auth.login("13800000000", "123456", "device");
expect(auth.isAuthenticated).toBe(true);
await auth.logout();
expect(auth.me).toBeNull();
expect(auth.isAuthenticated).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { createApiClient } from "../api/client";
export interface Me {
accountId: string;
publicId: string | null;
nickname: string | null;
avatarColor: string | null;
bio: string | null;
profileReviewStatus: string | null;
}
const accessToken = ref<string | null>(null);
export const api = createApiClient({
getToken: () => accessToken.value,
setToken: (value) => {
accessToken.value = value;
},
});
export const useAuthStore = defineStore("auth", () => {
const me = ref<Me | null>(null);
const ready = ref(false);
const isAuthenticated = computed(() =>
Boolean(accessToken.value && me.value),
);
async function bootstrap() {
if (ready.value) return;
try {
await api.refresh();
me.value = await api.get<Me>("/me");
} catch {
accessToken.value = null;
me.value = null;
} finally {
ready.value = true;
}
}
async function sendCode(phone: string, deviceId: string) {
return api.post<{ sent: boolean; debugCode?: string }>("/auth/sms/send", {
phone,
deviceId,
});
}
async function login(phone: string, code: string, deviceId: string) {
const pair = await api.post<{ accessToken: string }>("/auth/sms/login", {
phone,
code,
deviceId,
});
accessToken.value = pair.accessToken;
me.value = await api.get<Me>("/me");
}
async function ensureAdmin() {
await api.get("/admin/reports?limit=1");
}
async function logout() {
try {
await api.post("/auth/logout");
} catch {
// 本地始终清理;服务端注销失败也不阻塞退出
}
accessToken.value = null;
me.value = null;
}
return {
me,
ready,
isAuthenticated,
bootstrap,
sendCode,
login,
ensureAdmin,
logout,
};
});
export function currentAccessToken() {
return accessToken.value;
}
+174
View File
@@ -0,0 +1,174 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises } from "@vue/test-utils";
import { api, useAuthStore } from "./auth";
import { useChatStore } from "./chat";
import type { Conversation } from "./chat";
import type { ChatMessage } from "./messages";
const socket = vi.hoisted(() => ({
connected: false,
on: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
}));
vi.mock("../api/socket", () => ({
createChatSocket: () => socket,
sendSocketMessage: vi.fn(),
}));
const me = {
accountId: "a",
publicId: "p",
nickname: null,
avatarColor: null,
bio: null,
profileReviewStatus: null,
};
function message(seq: number): ChatMessage {
return {
id: `m-${seq}`,
conversationId: "c-1",
sender: { publicId: "p" },
clientMsgId: `client-${seq}`,
seq: String(seq),
text: String(seq),
status: "sent",
sentAt: new Date(seq).toISOString(),
};
}
const conversation = (): Conversation => ({
id: "c-1",
status: "ACTIVE",
lastMessageAt: new Date(0).toISOString(),
lastMessage: null,
unread: "0",
lastReadSeq: "0",
});
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
socket.connected = false;
socket.disconnect.mockClear();
});
describe("chat history synchronization", () => {
it("loads every page beyond 100 messages and reliably deduplicates overlap", async () => {
const pages = [
Array.from({ length: 100 }, (_, index) => message(index + 1)),
Array.from({ length: 100 }, (_, index) => message(index + 100)),
Array.from({ length: 51 }, (_, index) => message(index + 199)),
[],
];
const get = vi
.spyOn(api, "get")
.mockImplementation(() =>
Promise.resolve({ items: pages.shift() ?? [] }),
);
const chat = useChatStore();
const result = await chat.loadMessages("c-1");
expect(result).toBeDefined();
expect(result ?? []).toHaveLength(249);
expect(result?.at(-1)?.seq).toBe("249");
expect(get).toHaveBeenCalledTimes(3);
expect(get.mock.calls.map(([path]) => path)).toEqual([
"/conversations/c-1/messages?afterSeq=0&limit=100",
"/conversations/c-1/messages?afterSeq=100&limit=100",
"/conversations/c-1/messages?afterSeq=199&limit=100",
]);
});
it("stops paging after the page cap to bound waterfall requests", async () => {
const pages = [
Array.from({ length: 100 }, (_, index) => message(index + 1)),
Array.from({ length: 100 }, (_, index) => message(index + 101)),
Array.from({ length: 100 }, (_, index) => message(index + 201)),
Array.from({ length: 100 }, (_, index) => message(index + 301)),
Array.from({ length: 100 }, (_, index) => message(index + 401)),
[],
];
const get = vi
.spyOn(api, "get")
.mockImplementation(() =>
Promise.resolve({ items: pages.shift() ?? [] }),
);
const chat = useChatStore();
const result = await chat.loadMessages("c-1");
expect(result ?? []).toHaveLength(500);
expect(get.mock.calls.length).toBeLessThanOrEqual(5);
expect(get.mock.calls.at(-1)?.[0]).toBe(
"/conversations/c-1/messages?afterSeq=400&limit=100",
);
});
it("syncs only the most recent active conversations when reconnecting", async () => {
const get = vi.spyOn(api, "get").mockResolvedValue({ items: [] });
const chat = useChatStore();
chat.conversations = Array.from({ length: 30 }, (_, index) => {
const id = `c-${index}`;
return {
id,
status: "ACTIVE",
lastMessageAt: new Date(1700000000000 + index).toISOString(),
lastMessage: null,
unread: "0",
lastReadSeq: "0",
};
});
await chat.syncAll();
expect(
get.mock.calls.filter(([path]) => path.includes("/messages")),
).toHaveLength(5);
expect(
get.mock.calls.filter(([path]) => path.includes("/messages"))[0]![0],
).toContain("c-29");
});
});
describe("chat session lifecycle", () => {
it("disconnects and clears account state when authentication ends", async () => {
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
await auth.login("13800000000", "123456", "device");
expect(auth.isAuthenticated).toBe(true);
const chat = useChatStore();
chat.conversations = [conversation()];
chat.messages = { "c-1": [message(1)] };
expect(chat.conversations).toHaveLength(1);
auth.me = null;
await flushPromises();
expect(auth.isAuthenticated).toBe(false);
expect(socket.disconnect).toHaveBeenCalled();
expect(chat.conversations).toEqual([]);
expect(chat.messages).toEqual({});
expect(chat.connected).toBe(false);
});
it("reset clears conversations and disconnects even when called directly", () => {
const auth = useAuthStore();
auth.me = me;
const chat = useChatStore();
chat.conversations = [conversation()];
chat.messages = { "c-1": [message(1)] };
chat.reset();
expect(socket.disconnect).toHaveBeenCalled();
expect(chat.conversations).toEqual([]);
expect(chat.messages).toEqual({});
});
});
+138
View File
@@ -0,0 +1,138 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { api, currentAccessToken, useAuthStore } from "./auth";
import { createChatSocket, sendSocketMessage } from "../api/socket";
import { mergeMessages, type ChatMessage } from "./messages";
export interface Conversation {
id: string;
status: string;
lastMessageAt: string;
lastMessage: ChatMessage | null;
unread: string;
lastReadSeq: string;
}
const MAX_MESSAGE_PAGES = 5; // 单个会话重连同步上限(500 条),避免瀑布请求
const MAX_SYNC_CONVERSATIONS = 5; // 重连时仅同步最近活跃会话
export const useChatStore = defineStore("chat", () => {
const auth = useAuthStore();
const conversations = ref<Conversation[]>([]);
const messages = ref<Record<string, ChatMessage[]>>({});
const socket = createChatSocket(currentAccessToken, api.refresh);
const connected = ref(false);
socket.on("connect", () => {
connected.value = true;
void syncAll();
});
socket.on("disconnect", () => {
connected.value = false;
});
socket.on("message:new", (message: ChatMessage) => {
messages.value[message.conversationId] = mergeMessages(
messages.value[message.conversationId] ?? [],
[message],
);
});
async function loadConversations() {
const page = await api.get<{ items: Conversation[] }>("/conversations");
conversations.value = page.items;
return page.items;
}
async function loadMessages(id: string) {
let afterSeq = messages.value[id]?.at(-1)?.seq ?? "0";
let hasMore = true;
let pages = 0;
while (hasMore && pages < MAX_MESSAGE_PAGES) {
pages += 1;
const page = await api.get<{ items: ChatMessage[] }>(
`/conversations/${id}/messages?afterSeq=${afterSeq}&limit=100`,
);
messages.value[id] = mergeMessages(messages.value[id] ?? [], page.items);
hasMore = page.items.length === 100;
if (!hasMore) break;
const nextAfterSeq = page.items.at(-1)?.seq;
if (!nextAfterSeq || BigInt(nextAfterSeq) <= BigInt(afterSeq)) break;
afterSeq = nextAfterSeq;
}
return messages.value[id];
}
async function syncAll() {
const recent = [...conversations.value]
.sort((a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt))
.slice(0, MAX_SYNC_CONVERSATIONS);
for (const conversation of recent) await loadMessages(conversation.id);
}
function reset() {
socket.disconnect();
connected.value = false;
conversations.value = [];
messages.value = {};
}
let wasAuthenticated = auth.isAuthenticated;
watch(
() => auth.isAuthenticated,
(authenticated) => {
if (wasAuthenticated && !authenticated) reset();
wasAuthenticated = authenticated;
},
);
function connect() {
if (!socket.connected) socket.connect();
}
function disconnect() {
socket.disconnect();
}
async function send(id: string, text: string) {
const clientMsgId = crypto.randomUUID();
const optimistic: ChatMessage = {
id: `local:${clientMsgId}`,
conversationId: id,
clientMsgId,
sender: { publicId: "" },
seq: "0",
text,
sentAt: new Date().toISOString(),
status: "sending",
};
messages.value[id] = mergeMessages(messages.value[id] ?? [], [optimistic]);
try {
const result = connected.value
? await sendSocketMessage(
socket,
{
conversationId: id,
clientMsgId,
text,
},
api.refresh,
)
: await api.post<{ message: ChatMessage }>(
`/conversations/${id}/messages/prepare`,
{ conversationId: id, clientMsgId, text },
);
messages.value[id] = mergeMessages(messages.value[id], [
{ ...result.message, status: "sent" },
]);
} catch (error) {
optimistic.status = "failed";
messages.value[id] = [...messages.value[id]];
throw error;
}
}
const unread = computed(() =>
conversations.value.reduce((sum, item) => sum + Number(item.unread), 0),
);
return {
conversations,
messages,
connected,
unread,
loadConversations,
loadMessages,
syncAll,
connect,
disconnect,
reset,
send,
};
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { mergeMessages, type ChatMessage } from "./messages";
const message = (id: string, seq: string): ChatMessage => ({
id,
conversationId: "conversation",
sender: { publicId: "sender" },
clientMsgId: `client-${id}`,
seq,
text: id,
sentAt: "2026-09-17T00:00:00.000Z",
status: "sent",
});
describe("message synchronization", () => {
it("deduplicates socket and reconnect history messages and sorts by sequence", () => {
const result = mergeMessages(
[message("two", "2"), message("three", "3")],
[message("one", "1"), message("two", "2")],
);
expect(result.map(({ id }) => id)).toEqual(["one", "two", "three"]);
});
it("reconciles an optimistic message by client message id", () => {
const optimistic = {
...message("temporary", "0"),
clientMsgId: "same",
status: "sending" as const,
};
const confirmed = { ...message("server", "4"), clientMsgId: "same" };
expect(mergeMessages([optimistic], [confirmed])).toEqual([confirmed]);
});
});
+31
View File
@@ -0,0 +1,31 @@
export interface ChatMessage {
id: string;
conversationId: string;
sender: { publicId: string };
clientMsgId: string;
seq: string;
text: string;
status: string;
reviewStatus?: string;
sentAt: string;
}
export function mergeMessages(
current: ChatMessage[],
incoming: ChatMessage[],
): ChatMessage[] {
// 双 Map:按 id 去重 + 按 clientMsgId 调和乐观消息,整体 O(n)
const byId = new Map<string, ChatMessage>();
const byClientMsgId = new Map<string, string>();
const consider = (item: ChatMessage) => {
const existingId = byClientMsgId.get(item.clientMsgId);
if (existingId) byId.delete(existingId);
byClientMsgId.set(item.clientMsgId, item.id);
byId.set(item.id, item);
};
for (const item of current) consider(item);
for (const item of incoming) consider(item);
return [...byId.values()].sort((a, b) =>
BigInt(a.seq) < BigInt(b.seq) ? -1 : BigInt(a.seq) > BigInt(b.seq) ? 1 : 0,
);
}