diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 6aec13e..d175367 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -30,6 +30,8 @@ from app.schemas.website import ( TestResult, WebsiteCreate, WebsiteGroupResponse, + WebsiteGroupCreate, + WebsiteGroupUpdate, WebsiteResponse, WebsiteSyncLogResponse, WebsiteUpdate, @@ -291,6 +293,49 @@ def list_website_groups(wid: int, db: Session = Depends(get_db), _=Depends(get_c raise HTTPException(502, str(exc)) +@router.post("/api/websites/{wid}/groups", response_model=dict) +def create_website_group( + wid: int, + body: WebsiteGroupCreate, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + row = db.query(Website).filter(Website.id == wid).first() + if not row: + raise HTTPException(404, "website not found") + try: + with _client(row) as c: + payload = { + "name": body.name, + "description": body.description, + } + return c.create_group(payload, row.groups_endpoint) + except Exception as exc: + raise HTTPException(502, str(exc)) + + +@router.put("/api/websites/{wid}/groups/{group_id}", response_model=dict) +def update_website_group( + wid: int, + group_id: str, + body: WebsiteGroupUpdate, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + row = db.query(Website).filter(Website.id == wid).first() + if not row: + raise HTTPException(404, "website not found") + try: + with _client(row) as c: + payload = { + "name": body.name, + "description": body.description, + } + return c.update_group(row.group_update_endpoint, group_id, payload) + except Exception as exc: + raise HTTPException(502, str(exc)) + + @router.post("/api/websites/{wid}/groups/import-from-upstream/{upstream_id}", response_model=ImportGroupsResponse) def import_groups_from_upstream( wid: int, diff --git a/backend/app/schemas/website.py b/backend/app/schemas/website.py index f55d9be..df3ab23 100644 --- a/backend/app/schemas/website.py +++ b/backend/app/schemas/website.py @@ -64,9 +64,20 @@ class WebsiteGroupResponse(BaseModel): id: str name: str rate_multiplier: Optional[str] = None + description: Optional[str] = None raw: dict[str, Any] = {} +class WebsiteGroupCreate(BaseModel): + name: str = Field(..., min_length=1) + description: Optional[str] = None + + +class WebsiteGroupUpdate(BaseModel): + name: str = Field(..., min_length=1) + description: Optional[str] = None + + class BindingSourceGroup(BaseModel): upstream_id: int group_id: str diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index d726a2c..4492d38 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -113,7 +113,13 @@ def normalize_groups(value: Any) -> list[dict[str, Any]]: groups: list[dict[str, Any]] = [] for item in raw: if isinstance(item, str): - groups.append({"id": item, "name": item, "rate_multiplier": None, "raw": {"id": item, "name": item}}) + groups.append({ + "id": item, + "name": item, + "rate_multiplier": None, + "description": None, + "raw": {"id": item, "name": item} + }) continue if not isinstance(item, dict): continue @@ -122,10 +128,12 @@ def normalize_groups(value: Any) -> list[dict[str, Any]]: continue name = item.get("name") or item.get("group_name") or str(gid) rate = item.get("rate_multiplier") or item.get("rateMultiplier") or item.get("ratio") + desc = item.get("description") or item.get("desc") or item.get("remark") groups.append({ "id": str(gid), "name": str(name), "rate_multiplier": fixed_decimal_string(rate, 2) if rate is not None else None, + "description": str(desc) if desc is not None else None, "raw": item, }) return groups @@ -262,6 +270,12 @@ class Sub2ApiWebsiteClient: path = endpoint_template.replace("{id}", quote(group_id, safe="")) return self._request("PUT", path, {"rate_multiplier": fixed_decimal_number(rate, 2)}) + def update_group(self, endpoint_template: str, group_id: str, body: dict[str, Any]) -> dict[str, Any]: + path = endpoint_template.replace("{id}", quote(group_id, safe="")) + resp = self._request("PUT", path, body) + data = _unwrap_data(resp) + return data if isinstance(data, dict) else {"value": data} + def create_group(self, body: dict[str, Any], endpoint: str = "/groups") -> dict[str, Any]: resp = self._request("POST", endpoint, body) data = _unwrap_data(resp) diff --git a/backend/test_website_client.py b/backend/test_website_client.py index f4334cd..a84bbdd 100644 --- a/backend/test_website_client.py +++ b/backend/test_website_client.py @@ -49,6 +49,7 @@ def test_normalize_groups_unwraps_wrapped_list_response(): "id": "default", "name": "Default", "rate_multiplier": "2.00", + "description": None, "raw": {"id": "default", "name": "Default", "rateMultiplier": "2.0"}, }] diff --git a/backend/test_website_group_mgmt.py b/backend/test_website_group_mgmt.py new file mode 100644 index 0000000..f5799a9 --- /dev/null +++ b/backend/test_website_group_mgmt.py @@ -0,0 +1,79 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base +from app.models.website import Website +from app.routers.websites import create_website_group, update_website_group +from app.schemas.website import WebsiteGroupCreate, WebsiteGroupUpdate + + +@pytest.fixture() +def db_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key + Base.metadata.create_all(bind=engine) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_create_and_update_website_group(db_session, monkeypatch): + w = Website( + name="W1", + site_type="sub2api", + base_url="http://w1", + enabled=True, + auth_config_json="{}", + timeout_seconds=30 + ) + db_session.add(w) + db_session.commit() + db_session.refresh(w) + + called_create = [] + called_update = [] + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + def __enter__(self): + return self + def __exit__(self, *args): + pass + def create_group(self, body, endpoint): + called_create.append((body, endpoint)) + return {"id": "new-gid", "name": body["name"], "description": body.get("description")} + def update_group(self, endpoint_template, group_id, body): + called_update.append((endpoint_template, group_id, body)) + return {"id": group_id, "name": body["name"], "description": body.get("description")} + + monkeypatch.setattr("app.routers.websites._client", lambda website: FakeClient()) + + # 1. Test create_website_group + body_create = WebsiteGroupCreate(name="NewGroup", description="NewDesc") + res_create = create_website_group(wid=w.id, body=body_create, db=db_session) + assert res_create["id"] == "new-gid" + assert res_create["name"] == "NewGroup" + assert res_create["description"] == "NewDesc" + assert len(called_create) == 1 + assert called_create[0][0] == {"name": "NewGroup", "description": "NewDesc"} + + # 2. Test update_website_group + body_update = WebsiteGroupUpdate(name="UpdatedGroup", description="UpdatedDesc") + res_update = update_website_group(wid=w.id, group_id="g123", body=body_update, db=db_session) + assert res_update["id"] == "g123" + assert res_update["name"] == "UpdatedGroup" + assert res_update["description"] == "UpdatedDesc" + assert len(called_update) == 1 + assert called_update[0][1] == "g123" + assert called_update[0][2] == {"name": "UpdatedGroup", "description": "UpdatedDesc"} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 72fe2aa..5682db7 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -217,6 +217,7 @@ export interface WebsiteGroup { id: string name: string rate_multiplier: string | null + description?: string | null raw: Record } @@ -336,6 +337,8 @@ export const websitesApi = { delete: (id: number) => api.delete(`/api/websites/${id}`), test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/websites/${id}/test`), groups: (id: number) => api.get(`/api/websites/${id}/groups`), + createGroup: (id: number, data: { name: string; description?: string | null }) => api.post(`/api/websites/${id}/groups`, data), + updateGroup: (id: number, groupId: string, data: { name: string; description?: string | null }) => api.put(`/api/websites/${id}/groups/${groupId}`, data), importGroupsFromUpstream: (id: number, upstreamId: number, data: { group_ids: string[]; name_prefix: string }) => api.post<{ success: boolean; message: string; items: ImportGroupItem[] }>(`/api/websites/${id}/groups/import-from-upstream/${upstreamId}`, data), syncImportedUpstreamKeys: (id: number, data: { upstream_id: number }) => diff --git a/frontend/src/views/Websites.vue b/frontend/src/views/Websites.vue index bfbd251..7eeb286 100644 --- a/frontend/src/views/Websites.vue +++ b/frontend/src/views/Websites.vue @@ -70,16 +70,19 @@ 拉取分组 + 新增分组 + - + @@ -307,6 +310,30 @@ + + + + + + + + + + + + + @@ -851,6 +878,22 @@ const importGroupsForm = ref({ }) const importGroupResults = ref([]) +const groupFormVisible = ref(false) +const groupFormSaving = ref(false) +const editingGroupId = ref(null) +const groupFormRef = ref() +const groupForm = ref({ + name: '', + description: '', +}) +const groupFormRules = { + name: [{ required: true, message: '请输入分组名称', trigger: 'blur' }], +} + +const groupFormTitle = computed(() => { + return editingGroupId.value ? '编辑分组' : '新增分组' +}) + const importAccountsDialog = ref(false) const importAccountsForm = ref({ website_id: 0, @@ -1255,6 +1298,56 @@ function onSourceGroupsChange(values: string[]) { bindingForm.value.source_groups = values.map(value => options.get(value)).filter((item): item is BindingSourceGroup => Boolean(item)) } +function openGroupCreate() { + editingGroupId.value = null + groupForm.value = { + name: '', + description: '', + } + groupFormVisible.value = true +} + +function openGroupEdit(row: WebsiteGroup) { + editingGroupId.value = row.id + groupForm.value = { + name: row.name, + description: row.description || '', + } + groupFormVisible.value = true +} + +async function saveWebsiteGroup() { + if (!selectedWebsite.value) return + const websiteId = selectedWebsite.value.id + if (!groupFormRef.value) return + + await groupFormRef.value.validate(async (valid) => { + if (!valid) return + groupFormSaving.value = true + try { + if (editingGroupId.value) { + await websitesApi.updateGroup(websiteId, editingGroupId.value, { + name: groupForm.value.name, + description: groupForm.value.description, + }) + ElMessage.success('编辑成功') + } else { + await websitesApi.createGroup(websiteId, { + name: groupForm.value.name, + description: groupForm.value.description, + }) + ElMessage.success('新增成功') + } + groupFormVisible.value = false + await loadWebsiteGroups() + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || e.message || '保存失败') + } finally { + groupFormSaving.value = false + } + }) +} + async function saveBinding() { if (!bindingForm.value.source_groups.length) { ElMessage.error('请选择至少一个监听上游分组')