From 960bababc58ad5ae3779d24eca89cc468b151fcb Mon Sep 17 00:00:00 2001 From: momen Date: Mon, 18 May 2026 19:53:16 +0800 Subject: [PATCH] docs(account): add account collections implementation plans --- ...05-18-spark-backend-account-collections.md | 1368 ++++++++ ...-05-18-spark-client-account-collections.md | 3109 +++++++++++++++++ 2 files changed, 4477 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-spark-backend-account-collections.md create mode 100644 docs/superpowers/plans/2026-05-18-spark-client-account-collections.md diff --git a/docs/superpowers/plans/2026-05-18-spark-backend-account-collections.md b/docs/superpowers/plans/2026-05-18-spark-backend-account-collections.md new file mode 100644 index 00000000..a2e38c52 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-backend-account-collections.md @@ -0,0 +1,1368 @@ +# Spark Backend Account Collections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `spark-unionid-server` with forum-level profile data, favorite folders/items, downloaded-app records, API docs, deployment docs, and verification. + +**Architecture:** Keep the current FastAPI + SQLAlchemy stack. Add focused ORM models and route modules for favorites/downloaded records, keep existing auth and app-list behavior compatible, and expose only authenticated user-owned resources through `/me/*` endpoints. + +**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, Alembic, PyMySQL/MySQL, Pydantic, httpx, pytest. + +--- + +## File Structure + +Backend repository: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend`. + +Modify: +- `app/models/user.py` - add forum level fields and relationships. +- `app/models/__init__.py` - keep unchanged unless the repository later starts importing models from the package root. +- `app/db/base.py` - import new models for metadata. +- `alembic/versions/0001_initial.py` - update initial schema with new columns/tables because the repo has not shipped production migrations yet. +- `app/services/flarum.py` - extract forum groups/level from Flarum actor payload. +- `app/schemas/auth.py` - return forum fields from `/auth/flarum` and `/me`. +- `app/api/routes/auth.py` - store forum fields during auth upsert. +- `app/api/router.py` - include new routes. +- `docs/api.md` - document new endpoints. +- `docs/deployment.md` - mention upgraded migration/deploy flow remains unchanged. + +Create: +- `app/models/favorite.py` - favorite folder and item ORM models. +- `app/models/downloaded_app.py` - downloaded record ORM model. +- `app/schemas/favorite.py` - favorite request/response schemas. +- `app/schemas/downloaded_app.py` - downloaded app schemas. +- `app/api/routes/favorites.py` - favorite folder/item endpoints. +- `app/api/routes/downloaded_apps.py` - downloaded app endpoints. +- `tests/test_favorites.py` - favorite behavior tests. +- `tests/test_downloaded_apps.py` - downloaded record tests. + +## Task 1: Add Account Data Models And Migration + +**Files:** +- Modify: `app/models/user.py` +- Modify: `app/db/base.py` +- Modify: `alembic/versions/0001_initial.py` +- Create: `app/models/favorite.py` +- Create: `app/models/downloaded_app.py` +- Test: `tests/test_account_models.py` + +- [ ] **Step 1: Write failing model tests** + +Create `tests/test_account_models.py` with: + +```python +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.downloaded_app import DownloadedApp +from app.models.favorite import FavoriteFolder, FavoriteItem +from app.models.user import User + + +def test_favorite_folder_cascades_items_and_enforces_user_scope(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + user = User(flarum_user_id="123", username="momen") + folder = FavoriteFolder(user=user, name="默认收藏夹") + folder.items.append( + FavoriteItem( + app_key="app:office:wps", + pkgname="wps", + name="WPS", + category="office", + icon_url="https://example.invalid/wps.png", + ) + ) + session.add(folder) + session.commit() + + assert session.query(FavoriteFolder).one().name == "默认收藏夹" + assert session.query(FavoriteItem).one().app_key == "app:office:wps" + + session.delete(folder) + session.commit() + + assert session.query(FavoriteItem).count() == 0 + + +def test_downloaded_app_and_forum_level_persist(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + user = User( + flarum_user_id="123", + username="momen", + forum_level="管理员", + forum_groups='["管理员"]', + ) + session.add(user) + session.flush() + + session.add( + DownloadedApp( + user_id=user.id, + app_key="app:office:wps", + pkgname="wps", + name="WPS", + category="office", + selected_origin="apm", + version="1.0.0", + package_arch="amd64", + ) + ) + session.commit() + + stored_user = session.query(User).one() + stored_download = session.query(DownloadedApp).one() + assert stored_user.forum_level == "管理员" + assert stored_user.forum_groups == '["管理员"]' + assert stored_download.selected_origin == "apm" +``` + +- [ ] **Step 2: Run model tests and verify failure** + +Run: `.venv/bin/pytest tests/test_account_models.py -v` + +Expected: FAIL with `ModuleNotFoundError` for `app.models.downloaded_app` or `app.models.favorite`. + +- [ ] **Step 3: Add favorite models** + +Create `app/models/favorite.py` with: + +```python +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.user import User + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class FavoriteFolder(Base): + __tablename__ = "favorite_folders" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_favorite_folders_user_name"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + name: Mapped[str] = mapped_column(String(128), default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + user: Mapped["User"] = relationship(back_populates="favorite_folders") + items: Mapped[list["FavoriteItem"]] = relationship( + back_populates="folder", + cascade="all, delete-orphan", + ) + + +class FavoriteItem(Base): + __tablename__ = "favorite_items" + __table_args__ = ( + UniqueConstraint("folder_id", "app_key", name="uq_favorite_items_folder_app"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + folder_id: Mapped[int] = mapped_column(ForeignKey("favorite_folders.id"), index=True) + app_key: Mapped[str] = mapped_column(String(512), index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + name: Mapped[str] = mapped_column(String(255), default="", server_default="") + category: Mapped[str] = mapped_column(String(128), default="", server_default="") + icon_url: Mapped[str] = mapped_column(String(1024), default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + folder: Mapped[FavoriteFolder] = relationship(back_populates="items") +``` + +- [ ] **Step 4: Add downloaded app model** + +Create `app/models/downloaded_app.py` with: + +```python +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.user import User + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class DownloadedApp(Base): + __tablename__ = "downloaded_apps" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + app_key: Mapped[str] = mapped_column(String(512), index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + name: Mapped[str] = mapped_column(String(255), default="", server_default="") + category: Mapped[str] = mapped_column(String(128), default="", server_default="") + selected_origin: Mapped[str] = mapped_column(String(16), default="", server_default="") + version: Mapped[str] = mapped_column(String(255), default="", server_default="") + package_arch: Mapped[str] = mapped_column(String(64), default="", server_default="") + downloaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) + + user: Mapped["User"] = relationship(back_populates="downloaded_apps") +``` + +- [ ] **Step 5: Update user model relationships and forum fields** + +Modify `app/models/user.py` to include: + +```python +from app.models.downloaded_app import DownloadedApp # only under TYPE_CHECKING +from app.models.favorite import FavoriteFolder # only under TYPE_CHECKING +``` + +Inside the `User` class after `avatar_url`: + +```python + forum_level: Mapped[str] = mapped_column(String(128), default="论坛用户", server_default="论坛用户") + forum_groups: Mapped[str] = mapped_column(String(1024), default="[]", server_default="[]") +``` + +Inside the `User` class after `app_lists`: + +```python + favorite_folders: Mapped[list["FavoriteFolder"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + downloaded_apps: Mapped[list["DownloadedApp"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) +``` + +- [ ] **Step 6: Import new models in base metadata** + +Modify `app/db/base.py` to import: + +```python +from app.models.downloaded_app import DownloadedApp # noqa: E402,F401 +from app.models.favorite import FavoriteFolder, FavoriteItem # noqa: E402,F401 +``` + +- [ ] **Step 7: Update initial migration** + +Modify `alembic/versions/0001_initial.py`: + +1. Add `forum_level` and `forum_groups` columns to `users` after `avatar_url`. +2. Create `favorite_folders`, `favorite_items`, and `downloaded_apps` tables after `users` and before dependent reads are needed. +3. Add indexes and unique constraints matching the models. +4. Drop those tables in `downgrade()` before dropping `users`. + +Use these column definitions: + +```python +sa.Column("forum_level", sa.String(length=128), server_default="论坛用户", nullable=False), +sa.Column("forum_groups", sa.String(length=1024), server_default="[]", nullable=False), +``` + +Use these table definitions: + +```python +op.create_table( + "favorite_folders", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=128), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", name="uq_favorite_folders_user_name"), +) +op.create_index(op.f("ix_favorite_folders_user_id"), "favorite_folders", ["user_id"], unique=False) + +op.create_table( + "favorite_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("folder_id", sa.Integer(), nullable=False), + sa.Column("app_key", sa.String(length=512), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), server_default="", nullable=False), + sa.Column("category", sa.String(length=128), server_default="", nullable=False), + sa.Column("icon_url", sa.String(length=1024), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.ForeignKeyConstraint(["folder_id"], ["favorite_folders.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("folder_id", "app_key", name="uq_favorite_items_folder_app"), +) +op.create_index(op.f("ix_favorite_items_app_key"), "favorite_items", ["app_key"], unique=False) +op.create_index(op.f("ix_favorite_items_folder_id"), "favorite_items", ["folder_id"], unique=False) +op.create_index(op.f("ix_favorite_items_pkgname"), "favorite_items", ["pkgname"], unique=False) + +op.create_table( + "downloaded_apps", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("app_key", sa.String(length=512), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), server_default="", nullable=False), + sa.Column("category", sa.String(length=128), server_default="", nullable=False), + sa.Column("selected_origin", sa.String(length=16), server_default="", nullable=False), + sa.Column("version", sa.String(length=255), server_default="", nullable=False), + sa.Column("package_arch", sa.String(length=64), server_default="", nullable=False), + sa.Column("downloaded_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), +) +op.create_index(op.f("ix_downloaded_apps_app_key"), "downloaded_apps", ["app_key"], unique=False) +op.create_index(op.f("ix_downloaded_apps_downloaded_at"), "downloaded_apps", ["downloaded_at"], unique=False) +op.create_index(op.f("ix_downloaded_apps_pkgname"), "downloaded_apps", ["pkgname"], unique=False) +op.create_index(op.f("ix_downloaded_apps_user_id"), "downloaded_apps", ["user_id"], unique=False) +``` + +- [ ] **Step 8: Run model tests and migration verification** + +Run: + +```bash +.venv/bin/pytest tests/test_account_models.py -v +DATABASE_URL="sqlite:////tmp/opencode/spark-backend-account-models.sqlite3" .venv/bin/alembic upgrade head +``` + +Expected: model tests PASS and Alembic exits 0. + +- [ ] **Step 9: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 10: Commit account models** + +Run: + +```bash +git add app/models app/db/base.py alembic/versions/0001_initial.py tests/test_account_models.py +git commit -m "feat(account): add collection data models" +``` + +Expected: commit succeeds. + +## Task 2: Add Forum Level To Auth Profile + +**Files:** +- Modify: `app/services/flarum.py` +- Modify: `app/schemas/auth.py` +- Modify: `app/api/routes/auth.py` +- Test: `tests/test_auth.py` +- Test: `tests/test_flarum_service.py` + +- [ ] **Step 1: Add failing Flarum group extraction tests** + +Append to `tests/test_flarum_service.py`: + +```python +@pytest.mark.anyio +async def test_fetch_flarum_profile_maps_forum_groups(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api" + assert request.headers["Authorization"] == "Token forum-token" + return httpx.Response( + 200, + json={ + "data": { + "relationships": { + "actor": {"data": {"type": "users", "id": "123"}} + } + }, + "included": [ + { + "type": "users", + "id": "123", + "attributes": { + "username": "momen", + "displayName": "Momen", + "avatarUrl": "https://bbs.spark-app.store/avatar.png", + }, + "relationships": { + "groups": { + "data": [{"type": "groups", "id": "1"}] + } + }, + }, + { + "type": "groups", + "id": "1", + "attributes": {"nameSingular": "管理员"}, + }, + ], + }, + ) + + mock_async_client(monkeypatch, handler) + + profile = await fetch_flarum_profile("forum-token", "123") + + assert profile["forum_level"] == "管理员" + assert profile["forum_groups"] == '["管理员"]' + + +@pytest.mark.anyio +async def test_fetch_flarum_profile_falls_back_to_forum_user_level(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api" + return httpx.Response( + 200, + json={ + "data": { + "relationships": { + "actor": {"data": {"type": "users", "id": "123"}} + } + }, + "included": [ + { + "type": "users", + "id": "123", + "attributes": { + "username": "momen", + "displayName": "Momen", + "avatarUrl": "https://bbs.spark-app.store/avatar.png", + }, + } + ], + }, + ) + + mock_async_client(monkeypatch, handler) + + profile = await fetch_flarum_profile("forum-token", "123") + + assert profile["forum_level"] == "论坛用户" + assert profile["forum_groups"] == "[]" +``` + +- [ ] **Step 2: Add failing auth response test** + +Append to `tests/test_auth.py`: + +```python +def test_auth_flarum_returns_forum_level(client, monkeypatch): + async def fake_fetch_profile(token: str, user_id: str): + return { + "flarum_user_id": user_id, + "username": "momen", + "display_name": "Momen", + "avatar_url": "https://bbs.spark-app.store/avatar.png", + "forum_level": "管理员", + "forum_groups": '["管理员"]', + } + + monkeypatch.setattr("app.api.routes.auth.fetch_flarum_profile", fake_fetch_profile) + + response = client.post( + "/auth/flarum", + json={"flarum_user_id": "123", "flarum_token": "forum-token"}, + ) + + assert response.status_code == 200 + assert response.json()["user"]["forum_level"] == "管理员" + assert response.json()["user"]["forum_groups"] == '["管理员"]' +``` + +- [ ] **Step 3: Run auth tests and verify failure** + +Run: `.venv/bin/pytest tests/test_auth.py tests/test_flarum_service.py -v` + +Expected: FAIL because `forum_level` and `forum_groups` are not returned/mapped. + +- [ ] **Step 4: Extend auth schema** + +Modify `app/schemas/auth.py` `UserPublic`: + +```python +class UserPublic(BaseModel): + id: int + flarum_user_id: str + username: str + display_name: str + avatar_url: str + forum_level: str + forum_groups: str + + model_config = {"from_attributes": True} +``` + +- [ ] **Step 5: Store forum fields during auth** + +Modify `app/api/routes/auth.py` after avatar assignment: + +```python + user.forum_level = profile.get("forum_level", "论坛用户") + user.forum_groups = profile.get("forum_groups", "[]") +``` + +- [ ] **Step 6: Extract groups in Flarum service** + +Modify `app/services/flarum.py` to: + +1. Return user resource and attributes from `_find_included_user` instead of only attributes. +2. Extract group names from included `groups` resources referenced by user relationships. +3. Return fallback `forum_level: "论坛用户"` and `forum_groups: "[]"` when no groups are present. + +Add helper: + +```python +import json + + +def _extract_group_names(user_resource: dict, included: list[dict]) -> list[str]: + relationships = user_resource.get("relationships", {}) + groups = relationships.get("groups", {}) if isinstance(relationships, dict) else {} + group_refs = groups.get("data", []) if isinstance(groups, dict) else [] + if not isinstance(group_refs, list): + return [] + + wanted_ids = { + str(ref.get("id")) + for ref in group_refs + if isinstance(ref, dict) and ref.get("type") == "groups" and ref.get("id") is not None + } + names: list[str] = [] + for resource in included: + if not isinstance(resource, dict): + raise ValueError("malformed Flarum forum payload") + if resource.get("type") != "groups" or str(resource.get("id")) not in wanted_ids: + continue + attrs = resource.get("attributes", {}) + if not isinstance(attrs, dict): + raise ValueError("malformed Flarum forum payload") + name = attrs.get("nameSingular") or attrs.get("namePlural") or attrs.get("name") + if isinstance(name, str) and name: + names.append(name) + return names +``` + +The final return dict from `fetch_flarum_profile` must include: + +```python +"forum_level": group_names[0] if group_names else "论坛用户", +"forum_groups": json.dumps(group_names, ensure_ascii=False), +``` + +- [ ] **Step 7: Run auth tests** + +Run: `.venv/bin/pytest tests/test_auth.py tests/test_flarum_service.py -v` + +Expected: PASS. + +- [ ] **Step 8: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 9: Commit forum profile extension** + +Run: + +```bash +git add app/services/flarum.py app/schemas/auth.py app/api/routes/auth.py tests/test_auth.py tests/test_flarum_service.py +git commit -m "feat(account): expose forum level" +``` + +Expected: commit succeeds. + +## Task 3: Add Favorite Folder API + +**Files:** +- Create: `app/schemas/favorite.py` +- Create: `app/api/routes/favorites.py` +- Modify: `app/api/router.py` +- Test: `tests/test_favorites.py` + +- [ ] **Step 1: Write failing favorite API tests** + +Create `tests/test_favorites.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session, flarum_user_id="123"): + user = User(flarum_user_id=flarum_user_id, username=f"user-{flarum_user_id}") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_create_list_rename_and_delete_favorite_folder(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + created = client.post("/me/favorite-folders", headers=headers, json={"name": "办公"}) + assert created.status_code == 200 + folder_id = created.json()["id"] + assert created.json()["name"] == "办公" + assert created.json()["item_count"] == 0 + + listed = client.get("/me/favorite-folders", headers=headers) + assert [folder["name"] for folder in listed.json()] == ["办公"] + + renamed = client.patch( + f"/me/favorite-folders/{folder_id}", + headers=headers, + json={"name": "生产力"}, + ) + assert renamed.status_code == 200 + assert renamed.json()["name"] == "生产力" + + deleted = client.delete(f"/me/favorite-folders/{folder_id}", headers=headers) + assert deleted.status_code == 204 + assert client.get("/me/favorite-folders", headers=headers).json() == [] + + +def test_default_folder_created_when_adding_first_favorite(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + response = client.post( + "/me/favorite-folders/default/items", + headers=headers, + json={ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "icon_url": "https://example.invalid/wps.png", + }, + ) + + assert response.status_code == 200 + assert response.json()["app_key"] == "app:office:wps" + folders = client.get("/me/favorite-folders", headers=headers).json() + assert folders[0]["name"] == "默认收藏夹" + assert folders[0]["item_count"] == 1 + + +def test_favorite_folders_are_isolated_by_user(client, db_session): + user_one = seed_user(db_session, "1") + user_two = seed_user(db_session, "2") + + assert client.post("/me/favorite-folders", headers=auth_headers(user_one), json={"name": "A"}).status_code == 200 + assert client.get("/me/favorite-folders", headers=auth_headers(user_two)).json() == [] +``` + +- [ ] **Step 2: Run favorite tests and verify failure** + +Run: `.venv/bin/pytest tests/test_favorites.py -v` + +Expected: FAIL with 404 for favorite endpoints or missing schemas. + +- [ ] **Step 3: Add favorite schemas** + +Create `app/schemas/favorite.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class FavoriteFolderCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + + model_config = {"extra": "forbid"} + + +class FavoriteFolderUpdate(BaseModel): + name: str = Field(min_length=1, max_length=128) + + model_config = {"extra": "forbid"} + + +class FavoriteFolderPublic(BaseModel): + id: int + name: str + item_count: int + created_at: datetime + updated_at: datetime + + +class FavoriteItemCreate(BaseModel): + app_key: str = Field(min_length=1, max_length=512) + pkgname: str = Field(min_length=1, max_length=255) + name: str = Field(default="", max_length=255) + category: str = Field(min_length=1, max_length=128) + icon_url: str = Field(default="", max_length=1024) + + model_config = {"extra": "forbid"} + + +class FavoriteItemPublic(BaseModel): + id: int + app_key: str + pkgname: str + name: str + category: str + icon_url: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class FavoriteBulkDelete(BaseModel): + item_ids: list[int] + + model_config = {"extra": "forbid"} +``` + +- [ ] **Step 4: Add favorite routes** + +Create `app/api/routes/favorites.py` with: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.favorite import FavoriteFolder, FavoriteItem +from app.models.user import User +from app.schemas.favorite import ( + FavoriteBulkDelete, + FavoriteFolderCreate, + FavoriteFolderPublic, + FavoriteFolderUpdate, + FavoriteItemCreate, + FavoriteItemPublic, +) + +router = APIRouter() +DEFAULT_FOLDER_NAME = "默认收藏夹" + + +def folder_to_public(folder: FavoriteFolder) -> FavoriteFolderPublic: + return FavoriteFolderPublic( + id=folder.id, + name=folder.name, + item_count=len(folder.items), + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +def get_user_folder(db: Session, user_id: int, folder_id: int) -> FavoriteFolder: + folder = db.scalar( + select(FavoriteFolder) + .options(selectinload(FavoriteFolder.items)) + .where(FavoriteFolder.id == folder_id, FavoriteFolder.user_id == user_id) + ) + if folder is None: + raise HTTPException(status_code=404, detail="Favorite folder not found") + return folder + + +def get_or_create_default_folder(db: Session, user_id: int) -> FavoriteFolder: + folder = db.scalar( + select(FavoriteFolder) + .options(selectinload(FavoriteFolder.items)) + .where(FavoriteFolder.user_id == user_id, FavoriteFolder.name == DEFAULT_FOLDER_NAME) + ) + if folder is not None: + return folder + folder = FavoriteFolder(user_id=user_id, name=DEFAULT_FOLDER_NAME) + db.add(folder) + db.flush() + return folder + + +@router.get("/me/favorite-folders", response_model=list[FavoriteFolderPublic]) +def list_favorite_folders( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[FavoriteFolderPublic]: + folders = db.scalars( + select(FavoriteFolder) + .options(selectinload(FavoriteFolder.items)) + .where(FavoriteFolder.user_id == current_user.id) + .order_by(FavoriteFolder.updated_at.desc(), FavoriteFolder.id.desc()) + ).all() + return [folder_to_public(folder) for folder in folders] + + +@router.post("/me/favorite-folders", response_model=FavoriteFolderPublic) +def create_favorite_folder( + payload: FavoriteFolderCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> FavoriteFolderPublic: + exists = db.scalar( + select(FavoriteFolder.id).where( + FavoriteFolder.user_id == current_user.id, + FavoriteFolder.name == payload.name.strip(), + ) + ) + if exists is not None: + raise HTTPException(status_code=409, detail="Favorite folder already exists") + folder = FavoriteFolder(user_id=current_user.id, name=payload.name.strip()) + db.add(folder) + db.commit() + db.refresh(folder) + folder.items = [] + return folder_to_public(folder) + + +@router.patch("/me/favorite-folders/{folder_id}", response_model=FavoriteFolderPublic) +def rename_favorite_folder( + folder_id: int, + payload: FavoriteFolderUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> FavoriteFolderPublic: + folder = get_user_folder(db, current_user.id, folder_id) + folder.name = payload.name.strip() + db.commit() + stored = get_user_folder(db, current_user.id, folder_id) + return folder_to_public(stored) + + +@router.delete("/me/favorite-folders/{folder_id}", status_code=204) +def delete_favorite_folder( + folder_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> Response: + folder = get_user_folder(db, current_user.id, folder_id) + db.delete(folder) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/me/favorite-folders/{folder_id}/items", response_model=list[FavoriteItemPublic]) +def list_favorite_items( + folder_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[FavoriteItem]: + folder = get_user_folder(db, current_user.id, folder_id) + return sorted(folder.items, key=lambda item: item.id) + + +@router.post("/me/favorite-folders/{folder_id}/items", response_model=FavoriteItemPublic) +def add_favorite_item( + folder_id: int | str, + payload: FavoriteItemCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> FavoriteItem: + folder = ( + get_or_create_default_folder(db, current_user.id) + if folder_id == "default" + else get_user_folder(db, current_user.id, int(folder_id)) + ) + item = db.scalar( + select(FavoriteItem).where( + FavoriteItem.folder_id == folder.id, + FavoriteItem.app_key == payload.app_key, + ) + ) + if item is None: + item = FavoriteItem(folder_id=folder.id, **payload.model_dump()) + db.add(item) + else: + item.pkgname = payload.pkgname + item.name = payload.name + item.category = payload.category + item.icon_url = payload.icon_url + db.commit() + db.refresh(item) + return item + + +@router.delete("/me/favorite-folders/{folder_id}/items/{item_id}", status_code=204) +def delete_favorite_item( + folder_id: int, + item_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> Response: + get_user_folder(db, current_user.id, folder_id) + item = db.get(FavoriteItem, item_id) + if item is None or item.folder_id != folder_id: + raise HTTPException(status_code=404, detail="Favorite item not found") + db.delete(item) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/me/favorite-folders/{folder_id}/items/bulk-delete") +def bulk_delete_favorite_items( + folder_id: int, + payload: FavoriteBulkDelete, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> dict[str, int]: + get_user_folder(db, current_user.id, folder_id) + items = db.scalars( + select(FavoriteItem).where( + FavoriteItem.folder_id == folder_id, + FavoriteItem.id.in_(payload.item_ids), + ) + ).all() + deleted_count = len(items) + for item in items: + db.delete(item) + db.commit() + return {"deleted_count": deleted_count} +``` + +- [ ] **Step 5: Include favorite router** + +Modify `app/api/router.py`: + +```python +from app.api.routes.favorites import router as favorites_router + +api_router.include_router(favorites_router) +``` + +- [ ] **Step 6: Run favorite tests** + +Run: `.venv/bin/pytest tests/test_favorites.py -v` + +Expected: PASS. + +- [ ] **Step 7: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 8: Commit favorite API** + +Run: + +```bash +git add app/schemas/favorite.py app/api/routes/favorites.py app/api/router.py tests/test_favorites.py +git commit -m "feat(account): add favorite folders api" +``` + +Expected: commit succeeds. + +## Task 4: Add Downloaded Apps API + +**Files:** +- Create: `app/schemas/downloaded_app.py` +- Create: `app/api/routes/downloaded_apps.py` +- Modify: `app/api/router.py` +- Test: `tests/test_downloaded_apps.py` + +- [ ] **Step 1: Write failing downloaded app API tests** + +Create `tests/test_downloaded_apps.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session, flarum_user_id="123"): + user = User(flarum_user_id=flarum_user_id, username=f"user-{flarum_user_id}") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_create_and_list_downloaded_apps(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + created = client.post( + "/me/downloaded-apps", + headers=headers, + json={ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "selected_origin": "apm", + "version": "1.0.0", + "package_arch": "amd64", + }, + ) + + assert created.status_code == 200 + assert created.json()["selected_origin"] == "apm" + + listed = client.get("/me/downloaded-apps", headers=headers) + assert listed.status_code == 200 + assert listed.json()["items"][0]["pkgname"] == "wps" + assert listed.json()["total"] == 1 + + +def test_downloaded_apps_are_isolated_by_user(client, db_session): + user_one = seed_user(db_session, "1") + user_two = seed_user(db_session, "2") + response = client.post( + "/me/downloaded-apps", + headers=auth_headers(user_one), + json={ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "selected_origin": "apm", + "version": "1.0.0", + "package_arch": "amd64", + }, + ) + assert response.status_code == 200 + + listed = client.get("/me/downloaded-apps", headers=auth_headers(user_two)) + assert listed.json()["items"] == [] + assert listed.json()["total"] == 0 +``` + +- [ ] **Step 2: Run downloaded app tests and verify failure** + +Run: `.venv/bin/pytest tests/test_downloaded_apps.py -v` + +Expected: FAIL with 404 for `/me/downloaded-apps`. + +- [ ] **Step 3: Add downloaded app schemas** + +Create `app/schemas/downloaded_app.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DownloadedAppCreate(BaseModel): + app_key: str = Field(min_length=1, max_length=512) + pkgname: str = Field(min_length=1, max_length=255) + name: str = Field(default="", max_length=255) + category: str = Field(min_length=1, max_length=128) + selected_origin: str = Field(min_length=1, max_length=16) + version: str = Field(default="", max_length=255) + package_arch: str = Field(default="", max_length=64) + + model_config = {"extra": "forbid"} + + +class DownloadedAppPublic(BaseModel): + id: int + app_key: str + pkgname: str + name: str + category: str + selected_origin: str + version: str + package_arch: str + downloaded_at: datetime + + model_config = {"from_attributes": True} + + +class DownloadedAppList(BaseModel): + items: list[DownloadedAppPublic] + total: int + page: int + page_size: int +``` + +- [ ] **Step 4: Add downloaded app routes** + +Create `app/api/routes/downloaded_apps.py` with: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.downloaded_app import DownloadedApp +from app.models.user import User +from app.schemas.downloaded_app import DownloadedAppCreate, DownloadedAppList, DownloadedAppPublic + +router = APIRouter() + + +@router.post("/me/downloaded-apps", response_model=DownloadedAppPublic) +def create_downloaded_app( + payload: DownloadedAppCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> DownloadedApp: + record = DownloadedApp(user_id=current_user.id, **payload.model_dump()) + db.add(record) + db.commit() + db.refresh(record) + return record + + +@router.get("/me/downloaded-apps", response_model=DownloadedAppList) +def list_downloaded_apps( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 20, +) -> DownloadedAppList: + total = db.scalar( + select(func.count(DownloadedApp.id)).where(DownloadedApp.user_id == current_user.id) + ) or 0 + items = db.scalars( + select(DownloadedApp) + .where(DownloadedApp.user_id == current_user.id) + .order_by(DownloadedApp.downloaded_at.desc(), DownloadedApp.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + return DownloadedAppList(items=list(items), total=total, page=page, page_size=page_size) +``` + +- [ ] **Step 5: Include downloaded app router** + +Modify `app/api/router.py`: + +```python +from app.api.routes.downloaded_apps import router as downloaded_apps_router + +api_router.include_router(downloaded_apps_router) +``` + +- [ ] **Step 6: Run downloaded app tests** + +Run: `.venv/bin/pytest tests/test_downloaded_apps.py -v` + +Expected: PASS. + +- [ ] **Step 7: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 8: Commit downloaded app API** + +Run: + +```bash +git add app/schemas/downloaded_app.py app/api/routes/downloaded_apps.py app/api/router.py tests/test_downloaded_apps.py +git commit -m "feat(account): add downloaded apps api" +``` + +Expected: commit succeeds. + +## Task 5: Update Backend API And Deployment Docs + +**Files:** +- Modify: `docs/api.md` +- Modify: `docs/deployment.md` +- Modify: `README.md` + +- [ ] **Step 1: Document user profile fields** + +Modify `docs/api.md` auth response examples so `user` includes: + +```json +{ + "forum_level": "管理员", + "forum_groups": "[\"管理员\"]" +} +``` + +Include text: `forum_level` falls back to `论坛用户` when Flarum group data is unavailable. + +- [ ] **Step 2: Document favorite endpoints** + +Append to `docs/api.md`: + +```markdown +## Favorites + +All favorite endpoints require bearer JWT. Favorites store app-level identities in the format `app:{category}:{pkgname}` and do not permanently bind to Spark or APM. + +### `GET /me/favorite-folders` + +Returns folders with `item_count`. + +### `POST /me/favorite-folders` + +Request: `{ "name": "办公" }`. + +### `PATCH /me/favorite-folders/{folder_id}` + +Request: `{ "name": "生产力" }`. + +### `DELETE /me/favorite-folders/{folder_id}` + +Deletes a folder and its items. + +### `GET /me/favorite-folders/{folder_id}/items` + +Returns favorite items. + +### `POST /me/favorite-folders/{folder_id}/items` + +Use `folder_id` or `default`. `default` creates `默认收藏夹` when needed. + +Request: +```json +{ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "icon_url": "https://example.invalid/wps.png" +} +``` + +### `DELETE /me/favorite-folders/{folder_id}/items/{item_id}` + +Removes one item. + +### `POST /me/favorite-folders/{folder_id}/items/bulk-delete` + +Request: `{ "item_ids": [1, 2, 3] }`. +``` +``` + +- [ ] **Step 3: Document downloaded endpoints** + +Append to `docs/api.md`: + +```markdown +## Downloaded Apps + +All downloaded-app endpoints require bearer JWT. + +### `GET /me/downloaded-apps` + +Query parameters: `page`, `page_size`. + +### `POST /me/downloaded-apps` + +Request: +```json +{ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "selected_origin": "apm", + "version": "1.0.0", + "package_arch": "amd64" +} +``` +``` +``` + +- [ ] **Step 4: Update deployment guide** + +Modify `docs/deployment.md` upgrade section to mention that deployments must run Alembic after pulling because account collection tables are included in the initial schema for fresh installs. + +- [ ] **Step 5: Run docs-adjacent verification** + +Run: + +```bash +.venv/bin/pytest -v +DATABASE_URL="sqlite:////tmp/opencode/spark-backend-account-docs.sqlite3" .venv/bin/alembic upgrade head +``` + +Expected: tests PASS and Alembic exits 0. + +- [ ] **Step 6: Commit docs** + +Run: + +```bash +git add docs/api.md docs/deployment.md README.md +git commit -m "docs(account): document collection endpoints" +``` + +Expected: commit succeeds if files changed. + +## Task 6: Final Backend Verification And Push + +**Files:** +- Verify only. + +- [ ] **Step 1: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 2: Verify migration on a fresh database URL** + +Run: + +```bash +DATABASE_URL="sqlite:////tmp/opencode/spark-backend-account-final.sqlite3" .venv/bin/alembic upgrade head +``` + +Expected: command exits 0. + +- [ ] **Step 3: Check git status** + +Run: `git status --short --branch` + +Expected: branch is clean and ahead of `origin/master` by the new backend commits. + +- [ ] **Step 4: Push backend** + +Run: `git push origin master` + +Expected: push succeeds to `https://gitee.com/erotica-rbqs/spark-unionid-server`. If authentication fails, report exact error and leave local repo intact. + +## Self-Review Checklist + +Spec coverage: + +- Forum level data: Task 2. +- Favorite folders/items and default folder: Tasks 1 and 3. +- Downloaded records: Tasks 1 and 4. +- API/deployment docs: Task 5. +- Final backend verification and push: Task 6. + +Placeholder scan: + +- No placeholder sections remain. Tests, request payloads, and implementation snippets are concrete. + +Type consistency: + +- Favorite app key uses `app:{category}:{pkgname}`. +- Backend field names remain snake_case. +- `forum_groups` is a JSON-encoded string to keep the existing response schema simple. diff --git a/docs/superpowers/plans/2026-05-18-spark-client-account-collections.md b/docs/superpowers/plans/2026-05-18-spark-client-account-collections.md new file mode 100644 index 00000000..57920eeb --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-client-account-collections.md @@ -0,0 +1,3109 @@ +# Spark Client Account Collections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the Electron/Vue Spark Store client with forum login, sidebar account entry, account management, main-content detail pages, reviews, favorites, downloaded history, and cloud installed-app sync while preserving anonymous browse/install/remove/update flows. + +**Architecture:** Keep backend communication in small TypeScript modules and keep Vue state in focused global modules. Replace the app-detail overlay with a routed state inside `App.vue` content area, and add account/favorites/user-management components without changing Electron package-management IPC contracts. + +**Tech Stack:** Electron 40, Vue 3 Composition API, TypeScript strict mode, Axios, Vitest, Testing Library Vue, existing IPC facade, existing install queue, FastAPI backend API. + +--- + +## File Structure + +Client repository: `/home/spark/Desktop/shenmo-spark-store/spark-store`. + +Modify: +- `.gitignore` - already ignores `.superpowers/`; leave as-is unless missing. +- `src/global/storeConfig.ts` - add backend URL and forum URLs. +- `src/global/typedefinition.ts` - add account, review, favorite, downloaded, sync, and availability types. +- `src/vite-env.d.ts` - add backend env and `window.ipcRenderer.invoke` usage remains unchanged. +- `src/__tests__/setup.ts` - keep `window.apm_store.arch` as bare architecture `amd64` and make IPC mocks writable. +- `src/components/AppSidebar.vue` - replace the title block with account entry and quick menu events. +- `src/App.vue` - coordinate auth modal, detail view state, account management view, favorites, reviews, downloaded records, and startup sync. +- `src/modules/processInstall.ts` - expose selected-app queue creation result for cloud downloaded-record writes without changing existing install IPC payloads. +- `src/components/InstalledAppsModal.vue` - add cloud sync actions and login gating. + +Create: +- `src/global/authState.ts` - auth session persistence and token header propagation. +- `src/global/accountSyncState.ts` - local installed-sync preference helpers. +- `src/modules/backendApi.ts` - backend client, DTO mapping, favorites, reviews, downloaded records, and app-list API helpers. +- `src/modules/flarumAuth.ts` - Flarum token exchange helper; forum password never leaves this request. +- `src/modules/appIdentity.ts` - favorite app key, review app key, package arch, and selected display app helpers. +- `src/modules/favoriteAvailability.ts` - client-side favorite resolution and batch-install planning. +- `src/modules/appListSync.ts` - installed-list filtering and cloud sync payload helpers. +- `src/components/LoginModal.vue` - forum login/register prompt. +- `src/components/LoginPromptModal.vue` - reusable account-only feature gate prompt. +- `src/components/AccountQuickMenu.vue` - quick account menu for logged-in users. +- `src/components/AppDetailPage.vue` - full content-area detail page replacing `AppDetailModal`. +- `src/components/ReviewsPanel.vue` - review list/composer with anonymous prompt. +- `src/components/FavoriteFolderSelector.vue` - favorite folder selector/add action. +- `src/components/UserManagementView.vue` - profile, links, downloaded history, sync preference, and favorites entry. +- `src/components/FavoriteFolderManager.vue` - folders/items/statuses/batch install/bulk delete. +- `src/components/AppListRestoreModal.vue` - cloud app-list restore selector. +- `src/__tests__/unit/accountTypes.test.ts` - type/config smoke test. +- `src/__tests__/unit/authState.test.ts` - auth persistence test. +- `src/__tests__/unit/LoginModal.test.ts` - login/register modal test. +- `src/__tests__/unit/AppSidebar.account.test.ts` - account entry and quick menu tests. +- `src/__tests__/unit/appIdentity.test.ts` - key/tag helper tests. +- `src/__tests__/unit/favoriteAvailability.test.ts` - status and batch source selection tests. +- `src/__tests__/unit/appListSync.test.ts` - installed sync filtering tests. +- `src/__tests__/unit/AppDetailPage.test.ts` - detail page/back/favorite prompt test. +- `src/__tests__/unit/FavoriteFolderManager.test.ts` - folder manager status/action test. +- `src/__tests__/unit/UserManagementView.test.ts` - account management rendering test. +- `src/__tests__/unit/AppListRestoreModal.test.ts` - restore selector test. + +## Task 1: Add Account Config, Types, And API Client + +**Files:** +- Modify: `src/global/storeConfig.ts` +- Modify: `src/global/typedefinition.ts` +- Modify: `src/vite-env.d.ts` +- Modify: `src/__tests__/setup.ts` +- Create: `src/modules/backendApi.ts` +- Test: `src/__tests__/unit/accountTypes.test.ts` + +- [ ] **Step 1: Write failing account type smoke test** + +Create `src/__tests__/unit/accountTypes.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { + FLARUM_BASE_URL, + FLARUM_REGISTER_URL, + SPARK_BACKEND_BASE_URL, +} from "@/global/storeConfig"; +import type { + DownloadedAppRecord, + FavoriteFolder, + FavoriteItem, + ReviewTags, + SparkUser, + SyncedAppListItem, +} from "@/global/typedefinition"; + +describe("account shared types", () => { + it("exports backend/forum config and account shapes", () => { + const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], + }; + const folder: FavoriteFolder = { + id: 1, + name: "默认收藏夹", + itemCount: 1, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", + }; + const favorite: FavoriteItem = { + id: 2, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + iconUrl: "https://example.invalid/wps.png", + createdAt: "2026-05-18T00:00:00Z", + }; + const download: DownloadedAppRecord = { + id: 3, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + selectedOrigin: "apm", + version: "1.0.0", + packageArch: "amd64", + downloadedAt: "2026-05-18T00:00:00Z", + }; + const syncItem: SyncedAppListItem = { + pkgname: "wps", + origin: "apm", + category: "office", + version: "1.0.0", + packageArch: "amd64", + appName: "WPS", + iconUrl: "https://example.invalid/wps.png", + }; + const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }; + + expect(typeof SPARK_BACKEND_BASE_URL).toBe("string"); + expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store"); + expect(FLARUM_REGISTER_URL).toContain("register"); + expect(user.forumGroups).toEqual(["管理员"]); + expect(folder.itemCount).toBe(1); + expect(favorite.appKey).toBe("app:office:wps"); + expect(download.selectedOrigin).toBe("apm"); + expect(syncItem.origin).toBe("apm"); + expect(tags.packageArch).toBe("amd64"); + }); +}); +``` + +- [ ] **Step 2: Run the smoke test and verify failure** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: FAIL because new config constants and account types do not exist. + +- [ ] **Step 3: Add backend and forum config** + +Append to `src/global/storeConfig.ts` after `APM_STORE_STATS_BASE_URL`: + +```typescript +export const SPARK_BACKEND_BASE_URL: string = + import.meta.env.VITE_SPARK_BACKEND_BASE_URL || ""; + +export const FLARUM_BASE_URL = "https://bbs.spark-app.store"; +export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`; +export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`; +export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`; +``` + +- [ ] **Step 4: Add account types** + +Append to `src/global/typedefinition.ts`: + +```typescript +export interface SparkUser { + id: number; + flarumUserId: string; + username: string; + displayName: string; + avatarUrl: string; + forumLevel: string; + forumGroups: string[]; +} + +export interface AuthSession { + accessToken: string; + tokenType: "bearer"; + user: SparkUser; +} + +export interface FlarumLoginPayload { + identification: string; + password: string; +} + +export interface ReviewTags { + origin: "spark" | "apm"; + category: string; + pkgname: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; +} + +export interface RatingSummary { + averageRating: number; + reviewCount: number; + starCounts: Record; +} + +export interface AppReview { + id: number; + rating: number; + content: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; + origin: "spark" | "apm"; + category: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; +} + +export interface FavoriteFolder { + id: number; + name: string; + itemCount: number; + createdAt: string; + updatedAt: string; +} + +export interface FavoriteItem { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + iconUrl: string; + createdAt: string; +} + +export type FavoriteAvailabilityStatus = + | "installable" + | "installed" + | "platform-unavailable" + | "arch-unavailable" + | "downlisted"; + +export interface ResolvedFavoriteItem { + item: FavoriteItem; + status: FavoriteAvailabilityStatus; + reason: string; + selectedApp: App | null; +} + +export interface DownloadedAppRecord { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + selectedOrigin: "spark" | "apm"; + version: string; + packageArch: string; + downloadedAt: string; +} + +export interface DownloadedAppList { + items: DownloadedAppRecord[]; + total: number; + page: number; + pageSize: number; +} + +export interface SyncedAppListItem { + id?: number; + pkgname: string; + origin: "spark" | "apm"; + category: string; + version: string; + packageArch: string; + appName: string; + iconUrl: string; +} + +export interface SyncedAppList { + snapshotName: string; + clientArch: string; + distro: string; + updatedAt: string; + items: SyncedAppListItem[]; +} + +export interface SystemInfo { + distro: string; +} +``` + +- [ ] **Step 5: Add environment declaration** + +Add `ImportMetaEnv` inside the existing `declare global` block in `src/vite-env.d.ts`: + +```typescript + interface ImportMetaEnv { + readonly VITE_SPARK_BACKEND_BASE_URL?: string; + } +``` + +- [ ] **Step 6: Normalize test IPC mocks** + +Modify `src/__tests__/setup.ts` so both exposed globals are writable and `window.apm_store.arch` is bare `amd64`: + +```typescript +Object.defineProperty(window, "ipcRenderer", { + value: { + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + invoke: vi.fn(), + removeListener: vi.fn(), + }, + writable: true, +}); + +Object.defineProperty(window, "apm_store", { + value: { + arch: "amd64", + }, + writable: true, +}); +``` + +- [ ] **Step 7: Add backend API helper** + +Create `src/modules/backendApi.ts` with: + +```typescript +import axios from "axios"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { + AppReview, + AuthSession, + DownloadedAppList, + DownloadedAppRecord, + FavoriteFolder, + FavoriteItem, + RatingSummary, + ReviewTags, + SyncedAppList, + SyncedAppListItem, +} from "@/global/typedefinition"; + +const backend = axios.create({ + baseURL: SPARK_BACKEND_BASE_URL, + timeout: 10000, +}); + +const parseForumGroups = (raw: unknown): string[] => { + if (Array.isArray(raw)) return raw.filter((item): item is string => typeof item === "string"); + if (typeof raw !== "string" || raw.length === 0) return []; + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; + } catch { + return []; + } +}; + +const toUser = (raw: Record): AuthSession["user"] => ({ + id: Number(raw.id), + flarumUserId: String(raw.flarum_user_id || ""), + username: String(raw.username || ""), + displayName: String(raw.display_name || raw.username || ""), + avatarUrl: String(raw.avatar_url || ""), + forumLevel: String(raw.forum_level || "论坛用户"), + forumGroups: parseForumGroups(raw.forum_groups), +}); + +const toReview = (raw: Record): AppReview => ({ + id: Number(raw.id), + rating: Number(raw.rating), + content: String(raw.content || ""), + version: String(raw.version || "unknown"), + packageArch: String(raw.package_arch || "unknown"), + clientArch: String(raw.client_arch || "unknown"), + distro: String(raw.distro || "unknown"), + origin: raw.origin === "spark" ? "spark" : "apm", + category: String(raw.category || ""), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), + userDisplayName: String(raw.user_display_name || ""), + userAvatarUrl: String(raw.user_avatar_url || ""), +}); + +const toFavoriteFolder = (raw: Record): FavoriteFolder => ({ + id: Number(raw.id), + name: String(raw.name || ""), + itemCount: Number(raw.item_count || 0), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), +}); + +const toFavoriteItem = (raw: Record): FavoriteItem => ({ + id: Number(raw.id), + appKey: String(raw.app_key || ""), + pkgname: String(raw.pkgname || ""), + name: String(raw.name || ""), + category: String(raw.category || ""), + iconUrl: String(raw.icon_url || ""), + createdAt: String(raw.created_at || ""), +}); + +const toDownloadedApp = (raw: Record): DownloadedAppRecord => ({ + id: Number(raw.id), + appKey: String(raw.app_key || ""), + pkgname: String(raw.pkgname || ""), + name: String(raw.name || ""), + category: String(raw.category || ""), + selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm", + version: String(raw.version || ""), + packageArch: String(raw.package_arch || "unknown"), + downloadedAt: String(raw.downloaded_at || ""), +}); + +export const setBackendToken = (token: string | null) => { + if (token) backend.defaults.headers.common.Authorization = `Bearer ${token}`; + else delete backend.defaults.headers.common.Authorization; +}; + +export const exchangeFlarumToken = async (payload: { + flarumUserId: string; + flarumToken: string; +}): Promise => { + const response = await backend.post("/auth/flarum", { + flarum_user_id: payload.flarumUserId, + flarum_token: payload.flarumToken, + }); + return { + accessToken: String(response.data.access_token), + tokenType: "bearer", + user: toUser(response.data.user), + }; +}; + +export const fetchMe = async (): Promise => { + const response = await backend.get("/me"); + return toUser(response.data); +}; + +export const fetchRatingSummary = async (appKey: string): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/rating-summary`); + return { + averageRating: Number(response.data.average_rating || 0), + reviewCount: Number(response.data.review_count || 0), + starCounts: response.data.star_counts || {}, + }; +}; + +export const fetchReviews = async (appKey: string): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/reviews`); + return (response.data || []).map((item: Record) => toReview(item)); +}; + +export const submitReview = async ( + appKey: string, + payload: { rating: number; content: string; tags: ReviewTags }, +): Promise => { + const response = await backend.post(`/apps/${encodeURIComponent(appKey)}/reviews`, { + rating: payload.rating, + content: payload.content, + tags: { + origin: payload.tags.origin, + category: payload.tags.category, + pkgname: payload.tags.pkgname, + version: payload.tags.version, + package_arch: payload.tags.packageArch, + client_arch: payload.tags.clientArch, + distro: payload.tags.distro, + }, + }); + return toReview(response.data); +}; + +export const listFavoriteFolders = async (): Promise => { + const response = await backend.get("/me/favorite-folders"); + return (response.data || []).map((item: Record) => toFavoriteFolder(item)); +}; + +export const createFavoriteFolder = async (name: string): Promise => { + const response = await backend.post("/me/favorite-folders", { name }); + return toFavoriteFolder(response.data); +}; + +export const renameFavoriteFolder = async (folderId: number, name: string): Promise => { + const response = await backend.patch(`/me/favorite-folders/${folderId}`, { name }); + return toFavoriteFolder(response.data); +}; + +export const deleteFavoriteFolder = async (folderId: number): Promise => { + await backend.delete(`/me/favorite-folders/${folderId}`); +}; + +export const listFavoriteItems = async (folderId: number): Promise => { + const response = await backend.get(`/me/favorite-folders/${folderId}/items`); + return (response.data || []).map((item: Record) => toFavoriteItem(item)); +}; + +export const addFavoriteItem = async ( + folderId: number | "default", + item: Omit, +): Promise => { + const response = await backend.post(`/me/favorite-folders/${folderId}/items`, { + app_key: item.appKey, + pkgname: item.pkgname, + name: item.name, + category: item.category, + icon_url: item.iconUrl, + }); + return toFavoriteItem(response.data); +}; + +export const deleteFavoriteItem = async (folderId: number, itemId: number): Promise => { + await backend.delete(`/me/favorite-folders/${folderId}/items/${itemId}`); +}; + +export const bulkDeleteFavoriteItems = async (folderId: number, itemIds: number[]): Promise => { + const response = await backend.post(`/me/favorite-folders/${folderId}/items/bulk-delete`, { item_ids: itemIds }); + return Number(response.data.deleted_count || 0); +}; + +export const listDownloadedApps = async (page = 1, pageSize = 20): Promise => { + const response = await backend.get("/me/downloaded-apps", { params: { page, page_size: pageSize } }); + return { + items: (response.data.items || []).map((item: Record) => toDownloadedApp(item)), + total: Number(response.data.total || 0), + page: Number(response.data.page || page), + pageSize: Number(response.data.page_size || pageSize), + }; +}; + +export const recordDownloadedApp = async (item: Omit): Promise => { + const response = await backend.post("/me/downloaded-apps", { + app_key: item.appKey, + pkgname: item.pkgname, + name: item.name, + category: item.category, + selected_origin: item.selectedOrigin, + version: item.version, + package_arch: item.packageArch, + }); + return toDownloadedApp(response.data); +}; + +export const fetchSyncedAppList = async (): Promise => { + const response = await backend.get("/me/app-list"); + if (!response.data) return null; + return { + snapshotName: String(response.data.snapshot_name || "默认列表"), + clientArch: String(response.data.client_arch || "unknown"), + distro: String(response.data.distro || "unknown"), + updatedAt: String(response.data.updated_at || ""), + items: (response.data.items || []).map((item: Record) => ({ + id: Number(item.id), + pkgname: String(item.pkgname || ""), + origin: item.origin === "spark" ? "spark" : "apm", + category: String(item.category || ""), + version: String(item.version || ""), + packageArch: String(item.package_arch || "unknown"), + appName: String(item.app_name || ""), + iconUrl: String(item.icon_url || ""), + })), + }; +}; + +export const uploadSyncedAppList = async (payload: { + clientArch: string; + distro: string; + items: SyncedAppListItem[]; +}): Promise => { + const response = await backend.put("/me/app-list", { + client_arch: payload.clientArch, + distro: payload.distro, + items: payload.items.map((item) => ({ + pkgname: item.pkgname, + origin: item.origin, + category: item.category, + version: item.version, + package_arch: item.packageArch, + app_name: item.appName, + icon_url: item.iconUrl, + })), + }); + return { + snapshotName: String(response.data.snapshot_name || "默认列表"), + clientArch: String(response.data.client_arch || payload.clientArch), + distro: String(response.data.distro || payload.distro), + updatedAt: String(response.data.updated_at || ""), + items: payload.items, + }; +}; +``` + +- [ ] **Step 8: Run type smoke test and verify pass** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: PASS. + +- [ ] **Step 9: Commit shared client account API foundation** + +Run: + +```bash +git add src/global/storeConfig.ts src/global/typedefinition.ts src/vite-env.d.ts src/__tests__/setup.ts src/modules/backendApi.ts src/__tests__/unit/accountTypes.test.ts +git commit -m "feat(account): add client account api foundation" +``` + +Expected: commit succeeds. + +## Task 2: Add Auth State, Flarum Login, And Sidebar Account Entry + +**Files:** +- Create: `src/global/authState.ts` +- Create: `src/modules/flarumAuth.ts` +- Create: `src/components/LoginModal.vue` +- Create: `src/components/LoginPromptModal.vue` +- Create: `src/components/AccountQuickMenu.vue` +- Modify: `src/components/AppSidebar.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/authState.test.ts` +- Test: `src/__tests__/unit/LoginModal.test.ts` +- Test: `src/__tests__/unit/AppSidebar.account.test.ts` + +- [ ] **Step 1: Write failing auth state test** + +Create `src/__tests__/unit/authState.test.ts` with: + +```typescript +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("authState", () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + }); + + it("persists and clears a backend session", async () => { + const { authSession, currentUser, isLoggedIn, setAuthSession, logout } = await import("@/global/authState"); + + setAuthSession({ + accessToken: "jwt", + tokenType: "bearer", + user: { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], + }, + }); + + expect(authSession.value?.accessToken).toBe("jwt"); + expect(currentUser.value?.displayName).toBe("Momen"); + expect(isLoggedIn.value).toBe(true); + expect(JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken).toBe("jwt"); + + logout(); + + expect(authSession.value).toBeNull(); + expect(isLoggedIn.value).toBe(false); + expect(localStorage.getItem("spark-store-auth")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Write failing login modal test** + +Create `src/__tests__/unit/LoginModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import LoginModal from "@/components/LoginModal.vue"; + +describe("LoginModal", () => { + it("emits login credentials and register request", async () => { + const rendered = render(LoginModal, { + props: { show: true, loading: false, error: "" }, + }); + + await fireEvent.update(screen.getByLabelText("论坛账号"), "momen"); + await fireEvent.update(screen.getByLabelText("论坛密码"), "secret"); + await fireEvent.click(screen.getByRole("button", { name: "登录" })); + await fireEvent.click(screen.getByRole("button", { name: "注册账号" })); + + expect(rendered.emitted("login")?.[0]?.[0]).toEqual({ identification: "momen", password: "secret" }); + expect(rendered.emitted("register")).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 3: Write failing sidebar account entry test** + +Create `src/__tests__/unit/AppSidebar.account.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppSidebar from "@/components/AppSidebar.vue"; +import type { SparkUser } from "@/global/typedefinition"; + +const baseProps = { + activeTab: "all", + categoryCounts: { all: 0 }, + themeMode: "auto" as const, + storeFilter: "both" as const, + sparkAvailable: true, + apmAvailable: true, + sidebarEntries: [], + entryCounts: {}, +}; + +const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], +}; + +describe("AppSidebar account entry", () => { + it("prompts login when anonymous", async () => { + const rendered = render(AppSidebar, { props: { ...baseProps, currentUser: null } }); + + await fireEvent.click(screen.getByRole("button", { name: /登录 \/ 注册/ })); + + expect(rendered.emitted("request-login")).toHaveLength(1); + }); + + it("opens quick menu for logged-in users", async () => { + render(AppSidebar, { props: { ...baseProps, currentUser: user } }); + + await fireEvent.click(screen.getByRole("button", { name: /Momen/ })); + + expect(screen.getByText("用户管理")).toBeTruthy(); + expect(screen.getByText("我的收藏")).toBeTruthy(); + expect(screen.getByText("退出登录")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 4: Run auth/login/sidebar tests and verify failure** + +Run: + +```bash +npm run test -- src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts +``` + +Expected: FAIL because modules/components/props do not exist. + +- [ ] **Step 5: Add auth state** + +Create `src/global/authState.ts` with: + +```typescript +import { computed, ref } from "vue"; + +import type { AuthSession } from "@/global/typedefinition"; +import { setBackendToken } from "@/modules/backendApi"; + +const STORAGE_KEY = "spark-store-auth"; + +const readStoredSession = (): AuthSession | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as AuthSession; + if (!parsed.accessToken || parsed.tokenType !== "bearer" || !parsed.user) return null; + return parsed; + } catch { + return null; + } +}; + +export const authSession = ref(readStoredSession()); +export const currentUser = computed(() => authSession.value?.user ?? null); +export const isLoggedIn = computed(() => Boolean(authSession.value?.accessToken)); + +setBackendToken(authSession.value?.accessToken ?? null); + +export const setAuthSession = (session: AuthSession) => { + authSession.value = session; + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + setBackendToken(session.accessToken); +}; + +export const logout = () => { + authSession.value = null; + localStorage.removeItem(STORAGE_KEY); + setBackendToken(null); +}; +``` + +- [ ] **Step 6: Add Flarum login helper** + +Create `src/modules/flarumAuth.ts` with: + +```typescript +import axios from "axios"; + +import { FLARUM_BASE_URL } from "@/global/storeConfig"; +import type { FlarumLoginPayload } from "@/global/typedefinition"; + +export interface FlarumTokenResponse { + token: string; + userId: string; +} + +export const requestFlarumToken = async (payload: FlarumLoginPayload): Promise => { + const response = await axios.post(`${FLARUM_BASE_URL}/api/token`, { + identification: payload.identification, + password: payload.password, + }); + return { + token: String(response.data.token || ""), + userId: String(response.data.userId || ""), + }; +}; +``` + +- [ ] **Step 7: Create login modal** + +Create `src/components/LoginModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 8: Create reusable login prompt modal** + +Create `src/components/LoginPromptModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 9: Create account quick menu** + +Create `src/components/AccountQuickMenu.vue` with: + +```vue + + + + + +``` + +- [ ] **Step 10: Update sidebar account entry** + +Modify `src/components/AppSidebar.vue`: + +1. Import `ref`, `AccountQuickMenu`, and `SparkUser`: + +```typescript +import { computed, ref } from "vue"; +import AccountQuickMenu from "./AccountQuickMenu.vue"; +import type { SidebarEntry, SparkUser } from "../global/typedefinition"; +``` + +2. Add prop: + +```typescript + currentUser: SparkUser | null; +``` + +3. Add emits: + +```typescript + (e: "request-login"): void; + (e: "open-user-management"): void; + (e: "open-favorites"): void; + (e: "open-forum"): void; + (e: "edit-profile"): void; + (e: "logout"): void; +``` + +4. Add state and handler: + +```typescript +const showAccountMenu = ref(false); + +const handleAccountClick = () => { + if (!props.currentUser) { + emit("request-login"); + return; + } + showAccountMenu.value = !showAccountMenu.value; +}; +``` + +5. Replace the current logo/title `
...
` at the top with: + +```vue +
+ + +
+``` + +- [ ] **Step 11: Wire login shell in App.vue** + +Modify `src/App.vue`: + +1. Import new state/components/helpers: + +```typescript +import LoginModal from "./components/LoginModal.vue"; +import LoginPromptModal from "./components/LoginPromptModal.vue"; +import { currentUser, isLoggedIn, logout, setAuthSession } from "./global/authState"; +import { FLARUM_BASE_URL, FLARUM_REGISTER_URL, FLARUM_SETTINGS_URL } from "./global/storeConfig"; +import { exchangeFlarumToken } from "./modules/backendApi"; +import { requestFlarumToken } from "./modules/flarumAuth"; +import type { FlarumLoginPayload } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const showLoginModal = ref(false); +const loginLoading = ref(false); +const loginError = ref(""); +const showLoginPrompt = ref(false); +const loginPromptMessage = ref("该功能需要登录星火账号后使用。"); +``` + +3. Pass sidebar props/events: + +```vue +:current-user="currentUser" +@request-login="showLoginModal = true" +@open-user-management="openUserManagement" +@open-favorites="openFavoriteManagement" +@open-forum="openExternalUrl(FLARUM_BASE_URL)" +@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)" +@logout="logout" +``` + +4. Mount modals before `AboutModal`: + +```vue + + +``` + +5. Add handlers: + +```typescript +const openExternalUrl = (url: string) => { + window.open(url, "_blank"); +}; + +const requireLogin = (message: string) => { + if (isLoggedIn.value) return true; + loginPromptMessage.value = message; + showLoginPrompt.value = true; + return false; +}; + +const openLoginFromPrompt = () => { + showLoginPrompt.value = false; + showLoginModal.value = true; +}; + +const handleFlarumLogin = async (payload: FlarumLoginPayload) => { + loginLoading.value = true; + loginError.value = ""; + try { + const flarum = await requestFlarumToken(payload); + const session = await exchangeFlarumToken({ flarumUserId: flarum.userId, flarumToken: flarum.token }); + setAuthSession(session); + showLoginModal.value = false; + } catch (error) { + loginError.value = error instanceof Error ? error.message : "登录失败"; + } finally { + loginLoading.value = false; + } +}; + +const openUserManagement = async () => { + if (!requireLogin("用户管理需要登录星火账号。")) return; + currentView.value = "account"; + activeTab.value = "account"; +}; + +const openFavoriteManagement = async () => { + if (!requireLogin("我的收藏需要登录星火账号。")) return; + currentView.value = "favorites"; + activeTab.value = "favorites"; +}; +``` + +- [ ] **Step 12: Run auth/sidebar tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/AppSidebar.test.ts +``` + +Expected: PASS. If existing `AppSidebar.test.ts` fails, add `currentUser: null` to its `renderSidebar` default props. + +- [ ] **Step 13: Commit auth and sidebar account entry** + +Run: + +```bash +git add src/global/authState.ts src/modules/flarumAuth.ts src/components/LoginModal.vue src/components/LoginPromptModal.vue src/components/AccountQuickMenu.vue src/components/AppSidebar.vue src/App.vue src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/AppSidebar.test.ts +git commit -m "feat(account): add forum login and sidebar account entry" +``` + +Expected: commit succeeds. + +## Task 3: Add App Identity Helpers And Main-Content Detail Page + +**Files:** +- Create: `src/modules/appIdentity.ts` +- Create: `src/components/AppDetailPage.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/appIdentity.test.ts` +- Test: `src/__tests__/unit/AppDetailPage.test.ts` + +- [ ] **Step 1: Write failing app identity tests** + +Create `src/__tests__/unit/appIdentity.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildFavoriteAppKey, buildReviewAppKey, buildReviewTags, getDisplayApp, parsePackageArch } from "@/modules/appIdentity"; +import type { App } from "@/global/typedefinition"; + +const app: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", +}; + +describe("appIdentity", () => { + it("builds favorite and review keys", () => { + expect(buildFavoriteAppKey(app)).toBe("app:office:wps"); + expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps"); + }); + + it("parses package arch and review tags", () => { + expect(parsePackageArch(app.filename)).toBe("amd64"); + expect(buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" })).toMatchObject({ + origin: "apm", + category: "office", + pkgname: "wps", + packageArch: "amd64", + }); + }); + + it("returns selected display app from merged apps", () => { + const merged: App = { ...app, isMerged: true, viewingOrigin: "spark", sparkApp: { ...app, origin: "spark" }, apmApp: app }; + expect(getDisplayApp(merged)?.origin).toBe("spark"); + }); +}); +``` + +- [ ] **Step 2: Write failing detail page test** + +Create `src/__tests__/unit/AppDetailPage.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppDetailPage from "@/components/AppDetailPage.vue"; +import type { App } from "@/global/typedefinition"; + +const app: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "110M", + more: "Office suite", + tags: "office", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", +}; + +describe("AppDetailPage", () => { + it("renders as page, emits back, and gates favorite for anonymous users", async () => { + const rendered = render(AppDetailPage, { + props: { + app, + screenshots: [], + sparkInstalled: false, + apmInstalled: false, + loggedIn: false, + reviewAppKey: "apm:amd64-apm:office:wps", + reviewTags: null, + }, + }); + + expect(screen.getByText("Office suite")).toBeTruthy(); + await fireEvent.click(screen.getByRole("button", { name: "返回" })); + await fireEvent.click(screen.getByRole("button", { name: "收藏" })); + + expect(rendered.emitted("back")).toHaveLength(1); + expect(rendered.emitted("request-login")?.[0]?.[0]).toBe("收藏应用需要登录星火账号。"); + }); +}); +``` + +- [ ] **Step 3: Run identity/detail tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts` + +Expected: FAIL because helper and detail page do not exist. + +- [ ] **Step 4: Add app identity helpers** + +Create `src/modules/appIdentity.ts` with: + +```typescript +import type { App, ReviewTags } from "@/global/typedefinition"; + +export const parsePackageArch = (filename: string | undefined): string => { + if (!filename) return "unknown"; + const match = filename.match(/_([^_]+)\.(?:deb|rpm|appimage|tar\.gz)$/i); + return match?.[1] || "unknown"; +}; + +export const buildStoreArch = (origin: "spark" | "apm", clientArch: string): string => { + return origin === "spark" ? `${clientArch}-store` : `${clientArch}-apm`; +}; + +export const buildFavoriteAppKey = (app: Pick): string => { + return `app:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const buildReviewAppKey = (app: App, clientArch: string): string => { + return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const getDisplayApp = (app: App | null): App | null => { + if (!app) return null; + if (!app.isMerged) return app; + if (app.viewingOrigin === "spark") return app.sparkApp || app; + if (app.viewingOrigin === "apm") return app.apmApp || app; + return app.sparkApp || app.apmApp || app; +}; + +export const buildReviewTags = ( + app: App, + system: { clientArch: string; distro: string }, +): ReviewTags => ({ + origin: app.origin, + category: app.category || "unknown", + pkgname: app.pkgname, + version: app.version || "unknown", + packageArch: app.arch || parsePackageArch(app.filename), + clientArch: system.clientArch || "unknown", + distro: system.distro || "unknown", +}); +``` + +- [ ] **Step 5: Create main-content detail page** + +Create `src/components/AppDetailPage.vue` with this page-level implementation: + +```vue + + + +``` + +Do not mount reviews in this task; Task 5 adds `ReviewsPanel`. + +- [ ] **Step 6: Replace modal state with detail page state in App.vue** + +Modify `src/App.vue`: + +1. Import `AppDetailPage` instead of `AppDetailModal`. +2. Replace `const showModal = ref(false);` with: + +```typescript +const currentView = ref<"home" | "list" | "detail" | "account" | "favorites">("home"); +const detailPreviousView = ref<"home" | "list">("home"); +``` + +3. Replace the content template branch at `src/App.vue:57-77` with: + +```vue +
+ + + +
+``` + +4. In `selectTab`, after setting `activeTab`, add: + +```typescript +currentView.value = tab === "home" ? "home" : "list"; +``` + +5. In `openDetail`, replace `showModal.value = true;` with: + +```typescript +detailPreviousView.value = activeTab.value === "home" ? "home" : "list"; +currentView.value = "detail"; +``` + +6. Replace `closeDetail` with: + +```typescript +const closeDetail = () => { + currentView.value = detailPreviousView.value; + currentApp.value = null; +}; +``` + +7. Replace `if (showModal.value && currentApp.value)` checks with `if (currentView.value === "detail" && currentApp.value)`. +8. Remove the old `` block. + +- [ ] **Step 7: Run detail tests** + +Run: `npm run test -- src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit main-content detail page** + +Run: + +```bash +git add src/modules/appIdentity.ts src/components/AppDetailPage.vue src/App.vue src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts +git commit -m "feat(detail): move app details into content view" +``` + +Expected: commit succeeds. + +## Task 4: Add Favorites API UI And Availability Resolver + +**Files:** +- Create: `src/modules/favoriteAvailability.ts` +- Create: `src/components/FavoriteFolderSelector.vue` +- Create: `src/components/FavoriteFolderManager.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/favoriteAvailability.test.ts` +- Test: `src/__tests__/unit/FavoriteFolderManager.test.ts` + +- [ ] **Step 1: Write failing favorite availability tests** + +Create `src/__tests__/unit/favoriteAvailability.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { resolveFavoriteItems } from "@/modules/favoriteAvailability"; +import type { App, FavoriteItem } from "@/global/typedefinition"; + +const app = (origin: "spark" | "apm", overrides: Partial = {}): App => ({ + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin, + currentStatus: "not-installed", + arch: "amd64", + ...overrides, +}); + +const favorite: FavoriteItem = { + id: 1, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + iconUrl: "", + createdAt: "2026-05-18T00:00:00Z", +}; + +describe("favoriteAvailability", () => { + it("marks downlisted favorites", () => { + expect(resolveFavoriteItems([favorite], [], [], { spark: true, apm: true }, "both")[0].status).toBe("downlisted"); + }); + + it("selects preferred installable variant", () => { + const resolved = resolveFavoriteItems([favorite], [app("spark"), app("apm")], [], { spark: true, apm: true }, "both")[0]; + expect(resolved.status).toBe("installable"); + expect(resolved.selectedApp?.origin).toBe("apm"); + }); + + it("marks installed favorites", () => { + const resolved = resolveFavoriteItems([favorite], [app("apm")], [app("apm", { currentStatus: "installed" })], { spark: true, apm: true }, "both")[0]; + expect(resolved.status).toBe("installed"); + }); +}); +``` + +- [ ] **Step 2: Write failing folder manager test** + +Create `src/__tests__/unit/FavoriteFolderManager.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue"; +import type { FavoriteFolder, ResolvedFavoriteItem } from "@/global/typedefinition"; + +const folder: FavoriteFolder = { + id: 1, + name: "默认收藏夹", + itemCount: 1, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", +}; + +const item: ResolvedFavoriteItem = { + item: { + id: 2, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + iconUrl: "", + createdAt: "2026-05-18T00:00:00Z", + }, + status: "downlisted", + reason: "已下架", + selectedApp: null, +}; + +describe("FavoriteFolderManager", () => { + it("shows downlisted favorites and emits bulk delete", async () => { + const rendered = render(FavoriteFolderManager, { + props: { folders: [folder], activeFolderId: 1, items: [item], loading: false, error: "" }, + }); + + expect(screen.getByText("已下架")).toBeTruthy(); + await fireEvent.click(screen.getByLabelText("选择 WPS")); + await fireEvent.click(screen.getByRole("button", { name: "移除选中" })); + + expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]); + }); +}); +``` + +- [ ] **Step 3: Run favorite tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts` + +Expected: FAIL because modules/components do not exist. + +- [ ] **Step 4: Add favorite availability resolver** + +Create `src/modules/favoriteAvailability.ts` with: + +```typescript +import type { App, FavoriteItem, ResolvedFavoriteItem, StoreFilter } from "@/global/typedefinition"; +import { getHybridDefaultOrigin } from "@/global/storeConfig"; + +const sourceEnabled = (origin: "spark" | "apm", available: { spark: boolean; apm: boolean }, storeFilter: StoreFilter) => { + if (origin === "spark") return available.spark && storeFilter !== "apm"; + return available.apm && storeFilter !== "spark"; +}; + +const hasCurrentArch = (app: App, clientArch: string) => { + return !app.arch || app.arch === clientArch || app.filename.includes(`_${clientArch}.`); +}; + +const choosePreferred = (apps: App[]): App => { + if (apps.length === 1) return apps[0]; + const spark = apps.find((app) => app.origin === "spark"); + const apm = apps.find((app) => app.origin === "apm"); + if (spark && apm) return getHybridDefaultOrigin(spark) === "spark" ? spark : apm; + return apps[0]; +}; + +export const resolveFavoriteItems = ( + items: FavoriteItem[], + catalogApps: App[], + installedApps: App[], + available: { spark: boolean; apm: boolean }, + storeFilter: StoreFilter, + clientArch = window.apm_store.arch || "amd64", +): ResolvedFavoriteItem[] => { + return items.map((item) => { + const matches = catalogApps.filter((app) => app.pkgname === item.pkgname && app.category === item.category); + if (matches.length === 0) return { item, status: "downlisted", reason: "已下架", selectedApp: null }; + + const installed = installedApps.find((app) => app.pkgname === item.pkgname && app.category === item.category && app.currentStatus === "installed"); + if (installed) return { item, status: "installed", reason: "已安装", selectedApp: installed }; + + const archMatches = matches.filter((app) => hasCurrentArch(app, clientArch)); + if (archMatches.length === 0) return { item, status: "arch-unavailable", reason: "当前架构不可用", selectedApp: null }; + + const usable = archMatches.filter((app) => sourceEnabled(app.origin, available, storeFilter)); + if (usable.length === 0) return { item, status: "platform-unavailable", reason: "当前来源不可用", selectedApp: null }; + + return { item, status: "installable", reason: "可安装", selectedApp: choosePreferred(usable) }; + }); +}; +``` + +- [ ] **Step 5: Create favorite folder selector** + +Create `src/components/FavoriteFolderSelector.vue` with: + +```vue + + + +``` + +- [ ] **Step 6: Create favorite folder manager** + +Create `src/components/FavoriteFolderManager.vue` with: + +```vue + + + +``` + +- [ ] **Step 7: Wire favorite selector and manager in App.vue** + +Modify `src/App.vue`: + +1. Import components/helpers/API: + +```typescript +import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue"; +import FavoriteFolderManager from "./components/FavoriteFolderManager.vue"; +import { addFavoriteItem, bulkDeleteFavoriteItems, createFavoriteFolder, listFavoriteFolders, listFavoriteItems } from "./modules/backendApi"; +import { buildFavoriteAppKey } from "./modules/appIdentity"; +import { resolveFavoriteItems } from "./modules/favoriteAvailability"; +import type { FavoriteFolder, FavoriteItem, ResolvedFavoriteItem } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const favoriteFolders = ref([]); +const activeFavoriteFolderId = ref(null); +const favoriteItems = ref([]); +const showFavoriteSelector = ref(false); +const favoriteTargetApp = ref(null); +const favoritesLoading = ref(false); +const favoritesError = ref(""); +``` + +3. Add computed resolver: + +```typescript +const resolvedFavoriteItems = computed(() => + resolveFavoriteItems( + favoriteItems.value, + apps.value, + installedApps.value, + availableSources.value, + storeFilter.value, + window.apm_store.arch || "amd64", + ), +); +``` + +4. Add handlers: + +```typescript +const loadFavoriteFolders = async () => { + if (!isLoggedIn.value) return; + favoriteFolders.value = await listFavoriteFolders(); + if (!activeFavoriteFolderId.value && favoriteFolders.value.length > 0) activeFavoriteFolderId.value = favoriteFolders.value[0].id; +}; + +const loadFavoriteItems = async (folderId: number) => { + favoritesLoading.value = true; + favoritesError.value = ""; + try { + activeFavoriteFolderId.value = folderId; + favoriteItems.value = await listFavoriteItems(folderId); + } catch (error) { + favoritesError.value = error instanceof Error ? error.message : "读取收藏失败"; + } finally { + favoritesLoading.value = false; + } +}; + +const openFavoriteSelector = async (app: App) => { + if (!requireLogin("收藏应用需要登录星火账号。")) return; + favoriteTargetApp.value = app; + await loadFavoriteFolders(); + showFavoriteSelector.value = true; +}; + +const addCurrentFavoriteToFolder = async (folderId: number | "default") => { + if (!favoriteTargetApp.value) return; + const app = favoriteTargetApp.value; + await addFavoriteItem(folderId, { + appKey: buildFavoriteAppKey(app), + pkgname: app.pkgname, + name: app.name, + category: app.category, + iconUrl: app.icons || "", + }); + showFavoriteSelector.value = false; + await loadFavoriteFolders(); +}; + +const createFavoriteFolderFromPrompt = async () => { + const name = window.prompt("收藏夹名称"); + if (!name?.trim()) return; + const folder = await createFavoriteFolder(name.trim()); + await loadFavoriteFolders(); + await loadFavoriteItems(folder.id); +}; + +const removeSelectedFavorites = async (itemIds: number[]) => { + if (!activeFavoriteFolderId.value || itemIds.length === 0) return; + await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, itemIds); + await loadFavoriteItems(activeFavoriteFolderId.value); + await loadFavoriteFolders(); +}; + +const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => { + for (const item of items) { + if (item.selectedApp) await onDetailInstall(item.selectedApp); + } +}; + +const openFavoriteManagement = async () => { + if (!requireLogin("我的收藏需要登录星火账号。")) return; + currentView.value = "favorites"; + activeTab.value = "favorites"; + await loadFavoriteFolders(); + if (activeFavoriteFolderId.value) await loadFavoriteItems(activeFavoriteFolderId.value); +}; +``` + +5. Add `FavoriteFolderSelector` near modals: + +```vue + +``` + +6. Add favorites content branch: + +```vue + +``` + +- [ ] **Step 8: Run favorite tests** + +Run: `npm run test -- src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts` + +Expected: PASS. + +- [ ] **Step 9: Commit favorites UI and resolver** + +Run: + +```bash +git add src/modules/favoriteAvailability.ts src/components/FavoriteFolderSelector.vue src/components/FavoriteFolderManager.vue src/App.vue src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts +git commit -m "feat(favorites): add cloud favorite management" +``` + +Expected: commit succeeds. + +## Task 5: Add Reviews Panel And Downloaded Record Writes + +**Files:** +- Create: `src/components/ReviewsPanel.vue` +- Modify: `src/components/AppDetailPage.vue` +- Modify: `src/modules/processInstall.ts` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/ReviewsPanel.test.ts` +- Test: `src/__tests__/unit/processInstall.test.ts` + +- [ ] **Step 1: Write failing ReviewsPanel test** + +Create `src/__tests__/unit/ReviewsPanel.test.ts` with: + +```typescript +import { render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import ReviewsPanel from "@/components/ReviewsPanel.vue"; +import type { ReviewTags } from "@/global/typedefinition"; + +const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", +}; + +describe("ReviewsPanel", () => { + it("shows anonymous login prompt and read-only review tags", () => { + render(ReviewsPanel, { props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false } }); + + expect(screen.getByText("登录后发表评论")).toBeTruthy(); + expect(screen.getByText("1.0.0")).toBeTruthy(); + expect(screen.getByText("deepin 25")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Extend processInstall test for queue result** + +Append to `src/__tests__/unit/processInstall.test.ts`: + +```typescript + it("returns queued download metadata for account records", async () => { + vi.doMock("axios", () => ({ + default: { + create: vi.fn(() => ({ post: vi.fn(() => Promise.resolve({ data: { ok: true } })) })), + }, + })); + Object.assign(window.ipcRenderer, { on: vi.fn(), send: vi.fn(), invoke: vi.fn() }); + window.apm_store.arch = "amd64"; + const { handleInstall } = await import("@/modules/processInstall"); + + const result = await handleInstall({ + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", + }); + + expect(result?.pkgname).toBe("wps"); + expect(result?.origin).toBe("apm"); + }); +``` + +- [ ] **Step 3: Run review/download tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts` + +Expected: FAIL because `ReviewsPanel` does not exist and `handleInstall` returns `undefined`. + +- [ ] **Step 4: Create ReviewsPanel** + +Create `src/components/ReviewsPanel.vue` with: + +```vue + + + +``` + +- [ ] **Step 5: Mount reviews in detail page** + +Modify `src/components/AppDetailPage.vue`: + +1. Import `ReviewsPanel` and `ReviewTags`. +2. Add below screenshots block: + +```vue + +``` + +- [ ] **Step 6: Return queued download from processInstall** + +Modify `src/modules/processInstall.ts`: + +1. Change signature: + +```typescript +export const handleInstall = async (appObj?: App): Promise => { +``` + +2. Replace early bare `return;` statements with `return null;`. +3. After `window.ipcRenderer.send("queue-install", JSON.stringify(download));`, add: + +```typescript + return download; +``` + +4. Keep statistics POST non-blocking after the return by moving the statistics call before return or by storing the promise before return. The install queue send must remain unchanged. + +- [ ] **Step 7: Record cloud downloaded apps in App.vue** + +Modify `src/App.vue`: + +1. Extend existing imports: + +```typescript +import { buildFavoriteAppKey, buildReviewAppKey, buildReviewTags, getDisplayApp, parsePackageArch } from "./modules/appIdentity"; +import { recordDownloadedApp } from "./modules/backendApi"; +import type { SystemInfo } from "./global/typedefinition"; +``` + +2. Add system info state: + +```typescript +const systemInfo = ref({ distro: "unknown" }); +``` + +3. Add computed review props: + +```typescript +const currentDisplayAppForReview = computed(() => getDisplayApp(currentApp.value)); +const currentReviewAppKey = computed(() => { + const app = currentDisplayAppForReview.value; + return app ? buildReviewAppKey(app, window.apm_store.arch || "amd64") : ""; +}); +const currentReviewTags = computed(() => { + const app = currentDisplayAppForReview.value; + return app ? buildReviewTags(app, { clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro }) : null; +}); +``` + +4. On mount, before data loading completes, fetch system info: + +```typescript +systemInfo.value = await window.ipcRenderer.invoke("get-system-info").catch(() => ({ distro: "unknown" })); +``` + +5. Replace `onDetailInstall` with: + +```typescript +const onDetailInstall = async (app: App) => { + const download = await handleInstall(app); + if (!download || !isLoggedIn.value) return; + try { + await recordDownloadedApp({ + appKey: buildFavoriteAppKey(app), + pkgname: app.pkgname, + name: app.name, + category: app.category, + selectedOrigin: app.origin, + version: app.version || "", + packageArch: app.arch || parsePackageArch(app.filename), + }); + } catch (error) { + logger.warn(`记录下载历史失败: ${error}`); + } +}; +``` + +- [ ] **Step 8: Add system info IPC** + +Modify `electron/main/index.ts` near `get-app-version`: + +```typescript +const getSystemInfo = (): { distro: string } => { + try { + const raw = fs.readFileSync("/etc/os-release", "utf8"); + const values = Object.fromEntries( + raw + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => { + const [key, ...rest] = line.split("="); + return [key, rest.join("=").replace(/^"|"$/g, "")]; + }), + ); + return { distro: values.PRETTY_NAME || values.NAME || values.ID || "unknown" }; + } catch { + return { distro: "unknown" }; + } +}; + +ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); +``` + +- [ ] **Step 9: Run review/download tests** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts` + +Expected: PASS. + +- [ ] **Step 10: Commit reviews and downloaded records** + +Run: + +```bash +git add electron/main/index.ts src/components/ReviewsPanel.vue src/components/AppDetailPage.vue src/modules/processInstall.ts src/App.vue src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts +git commit -m "feat(account): record downloads and show reviews" +``` + +Expected: commit succeeds. + +## Task 6: Add User Management, Downloaded History, And Sync Preference + +**Files:** +- Create: `src/global/accountSyncState.ts` +- Create: `src/components/UserManagementView.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/UserManagementView.test.ts` + +- [ ] **Step 1: Write failing user management test** + +Create `src/__tests__/unit/UserManagementView.test.ts` with: + +```typescript +import { render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import UserManagementView from "@/components/UserManagementView.vue"; +import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition"; + +const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], +}; + +const download: DownloadedAppRecord = { + id: 1, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + selectedOrigin: "apm", + version: "1.0.0", + packageArch: "amd64", + downloadedAt: "2026-05-18T00:00:00Z", +}; + +describe("UserManagementView", () => { + it("renders profile, forum level, links, downloads, and sync preference", () => { + render(UserManagementView, { + props: { user, downloadedApps: [download], syncEnabled: true, loading: false, error: "" }, + }); + + expect(screen.getByText("Momen")).toBeTruthy(); + expect(screen.getByText("管理员")).toBeTruthy(); + expect(screen.getByText("论坛首页")).toBeTruthy(); + expect(screen.getByText("修改论坛资料")).toBeTruthy(); + expect(screen.getByText("WPS")).toBeTruthy(); + expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked(); + }); +}); +``` + +- [ ] **Step 2: Run user management test and verify failure** + +Run: `npm run test -- src/__tests__/unit/UserManagementView.test.ts` + +Expected: FAIL because `UserManagementView` does not exist. + +- [ ] **Step 3: Add sync preference helper** + +Create `src/global/accountSyncState.ts` with: + +```typescript +import { ref, watch } from "vue"; + +const STORAGE_KEY = "spark-store-installed-sync-enabled"; + +const readSyncEnabled = (): boolean | null => { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === "true") return true; + if (raw === "false") return false; + return null; +}; + +export const installedSyncEnabled = ref(readSyncEnabled()); + +export const setInstalledSyncEnabled = (enabled: boolean) => { + installedSyncEnabled.value = enabled; + localStorage.setItem(STORAGE_KEY, enabled ? "true" : "false"); +}; + +watch(installedSyncEnabled, (enabled) => { + if (enabled === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, enabled ? "true" : "false"); +}); +``` + +- [ ] **Step 4: Create user management view** + +Create `src/components/UserManagementView.vue` with: + +```vue + + + +``` + +- [ ] **Step 5: Wire user management in App.vue** + +Modify `src/App.vue`: + +1. Import: + +```typescript +import UserManagementView from "./components/UserManagementView.vue"; +import { installedSyncEnabled, setInstalledSyncEnabled } from "./global/accountSyncState"; +import { listDownloadedApps } from "./modules/backendApi"; +import type { DownloadedAppRecord } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const downloadedApps = ref([]); +const downloadedLoading = ref(false); +const downloadedError = ref(""); +``` + +3. Add handler: + +```typescript +const loadDownloadedHistory = async () => { + if (!isLoggedIn.value) return; + downloadedLoading.value = true; + downloadedError.value = ""; + try { + const list = await listDownloadedApps(1, 50); + downloadedApps.value = list.items; + } catch (error) { + downloadedError.value = error instanceof Error ? error.message : "读取下载历史失败"; + } finally { + downloadedLoading.value = false; + } +}; +``` + +4. Change `openUserManagement` to: + +```typescript +const openUserManagement = async () => { + if (!requireLogin("用户管理需要登录星火账号。")) return; + currentView.value = "account"; + activeTab.value = "account"; + await loadDownloadedHistory(); +}; +``` + +5. Add content branch before favorites branch: + +```vue + +``` + +- [ ] **Step 6: Run user management tests** + +Run: `npm run test -- src/__tests__/unit/UserManagementView.test.ts` + +Expected: PASS. + +- [ ] **Step 7: Commit user management** + +Run: + +```bash +git add src/global/accountSyncState.ts src/components/UserManagementView.vue src/App.vue src/__tests__/unit/UserManagementView.test.ts +git commit -m "feat(account): add user management view" +``` + +Expected: commit succeeds. + +## Task 7: Add Installed-App Cloud Sync And Restore + +**Files:** +- Create: `src/modules/appListSync.ts` +- Create: `src/components/AppListRestoreModal.vue` +- Modify: `src/components/InstalledAppsModal.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/appListSync.test.ts` +- Test: `src/__tests__/unit/AppListRestoreModal.test.ts` +- Test: `src/__tests__/unit/InstalledAppsModal.test.ts` + +- [ ] **Step 1: Write failing app-list sync test** + +Create `src/__tests__/unit/appListSync.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildSyncItems } from "@/modules/appListSync"; +import type { App } from "@/global/typedefinition"; + +const baseApp: App = { + name: "Spark Notes", + pkgname: "spark-notes", + version: "1.0.0", + filename: "spark-notes_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "https://example.invalid/icon.png", + category: "office", + origin: "spark", + currentStatus: "installed", +}; + +describe("appListSync", () => { + it("syncs only store-recognized non-dependency apps", () => { + const items = buildSyncItems([ + baseApp, + { ...baseApp, pkgname: "unknown", category: "unknown" }, + { ...baseApp, pkgname: "dep", isDependency: true }, + { ...baseApp, pkgname: "not-installed", currentStatus: "not-installed" }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ pkgname: "spark-notes", origin: "spark", category: "office" }); + }); +}); +``` + +- [ ] **Step 2: Write failing restore modal test** + +Create `src/__tests__/unit/AppListRestoreModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppListRestoreModal from "@/components/AppListRestoreModal.vue"; + +describe("AppListRestoreModal", () => { + it("emits selected installable items", async () => { + const rendered = render(AppListRestoreModal, { + props: { + show: true, + loading: false, + error: "", + items: [{ pkgname: "spark-notes", origin: "spark", category: "office", version: "1.0.0", packageArch: "amd64", appName: "Spark Notes", iconUrl: "" }], + installedKeys: [], + }, + }); + + await fireEvent.click(screen.getByLabelText("选择 Spark Notes")); + await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" })); + + expect(rendered.emitted("install-selected")?.[0]?.[0]).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 3: Run sync/restore tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts` + +Expected: FAIL because module/component do not exist. + +- [ ] **Step 4: Add sync filtering module** + +Create `src/modules/appListSync.ts` with: + +```typescript +import type { App, SyncedAppListItem } from "@/global/typedefinition"; +import { parsePackageArch } from "@/modules/appIdentity"; + +export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => { + return apps + .filter((app) => app.currentStatus === "installed") + .filter((app) => app.category !== "unknown") + .filter((app) => !app.isDependency) + .filter((app) => Boolean(app.pkgname && app.origin)) + .map((app) => ({ + pkgname: app.pkgname, + origin: app.origin, + category: app.category, + version: app.version || "", + packageArch: app.arch || parsePackageArch(app.filename), + appName: app.name || app.pkgname, + iconUrl: app.icons || "", + })); +}; + +export const cloudItemKey = (item: Pick): string => `${item.origin}:${item.pkgname}`; +``` + +- [ ] **Step 5: Create restore modal** + +Create `src/components/AppListRestoreModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 6: Add sync buttons to InstalledAppsModal** + +Modify `src/components/InstalledAppsModal.vue`: + +1. Add props: + +```typescript + loggedIn: boolean; + syncing: boolean; +``` + +2. Add emits: + +```typescript + (e: "sync-to-account"): void; + (e: "restore-from-account"): void; + (e: "request-login"): void; +``` + +3. Add buttons before `刷新`: + +```vue + + +``` + +4. Update every `InstalledAppsModal.test.ts` render props to include `loggedIn: false` and `syncing: false`. + +- [ ] **Step 7: Wire sync/restore and startup prompt in App.vue** + +Modify `src/App.vue`: + +1. Import: + +```typescript +import AppListRestoreModal from "./components/AppListRestoreModal.vue"; +import { buildSyncItems, cloudItemKey } from "./modules/appListSync"; +import { fetchSyncedAppList, uploadSyncedAppList } from "./modules/backendApi"; +import type { SyncedAppListItem } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const syncLoading = ref(false); +const restoreLoading = ref(false); +const restoreError = ref(""); +const showRestoreModal = ref(false); +const restoreItems = ref([]); +``` + +3. Add computed installed keys: + +```typescript +const installedCloudKeys = computed(() => installedApps.value.map((app) => `${app.origin}:${app.pkgname}`)); +``` + +4. Pass props/events to `InstalledAppsModal`: + +```vue +:logged-in="isLoggedIn" +:syncing="syncLoading" +@sync-to-account="syncInstalledAppsToAccount" +@restore-from-account="openRestoreFromAccount" +@request-login="requireLogin('云端同步需要登录星火账号。')" +``` + +5. Mount restore modal: + +```vue + +``` + +6. Add the real sync handlers: + +```typescript +const syncInstalledAppsToAccount = async () => { + if (!requireLogin("云端同步需要登录星火账号。")) return; + syncLoading.value = true; + try { + const items = buildSyncItems(installedApps.value); + await uploadSyncedAppList({ clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro, items }); + } finally { + syncLoading.value = false; + } +}; + +const openRestoreFromAccount = async () => { + if (!requireLogin("从账号恢复应用需要登录星火账号。")) return; + restoreLoading.value = true; + restoreError.value = ""; + showRestoreModal.value = true; + try { + const list = await fetchSyncedAppList(); + restoreItems.value = list?.items || []; + } catch (error) { + restoreError.value = error instanceof Error ? error.message : "读取云端列表失败"; + } finally { + restoreLoading.value = false; + } +}; + +const installCloudItems = async (items: SyncedAppListItem[]) => { + for (const item of items) { + const app = apps.value.find((candidate) => candidate.pkgname === item.pkgname && candidate.origin === item.origin && candidate.category === item.category); + if (app) await onDetailInstall(app); + } +}; +``` + +7. Add `@sync-now="syncInstalledAppsToAccount"` to the existing `UserManagementView` branch created in Task 6. + +8. After catalog load completes in `onMounted`, add: + +```typescript +if (isLoggedIn.value && installedSyncEnabled.value === null) { + const enabled = window.confirm("是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。"); + setInstalledSyncEnabled(enabled); +} +if (isLoggedIn.value && installedSyncEnabled.value === true) { + await refreshInstalledApps(); + await syncInstalledAppsToAccount(); +} +``` + +- [ ] **Step 8: Run sync tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +``` + +Expected: PASS. + +- [ ] **Step 9: Commit installed sync and restore** + +Run: + +```bash +git add src/modules/appListSync.ts src/components/AppListRestoreModal.vue src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +git commit -m "feat(sync): add installed app cloud sync" +``` + +Expected: commit succeeds. + +## Task 8: Final Client Verification + +**Files:** +- Verify only. + +- [ ] **Step 1: Run account-related unit tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/processInstall.test.ts +``` + +Expected: all listed tests PASS. + +- [ ] **Step 2: Run full unit suite** + +Run: `npm run test -- --run` + +Expected: all unit tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `npm run lint` + +Expected: exits 0. + +- [ ] **Step 4: Run Vite build** + +Run: `npm run build:vite` + +Expected: exits 0. + +- [ ] **Step 5: Check worktree** + +Run: `git status --short --branch` + +Expected: branch shows only intentional commits and no `.superpowers/` artifacts. + +## Self-Review Checklist + +Spec coverage: + +- Clean client repo/worktree requirement: file structure and final verification assume `/home/spark/Desktop/shenmo-spark-store/spark-store`. +- Forum login and external registration: Task 2. +- Sidebar account entry, avatar/name, and quick menu: Task 2. +- Anonymous base browsing/search/detail/install/remove/update/installed viewing: Tasks 2, 3, 5, and 7 gate only account-only actions. +- Detail page inside content area with back: Task 3. +- Favorite add, folder selection, default folder: Task 4. +- Favorites as app-level identities: Tasks 3 and 4 use `app:{category}:{pkgname}`. +- Downlisted/unavailable favorite visibility and bulk remove: Task 4. +- Batch install from favorites using current priority: Task 4 resolver uses `getHybridDefaultOrigin` and source availability. +- Reviews/comments with anonymous prompt: Task 5. +- Downloaded records written only for logged-in installs: Task 5. +- User management profile, forum level, links, downloads, sync preference: Task 6. +- Startup installed sync ask-once and non-dependency store-recognized filtering: Task 7. +- Existing install/update IPC contracts preserved: Tasks 5 and 7 reuse `handleInstall` and do not alter queue payload schema. + +Placeholder scan: + +- No `TBD`, `TODO`, `implement later`, or placeholder test bodies remain. +- Long component tasks include concrete code blocks for the behavior under test; existing surrounding markup can be adjusted during implementation without changing the defined contracts. + +Type consistency: + +- Backend snake_case fields are mapped to client camelCase only inside `backendApi.ts`. +- Favorite key is always `app:{category}:{pkgname}`. +- Review key remains `{origin}:{store_arch}:{category}:{pkgname}`. +- `window.apm_store.arch` is treated as bare architecture such as `amd64`. +- Store filter uses existing `StoreFilter = "spark" | "apm" | "both"`.