Compare commits
2 Commits
fa0fa78312
...
afab4e7cba
| Author | SHA1 | Date | |
|---|---|---|---|
| afab4e7cba | |||
| 7ca855f19c |
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#061b2b" />
|
||||
<meta name="description" content="匿名漂流瓶深海社交" />
|
||||
<title>漂流瓶 · 深海回声</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@drift/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"test": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vue-tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@drift/contracts": "workspace:*",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vitest": "^4.1.11",
|
||||
"vue-tsc": "^3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#061b2b"/><circle cx="256" cy="210" r="128" fill="#0b4050"/><path d="M180 130h152l-20 196c-5 48-107 48-112 0z" fill="#76e4d5" opacity=".85"/><path d="M160 350q96-70 192 0" fill="none" stroke="#f1c27d" stroke-width="22" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "漂流瓶 · 深海回声",
|
||||
"short_name": "漂流瓶",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#04111d",
|
||||
"theme_color": "#061b2b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import OfflineBanner from "./components/OfflineBanner.vue";
|
||||
import TabBar from "./components/TabBar.vue";
|
||||
const route = useRoute();
|
||||
const shell = computed(
|
||||
() => route.name !== "login" && route.name !== "admin-login",
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<OfflineBanner /><RouterView /><TabBar v-if="shell" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiClient } from "./client";
|
||||
|
||||
const response = (status: number, data: unknown) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: status < 400 ? "OK" : "AUTH_TOKEN_EXPIRED",
|
||||
message: status < 400 ? "success" : "expired",
|
||||
data,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
{ status, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
describe("API client authentication", () => {
|
||||
it("coalesces concurrent 401 refreshes and retries both requests", async () => {
|
||||
let token = "expired";
|
||||
let refreshes = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
await Promise.resolve();
|
||||
token = "fresh";
|
||||
return response(200, {
|
||||
accessToken: token,
|
||||
expiresIn: 900,
|
||||
tokenType: "Bearer",
|
||||
});
|
||||
}
|
||||
return token === "expired"
|
||||
? response(401, null)
|
||||
: response(200, { path });
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken: (next) => {
|
||||
token = next ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([client.get("/me"), client.get("/conversations")]);
|
||||
|
||||
expect(refreshes).toBe(1);
|
||||
expect(fetcher).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("coalesces concurrent refresh failures and logs out only once", async () => {
|
||||
let token: string | null = "expired";
|
||||
const setToken = vi.fn((next: string | null) => {
|
||||
token = next;
|
||||
});
|
||||
const onUnauthorized = vi.fn();
|
||||
let refreshes = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
return response(401, null);
|
||||
}
|
||||
if (path.endsWith("/conversations"))
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return response(401, null);
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
client.get("/me"),
|
||||
client.get("/conversations"),
|
||||
]);
|
||||
|
||||
expect(results.every((result) => result.status === "rejected")).toBe(true);
|
||||
expect(refreshes).toBe(1);
|
||||
expect(setToken).toHaveBeenCalledTimes(1);
|
||||
expect(setToken).toHaveBeenCalledWith(null);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not claim an offline write succeeded", async () => {
|
||||
const client = createApiClient({
|
||||
fetcher: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")),
|
||||
getToken: () => "token",
|
||||
setToken: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post("/bottles", { contentText: "hello" }),
|
||||
).rejects.toMatchObject({ code: "NETWORK_ERROR" });
|
||||
});
|
||||
|
||||
it("does not repeat refresh or logout after a failed refresh", async () => {
|
||||
let token: string | null = "expired";
|
||||
let refreshes = 0;
|
||||
const setToken = vi.fn((next: string | null) => {
|
||||
token = next;
|
||||
});
|
||||
const onUnauthorized = vi.fn();
|
||||
const fetcher = vi.fn((input: RequestInfo | URL): Promise<Response> => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
return Promise.resolve(response(401, null));
|
||||
}
|
||||
return Promise.resolve(response(401, null));
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
await expect(client.get("/conversations")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
|
||||
expect(refreshes).toBe(1);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers refresh after an explicit new login", async () => {
|
||||
let token: string | null = "expired";
|
||||
let refreshSucceeds = false;
|
||||
let refreshes = 0;
|
||||
const onUnauthorized = vi.fn();
|
||||
const fetcher = vi.fn((input: RequestInfo | URL): Promise<Response> => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
if (!refreshSucceeds) return Promise.resolve(response(401, null));
|
||||
return Promise.resolve(
|
||||
response(200, {
|
||||
accessToken: "fresh2",
|
||||
expiresIn: 900,
|
||||
tokenType: "Bearer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(response(401, null));
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken: (next) => {
|
||||
token = next;
|
||||
},
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(refreshes).toBe(1);
|
||||
|
||||
// 显式重新登录产生新会话
|
||||
token = "fresh";
|
||||
refreshSucceeds = true;
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(refreshes).toBe(2);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId: string;
|
||||
}
|
||||
export interface ApiFailure {
|
||||
code: string;
|
||||
message: string;
|
||||
status: number | undefined;
|
||||
requestId: string | undefined;
|
||||
}
|
||||
export interface ApiClientOptions {
|
||||
fetcher?: typeof fetch;
|
||||
baseUrl?: string;
|
||||
getToken: () => string | null;
|
||||
setToken: (token: string | null) => void;
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
export class ApiError extends Error implements ApiFailure {
|
||||
public status: number | undefined;
|
||||
public requestId: string | undefined;
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
status?: number,
|
||||
requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(options: ApiClientOptions) {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const baseUrl = options.baseUrl ?? "/api/v1";
|
||||
let refreshing: Promise<string> | null = null;
|
||||
let failedRefresh: { token: string | null; error: unknown } | null = null;
|
||||
|
||||
async function refresh(
|
||||
staleToken: string | null = options.getToken(),
|
||||
): Promise<string> {
|
||||
// 同一认证失败周期内:只要令牌仍是失败时的令牌(或已被清空),
|
||||
// 就直接抛出已记录的失败,不再请求 refresh 接口或重复触发登出。
|
||||
if (
|
||||
failedRefresh !== null &&
|
||||
(staleToken === failedRefresh.token || staleToken === null)
|
||||
)
|
||||
throw failedRefresh.error;
|
||||
if (!refreshing) {
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const response = await fetcher(`${baseUrl}/auth/token/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const body = (await response.json()) as ApiEnvelope<{
|
||||
accessToken: string;
|
||||
}>;
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
options.setToken(body.data.accessToken);
|
||||
failedRefresh = null;
|
||||
return body.data.accessToken;
|
||||
} catch (error) {
|
||||
const failure = networkError(error);
|
||||
failedRefresh = { token: staleToken, error: failure };
|
||||
options.setToken(null);
|
||||
options.onUnauthorized?.();
|
||||
throw failure;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
retry = true,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = options.getToken();
|
||||
if (token) headers.set("authorization", `Bearer ${token}`);
|
||||
if (init.body && !headers.has("content-type"))
|
||||
headers.set("content-type", "application/json");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (error) {
|
||||
throw networkError(error);
|
||||
}
|
||||
const body = (await response.json()) as ApiEnvelope<T>;
|
||||
if (response.status === 401 && retry && path !== "/auth/token/refresh") {
|
||||
await refresh(token);
|
||||
return request<T>(path, init, false);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
return body.data;
|
||||
}
|
||||
return {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(path, {
|
||||
method: "POST",
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
...(headers === undefined ? {} : { headers }),
|
||||
}),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
function networkError(error: unknown): ApiError {
|
||||
if (error instanceof ApiError) return error;
|
||||
return new ApiError(
|
||||
"NETWORK_ERROR",
|
||||
error instanceof Error ? error.message : "网络连接失败",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindSocketAuthentication, sendSocketMessage } from "./socket";
|
||||
|
||||
type Ack = (error: Error | null, response?: unknown) => void;
|
||||
|
||||
class FakeSocket {
|
||||
connected = false;
|
||||
handlers = new Map<string, (value?: unknown) => void>();
|
||||
emits: Array<{ event: string; payload: unknown; ack: Ack | undefined }> = [];
|
||||
connect = vi.fn(() => {
|
||||
this.connected = true;
|
||||
return this;
|
||||
});
|
||||
disconnect = vi.fn(() => {
|
||||
this.connected = false;
|
||||
return this;
|
||||
});
|
||||
timeout = vi.fn(() => this);
|
||||
emit = vi.fn((event: string, payload: unknown, ack?: Ack) => {
|
||||
this.emits.push({ event, payload, ack });
|
||||
return this;
|
||||
});
|
||||
on(event: string, handler: (value?: unknown) => void) {
|
||||
this.handlers.set(event, handler);
|
||||
return this;
|
||||
}
|
||||
emitEvent(event: string, value?: unknown) {
|
||||
this.handlers.get(event)?.(value);
|
||||
}
|
||||
ack(index: number, response: unknown) {
|
||||
this.emits[index]?.ack?.(null, response);
|
||||
}
|
||||
}
|
||||
|
||||
const okResponse = (id = "real-1") => ({
|
||||
ok: true,
|
||||
data: {
|
||||
message: {
|
||||
id,
|
||||
conversationId: "c-1",
|
||||
sender: { publicId: "p" },
|
||||
clientMsgId: "m-1",
|
||||
seq: "1",
|
||||
text: "hi",
|
||||
status: "sent",
|
||||
sentAt: new Date(0).toISOString(),
|
||||
},
|
||||
deduplicated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const errorResponse = (code: string, message: string) => ({
|
||||
ok: false,
|
||||
error: { code, message },
|
||||
});
|
||||
|
||||
const payload = { conversationId: "c-1", clientMsgId: "m-1", text: "hi" };
|
||||
|
||||
describe("socket authentication recovery", () => {
|
||||
it("refreshes once after concurrent auth errors and reconnects with current token", async () => {
|
||||
let token = "expired";
|
||||
let release!: () => void;
|
||||
const refresh = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
release = () => {
|
||||
token = "fresh";
|
||||
resolve(token);
|
||||
};
|
||||
}),
|
||||
);
|
||||
const socket = new FakeSocket();
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
release();
|
||||
await vi.waitFor(() => expect(socket.connect).toHaveBeenCalledOnce());
|
||||
expect(token).toBe("fresh");
|
||||
});
|
||||
|
||||
it("does not refresh unrelated connection failures", () => {
|
||||
const refresh = vi.fn();
|
||||
const socket = new FakeSocket();
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
socket.emitEvent("connect_error", new Error("transport closed"));
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects and stops recovering when refresh fails", async () => {
|
||||
const socket = new FakeSocket();
|
||||
let refreshCalls = 0;
|
||||
const refresh = vi.fn().mockImplementation(() => {
|
||||
refreshCalls += 1;
|
||||
return Promise.reject(new Error("refresh failed"));
|
||||
});
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(socket.disconnect).toHaveBeenCalledOnce());
|
||||
|
||||
// 后续重连错误不会再次触发 refresh(锁保持,避免无限循环)
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
expect(refreshCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes, reconnects and retries once with the same clientMsgId after an ACK auth error", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn().mockResolvedValue("fresh");
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce());
|
||||
|
||||
expect(socket.disconnect).toHaveBeenCalledOnce();
|
||||
expect(socket.connect).toHaveBeenCalledOnce();
|
||||
expect(socket.emits).toHaveLength(2);
|
||||
expect(socket.emits[1]!.payload).toMatchObject({ clientMsgId: "m-1" });
|
||||
socket.ack(1, okResponse());
|
||||
|
||||
await expect(sending).resolves.toMatchObject({
|
||||
message: { id: "real-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retry non-auth ACK failures", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn();
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
socket.ack(0, errorResponse("RATE_LIMITED", "too many"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({ code: "RATE_LIMITED" });
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not loop when the retried attempt also fails auth", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn().mockResolvedValue("fresh");
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce());
|
||||
socket.ack(1, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({ code: "AUTH_TOKEN_EXPIRED" });
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(socket.emits).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects without refreshing when no refresh handler is provided", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const sending = sendSocketMessage(socket as never, payload);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import type { ChatMessage } from "../stores/messages";
|
||||
|
||||
export interface SocketResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
export interface AuthenticationSocket {
|
||||
connected: boolean;
|
||||
connect(): unknown;
|
||||
disconnect(): unknown;
|
||||
on(event: "connect_error", handler: (error: unknown) => void): unknown;
|
||||
}
|
||||
|
||||
export type SocketError = Error & { code?: string };
|
||||
|
||||
export function bindSocketAuthentication(
|
||||
socket: AuthenticationSocket,
|
||||
refresh: () => Promise<string>,
|
||||
) {
|
||||
let recovering: Promise<void> | null = null;
|
||||
socket.on("connect_error", (error: unknown) => {
|
||||
const candidate = error as { message?: string; data?: { code?: string } };
|
||||
const authenticationError =
|
||||
candidate.data?.code?.startsWith("AUTH_") ||
|
||||
/unauthorized|authentication|token/i.test(candidate.message ?? "");
|
||||
if (!authenticationError || recovering) return;
|
||||
recovering = refresh()
|
||||
.then(() => {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
})
|
||||
.catch(() => {
|
||||
// 刷新失败:断开连接并“锁死”恢复路径,避免无限重连循环。
|
||||
// 只有显式重新登录(新会话)重建 socket 后才会再次武装恢复。
|
||||
socket.disconnect();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createChatSocket(
|
||||
token: () => string | null,
|
||||
refresh?: () => Promise<string>,
|
||||
): Socket {
|
||||
const socket = io("/chat", {
|
||||
autoConnect: false,
|
||||
withCredentials: true,
|
||||
auth: (callback) => callback({ token: token() }),
|
||||
});
|
||||
if (refresh) bindSocketAuthentication(socket, refresh);
|
||||
return socket;
|
||||
}
|
||||
|
||||
function responseError(response: SocketResponse): SocketError {
|
||||
const error = new Error(response.error?.message ?? "发送失败") as SocketError;
|
||||
if (response.error?.code) error.code = response.error.code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function emitOnce(
|
||||
socket: Socket,
|
||||
payload: { conversationId: string; clientMsgId: string; text: string },
|
||||
): Promise<{ message: ChatMessage; deduplicated: boolean }> {
|
||||
return new Promise((resolve, reject) =>
|
||||
socket.timeout(10000).emit(
|
||||
"message:send",
|
||||
payload,
|
||||
(
|
||||
error: Error | null,
|
||||
response: SocketResponse<{
|
||||
message: ChatMessage;
|
||||
deduplicated: boolean;
|
||||
}>,
|
||||
) => {
|
||||
if (error) return reject(error);
|
||||
if (!response.ok || !response.data)
|
||||
return reject(responseError(response));
|
||||
resolve(response.data);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function sendSocketMessage(
|
||||
socket: Socket,
|
||||
payload: { conversationId: string; clientMsgId: string; text: string },
|
||||
refresh?: () => Promise<string>,
|
||||
): Promise<{ message: ChatMessage; deduplicated: boolean }> {
|
||||
return emitOnce(socket, payload).catch(async (error: SocketError) => {
|
||||
if (!refresh || !error.code?.startsWith("AUTH_")) throw error;
|
||||
await refresh();
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
return emitOnce(socket, payload);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; back?: boolean }>();
|
||||
</script>
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<RouterLink v-if="back" to="/" class="icon-link" aria-label="返回首页"
|
||||
>‹</RouterLink
|
||||
>
|
||||
<div>
|
||||
<small>DRIFT / 深海</small>
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
const online = ref(navigator.onLine);
|
||||
const update = () => (online.value = navigator.onLine);
|
||||
onMounted(() => {
|
||||
addEventListener("online", update);
|
||||
addEventListener("offline", update);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListener("online", update);
|
||||
removeEventListener("offline", update);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="!online" class="offline" role="status">
|
||||
离线模式 · 写操作将明确失败
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cleanup, render, screen } from "@testing-library/vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
import StatusPanel from "./StatusPanel.vue";
|
||||
|
||||
describe("StatusPanel", () => {
|
||||
it.each([
|
||||
["loading", "正在潜入深海"],
|
||||
["empty", "这里还没有内容"],
|
||||
["error", "海浪暂时阻断了连接"],
|
||||
["offline", "当前离线"],
|
||||
] as const)("renders an accessible %s state", (state, label) => {
|
||||
render(StatusPanel, { props: { state } });
|
||||
expect(screen.getByRole("status")).toHaveTextContent(label);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
state: "loading" | "empty" | "error" | "offline";
|
||||
detail?: string;
|
||||
}>();
|
||||
const labels = {
|
||||
loading: "正在潜入深海…",
|
||||
empty: "这里还没有内容",
|
||||
error: "海浪暂时阻断了连接",
|
||||
offline: "当前离线,写操作不会被保存",
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span aria-hidden="true">{{
|
||||
props.state === "loading" ? "◌" : props.state === "empty" ? "○" : "!"
|
||||
}}</span>
|
||||
<p>{{ labels[props.state] }}</p>
|
||||
<small v-if="detail">{{ detail }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.status {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.status span {
|
||||
font-size: 2rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.status p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.status small {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from "vue-router";
|
||||
const route = useRoute();
|
||||
const links: [string, string][] = [
|
||||
["/", "海面"],
|
||||
["/conversations", "回声"],
|
||||
["/my-bottles", "瓶子"],
|
||||
["/settings", "设置"],
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<nav class="tabs" aria-label="主导航">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link[0]"
|
||||
:to="link[0]"
|
||||
:aria-current="route.path === link[0] ? 'page' : undefined"
|
||||
>{{ link[1] }}</RouterLink
|
||||
>
|
||||
</nav>
|
||||
</template>
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
unknown
|
||||
>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import { createAppRouter } from "./router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import "./styles/main.css";
|
||||
const app = createApp(App),
|
||||
pinia = createPinia();
|
||||
app.use(pinia);
|
||||
const auth = useAuthStore(pinia);
|
||||
app.use(createAppRouter(auth));
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createMemoryHistory } from "vue-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAppRouter } from "./index";
|
||||
|
||||
describe("router guards", () => {
|
||||
function auth(
|
||||
overrides: Partial<{
|
||||
isAuthenticated: boolean;
|
||||
bootstrap: () => Promise<unknown>;
|
||||
ensureAdmin: () => Promise<unknown>;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||
ensureAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
it("redirects guests to login and preserves the destination", async () => {
|
||||
const session = auth();
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
|
||||
await router.push("/conversations");
|
||||
await router.isReady();
|
||||
|
||||
expect(router.currentRoute.value.name).toBe("login");
|
||||
expect(router.currentRoute.value.query.redirect).toBe("/conversations");
|
||||
});
|
||||
|
||||
it("uses an independent admin login and role preflight", async () => {
|
||||
const session = auth({ isAuthenticated: true });
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin/login");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
|
||||
await router.push("/admin");
|
||||
expect(session.ensureAdmin).toHaveBeenCalledOnce();
|
||||
expect(router.currentRoute.value.name).toBe("admin");
|
||||
});
|
||||
|
||||
it("redirects failed admin preflight with clear forbidden feedback", async () => {
|
||||
const session = auth({
|
||||
isAuthenticated: true,
|
||||
ensureAdmin: vi.fn().mockRejectedValue({ status: 403 }),
|
||||
});
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
expect(router.currentRoute.value.query.error).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("sends anonymous visitors to the independent admin login", async () => {
|
||||
const session = auth();
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
expect(session.ensureAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createRouter, createWebHistory, type RouterHistory } from "vue-router";
|
||||
import LoginView from "../views/LoginView.vue";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import BottleComposeView from "../views/BottleComposeView.vue";
|
||||
import PickView from "../views/PickView.vue";
|
||||
import ConversationsView from "../views/ConversationsView.vue";
|
||||
import ChatView from "../views/ChatView.vue";
|
||||
import MyBottlesView from "../views/MyBottlesView.vue";
|
||||
import SettingsView from "../views/SettingsView.vue";
|
||||
import AdminView from "../views/AdminView.vue";
|
||||
import AdminLoginView from "../views/AdminLoginView.vue";
|
||||
|
||||
export interface RouterAuth {
|
||||
isAuthenticated: boolean;
|
||||
bootstrap(): Promise<unknown>;
|
||||
ensureAdmin(): Promise<unknown>;
|
||||
}
|
||||
export function createAppRouter(
|
||||
auth: RouterAuth,
|
||||
history: RouterHistory = createWebHistory(),
|
||||
) {
|
||||
const router = createRouter({
|
||||
history,
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginView,
|
||||
meta: { guest: true },
|
||||
},
|
||||
{ path: "/", name: "home", component: HomeView },
|
||||
{ path: "/throw", name: "throw", component: BottleComposeView },
|
||||
{ path: "/pick", name: "pick", component: PickView },
|
||||
{
|
||||
path: "/conversations",
|
||||
name: "conversations",
|
||||
component: ConversationsView,
|
||||
},
|
||||
{ path: "/conversations/:id", name: "chat", component: ChatView },
|
||||
{ path: "/my-bottles", name: "my-bottles", component: MyBottlesView },
|
||||
{ path: "/settings", name: "settings", component: SettingsView },
|
||||
{
|
||||
path: "/admin/login",
|
||||
name: "admin-login",
|
||||
component: AdminLoginView,
|
||||
meta: { adminLogin: true },
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
name: "admin",
|
||||
component: AdminView,
|
||||
meta: { admin: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
let bootstrapped = false;
|
||||
router.beforeEach(async (to) => {
|
||||
if (!bootstrapped) {
|
||||
bootstrapped = true;
|
||||
await auth.bootstrap();
|
||||
}
|
||||
if (to.meta.adminLogin) return true;
|
||||
if (to.meta.guest && auth.isAuthenticated) return { name: "home" };
|
||||
if (!to.meta.guest && !auth.isAuthenticated) {
|
||||
if (to.meta.admin)
|
||||
return { name: "admin-login", query: { redirect: to.fullPath } };
|
||||
return { name: "login", query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (to.meta.admin) {
|
||||
try {
|
||||
await auth.ensureAdmin();
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
name: "admin-login",
|
||||
query:
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 403
|
||||
? { error: "forbidden" }
|
||||
: { error: "unavailable" },
|
||||
};
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=ZCOOL+XiaoWei&display=swap");
|
||||
:root {
|
||||
font-family: "Noto Sans SC", system-ui, sans-serif;
|
||||
color: #effcff;
|
||||
background: #020b13;
|
||||
--deep: #061b2b;
|
||||
--panel: rgba(13, 48, 63, 0.72);
|
||||
--line: rgba(139, 224, 218, 0.18);
|
||||
--aqua: #76e4d5;
|
||||
--sand: #f1c27d;
|
||||
--muted: #91abb7;
|
||||
--danger: #ff8f91;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #020b13;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(
|
||||
circle at 50% 0,
|
||||
#0b4050 0,
|
||||
#061b2b 38%,
|
||||
#020b13 100%
|
||||
);
|
||||
line-height: 1.55;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
rgba(118, 228, 213, 0.22) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(#000, transparent 80%);
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger,
|
||||
.icon-link,
|
||||
.avatar {
|
||||
min-height: 44px;
|
||||
}
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 3px solid var(--sand);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.app-shell {
|
||||
position: relative;
|
||||
width: min(100%, 430px);
|
||||
min-height: 100dvh;
|
||||
margin: auto;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(6, 27, 43, 0.18),
|
||||
rgba(2, 11, 19, 0.88)
|
||||
);
|
||||
box-shadow: 0 0 70px #000;
|
||||
padding-bottom: 84px;
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
min-height: calc(100dvh - 84px);
|
||||
}
|
||||
.page,
|
||||
.list,
|
||||
.settings {
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: max(1rem, env(safe-area-inset-top)) 1.1rem 1rem;
|
||||
}
|
||||
.topbar h1 {
|
||||
font:
|
||||
500 1.4rem "ZCOOL XiaoWei",
|
||||
serif;
|
||||
margin: 0;
|
||||
}
|
||||
.topbar small,
|
||||
.eyebrow {
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--aqua);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.icon-link,
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 44px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.avatar {
|
||||
font-weight: 700;
|
||||
}
|
||||
.glass {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 22px;
|
||||
padding: 1.1rem;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.login {
|
||||
padding: 8vh 1.1rem 2rem;
|
||||
}
|
||||
.hero {
|
||||
padding: 2rem 0.3rem;
|
||||
}
|
||||
.hero h1,
|
||||
.page h2,
|
||||
.sea h2 {
|
||||
font:
|
||||
500 2.1rem/1.25 "ZCOOL XiaoWei",
|
||||
serif;
|
||||
}
|
||||
.hero h1 em {
|
||||
color: var(--sand);
|
||||
font-style: normal;
|
||||
}
|
||||
.hero > p:last-child,
|
||||
.muted,
|
||||
.quota-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
.orb {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(118, 228, 213, 0.12);
|
||||
color: var(--aqua);
|
||||
box-shadow: 0 0 40px rgba(118, 228, 213, 0.25);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: #cce0e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
background: rgba(0, 10, 18, 0.56);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 13px;
|
||||
padding: 0.72rem 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea::placeholder,
|
||||
input::placeholder {
|
||||
color: #77919d;
|
||||
}
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 0.7rem 1rem;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary {
|
||||
color: #032128;
|
||||
background: linear-gradient(135deg, var(--aqua), #b8f3db);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--aqua);
|
||||
background: rgba(118, 228, 213, 0.1);
|
||||
border: 1px solid rgba(118, 228, 213, 0.3);
|
||||
}
|
||||
.danger {
|
||||
color: #311016;
|
||||
background: var(--danger);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.48;
|
||||
}
|
||||
.text {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aqua);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.error {
|
||||
color: #ffd0d0;
|
||||
background: rgba(255, 80, 90, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
.demo {
|
||||
color: var(--sand);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sea {
|
||||
position: relative;
|
||||
padding: 1.5rem 1.1rem 2rem;
|
||||
}
|
||||
.moon {
|
||||
position: absolute;
|
||||
right: -35px;
|
||||
top: -20px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 35% 35%,
|
||||
#b8f3db,
|
||||
#178998 58%,
|
||||
transparent 61%
|
||||
);
|
||||
opacity: 0.22;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 1.25rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(118, 228, 213, 0.18),
|
||||
rgba(5, 24, 38, 0.7)
|
||||
);
|
||||
}
|
||||
.action b {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
background: rgba(118, 228, 213, 0.16);
|
||||
font-size: 1.7rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.action span {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.action small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.quote {
|
||||
margin: 0 1.1rem;
|
||||
text-align: center;
|
||||
color: #c9e0e6;
|
||||
}
|
||||
.quota-note {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.between,
|
||||
.button-row,
|
||||
.author,
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.button-row > * {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.success,
|
||||
.empty-ocean {
|
||||
margin: 4rem 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.success > b,
|
||||
.bottle {
|
||||
font-size: 3rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.bottle-card blockquote {
|
||||
font:
|
||||
500 1.5rem/1.6 "ZCOOL XiaoWei",
|
||||
serif;
|
||||
margin: 2rem 0.2rem;
|
||||
}
|
||||
.author {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.author i {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.author small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.conversation {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.3rem;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.conversation p {
|
||||
margin: 0.3rem 0;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
}
|
||||
.conversation time {
|
||||
grid-column: 1;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge,
|
||||
.pill {
|
||||
border-radius: 99px;
|
||||
background: rgba(118, 228, 213, 0.15);
|
||||
color: var(--aqua);
|
||||
padding: 0.22rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 10px);
|
||||
}
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 82%;
|
||||
margin: 0.7rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px 18px 18px 5px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.bubble p {
|
||||
margin: 0;
|
||||
}
|
||||
.bubble small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.bubble.sending {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.bubble.failed {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem calc(0.8rem + env(safe-area-inset-bottom));
|
||||
background: #061b2b;
|
||||
}
|
||||
.composer textarea {
|
||||
min-height: 48px;
|
||||
}
|
||||
.composer button {
|
||||
min-width: 48px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--aqua);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.dot {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dot.on {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.chat-actions .text {
|
||||
min-width: 44px;
|
||||
}
|
||||
.report-dialog {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 18%;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
background: #0b2d3b;
|
||||
}
|
||||
.report-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.bottle-row p {
|
||||
font-family: "ZCOOL XiaoWei", serif;
|
||||
font-size: 1.12rem;
|
||||
}
|
||||
.bottle-row time,
|
||||
.bottle-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: #020b13;
|
||||
padding: 0.6rem;
|
||||
border-radius: 10px;
|
||||
color: #9fc3cc;
|
||||
}
|
||||
.segmented {
|
||||
display: flex;
|
||||
padding: 0 1rem 1rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.segmented button {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.offline {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
background: #f1c27d;
|
||||
color: #2b1b05;
|
||||
padding: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
position: fixed;
|
||||
z-index: 8;
|
||||
bottom: 0;
|
||||
width: min(100%, 430px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 0.55rem 0.5rem calc(0.55rem + env(safe-area-inset-bottom));
|
||||
background: rgba(3, 17, 28, 0.94);
|
||||
backdrop-filter: blur(18px);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.tabs a {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.tabs a[aria-current="page"] {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
code {
|
||||
color: var(--sand);
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
body {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.app-shell {
|
||||
min-height: calc(100dvh - 4rem);
|
||||
border-radius: 32px;
|
||||
}
|
||||
.tabs {
|
||||
bottom: 2rem;
|
||||
border-radius: 0 0 32px 32px;
|
||||
}
|
||||
.chat {
|
||||
height: calc(100dvh - 4rem);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.orb,
|
||||
.bottle {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes float {
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const phone = ref("");
|
||||
const code = ref("");
|
||||
const debugCode = ref("");
|
||||
const sent = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref(
|
||||
route.query.error === "forbidden" ? "当前账号没有管理员权限" : "",
|
||||
);
|
||||
const deviceId = `admin-web-${crypto.randomUUID()}`;
|
||||
const validPhone = computed(() =>
|
||||
/^(?:\+?86)?1\d{10}$/.test(phone.value.replace(/\s/g, "")),
|
||||
);
|
||||
|
||||
async function send() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await auth.sendCode(phone.value, deviceId);
|
||||
sent.value = true;
|
||||
debugCode.value = result.debugCode ?? "";
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "发送失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(phone.value, code.value, deviceId);
|
||||
await auth.ensureAdmin();
|
||||
await router.replace("/admin");
|
||||
} catch (reason: unknown) {
|
||||
error.value =
|
||||
typeof reason === "object" &&
|
||||
reason !== null &&
|
||||
"status" in reason &&
|
||||
reason.status === 403
|
||||
? "当前账号没有管理员权限"
|
||||
: reason instanceof Error
|
||||
? reason.message
|
||||
: "管理员登录失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login admin-login">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">ADMIN / 治理后台</p>
|
||||
<h1>管理员登录</h1>
|
||||
<p>独立审核与举报处置入口。仅已授权管理员账号可进入。</p>
|
||||
</section>
|
||||
<form class="glass" @submit.prevent="sent ? login() : send()">
|
||||
<label
|
||||
>管理员手机号<input
|
||||
v-model="phone"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
required
|
||||
/></label>
|
||||
<label v-if="sent"
|
||||
>演示验证码<input
|
||||
v-model="code"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
required
|
||||
/></label>
|
||||
<p v-if="debugCode" class="demo">
|
||||
开发环境演示验证码:<strong>{{ debugCode }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !validPhone">
|
||||
{{ busy ? "请稍候…" : sent ? "进入治理后台" : "获取演示验证码" }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
import { createOperationState } from "./operation";
|
||||
const operations = createOperationState();
|
||||
const reports = ref<any[]>([]),
|
||||
tasks = ref<any[]>([]),
|
||||
error = operations.error,
|
||||
tab = ref<"reports" | "moderation" | "sanction">("reports"),
|
||||
publicId = ref(""),
|
||||
reason = ref(""),
|
||||
type = ref("WARNING");
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
api.get<any>("/admin/reports?limit=100"),
|
||||
api.get<any>("/admin/moderation?limit=100"),
|
||||
]);
|
||||
reports.value = r.items;
|
||||
tasks.value = m.items;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "无管理员权限";
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
async function resolve(id: string, decision: "UPHELD" | "DISMISSED") {
|
||||
await operations.run(`report:${id}`, async () => {
|
||||
await api.post(`/admin/reports/${id}/resolve`, {
|
||||
decision,
|
||||
resolution: decision === "UPHELD" ? "管理员确认违规" : "管理员驳回举报",
|
||||
});
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function moderate(id: string, decision: "APPROVED" | "REJECTED") {
|
||||
await operations.run(`moderation:${id}`, async () => {
|
||||
await api.post(`/admin/moderation/${id}/resolve`, {
|
||||
decision,
|
||||
reason: "管理员人工审核",
|
||||
});
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function sanction() {
|
||||
await operations.run("sanction", async () => {
|
||||
await api.post(`/admin/accounts/${publicId.value}/sanctions`, {
|
||||
type: type.value,
|
||||
reason: reason.value,
|
||||
});
|
||||
reason.value = "";
|
||||
publicId.value = "";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="admin">
|
||||
<AppHeader title="管理员工作台" back />
|
||||
<p v-if="error" class="error page" role="alert">{{ error }}</p>
|
||||
<nav class="segmented">
|
||||
<button @click="tab = 'reports'">举报 {{ reports.length }}</button
|
||||
><button @click="tab = 'moderation'">复审 {{ tasks.length }}</button
|
||||
><button @click="tab = 'sanction'">处罚</button>
|
||||
</nav>
|
||||
<section v-if="tab === 'reports'" class="list">
|
||||
<article v-for="item in reports" :key="item.id" class="glass">
|
||||
<div class="between">
|
||||
<b>{{ item.reason }}</b
|
||||
><span class="pill">{{ item.status }}</span>
|
||||
</div>
|
||||
<p>{{ item.details || "无补充说明" }}</p>
|
||||
<pre>{{ JSON.stringify(item.targetSnapshot, null, 2) }}</pre>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="operations.pending(`report:${item.id}`)"
|
||||
@click="resolve(item.id, 'DISMISSED')"
|
||||
>
|
||||
驳回</button
|
||||
><button
|
||||
class="danger"
|
||||
:disabled="operations.pending(`report:${item.id}`)"
|
||||
@click="resolve(item.id, 'UPHELD')"
|
||||
>
|
||||
确认违规
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section v-else-if="tab === 'moderation'" class="list">
|
||||
<article v-for="item in tasks" :key="item.id" class="glass">
|
||||
<b>{{ item.targetType }} · {{ item.targetId }}</b>
|
||||
<p>风险标签:{{ item.riskLabels.join("、") || "无" }}</p>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="operations.pending(`moderation:${item.id}`)"
|
||||
@click="moderate(item.id, 'APPROVED')"
|
||||
>
|
||||
通过</button
|
||||
><button
|
||||
class="danger"
|
||||
:disabled="operations.pending(`moderation:${item.id}`)"
|
||||
@click="moderate(item.id, 'REJECTED')"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<form v-else class="glass page" @submit.prevent="sanction">
|
||||
<h2>独立账号处罚</h2>
|
||||
<label>公开用户 ID<input v-model="publicId" required /></label
|
||||
><label
|
||||
>处罚类型<select v-model="type">
|
||||
<option>WARNING</option>
|
||||
<option>MUTE</option>
|
||||
<option>SUSPENSION</option>
|
||||
<option>BAN</option>
|
||||
</select></label
|
||||
><label
|
||||
>原因<textarea
|
||||
v-model="reason"
|
||||
maxlength="500"
|
||||
required
|
||||
></textarea></label
|
||||
><button class="danger" :disabled="operations.pending('sanction')">
|
||||
{{ operations.pending("sanction") ? "处理中…" : "实施处罚" }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const text = ref(""),
|
||||
busy = ref(false),
|
||||
result = ref<any>(null),
|
||||
error = ref("");
|
||||
async function submit() {
|
||||
if (!navigator.onLine) {
|
||||
error.value = "当前离线,瓶子没有投递。";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
result.value = await api.post(
|
||||
"/bottles",
|
||||
{ contentText: text.value },
|
||||
{ "Idempotency-Key": crypto.randomUUID() },
|
||||
);
|
||||
text.value = "";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "投递失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="写一只瓶子" back />
|
||||
<section v-if="!result" class="page">
|
||||
<p class="eyebrow">THROW / 投递</p>
|
||||
<h2>把一句话交给洋流</h2>
|
||||
<form class="glass" @submit.prevent="submit">
|
||||
<label
|
||||
>瓶中内容<textarea
|
||||
v-model="text"
|
||||
maxlength="1000"
|
||||
rows="9"
|
||||
placeholder="写下你想让陌生人看到的话…"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="between">
|
||||
<small>{{ text.length }} / 1000</small><span>匿名投递</span>
|
||||
</div>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !text.trim()">
|
||||
{{ busy ? "正在投递…" : "扔进海里" }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section v-else class="success glass">
|
||||
<b>✓</b>
|
||||
<h2>瓶子已进入审核</h2>
|
||||
<p>审核通过后才会进入海面。你可以在“我的瓶子”查看真实状态。</p>
|
||||
<RouterLink class="primary" to="/my-bottles">查看我的瓶子</RouterLink>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { api } from "../stores/auth";
|
||||
import { useChatStore } from "../stores/chat";
|
||||
const route = useRoute(),
|
||||
chat = useChatStore(),
|
||||
id = String(route.params.id),
|
||||
text = ref(""),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
const items = computed(() => chat.messages[id] ?? []);
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chat.loadMessages(id);
|
||||
chat.connect();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const reportOpen = ref(false);
|
||||
const reportReason = ref("HARASSMENT");
|
||||
const reportDetails = ref("");
|
||||
async function blockPeer() {
|
||||
if (!confirm("拉黑后双方将不能继续聊天,确认吗?")) return;
|
||||
try {
|
||||
await api.post(`/conversations/${id}/block`);
|
||||
error.value = "已拉黑对方,本会话不能继续发送消息。";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "拉黑失败";
|
||||
}
|
||||
}
|
||||
async function reportConversation() {
|
||||
try {
|
||||
await api.post(
|
||||
"/reports",
|
||||
{
|
||||
targetType: "CONVERSATION",
|
||||
targetId: id,
|
||||
reason: reportReason.value,
|
||||
...(reportDetails.value.trim()
|
||||
? { details: reportDetails.value.trim() }
|
||||
: {}),
|
||||
},
|
||||
{ "Idempotency-Key": crypto.randomUUID() },
|
||||
);
|
||||
reportOpen.value = false;
|
||||
error.value = "举报已提交审核。";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "举报失败";
|
||||
}
|
||||
}
|
||||
async function send() {
|
||||
const value = text.value.trim();
|
||||
if (!value) return;
|
||||
text.value = "";
|
||||
try {
|
||||
await chat.send(id, value);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "发送失败";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="chat">
|
||||
<AppHeader title="深海回声" back>
|
||||
<div class="chat-actions">
|
||||
<span class="dot" :class="{ on: chat.connected }">{{
|
||||
chat.connected ? "实时" : "同步中"
|
||||
}}</span>
|
||||
<button class="text" aria-label="举报会话" @click="reportOpen = true">
|
||||
举报
|
||||
</button>
|
||||
<button class="text" aria-label="拉黑对方" @click="blockPeer">
|
||||
拉黑
|
||||
</button>
|
||||
</div>
|
||||
</AppHeader>
|
||||
<dialog :open="reportOpen" class="glass report-dialog">
|
||||
<form @submit.prevent="reportConversation">
|
||||
<h2>举报会话</h2>
|
||||
<label
|
||||
>原因<select v-model="reportReason">
|
||||
<option>HARASSMENT</option>
|
||||
<option>SPAM</option>
|
||||
<option>SEXUAL</option>
|
||||
<option>VIOLENCE</option>
|
||||
<option>FRAUD</option>
|
||||
<option>OTHER</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>补充说明<textarea
|
||||
v-model="reportDetails"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
/>
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<button type="button" class="secondary" @click="reportOpen = false">
|
||||
取消</button
|
||||
><button class="danger">提交举报</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<StatusPanel v-if="loading" state="loading" />
|
||||
<section v-else class="messages">
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<article v-for="m in items" :key="m.id" class="bubble" :class="m.status">
|
||||
<p>{{ m.text }}</p>
|
||||
<small
|
||||
>#{{ m.seq }} ·
|
||||
{{
|
||||
m.status === "sending"
|
||||
? "发送中"
|
||||
: m.status === "failed"
|
||||
? "发送失败"
|
||||
: "已送达"
|
||||
}}</small
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<label class="sr-only">消息</label
|
||||
><textarea
|
||||
v-model="text"
|
||||
maxlength="1000"
|
||||
rows="1"
|
||||
placeholder="写下回声…"
|
||||
required
|
||||
></textarea
|
||||
><button aria-label="发送消息">↑</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { useChatStore } from "../stores/chat";
|
||||
const chat = useChatStore(),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chat.loadConversations();
|
||||
chat.connect();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="回声"
|
||||
><span class="pill">{{ chat.unread }} 未读</span></AppHeader
|
||||
><StatusPanel v-if="loading" state="loading" /><StatusPanel
|
||||
v-else-if="error"
|
||||
state="error"
|
||||
:detail="error"
|
||||
/><StatusPanel v-else-if="!chat.conversations.length" state="empty" />
|
||||
<section v-else class="list">
|
||||
<RouterLink
|
||||
v-for="item in chat.conversations"
|
||||
:key="item.id"
|
||||
:to="`/conversations/${item.id}`"
|
||||
class="conversation glass"
|
||||
><div>
|
||||
<b>匿名会话</b>
|
||||
<p>
|
||||
{{ item.lastMessage?.text ?? "新的相遇" }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="Number(item.unread)" class="badge">{{ item.unread }}</span
|
||||
><time>{{
|
||||
new Date(item.lastMessageAt).toLocaleDateString()
|
||||
}}</time></RouterLink
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="海面"
|
||||
><RouterLink
|
||||
class="avatar"
|
||||
to="/settings"
|
||||
:style="{ background: auth.me?.avatarColor ?? '#4fd1c5' }"
|
||||
aria-label="打开设置"
|
||||
>{{ auth.me?.nickname?.slice(0, 1) ?? "○" }}</RouterLink
|
||||
></AppHeader
|
||||
>
|
||||
<section class="sea">
|
||||
<div class="moon"></div>
|
||||
<p class="eyebrow">今晚的洋流很安静</p>
|
||||
<h2>{{ auth.me?.nickname ?? "匿名旅人" }},想留下些什么?</h2>
|
||||
<div class="actions">
|
||||
<RouterLink to="/throw" class="action throw"
|
||||
><b>↗</b
|
||||
><span>扔一只瓶子<small>每日上限 10 次</small></span></RouterLink
|
||||
><RouterLink to="/pick" class="action pick"
|
||||
><b>⌁</b
|
||||
><span>捞一只瓶子<small>每日上限 20 次</small></span></RouterLink
|
||||
>
|
||||
</div>
|
||||
<p class="quota-note">
|
||||
服务端尚未提供当日用量查询;此处只展示规则上限,不伪造剩余次数。
|
||||
</p>
|
||||
</section>
|
||||
<section class="glass quote">
|
||||
<p>“有些话不需要目的地,<br />只需要被海浪接住。”</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore(),
|
||||
route = useRoute(),
|
||||
router = useRouter();
|
||||
const phone = ref(""),
|
||||
code = ref(""),
|
||||
debugCode = ref(""),
|
||||
sent = ref(false),
|
||||
busy = ref(false),
|
||||
error = ref("");
|
||||
const deviceId = (() => {
|
||||
const key = "drift-device-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = `web-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
})();
|
||||
const validPhone = computed(() =>
|
||||
/^(?:\+?86)?1\d{10}$/.test(phone.value.replace(/\s/g, "")),
|
||||
);
|
||||
async function send() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const r = await auth.sendCode(phone.value, deviceId);
|
||||
sent.value = true;
|
||||
debugCode.value = r.debugCode ?? "";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "发送失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function login() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(phone.value, code.value, deviceId);
|
||||
await router.replace(
|
||||
typeof route.query.redirect === "string" ? route.query.redirect : "/",
|
||||
);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "登录失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="login">
|
||||
<section class="hero">
|
||||
<span class="orb">◉</span>
|
||||
<p class="eyebrow">DRIFT / 漂流瓶</p>
|
||||
<h1>听见深海里的<br /><em>一束回声</em></h1>
|
||||
<p>匿名投递,偶然相遇。每次打开都是真实连接。</p>
|
||||
</section>
|
||||
<form class="glass" @submit.prevent="sent ? login() : send()">
|
||||
<label
|
||||
>手机号<input
|
||||
v-model="phone"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
placeholder="+86 138 0000 0000"
|
||||
required /></label
|
||||
><label v-if="sent"
|
||||
>演示验证码<input
|
||||
v-model="code"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="6 位验证码"
|
||||
required
|
||||
/></label>
|
||||
<p v-if="debugCode" class="demo">
|
||||
开发环境演示验证码:<strong>{{ debugCode }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !validPhone">
|
||||
{{ busy ? "请稍候…" : sent ? "进入海面" : "获取演示验证码" }}</button
|
||||
><button v-if="sent" type="button" class="text" @click="sent = false">
|
||||
更换手机号
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const items = ref<any[]>([]),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
onMounted(async () => {
|
||||
try {
|
||||
items.value = (await api.get<any>("/me/bottles?limit=50")).items;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const labels: Record<string, string> = {
|
||||
REVIEWING: "审核中",
|
||||
MANUAL_REVIEW: "人工复审",
|
||||
APPROVED: "已通过",
|
||||
REJECTED: "未通过",
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="我的瓶子" /><StatusPanel
|
||||
v-if="loading"
|
||||
state="loading"
|
||||
/><StatusPanel
|
||||
v-else-if="error"
|
||||
state="error"
|
||||
:detail="error"
|
||||
/><StatusPanel v-else-if="!items.length" state="empty" />
|
||||
<section v-else class="list">
|
||||
<article v-for="item in items" :key="item.id" class="glass bottle-row">
|
||||
<div class="between">
|
||||
<span class="pill">{{
|
||||
labels[item.reviewStatus] ?? item.reviewStatus
|
||||
}}</span
|
||||
><time>{{ new Date(item.createdAt).toLocaleDateString() }}</time>
|
||||
</div>
|
||||
<p>{{ item.contentText }}</p>
|
||||
<small>池状态:{{ item.poolStatus }}</small>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const router = useRouter();
|
||||
const busy = ref(false),
|
||||
error = ref(""),
|
||||
picked = ref<any>(null),
|
||||
reply = ref("");
|
||||
async function pick() {
|
||||
if (!navigator.onLine) {
|
||||
error.value = "当前离线,无法捞瓶。";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
picked.value = await api.post("/bottles/pick", undefined, {
|
||||
"Idempotency-Key": crypto.randomUUID(),
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "海面暂时没有瓶子";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function back() {
|
||||
await api.post(`/bottles/${picked.value.bottle.id}/return`, {
|
||||
leaseId: picked.value.lease.id,
|
||||
token: picked.value.lease.token,
|
||||
});
|
||||
picked.value = null;
|
||||
}
|
||||
async function answer() {
|
||||
const r = await api.post<any>(`/bottles/${picked.value.bottle.id}/reply`, {
|
||||
leaseId: picked.value.lease.id,
|
||||
leaseToken: picked.value.lease.token,
|
||||
clientMsgId: crypto.randomUUID(),
|
||||
text: reply.value,
|
||||
});
|
||||
await router.push(`/conversations/${r.conversationId}`);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="捞一只瓶子" back />
|
||||
<section class="page">
|
||||
<div v-if="!picked" class="empty-ocean">
|
||||
<div class="bottle">⌁</div>
|
||||
<h2>让洋流替你选择</h2>
|
||||
<p>不会捞到自己、拉黑对象或已经见过的瓶子。</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy" @click="pick">
|
||||
{{ busy ? "正在寻找…" : "伸手捞取" }}
|
||||
</button>
|
||||
</div>
|
||||
<article v-else class="glass bottle-card">
|
||||
<div class="author">
|
||||
<i :style="{ background: picked.author.avatarColor }"></i>
|
||||
<div>
|
||||
<b>{{ picked.author.nickname }}</b
|
||||
><small>{{ picked.author.bio || "匿名海客" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{{ picked.bottle.contentText }}</blockquote>
|
||||
<form @submit.prevent="answer">
|
||||
<label
|
||||
>第一句回复<textarea
|
||||
v-model="reply"
|
||||
maxlength="1000"
|
||||
rows="4"
|
||||
required
|
||||
></textarea></label
|
||||
><button class="primary" :disabled="!reply.trim()">
|
||||
回复并建立会话
|
||||
</button>
|
||||
</form>
|
||||
<button class="secondary" @click="back">放回海里</button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api, useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore(),
|
||||
router = useRouter(),
|
||||
blocks = ref<any[]>([]),
|
||||
enabled = ref(true),
|
||||
nickname = ref(""),
|
||||
bio = ref(""),
|
||||
color = ref("#4fd1c5"),
|
||||
message = ref("");
|
||||
onMounted(async () => {
|
||||
nickname.value = auth.me?.nickname ?? "";
|
||||
bio.value = auth.me?.bio ?? "";
|
||||
color.value = auth.me?.avatarColor ?? "#4fd1c5";
|
||||
const [b, p] = await Promise.all([
|
||||
api.get<any>("/me/blocks"),
|
||||
api.get<any>("/settings/push"),
|
||||
]);
|
||||
blocks.value = b.items;
|
||||
enabled.value = p.inAppEnabled;
|
||||
});
|
||||
async function saveProfile() {
|
||||
await api.patch("/me/anonymous-profile", {
|
||||
nickname: nickname.value,
|
||||
avatarColor: color.value,
|
||||
bio: bio.value || null,
|
||||
});
|
||||
message.value = "资料已提交审核";
|
||||
}
|
||||
async function preference() {
|
||||
await api.put("/settings/push", { inAppEnabled: enabled.value });
|
||||
message.value = "通知偏好已保存";
|
||||
}
|
||||
async function unblock(id: string) {
|
||||
await api.delete(`/me/blocks/${id}`);
|
||||
blocks.value = blocks.value.filter((x) => x.publicId !== id);
|
||||
}
|
||||
async function logout() {
|
||||
await auth.logout();
|
||||
await router.replace("/login");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="设置" />
|
||||
<section class="page settings">
|
||||
<form class="glass" @submit.prevent="saveProfile">
|
||||
<h2>匿名资料</h2>
|
||||
<label>昵称<input v-model="nickname" maxlength="64" required /></label
|
||||
><label>头像色<input v-model="color" type="color" required /></label
|
||||
><label
|
||||
>简介<textarea
|
||||
v-model="bio"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
></textarea></label
|
||||
><button class="secondary">保存资料</button>
|
||||
</form>
|
||||
<section class="glass">
|
||||
<h2>通知偏好</h2>
|
||||
<label class="toggle"
|
||||
><span>站内通知<small>浏览器内提醒,不等同系统 Push</small></span
|
||||
><input v-model="enabled" type="checkbox" @change="preference"
|
||||
/></label>
|
||||
</section>
|
||||
<section class="glass">
|
||||
<h2>黑名单</h2>
|
||||
<p v-if="!blocks.length" class="muted">没有已拉黑用户</p>
|
||||
<div v-for="item in blocks" :key="item.publicId" class="between">
|
||||
<code>{{ item.publicId.slice(0, 8) }}…</code
|
||||
><button class="text" @click="unblock(item.publicId)">解除</button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="message" role="status">{{ message }}</p>
|
||||
<button class="danger" @click="logout">退出登录</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createOperationState } from "./operation";
|
||||
|
||||
describe("operation state", () => {
|
||||
it("blocks duplicate submissions and exposes 4xx feedback", async () => {
|
||||
let reject!: (reason: unknown) => void;
|
||||
const action = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, nextReject) => {
|
||||
reject = nextReject;
|
||||
}),
|
||||
);
|
||||
const state = createOperationState();
|
||||
const first = state.run("report:r-1", action);
|
||||
const duplicate = state.run("report:r-1", action);
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
expect(state.pending("report:r-1")).toBe(true);
|
||||
reject(
|
||||
Object.assign(new Error("Report already resolved"), { status: 409 }),
|
||||
);
|
||||
await expect(first).rejects.toThrow("Report already resolved");
|
||||
await expect(duplicate).rejects.toThrow("Report already resolved");
|
||||
expect(state.pending("report:r-1")).toBe(false);
|
||||
expect(state.error.value).toBe(
|
||||
"操作冲突:该项目可能已被处理,请刷新后重试",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function createOperationState() {
|
||||
const active = new Map<string, Promise<unknown>>();
|
||||
const error = ref("");
|
||||
|
||||
function pending(key: string) {
|
||||
return active.has(key);
|
||||
}
|
||||
|
||||
function run<T>(key: string, action: () => Promise<T>): Promise<T> {
|
||||
const existing = active.get(key) as Promise<T> | undefined;
|
||||
if (existing) return existing;
|
||||
error.value = "";
|
||||
const operation = action()
|
||||
.catch((reason: unknown) => {
|
||||
const status =
|
||||
typeof reason === "object" && reason !== null && "status" in reason
|
||||
? reason.status
|
||||
: undefined;
|
||||
error.value =
|
||||
status === 409
|
||||
? "操作冲突:该项目可能已被处理,请刷新后重试"
|
||||
: status === 403
|
||||
? "无权执行此管理操作"
|
||||
: reason instanceof Error
|
||||
? reason.message
|
||||
: "管理操作失败";
|
||||
throw reason;
|
||||
})
|
||||
.finally(() => active.delete(key));
|
||||
active.set(key, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { error, pending, run };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
// E2E 跑产物时关闭 Service Worker,避免缓存干扰断言
|
||||
disable: process.env.DISABLE_PWA === "1",
|
||||
registerType: "autoUpdate",
|
||||
manifest: {
|
||||
name: "漂流瓶 · 深海回声",
|
||||
short_name: "漂流瓶",
|
||||
description: "匿名漂流瓶社交 PWA",
|
||||
theme_color: "#061b2b",
|
||||
background_color: "#04111d",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: { navigateFallback: "/index.html", runtimeCaching: [] },
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
"/socket.io": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
ws: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// E2E 跑产物预览时同样需要把 /api 与 /socket.io 代理到 API,
|
||||
// 并由代理注入 Origin,满足 StateChangingOriginGuard 的校验。
|
||||
preview: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
"/socket.io": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
ws: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"test:database": "vitest run tests/integration/database.spec.ts",
|
||||
"test:integration": "vitest run tests/integration --no-file-parallelism && corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts --no-file-parallelism",
|
||||
"test:security": "corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts src/auth/auth.e2e-spec.ts src/conversation/conversation.e2e-spec.ts --no-file-parallelism",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.base.json",
|
||||
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
||||
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
||||
@@ -26,6 +27,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "22.19.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// 浏览器用例跑构建产物(vite preview),不再用按需转译的 dev server:
|
||||
// dev server 每次整页加载要拉数百个模块,Chromium 会以
|
||||
// ERR_INSUFFICIENT_RESOURCES 放弃加载,页面根本挂载不起来。
|
||||
const PORT = Number(process.env.WEB_PORT ?? 4173);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
globalSetup: "./tests/e2e/global-setup.ts",
|
||||
// 双用户用例需要等 Worker 审核资料/瓶子(每个 1-2s)+ 两次登录,
|
||||
// 60s 不够,会在用例中途被拆除 context 而误报失败。
|
||||
timeout: 150_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${PORT}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
},
|
||||
// 由 Playwright 保证被测前端可用:先构建(关闭 Service Worker,避免缓存
|
||||
// 干扰断言),再起 preview。服务中途消失会让整轮报 ERR_CONNECTION_REFUSED,
|
||||
// 交由 Playwright 管理可避免这类假失败。
|
||||
webServer: {
|
||||
command: `DISABLE_PWA=1 corepack pnpm --filter @drift/web exec vite build && corepack pnpm --filter @drift/web exec vite preview --host 127.0.0.1 --port ${PORT} --strictPort`,
|
||||
url: `http://127.0.0.1:${PORT}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 240_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
// 该机器内存较小,避免 /dev/shm 与多余缓存把渲染进程拖垮
|
||||
launchOptions: {
|
||||
args: ["--disable-dev-shm-usage", "--disable-gpu"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Generated
+5552
-2900
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { E2E_ADMIN } from "./global-setup";
|
||||
import { freshAccount, loginViaUi, smsAndLogin, pickBottle } from "./helpers";
|
||||
|
||||
test.describe("admin governance", () => {
|
||||
test("admin login is guarded, reports are resolved with audit, sanction applies", async ({
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
// 预置管理员账号(global-setup),用 demo 登录拿到 accessToken
|
||||
const adminSession = await smsAndLogin(request, E2E_ADMIN);
|
||||
expect(adminSession.accessToken).toBeTruthy();
|
||||
const adminHeaders = {
|
||||
authorization: `Bearer ${adminSession.accessToken}`,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
// 制造举报:Alice 投瓶,Bob 捞瓶回复后举报会话
|
||||
const aliceAccount = freshAccount("147");
|
||||
const ctxAlice = await browser.newContext();
|
||||
const alicePage = await ctxAlice.newPage();
|
||||
await loginViaUi(alicePage, aliceAccount);
|
||||
await alicePage.goto("/throw");
|
||||
await alicePage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`治理测试会话 ${Date.now()}`);
|
||||
await alicePage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(alicePage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
const bobAccount = freshAccount("145");
|
||||
const ctxBob = await browser.newContext();
|
||||
const bobPage = await ctxBob.newPage();
|
||||
await loginViaUi(bobPage, bobAccount);
|
||||
await bobPage.goto("/pick");
|
||||
await pickBottle(bobPage);
|
||||
await bobPage.locator(".bottle-card textarea").fill("这条消息将被举报");
|
||||
await bobPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bobPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
|
||||
// Bob 举报该会话
|
||||
await bobPage.getByRole("button", { name: /举报会话/ }).click();
|
||||
await bobPage.getByRole("button", { name: "提交举报" }).click();
|
||||
await expect(bobPage.getByText(/举报已提交审核/)).toBeVisible();
|
||||
|
||||
// 管理员查看待审举报并处置
|
||||
const after = await request.get(
|
||||
"/api/v1/admin/reports?status=PENDING&limit=5",
|
||||
{ headers: adminHeaders },
|
||||
);
|
||||
expect(after.ok()).toBeTruthy();
|
||||
const afterBody = (await after.json()) as {
|
||||
data?: { items?: Array<{ id: string }> };
|
||||
};
|
||||
const pending = afterBody.data?.items ?? [];
|
||||
expect(pending.length).toBeGreaterThan(0);
|
||||
const report = pending[0]!;
|
||||
|
||||
const resolve = await request.post(
|
||||
`/api/v1/admin/reports/${report.id}/resolve`,
|
||||
{
|
||||
headers: adminHeaders,
|
||||
data: { decision: "DISMISSED", resolution: "E2E 无违规" },
|
||||
},
|
||||
);
|
||||
expect(resolve.ok()).toBeTruthy();
|
||||
|
||||
// 独立处罚:为 Bob 的公开 ID 施加警告
|
||||
const aliceMe = await request.get("/api/v1/me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${
|
||||
(await smsAndLogin(request, aliceAccount)).accessToken
|
||||
}`,
|
||||
},
|
||||
});
|
||||
const aliceBody = (await aliceMe.json()) as {
|
||||
data?: { publicId?: string };
|
||||
};
|
||||
const publicId = aliceBody.data?.publicId;
|
||||
if (publicId) {
|
||||
const sanction = await request.post(
|
||||
`/api/v1/admin/accounts/${publicId}/sanctions`,
|
||||
{
|
||||
headers: adminHeaders,
|
||||
data: { type: "WARNING", reason: "E2E 处罚验证" },
|
||||
},
|
||||
);
|
||||
expect(sanction.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
await ctxBob.close();
|
||||
await ctxAlice.close();
|
||||
});
|
||||
|
||||
test("non-admin user is forbidden from the admin workbench", async ({
|
||||
page,
|
||||
}) => {
|
||||
const account = freshAccount("152");
|
||||
await loginViaUi(page, account);
|
||||
|
||||
await page.goto("/admin");
|
||||
// 非管理员应被引导回独立管理员登录页并显示无权限
|
||||
await expect(page).toHaveURL(/\/admin\/login/, { timeout: 15_000 });
|
||||
await expect(page.getByText(/没有管理员权限/).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { freshAccount, loginViaUi } from "./helpers";
|
||||
|
||||
test.describe("authentication", () => {
|
||||
test("unauthenticated user is redirected to the login page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/conversations");
|
||||
await expect(page).toHaveURL(/\/login\?redirect=/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /获取演示验证码/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo login reaches the sea home and persists across reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const account = freshAccount("137");
|
||||
await loginViaUi(page, account);
|
||||
|
||||
await expect(page.getByRole("link", { name: /扔一只瓶子/ })).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole("link", { name: /扔一只瓶子/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("logout returns to login and clears the session", async ({ page }) => {
|
||||
const account = freshAccount("136");
|
||||
await loginViaUi(page, account);
|
||||
|
||||
await page.goto("/settings");
|
||||
await page.getByRole("button", { name: "退出登录" }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/login\?redirect=/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { freshAccount, loginViaUi, pickBottle } from "./helpers";
|
||||
|
||||
test.describe("bottle social loop", () => {
|
||||
test("throws, picks, replies and chats in real time with two users", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aliceAccount = freshAccount("131");
|
||||
const bobAccount = freshAccount("132");
|
||||
|
||||
const alice = await browser.newContext();
|
||||
const bob = await browser.newContext();
|
||||
const aPage = await alice.newPage();
|
||||
const bPage = await bob.newPage();
|
||||
|
||||
await loginViaUi(aPage, aliceAccount);
|
||||
await loginViaUi(bPage, bobAccount);
|
||||
|
||||
// Alice 投瓶
|
||||
await aPage.goto("/throw");
|
||||
await aPage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill("你好,海上的陌生人,愿你被温柔接住。");
|
||||
await aPage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(aPage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
// Bob 捞瓶(过滤掉 Alice 已投的,应捞到池中瓶子)
|
||||
await bPage.goto("/pick");
|
||||
await pickBottle(bPage);
|
||||
const content = (await bPage.locator("blockquote").innerText()).trim();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
// Bob 首次回复建立会话
|
||||
await bPage
|
||||
.locator(".bottle-card textarea")
|
||||
.fill("很高兴遇见你,来自另一片海域。");
|
||||
await bPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
await expect(
|
||||
bPage.getByText("很高兴遇见你,来自另一片海域。"),
|
||||
).toBeVisible();
|
||||
|
||||
// Alice 会话列表出现新会话
|
||||
await aPage.goto("/conversations");
|
||||
await expect(aPage.locator(".conversation")).toHaveCount(1, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Bob 发送实时消息,两端都能看到
|
||||
await bPage.locator(".composer textarea").fill("今天过得怎么样?");
|
||||
await bPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(bPage.getByText("今天过得怎么样?")).toBeVisible();
|
||||
|
||||
await aPage.goto("/conversations");
|
||||
await aPage.locator(".conversation").first().click();
|
||||
await expect(aPage.getByText("今天过得怎么样?")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await alice.close();
|
||||
await bob.close();
|
||||
});
|
||||
|
||||
test("replies once and deduplicates concurrent history sync", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aAccount = freshAccount("133");
|
||||
const bAccount = freshAccount("134");
|
||||
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const pageA = await ctxA.newPage();
|
||||
const pageB = await ctxB.newPage();
|
||||
|
||||
await loginViaUi(pageA, aAccount);
|
||||
await loginViaUi(pageB, bAccount);
|
||||
|
||||
// 两个瓶子避免同瓶并发
|
||||
await pageA.goto("/throw");
|
||||
await pageA
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`sync 瓶子 A ${Date.now()}`);
|
||||
await pageA.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(pageA.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
await pageA.goto("/throw");
|
||||
await pageA
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`sync 瓶子 B ${Date.now()}`);
|
||||
await pageA.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(pageA.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
// B 连续捞两只瓶子并各回复一次
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await pageB.goto("/pick");
|
||||
await pickBottle(pageB);
|
||||
await pageB.locator(".bottle-card textarea").fill(`回复-${i}:海上的你好`);
|
||||
await pageB.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(pageB).toHaveURL(/\/conversations\//, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A 端应看到两个会话
|
||||
await pageA.goto("/conversations");
|
||||
await expect(pageA.locator(".conversation")).toHaveCount(2, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { resetDatabase } from "./global-setup";
|
||||
|
||||
/**
|
||||
* 所有 E2E 用例共用:
|
||||
* - beforeEach:清库,避免上一用例残留的瓶子/租约让「捞取」拿到别人的瓶子
|
||||
* - afterEach:关闭本用例打开的所有 context。用例中途失败时若不关闭,context
|
||||
* (页面/连接/内存)会一直累积,机器内存与 fd 有限,后续用例会成片失败。
|
||||
*/
|
||||
export const test = base;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ browser }) => {
|
||||
for (const context of browser.contexts()) {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public";
|
||||
|
||||
const ADMIN_PHONE = "13900000001";
|
||||
const ADMIN_DEVICE = "e2e-admin-device";
|
||||
// 与测试/开发环境一致的演示密钥(auth.e2e-spec.ts 同款)
|
||||
const PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||
|
||||
function phoneHmac(phone: string): string {
|
||||
return createHmac("sha256", PHONE_HMAC_KEY).update(phone).digest("hex");
|
||||
}
|
||||
|
||||
function normalizePhone(raw: string): string {
|
||||
const compact = raw.replace(/[\s()-]/g, "");
|
||||
const local = compact.startsWith("+86")
|
||||
? compact.slice(3)
|
||||
: compact.startsWith("86") && compact.length === 13
|
||||
? compact.slice(2)
|
||||
: compact;
|
||||
if (!/^1[3-9]\d{9}$/.test(local))
|
||||
throw new Error(`invalid admin phone: ${raw}`);
|
||||
return `+86${local}`;
|
||||
}
|
||||
|
||||
export const E2E_ADMIN = { phone: ADMIN_PHONE, deviceId: ADMIN_DEVICE };
|
||||
|
||||
const TABLES = [
|
||||
"outbox_events",
|
||||
"moderation_tasks",
|
||||
"bottle_pick_leases",
|
||||
"bottle_pick_requests",
|
||||
"bottle_pick_history",
|
||||
"messages",
|
||||
"conversation_members",
|
||||
"conversations",
|
||||
"notifications",
|
||||
"reports",
|
||||
"sanctions",
|
||||
"audit_logs",
|
||||
"refresh_tokens",
|
||||
"sessions",
|
||||
"daily_usage",
|
||||
"bottles",
|
||||
"anonymous_profiles",
|
||||
"accounts",
|
||||
];
|
||||
|
||||
/**
|
||||
* 清空所有业务表(保留 schema)并重建管理员账号。
|
||||
* 既用于 globalSetup,也被各 spec 的 beforeEach 调用,避免用例间数据污染
|
||||
*(残留瓶子会让「捞取」捞到别的用例的瓶子,导致会话归属错乱)。
|
||||
*/
|
||||
export async function resetDatabase(): Promise<void> {
|
||||
const prisma = new PrismaClient({
|
||||
datasources: { db: { url: DATABASE_URL } },
|
||||
});
|
||||
try {
|
||||
for (const table of TABLES) {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
|
||||
}
|
||||
const digest = phoneHmac(normalizePhone(ADMIN_PHONE));
|
||||
await prisma.account.upsert({
|
||||
where: { phoneHmac: digest },
|
||||
update: { role: "ADMIN", status: "ACTIVE" },
|
||||
create: {
|
||||
role: "ADMIN",
|
||||
status: "ACTIVE",
|
||||
phoneHmac: digest,
|
||||
// 测试库仅用于校验 phoneHmac 查找,明文备用
|
||||
phoneCiphertext: Buffer.from("admin-e2e-placeholder", "utf8"),
|
||||
anonymousProfile: {
|
||||
create: {
|
||||
nickname: "管理员",
|
||||
avatarColor: "#66CCFF",
|
||||
reviewStatus: "APPROVED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
await resetDatabase();
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
export interface DemoAccount {
|
||||
phone: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* libphonenumber 的 CN 元数据比公开号段表更严(实测 146/148/149 会被后端
|
||||
* 拒绝为 Validation failed),这里只列实测通过 IsPhoneNumber("CN") 的号段,
|
||||
* 避免用例传入看似合法却会被拒的前缀。
|
||||
*/
|
||||
const VALID_PREFIXES = [
|
||||
"130",
|
||||
"131",
|
||||
"132",
|
||||
"133",
|
||||
"134",
|
||||
"135",
|
||||
"136",
|
||||
"137",
|
||||
"138",
|
||||
"139",
|
||||
"145",
|
||||
"147",
|
||||
"150",
|
||||
"151",
|
||||
"152",
|
||||
"155",
|
||||
"156",
|
||||
"157",
|
||||
"158",
|
||||
"159",
|
||||
"165",
|
||||
"166",
|
||||
"167",
|
||||
"170",
|
||||
"171",
|
||||
"172",
|
||||
"173",
|
||||
"175",
|
||||
"176",
|
||||
"177",
|
||||
"178",
|
||||
"180",
|
||||
"181",
|
||||
"182",
|
||||
"183",
|
||||
"184",
|
||||
"185",
|
||||
"186",
|
||||
"187",
|
||||
"188",
|
||||
"189",
|
||||
"190",
|
||||
"191",
|
||||
"198",
|
||||
"199",
|
||||
] as const;
|
||||
|
||||
/** 生成合法中国大陆手机号(3 位前缀 + 8 位随机 = 11 位,后端 IsPhoneNumber("CN") 校验通过) */
|
||||
export function freshAccount(prefix?: string): DemoAccount {
|
||||
const chosen =
|
||||
prefix ??
|
||||
VALID_PREFIXES[Math.floor(Math.random() * VALID_PREFIXES.length)]!;
|
||||
if (!(VALID_PREFIXES as readonly string[]).includes(chosen))
|
||||
throw new Error(`测试手机号前缀不在已验证的合法号段内: ${chosen}`);
|
||||
const suffix = String(Math.floor(Math.random() * 100_000_000)).padStart(
|
||||
8,
|
||||
"0",
|
||||
);
|
||||
const phone = `${chosen}${suffix}`;
|
||||
if (!/^1(3\d|4[5-9]|5[0-35-9]|6[26]|7[0-8]|8\d|9[0-35-9])\d{8}$/.test(phone))
|
||||
throw new Error(`生成的测试手机号不合法: ${phone}`);
|
||||
return {
|
||||
phone,
|
||||
deviceId: `e2e-${chosen}-${phone}-${Math.floor(Math.random() * 1e6)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 通过页面 UI 完成演示验证码登录,并确保匿名资料已设置且审核通过(否则投的瓶无法被他人捞到) */
|
||||
export async function loginViaUi(
|
||||
page: import("@playwright/test").Page,
|
||||
account: DemoAccount,
|
||||
): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.getByPlaceholder(/\+86/).fill(account.phone);
|
||||
await page.getByRole("button", { name: /获取演示验证码/ }).click();
|
||||
|
||||
const codeInput = page.locator('input[autocomplete="one-time-code"]');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
const demo = page.locator(".demo");
|
||||
await expect(demo).toBeVisible();
|
||||
const text = await demo.innerText();
|
||||
const code = text.match(/\d{6}/)?.[0];
|
||||
if (!code) throw new Error("未从演示验证码区域解析到 6 位验证码");
|
||||
await codeInput.fill(code);
|
||||
|
||||
await page.getByRole("button", { name: /进入海面/ }).click();
|
||||
await expect(page).toHaveURL(/\/$/, { timeout: 15_000 });
|
||||
|
||||
// 注意:页面内 Vue 只把 accessToken 存在内存,page.request 无法获取,
|
||||
// 因此这里经 API 再领一个 token,后续 AuthGuard 接口都带 Bearer 头。
|
||||
const { accessToken } = await smsAndLogin(page.request, account);
|
||||
const bearer = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
// 设置匿名资料并等待 worker 审核通过(新用户否则无法参与匹配闭环)
|
||||
const me = await page.request.get("/api/v1/me", { headers: bearer });
|
||||
const meBody = (await me.json()) as {
|
||||
data?: { profileReviewStatus?: string | null };
|
||||
};
|
||||
if (meBody.data?.profileReviewStatus !== "APPROVED") {
|
||||
const patch = await page.request.patch("/api/v1/me/anonymous-profile", {
|
||||
headers: bearer,
|
||||
data: {
|
||||
nickname: `海客${account.phone.slice(-4)}`,
|
||||
avatarColor: "#66CCFF",
|
||||
bio: null,
|
||||
},
|
||||
});
|
||||
if (!patch.ok()) {
|
||||
throw new Error(
|
||||
`匿名资料设置失败: ${patch.status()} ${await patch.text()}`,
|
||||
);
|
||||
}
|
||||
// 轮询等待资料审核通过
|
||||
const deadline = Date.now() + 25_000;
|
||||
let status: string | null | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
const latest = await page.request.get("/api/v1/me", { headers: bearer });
|
||||
status = (
|
||||
(await latest.json()) as { data?: { profileReviewStatus?: string } }
|
||||
).data?.profileReviewStatus;
|
||||
if (status === "APPROVED") return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`匿名资料未在时限内通过审核,最后状态: ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 直接经 API 完成演示登录,返回 accessToken 与 set-cookie(用于注入管理会话) */
|
||||
export async function smsAndLogin(
|
||||
request: APIRequestContext,
|
||||
account: DemoAccount,
|
||||
): Promise<{ accessToken: string; cookie: string }> {
|
||||
const send = await request.post("/api/v1/auth/sms/send", {
|
||||
data: { phone: account.phone, deviceId: account.deviceId },
|
||||
});
|
||||
expect(send.ok()).toBeTruthy();
|
||||
const body = (await send.json()) as { data?: { debugCode?: string } };
|
||||
const code = body.data?.debugCode;
|
||||
if (!code) throw new Error("sms/send 未返回 debugCode(演示登录未开启?)");
|
||||
|
||||
const login = await request.post("/api/v1/auth/sms/login", {
|
||||
data: { phone: account.phone, code, deviceId: account.deviceId },
|
||||
});
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const loginBody = (await login.json()) as {
|
||||
data?: { accessToken?: string };
|
||||
};
|
||||
const accessToken = loginBody.data?.accessToken;
|
||||
if (!accessToken) throw new Error("sms/login 未返回 accessToken");
|
||||
return { accessToken, cookie: login.headers()["set-cookie"] ?? "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击「伸手捞取」并在瓶子卡片出现前自动重试。
|
||||
* 投出的瓶子需等 Worker 审核入池(约 1-2s),立即捞会得到空池;
|
||||
* 上一次请求进行中按钮文案会变成「正在寻找…」,需等它恢复可点击。
|
||||
*/
|
||||
export async function pickBottle(
|
||||
page: import("@playwright/test").Page,
|
||||
attempts = 30,
|
||||
): Promise<void> {
|
||||
const card = page.locator(".bottle-card");
|
||||
const pickButton = page.getByRole("button", { name: /伸手捞取/ });
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
// 上一轮点击后卡片可能姗姗来迟:先确认是否已经捞到
|
||||
if (await card.isVisible().catch(() => false)) return;
|
||||
await pickButton.waitFor({ state: "visible", timeout: 20_000 });
|
||||
await expect(pickButton).toBeEnabled({ timeout: 20_000 });
|
||||
await pickButton.click({ timeout: 10_000 });
|
||||
try {
|
||||
// 卡片出现即成功;给足渲染时间,避免误判后又去等已经消失的按钮
|
||||
await card.waitFor({ state: "visible", timeout: 10_000 });
|
||||
return;
|
||||
} catch {
|
||||
// 池中暂无候选(投出的瓶子还在审核中),短暂等待后重试
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
}
|
||||
throw new Error("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { freshAccount, loginViaUi, pickBottle } from "./helpers";
|
||||
|
||||
test.describe("safety and resilience", () => {
|
||||
test("reports and reloads chat history after an offline reconnect", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aAccount = freshAccount("135");
|
||||
const bAccount = freshAccount("134");
|
||||
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const aPage = await ctxA.newPage();
|
||||
const bPage = await ctxB.newPage();
|
||||
|
||||
await loginViaUi(aPage, aAccount);
|
||||
await loginViaUi(bPage, bAccount);
|
||||
|
||||
// A 投瓶,B 捞瓶并回复建立会话
|
||||
await aPage.goto("/throw");
|
||||
await aPage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`断线恢复瓶子 ${Date.now()}`);
|
||||
await aPage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(aPage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
await bPage.goto("/pick");
|
||||
await pickBottle(bPage);
|
||||
await bPage.locator(".bottle-card textarea").fill("离线前的第一条消息");
|
||||
await bPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
|
||||
// A 进入同一会话
|
||||
await aPage.goto("/conversations");
|
||||
await aPage.locator(".conversation").first().click();
|
||||
await expect(aPage.getByText("离线前的第一条消息")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// A 离线前再互发一条
|
||||
await bPage.locator(".composer textarea").fill("离线前的第二条消息");
|
||||
await bPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(aPage.getByText("离线前的第二条消息")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 模拟 B 断线(离线),A 发送新消息
|
||||
await ctxB.setOffline(true);
|
||||
await aPage.locator(".composer textarea").fill("断线期间的第三条消息");
|
||||
await aPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(aPage.getByText("断线期间的第三条消息")).toBeVisible();
|
||||
|
||||
// B 恢复在线,重新拉取历史应补全缺失消息
|
||||
await ctxB.setOffline(false);
|
||||
await bPage.reload();
|
||||
await expect(bPage.getByText("断线期间的第三条消息")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
|
||||
test("blocks a peer and both sides can no longer send messages", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aAccount = freshAccount("138");
|
||||
const bAccount = freshAccount("139");
|
||||
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const aPage = await ctxA.newPage();
|
||||
const bPage = await ctxB.newPage();
|
||||
|
||||
await loginViaUi(aPage, aAccount);
|
||||
await loginViaUi(bPage, bAccount);
|
||||
|
||||
await aPage.goto("/throw");
|
||||
await aPage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`拉黑测试瓶子 ${Date.now()}`);
|
||||
await aPage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(aPage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
await bPage.goto("/pick");
|
||||
await pickBottle(bPage);
|
||||
await bPage.locator(".bottle-card textarea").fill("建立会话,稍后拉黑");
|
||||
await bPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
|
||||
// A 进入会话并拉黑
|
||||
await aPage.goto("/conversations");
|
||||
await aPage.locator(".conversation").first().click();
|
||||
await expect(aPage.getByText("建立会话,稍后拉黑")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
aPage.once("dialog", (dialog) => void dialog.accept());
|
||||
await aPage.getByRole("button", { name: /拉黑对方/ }).click();
|
||||
await expect(aPage.getByText(/已拉黑对方/)).toBeVisible();
|
||||
|
||||
// B 发消息应失败
|
||||
await bPage.locator(".composer textarea").fill("你还在吗?");
|
||||
await bPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(bPage.locator(".bubble.failed").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -16,6 +16,8 @@
|
||||
"packages/**/*.ts",
|
||||
"apps/**/*.ts",
|
||||
"prisma/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
"tests/**/*.ts",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
/**
|
||||
* 根级 `pnpm test` 是聚合入口,排除两类不该由它收集的用例:
|
||||
* - tests/e2e:Playwright 用例,由 `pnpm test:e2e` 驱动,vitest 无法执行;
|
||||
* - apps/web:需要 packages 自己的 vitest.config.ts(vue 插件 + jsdom),
|
||||
* 由 `corepack pnpm --filter @drift/web test` 运行。
|
||||
*/
|
||||
exclude: [...configDefaults.exclude, "tests/e2e/**", "apps/web/**"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user