0.3 增加登录模块
This commit is contained in:
@@ -205,23 +205,29 @@
|
||||
径/目录视图”,仍建议用业务表字段(workspace_id、user_id、path)来实现管理与展示,这是最贴合你“一 体化 + 不引入外部对象存储”的路线。citeturn0search5
|
||||
checklist:
|
||||
- M1(已完成):documents/workspaces/media/search 全链路 Convex 化 + 冒烟回归
|
||||
- M2:Mindmap 数据与接口迁移(/api/mindmap/**、/api/mindmap-trash/empty),落到 Convex(优先复用
|
||||
- M2(已完成):Mindmap 数据与接口迁移(/api/mindmap/**、/api/mindmap-trash/empty),落到 Convex(优先复用
|
||||
documents.mindmap_data)
|
||||
- M3:References(页面引用/反链)迁移(/api/references/record、/api/references/backlinks),补齐目前的 占位实现
|
||||
- M4:AI Agent(/api/ai-agent/run、/api/ai-agent/client-tool-result)去 Supabase 化(鉴权/读写文档/工
|
||||
- M3(已完成):References(页面引用/反链)迁移(/api/references/record、/api/references/backlinks),补齐目前的 占位实现
|
||||
- M4(已完成):AI Agent(/api/ai-agent/run、/api/ai-agent/client-tool-result)去 Supabase 化(鉴权/读写文档/工
|
||||
具回调)
|
||||
- M5:Online Table / Luckysheet(/api/tables/**、/api/luckysheet/**)迁移或在 Convex 模式下先禁用(给
|
||||
- M5(待继续):Online Table / Luckysheet(/api/tables/**、/api/luckysheet/**)迁移或在 Convex 模式下先禁用(给
|
||||
出明确 UI 提示)
|
||||
- M6:OnlyOffice(/api/onlyoffice/*)迁移或在 Convex 模式下先禁用(同上)
|
||||
- M7:服务侧(services/ingest_service 等)去 Supabase 化:任务表/状态机迁到 Convex jobs/actions(或先
|
||||
停用该链路)
|
||||
- M8:鉴权从“固定用户”升级为可扩展方案(仍保持 Convex 一体化)
|
||||
- M9:运维闭环:Convex 数据/Files 卷备份恢复、日志与健康检查、启动脚本收敛
|
||||
- M6(待继续):OnlyOffice(/api/onlyoffice/*)迁移或在 Convex 模式下先禁用(同上)
|
||||
- M7(待继续):服务侧(services/ingest_service 等)去 Supabase 化:任务表/状态机迁到 Convex jobs/actions(或先
|
||||
停用该链路)
|
||||
- M8(未开始):鉴权从“固定用户”升级为可扩展方案(仍保持 Convex 一体化)
|
||||
- M9(未开始):运维闭环:Convex 数据/Files 卷备份恢复、日志与健康检查、启动脚本收敛
|
||||
|
||||
我刚做完的(你现在的代码状态)
|
||||
|
||||
- 补齐 documents 的最后缺口:wolai-frontend/src/app/api/documents/embed/route.ts:1 已支持 Convex。
|
||||
- 抽了一个路由侧通用 helper:wolai-frontend/src/lib/convex/route.ts:1(统一拿 auth+client)。
|
||||
- M7 进展:已把“自动入库/LightRAG 触发”迁到 Convex jobs/actions 的最小骨架(支持 document/mindmap/media 三类入库任务),并在 Convex 模式下默认关闭 services/ingest_service 的 Supabase 轮询入库(避免继续依赖 Supabase)。
|
||||
- 触发入库(开发用):POST wolai-frontend/src/app/api/dev/ingest/enqueue/route.ts:1
|
||||
- 查询任务:GET wolai-frontend/src/app/api/dev/jobs/demo/route.ts:1(传 id)
|
||||
- Convex action 需要环境变量:LIGHTRAG_URL / LIGHTRAG_API_KEY(运行在 Convex 侧)
|
||||
- 自动触发(Convex 侧):保存页面/导图会 debounce enqueue 入库任务(wolai-frontend/convex/_utils/ingestJobs.ts:1)
|
||||
- 已移除 ingest_service 内与 Supabase 相关代码(不再包含 Supabase 客户端/配置/轮询入库实现)
|
||||
- “最近访问”已落库到 Convex:wolai-frontend/convex/schema.ts:1 新增 user_recent_pages;wolai-frontend/ convex/recents.ts:1 + 接入 wolai-frontend/src/app/api/search/recent/route.ts:1、wolai-frontend/src/
|
||||
app/api/search/documents/route.ts:1。
|
||||
- 回归脚本已扩展并跑通:pw-tests/scripts/e2e_convex_smoke.py:1;最新产物 pw-tests/artifacts/convex-
|
||||
|
||||
@@ -38,10 +38,10 @@ npx convex dev --once --tail-logs disable --env-file .env.local --run ping:ping
|
||||
|
||||
### 2) 验证固定开发用户(阶段 3)
|
||||
|
||||
启动前端后访问:`http://localhost:3000/api/dev/whoami`
|
||||
启动前端后访问:`http://127.0.0.1:3000/api/dev/whoami`
|
||||
|
||||
### 3) 验证异步任务骨架(阶段 5)
|
||||
|
||||
启动前端后:
|
||||
- `POST http://localhost:3000/api/dev/jobs/demo`(body 可选:`{ "ms": 800 }`)创建 demo job
|
||||
- `GET http://localhost:3000/api/dev/jobs/demo?id=<jobId>` 查询状态(queued/running/succeeded/failed)
|
||||
- `POST http://127.0.0.1:3000/api/dev/jobs/demo`(body 可选:`{ "ms": 800 }`)创建 demo job
|
||||
- `GET http://127.0.0.1:3000/api/dev/jobs/demo?id=<jobId>` 查询状态(queued/running/succeeded/failed)
|
||||
|
||||
@@ -14,6 +14,9 @@ services:
|
||||
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://127.0.0.1:${PORT:-3210}}
|
||||
- CONVEX_RELEASE_VERSION_DEV
|
||||
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://127.0.0.1:${SITE_PROXY_PORT:-3211}}
|
||||
# Convex Auth 依赖(站点地址用于 JWT issuer / redirect 校验等)
|
||||
- CONVEX_SITE_URL=${CONVEX_SITE_URL:-http://127.0.0.1:3000}
|
||||
- SITE_URL=${SITE_URL:-http://127.0.0.1:3000}
|
||||
- DATABASE_URL
|
||||
- DISABLE_BEACON
|
||||
- DOCUMENT_RETENTION_DELAY=${DOCUMENT_RETENTION_DELAY:-172800} # 降低默认保留到 2 天
|
||||
@@ -21,11 +24,15 @@ services:
|
||||
- HTTP_SERVER_TIMEOUT_SECONDS
|
||||
- INSTANCE_NAME
|
||||
- INSTANCE_SECRET
|
||||
- JWT_PRIVATE_KEY
|
||||
- JWKS
|
||||
- MYSQL_URL
|
||||
- POSTGRES_URL
|
||||
- REDACT_LOGS_TO_CLIENT
|
||||
- RUST_BACKTRACE
|
||||
- RUST_LOG=${RUST_LOG:-info}
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
|
||||
interval: 5s
|
||||
|
||||
@@ -36,27 +36,10 @@ class Settings(BaseSettings):
|
||||
environment: str = Field("development", env="INGEST_ENV")
|
||||
api_prefix: str = "/api"
|
||||
|
||||
# Supabase(通过 Kong: http://127.0.0.1:18000)
|
||||
supabase_url: str = Field("http://127.0.0.1:18000", env="SUPABASE_URL")
|
||||
supabase_service_role_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY")
|
||||
|
||||
# LightRAG(独立服务 7777)
|
||||
lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL")
|
||||
lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY")
|
||||
|
||||
# 自动入库(rag_index_sources -> LightRAG)
|
||||
auto_index_enabled: bool = Field(True, env="AUTO_INDEX_ENABLED")
|
||||
auto_index_interval_seconds: int = Field(5, env="AUTO_INDEX_INTERVAL_SECONDS")
|
||||
auto_index_batch_size: int = Field(5, env="AUTO_INDEX_BATCH_SIZE")
|
||||
auto_index_max_attempts: int = Field(6, env="AUTO_INDEX_MAX_ATTEMPTS")
|
||||
|
||||
# 删除宽限期(用于“可撤销删除”):例如用户误删后 10 分钟内可恢复,宽限期内不清理 OCR / Storage / LightRAG 资源
|
||||
delete_grace_seconds: int = Field(600, env="DELETE_GRACE_SECONDS")
|
||||
|
||||
# MinerU(用于 OCR / PDF 结构化解析)
|
||||
mineru_enabled: bool = Field(True, env="MINERU_ENABLED")
|
||||
mineru_endpoint: str = Field("", env="MINERU_ENDPOINT")
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import FastAPI
|
||||
from app.api.routes import router as api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import setup_logging
|
||||
from app.services.runtime import auto_indexer, scheduler, worker
|
||||
from app.services.runtime import worker
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -15,22 +15,9 @@ app.include_router(api_router, prefix=settings.api_prefix)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup() -> None:
|
||||
scheduler.start()
|
||||
if getattr(settings, "auto_index_enabled", False):
|
||||
try:
|
||||
await auto_indexer.startup()
|
||||
except Exception: # noqa: BLE001
|
||||
# 启动阶段不阻塞服务:自动入库失败会在后续 tick 中继续重试/记录
|
||||
pass
|
||||
scheduler.add_interval_job(
|
||||
job_id="auto_index_tick",
|
||||
func=auto_indexer.tick,
|
||||
seconds=int(getattr(settings, "auto_index_interval_seconds", 5)),
|
||||
)
|
||||
await worker.start()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown() -> None:
|
||||
scheduler.shutdown()
|
||||
await worker.stop()
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
@dataclass
|
||||
class IngestJob:
|
||||
"""表示一次 ingest 任务,后续可持久化到 Supabase"""
|
||||
"""表示一次 ingest 任务,后续可持久化到数据库"""
|
||||
|
||||
id: str
|
||||
notebook_id: Optional[str]
|
||||
|
||||
@@ -5,7 +5,7 @@ from app.models.job import IngestJob
|
||||
|
||||
|
||||
class InMemoryJobStore:
|
||||
"""简单的内存 JobStore,后续可替换为 Supabase 持久化"""
|
||||
"""简单的内存 JobStore,后续可替换为数据库持久化"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._jobs: Dict[str, IngestJob] = {}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
from app.core.config import get_settings
|
||||
from app.services.auto_indexer import AutoIndexService
|
||||
from app.services.job_store import InMemoryJobStore
|
||||
from app.services.mineru_client import MinerUClient
|
||||
from app.services.scheduler import SchedulerManager
|
||||
from app.services.supabase_rest import SupabaseRestAsyncClient
|
||||
from app.services.tasks import IngestWorker
|
||||
from app.services.webhooks import LightRAGWebhook
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
job_store = InMemoryJobStore()
|
||||
scheduler = SchedulerManager()
|
||||
webhook = LightRAGWebhook()
|
||||
worker = IngestWorker(job_store=job_store, webhook=webhook)
|
||||
|
||||
# 自动入库依赖:Supabase + MinerU + LightRAG
|
||||
supabase = SupabaseRestAsyncClient()
|
||||
mineru = MinerUClient()
|
||||
auto_indexer = AutoIndexService(supabase=supabase, webhook=webhook, mineru=mineru)
|
||||
|
||||
|
||||
+16
@@ -8,14 +8,22 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as _utils_id from "../_utils/id.js";
|
||||
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
|
||||
import type * as _utils_lightrag from "../_utils/lightrag.js";
|
||||
import type * as _utils_text from "../_utils/text.js";
|
||||
import type * as _utils_time from "../_utils/time.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as documents from "../documents.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as jobs from "../jobs.js";
|
||||
import type * as mediaAssets from "../mediaAssets.js";
|
||||
import type * as mindmaps from "../mindmaps.js";
|
||||
import type * as ping from "../ping.js";
|
||||
import type * as recents from "../recents.js";
|
||||
import type * as references from "../references.js";
|
||||
import type * as tables from "../tables.js";
|
||||
import type * as users from "../users.js";
|
||||
import type * as workspaces from "../workspaces.js";
|
||||
|
||||
import type {
|
||||
@@ -25,14 +33,22 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
"_utils/id": typeof _utils_id;
|
||||
"_utils/ingestJobs": typeof _utils_ingestJobs;
|
||||
"_utils/lightrag": typeof _utils_lightrag;
|
||||
"_utils/text": typeof _utils_text;
|
||||
"_utils/time": typeof _utils_time;
|
||||
auth: typeof auth;
|
||||
documents: typeof documents;
|
||||
http: typeof http;
|
||||
jobs: typeof jobs;
|
||||
mediaAssets: typeof mediaAssets;
|
||||
mindmaps: typeof mindmaps;
|
||||
ping: typeof ping;
|
||||
recents: typeof recents;
|
||||
references: typeof references;
|
||||
tables: typeof tables;
|
||||
users: typeof users;
|
||||
workspaces: typeof workspaces;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// 简单的 ID 生成器,用于生成类似 UUID 的字符串
|
||||
export function generateId(): string {
|
||||
// 使用时间戳 + 随机数生成唯一 ID
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 15);
|
||||
return `${timestamp}${random}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { internal } from "../_generated/api";
|
||||
import { nowIso } from "./time";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
type JobStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
async function upsertJob(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
debounceMs?: number;
|
||||
},
|
||||
): Promise<{ id: string; status: JobStatus }> {
|
||||
const debounceMs = Math.max(0, Math.floor(args.debounceMs ?? 1200));
|
||||
const ts = nowIso();
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("jobs")
|
||||
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
if (existing.user_id !== args.userId) {
|
||||
// 说明:避免不同用户复用同一 job id 导致“互相覆盖”。
|
||||
return { id: args.id, status: existing.status as JobStatus };
|
||||
}
|
||||
|
||||
const status = String(existing.status) as JobStatus;
|
||||
if (status === "running" || status === "queued") {
|
||||
await ctx.db.patch(existing._id, { updated_at: ts });
|
||||
// 说明:start 内部会检查状态,重复 schedule 不会造成重复执行。
|
||||
await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id });
|
||||
return { id: args.id, status };
|
||||
}
|
||||
|
||||
await ctx.db.patch(existing._id, {
|
||||
type: args.type,
|
||||
status: "queued",
|
||||
payload: args.payload,
|
||||
result: null,
|
||||
error: null,
|
||||
updated_at: ts,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
});
|
||||
await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id });
|
||||
return { id: args.id, status: "queued" };
|
||||
}
|
||||
|
||||
await ctx.db.insert("jobs", {
|
||||
id: args.id,
|
||||
user_id: args.userId,
|
||||
type: args.type,
|
||||
status: "queued",
|
||||
payload: args.payload,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
});
|
||||
await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id });
|
||||
return { id: args.id, status: "queued" };
|
||||
}
|
||||
|
||||
export function ingestDocumentJobId(documentId: string): string {
|
||||
return `ingest:document:${documentId}`;
|
||||
}
|
||||
|
||||
export function ingestMindmapJobId(docId: string, mindmapId: string): string {
|
||||
return `ingest:mindmap:${docId}:${mindmapId}`;
|
||||
}
|
||||
|
||||
export function ingestMediaAssetJobId(assetId: string): string {
|
||||
return `ingest:media:${assetId}`;
|
||||
}
|
||||
|
||||
export async function enqueueIngestDocumentJob(
|
||||
ctx: MutationCtx,
|
||||
args: { userId: string; documentId: string; debounceMs?: number },
|
||||
): Promise<{ id: string }> {
|
||||
const id = ingestDocumentJobId(args.documentId);
|
||||
await upsertJob(ctx, {
|
||||
id,
|
||||
userId: args.userId,
|
||||
type: "ingest.rag_index_document",
|
||||
payload: { documentId: args.documentId },
|
||||
debounceMs: args.debounceMs,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function enqueueIngestMindmapJob(
|
||||
ctx: MutationCtx,
|
||||
args: { userId: string; docId: string; mindmapId: string; debounceMs?: number },
|
||||
): Promise<{ id: string }> {
|
||||
const id = ingestMindmapJobId(args.docId, args.mindmapId);
|
||||
await upsertJob(ctx, {
|
||||
id,
|
||||
userId: args.userId,
|
||||
type: "ingest.rag_index_mindmap",
|
||||
payload: { docId: args.docId, mindmapId: args.mindmapId },
|
||||
debounceMs: args.debounceMs,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function enqueueIngestMediaAssetJob(
|
||||
ctx: MutationCtx,
|
||||
args: { userId: string; assetId: string; debounceMs?: number },
|
||||
): Promise<{ id: string }> {
|
||||
const id = ingestMediaAssetJobId(args.assetId);
|
||||
await upsertJob(ctx, {
|
||||
id,
|
||||
userId: args.userId,
|
||||
type: "ingest.rag_index_media_asset",
|
||||
payload: { assetId: args.assetId },
|
||||
debounceMs: args.debounceMs,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
type IngestTextArgs = {
|
||||
fileSource: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackId: string | null }> {
|
||||
const baseUrl = (process.env.LIGHTRAG_URL || "").trim().replace(/\/+$/, "");
|
||||
if (!baseUrl) {
|
||||
throw new Error("缺少环境变量:LIGHTRAG_URL");
|
||||
}
|
||||
|
||||
const fileSource = String(args.fileSource ?? "").trim();
|
||||
if (!fileSource) {
|
||||
throw new Error("缺少 fileSource");
|
||||
}
|
||||
|
||||
const text = String(args.text ?? "");
|
||||
if (!text.trim()) {
|
||||
return { trackId: null };
|
||||
}
|
||||
|
||||
const apiKey = (process.env.LIGHTRAG_API_KEY || "").trim();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (apiKey) {
|
||||
headers["X-API-Key"] = apiKey;
|
||||
}
|
||||
|
||||
// 说明:LightRAG(>=1.4.x) 使用 /documents/text 作为“文本入库”接口。
|
||||
const resp = await fetch(`${baseUrl}/documents/text`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ text, file_source: fileSource }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const raw = await resp.text().catch(() => "");
|
||||
throw new Error(`LightRAG 入库失败:HTTP ${resp.status} ${raw.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json().catch(() => null)) as unknown;
|
||||
const trackId = (() => {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const v = (data as Record<string, unknown>).track_id;
|
||||
return typeof v === "string" ? v : null;
|
||||
})();
|
||||
return { trackId };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
function pushText(out: string[], value: unknown) {
|
||||
const s = typeof value === "string" ? value : null;
|
||||
if (!s) return;
|
||||
const trimmed = s.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return;
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
export function extractTextFromDocumentContent(content: unknown, maxChars = 60000): string {
|
||||
const out: string[] = [];
|
||||
|
||||
const readPropText = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const t = (value as Record<string, unknown>).text;
|
||||
return typeof t === "string" ? t : null;
|
||||
};
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
|
||||
if (node == null) return;
|
||||
if (typeof node === "string") {
|
||||
pushText(out, node);
|
||||
return;
|
||||
}
|
||||
if (typeof node === "number" || typeof node === "boolean") return;
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
walk(item);
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof node === "object") {
|
||||
const obj = node as Record<string, unknown>;
|
||||
|
||||
// 兼容常见 BlockNote 结构:
|
||||
// - block.props.text
|
||||
// - block.content: [{ text: "..." }, ...]
|
||||
// - block.children: [...]
|
||||
pushText(out, obj.text);
|
||||
pushText(out, readPropText(obj.props));
|
||||
|
||||
const maybeContent = obj.content;
|
||||
if (Array.isArray(maybeContent)) {
|
||||
for (const seg of maybeContent) {
|
||||
if (typeof seg === "string") pushText(out, seg);
|
||||
else if (seg && typeof seg === "object") pushText(out, readPropText(seg));
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
|
||||
const children = obj.children;
|
||||
if (Array.isArray(children)) {
|
||||
for (const c of children) {
|
||||
walk(c);
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:遍历其它字段(避免遗漏 title/caption 等)
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === "children" || k === "content" || k === "props" || k === "text") continue;
|
||||
if (typeof v === "string") pushText(out, v);
|
||||
else if (Array.isArray(v) || (v && typeof v === "object")) walk(v);
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(content);
|
||||
|
||||
const joined = out.join("\n").trim();
|
||||
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
|
||||
}
|
||||
|
||||
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
|
||||
|
||||
export function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
|
||||
const out: string[] = [];
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as MindmapNode;
|
||||
pushText(out, n.data?.text);
|
||||
if (Array.isArray(n.children)) {
|
||||
for (const c of n.children) {
|
||||
walk(c);
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(data);
|
||||
|
||||
const joined = out.join("\n").trim();
|
||||
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Convex Auth 配置文件
|
||||
*
|
||||
* 说明:
|
||||
* - 该文件由 @convex-dev/auth 约定读取,用于声明 “Convex” 这一内置 provider 的基本信息。
|
||||
* - domain 必须与站点对外访问的 Origin 一致(本地默认 http://127.0.0.1:3000)。
|
||||
*/
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain: process.env.CONVEX_SITE_URL,
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Password } from "@convex-dev/auth/providers/Password";
|
||||
import { convexAuth } from "@convex-dev/auth/server";
|
||||
|
||||
/**
|
||||
* Convex Auth 配置
|
||||
*
|
||||
* 配置密码认证方式,支持用户通过邮箱和密码注册/登录。
|
||||
*/
|
||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
providers: [
|
||||
Password({
|
||||
/**
|
||||
* 密码要求验证
|
||||
*/
|
||||
validatePasswordRequirements: (password: string) => {
|
||||
if (password.length < 8) {
|
||||
throw new Error("密码至少需要 8 个字符");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户资料配置
|
||||
*/
|
||||
profile(params, _ctx) {
|
||||
const email = typeof params.email === "string" ? params.email : String(params.email ?? "");
|
||||
const name = typeof params.name === "string" ? params.name : "";
|
||||
// 说明:Convex 的 Value 类型不允许 undefined;这里统一给 name 一个字符串占位。
|
||||
return { email, name: name.trim() ? name : "" };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||
|
||||
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
||||
|
||||
@@ -175,6 +176,10 @@ export const updateContent = mutation({
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
|
||||
|
||||
// 说明:在 Convex 模式下,把“自动入库/LightRAG 触发”迁到 Convex jobs/actions。
|
||||
// 采用 debounce,避免频繁保存时触发过多任务。
|
||||
await enqueueIngestDocumentJob(ctx, { userId: args.userId, documentId: args.id, debounceMs: 1500 });
|
||||
return { ok: true, updated_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { httpRouter } from "convex/server";
|
||||
import { auth } from "./auth";
|
||||
|
||||
// 说明:
|
||||
// - Convex Auth 需要通过 HTTP Routes 提供回调/授权等端点(即使你只用密码登录,也建议保持该配置)。
|
||||
// - 该文件是 Convex 的约定入口(convex/http.ts)。
|
||||
|
||||
const http = httpRouter();
|
||||
|
||||
auth.addHttpRoutes(http);
|
||||
|
||||
export default http;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { internal } from "./_generated/api";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import { lightragIngestText } from "./_utils/lightrag";
|
||||
import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_utils/text";
|
||||
import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
|
||||
export const get = query({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
@@ -52,6 +55,35 @@ export const enqueueDemo = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const enqueueRagIndexDocument = mutation({
|
||||
args: { userId: v.string(), documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { id } = await enqueueIngestDocumentJob(ctx, { userId: args.userId, documentId: args.documentId, debounceMs: 0 });
|
||||
return { ok: true, id };
|
||||
},
|
||||
});
|
||||
|
||||
export const enqueueRagIndexMindmap = mutation({
|
||||
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { id } = await enqueueIngestMindmapJob(ctx, {
|
||||
userId: args.userId,
|
||||
docId: args.docId,
|
||||
mindmapId: args.mindmapId,
|
||||
debounceMs: 0,
|
||||
});
|
||||
return { ok: true, id };
|
||||
},
|
||||
});
|
||||
|
||||
export const enqueueRagIndexMediaAsset = mutation({
|
||||
args: { userId: v.string(), assetId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { id } = await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.assetId, debounceMs: 0 });
|
||||
return { ok: true, id };
|
||||
},
|
||||
});
|
||||
|
||||
export const start = internalMutation({
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -85,6 +117,88 @@ export const run = internalAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.type === "ingest.rag_index_document") {
|
||||
const documentId = String(job.payload?.documentId ?? "").trim();
|
||||
if (!documentId) throw new Error("缺少 documentId");
|
||||
|
||||
const [meta, contentRes] = await Promise.all([
|
||||
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: documentId }),
|
||||
ctx.runQuery(api.documents.getContent, { userId: job.user_id, id: documentId }),
|
||||
]);
|
||||
|
||||
if (!meta) throw new Error("页面不存在或无权限");
|
||||
|
||||
const title = meta.title ?? "无标题";
|
||||
const text = extractTextFromDocumentContent(contentRes?.content ?? null);
|
||||
const fileSource = `document:${documentId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "document", documentId, trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.type === "ingest.rag_index_mindmap") {
|
||||
const docId = String(job.payload?.docId ?? "").trim();
|
||||
const mindmapId = String(job.payload?.mindmapId ?? "").trim();
|
||||
if (!docId) throw new Error("缺少 docId");
|
||||
if (!mindmapId) throw new Error("缺少 mindmapId");
|
||||
|
||||
const [docMeta, mindmapRes] = await Promise.all([
|
||||
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: docId }),
|
||||
ctx.runQuery(api.mindmaps.get, { userId: job.user_id, docId, mindmapId }),
|
||||
]);
|
||||
|
||||
if (!docMeta) throw new Error("页面不存在或无权限");
|
||||
if (!mindmapRes?.ok) throw new Error("导图读取失败");
|
||||
if (!mindmapRes.meta?.exists) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, skipped: true, reason: "mindmap_not_exists" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const title = docMeta.title ?? "无标题";
|
||||
const text = extractTextFromMindmapData(mindmapRes.data ?? null);
|
||||
const fileSource = `mindmap:${docId}:${mindmapId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.type === "ingest.rag_index_media_asset") {
|
||||
const assetId = String(job.payload?.assetId ?? "").trim();
|
||||
if (!assetId) throw new Error("缺少 assetId");
|
||||
|
||||
const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: job.user_id, id: assetId });
|
||||
if (!asset) throw new Error("资源不存在或无权限");
|
||||
|
||||
const title = asset.file_name ?? asset.id;
|
||||
const text = String(asset.ocr_text ?? "").trim();
|
||||
if (!text) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "media_asset", assetId, skipped: true, reason: "empty_ocr_text" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fileSource = `media:${assetId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "media_asset", assetId, trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`未知任务类型:${job.type}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -103,6 +217,7 @@ export const _getInternal = internalQuery({
|
||||
if (!job) return null;
|
||||
return {
|
||||
id: job.id,
|
||||
user_id: job.user_id,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
payload: job.payload,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
|
||||
@@ -251,6 +252,15 @@ export const patchById = mutation({
|
||||
|
||||
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
|
||||
await ctx.db.patch(row._id, next);
|
||||
|
||||
// 说明:当 OCR 写回完成时,自动触发 LightRAG 入库任务(Convex jobs/actions)。
|
||||
const patch = args.patch as Record<string, unknown>;
|
||||
const prev = row as Record<string, unknown>;
|
||||
const nextOcrStatus = typeof patch.ocr_status === "string" ? patch.ocr_status : prev.ocr_status;
|
||||
const nextOcrText = typeof patch.ocr_text === "string" ? patch.ocr_text : prev.ocr_text;
|
||||
if (nextOcrStatus === "completed" && typeof nextOcrText === "string" && nextOcrText.trim()) {
|
||||
await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 1200 });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -101,6 +102,7 @@ export const put = mutation({
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
});
|
||||
await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 });
|
||||
return { ok: true, created: false, skipped: false, updated_at: ts };
|
||||
}
|
||||
|
||||
@@ -117,6 +119,7 @@ export const put = mutation({
|
||||
deleted_by: null,
|
||||
});
|
||||
|
||||
await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 });
|
||||
return { ok: true, created: true, skipped: false, updated_at: ts };
|
||||
},
|
||||
});
|
||||
@@ -291,4 +294,3 @@ export const emptyTrashByWorkspace = mutation({
|
||||
return { ok: true, deletedCount: toDelete.length };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { authTables } from "@convex-dev/auth/server";
|
||||
|
||||
// 说明:
|
||||
// - 这里先按“兼容现有 Next API 返回结构”的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。
|
||||
// - 这里先按"兼容现有 Next API 返回结构"的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。
|
||||
// - Convex 自带的 _id 仍然存在,但我们暂时不把它暴露给上层业务,便于逐步迁移与回退。
|
||||
// - M8: 集成 Convex Auth,authTables 提供用户认证所需的数据表
|
||||
|
||||
export default defineSchema({
|
||||
// Convex Auth 表(用户、账户、会话等)
|
||||
...authTables,
|
||||
workspaces: defineTable({
|
||||
id: v.string(),
|
||||
name: v.string(),
|
||||
@@ -187,4 +191,50 @@ export default defineSchema({
|
||||
.index("by_workspace_target", ["workspace_id", "target_page_id"])
|
||||
.index("by_workspace_source", ["workspace_id", "source_page_id"])
|
||||
.index("by_unique", ["workspace_id", "source_page_id", "source_block_id", "target_page_id", "display_mode"]),
|
||||
|
||||
// M5:在线表格(替代 Supabase document_tables)
|
||||
document_tables: defineTable({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
grid_key: v.string(),
|
||||
title: v.string(),
|
||||
schema: v.any(), // TableSchema: { columns, frozenRowCount, frozenColCount }
|
||||
view_preferences: v.any(),
|
||||
snapshot: v.optional(v.any()), // DocumentTableSnapshot: { rows?, luckysheet? }
|
||||
is_archived: v.boolean(),
|
||||
last_synced_at: v.optional(v.string()),
|
||||
created_by: v.string(),
|
||||
updated_by: v.optional(v.string()),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_table_id", ["id"])
|
||||
.index("by_grid_key", ["grid_key"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_workspace", ["workspace_id"])
|
||||
.index("by_workspace_archived", ["workspace_id", "is_archived"]),
|
||||
|
||||
// M5:在线表格行数据(替代 Supabase document_table_rows)
|
||||
document_table_rows: defineTable({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
table_id: v.string(),
|
||||
row_index: v.number(),
|
||||
row_data: v.any(),
|
||||
row_hash: v.optional(v.string()),
|
||||
is_deleted: v.boolean(),
|
||||
updated_by: v.optional(v.string()),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_row_id", ["id"])
|
||||
.index("by_table", ["table_id"])
|
||||
.index("by_table_row", ["table_id", "row_index"])
|
||||
.index("by_workspace", ["workspace_id"])
|
||||
.searchIndex("by_table_row_full", {
|
||||
searchField: "row_hash",
|
||||
filterFields: ["table_id", "is_deleted"],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { generateId } from "./_utils/id";
|
||||
|
||||
// Query: 获取单个表格
|
||||
export const get = query({
|
||||
args: { userId: v.string(), tableId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
|
||||
.first();
|
||||
if (!table) return null;
|
||||
if (table.workspace_id) {
|
||||
// 验证用户是否在工作区中(简化版:假设 userId 有效)
|
||||
// TODO: 添加 workspace_members 验证
|
||||
}
|
||||
return {
|
||||
id: table.id,
|
||||
workspace_id: table.workspace_id,
|
||||
document_id: table.document_id,
|
||||
grid_key: table.grid_key,
|
||||
title: table.title,
|
||||
schema: table.schema,
|
||||
view_preferences: table.view_preferences,
|
||||
snapshot: table.snapshot ?? null,
|
||||
is_archived: table.is_archived,
|
||||
last_synced_at: table.last_synced_at ?? null,
|
||||
created_by: table.created_by,
|
||||
updated_by: table.updated_by ?? null,
|
||||
created_at: table.created_at,
|
||||
updated_at: table.updated_at,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Query: 通过 gridKey 获取表格(用于 Luckysheet get-workerbook)
|
||||
export const getByGridKey = query({
|
||||
args: { gridKey: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
|
||||
.first();
|
||||
if (!table) return null;
|
||||
|
||||
// 返回 Luckysheet 需要的格式
|
||||
return {
|
||||
title: table.title,
|
||||
gridKey: table.grid_key,
|
||||
lang: "zh",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Query: 通过 gridKey 获取完整表格数据(用于 Luckysheet load)
|
||||
export const getByGridKeyFull = query({
|
||||
args: { gridKey: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
|
||||
.first();
|
||||
if (!table) return null;
|
||||
|
||||
// 返回完整的表格数据,包括 schema 和 snapshot
|
||||
return {
|
||||
id: table.id,
|
||||
grid_key: table.grid_key,
|
||||
title: table.title,
|
||||
schema: table.schema,
|
||||
snapshot: table.snapshot ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Query: 列出文档的所有表格
|
||||
export const listByDocument = query({
|
||||
args: { userId: v.string(), documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const tables = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_document", (q) => q.eq("document_id", args.documentId))
|
||||
.collect();
|
||||
|
||||
return tables
|
||||
.filter((t) => !t.is_archived)
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
document_id: t.document_id,
|
||||
grid_key: t.grid_key,
|
||||
title: t.title,
|
||||
schema: t.schema,
|
||||
view_preferences: t.view_preferences,
|
||||
snapshot: t.snapshot ?? null,
|
||||
is_archived: t.is_archived,
|
||||
last_synced_at: t.last_synced_at ?? null,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: 创建表格
|
||||
export const create = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
documentId: v.string(),
|
||||
title: v.optional(v.string()),
|
||||
schema: v.optional(v.any()),
|
||||
snapshot: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = nowIso();
|
||||
const tableId = generateId();
|
||||
const gridKey = generateId(); // 用于 WebSocket 协同标识
|
||||
|
||||
const table = {
|
||||
id: tableId,
|
||||
workspace_id: args.workspaceId,
|
||||
document_id: args.documentId,
|
||||
grid_key: gridKey,
|
||||
title: args.title ?? "未命名表格",
|
||||
schema: args.schema ?? {},
|
||||
view_preferences: {},
|
||||
snapshot: args.snapshot ?? null,
|
||||
is_archived: false,
|
||||
last_synced_at: now,
|
||||
created_by: args.userId,
|
||||
updated_by: args.userId,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
await ctx.db.insert("document_tables", table);
|
||||
|
||||
return {
|
||||
id: table.id,
|
||||
grid_key: table.grid_key,
|
||||
title: table.title,
|
||||
schema: table.schema,
|
||||
snapshot: table.snapshot,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: 更新表格
|
||||
export const update = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
tableId: v.string(),
|
||||
title: v.optional(v.string()),
|
||||
schema: v.optional(v.any()),
|
||||
view_preferences: v.optional(v.any()),
|
||||
snapshot: v.optional(v.any()),
|
||||
rows: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
|
||||
.first();
|
||||
if (!table) {
|
||||
throw new Error("Table not found");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const updates: any = {
|
||||
updated_at: now,
|
||||
updated_by: args.userId,
|
||||
last_synced_at: now,
|
||||
};
|
||||
|
||||
if (args.title !== undefined) updates.title = args.title;
|
||||
if (args.schema !== undefined) updates.schema = args.schema;
|
||||
if (args.view_preferences !== undefined) updates.view_preferences = args.view_preferences;
|
||||
if (args.snapshot !== undefined) updates.snapshot = args.snapshot;
|
||||
|
||||
await ctx.db.patch(table._id, updates);
|
||||
|
||||
// 如果提供了 rows,更新行数据
|
||||
if (args.rows && Array.isArray(args.rows)) {
|
||||
// 先删除旧行
|
||||
const existingRows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
|
||||
.collect();
|
||||
|
||||
for (const row of existingRows) {
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
|
||||
// 插入新行
|
||||
for (const [index, rowData] of args.rows.entries()) {
|
||||
await ctx.db.insert("document_table_rows", {
|
||||
id: generateId(),
|
||||
workspace_id: table.workspace_id,
|
||||
document_id: table.document_id,
|
||||
table_id: args.tableId,
|
||||
row_index: index,
|
||||
row_data: rowData,
|
||||
row_hash: JSON.stringify(rowData),
|
||||
is_deleted: false,
|
||||
updated_by: args.userId,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: table.id,
|
||||
title: updates.title ?? table.title,
|
||||
schema: updates.schema ?? table.schema,
|
||||
view_preferences: updates.view_preferences ?? table.view_preferences,
|
||||
snapshot: updates.snapshot ?? table.snapshot,
|
||||
updated_at: now,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: 删除表格(软删除)
|
||||
export const remove = mutation({
|
||||
args: { userId: v.string(), tableId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
|
||||
.first();
|
||||
if (!table) {
|
||||
throw new Error("Table not found");
|
||||
}
|
||||
|
||||
await ctx.db.patch(table._id, {
|
||||
is_archived: true,
|
||||
updated_at: nowIso(),
|
||||
updated_by: args.userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: 永久删除表格
|
||||
export const purge = mutation({
|
||||
args: { userId: v.string(), tableId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
|
||||
.first();
|
||||
if (!table) {
|
||||
throw new Error("Table not found");
|
||||
}
|
||||
|
||||
// 删除关联的行
|
||||
const rows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
|
||||
.collect();
|
||||
|
||||
for (const row of rows) {
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
|
||||
// 删除表格
|
||||
await ctx.db.delete(table._id);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Query: 获取表格行数据
|
||||
export const getRows = query({
|
||||
args: { tableId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const rows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
|
||||
.collect();
|
||||
|
||||
return rows
|
||||
.filter((r) => !r.is_deleted)
|
||||
.sort((a, b) => a.row_index - b.row_index)
|
||||
.map((r) => r.row_data);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
|
||||
/**
|
||||
* 获取当前登录用户
|
||||
*/
|
||||
export const currentUser = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ctx.db.get(userId);
|
||||
},
|
||||
});
|
||||
@@ -10,9 +10,11 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "0.37.0",
|
||||
"@blocknote/core": "^0.42.0",
|
||||
"@blocknote/mantine": "0.42.0",
|
||||
"@blocknote/react": "^0.42.0",
|
||||
"@convex-dev/auth": "^0.0.90",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -65,6 +67,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"jose": "^6.1.3",
|
||||
"jsdom": "^27.2.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
Generated
+163
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@auth/core':
|
||||
specifier: 0.37.0
|
||||
version: 0.37.0
|
||||
'@blocknote/core':
|
||||
specifier: ^0.42.0
|
||||
version: 0.42.0(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)
|
||||
@@ -17,6 +20,9 @@ importers:
|
||||
'@blocknote/react':
|
||||
specifier: ^0.42.0
|
||||
version: 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@convex-dev/auth':
|
||||
specifier: ^0.0.90
|
||||
version: 0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -168,6 +174,9 @@ importers:
|
||||
eslint-config-next:
|
||||
specifier: 16.0.3
|
||||
version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
jose:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
jsdom:
|
||||
specifier: ^27.2.0
|
||||
version: 27.2.0
|
||||
@@ -202,6 +211,20 @@ packages:
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@auth/core@0.37.0':
|
||||
resolution: {integrity: sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
'@simplewebauthn/server': ^9.0.2
|
||||
nodemailer: ^6.8.0
|
||||
peerDependenciesMeta:
|
||||
'@simplewebauthn/browser':
|
||||
optional: true
|
||||
'@simplewebauthn/server':
|
||||
optional: true
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -382,6 +405,17 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@convex-dev/auth@0.0.90':
|
||||
resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@auth/core': ^0.37.0
|
||||
convex: ^1.17.0
|
||||
react: ^18.2.0 || ^19.0.0-0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1356,6 +1390,21 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@oslojs/asn1@1.0.0':
|
||||
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
|
||||
|
||||
'@oslojs/binary@1.0.0':
|
||||
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
|
||||
|
||||
'@oslojs/crypto@1.0.1':
|
||||
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
|
||||
|
||||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
'@panva/hkdf@1.2.1':
|
||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
|
||||
|
||||
@@ -2191,6 +2240,9 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/cookie@0.6.0':
|
||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
@@ -2748,6 +2800,10 @@ packages:
|
||||
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookie@0.7.1:
|
||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3611,6 +3667,10 @@ packages:
|
||||
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-network-error@1.3.0:
|
||||
resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
is-number-object@1.1.1:
|
||||
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3685,6 +3745,12 @@ packages:
|
||||
jose@4.15.9:
|
||||
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||
|
||||
jose@5.10.0:
|
||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -3731,6 +3797,10 @@ packages:
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
jwt-decode@4.0.0:
|
||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
katex@0.16.27:
|
||||
resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==}
|
||||
hasBin: true
|
||||
@@ -3868,6 +3938,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lucia@3.2.2:
|
||||
resolution: {integrity: sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==}
|
||||
deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate.
|
||||
|
||||
lucide-react@0.554.0:
|
||||
resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==}
|
||||
peerDependencies:
|
||||
@@ -4170,6 +4244,9 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
oauth4webapi@3.8.3:
|
||||
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4293,6 +4370,14 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
preact-render-to-string@5.2.3:
|
||||
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
|
||||
peerDependencies:
|
||||
preact: '>=10'
|
||||
|
||||
preact@10.11.3:
|
||||
resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4302,6 +4387,9 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-format@3.8.0:
|
||||
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
|
||||
|
||||
printable-characters@1.0.42:
|
||||
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
|
||||
|
||||
@@ -4647,6 +4735,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
@@ -5402,6 +5493,16 @@ snapshots:
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
'@auth/core@0.37.0':
|
||||
dependencies:
|
||||
'@panva/hkdf': 1.2.1
|
||||
'@types/cookie': 0.6.0
|
||||
cookie: 0.7.1
|
||||
jose: 5.10.0
|
||||
oauth4webapi: 3.8.3
|
||||
preact: 10.11.3
|
||||
preact-render-to-string: 5.2.3(preact@10.11.3)
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -5665,6 +5766,23 @@ snapshots:
|
||||
'@cloudflare/workerd-windows-64@1.20251210.0':
|
||||
optional: true
|
||||
|
||||
'@convex-dev/auth@0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@auth/core': 0.37.0
|
||||
'@oslojs/crypto': 1.0.1
|
||||
'@oslojs/encoding': 1.1.0
|
||||
convex: 1.31.4(react@19.2.0)
|
||||
cookie: 1.1.1
|
||||
is-network-error: 1.3.0
|
||||
jose: 5.10.0
|
||||
jwt-decode: 4.0.0
|
||||
lucia: 3.2.2
|
||||
oauth4webapi: 3.8.3
|
||||
path-to-regexp: 6.3.0
|
||||
server-only: 0.0.1
|
||||
optionalDependencies:
|
||||
react: 19.2.0
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
@@ -6345,6 +6463,21 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@oslojs/asn1@1.0.0':
|
||||
dependencies:
|
||||
'@oslojs/binary': 1.0.0
|
||||
|
||||
'@oslojs/binary@1.0.0': {}
|
||||
|
||||
'@oslojs/crypto@1.0.1':
|
||||
dependencies:
|
||||
'@oslojs/asn1': 1.0.0
|
||||
'@oslojs/binary': 1.0.0
|
||||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@panva/hkdf@1.2.1': {}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
@@ -7124,6 +7257,8 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/cookie@0.6.0': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
@@ -7704,6 +7839,8 @@ snapshots:
|
||||
|
||||
cookie@0.5.0: {}
|
||||
|
||||
cookie@0.7.1: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
@@ -8804,6 +8941,8 @@ snapshots:
|
||||
|
||||
is-negative-zero@2.0.3: {}
|
||||
|
||||
is-network-error@1.3.0: {}
|
||||
|
||||
is-number-object@1.1.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -8875,6 +9014,10 @@ snapshots:
|
||||
|
||||
jose@4.15.9: {}
|
||||
|
||||
jose@5.10.0: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
@@ -8936,6 +9079,8 @@ snapshots:
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
jwt-decode@4.0.0: {}
|
||||
|
||||
katex@0.16.27:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
@@ -9044,6 +9189,11 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucia@3.2.2:
|
||||
dependencies:
|
||||
'@oslojs/crypto': 1.0.1
|
||||
'@oslojs/encoding': 1.1.0
|
||||
|
||||
lucide-react@0.554.0(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
@@ -9626,6 +9776,8 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
oauth4webapi@3.8.3: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -9759,10 +9911,19 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
preact-render-to-string@5.2.3(preact@10.11.3):
|
||||
dependencies:
|
||||
preact: 10.11.3
|
||||
pretty-format: 3.8.0
|
||||
|
||||
preact@10.11.3: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.8.0: {}
|
||||
|
||||
pretty-format@3.8.0: {}
|
||||
|
||||
printable-characters@1.0.42: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
@@ -10189,6 +10350,8 @@ snapshots:
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function setConvexEnv(name, value) {
|
||||
// 说明:使用 stdin 传值,避免以 `-----BEGIN ...` 开头的内容被 CLI 误当作参数/选项。
|
||||
execSync(`npx convex env set ${name}`, {
|
||||
input: String(value ?? ""),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
const privateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1
|
||||
gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK
|
||||
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
|
||||
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
|
||||
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
|
||||
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
|
||||
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
|
||||
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
|
||||
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
|
||||
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
|
||||
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
|
||||
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
|
||||
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
|
||||
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
|
||||
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
|
||||
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
|
||||
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
|
||||
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
|
||||
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
|
||||
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
|
||||
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
|
||||
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
|
||||
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
|
||||
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
|
||||
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
|
||||
AT5PRKihrWbg63/rXaSCxw==
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}';
|
||||
|
||||
// 使用临时目录(跨平台兼容)
|
||||
const tmpDir = process.env.TMPDIR || process.env.TEMP || '/tmp';
|
||||
const keyFile = path.join(tmpDir, 'convex_jwt_key.txt');
|
||||
const jwksFile = path.join(tmpDir, 'convex_jwks.txt');
|
||||
|
||||
// 写入临时文件
|
||||
fs.writeFileSync(keyFile, privateKey);
|
||||
fs.writeFileSync(jwksFile, jwks);
|
||||
|
||||
console.log('Setting JWT_PRIVATE_KEY from file...');
|
||||
const keyContent = fs.readFileSync(keyFile, 'utf8');
|
||||
try {
|
||||
setConvexEnv("JWT_PRIVATE_KEY", keyContent);
|
||||
console.log('✓ JWT_PRIVATE_KEY set');
|
||||
} catch (e) {
|
||||
console.error('✗ Failed to set JWT_PRIVATE_KEY:', e.message);
|
||||
}
|
||||
|
||||
console.log('Setting JWKS...');
|
||||
try {
|
||||
setConvexEnv("JWKS", jwks);
|
||||
console.log('✓ JWKS set');
|
||||
} catch (e) {
|
||||
console.error('✗ Failed to set JWKS:', e.message);
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
try {
|
||||
fs.unlinkSync(keyFile);
|
||||
fs.unlinkSync(jwksFile);
|
||||
} catch (e) {
|
||||
// 忽略清理错误
|
||||
}
|
||||
|
||||
console.log('Done! You can now test registration.');
|
||||
@@ -0,0 +1,13 @@
|
||||
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
|
||||
|
||||
const keys = await generateKeyPair("RS256", { extractable: true });
|
||||
const privateKey = await exportPKCS8(keys.privateKey);
|
||||
const publicKey = await exportJWK(keys.publicKey);
|
||||
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
|
||||
|
||||
process.stdout.write(
|
||||
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`,
|
||||
);
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(`JWKS=${jwks}`);
|
||||
process.stdout.write("\n");
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 注册测试账号脚本
|
||||
*
|
||||
* 用途:快速注册测试账号,方便开发和测试
|
||||
* 运行:node scripts/register-test-user.js
|
||||
*/
|
||||
|
||||
const TEST_CREDENTIALS = {
|
||||
email: "test@example.com",
|
||||
password: "Test123456",
|
||||
name: "测试用户",
|
||||
};
|
||||
|
||||
async function registerTestUser() {
|
||||
const baseUrl = "http://localhost:3000";
|
||||
|
||||
console.log("正在注册测试账号...");
|
||||
console.log(`邮箱: ${TEST_CREDENTIALS.email}`);
|
||||
console.log(`密码: ${TEST_CREDENTIALS.password}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/auth/signin`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: TEST_CREDENTIALS.email,
|
||||
password: TEST_CREDENTIALS.password,
|
||||
name: TEST_CREDENTIALS.name,
|
||||
flow: "signUp",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
console.log("✓ 测试账号注册成功!");
|
||||
console.log(`\n您现在可以使用以下凭据登录:`);
|
||||
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
|
||||
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
|
||||
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
|
||||
} else if (response.status === 501) {
|
||||
console.log("ℹ API 路由暂未实现,请通过浏览器手动注册:");
|
||||
console.log(` 1. 访问 http://localhost:3000/auth`);
|
||||
console.log(` 2. 点击"还没有账户?立即注册"`);
|
||||
console.log(` 3. 填写:`);
|
||||
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
|
||||
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
|
||||
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
|
||||
} else {
|
||||
console.error("✗ 注册失败:", result.error || result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("✗ 请求失败:", error.message);
|
||||
console.log("\n请确保开发服务器正在运行 (pnpm dev)");
|
||||
}
|
||||
}
|
||||
|
||||
registerTestUser();
|
||||
@@ -0,0 +1,57 @@
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function setConvexEnv(name, value) {
|
||||
// 说明:使用 stdin 传值,避免以 `-----BEGIN ...` 开头的内容被 CLI 误当作参数/选项。
|
||||
execSync(`npx convex env set ${name}`, {
|
||||
input: String(value ?? ""),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
const privateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1
|
||||
gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK
|
||||
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
|
||||
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
|
||||
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
|
||||
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
|
||||
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
|
||||
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
|
||||
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
|
||||
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
|
||||
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
|
||||
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
|
||||
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
|
||||
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
|
||||
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
|
||||
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
|
||||
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
|
||||
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
|
||||
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
|
||||
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
|
||||
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
|
||||
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
|
||||
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
|
||||
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
|
||||
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
|
||||
AT5PRKihrWbg63/rXaSCxw==
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}';
|
||||
|
||||
console.log('Setting JWT_PRIVATE_KEY...');
|
||||
try {
|
||||
setConvexEnv("JWT_PRIVATE_KEY", privateKey);
|
||||
} catch (e) {
|
||||
console.error('Failed to set JWT_PRIVATE_KEY:', e.message);
|
||||
}
|
||||
|
||||
console.log('Setting JWKS...');
|
||||
try {
|
||||
setConvexEnv("JWKS", jwks);
|
||||
} catch (e) {
|
||||
console.error('Failed to set JWKS:', e.message);
|
||||
}
|
||||
|
||||
console.log('Done!');
|
||||
@@ -20,7 +20,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id });
|
||||
if (!doc) {
|
||||
|
||||
@@ -66,7 +66,7 @@ function makeId(): string {
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
|
||||
type AuthStep = "signIn" | "signUp";
|
||||
|
||||
// 测试账号凭据常量
|
||||
const TEST_CREDENTIALS = {
|
||||
email: "test@example.com",
|
||||
password: "Test123456",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Convex Auth 登录/注册页面
|
||||
*
|
||||
* 支持功能:
|
||||
* - 邮箱密码登录
|
||||
* - 邮箱密码注册
|
||||
*/
|
||||
export default function AuthPage() {
|
||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||
const { signIn } = useAuthActions();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
const [flow, setFlow] = useState<AuthStep>("signIn");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error" | "info";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
// 执行登录的核心逻辑
|
||||
const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => {
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("email", email);
|
||||
formData.append("password", password);
|
||||
if (flow === "signUp") {
|
||||
formData.append("name", name);
|
||||
}
|
||||
formData.append("flow", flow);
|
||||
|
||||
const result = await signIn("password", formData);
|
||||
|
||||
if (result.signingIn) {
|
||||
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" });
|
||||
setTimeout(() => router.push("/"), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 说明:部分认证方式会返回 redirect(例如 OAuth);密码模式一般不会走到这里。
|
||||
if (result.redirect) {
|
||||
setMessage({ type: "info", text: "正在跳转..." });
|
||||
router.push(result.redirect.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage({ type: "error", text: "操作失败,请重试" });
|
||||
} catch (error: any) {
|
||||
setMessage({ type: "error", text: error.message || "操作失败,请重试" });
|
||||
}
|
||||
}, [name, signIn, router]);
|
||||
|
||||
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await performSignIn(email, password, flow);
|
||||
}, [email, password, flow, performSignIn]);
|
||||
|
||||
// 检查是否启用了 Convex
|
||||
const isConvex = isConvexEnabled();
|
||||
if (!isConvex) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">认证功能不可用</h1>
|
||||
<p className="text-gray-600">请启用 Convex 后端后再试</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在跳转...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
{flow === "signIn" ? "登录账户" : "创建账户"}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
使用 Convex Auth
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`rounded-md p-4 ${
|
||||
message.type === "success" ? "bg-green-50 text-green-800" :
|
||||
message.type === "error" ? "bg-red-50 text-red-800" :
|
||||
"bg-blue-50 text-blue-800"
|
||||
}`}>
|
||||
<p className="text-sm">{message.text}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">邮箱地址</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="邮箱地址"
|
||||
/>
|
||||
</div>
|
||||
{flow === "signUp" && (
|
||||
<div>
|
||||
<label htmlFor="name" className="sr-only">姓名</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="姓名(可选)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">密码</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={flow === "signIn" ? "current-password" : "new-password"}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="密码(至少 8 位)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
{flow === "signIn" ? "登录" : "注册"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{flow === "signIn" && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail(TEST_CREDENTIALS.email);
|
||||
setPassword(TEST_CREDENTIALS.password);
|
||||
// 直接调用登录逻辑
|
||||
performSignIn(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password, "signIn");
|
||||
}}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
测试账号快速登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFlow(flow === "signIn" ? "signUp" : "signIn");
|
||||
setMessage(null);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-500 text-sm"
|
||||
>
|
||||
{flow === "signIn" ? "还没有账户?立即注册" : "已有账户?去登录"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,13 +28,11 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = (() => {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
return auth.userId;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
let userId: string | null = null;
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
userId = auth.userId;
|
||||
}
|
||||
|
||||
const resolvedUserId = async () => {
|
||||
if (userId) return userId;
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
|
||||
const convexOn = isConvexEnabled();
|
||||
const { userId, supabase, convexClient } = await (async () => {
|
||||
if (convexOn) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
return { userId: auth.userId, supabase: null as any, convexClient: client };
|
||||
}
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
|
||||
interface SignInRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
flow: "signIn" | "signUp";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/signin
|
||||
*
|
||||
* 处理登录/注册请求
|
||||
* - 在 Convex 模式下调用 Convex mutation
|
||||
* - 在 Supabase 模式下调用 Supabase Auth
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const body = await request.json() as SignInRequest;
|
||||
const { email, password, name, flow } = body;
|
||||
|
||||
// TODO: 调用 Convex Auth mutation 处理登录/注册
|
||||
// 当前暂时返回未实现错误
|
||||
return NextResponse.json(
|
||||
{ error: "Convex Auth API 路由暂未实现,请稍后实现" },
|
||||
{ status: 501 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Convex Auth error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式(保留兼容)
|
||||
try {
|
||||
const { createSupabaseRouteClient } = await import("@/lib/supabase/server");
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
|
||||
const body = await request.json() as SignInRequest;
|
||||
const { email, password, name, flow } = body;
|
||||
|
||||
if (flow === "signUp") {
|
||||
// 注册
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
name: name ?? "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
return NextResponse.json(
|
||||
{ error: signInError.message },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "注册并登录成功" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
// 登录
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "登录成功" },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Supabase Auth error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
@@ -113,4 +113,3 @@ export async function POST(request: Request) {
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
@@ -46,4 +46,3 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json({ ok: true, block: hit.block });
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
@@ -97,4 +97,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
@@ -62,4 +62,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Payload =
|
||||
| { type: "document"; documentId: string }
|
||||
| { type: "mindmap"; docId: string; mindmapId: string }
|
||||
| { type: "media"; assetId: string };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const body = (await request.json().catch(() => null)) as Payload | null;
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "缺少请求体" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.type === "document") {
|
||||
const documentId = String(body.documentId ?? "").trim();
|
||||
if (!documentId) return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
const result = await client.mutation(api.jobs.enqueueRagIndexDocument, { userId: auth.userId, documentId });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
if (body.type === "mindmap") {
|
||||
const docId = String(body.docId ?? "").trim();
|
||||
const mindmapId = String(body.mindmapId ?? "").trim();
|
||||
if (!docId) return NextResponse.json({ error: "缺少 docId" }, { status: 400 });
|
||||
if (!mindmapId) return NextResponse.json({ error: "缺少 mindmapId" }, { status: 400 });
|
||||
const result = await client.mutation(api.jobs.enqueueRagIndexMindmap, { userId: auth.userId, docId, mindmapId });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
if (body.type === "media") {
|
||||
const assetId = String(body.assetId ?? "").trim();
|
||||
if (!assetId) return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
const result = await client.mutation(api.jobs.enqueueRagIndexMediaAsset, { userId: auth.userId, assetId });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "不支持的 type" }, { status: 400 });
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { api } from "@/lib/convex/api";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { ms }: { ms?: number } = await request.json().catch(() => ({}));
|
||||
@@ -23,7 +23,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -39,4 +39,3 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json(job);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
return NextResponse.json({ ok: true, auth }, { status: 200 });
|
||||
} catch (err) {
|
||||
const status = err instanceof HttpError ? err.status : 500;
|
||||
|
||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const url = new URL(request.url);
|
||||
const documentId = url.searchParams.get("documentId") ?? "";
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
async function handleCreateRequestConvex(request: Request) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
|
||||
@@ -48,7 +48,7 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
|
||||
@@ -14,7 +14,7 @@ interface EmbedPayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ interface EmptyTrashPayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
|
||||
@@ -13,7 +13,7 @@ interface MovePayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
@@ -24,7 +24,7 @@ const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Table
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, options }: OptionsPayload = await request.json();
|
||||
if (!documentId || !options) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
|
||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
|
||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
|
||||
@@ -12,7 +12,7 @@ interface SavePayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, content }: SavePayload = await request.json();
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
|
||||
@@ -13,7 +13,7 @@ interface StatsPayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, stats }: StatsPayload = await request.json();
|
||||
if (!documentId || !stats) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
|
||||
@@ -12,7 +12,7 @@ interface RenamePayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, title }: RenamePayload = await request.json();
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.updateTitle, {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
@@ -11,6 +14,28 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const client = getConvexHttpClient();
|
||||
const result = await client.query(api.tables.getByGridKey, { gridKey });
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
msg: "ok",
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ code: 500, msg: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const { data, error } = await supabase
|
||||
.from("document_tables")
|
||||
|
||||
@@ -4,6 +4,9 @@ import { NextResponse } from "next/server";
|
||||
import { createDefaultTableSnapshot } from "@/lib/online-table";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
const fallbackSchema: TableSchema = {
|
||||
columns: [],
|
||||
@@ -19,6 +22,38 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const client = getConvexHttpClient();
|
||||
const tableData = await client.query(api.tables.getByGridKeyFull, { gridKey });
|
||||
|
||||
if (!tableData) {
|
||||
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
|
||||
}
|
||||
|
||||
const snapshot = (tableData.snapshot as DocumentTableSnapshot | null) ?? null;
|
||||
const schema = (tableData.schema as TableSchema | null) ?? fallbackSchema;
|
||||
|
||||
let payload: unknown[] = [];
|
||||
if (snapshot?.luckysheet && Array.isArray(snapshot.luckysheet)) {
|
||||
payload = snapshot.luckysheet;
|
||||
} else {
|
||||
const defaultSnapshot = createDefaultTableSnapshot(schema);
|
||||
payload = defaultSnapshot.luckysheet ?? [];
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
return new NextResponse(body, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ code: 500, msg: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const { data, error } = await supabase
|
||||
.from("document_tables")
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
@@ -85,7 +85,7 @@ export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function GET(request: Request) {
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
try {
|
||||
requireAuthContext();
|
||||
await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
|
||||
@@ -33,7 +33,7 @@ async function purgeTrashFolder(folder: string): Promise<number> {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function POST(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
||||
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function GET(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const res = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
@@ -171,7 +171,7 @@ export async function POST(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
|
||||
data?: unknown;
|
||||
createOnly?: boolean;
|
||||
@@ -241,7 +241,7 @@ export async function DELETE(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||
userId: auth.userId,
|
||||
@@ -316,7 +316,7 @@ export async function PATCH(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
|
||||
if (action !== "restore" && action !== "purge") {
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function GET(
|
||||
const { docId: id } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const res = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
@@ -106,7 +106,7 @@ export async function POST(
|
||||
const { docId: id } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const { data } = await request.json();
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
@@ -161,7 +161,7 @@ export async function DELETE(
|
||||
const { docId: id } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||
userId: auth.userId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api } from "@/lib/convex/api";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const workspaceId = searchParams.get("workspaceId");
|
||||
const pageId = searchParams.get("pageId");
|
||||
|
||||
@@ -20,7 +20,7 @@ const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inli
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const body = (await request.json()) as RecordReferencePayload;
|
||||
if (!body?.workspaceId || !body?.sourcePageId || !body?.targetPageId) {
|
||||
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||
|
||||
@@ -148,7 +148,7 @@ const fetchOcrMatches = async (
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface RecentPayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { workspaceId, documentId }: RecentPayload = await request.json();
|
||||
|
||||
if (!workspaceId || !documentId) {
|
||||
|
||||
@@ -72,7 +72,7 @@ function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{
|
||||
@@ -35,19 +38,38 @@ const getAuthUser = async () => {
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const tableId = await extractTableId(context);
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const table = await client.query(api.tables.get, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
});
|
||||
|
||||
if (!table) {
|
||||
return NextResponse.json({ error: "Table not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(table, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const { supabase, user } = await getAuthUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 强制 await params,以解决 Next.js 16/Turbopack 错误。
|
||||
const tableId = await extractTableId(context);
|
||||
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 假设 RLS 确保了用户只能访问其有权限的表格
|
||||
const { data: tableData, error } = await supabase
|
||||
@@ -74,17 +96,47 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const tableId = await extractTableId(context);
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const body = await request.json() as UpdateTableRequest;
|
||||
|
||||
const result = await client.mutation(api.tables.update, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
title: body.title,
|
||||
schema: body.schema,
|
||||
view_preferences: body.viewPreferences,
|
||||
snapshot: body.snapshot,
|
||||
rows: body.rows,
|
||||
});
|
||||
|
||||
// 重新获取更新后的表格数据
|
||||
const updated = await client.query(api.tables.get, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const { supabase, user } = await getAuthUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const tableId = await extractTableId(context);
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let body: UpdateTableRequest | null = null;
|
||||
try {
|
||||
body = await request.json() as UpdateTableRequest;
|
||||
@@ -191,17 +243,34 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
const tableId = await extractTableId(context);
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
await client.mutation(api.tables.purge, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const { supabase, user } = await getAuthUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const tableId = await extractTableId(context);
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: deleteRowsError } = await supabase
|
||||
.from("document_table_rows")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
// 定义请求体类型
|
||||
interface CreateTableRequestBody {
|
||||
@@ -11,6 +14,46 @@ interface CreateTableRequestBody {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
try {
|
||||
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
|
||||
|
||||
if (!documentId || !title || !schema) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取 document 所在的 workspace_id
|
||||
const document = await client.query(api.documents.getMeta, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 创建表格
|
||||
const result = await client.mutation(api.tables.create, {
|
||||
userId: auth.userId,
|
||||
workspaceId: document.workspace_id,
|
||||
documentId,
|
||||
title,
|
||||
schema,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const workspaceId = payload.workspaceId as string | undefined;
|
||||
if (!workspaceId) {
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { SupabaseProvider } from "@/components/providers/supabase-provider";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ConvexClientProvider } from "@/components/providers/convex-provider";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -51,7 +53,11 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
{useConvex ? (
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<ConvexClientProvider>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</ConvexClientProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
) : (
|
||||
<SupabaseProvider session={session}>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
|
||||
@@ -14,7 +14,7 @@ function makeId(): string {
|
||||
|
||||
export default async function Home() {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState } from "react";
|
||||
import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
|
||||
interface ConvexClientProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convex 客户端 Provider(包含 Auth)
|
||||
*/
|
||||
export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
|
||||
const [convex] = useState(() => {
|
||||
// 动态获取 Convex URL,默认优先使用环境变量。
|
||||
// 说明:浏览器侧优先用当前 hostname(端口固定 3210),避免通过非 localhost/127 访问前端时出现跨域/白名单不匹配。
|
||||
const getConvexUrl = () => {
|
||||
// 默认使用环境变量
|
||||
const envUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
if (typeof window === "undefined") {
|
||||
return envUrl || "http://127.0.0.1:3210";
|
||||
}
|
||||
// 在浏览器中,使用当前主机名,但端口改为 3210
|
||||
const hostname = window.location.hostname;
|
||||
return `http://${hostname}:3210`;
|
||||
};
|
||||
|
||||
const convexUrl = getConvexUrl();
|
||||
if (!convexUrl) {
|
||||
throw new Error("NEXT_PUBLIC_CONVEX_URL is not defined");
|
||||
}
|
||||
return new ConvexReactClient(convexUrl);
|
||||
});
|
||||
|
||||
return (
|
||||
<ConvexAuthNextjsProvider client={convex}>
|
||||
{children}
|
||||
</ConvexAuthNextjsProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AuthContext } from "@/lib/auth/types";
|
||||
import { getDevUser, isDevAuthEnabled } from "@/lib/auth/devUser";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
@@ -11,17 +15,79 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthContext(): AuthContext {
|
||||
if (isDevAuthEnabled()) return getDevUser();
|
||||
// 说明:后续接入真实鉴权时,在这里替换为 Supabase/Convex Auth 的校验逻辑。
|
||||
/**
|
||||
* 获取认证上下文
|
||||
*
|
||||
* 优先级:
|
||||
* 1. Convex Auth(如果启用)- 服务端:从 Convex Auth cookies 获取并查询当前用户;客户端:从 hooks 获取
|
||||
* 2. 开发用户(如果启用)
|
||||
* 3. 抛出错误(未配置)
|
||||
*/
|
||||
export async function getAuthContext(): Promise<AuthContext> {
|
||||
// 1. 检查是否启用 Convex Auth
|
||||
if (isConvexEnabled()) {
|
||||
// 说明:开发模式下允许使用固定用户快速跑通链路(例如迁移测试)。
|
||||
if (isDevAuthEnabled()) {
|
||||
return getDevUser();
|
||||
}
|
||||
|
||||
const token = await convexAuthNextjsToken();
|
||||
if (!token) {
|
||||
throw new Error("未登录,请先登录");
|
||||
}
|
||||
|
||||
// 说明:从 Convex Auth token 获取当前用户(users 表由 authTables 提供)。
|
||||
// 这里不强依赖具体字段结构,避免升级 authTables 后类型不兼容。
|
||||
const user = (await fetchQuery(api.users.currentUser, {}, { token })) as unknown;
|
||||
if (!user || typeof user !== "object") {
|
||||
throw new Error("未登录,请先登录");
|
||||
}
|
||||
|
||||
const record = user as Record<string, unknown>;
|
||||
const id = record["_id"];
|
||||
const email = record["email"];
|
||||
const name = record["name"];
|
||||
|
||||
if (typeof id !== "string" || !id) {
|
||||
throw new Error("未登录,请先登录");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: id,
|
||||
email: typeof email === "string" ? email : undefined,
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查是否启用开发用户模式
|
||||
if (isDevAuthEnabled()) {
|
||||
return getDevUser();
|
||||
}
|
||||
|
||||
// 3. 未配置认证
|
||||
throw new Error("Auth is not configured");
|
||||
}
|
||||
|
||||
export function requireAuthContext(): AuthContext {
|
||||
/**
|
||||
* 要求已认证的上下文
|
||||
*
|
||||
* 如果未认证,抛出 401 错误
|
||||
*/
|
||||
export async function requireAuthContext(): Promise<AuthContext> {
|
||||
try {
|
||||
return getAuthContext();
|
||||
return await getAuthContext();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unauthorized";
|
||||
throw new HttpError(401, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户 ID
|
||||
*
|
||||
* 用于服务端组件和 API 路由
|
||||
*/
|
||||
export async function getCurrentUserId(): Promise<string> {
|
||||
const auth = await requireAuthContext();
|
||||
return auth.userId;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function isConvexEnabled(): boolean {
|
||||
return process.env.USE_CONVEX === "1";
|
||||
return process.env.NEXT_PUBLIC_USE_CONVEX === "1";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import type { AuthContext } from "@/lib/auth/types";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export function getAuthedConvexClient(): { auth: AuthContext; client: ConvexHttpClient } {
|
||||
const auth = requireAuthContext();
|
||||
export async function getAuthedConvexClient(): Promise<{ auth: AuthContext; client: ConvexHttpClient }> {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
return { auth, client };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ export function getConvexHttpClient(): ConvexHttpClient {
|
||||
const client = new ConvexHttpClient(url);
|
||||
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
|
||||
if (adminKey) {
|
||||
client.setAdminAuth(adminKey);
|
||||
// 说明:ConvexHttpClient 的类型声明可能未暴露 setAdminAuth(但运行期存在)。
|
||||
// 这里用最小侵入方式兼容自托管管理密钥。
|
||||
(client as any).setAdminAuth(adminKey);
|
||||
}
|
||||
|
||||
cached = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirect } from "@convex-dev/auth/nextjs/server";
|
||||
|
||||
// 说明:
|
||||
// - 这里启用 Convex Auth 的 Next.js 中间件,负责:
|
||||
// 1) 代理 /api/auth 到 Convex(用于 signIn / signOut 等动作)
|
||||
// 2) 刷新/同步 auth cookies
|
||||
// 3) 对需要登录的页面做统一拦截
|
||||
// - 默认将未登录用户重定向到 /auth(Convex Auth 登录页)。
|
||||
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
"/auth",
|
||||
"/login",
|
||||
"/api/auth(.*)",
|
||||
"/api/health(.*)",
|
||||
"/_next(.*)",
|
||||
"/favicon.ico",
|
||||
]);
|
||||
|
||||
export default convexAuthNextjsMiddleware(async (request, ctx) => {
|
||||
// 公共路由不做拦截(但 middleware 仍会处理 token 刷新/代理等)。
|
||||
if (isPublicRoute(request)) return;
|
||||
|
||||
// 只在启用 Convex 模式时做鉴权拦截;否则走 Supabase 逻辑(页面内部自行处理)。
|
||||
// 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
|
||||
if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return;
|
||||
|
||||
const authed = await ctx.convexAuth.isAuthenticated();
|
||||
if (!authed) {
|
||||
return nextjsMiddlewareRedirect(request, "/auth");
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
// 说明:排除静态资源,避免无意义的中间件开销。
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user