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: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: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: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",
|
"typecheck": "tsc --noEmit -p tsconfig.base.json",
|
||||||
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
||||||
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/node": "22.19.3",
|
"@types/node": "22.19.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||||
"@typescript-eslint/parser": "^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
+5488
-2836
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",
|
"packages/**/*.ts",
|
||||||
"apps/**/*.ts",
|
"apps/**/*.ts",
|
||||||
"prisma/**/*.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