feat(web): 构建漂流瓶移动端 PWA
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#061b2b" />
|
||||
<meta name="description" content="匿名漂流瓶深海社交" />
|
||||
<title>漂流瓶 · 深海回声</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@drift/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"test": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vue-tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@drift/contracts": "workspace:*",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vitest": "^4.1.11",
|
||||
"vue-tsc": "^3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#061b2b"/><circle cx="256" cy="210" r="128" fill="#0b4050"/><path d="M180 130h152l-20 196c-5 48-107 48-112 0z" fill="#76e4d5" opacity=".85"/><path d="M160 350q96-70 192 0" fill="none" stroke="#f1c27d" stroke-width="22" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "漂流瓶 · 深海回声",
|
||||
"short_name": "漂流瓶",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#04111d",
|
||||
"theme_color": "#061b2b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import OfflineBanner from "./components/OfflineBanner.vue";
|
||||
import TabBar from "./components/TabBar.vue";
|
||||
const route = useRoute();
|
||||
const shell = computed(
|
||||
() => route.name !== "login" && route.name !== "admin-login",
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<OfflineBanner /><RouterView /><TabBar v-if="shell" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiClient } from "./client";
|
||||
|
||||
const response = (status: number, data: unknown) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: status < 400 ? "OK" : "AUTH_TOKEN_EXPIRED",
|
||||
message: status < 400 ? "success" : "expired",
|
||||
data,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
{ status, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
describe("API client authentication", () => {
|
||||
it("coalesces concurrent 401 refreshes and retries both requests", async () => {
|
||||
let token = "expired";
|
||||
let refreshes = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
await Promise.resolve();
|
||||
token = "fresh";
|
||||
return response(200, {
|
||||
accessToken: token,
|
||||
expiresIn: 900,
|
||||
tokenType: "Bearer",
|
||||
});
|
||||
}
|
||||
return token === "expired"
|
||||
? response(401, null)
|
||||
: response(200, { path });
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken: (next) => {
|
||||
token = next ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([client.get("/me"), client.get("/conversations")]);
|
||||
|
||||
expect(refreshes).toBe(1);
|
||||
expect(fetcher).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("coalesces concurrent refresh failures and logs out only once", async () => {
|
||||
let token: string | null = "expired";
|
||||
const setToken = vi.fn((next: string | null) => {
|
||||
token = next;
|
||||
});
|
||||
const onUnauthorized = vi.fn();
|
||||
let refreshes = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
return response(401, null);
|
||||
}
|
||||
if (path.endsWith("/conversations"))
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return response(401, null);
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
client.get("/me"),
|
||||
client.get("/conversations"),
|
||||
]);
|
||||
|
||||
expect(results.every((result) => result.status === "rejected")).toBe(true);
|
||||
expect(refreshes).toBe(1);
|
||||
expect(setToken).toHaveBeenCalledTimes(1);
|
||||
expect(setToken).toHaveBeenCalledWith(null);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not claim an offline write succeeded", async () => {
|
||||
const client = createApiClient({
|
||||
fetcher: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")),
|
||||
getToken: () => "token",
|
||||
setToken: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post("/bottles", { contentText: "hello" }),
|
||||
).rejects.toMatchObject({ code: "NETWORK_ERROR" });
|
||||
});
|
||||
|
||||
it("does not repeat refresh or logout after a failed refresh", async () => {
|
||||
let token: string | null = "expired";
|
||||
let refreshes = 0;
|
||||
const setToken = vi.fn((next: string | null) => {
|
||||
token = next;
|
||||
});
|
||||
const onUnauthorized = vi.fn();
|
||||
const fetcher = vi.fn((input: RequestInfo | URL): Promise<Response> => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
return Promise.resolve(response(401, null));
|
||||
}
|
||||
return Promise.resolve(response(401, null));
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
await expect(client.get("/conversations")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
|
||||
expect(refreshes).toBe(1);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers refresh after an explicit new login", async () => {
|
||||
let token: string | null = "expired";
|
||||
let refreshSucceeds = false;
|
||||
let refreshes = 0;
|
||||
const onUnauthorized = vi.fn();
|
||||
const fetcher = vi.fn((input: RequestInfo | URL): Promise<Response> => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
if (!refreshSucceeds) return Promise.resolve(response(401, null));
|
||||
return Promise.resolve(
|
||||
response(200, {
|
||||
accessToken: "fresh2",
|
||||
expiresIn: 900,
|
||||
tokenType: "Bearer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(response(401, null));
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken: (next) => {
|
||||
token = next;
|
||||
},
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(refreshes).toBe(1);
|
||||
|
||||
// 显式重新登录产生新会话
|
||||
token = "fresh";
|
||||
refreshSucceeds = true;
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(refreshes).toBe(2);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId: string;
|
||||
}
|
||||
export interface ApiFailure {
|
||||
code: string;
|
||||
message: string;
|
||||
status: number | undefined;
|
||||
requestId: string | undefined;
|
||||
}
|
||||
export interface ApiClientOptions {
|
||||
fetcher?: typeof fetch;
|
||||
baseUrl?: string;
|
||||
getToken: () => string | null;
|
||||
setToken: (token: string | null) => void;
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
export class ApiError extends Error implements ApiFailure {
|
||||
public status: number | undefined;
|
||||
public requestId: string | undefined;
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
status?: number,
|
||||
requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(options: ApiClientOptions) {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const baseUrl = options.baseUrl ?? "/api/v1";
|
||||
let refreshing: Promise<string> | null = null;
|
||||
let failedRefresh: { token: string | null; error: unknown } | null = null;
|
||||
|
||||
async function refresh(
|
||||
staleToken: string | null = options.getToken(),
|
||||
): Promise<string> {
|
||||
// 同一认证失败周期内:只要令牌仍是失败时的令牌(或已被清空),
|
||||
// 就直接抛出已记录的失败,不再请求 refresh 接口或重复触发登出。
|
||||
if (
|
||||
failedRefresh !== null &&
|
||||
(staleToken === failedRefresh.token || staleToken === null)
|
||||
)
|
||||
throw failedRefresh.error;
|
||||
if (!refreshing) {
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const response = await fetcher(`${baseUrl}/auth/token/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const body = (await response.json()) as ApiEnvelope<{
|
||||
accessToken: string;
|
||||
}>;
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
options.setToken(body.data.accessToken);
|
||||
failedRefresh = null;
|
||||
return body.data.accessToken;
|
||||
} catch (error) {
|
||||
const failure = networkError(error);
|
||||
failedRefresh = { token: staleToken, error: failure };
|
||||
options.setToken(null);
|
||||
options.onUnauthorized?.();
|
||||
throw failure;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
retry = true,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = options.getToken();
|
||||
if (token) headers.set("authorization", `Bearer ${token}`);
|
||||
if (init.body && !headers.has("content-type"))
|
||||
headers.set("content-type", "application/json");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (error) {
|
||||
throw networkError(error);
|
||||
}
|
||||
const body = (await response.json()) as ApiEnvelope<T>;
|
||||
if (response.status === 401 && retry && path !== "/auth/token/refresh") {
|
||||
await refresh(token);
|
||||
return request<T>(path, init, false);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
return body.data;
|
||||
}
|
||||
return {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(path, {
|
||||
method: "POST",
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
...(headers === undefined ? {} : { headers }),
|
||||
}),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
function networkError(error: unknown): ApiError {
|
||||
if (error instanceof ApiError) return error;
|
||||
return new ApiError(
|
||||
"NETWORK_ERROR",
|
||||
error instanceof Error ? error.message : "网络连接失败",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindSocketAuthentication, sendSocketMessage } from "./socket";
|
||||
|
||||
type Ack = (error: Error | null, response?: unknown) => void;
|
||||
|
||||
class FakeSocket {
|
||||
connected = false;
|
||||
handlers = new Map<string, (value?: unknown) => void>();
|
||||
emits: Array<{ event: string; payload: unknown; ack: Ack | undefined }> = [];
|
||||
connect = vi.fn(() => {
|
||||
this.connected = true;
|
||||
return this;
|
||||
});
|
||||
disconnect = vi.fn(() => {
|
||||
this.connected = false;
|
||||
return this;
|
||||
});
|
||||
timeout = vi.fn(() => this);
|
||||
emit = vi.fn((event: string, payload: unknown, ack?: Ack) => {
|
||||
this.emits.push({ event, payload, ack });
|
||||
return this;
|
||||
});
|
||||
on(event: string, handler: (value?: unknown) => void) {
|
||||
this.handlers.set(event, handler);
|
||||
return this;
|
||||
}
|
||||
emitEvent(event: string, value?: unknown) {
|
||||
this.handlers.get(event)?.(value);
|
||||
}
|
||||
ack(index: number, response: unknown) {
|
||||
this.emits[index]?.ack?.(null, response);
|
||||
}
|
||||
}
|
||||
|
||||
const okResponse = (id = "real-1") => ({
|
||||
ok: true,
|
||||
data: {
|
||||
message: {
|
||||
id,
|
||||
conversationId: "c-1",
|
||||
sender: { publicId: "p" },
|
||||
clientMsgId: "m-1",
|
||||
seq: "1",
|
||||
text: "hi",
|
||||
status: "sent",
|
||||
sentAt: new Date(0).toISOString(),
|
||||
},
|
||||
deduplicated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const errorResponse = (code: string, message: string) => ({
|
||||
ok: false,
|
||||
error: { code, message },
|
||||
});
|
||||
|
||||
const payload = { conversationId: "c-1", clientMsgId: "m-1", text: "hi" };
|
||||
|
||||
describe("socket authentication recovery", () => {
|
||||
it("refreshes once after concurrent auth errors and reconnects with current token", async () => {
|
||||
let token = "expired";
|
||||
let release!: () => void;
|
||||
const refresh = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
release = () => {
|
||||
token = "fresh";
|
||||
resolve(token);
|
||||
};
|
||||
}),
|
||||
);
|
||||
const socket = new FakeSocket();
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
release();
|
||||
await vi.waitFor(() => expect(socket.connect).toHaveBeenCalledOnce());
|
||||
expect(token).toBe("fresh");
|
||||
});
|
||||
|
||||
it("does not refresh unrelated connection failures", () => {
|
||||
const refresh = vi.fn();
|
||||
const socket = new FakeSocket();
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
socket.emitEvent("connect_error", new Error("transport closed"));
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects and stops recovering when refresh fails", async () => {
|
||||
const socket = new FakeSocket();
|
||||
let refreshCalls = 0;
|
||||
const refresh = vi.fn().mockImplementation(() => {
|
||||
refreshCalls += 1;
|
||||
return Promise.reject(new Error("refresh failed"));
|
||||
});
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(socket.disconnect).toHaveBeenCalledOnce());
|
||||
|
||||
// 后续重连错误不会再次触发 refresh(锁保持,避免无限循环)
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
expect(refreshCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes, reconnects and retries once with the same clientMsgId after an ACK auth error", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn().mockResolvedValue("fresh");
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce());
|
||||
|
||||
expect(socket.disconnect).toHaveBeenCalledOnce();
|
||||
expect(socket.connect).toHaveBeenCalledOnce();
|
||||
expect(socket.emits).toHaveLength(2);
|
||||
expect(socket.emits[1]!.payload).toMatchObject({ clientMsgId: "m-1" });
|
||||
socket.ack(1, okResponse());
|
||||
|
||||
await expect(sending).resolves.toMatchObject({
|
||||
message: { id: "real-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retry non-auth ACK failures", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn();
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
socket.ack(0, errorResponse("RATE_LIMITED", "too many"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({ code: "RATE_LIMITED" });
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not loop when the retried attempt also fails auth", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn().mockResolvedValue("fresh");
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce());
|
||||
socket.ack(1, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({ code: "AUTH_TOKEN_EXPIRED" });
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(socket.emits).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects without refreshing when no refresh handler is provided", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const sending = sendSocketMessage(socket as never, payload);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import type { ChatMessage } from "../stores/messages";
|
||||
|
||||
export interface SocketResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
export interface AuthenticationSocket {
|
||||
connected: boolean;
|
||||
connect(): unknown;
|
||||
disconnect(): unknown;
|
||||
on(event: "connect_error", handler: (error: unknown) => void): unknown;
|
||||
}
|
||||
|
||||
export type SocketError = Error & { code?: string };
|
||||
|
||||
export function bindSocketAuthentication(
|
||||
socket: AuthenticationSocket,
|
||||
refresh: () => Promise<string>,
|
||||
) {
|
||||
let recovering: Promise<void> | null = null;
|
||||
socket.on("connect_error", (error: unknown) => {
|
||||
const candidate = error as { message?: string; data?: { code?: string } };
|
||||
const authenticationError =
|
||||
candidate.data?.code?.startsWith("AUTH_") ||
|
||||
/unauthorized|authentication|token/i.test(candidate.message ?? "");
|
||||
if (!authenticationError || recovering) return;
|
||||
recovering = refresh()
|
||||
.then(() => {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
})
|
||||
.catch(() => {
|
||||
// 刷新失败:断开连接并“锁死”恢复路径,避免无限重连循环。
|
||||
// 只有显式重新登录(新会话)重建 socket 后才会再次武装恢复。
|
||||
socket.disconnect();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createChatSocket(
|
||||
token: () => string | null,
|
||||
refresh?: () => Promise<string>,
|
||||
): Socket {
|
||||
const socket = io("/chat", {
|
||||
autoConnect: false,
|
||||
withCredentials: true,
|
||||
auth: (callback) => callback({ token: token() }),
|
||||
});
|
||||
if (refresh) bindSocketAuthentication(socket, refresh);
|
||||
return socket;
|
||||
}
|
||||
|
||||
function responseError(response: SocketResponse): SocketError {
|
||||
const error = new Error(response.error?.message ?? "发送失败") as SocketError;
|
||||
if (response.error?.code) error.code = response.error.code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function emitOnce(
|
||||
socket: Socket,
|
||||
payload: { conversationId: string; clientMsgId: string; text: string },
|
||||
): Promise<{ message: ChatMessage; deduplicated: boolean }> {
|
||||
return new Promise((resolve, reject) =>
|
||||
socket.timeout(10000).emit(
|
||||
"message:send",
|
||||
payload,
|
||||
(
|
||||
error: Error | null,
|
||||
response: SocketResponse<{
|
||||
message: ChatMessage;
|
||||
deduplicated: boolean;
|
||||
}>,
|
||||
) => {
|
||||
if (error) return reject(error);
|
||||
if (!response.ok || !response.data)
|
||||
return reject(responseError(response));
|
||||
resolve(response.data);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function sendSocketMessage(
|
||||
socket: Socket,
|
||||
payload: { conversationId: string; clientMsgId: string; text: string },
|
||||
refresh?: () => Promise<string>,
|
||||
): Promise<{ message: ChatMessage; deduplicated: boolean }> {
|
||||
return emitOnce(socket, payload).catch(async (error: SocketError) => {
|
||||
if (!refresh || !error.code?.startsWith("AUTH_")) throw error;
|
||||
await refresh();
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
return emitOnce(socket, payload);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; back?: boolean }>();
|
||||
</script>
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<RouterLink v-if="back" to="/" class="icon-link" aria-label="返回首页"
|
||||
>‹</RouterLink
|
||||
>
|
||||
<div>
|
||||
<small>DRIFT / 深海</small>
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
const online = ref(navigator.onLine);
|
||||
const update = () => (online.value = navigator.onLine);
|
||||
onMounted(() => {
|
||||
addEventListener("online", update);
|
||||
addEventListener("offline", update);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListener("online", update);
|
||||
removeEventListener("offline", update);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="!online" class="offline" role="status">
|
||||
离线模式 · 写操作将明确失败
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cleanup, render, screen } from "@testing-library/vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
import StatusPanel from "./StatusPanel.vue";
|
||||
|
||||
describe("StatusPanel", () => {
|
||||
it.each([
|
||||
["loading", "正在潜入深海"],
|
||||
["empty", "这里还没有内容"],
|
||||
["error", "海浪暂时阻断了连接"],
|
||||
["offline", "当前离线"],
|
||||
] as const)("renders an accessible %s state", (state, label) => {
|
||||
render(StatusPanel, { props: { state } });
|
||||
expect(screen.getByRole("status")).toHaveTextContent(label);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
state: "loading" | "empty" | "error" | "offline";
|
||||
detail?: string;
|
||||
}>();
|
||||
const labels = {
|
||||
loading: "正在潜入深海…",
|
||||
empty: "这里还没有内容",
|
||||
error: "海浪暂时阻断了连接",
|
||||
offline: "当前离线,写操作不会被保存",
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span aria-hidden="true">{{
|
||||
props.state === "loading" ? "◌" : props.state === "empty" ? "○" : "!"
|
||||
}}</span>
|
||||
<p>{{ labels[props.state] }}</p>
|
||||
<small v-if="detail">{{ detail }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.status {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.status span {
|
||||
font-size: 2rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.status p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.status small {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from "vue-router";
|
||||
const route = useRoute();
|
||||
const links: [string, string][] = [
|
||||
["/", "海面"],
|
||||
["/conversations", "回声"],
|
||||
["/my-bottles", "瓶子"],
|
||||
["/settings", "设置"],
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<nav class="tabs" aria-label="主导航">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link[0]"
|
||||
:to="link[0]"
|
||||
:aria-current="route.path === link[0] ? 'page' : undefined"
|
||||
>{{ link[1] }}</RouterLink
|
||||
>
|
||||
</nav>
|
||||
</template>
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
unknown
|
||||
>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import { createAppRouter } from "./router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import "./styles/main.css";
|
||||
const app = createApp(App),
|
||||
pinia = createPinia();
|
||||
app.use(pinia);
|
||||
const auth = useAuthStore(pinia);
|
||||
app.use(createAppRouter(auth));
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createMemoryHistory } from "vue-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAppRouter } from "./index";
|
||||
|
||||
describe("router guards", () => {
|
||||
function auth(
|
||||
overrides: Partial<{
|
||||
isAuthenticated: boolean;
|
||||
bootstrap: () => Promise<unknown>;
|
||||
ensureAdmin: () => Promise<unknown>;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||
ensureAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
it("redirects guests to login and preserves the destination", async () => {
|
||||
const session = auth();
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
|
||||
await router.push("/conversations");
|
||||
await router.isReady();
|
||||
|
||||
expect(router.currentRoute.value.name).toBe("login");
|
||||
expect(router.currentRoute.value.query.redirect).toBe("/conversations");
|
||||
});
|
||||
|
||||
it("uses an independent admin login and role preflight", async () => {
|
||||
const session = auth({ isAuthenticated: true });
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin/login");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
|
||||
await router.push("/admin");
|
||||
expect(session.ensureAdmin).toHaveBeenCalledOnce();
|
||||
expect(router.currentRoute.value.name).toBe("admin");
|
||||
});
|
||||
|
||||
it("redirects failed admin preflight with clear forbidden feedback", async () => {
|
||||
const session = auth({
|
||||
isAuthenticated: true,
|
||||
ensureAdmin: vi.fn().mockRejectedValue({ status: 403 }),
|
||||
});
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
expect(router.currentRoute.value.query.error).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("sends anonymous visitors to the independent admin login", async () => {
|
||||
const session = auth();
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
expect(session.ensureAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createRouter, createWebHistory, type RouterHistory } from "vue-router";
|
||||
import LoginView from "../views/LoginView.vue";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import BottleComposeView from "../views/BottleComposeView.vue";
|
||||
import PickView from "../views/PickView.vue";
|
||||
import ConversationsView from "../views/ConversationsView.vue";
|
||||
import ChatView from "../views/ChatView.vue";
|
||||
import MyBottlesView from "../views/MyBottlesView.vue";
|
||||
import SettingsView from "../views/SettingsView.vue";
|
||||
import AdminView from "../views/AdminView.vue";
|
||||
import AdminLoginView from "../views/AdminLoginView.vue";
|
||||
|
||||
export interface RouterAuth {
|
||||
isAuthenticated: boolean;
|
||||
bootstrap(): Promise<unknown>;
|
||||
ensureAdmin(): Promise<unknown>;
|
||||
}
|
||||
export function createAppRouter(
|
||||
auth: RouterAuth,
|
||||
history: RouterHistory = createWebHistory(),
|
||||
) {
|
||||
const router = createRouter({
|
||||
history,
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginView,
|
||||
meta: { guest: true },
|
||||
},
|
||||
{ path: "/", name: "home", component: HomeView },
|
||||
{ path: "/throw", name: "throw", component: BottleComposeView },
|
||||
{ path: "/pick", name: "pick", component: PickView },
|
||||
{
|
||||
path: "/conversations",
|
||||
name: "conversations",
|
||||
component: ConversationsView,
|
||||
},
|
||||
{ path: "/conversations/:id", name: "chat", component: ChatView },
|
||||
{ path: "/my-bottles", name: "my-bottles", component: MyBottlesView },
|
||||
{ path: "/settings", name: "settings", component: SettingsView },
|
||||
{
|
||||
path: "/admin/login",
|
||||
name: "admin-login",
|
||||
component: AdminLoginView,
|
||||
meta: { adminLogin: true },
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
name: "admin",
|
||||
component: AdminView,
|
||||
meta: { admin: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
let bootstrapped = false;
|
||||
router.beforeEach(async (to) => {
|
||||
if (!bootstrapped) {
|
||||
bootstrapped = true;
|
||||
await auth.bootstrap();
|
||||
}
|
||||
if (to.meta.adminLogin) return true;
|
||||
if (to.meta.guest && auth.isAuthenticated) return { name: "home" };
|
||||
if (!to.meta.guest && !auth.isAuthenticated) {
|
||||
if (to.meta.admin)
|
||||
return { name: "admin-login", query: { redirect: to.fullPath } };
|
||||
return { name: "login", query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (to.meta.admin) {
|
||||
try {
|
||||
await auth.ensureAdmin();
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
name: "admin-login",
|
||||
query:
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 403
|
||||
? { error: "forbidden" }
|
||||
: { error: "unavailable" },
|
||||
};
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, useAuthStore } from "./auth";
|
||||
|
||||
const me = {
|
||||
accountId: "a",
|
||||
publicId: "p",
|
||||
nickname: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
profileReviewStatus: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("auth store", () => {
|
||||
it("becomes authenticated reactively after login", async () => {
|
||||
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
await auth.login("13800000000", "123456", "device");
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("restores authentication during bootstrap refresh", async () => {
|
||||
vi.spyOn(api, "refresh").mockResolvedValue("restored");
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
await auth.bootstrap();
|
||||
expect(auth.ready).toBe(true);
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the local session even when the server logout fails", async () => {
|
||||
vi.spyOn(api, "post").mockImplementation((path: string) => {
|
||||
if (path.includes("/auth/logout")) throw new Error("network down");
|
||||
return Promise.resolve({ accessToken: "fresh" });
|
||||
});
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
await auth.login("13800000000", "123456", "device");
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
|
||||
await auth.logout();
|
||||
|
||||
expect(auth.me).toBeNull();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { createApiClient } from "../api/client";
|
||||
|
||||
export interface Me {
|
||||
accountId: string;
|
||||
publicId: string | null;
|
||||
nickname: string | null;
|
||||
avatarColor: string | null;
|
||||
bio: string | null;
|
||||
profileReviewStatus: string | null;
|
||||
}
|
||||
const accessToken = ref<string | null>(null);
|
||||
export const api = createApiClient({
|
||||
getToken: () => accessToken.value,
|
||||
setToken: (value) => {
|
||||
accessToken.value = value;
|
||||
},
|
||||
});
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const me = ref<Me | null>(null);
|
||||
const ready = ref(false);
|
||||
const isAuthenticated = computed(() =>
|
||||
Boolean(accessToken.value && me.value),
|
||||
);
|
||||
async function bootstrap() {
|
||||
if (ready.value) return;
|
||||
try {
|
||||
await api.refresh();
|
||||
me.value = await api.get<Me>("/me");
|
||||
} catch {
|
||||
accessToken.value = null;
|
||||
me.value = null;
|
||||
} finally {
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
async function sendCode(phone: string, deviceId: string) {
|
||||
return api.post<{ sent: boolean; debugCode?: string }>("/auth/sms/send", {
|
||||
phone,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
async function login(phone: string, code: string, deviceId: string) {
|
||||
const pair = await api.post<{ accessToken: string }>("/auth/sms/login", {
|
||||
phone,
|
||||
code,
|
||||
deviceId,
|
||||
});
|
||||
accessToken.value = pair.accessToken;
|
||||
me.value = await api.get<Me>("/me");
|
||||
}
|
||||
async function ensureAdmin() {
|
||||
await api.get("/admin/reports?limit=1");
|
||||
}
|
||||
async function logout() {
|
||||
try {
|
||||
await api.post("/auth/logout");
|
||||
} catch {
|
||||
// 本地始终清理;服务端注销失败也不阻塞退出
|
||||
}
|
||||
accessToken.value = null;
|
||||
me.value = null;
|
||||
}
|
||||
return {
|
||||
me,
|
||||
ready,
|
||||
isAuthenticated,
|
||||
bootstrap,
|
||||
sendCode,
|
||||
login,
|
||||
ensureAdmin,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
export function currentAccessToken() {
|
||||
return accessToken.value;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { api, useAuthStore } from "./auth";
|
||||
import { useChatStore } from "./chat";
|
||||
import type { Conversation } from "./chat";
|
||||
import type { ChatMessage } from "./messages";
|
||||
|
||||
const socket = vi.hoisted(() => ({
|
||||
connected: false,
|
||||
on: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/socket", () => ({
|
||||
createChatSocket: () => socket,
|
||||
sendSocketMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const me = {
|
||||
accountId: "a",
|
||||
publicId: "p",
|
||||
nickname: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
profileReviewStatus: null,
|
||||
};
|
||||
|
||||
function message(seq: number): ChatMessage {
|
||||
return {
|
||||
id: `m-${seq}`,
|
||||
conversationId: "c-1",
|
||||
sender: { publicId: "p" },
|
||||
clientMsgId: `client-${seq}`,
|
||||
seq: String(seq),
|
||||
text: String(seq),
|
||||
status: "sent",
|
||||
sentAt: new Date(seq).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const conversation = (): Conversation => ({
|
||||
id: "c-1",
|
||||
status: "ACTIVE",
|
||||
lastMessageAt: new Date(0).toISOString(),
|
||||
lastMessage: null,
|
||||
unread: "0",
|
||||
lastReadSeq: "0",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
socket.connected = false;
|
||||
socket.disconnect.mockClear();
|
||||
});
|
||||
|
||||
describe("chat history synchronization", () => {
|
||||
it("loads every page beyond 100 messages and reliably deduplicates overlap", async () => {
|
||||
const pages = [
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 1)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 100)),
|
||||
Array.from({ length: 51 }, (_, index) => message(index + 199)),
|
||||
[],
|
||||
];
|
||||
const get = vi
|
||||
.spyOn(api, "get")
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ items: pages.shift() ?? [] }),
|
||||
);
|
||||
const chat = useChatStore();
|
||||
|
||||
const result = await chat.loadMessages("c-1");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result ?? []).toHaveLength(249);
|
||||
expect(result?.at(-1)?.seq).toBe("249");
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
expect(get.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/conversations/c-1/messages?afterSeq=0&limit=100",
|
||||
"/conversations/c-1/messages?afterSeq=100&limit=100",
|
||||
"/conversations/c-1/messages?afterSeq=199&limit=100",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops paging after the page cap to bound waterfall requests", async () => {
|
||||
const pages = [
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 1)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 101)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 201)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 301)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 401)),
|
||||
[],
|
||||
];
|
||||
const get = vi
|
||||
.spyOn(api, "get")
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ items: pages.shift() ?? [] }),
|
||||
);
|
||||
const chat = useChatStore();
|
||||
|
||||
const result = await chat.loadMessages("c-1");
|
||||
|
||||
expect(result ?? []).toHaveLength(500);
|
||||
expect(get.mock.calls.length).toBeLessThanOrEqual(5);
|
||||
expect(get.mock.calls.at(-1)?.[0]).toBe(
|
||||
"/conversations/c-1/messages?afterSeq=400&limit=100",
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs only the most recent active conversations when reconnecting", async () => {
|
||||
const get = vi.spyOn(api, "get").mockResolvedValue({ items: [] });
|
||||
const chat = useChatStore();
|
||||
chat.conversations = Array.from({ length: 30 }, (_, index) => {
|
||||
const id = `c-${index}`;
|
||||
return {
|
||||
id,
|
||||
status: "ACTIVE",
|
||||
lastMessageAt: new Date(1700000000000 + index).toISOString(),
|
||||
lastMessage: null,
|
||||
unread: "0",
|
||||
lastReadSeq: "0",
|
||||
};
|
||||
});
|
||||
|
||||
await chat.syncAll();
|
||||
|
||||
expect(
|
||||
get.mock.calls.filter(([path]) => path.includes("/messages")),
|
||||
).toHaveLength(5);
|
||||
expect(
|
||||
get.mock.calls.filter(([path]) => path.includes("/messages"))[0]![0],
|
||||
).toContain("c-29");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat session lifecycle", () => {
|
||||
it("disconnects and clears account state when authentication ends", async () => {
|
||||
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
await auth.login("13800000000", "123456", "device");
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
const chat = useChatStore();
|
||||
chat.conversations = [conversation()];
|
||||
chat.messages = { "c-1": [message(1)] };
|
||||
expect(chat.conversations).toHaveLength(1);
|
||||
|
||||
auth.me = null;
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
expect(chat.conversations).toEqual([]);
|
||||
expect(chat.messages).toEqual({});
|
||||
expect(chat.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("reset clears conversations and disconnects even when called directly", () => {
|
||||
const auth = useAuthStore();
|
||||
auth.me = me;
|
||||
const chat = useChatStore();
|
||||
chat.conversations = [conversation()];
|
||||
chat.messages = { "c-1": [message(1)] };
|
||||
|
||||
chat.reset();
|
||||
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
expect(chat.conversations).toEqual([]);
|
||||
expect(chat.messages).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { api, currentAccessToken, useAuthStore } from "./auth";
|
||||
import { createChatSocket, sendSocketMessage } from "../api/socket";
|
||||
import { mergeMessages, type ChatMessage } from "./messages";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
status: string;
|
||||
lastMessageAt: string;
|
||||
lastMessage: ChatMessage | null;
|
||||
unread: string;
|
||||
lastReadSeq: string;
|
||||
}
|
||||
const MAX_MESSAGE_PAGES = 5; // 单个会话重连同步上限(500 条),避免瀑布请求
|
||||
const MAX_SYNC_CONVERSATIONS = 5; // 重连时仅同步最近活跃会话
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
const auth = useAuthStore();
|
||||
const conversations = ref<Conversation[]>([]);
|
||||
const messages = ref<Record<string, ChatMessage[]>>({});
|
||||
const socket = createChatSocket(currentAccessToken, api.refresh);
|
||||
const connected = ref(false);
|
||||
socket.on("connect", () => {
|
||||
connected.value = true;
|
||||
void syncAll();
|
||||
});
|
||||
socket.on("disconnect", () => {
|
||||
connected.value = false;
|
||||
});
|
||||
socket.on("message:new", (message: ChatMessage) => {
|
||||
messages.value[message.conversationId] = mergeMessages(
|
||||
messages.value[message.conversationId] ?? [],
|
||||
[message],
|
||||
);
|
||||
});
|
||||
async function loadConversations() {
|
||||
const page = await api.get<{ items: Conversation[] }>("/conversations");
|
||||
conversations.value = page.items;
|
||||
return page.items;
|
||||
}
|
||||
async function loadMessages(id: string) {
|
||||
let afterSeq = messages.value[id]?.at(-1)?.seq ?? "0";
|
||||
let hasMore = true;
|
||||
let pages = 0;
|
||||
while (hasMore && pages < MAX_MESSAGE_PAGES) {
|
||||
pages += 1;
|
||||
const page = await api.get<{ items: ChatMessage[] }>(
|
||||
`/conversations/${id}/messages?afterSeq=${afterSeq}&limit=100`,
|
||||
);
|
||||
messages.value[id] = mergeMessages(messages.value[id] ?? [], page.items);
|
||||
hasMore = page.items.length === 100;
|
||||
if (!hasMore) break;
|
||||
const nextAfterSeq = page.items.at(-1)?.seq;
|
||||
if (!nextAfterSeq || BigInt(nextAfterSeq) <= BigInt(afterSeq)) break;
|
||||
afterSeq = nextAfterSeq;
|
||||
}
|
||||
return messages.value[id];
|
||||
}
|
||||
async function syncAll() {
|
||||
const recent = [...conversations.value]
|
||||
.sort((a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt))
|
||||
.slice(0, MAX_SYNC_CONVERSATIONS);
|
||||
for (const conversation of recent) await loadMessages(conversation.id);
|
||||
}
|
||||
function reset() {
|
||||
socket.disconnect();
|
||||
connected.value = false;
|
||||
conversations.value = [];
|
||||
messages.value = {};
|
||||
}
|
||||
let wasAuthenticated = auth.isAuthenticated;
|
||||
watch(
|
||||
() => auth.isAuthenticated,
|
||||
(authenticated) => {
|
||||
if (wasAuthenticated && !authenticated) reset();
|
||||
wasAuthenticated = authenticated;
|
||||
},
|
||||
);
|
||||
function connect() {
|
||||
if (!socket.connected) socket.connect();
|
||||
}
|
||||
function disconnect() {
|
||||
socket.disconnect();
|
||||
}
|
||||
async function send(id: string, text: string) {
|
||||
const clientMsgId = crypto.randomUUID();
|
||||
const optimistic: ChatMessage = {
|
||||
id: `local:${clientMsgId}`,
|
||||
conversationId: id,
|
||||
clientMsgId,
|
||||
sender: { publicId: "" },
|
||||
seq: "0",
|
||||
text,
|
||||
sentAt: new Date().toISOString(),
|
||||
status: "sending",
|
||||
};
|
||||
messages.value[id] = mergeMessages(messages.value[id] ?? [], [optimistic]);
|
||||
try {
|
||||
const result = connected.value
|
||||
? await sendSocketMessage(
|
||||
socket,
|
||||
{
|
||||
conversationId: id,
|
||||
clientMsgId,
|
||||
text,
|
||||
},
|
||||
api.refresh,
|
||||
)
|
||||
: await api.post<{ message: ChatMessage }>(
|
||||
`/conversations/${id}/messages/prepare`,
|
||||
{ conversationId: id, clientMsgId, text },
|
||||
);
|
||||
messages.value[id] = mergeMessages(messages.value[id], [
|
||||
{ ...result.message, status: "sent" },
|
||||
]);
|
||||
} catch (error) {
|
||||
optimistic.status = "failed";
|
||||
messages.value[id] = [...messages.value[id]];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const unread = computed(() =>
|
||||
conversations.value.reduce((sum, item) => sum + Number(item.unread), 0),
|
||||
);
|
||||
return {
|
||||
conversations,
|
||||
messages,
|
||||
connected,
|
||||
unread,
|
||||
loadConversations,
|
||||
loadMessages,
|
||||
syncAll,
|
||||
connect,
|
||||
disconnect,
|
||||
reset,
|
||||
send,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeMessages, type ChatMessage } from "./messages";
|
||||
|
||||
const message = (id: string, seq: string): ChatMessage => ({
|
||||
id,
|
||||
conversationId: "conversation",
|
||||
sender: { publicId: "sender" },
|
||||
clientMsgId: `client-${id}`,
|
||||
seq,
|
||||
text: id,
|
||||
sentAt: "2026-09-17T00:00:00.000Z",
|
||||
status: "sent",
|
||||
});
|
||||
|
||||
describe("message synchronization", () => {
|
||||
it("deduplicates socket and reconnect history messages and sorts by sequence", () => {
|
||||
const result = mergeMessages(
|
||||
[message("two", "2"), message("three", "3")],
|
||||
[message("one", "1"), message("two", "2")],
|
||||
);
|
||||
expect(result.map(({ id }) => id)).toEqual(["one", "two", "three"]);
|
||||
});
|
||||
|
||||
it("reconciles an optimistic message by client message id", () => {
|
||||
const optimistic = {
|
||||
...message("temporary", "0"),
|
||||
clientMsgId: "same",
|
||||
status: "sending" as const,
|
||||
};
|
||||
const confirmed = { ...message("server", "4"), clientMsgId: "same" };
|
||||
expect(mergeMessages([optimistic], [confirmed])).toEqual([confirmed]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: { publicId: string };
|
||||
clientMsgId: string;
|
||||
seq: string;
|
||||
text: string;
|
||||
status: string;
|
||||
reviewStatus?: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
export function mergeMessages(
|
||||
current: ChatMessage[],
|
||||
incoming: ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
// 双 Map:按 id 去重 + 按 clientMsgId 调和乐观消息,整体 O(n)
|
||||
const byId = new Map<string, ChatMessage>();
|
||||
const byClientMsgId = new Map<string, string>();
|
||||
const consider = (item: ChatMessage) => {
|
||||
const existingId = byClientMsgId.get(item.clientMsgId);
|
||||
if (existingId) byId.delete(existingId);
|
||||
byClientMsgId.set(item.clientMsgId, item.id);
|
||||
byId.set(item.id, item);
|
||||
};
|
||||
for (const item of current) consider(item);
|
||||
for (const item of incoming) consider(item);
|
||||
return [...byId.values()].sort((a, b) =>
|
||||
BigInt(a.seq) < BigInt(b.seq) ? -1 : BigInt(a.seq) > BigInt(b.seq) ? 1 : 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=ZCOOL+XiaoWei&display=swap");
|
||||
:root {
|
||||
font-family: "Noto Sans SC", system-ui, sans-serif;
|
||||
color: #effcff;
|
||||
background: #020b13;
|
||||
--deep: #061b2b;
|
||||
--panel: rgba(13, 48, 63, 0.72);
|
||||
--line: rgba(139, 224, 218, 0.18);
|
||||
--aqua: #76e4d5;
|
||||
--sand: #f1c27d;
|
||||
--muted: #91abb7;
|
||||
--danger: #ff8f91;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #020b13;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(
|
||||
circle at 50% 0,
|
||||
#0b4050 0,
|
||||
#061b2b 38%,
|
||||
#020b13 100%
|
||||
);
|
||||
line-height: 1.55;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
rgba(118, 228, 213, 0.22) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(#000, transparent 80%);
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger,
|
||||
.icon-link,
|
||||
.avatar {
|
||||
min-height: 44px;
|
||||
}
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 3px solid var(--sand);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.app-shell {
|
||||
position: relative;
|
||||
width: min(100%, 430px);
|
||||
min-height: 100dvh;
|
||||
margin: auto;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(6, 27, 43, 0.18),
|
||||
rgba(2, 11, 19, 0.88)
|
||||
);
|
||||
box-shadow: 0 0 70px #000;
|
||||
padding-bottom: 84px;
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
min-height: calc(100dvh - 84px);
|
||||
}
|
||||
.page,
|
||||
.list,
|
||||
.settings {
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: max(1rem, env(safe-area-inset-top)) 1.1rem 1rem;
|
||||
}
|
||||
.topbar h1 {
|
||||
font:
|
||||
500 1.4rem "ZCOOL XiaoWei",
|
||||
serif;
|
||||
margin: 0;
|
||||
}
|
||||
.topbar small,
|
||||
.eyebrow {
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--aqua);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.icon-link,
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 44px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.avatar {
|
||||
font-weight: 700;
|
||||
}
|
||||
.glass {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 22px;
|
||||
padding: 1.1rem;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.login {
|
||||
padding: 8vh 1.1rem 2rem;
|
||||
}
|
||||
.hero {
|
||||
padding: 2rem 0.3rem;
|
||||
}
|
||||
.hero h1,
|
||||
.page h2,
|
||||
.sea h2 {
|
||||
font:
|
||||
500 2.1rem/1.25 "ZCOOL XiaoWei",
|
||||
serif;
|
||||
}
|
||||
.hero h1 em {
|
||||
color: var(--sand);
|
||||
font-style: normal;
|
||||
}
|
||||
.hero > p:last-child,
|
||||
.muted,
|
||||
.quota-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
.orb {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(118, 228, 213, 0.12);
|
||||
color: var(--aqua);
|
||||
box-shadow: 0 0 40px rgba(118, 228, 213, 0.25);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: #cce0e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
background: rgba(0, 10, 18, 0.56);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 13px;
|
||||
padding: 0.72rem 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea::placeholder,
|
||||
input::placeholder {
|
||||
color: #77919d;
|
||||
}
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 0.7rem 1rem;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary {
|
||||
color: #032128;
|
||||
background: linear-gradient(135deg, var(--aqua), #b8f3db);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--aqua);
|
||||
background: rgba(118, 228, 213, 0.1);
|
||||
border: 1px solid rgba(118, 228, 213, 0.3);
|
||||
}
|
||||
.danger {
|
||||
color: #311016;
|
||||
background: var(--danger);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.48;
|
||||
}
|
||||
.text {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aqua);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.error {
|
||||
color: #ffd0d0;
|
||||
background: rgba(255, 80, 90, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
.demo {
|
||||
color: var(--sand);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sea {
|
||||
position: relative;
|
||||
padding: 1.5rem 1.1rem 2rem;
|
||||
}
|
||||
.moon {
|
||||
position: absolute;
|
||||
right: -35px;
|
||||
top: -20px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 35% 35%,
|
||||
#b8f3db,
|
||||
#178998 58%,
|
||||
transparent 61%
|
||||
);
|
||||
opacity: 0.22;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 1.25rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(118, 228, 213, 0.18),
|
||||
rgba(5, 24, 38, 0.7)
|
||||
);
|
||||
}
|
||||
.action b {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
background: rgba(118, 228, 213, 0.16);
|
||||
font-size: 1.7rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.action span {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.action small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.quote {
|
||||
margin: 0 1.1rem;
|
||||
text-align: center;
|
||||
color: #c9e0e6;
|
||||
}
|
||||
.quota-note {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.between,
|
||||
.button-row,
|
||||
.author,
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.button-row > * {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.success,
|
||||
.empty-ocean {
|
||||
margin: 4rem 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.success > b,
|
||||
.bottle {
|
||||
font-size: 3rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.bottle-card blockquote {
|
||||
font:
|
||||
500 1.5rem/1.6 "ZCOOL XiaoWei",
|
||||
serif;
|
||||
margin: 2rem 0.2rem;
|
||||
}
|
||||
.author {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.author i {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.author small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.conversation {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.3rem;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.conversation p {
|
||||
margin: 0.3rem 0;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
}
|
||||
.conversation time {
|
||||
grid-column: 1;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge,
|
||||
.pill {
|
||||
border-radius: 99px;
|
||||
background: rgba(118, 228, 213, 0.15);
|
||||
color: var(--aqua);
|
||||
padding: 0.22rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 10px);
|
||||
}
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 82%;
|
||||
margin: 0.7rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px 18px 18px 5px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.bubble p {
|
||||
margin: 0;
|
||||
}
|
||||
.bubble small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.bubble.sending {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.bubble.failed {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem calc(0.8rem + env(safe-area-inset-bottom));
|
||||
background: #061b2b;
|
||||
}
|
||||
.composer textarea {
|
||||
min-height: 48px;
|
||||
}
|
||||
.composer button {
|
||||
min-width: 48px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--aqua);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.dot {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dot.on {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.chat-actions .text {
|
||||
min-width: 44px;
|
||||
}
|
||||
.report-dialog {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 18%;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
background: #0b2d3b;
|
||||
}
|
||||
.report-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.bottle-row p {
|
||||
font-family: "ZCOOL XiaoWei", serif;
|
||||
font-size: 1.12rem;
|
||||
}
|
||||
.bottle-row time,
|
||||
.bottle-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: #020b13;
|
||||
padding: 0.6rem;
|
||||
border-radius: 10px;
|
||||
color: #9fc3cc;
|
||||
}
|
||||
.segmented {
|
||||
display: flex;
|
||||
padding: 0 1rem 1rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.segmented button {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.offline {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
background: #f1c27d;
|
||||
color: #2b1b05;
|
||||
padding: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
position: fixed;
|
||||
z-index: 8;
|
||||
bottom: 0;
|
||||
width: min(100%, 430px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 0.55rem 0.5rem calc(0.55rem + env(safe-area-inset-bottom));
|
||||
background: rgba(3, 17, 28, 0.94);
|
||||
backdrop-filter: blur(18px);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.tabs a {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.tabs a[aria-current="page"] {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
code {
|
||||
color: var(--sand);
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
body {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.app-shell {
|
||||
min-height: calc(100dvh - 4rem);
|
||||
border-radius: 32px;
|
||||
}
|
||||
.tabs {
|
||||
bottom: 2rem;
|
||||
border-radius: 0 0 32px 32px;
|
||||
}
|
||||
.chat {
|
||||
height: calc(100dvh - 4rem);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.orb,
|
||||
.bottle {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes float {
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const phone = ref("");
|
||||
const code = ref("");
|
||||
const debugCode = ref("");
|
||||
const sent = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref(
|
||||
route.query.error === "forbidden" ? "当前账号没有管理员权限" : "",
|
||||
);
|
||||
const deviceId = `admin-web-${crypto.randomUUID()}`;
|
||||
const validPhone = computed(() =>
|
||||
/^(?:\+?86)?1\d{10}$/.test(phone.value.replace(/\s/g, "")),
|
||||
);
|
||||
|
||||
async function send() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await auth.sendCode(phone.value, deviceId);
|
||||
sent.value = true;
|
||||
debugCode.value = result.debugCode ?? "";
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "发送失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(phone.value, code.value, deviceId);
|
||||
await auth.ensureAdmin();
|
||||
await router.replace("/admin");
|
||||
} catch (reason: unknown) {
|
||||
error.value =
|
||||
typeof reason === "object" &&
|
||||
reason !== null &&
|
||||
"status" in reason &&
|
||||
reason.status === 403
|
||||
? "当前账号没有管理员权限"
|
||||
: reason instanceof Error
|
||||
? reason.message
|
||||
: "管理员登录失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login admin-login">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">ADMIN / 治理后台</p>
|
||||
<h1>管理员登录</h1>
|
||||
<p>独立审核与举报处置入口。仅已授权管理员账号可进入。</p>
|
||||
</section>
|
||||
<form class="glass" @submit.prevent="sent ? login() : send()">
|
||||
<label
|
||||
>管理员手机号<input
|
||||
v-model="phone"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
required
|
||||
/></label>
|
||||
<label v-if="sent"
|
||||
>演示验证码<input
|
||||
v-model="code"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
required
|
||||
/></label>
|
||||
<p v-if="debugCode" class="demo">
|
||||
开发环境演示验证码:<strong>{{ debugCode }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !validPhone">
|
||||
{{ busy ? "请稍候…" : sent ? "进入治理后台" : "获取演示验证码" }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
import { createOperationState } from "./operation";
|
||||
const operations = createOperationState();
|
||||
const reports = ref<any[]>([]),
|
||||
tasks = ref<any[]>([]),
|
||||
error = operations.error,
|
||||
tab = ref<"reports" | "moderation" | "sanction">("reports"),
|
||||
publicId = ref(""),
|
||||
reason = ref(""),
|
||||
type = ref("WARNING");
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
api.get<any>("/admin/reports?limit=100"),
|
||||
api.get<any>("/admin/moderation?limit=100"),
|
||||
]);
|
||||
reports.value = r.items;
|
||||
tasks.value = m.items;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "无管理员权限";
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
async function resolve(id: string, decision: "UPHELD" | "DISMISSED") {
|
||||
await operations.run(`report:${id}`, async () => {
|
||||
await api.post(`/admin/reports/${id}/resolve`, {
|
||||
decision,
|
||||
resolution: decision === "UPHELD" ? "管理员确认违规" : "管理员驳回举报",
|
||||
});
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function moderate(id: string, decision: "APPROVED" | "REJECTED") {
|
||||
await operations.run(`moderation:${id}`, async () => {
|
||||
await api.post(`/admin/moderation/${id}/resolve`, {
|
||||
decision,
|
||||
reason: "管理员人工审核",
|
||||
});
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function sanction() {
|
||||
await operations.run("sanction", async () => {
|
||||
await api.post(`/admin/accounts/${publicId.value}/sanctions`, {
|
||||
type: type.value,
|
||||
reason: reason.value,
|
||||
});
|
||||
reason.value = "";
|
||||
publicId.value = "";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="admin">
|
||||
<AppHeader title="管理员工作台" back />
|
||||
<p v-if="error" class="error page" role="alert">{{ error }}</p>
|
||||
<nav class="segmented">
|
||||
<button @click="tab = 'reports'">举报 {{ reports.length }}</button
|
||||
><button @click="tab = 'moderation'">复审 {{ tasks.length }}</button
|
||||
><button @click="tab = 'sanction'">处罚</button>
|
||||
</nav>
|
||||
<section v-if="tab === 'reports'" class="list">
|
||||
<article v-for="item in reports" :key="item.id" class="glass">
|
||||
<div class="between">
|
||||
<b>{{ item.reason }}</b
|
||||
><span class="pill">{{ item.status }}</span>
|
||||
</div>
|
||||
<p>{{ item.details || "无补充说明" }}</p>
|
||||
<pre>{{ JSON.stringify(item.targetSnapshot, null, 2) }}</pre>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="operations.pending(`report:${item.id}`)"
|
||||
@click="resolve(item.id, 'DISMISSED')"
|
||||
>
|
||||
驳回</button
|
||||
><button
|
||||
class="danger"
|
||||
:disabled="operations.pending(`report:${item.id}`)"
|
||||
@click="resolve(item.id, 'UPHELD')"
|
||||
>
|
||||
确认违规
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section v-else-if="tab === 'moderation'" class="list">
|
||||
<article v-for="item in tasks" :key="item.id" class="glass">
|
||||
<b>{{ item.targetType }} · {{ item.targetId }}</b>
|
||||
<p>风险标签:{{ item.riskLabels.join("、") || "无" }}</p>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="operations.pending(`moderation:${item.id}`)"
|
||||
@click="moderate(item.id, 'APPROVED')"
|
||||
>
|
||||
通过</button
|
||||
><button
|
||||
class="danger"
|
||||
:disabled="operations.pending(`moderation:${item.id}`)"
|
||||
@click="moderate(item.id, 'REJECTED')"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<form v-else class="glass page" @submit.prevent="sanction">
|
||||
<h2>独立账号处罚</h2>
|
||||
<label>公开用户 ID<input v-model="publicId" required /></label
|
||||
><label
|
||||
>处罚类型<select v-model="type">
|
||||
<option>WARNING</option>
|
||||
<option>MUTE</option>
|
||||
<option>SUSPENSION</option>
|
||||
<option>BAN</option>
|
||||
</select></label
|
||||
><label
|
||||
>原因<textarea
|
||||
v-model="reason"
|
||||
maxlength="500"
|
||||
required
|
||||
></textarea></label
|
||||
><button class="danger" :disabled="operations.pending('sanction')">
|
||||
{{ operations.pending("sanction") ? "处理中…" : "实施处罚" }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const text = ref(""),
|
||||
busy = ref(false),
|
||||
result = ref<any>(null),
|
||||
error = ref("");
|
||||
async function submit() {
|
||||
if (!navigator.onLine) {
|
||||
error.value = "当前离线,瓶子没有投递。";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
result.value = await api.post(
|
||||
"/bottles",
|
||||
{ contentText: text.value },
|
||||
{ "Idempotency-Key": crypto.randomUUID() },
|
||||
);
|
||||
text.value = "";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "投递失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="写一只瓶子" back />
|
||||
<section v-if="!result" class="page">
|
||||
<p class="eyebrow">THROW / 投递</p>
|
||||
<h2>把一句话交给洋流</h2>
|
||||
<form class="glass" @submit.prevent="submit">
|
||||
<label
|
||||
>瓶中内容<textarea
|
||||
v-model="text"
|
||||
maxlength="1000"
|
||||
rows="9"
|
||||
placeholder="写下你想让陌生人看到的话…"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="between">
|
||||
<small>{{ text.length }} / 1000</small><span>匿名投递</span>
|
||||
</div>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !text.trim()">
|
||||
{{ busy ? "正在投递…" : "扔进海里" }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section v-else class="success glass">
|
||||
<b>✓</b>
|
||||
<h2>瓶子已进入审核</h2>
|
||||
<p>审核通过后才会进入海面。你可以在“我的瓶子”查看真实状态。</p>
|
||||
<RouterLink class="primary" to="/my-bottles">查看我的瓶子</RouterLink>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { api } from "../stores/auth";
|
||||
import { useChatStore } from "../stores/chat";
|
||||
const route = useRoute(),
|
||||
chat = useChatStore(),
|
||||
id = String(route.params.id),
|
||||
text = ref(""),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
const items = computed(() => chat.messages[id] ?? []);
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chat.loadMessages(id);
|
||||
chat.connect();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const reportOpen = ref(false);
|
||||
const reportReason = ref("HARASSMENT");
|
||||
const reportDetails = ref("");
|
||||
async function blockPeer() {
|
||||
if (!confirm("拉黑后双方将不能继续聊天,确认吗?")) return;
|
||||
try {
|
||||
await api.post(`/conversations/${id}/block`);
|
||||
error.value = "已拉黑对方,本会话不能继续发送消息。";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "拉黑失败";
|
||||
}
|
||||
}
|
||||
async function reportConversation() {
|
||||
try {
|
||||
await api.post(
|
||||
"/reports",
|
||||
{
|
||||
targetType: "CONVERSATION",
|
||||
targetId: id,
|
||||
reason: reportReason.value,
|
||||
...(reportDetails.value.trim()
|
||||
? { details: reportDetails.value.trim() }
|
||||
: {}),
|
||||
},
|
||||
{ "Idempotency-Key": crypto.randomUUID() },
|
||||
);
|
||||
reportOpen.value = false;
|
||||
error.value = "举报已提交审核。";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "举报失败";
|
||||
}
|
||||
}
|
||||
async function send() {
|
||||
const value = text.value.trim();
|
||||
if (!value) return;
|
||||
text.value = "";
|
||||
try {
|
||||
await chat.send(id, value);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "发送失败";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="chat">
|
||||
<AppHeader title="深海回声" back>
|
||||
<div class="chat-actions">
|
||||
<span class="dot" :class="{ on: chat.connected }">{{
|
||||
chat.connected ? "实时" : "同步中"
|
||||
}}</span>
|
||||
<button class="text" aria-label="举报会话" @click="reportOpen = true">
|
||||
举报
|
||||
</button>
|
||||
<button class="text" aria-label="拉黑对方" @click="blockPeer">
|
||||
拉黑
|
||||
</button>
|
||||
</div>
|
||||
</AppHeader>
|
||||
<dialog :open="reportOpen" class="glass report-dialog">
|
||||
<form @submit.prevent="reportConversation">
|
||||
<h2>举报会话</h2>
|
||||
<label
|
||||
>原因<select v-model="reportReason">
|
||||
<option>HARASSMENT</option>
|
||||
<option>SPAM</option>
|
||||
<option>SEXUAL</option>
|
||||
<option>VIOLENCE</option>
|
||||
<option>FRAUD</option>
|
||||
<option>OTHER</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>补充说明<textarea
|
||||
v-model="reportDetails"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
/>
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<button type="button" class="secondary" @click="reportOpen = false">
|
||||
取消</button
|
||||
><button class="danger">提交举报</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<StatusPanel v-if="loading" state="loading" />
|
||||
<section v-else class="messages">
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<article v-for="m in items" :key="m.id" class="bubble" :class="m.status">
|
||||
<p>{{ m.text }}</p>
|
||||
<small
|
||||
>#{{ m.seq }} ·
|
||||
{{
|
||||
m.status === "sending"
|
||||
? "发送中"
|
||||
: m.status === "failed"
|
||||
? "发送失败"
|
||||
: "已送达"
|
||||
}}</small
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<label class="sr-only">消息</label
|
||||
><textarea
|
||||
v-model="text"
|
||||
maxlength="1000"
|
||||
rows="1"
|
||||
placeholder="写下回声…"
|
||||
required
|
||||
></textarea
|
||||
><button aria-label="发送消息">↑</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { useChatStore } from "../stores/chat";
|
||||
const chat = useChatStore(),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chat.loadConversations();
|
||||
chat.connect();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="回声"
|
||||
><span class="pill">{{ chat.unread }} 未读</span></AppHeader
|
||||
><StatusPanel v-if="loading" state="loading" /><StatusPanel
|
||||
v-else-if="error"
|
||||
state="error"
|
||||
:detail="error"
|
||||
/><StatusPanel v-else-if="!chat.conversations.length" state="empty" />
|
||||
<section v-else class="list">
|
||||
<RouterLink
|
||||
v-for="item in chat.conversations"
|
||||
:key="item.id"
|
||||
:to="`/conversations/${item.id}`"
|
||||
class="conversation glass"
|
||||
><div>
|
||||
<b>匿名会话</b>
|
||||
<p>
|
||||
{{ item.lastMessage?.text ?? "新的相遇" }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="Number(item.unread)" class="badge">{{ item.unread }}</span
|
||||
><time>{{
|
||||
new Date(item.lastMessageAt).toLocaleDateString()
|
||||
}}</time></RouterLink
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="海面"
|
||||
><RouterLink
|
||||
class="avatar"
|
||||
to="/settings"
|
||||
:style="{ background: auth.me?.avatarColor ?? '#4fd1c5' }"
|
||||
aria-label="打开设置"
|
||||
>{{ auth.me?.nickname?.slice(0, 1) ?? "○" }}</RouterLink
|
||||
></AppHeader
|
||||
>
|
||||
<section class="sea">
|
||||
<div class="moon"></div>
|
||||
<p class="eyebrow">今晚的洋流很安静</p>
|
||||
<h2>{{ auth.me?.nickname ?? "匿名旅人" }},想留下些什么?</h2>
|
||||
<div class="actions">
|
||||
<RouterLink to="/throw" class="action throw"
|
||||
><b>↗</b
|
||||
><span>扔一只瓶子<small>每日上限 10 次</small></span></RouterLink
|
||||
><RouterLink to="/pick" class="action pick"
|
||||
><b>⌁</b
|
||||
><span>捞一只瓶子<small>每日上限 20 次</small></span></RouterLink
|
||||
>
|
||||
</div>
|
||||
<p class="quota-note">
|
||||
服务端尚未提供当日用量查询;此处只展示规则上限,不伪造剩余次数。
|
||||
</p>
|
||||
</section>
|
||||
<section class="glass quote">
|
||||
<p>“有些话不需要目的地,<br />只需要被海浪接住。”</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore(),
|
||||
route = useRoute(),
|
||||
router = useRouter();
|
||||
const phone = ref(""),
|
||||
code = ref(""),
|
||||
debugCode = ref(""),
|
||||
sent = ref(false),
|
||||
busy = ref(false),
|
||||
error = ref("");
|
||||
const deviceId = (() => {
|
||||
const key = "drift-device-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = `web-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
})();
|
||||
const validPhone = computed(() =>
|
||||
/^(?:\+?86)?1\d{10}$/.test(phone.value.replace(/\s/g, "")),
|
||||
);
|
||||
async function send() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const r = await auth.sendCode(phone.value, deviceId);
|
||||
sent.value = true;
|
||||
debugCode.value = r.debugCode ?? "";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "发送失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function login() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(phone.value, code.value, deviceId);
|
||||
await router.replace(
|
||||
typeof route.query.redirect === "string" ? route.query.redirect : "/",
|
||||
);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "登录失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="login">
|
||||
<section class="hero">
|
||||
<span class="orb">◉</span>
|
||||
<p class="eyebrow">DRIFT / 漂流瓶</p>
|
||||
<h1>听见深海里的<br /><em>一束回声</em></h1>
|
||||
<p>匿名投递,偶然相遇。每次打开都是真实连接。</p>
|
||||
</section>
|
||||
<form class="glass" @submit.prevent="sent ? login() : send()">
|
||||
<label
|
||||
>手机号<input
|
||||
v-model="phone"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
placeholder="+86 138 0000 0000"
|
||||
required /></label
|
||||
><label v-if="sent"
|
||||
>演示验证码<input
|
||||
v-model="code"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="6 位验证码"
|
||||
required
|
||||
/></label>
|
||||
<p v-if="debugCode" class="demo">
|
||||
开发环境演示验证码:<strong>{{ debugCode }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !validPhone">
|
||||
{{ busy ? "请稍候…" : sent ? "进入海面" : "获取演示验证码" }}</button
|
||||
><button v-if="sent" type="button" class="text" @click="sent = false">
|
||||
更换手机号
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const items = ref<any[]>([]),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
onMounted(async () => {
|
||||
try {
|
||||
items.value = (await api.get<any>("/me/bottles?limit=50")).items;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const labels: Record<string, string> = {
|
||||
REVIEWING: "审核中",
|
||||
MANUAL_REVIEW: "人工复审",
|
||||
APPROVED: "已通过",
|
||||
REJECTED: "未通过",
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="我的瓶子" /><StatusPanel
|
||||
v-if="loading"
|
||||
state="loading"
|
||||
/><StatusPanel
|
||||
v-else-if="error"
|
||||
state="error"
|
||||
:detail="error"
|
||||
/><StatusPanel v-else-if="!items.length" state="empty" />
|
||||
<section v-else class="list">
|
||||
<article v-for="item in items" :key="item.id" class="glass bottle-row">
|
||||
<div class="between">
|
||||
<span class="pill">{{
|
||||
labels[item.reviewStatus] ?? item.reviewStatus
|
||||
}}</span
|
||||
><time>{{ new Date(item.createdAt).toLocaleDateString() }}</time>
|
||||
</div>
|
||||
<p>{{ item.contentText }}</p>
|
||||
<small>池状态:{{ item.poolStatus }}</small>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const router = useRouter();
|
||||
const busy = ref(false),
|
||||
error = ref(""),
|
||||
picked = ref<any>(null),
|
||||
reply = ref("");
|
||||
async function pick() {
|
||||
if (!navigator.onLine) {
|
||||
error.value = "当前离线,无法捞瓶。";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
picked.value = await api.post("/bottles/pick", undefined, {
|
||||
"Idempotency-Key": crypto.randomUUID(),
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "海面暂时没有瓶子";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function back() {
|
||||
await api.post(`/bottles/${picked.value.bottle.id}/return`, {
|
||||
leaseId: picked.value.lease.id,
|
||||
token: picked.value.lease.token,
|
||||
});
|
||||
picked.value = null;
|
||||
}
|
||||
async function answer() {
|
||||
const r = await api.post<any>(`/bottles/${picked.value.bottle.id}/reply`, {
|
||||
leaseId: picked.value.lease.id,
|
||||
leaseToken: picked.value.lease.token,
|
||||
clientMsgId: crypto.randomUUID(),
|
||||
text: reply.value,
|
||||
});
|
||||
await router.push(`/conversations/${r.conversationId}`);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="捞一只瓶子" back />
|
||||
<section class="page">
|
||||
<div v-if="!picked" class="empty-ocean">
|
||||
<div class="bottle">⌁</div>
|
||||
<h2>让洋流替你选择</h2>
|
||||
<p>不会捞到自己、拉黑对象或已经见过的瓶子。</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy" @click="pick">
|
||||
{{ busy ? "正在寻找…" : "伸手捞取" }}
|
||||
</button>
|
||||
</div>
|
||||
<article v-else class="glass bottle-card">
|
||||
<div class="author">
|
||||
<i :style="{ background: picked.author.avatarColor }"></i>
|
||||
<div>
|
||||
<b>{{ picked.author.nickname }}</b
|
||||
><small>{{ picked.author.bio || "匿名海客" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{{ picked.bottle.contentText }}</blockquote>
|
||||
<form @submit.prevent="answer">
|
||||
<label
|
||||
>第一句回复<textarea
|
||||
v-model="reply"
|
||||
maxlength="1000"
|
||||
rows="4"
|
||||
required
|
||||
></textarea></label
|
||||
><button class="primary" :disabled="!reply.trim()">
|
||||
回复并建立会话
|
||||
</button>
|
||||
</form>
|
||||
<button class="secondary" @click="back">放回海里</button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api, useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore(),
|
||||
router = useRouter(),
|
||||
blocks = ref<any[]>([]),
|
||||
enabled = ref(true),
|
||||
nickname = ref(""),
|
||||
bio = ref(""),
|
||||
color = ref("#4fd1c5"),
|
||||
message = ref("");
|
||||
onMounted(async () => {
|
||||
nickname.value = auth.me?.nickname ?? "";
|
||||
bio.value = auth.me?.bio ?? "";
|
||||
color.value = auth.me?.avatarColor ?? "#4fd1c5";
|
||||
const [b, p] = await Promise.all([
|
||||
api.get<any>("/me/blocks"),
|
||||
api.get<any>("/settings/push"),
|
||||
]);
|
||||
blocks.value = b.items;
|
||||
enabled.value = p.inAppEnabled;
|
||||
});
|
||||
async function saveProfile() {
|
||||
await api.patch("/me/anonymous-profile", {
|
||||
nickname: nickname.value,
|
||||
avatarColor: color.value,
|
||||
bio: bio.value || null,
|
||||
});
|
||||
message.value = "资料已提交审核";
|
||||
}
|
||||
async function preference() {
|
||||
await api.put("/settings/push", { inAppEnabled: enabled.value });
|
||||
message.value = "通知偏好已保存";
|
||||
}
|
||||
async function unblock(id: string) {
|
||||
await api.delete(`/me/blocks/${id}`);
|
||||
blocks.value = blocks.value.filter((x) => x.publicId !== id);
|
||||
}
|
||||
async function logout() {
|
||||
await auth.logout();
|
||||
await router.replace("/login");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="设置" />
|
||||
<section class="page settings">
|
||||
<form class="glass" @submit.prevent="saveProfile">
|
||||
<h2>匿名资料</h2>
|
||||
<label>昵称<input v-model="nickname" maxlength="64" required /></label
|
||||
><label>头像色<input v-model="color" type="color" required /></label
|
||||
><label
|
||||
>简介<textarea
|
||||
v-model="bio"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
></textarea></label
|
||||
><button class="secondary">保存资料</button>
|
||||
</form>
|
||||
<section class="glass">
|
||||
<h2>通知偏好</h2>
|
||||
<label class="toggle"
|
||||
><span>站内通知<small>浏览器内提醒,不等同系统 Push</small></span
|
||||
><input v-model="enabled" type="checkbox" @change="preference"
|
||||
/></label>
|
||||
</section>
|
||||
<section class="glass">
|
||||
<h2>黑名单</h2>
|
||||
<p v-if="!blocks.length" class="muted">没有已拉黑用户</p>
|
||||
<div v-for="item in blocks" :key="item.publicId" class="between">
|
||||
<code>{{ item.publicId.slice(0, 8) }}…</code
|
||||
><button class="text" @click="unblock(item.publicId)">解除</button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="message" role="status">{{ message }}</p>
|
||||
<button class="danger" @click="logout">退出登录</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createOperationState } from "./operation";
|
||||
|
||||
describe("operation state", () => {
|
||||
it("blocks duplicate submissions and exposes 4xx feedback", async () => {
|
||||
let reject!: (reason: unknown) => void;
|
||||
const action = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, nextReject) => {
|
||||
reject = nextReject;
|
||||
}),
|
||||
);
|
||||
const state = createOperationState();
|
||||
const first = state.run("report:r-1", action);
|
||||
const duplicate = state.run("report:r-1", action);
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
expect(state.pending("report:r-1")).toBe(true);
|
||||
reject(
|
||||
Object.assign(new Error("Report already resolved"), { status: 409 }),
|
||||
);
|
||||
await expect(first).rejects.toThrow("Report already resolved");
|
||||
await expect(duplicate).rejects.toThrow("Report already resolved");
|
||||
expect(state.pending("report:r-1")).toBe(false);
|
||||
expect(state.error.value).toBe(
|
||||
"操作冲突:该项目可能已被处理,请刷新后重试",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function createOperationState() {
|
||||
const active = new Map<string, Promise<unknown>>();
|
||||
const error = ref("");
|
||||
|
||||
function pending(key: string) {
|
||||
return active.has(key);
|
||||
}
|
||||
|
||||
function run<T>(key: string, action: () => Promise<T>): Promise<T> {
|
||||
const existing = active.get(key) as Promise<T> | undefined;
|
||||
if (existing) return existing;
|
||||
error.value = "";
|
||||
const operation = action()
|
||||
.catch((reason: unknown) => {
|
||||
const status =
|
||||
typeof reason === "object" && reason !== null && "status" in reason
|
||||
? reason.status
|
||||
: undefined;
|
||||
error.value =
|
||||
status === 409
|
||||
? "操作冲突:该项目可能已被处理,请刷新后重试"
|
||||
: status === 403
|
||||
? "无权执行此管理操作"
|
||||
: reason instanceof Error
|
||||
? reason.message
|
||||
: "管理操作失败";
|
||||
throw reason;
|
||||
})
|
||||
.finally(() => active.delete(key));
|
||||
active.set(key, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { error, pending, run };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
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": "http://localhost:3000",
|
||||
"/socket.io": { target: "http://localhost:3000", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+5524
-2900
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user