feat(web): 构建漂流瓶移动端 PWA
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user