feat: 支持网站目标分组轻量管理(新增、编辑、描述展示),不修改和不提交倍率
This commit is contained in:
@@ -30,6 +30,8 @@ from app.schemas.website import (
|
|||||||
TestResult,
|
TestResult,
|
||||||
WebsiteCreate,
|
WebsiteCreate,
|
||||||
WebsiteGroupResponse,
|
WebsiteGroupResponse,
|
||||||
|
WebsiteGroupCreate,
|
||||||
|
WebsiteGroupUpdate,
|
||||||
WebsiteResponse,
|
WebsiteResponse,
|
||||||
WebsiteSyncLogResponse,
|
WebsiteSyncLogResponse,
|
||||||
WebsiteUpdate,
|
WebsiteUpdate,
|
||||||
@@ -291,6 +293,49 @@ def list_website_groups(wid: int, db: Session = Depends(get_db), _=Depends(get_c
|
|||||||
raise HTTPException(502, str(exc))
|
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)
|
@router.post("/api/websites/{wid}/groups/import-from-upstream/{upstream_id}", response_model=ImportGroupsResponse)
|
||||||
def import_groups_from_upstream(
|
def import_groups_from_upstream(
|
||||||
wid: int,
|
wid: int,
|
||||||
|
|||||||
@@ -64,9 +64,20 @@ class WebsiteGroupResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
rate_multiplier: Optional[str] = None
|
rate_multiplier: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
raw: dict[str, Any] = {}
|
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):
|
class BindingSourceGroup(BaseModel):
|
||||||
upstream_id: int
|
upstream_id: int
|
||||||
group_id: str
|
group_id: str
|
||||||
|
|||||||
@@ -113,7 +113,13 @@ def normalize_groups(value: Any) -> list[dict[str, Any]]:
|
|||||||
groups: list[dict[str, Any]] = []
|
groups: list[dict[str, Any]] = []
|
||||||
for item in raw:
|
for item in raw:
|
||||||
if isinstance(item, str):
|
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
|
continue
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
@@ -122,10 +128,12 @@ def normalize_groups(value: Any) -> list[dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
name = item.get("name") or item.get("group_name") or str(gid)
|
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")
|
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({
|
groups.append({
|
||||||
"id": str(gid),
|
"id": str(gid),
|
||||||
"name": str(name),
|
"name": str(name),
|
||||||
"rate_multiplier": fixed_decimal_string(rate, 2) if rate is not None else None,
|
"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,
|
"raw": item,
|
||||||
})
|
})
|
||||||
return groups
|
return groups
|
||||||
@@ -262,6 +270,12 @@ class Sub2ApiWebsiteClient:
|
|||||||
path = endpoint_template.replace("{id}", quote(group_id, safe=""))
|
path = endpoint_template.replace("{id}", quote(group_id, safe=""))
|
||||||
return self._request("PUT", path, {"rate_multiplier": fixed_decimal_number(rate, 2)})
|
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]:
|
def create_group(self, body: dict[str, Any], endpoint: str = "/groups") -> dict[str, Any]:
|
||||||
resp = self._request("POST", endpoint, body)
|
resp = self._request("POST", endpoint, body)
|
||||||
data = _unwrap_data(resp)
|
data = _unwrap_data(resp)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def test_normalize_groups_unwraps_wrapped_list_response():
|
|||||||
"id": "default",
|
"id": "default",
|
||||||
"name": "Default",
|
"name": "Default",
|
||||||
"rate_multiplier": "2.00",
|
"rate_multiplier": "2.00",
|
||||||
|
"description": None,
|
||||||
"raw": {"id": "default", "name": "Default", "rateMultiplier": "2.0"},
|
"raw": {"id": "default", "name": "Default", "rateMultiplier": "2.0"},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -217,6 +217,7 @@ export interface WebsiteGroup {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
rate_multiplier: string | null
|
rate_multiplier: string | null
|
||||||
|
description?: string | null
|
||||||
raw: Record<string, any>
|
raw: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +337,8 @@ export const websitesApi = {
|
|||||||
delete: (id: number) => api.delete(`/api/websites/${id}`),
|
delete: (id: number) => api.delete(`/api/websites/${id}`),
|
||||||
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/websites/${id}/test`),
|
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/websites/${id}/test`),
|
||||||
groups: (id: number) => api.get<WebsiteGroup[]>(`/api/websites/${id}/groups`),
|
groups: (id: number) => api.get<WebsiteGroup[]>(`/api/websites/${id}/groups`),
|
||||||
|
createGroup: (id: number, data: { name: string; description?: string | null }) => api.post<any>(`/api/websites/${id}/groups`, data),
|
||||||
|
updateGroup: (id: number, groupId: string, data: { name: string; description?: string | null }) => api.put<any>(`/api/websites/${id}/groups/${groupId}`, data),
|
||||||
importGroupsFromUpstream: (id: number, upstreamId: number, data: { group_ids: string[]; name_prefix: string }) =>
|
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),
|
api.post<{ success: boolean; message: string; items: ImportGroupItem[] }>(`/api/websites/${id}/groups/import-from-upstream/${upstreamId}`, data),
|
||||||
syncImportedUpstreamKeys: (id: number, data: { upstream_id: number }) =>
|
syncImportedUpstreamKeys: (id: number, data: { upstream_id: number }) =>
|
||||||
|
|||||||
@@ -70,16 +70,19 @@
|
|||||||
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
|
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button size="small" :disabled="!selectedWebsite" :loading="groupsLoading" @click="loadWebsiteGroups">拉取分组</el-button>
|
<el-button size="small" :disabled="!selectedWebsite" :loading="groupsLoading" @click="loadWebsiteGroups">拉取分组</el-button>
|
||||||
|
<el-button size="small" type="primary" :disabled="!selectedWebsite" @click="openGroupCreate">新增分组</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="sortedWebsiteGroups" v-loading="groupsLoading" row-key="id" size="small" style="width:100%">
|
<el-table :data="sortedWebsiteGroups" v-loading="groupsLoading" row-key="id" size="small" style="width:100%">
|
||||||
<el-table-column prop="name" label="分组" min-width="150" />
|
<el-table-column prop="name" label="分组" min-width="150" />
|
||||||
<el-table-column prop="id" label="ID" min-width="130" />
|
<el-table-column prop="id" label="ID" min-width="130" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="150" />
|
||||||
<el-table-column label="倍率" width="100">
|
<el-table-column label="倍率" width="100">
|
||||||
<template #default="{ row }"><span class="rate-value">{{ row.rate_multiplier ?? '—' }}</span></template>
|
<template #default="{ row }"><span class="rate-value">{{ row.rate_multiplier ?? '—' }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90">
|
<el-table-column label="操作" width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" text :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
|
<el-button size="small" text :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
|
||||||
|
<el-button size="small" text :disabled="!selectedWebsite" @click="openGroupEdit(row)">编辑</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -307,6 +310,30 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 新增/编辑分组弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="groupFormVisible"
|
||||||
|
:title="groupFormTitle"
|
||||||
|
width="450px"
|
||||||
|
destroy-on-close
|
||||||
|
class="custom-theme-dialog"
|
||||||
|
>
|
||||||
|
<el-form :model="groupForm" :rules="groupFormRules" ref="groupFormRef" label-width="80px">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="groupForm.name" placeholder="请输入分组名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述" prop="description">
|
||||||
|
<el-input v-model="groupForm.description" type="textarea" :rows="3" placeholder="请输入描述/备注(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="groupFormVisible = false" class="btn-cancel">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="groupFormSaving" @click="saveWebsiteGroup" class="btn-save">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 选择上游分组弹窗 -->
|
<!-- 选择上游分组弹窗 -->
|
||||||
<el-dialog v-model="groupSelectVisible" title="选择上游分组" width="min(85vw, 1200px)" destroy-on-close class="group-select-dialog custom-theme-dialog">
|
<el-dialog v-model="groupSelectVisible" title="选择上游分组" width="min(85vw, 1200px)" destroy-on-close class="group-select-dialog custom-theme-dialog">
|
||||||
<!-- 搜索和倍率筛选条 -->
|
<!-- 搜索和倍率筛选条 -->
|
||||||
@@ -851,6 +878,22 @@ const importGroupsForm = ref({
|
|||||||
})
|
})
|
||||||
const importGroupResults = ref<ImportGroupItem[]>([])
|
const importGroupResults = ref<ImportGroupItem[]>([])
|
||||||
|
|
||||||
|
const groupFormVisible = ref(false)
|
||||||
|
const groupFormSaving = ref(false)
|
||||||
|
const editingGroupId = ref<string | null>(null)
|
||||||
|
const groupFormRef = ref<FormInstance>()
|
||||||
|
const groupForm = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
const groupFormRules = {
|
||||||
|
name: [{ required: true, message: '请输入分组名称', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupFormTitle = computed(() => {
|
||||||
|
return editingGroupId.value ? '编辑分组' : '新增分组'
|
||||||
|
})
|
||||||
|
|
||||||
const importAccountsDialog = ref(false)
|
const importAccountsDialog = ref(false)
|
||||||
const importAccountsForm = ref({
|
const importAccountsForm = ref({
|
||||||
website_id: 0,
|
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))
|
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() {
|
async function saveBinding() {
|
||||||
if (!bindingForm.value.source_groups.length) {
|
if (!bindingForm.value.source_groups.length) {
|
||||||
ElMessage.error('请选择至少一个监听上游分组')
|
ElMessage.error('请选择至少一个监听上游分组')
|
||||||
|
|||||||
Reference in New Issue
Block a user