diff --git a/.memory/worklog.json b/.memory/worklog.json index 19802e3..28071e9 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,11 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record subject pack deployment", - "ts": "2026-05-20T02:53:56Z", - "type": "session-heartbeat" - }, { "files_changed": 1, "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record subject pack deployment", @@ -3225,6 +3219,13 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record Feishu OAuth enablement", "files_changed": 1 + }, + { + "ts": "2026-05-26T00:07:48+08:00", + "type": "commit", + "message": "auto-save 2026-05-26 00:07 (+1, ~3)", + "hash": "089a30d", + "files_changed": 4 } ] } diff --git a/api/db.py b/api/db.py index 8233580..f7a3dd1 100644 --- a/api/db.py +++ b/api/db.py @@ -417,14 +417,20 @@ def upsert_canvas_project(user: dict, project: dict) -> dict | None: ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s) ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - thumbnail = EXCLUDED.thumbnail, + name = CASE + WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.name + ELSE canvas_projects.name + END, + thumbnail = CASE + WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.thumbnail + ELSE canvas_projects.thumbnail + END, visibility = CASE - WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN EXCLUDED.visibility + WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.visibility ELSE canvas_projects.visibility END, canvas_data = CASE - WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN EXCLUDED.canvas_data + WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.canvas_data ELSE canvas_projects.canvas_data END, updated_at = CASE @@ -432,11 +438,11 @@ def upsert_canvas_project(user: dict, project: dict) -> dict | None: ELSE canvas_projects.updated_at END, version = CASE - WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN canvas_projects.version + 1 + WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN canvas_projects.version + 1 ELSE canvas_projects.version END, deleted_at = CASE - WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN NULL + WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN NULL ELSE canvas_projects.deleted_at END RETURNING id, name, thumbnail, visibility, canvas_data, created_at, updated_at, version, owner_id diff --git a/api/main.py b/api/main.py index f4209a1..cccb486 100644 --- a/api/main.py +++ b/api/main.py @@ -1544,6 +1544,7 @@ def _write_prompt_item(item: PromptLibraryItem) -> None: PROMPT_LIBRARY_ITEMS_DIR.mkdir(parents=True, exist_ok=True) _prompt_item_file(item).write_text(item.model_dump_json(indent=2), encoding="utf-8") _write_prompt_library_index() + db.index_prompt_item(item.model_dump()) def _write_asset_item(item: AssetLibraryItem) -> None: @@ -1551,6 +1552,7 @@ def _write_asset_item(item: AssetLibraryItem) -> None: p.parent.mkdir(parents=True, exist_ok=True) p.write_text(item.model_dump_json(indent=2), encoding="utf-8") _write_asset_library_index() + db.index_asset_item(item.model_dump()) def _read_prompt_item(path: Path) -> PromptLibraryItem | None: @@ -1921,6 +1923,7 @@ def cancel_queued_video_task(job_id: str, video_id: str) -> bool: @asynccontextmanager async def lifespan(_: FastAPI): + db_ready = db.init_schema() try: _rebuild_library_index() except Exception as e: @@ -2000,6 +2003,16 @@ async def lifespan(_: FastAPI): JOBS[p.name] = job except Exception: pass + if db_ready: + for job in JOBS.values(): + db.index_job(job.model_dump(), str(job_dir(job.id) / "state.json")) + try: + for item in load_prompt_library_items(): + db.index_prompt_item(item.model_dump()) + for item in load_asset_library_items(): + db.index_asset_item(item.model_dump()) + except Exception as e: + print(f"[db] initial library sync failed: {e}", flush=True) yield @@ -2057,11 +2070,12 @@ def auth_me(request: Request) -> dict: session = auth_session_from_request(request) if not session: raise HTTPException(401, "unauthorized") + db.upsert_user(session, request) return {"ok": True, "user": session} @app.post("/auth/login") -def auth_login(payload: AuthLoginPayload, response: Response) -> dict: +def auth_login(payload: AuthLoginPayload, request: Request, response: Response) -> dict: ensure_password_auth_configured() username = payload.username.strip() password = payload.password @@ -2080,6 +2094,9 @@ def auth_login(payload: AuthLoginPayload, response: Response) -> dict: samesite="lax", path="/", ) + session = _public_session({"u": WEB_AUTH_USERNAME, "name": WEB_AUTH_USERNAME, "provider": "password", "uid": f"password:{WEB_AUTH_USERNAME}"}) + db.upsert_user(session, request) + db.audit(session, "login.password", "user", session["uid"], request=request) return {"ok": True, "username": WEB_AUTH_USERNAME} @@ -2122,6 +2139,8 @@ def auth_feishu_callback(request: Request) -> RedirectResponse: access_token = _exchange_feishu_code(code, _feishu_redirect_uri(request)) session = _build_feishu_session(_fetch_feishu_user(access_token)) _validate_feishu_session(session) + db.upsert_user(session, request) + db.audit(session, "login.feishu", "user", session["uid"], request=request) ttl_seconds = 60 * 60 * 24 * 30 response = RedirectResponse(_normalize_next_url(str(state_payload.get("next") or "/")), status_code=302) @@ -2154,6 +2173,128 @@ def auth_logout(response: Response) -> dict: return {"ok": True} +class CanvasProjectWriteReq(BaseModel): + id: str = "" + name: str = "未命名项目" + thumbnail: str = "" + visibility: Literal["private", "team", "company"] = "private" + canvas_data: dict = Field(default_factory=dict) + created_at: float = 0.0 + updated_at: float = 0.0 + source: str = "canvas" + + +class CanvasProjectImportReq(BaseModel): + projects: list[CanvasProjectWriteReq] = Field(default_factory=list) + + +def _ts(value) -> float: + if hasattr(value, "timestamp"): + return float(value.timestamp()) + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def _require_db() -> None: + if not db.enabled(): + raise HTTPException(503, "database not configured") + + +def _canvas_project_public(row: dict) -> dict: + return { + "id": str(row.get("id") or ""), + "name": str(row.get("name") or ""), + "thumbnail": str(row.get("thumbnail") or ""), + "visibility": str(row.get("visibility") or "private"), + "canvas_data": row.get("canvas_data") or {}, + "created_at": _ts(row.get("created_at")), + "updated_at": _ts(row.get("updated_at")), + "version": int(row.get("version") or 1), + "owner_id": str(row.get("owner_id") or ""), + "owner_name": str(row.get("owner_name") or ""), + "owner_email": str(row.get("owner_email") or ""), + "owner_provider": str(row.get("owner_provider") or ""), + } + + +@app.get("/canvas-projects") +def list_canvas_projects(request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + db.upsert_user(user, request) + return { + "ok": True, + "items": [_canvas_project_public(row) for row in db.list_canvas_projects(user)], + } + + +@app.post("/canvas-projects") +def create_canvas_project(req: CanvasProjectWriteReq, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + db.upsert_user(user, request) + row = db.upsert_canvas_project(user, req.model_dump()) + if not row: + raise HTTPException(500, "canvas project save failed") + db.audit(user, "canvas_project.create", "canvas_project", str(row.get("id") or ""), req.model_dump(exclude={"canvas_data"}), request, str(row.get("visibility") or "private")) + return {"ok": True, "item": _canvas_project_public(row)} + + +@app.put("/canvas-projects/{project_id}") +def put_canvas_project(project_id: str, req: CanvasProjectWriteReq, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + db.upsert_user(user, request) + payload = req.model_dump() + payload["id"] = project_id + row = db.upsert_canvas_project(user, payload) + if not row: + raise HTTPException(500, "canvas project save failed") + if str(row.get("owner_id") or "") != _session_user_id(user): + raise HTTPException(403, "canvas project belongs to another user") + db.audit(user, "canvas_project.save", "canvas_project", project_id, {"name": req.name, "visibility": req.visibility}, request, str(row.get("visibility") or "private")) + return {"ok": True, "item": _canvas_project_public(row)} + + +@app.get("/canvas-projects/{project_id}") +def get_canvas_project(project_id: str, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + row = db.get_canvas_project(project_id, user) + if not row: + raise HTTPException(404, "canvas project not found") + return {"ok": True, "item": _canvas_project_public(row)} + + +@app.delete("/canvas-projects/{project_id}") +def delete_canvas_project(project_id: str, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + ok = db.soft_delete_canvas_project(user, project_id) + if not ok: + raise HTTPException(404, "canvas project not found") + db.audit(user, "canvas_project.delete", "canvas_project", project_id, request=request) + return {"ok": True, "id": project_id} + + +@app.post("/canvas-projects/import") +def import_canvas_projects(req: CanvasProjectImportReq, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + db.upsert_user(user, request) + imported = [] + for item in req.projects[:200]: + payload = item.model_dump() + payload["source"] = "localStorage" + row = db.upsert_canvas_project(user, payload) + if row: + imported.append(_canvas_project_public(row)) + db.audit(user, "canvas_project.import", "canvas_project", "", {"count": len(imported)}, request) + return {"ok": True, "items": imported} + + def _parse_library_metadata(raw: str) -> dict: if not raw.strip(): return {} @@ -2209,7 +2350,8 @@ def get_prompt_library_item(item_id: str) -> PromptLibraryItem: @app.post("/prompt-library", response_model=PromptLibraryItem) -def create_prompt_library_item(req: PromptLibraryWriteReq) -> PromptLibraryItem: +def create_prompt_library_item(req: PromptLibraryWriteReq, request: Request) -> PromptLibraryItem: + user = data_user_from_request(request) now = _now_ts() name = req.name.strip() prompt_en = _ensure_english(req.prompt_en.strip()) if req.prompt_en.strip() else "" @@ -2229,11 +2371,13 @@ def create_prompt_library_item(req: PromptLibraryWriteReq) -> PromptLibraryItem: updated_at=now, ) _write_prompt_item(item) + db.audit(user, "prompt_library.create", "prompt", item.id, {"category": item.category, "name": item.name}, request, "company") return item @app.patch("/prompt-library/{item_id}", response_model=PromptLibraryItem) -def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq) -> PromptLibraryItem: +def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq, request: Request) -> PromptLibraryItem: + user = data_user_from_request(request) item = find_prompt_library_item(item_id) data = item.model_dump() patch = req.model_dump(exclude_unset=True) @@ -2249,26 +2393,31 @@ def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq) -> Promp if not updated.name.strip(): raise HTTPException(400, "prompt name required") _write_prompt_item(updated) + db.audit(user, "prompt_library.update", "prompt", item_id, {"fields": sorted(patch.keys())}, request, "company") return updated @app.delete("/prompt-library/{item_id}") -def delete_prompt_library_item(item_id: str) -> dict: +def delete_prompt_library_item(item_id: str, request: Request) -> dict: + user = data_user_from_request(request) item = find_prompt_library_item(item_id) src = _prompt_item_file(item) trash = LIBRARY_TRASH_DIR / "prompt_library" / f"{item.id}_{int(_now_ts())}.json" trash.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(trash)) _write_prompt_library_index() + db.audit(user, "prompt_library.delete", "prompt", item.id, request=request, visibility="company") return {"ok": True, "id": item.id, "trashed": str(trash)} @app.post("/prompt-library/{item_id}/use", response_model=PromptLibraryItem) -def use_prompt_library_item(item_id: str) -> PromptLibraryItem: +def use_prompt_library_item(item_id: str, request: Request) -> PromptLibraryItem: + user = data_user_from_request(request) item = find_prompt_library_item(item_id) item.use_count += 1 item.updated_at = _now_ts() _write_prompt_item(item) + db.audit(user, "prompt_library.use", "prompt", item.id, request=request, visibility="company") return item @@ -2288,9 +2437,11 @@ def get_asset_library_item(kind: AssetLibraryKind, item_id: str) -> AssetLibrary @app.post("/asset-library/{kind}", response_model=AssetLibraryItem) async def create_asset_library_item( kind: AssetLibraryKind, + request: Request, metadata: str = Form("{}"), files: list[UploadFile] = File(default=[]), ) -> AssetLibraryItem: + user = data_user_from_request(request) meta = _parse_library_metadata(metadata) if not files: raise HTTPException(400, "at least one file required") @@ -2363,11 +2514,13 @@ async def create_asset_library_item( raise HTTPException(400, "video file required") _hydrate_asset_library_urls(item) _write_asset_item(item) + db.audit(user, "asset_library.create", "asset_library", item.id, {"kind": kind, "name": item.name}, request, "company") return item @app.patch("/asset-library/{kind}/{item_id}", response_model=AssetLibraryItem) -def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLibraryPatchReq) -> AssetLibraryItem: +def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLibraryPatchReq, request: Request) -> AssetLibraryItem: + user = data_user_from_request(request) item = find_asset_library_item(kind, item_id) data = item.model_dump() patch = req.model_dump(exclude_unset=True) @@ -2378,6 +2531,7 @@ def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLib updated = AssetLibraryItem(**data) _hydrate_asset_library_urls(updated) _write_asset_item(updated) + db.audit(user, "asset_library.update", "asset_library", item_id, {"kind": kind, "fields": sorted(patch.keys())}, request, "company") return updated @@ -2388,7 +2542,8 @@ def asset_library_refs(kind: AssetLibraryKind, item_id: str) -> dict: @app.delete("/asset-library/{kind}/{item_id}") -def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, force: bool = False) -> dict: +def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, request: Request, force: bool = False) -> dict: + user = data_user_from_request(request) item = find_asset_library_item(kind, item_id) refs = _library_ref_usage(kind, item_id) if refs["count"] and not force: @@ -2398,11 +2553,14 @@ def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, force: bool trash.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(trash)) _write_asset_library_index() + db.audit(user, "asset_library.delete", "asset_library", item.id, {"kind": kind, "refs": refs}, request, "company") return {"ok": True, "id": item.id, "refs": refs, "trashed": str(trash)} @app.post("/asset-library/{kind}/{item_id}/copy-to-job/{job_id}") -def copy_asset_library_to_job(kind: AssetLibraryKind, item_id: str, job_id: str) -> dict: +def copy_asset_library_to_job(kind: AssetLibraryKind, item_id: str, job_id: str, request: Request) -> dict: + user = data_user_from_request(request) + db.audit(user, "asset_library.copy_to_job", "asset_library", item_id, {"kind": kind, "job_id": job_id}, request, "company") return _copy_library_to_job(kind, item_id, job_id) @@ -5455,6 +5613,7 @@ def health() -> dict: "feishu": FEISHU_AUTH_CONFIGURED, "data_isolation": AUTH_DATA_ISOLATION_ENABLED, }, + "database": db.health(), "base_url": LLM_BASE_URL or "openai-default", "asr_base_url": ASR_BASE_URL or LLM_BASE_URL or "openai-default", "image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default", @@ -5571,6 +5730,7 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks, request: Request) - assign_owner(job, user) JOBS[job_id] = job save_state(job) + db.audit(user, "job.create", "job", job_id, {"url": job.url}, request) bg.add_task(pipeline_download, job_id) return job @@ -5623,6 +5783,7 @@ async def create_job_from_upload(bg: BackgroundTasks, request: Request, file: Up assign_owner(job, user) JOBS[job_id] = job save_state(job) + db.audit(user, "job.upload", "job", job_id, {"filename": file.filename}, request) bg.add_task(pipeline_download, job_id) return job @@ -5688,6 +5849,7 @@ async def create_creative_image_job(request: Request) -> Job: assign_owner(job, user) JOBS[job_id] = job save_state(job) + db.audit(user, "creative_job.create", "job", job_id, {"source": source_label}, request) return job @@ -9139,6 +9301,7 @@ def save_agent_run(run: AgentRun) -> None: d.mkdir(parents=True, exist_ok=True) agent_run_path(run.id).write_text(run.model_dump_json(indent=2), encoding="utf-8") AGENT_RUNS[run.id] = run + db.index_agent_run(run.model_dump()) def agent_log( @@ -9462,6 +9625,7 @@ async def create_agent_run( assign_owner(run, user) save_agent_run(run) agent_log(run, f"任务已入队 · job={job_id} · 产品图 {len(refs)} 张", status="queued", stage="queued", progress=1) + db.audit(user, "agent_run.create", "agent_run", run_id, {"job_id": job_id, "product_refs": len(refs)}, request) threading.Thread(target=agent_run_worker, args=(run_id, refs), daemon=True).start() return run diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 2ec829c..9394e77 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -9,6 +9,12 @@ KEYFRAME_COUNT=12 CORS_ORIGINS=https://marketing.skg.com API_PORT=4291 +# Company persistence database. Real password and DATABASE_URL live only on server. +POSTGRES_DB=skg_marketing +POSTGRES_USER=skg_marketing +POSTGRES_PASSWORD= +DATABASE_URL=postgresql://skg_marketing:CHANGE_ME@postgres:5432/skg_marketing + # Web login. Keep real password and session secret only on the server. WEB_AUTH_USERNAME=skg WEB_AUTH_PASSWORD= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index be3b917..8d7d975 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,24 @@ name: skg-marketing-studio services: + postgres: + image: postgres:16-alpine + container_name: skg-marketing-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-skg_marketing} + POSTGRES_USER: ${POSTGRES_USER:-skg_marketing} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - ./data/postgres:/var/lib/postgresql/data + restart: unless-stopped + networks: + - skg-marketing-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"] + interval: 10s + timeout: 5s + retries: 10 + api: build: context: . @@ -13,6 +31,7 @@ services: AGENT_RUNS_DIR: /data/agent_runs ASSET_LIBRARY_DIR: /data/asset_library PROMPT_LIBRARY_DIR: /data/prompt_library + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} CORS_ORIGINS: https://marketing.skg.com volumes: - ./data/jobs:/data/jobs @@ -22,6 +41,9 @@ services: - ./data/_trash:/data/_trash - ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt restart: unless-stopped + depends_on: + postgres: + condition: service_healthy networks: - skg-marketing-internal diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml index e5b4dd5..d4e6756 100644 --- a/docker-compose.standalone.yml +++ b/docker-compose.standalone.yml @@ -1,6 +1,24 @@ name: skg-agent-cut services: + postgres: + image: postgres:16-alpine + container_name: skg-agent-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-skg_marketing} + POSTGRES_USER: ${POSTGRES_USER:-skg_marketing} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - ./data/postgres:/var/lib/postgresql/data + restart: unless-stopped + networks: + - skg-agent-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"] + interval: 10s + timeout: 5s + retries: 10 + api: build: context: . @@ -13,6 +31,7 @@ services: AGENT_RUNS_DIR: /data/agent_runs ASSET_LIBRARY_DIR: /data/asset_library PROMPT_LIBRARY_DIR: /data/prompt_library + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290 volumes: - ./data/jobs:/data/jobs @@ -22,6 +41,9 @@ services: - ./data/_trash:/data/_trash - ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt restart: unless-stopped + depends_on: + postgres: + condition: service_healthy networks: skg-agent-internal: aliases: diff --git a/web/canvas-app/src/stores/projects.js b/web/canvas-app/src/stores/projects.js index 5072d13..f1580d5 100644 --- a/web/canvas-app/src/stores/projects.js +++ b/web/canvas-app/src/stores/projects.js @@ -16,11 +16,93 @@ export const projects = ref([]) // Current project ID | 当前项目ID export const currentProjectId = ref(null) +export const projectSyncStatus = ref('idle') +export const projectSyncError = ref('') + +const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api' +const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}` +const remoteSaveTimers = new Map() +let initPromise = null +let remoteAvailable = false + // Current project | 当前项目 export const currentProject = computed(() => { return projects.value.find(p => p.id === currentProjectId.value) || null }) +const dateToSeconds = (value) => { + if (value instanceof Date) return value.getTime() / 1000 + const parsed = new Date(value) + return Number.isFinite(parsed.getTime()) ? parsed.getTime() / 1000 : Date.now() / 1000 +} + +const secondsToDate = (value) => { + if (value instanceof Date) return value + const num = Number(value || 0) + return new Date(num > 100000000000 ? num : num * 1000) +} + +const projectFromApi = (item) => ({ + id: item.id, + name: item.name || '未命名项目', + thumbnail: item.thumbnail || '', + visibility: item.visibility || 'private', + ownerId: item.owner_id || '', + ownerName: item.owner_name || '', + ownerEmail: item.owner_email || '', + ownerProvider: item.owner_provider || '', + version: item.version || 1, + createdAt: secondsToDate(item.created_at), + updatedAt: secondsToDate(item.updated_at), + canvasData: item.canvas_data || { + nodes: [], + edges: [], + viewport: { x: 100, y: 50, zoom: 0.8 } + } +}) + +const projectToApi = (project) => ({ + id: project.id, + name: project.name || '未命名项目', + thumbnail: project.thumbnail || '', + visibility: project.visibility || 'private', + canvas_data: cleanProjectForStorage(project).canvasData || { + nodes: [], + edges: [], + viewport: { x: 100, y: 50, zoom: 0.8 } + }, + created_at: dateToSeconds(project.createdAt), + updated_at: dateToSeconds(project.updatedAt), + source: 'canvas' +}) + +const requestJson = async (path, init = {}) => { + const response = await fetch(apiUrl(path), { + ...init, + headers: { + ...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...(init.headers || {}) + } + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(text || `${path} ${response.status}`) + } + return response.json() +} + +const mergeProjectLists = (localItems, remoteItems) => { + const byId = new Map() + for (const item of remoteItems) byId.set(item.id, item) + for (const item of localItems) { + const existing = byId.get(item.id) + if (!existing || dateToSeconds(item.updatedAt) > dateToSeconds(existing.updatedAt)) { + byId.set(item.id, item) + } + } + return [...byId.values()].sort((a, b) => dateToSeconds(b.updatedAt) - dateToSeconds(a.updatedAt)) +} + /** * Load projects from localStorage | 从 localStorage 加载项目 */ @@ -42,6 +124,69 @@ export const loadProjects = () => { } } +const saveRemoteProjectNow = async (project) => { + if (!project?.id) return null + const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, { + method: 'PUT', + body: JSON.stringify(projectToApi(project)) + }) + return response.item ? projectFromApi(response.item) : null +} + +const scheduleRemoteSave = (project, delay = 800) => { + if (!remoteAvailable || !project?.id) return + if (remoteSaveTimers.has(project.id)) { + clearTimeout(remoteSaveTimers.get(project.id)) + } + remoteSaveTimers.set(project.id, setTimeout(async () => { + remoteSaveTimers.delete(project.id) + try { + projectSyncStatus.value = 'syncing' + await saveRemoteProjectNow(project) + projectSyncStatus.value = 'synced' + projectSyncError.value = '' + } catch (err) { + projectSyncStatus.value = 'error' + projectSyncError.value = err.message || '项目同步失败' + console.warn('Failed to sync project:', err) + } + }, delay)) +} + +const importLocalProjectsToServer = async (localItems) => { + if (!localItems.length) return [] + const payload = { projects: localItems.map(projectToApi) } + const response = await requestJson('/canvas-projects/import', { + method: 'POST', + body: JSON.stringify(payload) + }) + return (response.items || []).map(projectFromApi) +} + +export const loadRemoteProjects = async () => { + try { + projectSyncStatus.value = 'syncing' + const localItems = [...projects.value] + const response = await requestJson('/canvas-projects') + remoteAvailable = true + const remoteItems = (response.items || []).map(projectFromApi) + const missingLocal = localItems.filter(local => !remoteItems.some(remote => remote.id === local.id)) + const importedItems = await importLocalProjectsToServer(missingLocal) + const merged = mergeProjectLists(localItems, [...remoteItems, ...importedItems]) + projects.value = merged + saveProjects({ remote: false }) + projectSyncStatus.value = 'synced' + projectSyncError.value = '' + return merged + } catch (err) { + remoteAvailable = false + projectSyncStatus.value = 'error' + projectSyncError.value = err.message || '项目同步失败' + console.warn('Remote project sync unavailable:', err) + return projects.value + } +} + /** * Clean node data for storage | 清理节点数据用于存储 * Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小 @@ -89,7 +234,7 @@ const cleanProjectForStorage = (project) => { * Save projects to localStorage | 保存项目到 localStorage * Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误 */ -export const saveProjects = () => { +export const saveProjects = ({ remote = false } = {}) => { // Always clean data before saving | 保存前始终清理数据 const cleanedProjects = projects.value.map(cleanProjectForStorage) @@ -128,6 +273,10 @@ export const saveProjects = () => { console.error('Failed to save projects:', err) } } + + if (remote) { + for (const project of projects.value) scheduleRemoteSave(project) + } } /** @@ -155,6 +304,7 @@ export const createProject = (name = '未命名项目') => { projects.value = [newProject, ...projects.value] saveProjects() + scheduleRemoteSave(newProject, 0) return id } @@ -179,6 +329,7 @@ export const updateProject = (id, data) => { projects.value = [updated, ...projects.value] saveProjects() + scheduleRemoteSave(updated) return true } @@ -239,6 +390,10 @@ export const getProjectCanvas = (id) => { export const deleteProject = (id) => { projects.value = projects.value.filter(p => p.id !== id) saveProjects() + if (remoteAvailable) { + requestJson(`/canvas-projects/${encodeURIComponent(id)}`, { method: 'DELETE' }) + .catch(err => console.warn('Failed to delete remote project:', err)) + } } /** @@ -263,6 +418,7 @@ export const duplicateProject = (id) => { projects.value = [newProject, ...projects.value] saveProjects() + scheduleRemoteSave(newProject, 0) return newId } @@ -320,51 +476,14 @@ export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => { /** * Initialize projects store | 初始化项目存储 */ -export const initProjectsStore = () => { +export const initProjectsStore = async () => { + if (initPromise) return initPromise + initPromise = (async () => { loadProjects() - - // Create sample project if empty | 如果为空则创建示例项目 - if (projects.value.length === 0) { - const id = createProject('示例项目') - const project = projects.value.find(p => p.id === id) - if (project) { - project.canvasData = { - nodes: [ - { - id: 'node_0', - type: 'text', - position: { x: 150, y: 150 }, - data: { - content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。', - label: '文本输入' - } - }, - { - id: 'node_1', - type: 'imageConfig', - position: { x: 500, y: 150 }, - data: { - prompt: '', - model: 'auto', - size: '1024x1024', - label: '文生图' - } - } - ], - edges: [ - { - id: 'edge_node_0_node_1', - source: 'node_0', - target: 'node_1', - sourceHandle: 'right', - targetHandle: 'left' - } - ], - viewport: { x: 100, y: 50, zoom: 0.8 } - } - saveProjects() - } - } + await loadRemoteProjects() + return projects.value + })() + return initPromise } // Export for debugging | 导出用于调试 diff --git a/web/canvas-app/src/views/Home.vue b/web/canvas-app/src/views/Home.vue index 3765ef4..921f925 100644 --- a/web/canvas-app/src/views/Home.vue +++ b/web/canvas-app/src/views/Home.vue @@ -415,7 +415,7 @@ const scrollToProjects = () => { } // Initialize projects store on mount | 挂载时初始化项目存储 -onMounted(() => { - initProjectsStore() +onMounted(async () => { + await initProjectsStore() })