Compare commits

...

2 Commits

Author SHA1 Message Date
root afab4e7cba test(web): 补充 Web MVP 端到端测试与本地测试编排
- 新增 Playwright E2E(治理、登录、投瓶捞瓶回复与实时聊天、举报拉黑与断线恢复)共 9 条用例
- 每个用例前重置数据库、用例后关闭全部 browser context,消除跨用例污染与资源泄漏
- 前端改由 Playwright 托管构建与 preview(关闭 SW);Vite 代理注入 Origin 头以通过接口 Origin 校验
- 根级 vitest 排除 Playwright 用例与需专用配置的 web 用例,避免误收集导致的假失败
- 测试手机号按实测校验结果收敛白名单,避免用例传入会被后端拒绝的号段

验证结果:
- Web E2E 9/9 连续两轮通过(含运行前后服务健康检查)
- API E2E 146/146 连续两轮通过
- 单测:根级 14 文件/119 用例、web 包 8 文件/31 用例通过
- typecheck、lint、build、依赖审计(audit)均通过
2026-09-18 11:23:20 +08:00
root 7ca855f19c feat(web): 构建漂流瓶移动端 PWA
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
2026-09-17 22:19:24 +08:00
53 changed files with 9289 additions and 2901 deletions
+17
View File
@@ -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>
+31
View File
@@ -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"
}
}
+1
View File
@@ -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

+16
View File
@@ -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"
}
]
}
+15
View File
@@ -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>
+195
View File
@@ -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();
});
});
+142
View File
@@ -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 : "网络连接失败",
);
}
+185
View File
@@ -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);
});
});
+97
View File
@@ -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);
});
}
+15
View File
@@ -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>
+18
View File
@@ -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);
});
});
+38
View File
@@ -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>
+21
View File
@@ -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>
+10
View File
@@ -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;
}
+12
View File
@@ -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");
+63
View File
@@ -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();
});
});
+88
View File
@@ -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;
}
+53
View File
@@ -0,0 +1,53 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, useAuthStore } from "./auth";
const me = {
accountId: "a",
publicId: "p",
nickname: null,
avatarColor: null,
bio: null,
profileReviewStatus: null,
};
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
});
describe("auth store", () => {
it("becomes authenticated reactively after login", async () => {
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
expect(auth.isAuthenticated).toBe(false);
await auth.login("13800000000", "123456", "device");
expect(auth.isAuthenticated).toBe(true);
});
it("restores authentication during bootstrap refresh", async () => {
vi.spyOn(api, "refresh").mockResolvedValue("restored");
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
await auth.bootstrap();
expect(auth.ready).toBe(true);
expect(auth.isAuthenticated).toBe(true);
});
it("clears the local session even when the server logout fails", async () => {
vi.spyOn(api, "post").mockImplementation((path: string) => {
if (path.includes("/auth/logout")) throw new Error("network down");
return Promise.resolve({ accessToken: "fresh" });
});
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
await auth.login("13800000000", "123456", "device");
expect(auth.isAuthenticated).toBe(true);
await auth.logout();
expect(auth.me).toBeNull();
expect(auth.isAuthenticated).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { createApiClient } from "../api/client";
export interface Me {
accountId: string;
publicId: string | null;
nickname: string | null;
avatarColor: string | null;
bio: string | null;
profileReviewStatus: string | null;
}
const accessToken = ref<string | null>(null);
export const api = createApiClient({
getToken: () => accessToken.value,
setToken: (value) => {
accessToken.value = value;
},
});
export const useAuthStore = defineStore("auth", () => {
const me = ref<Me | null>(null);
const ready = ref(false);
const isAuthenticated = computed(() =>
Boolean(accessToken.value && me.value),
);
async function bootstrap() {
if (ready.value) return;
try {
await api.refresh();
me.value = await api.get<Me>("/me");
} catch {
accessToken.value = null;
me.value = null;
} finally {
ready.value = true;
}
}
async function sendCode(phone: string, deviceId: string) {
return api.post<{ sent: boolean; debugCode?: string }>("/auth/sms/send", {
phone,
deviceId,
});
}
async function login(phone: string, code: string, deviceId: string) {
const pair = await api.post<{ accessToken: string }>("/auth/sms/login", {
phone,
code,
deviceId,
});
accessToken.value = pair.accessToken;
me.value = await api.get<Me>("/me");
}
async function ensureAdmin() {
await api.get("/admin/reports?limit=1");
}
async function logout() {
try {
await api.post("/auth/logout");
} catch {
// 本地始终清理;服务端注销失败也不阻塞退出
}
accessToken.value = null;
me.value = null;
}
return {
me,
ready,
isAuthenticated,
bootstrap,
sendCode,
login,
ensureAdmin,
logout,
};
});
export function currentAccessToken() {
return accessToken.value;
}
+174
View File
@@ -0,0 +1,174 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises } from "@vue/test-utils";
import { api, useAuthStore } from "./auth";
import { useChatStore } from "./chat";
import type { Conversation } from "./chat";
import type { ChatMessage } from "./messages";
const socket = vi.hoisted(() => ({
connected: false,
on: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
}));
vi.mock("../api/socket", () => ({
createChatSocket: () => socket,
sendSocketMessage: vi.fn(),
}));
const me = {
accountId: "a",
publicId: "p",
nickname: null,
avatarColor: null,
bio: null,
profileReviewStatus: null,
};
function message(seq: number): ChatMessage {
return {
id: `m-${seq}`,
conversationId: "c-1",
sender: { publicId: "p" },
clientMsgId: `client-${seq}`,
seq: String(seq),
text: String(seq),
status: "sent",
sentAt: new Date(seq).toISOString(),
};
}
const conversation = (): Conversation => ({
id: "c-1",
status: "ACTIVE",
lastMessageAt: new Date(0).toISOString(),
lastMessage: null,
unread: "0",
lastReadSeq: "0",
});
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
socket.connected = false;
socket.disconnect.mockClear();
});
describe("chat history synchronization", () => {
it("loads every page beyond 100 messages and reliably deduplicates overlap", async () => {
const pages = [
Array.from({ length: 100 }, (_, index) => message(index + 1)),
Array.from({ length: 100 }, (_, index) => message(index + 100)),
Array.from({ length: 51 }, (_, index) => message(index + 199)),
[],
];
const get = vi
.spyOn(api, "get")
.mockImplementation(() =>
Promise.resolve({ items: pages.shift() ?? [] }),
);
const chat = useChatStore();
const result = await chat.loadMessages("c-1");
expect(result).toBeDefined();
expect(result ?? []).toHaveLength(249);
expect(result?.at(-1)?.seq).toBe("249");
expect(get).toHaveBeenCalledTimes(3);
expect(get.mock.calls.map(([path]) => path)).toEqual([
"/conversations/c-1/messages?afterSeq=0&limit=100",
"/conversations/c-1/messages?afterSeq=100&limit=100",
"/conversations/c-1/messages?afterSeq=199&limit=100",
]);
});
it("stops paging after the page cap to bound waterfall requests", async () => {
const pages = [
Array.from({ length: 100 }, (_, index) => message(index + 1)),
Array.from({ length: 100 }, (_, index) => message(index + 101)),
Array.from({ length: 100 }, (_, index) => message(index + 201)),
Array.from({ length: 100 }, (_, index) => message(index + 301)),
Array.from({ length: 100 }, (_, index) => message(index + 401)),
[],
];
const get = vi
.spyOn(api, "get")
.mockImplementation(() =>
Promise.resolve({ items: pages.shift() ?? [] }),
);
const chat = useChatStore();
const result = await chat.loadMessages("c-1");
expect(result ?? []).toHaveLength(500);
expect(get.mock.calls.length).toBeLessThanOrEqual(5);
expect(get.mock.calls.at(-1)?.[0]).toBe(
"/conversations/c-1/messages?afterSeq=400&limit=100",
);
});
it("syncs only the most recent active conversations when reconnecting", async () => {
const get = vi.spyOn(api, "get").mockResolvedValue({ items: [] });
const chat = useChatStore();
chat.conversations = Array.from({ length: 30 }, (_, index) => {
const id = `c-${index}`;
return {
id,
status: "ACTIVE",
lastMessageAt: new Date(1700000000000 + index).toISOString(),
lastMessage: null,
unread: "0",
lastReadSeq: "0",
};
});
await chat.syncAll();
expect(
get.mock.calls.filter(([path]) => path.includes("/messages")),
).toHaveLength(5);
expect(
get.mock.calls.filter(([path]) => path.includes("/messages"))[0]![0],
).toContain("c-29");
});
});
describe("chat session lifecycle", () => {
it("disconnects and clears account state when authentication ends", async () => {
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
vi.spyOn(api, "get").mockResolvedValue(me);
const auth = useAuthStore();
await auth.login("13800000000", "123456", "device");
expect(auth.isAuthenticated).toBe(true);
const chat = useChatStore();
chat.conversations = [conversation()];
chat.messages = { "c-1": [message(1)] };
expect(chat.conversations).toHaveLength(1);
auth.me = null;
await flushPromises();
expect(auth.isAuthenticated).toBe(false);
expect(socket.disconnect).toHaveBeenCalled();
expect(chat.conversations).toEqual([]);
expect(chat.messages).toEqual({});
expect(chat.connected).toBe(false);
});
it("reset clears conversations and disconnects even when called directly", () => {
const auth = useAuthStore();
auth.me = me;
const chat = useChatStore();
chat.conversations = [conversation()];
chat.messages = { "c-1": [message(1)] };
chat.reset();
expect(socket.disconnect).toHaveBeenCalled();
expect(chat.conversations).toEqual([]);
expect(chat.messages).toEqual({});
});
});
+138
View File
@@ -0,0 +1,138 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { api, currentAccessToken, useAuthStore } from "./auth";
import { createChatSocket, sendSocketMessage } from "../api/socket";
import { mergeMessages, type ChatMessage } from "./messages";
export interface Conversation {
id: string;
status: string;
lastMessageAt: string;
lastMessage: ChatMessage | null;
unread: string;
lastReadSeq: string;
}
const MAX_MESSAGE_PAGES = 5; // 单个会话重连同步上限(500 条),避免瀑布请求
const MAX_SYNC_CONVERSATIONS = 5; // 重连时仅同步最近活跃会话
export const useChatStore = defineStore("chat", () => {
const auth = useAuthStore();
const conversations = ref<Conversation[]>([]);
const messages = ref<Record<string, ChatMessage[]>>({});
const socket = createChatSocket(currentAccessToken, api.refresh);
const connected = ref(false);
socket.on("connect", () => {
connected.value = true;
void syncAll();
});
socket.on("disconnect", () => {
connected.value = false;
});
socket.on("message:new", (message: ChatMessage) => {
messages.value[message.conversationId] = mergeMessages(
messages.value[message.conversationId] ?? [],
[message],
);
});
async function loadConversations() {
const page = await api.get<{ items: Conversation[] }>("/conversations");
conversations.value = page.items;
return page.items;
}
async function loadMessages(id: string) {
let afterSeq = messages.value[id]?.at(-1)?.seq ?? "0";
let hasMore = true;
let pages = 0;
while (hasMore && pages < MAX_MESSAGE_PAGES) {
pages += 1;
const page = await api.get<{ items: ChatMessage[] }>(
`/conversations/${id}/messages?afterSeq=${afterSeq}&limit=100`,
);
messages.value[id] = mergeMessages(messages.value[id] ?? [], page.items);
hasMore = page.items.length === 100;
if (!hasMore) break;
const nextAfterSeq = page.items.at(-1)?.seq;
if (!nextAfterSeq || BigInt(nextAfterSeq) <= BigInt(afterSeq)) break;
afterSeq = nextAfterSeq;
}
return messages.value[id];
}
async function syncAll() {
const recent = [...conversations.value]
.sort((a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt))
.slice(0, MAX_SYNC_CONVERSATIONS);
for (const conversation of recent) await loadMessages(conversation.id);
}
function reset() {
socket.disconnect();
connected.value = false;
conversations.value = [];
messages.value = {};
}
let wasAuthenticated = auth.isAuthenticated;
watch(
() => auth.isAuthenticated,
(authenticated) => {
if (wasAuthenticated && !authenticated) reset();
wasAuthenticated = authenticated;
},
);
function connect() {
if (!socket.connected) socket.connect();
}
function disconnect() {
socket.disconnect();
}
async function send(id: string, text: string) {
const clientMsgId = crypto.randomUUID();
const optimistic: ChatMessage = {
id: `local:${clientMsgId}`,
conversationId: id,
clientMsgId,
sender: { publicId: "" },
seq: "0",
text,
sentAt: new Date().toISOString(),
status: "sending",
};
messages.value[id] = mergeMessages(messages.value[id] ?? [], [optimistic]);
try {
const result = connected.value
? await sendSocketMessage(
socket,
{
conversationId: id,
clientMsgId,
text,
},
api.refresh,
)
: await api.post<{ message: ChatMessage }>(
`/conversations/${id}/messages/prepare`,
{ conversationId: id, clientMsgId, text },
);
messages.value[id] = mergeMessages(messages.value[id], [
{ ...result.message, status: "sent" },
]);
} catch (error) {
optimistic.status = "failed";
messages.value[id] = [...messages.value[id]];
throw error;
}
}
const unread = computed(() =>
conversations.value.reduce((sum, item) => sum + Number(item.unread), 0),
);
return {
conversations,
messages,
connected,
unread,
loadConversations,
loadMessages,
syncAll,
connect,
disconnect,
reset,
send,
};
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { mergeMessages, type ChatMessage } from "./messages";
const message = (id: string, seq: string): ChatMessage => ({
id,
conversationId: "conversation",
sender: { publicId: "sender" },
clientMsgId: `client-${id}`,
seq,
text: id,
sentAt: "2026-09-17T00:00:00.000Z",
status: "sent",
});
describe("message synchronization", () => {
it("deduplicates socket and reconnect history messages and sorts by sequence", () => {
const result = mergeMessages(
[message("two", "2"), message("three", "3")],
[message("one", "1"), message("two", "2")],
);
expect(result.map(({ id }) => id)).toEqual(["one", "two", "three"]);
});
it("reconciles an optimistic message by client message id", () => {
const optimistic = {
...message("temporary", "0"),
clientMsgId: "same",
status: "sending" as const,
};
const confirmed = { ...message("server", "4"), clientMsgId: "same" };
expect(mergeMessages([optimistic], [confirmed])).toEqual([confirmed]);
});
});
+31
View File
@@ -0,0 +1,31 @@
export interface ChatMessage {
id: string;
conversationId: string;
sender: { publicId: string };
clientMsgId: string;
seq: string;
text: string;
status: string;
reviewStatus?: string;
sentAt: string;
}
export function mergeMessages(
current: ChatMessage[],
incoming: ChatMessage[],
): ChatMessage[] {
// 双 Map:按 id 去重 + 按 clientMsgId 调和乐观消息,整体 O(n)
const byId = new Map<string, ChatMessage>();
const byClientMsgId = new Map<string, string>();
const consider = (item: ChatMessage) => {
const existingId = byClientMsgId.get(item.clientMsgId);
if (existingId) byId.delete(existingId);
byClientMsgId.set(item.clientMsgId, item.id);
byId.set(item.id, item);
};
for (const item of current) consider(item);
for (const item of incoming) consider(item);
return [...byId.values()].sort((a, b) =>
BigInt(a.seq) < BigInt(b.seq) ? -1 : BigInt(a.seq) > BigInt(b.seq) ? 1 : 0,
);
}
+545
View File
@@ -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);
}
}
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+91
View File
@@ -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>
+133
View File
@@ -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>
+62
View File
@@ -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>
+139
View File
@@ -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>
+48
View File
@@ -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>
+38
View File
@@ -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>
+90
View File
@@ -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>
+48
View File
@@ -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>
+83
View File
@@ -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>
+81
View File
@@ -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>
+28
View File
@@ -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(
"操作冲突:该项目可能已被处理,请刷新后重试",
);
});
});
+37
View File
@@ -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 };
}
+16
View File
@@ -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"
]
}
+61
View File
@@ -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" },
},
},
},
});
+11
View File
@@ -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,
},
});
+2
View File
@@ -8,6 +8,7 @@
"test:database": "vitest run tests/integration/database.spec.ts",
"test:integration": "vitest run tests/integration --no-file-parallelism && corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts --no-file-parallelism",
"test:security": "corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts src/auth/auth.e2e-spec.ts src/conversation/conversation.e2e-spec.ts --no-file-parallelism",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit -p tsconfig.base.json",
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
@@ -26,6 +27,7 @@
}
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "22.19.3",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
+46
View File
@@ -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"],
},
},
},
],
});
+5552 -2900
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -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();
});
});
+39
View File
@@ -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=/);
});
});
+112
View File
@@ -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();
});
});
+22
View File
@@ -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 };
+91
View File
@@ -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();
}
+193
View File
@@ -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("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)");
}
+111
View File
@@ -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
View File
@@ -16,6 +16,8 @@
"packages/**/*.ts",
"apps/**/*.ts",
"prisma/**/*.ts",
"tests/**/*.ts"
"tests/**/*.ts",
"playwright.config.ts",
"vitest.config.ts"
]
}
+13
View File
@@ -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/**"],
},
});