From 4a8974e387d5cdcc03f319d80cff38cedb51e6fa Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Mon, 27 Apr 2026 18:04:00 +0800 Subject: [PATCH] Add admin settings UI and provider deletes --- backend/app/modules/providers/router.py | 17 + backend/app/modules/providers/service.py | 73 ++- .../admin/(secure)/provider-accounts/page.tsx | 38 +- .../admin/(secure)/provider-models/page.tsx | 37 +- .../app/admin/(secure)/system-config/page.tsx | 473 ++++++++++++++++-- frontend-web/src/app/globals.css | 130 ++++- 6 files changed, 714 insertions(+), 54 deletions(-) diff --git a/backend/app/modules/providers/router.py b/backend/app/modules/providers/router.py index 8cc3733..bf18988 100644 --- a/backend/app/modules/providers/router.py +++ b/backend/app/modules/providers/router.py @@ -38,6 +38,15 @@ def update_provider_account( return success_response(ProvidersService(db).update_account(account_id, payload)) +@router.delete("/provider-accounts/{account_id}") +def delete_provider_account( + account_id: int, + _=Depends(require_admin_permission()), + db: Session = Depends(get_db), +): + return success_response(ProvidersService(db).delete_account(account_id)) + + @router.get("/provider-models") def list_provider_models( _=Depends(require_admin_permission()), @@ -64,3 +73,11 @@ def update_provider_model( ): return success_response(ProvidersService(db).update_model(model_id, payload)) + +@router.delete("/provider-models/{model_id}") +def delete_provider_model( + model_id: int, + _=Depends(require_admin_permission()), + db: Session = Depends(get_db), +): + return success_response(ProvidersService(db).delete_model(model_id)) diff --git a/backend/app/modules/providers/service.py b/backend/app/modules/providers/service.py index 35fc156..a06a8d2 100644 --- a/backend/app/modules/providers/service.py +++ b/backend/app/modules/providers/service.py @@ -1,7 +1,13 @@ +from sqlalchemy import delete, func, or_, select from sqlalchemy.orm import Session -from app.common.errors.app_error import NotFoundAppError -from app.models.entities import ProviderAccount, ProviderModel +from app.common.errors.app_error import BusinessAppError, NotFoundAppError +from app.models.entities import ( + ProviderAccount, + ProviderModel, + VideoGenerationTask, + VideoModelSupplierBinding, +) from app.modules.providers.repository import ProvidersRepository @@ -50,6 +56,43 @@ class ProvidersService: self.db.commit() return self.serialize_account(item) + def delete_account(self, account_id: int) -> dict: + item = self.repository.get_account(account_id) + if not item: + raise NotFoundAppError("provider account not found", code=60001) + + model_ids = list( + self.db.scalars( + select(ProviderModel.id).where(ProviderModel.provider_account_id == account_id) + ) + ) + task_conditions = [VideoGenerationTask.provider_account_id == account_id] + if model_ids: + task_conditions.append(VideoGenerationTask.provider_model_id.in_(model_ids)) + task_count = self.db.scalar( + select(func.count()) + .select_from(VideoGenerationTask) + .where(or_(*task_conditions)) + ) + if task_count: + raise BusinessAppError( + "该供应商已有任务记录,不能直接删除,请先停用。", + code=60011, + ) + + if model_ids: + self.db.execute( + delete(VideoModelSupplierBinding).where( + VideoModelSupplierBinding.provider_model_id.in_(model_ids) + ) + ) + self.db.execute( + delete(ProviderModel).where(ProviderModel.provider_account_id == account_id) + ) + self.db.delete(item) + self.db.commit() + return {"id": account_id, "deleted": True} + def list_models(self) -> list[dict]: accounts = {item.id: item for item in self.repository.list_accounts().all()} return [self.serialize_model(item, accounts) for item in self.repository.list_models().all()] @@ -72,6 +115,31 @@ class ProvidersService: account = self.repository.get_account(item.provider_account_id) return self.serialize_model(item, {account.id: account} if account else {}) + def delete_model(self, model_id: int) -> dict: + item = self.repository.get_model(model_id) + if not item: + raise NotFoundAppError("provider model not found", code=60002) + + task_count = self.db.scalar( + select(func.count()) + .select_from(VideoGenerationTask) + .where(VideoGenerationTask.provider_model_id == model_id) + ) + if task_count: + raise BusinessAppError( + "该模型已有任务记录,不能直接删除,请先停用。", + code=60012, + ) + + self.db.execute( + delete(VideoModelSupplierBinding).where( + VideoModelSupplierBinding.provider_model_id == model_id + ) + ) + self.db.delete(item) + self.db.commit() + return {"id": model_id, "deleted": True} + @staticmethod def serialize_account(item: ProviderAccount) -> dict: return { @@ -109,4 +177,3 @@ class ProvidersService: "defaultResolution": item.default_resolution, "status": item.status, } - diff --git a/frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx b/frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx index 15460b3..fe964a8 100644 --- a/frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx +++ b/frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx @@ -1,10 +1,10 @@ "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CheckCircle2, PlugZap } from "lucide-react"; +import { CheckCircle2, PlugZap, Trash2 } from "lucide-react"; import { useState } from "react"; -import { api } from "@/lib/api"; +import { api, ApiError } from "@/lib/api"; type ProviderAccountRow = { id: number; @@ -74,6 +74,18 @@ export default function ProviderAccountsPage() { }, }); + const deleteMutation = useMutation({ + mutationFn: (accountId: number) => + api.del(`/api/v1/admin/provider-accounts/${accountId}`), + async onSuccess() { + setFeedback("渠道已删除。"); + await query.refetch(); + }, + onError(error) { + setFeedback(error instanceof ApiError ? error.message : "删除失败,请稍后重试。"); + }, + }); + return (
@@ -228,9 +240,25 @@ export default function ProviderAccountsPage() { {item.providerName}
{item.providerCode}
- - {item.status === 1 ? "启用" : "停用"} - +
+ + {item.status === 1 ? "启用" : "停用"} + + +
{protocolOptions.find((option) => option.value === item.apiFormat)?.label ?? item.apiFormat} diff --git a/frontend-web/src/app/admin/(secure)/provider-models/page.tsx b/frontend-web/src/app/admin/(secure)/provider-models/page.tsx index 08115df..3dedf12 100644 --- a/frontend-web/src/app/admin/(secure)/provider-models/page.tsx +++ b/frontend-web/src/app/admin/(secure)/provider-models/page.tsx @@ -1,10 +1,10 @@ "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { Boxes, Plus } from "lucide-react"; +import { Boxes, Plus, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; -import { api } from "@/lib/api"; +import { api, ApiError } from "@/lib/api"; type ProviderAccountRow = { id: number; @@ -100,6 +100,17 @@ export default function ProviderModelsPage() { }, }); + const deleteMutation = useMutation({ + mutationFn: (modelId: number) => api.del(`/api/v1/admin/provider-models/${modelId}`), + async onSuccess() { + setFeedback("模型已删除。"); + await modelsQuery.refetch(); + }, + onError(error) { + setFeedback(error instanceof ApiError ? error.message : "删除失败,请稍后重试。"); + }, + }); + return (
@@ -279,9 +290,25 @@ export default function ProviderModelsPage() { {item.modelName || item.modelCode}
{item.modelCode}
- - {item.status === 1 ? "启用" : "停用"} - +
+ + {item.status === 1 ? "启用" : "停用"} + + +
{item.providerName} diff --git a/frontend-web/src/app/admin/(secure)/system-config/page.tsx b/frontend-web/src/app/admin/(secure)/system-config/page.tsx index 52bd865..af16a94 100644 --- a/frontend-web/src/app/admin/(secure)/system-config/page.tsx +++ b/frontend-web/src/app/admin/(secure)/system-config/page.tsx @@ -1,73 +1,466 @@ "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { Save, Settings2, SlidersHorizontal } from "lucide-react"; +import { useMemo, useState } from "react"; -import { api } from "@/lib/api"; +import { api, ApiError } from "@/lib/api"; type ConfigRow = { configKey: string; configValue: string; + valueType: string; groupName: string; + description: string; + isPublic: boolean; }; -const fieldLabels: Record = { - config_key: "配置键", - config_value: "配置值", - value_type: "值类型", - group_name: "配置分组", - description: "配置说明", - is_public: "是否公开", +type EditableConfig = { + key: string; + label: string; + helper: string; + group: string; + valueType: "string" | "number" | "boolean"; + control: "text" | "textarea" | "number" | "switch"; + defaultValue: string; + isPublic: boolean; }; +const groupMeta = [ + { key: "site", title: "站点设置", subtitle: "前台展示名称和公告文案。" }, + { key: "reward", title: "奖励设置", subtitle: "注册奖励、邀请奖励的开关和积分。" }, + { key: "invite", title: "邀请设置", subtitle: "控制邀请码体系是否开放。" }, + { key: "task", title: "任务设置", subtitle: "视频任务调度和轮询默认值。" }, +]; + +const editableConfigs: EditableConfig[] = [ + { + key: "site.title", + label: "站点名称", + helper: "展示在前台导航、标题和登录页。", + group: "site", + valueType: "string", + control: "text", + defaultValue: "AIVideo", + isPublic: true, + }, + { + key: "site.notice", + label: "站点公告", + helper: "给用户展示的运营提示,可以留空。", + group: "site", + valueType: "string", + control: "textarea", + defaultValue: "", + isPublic: true, + }, + { + key: "reward.signup.enabled", + label: "注册奖励", + helper: "开启后,新用户注册自动发放积分。", + group: "reward", + valueType: "boolean", + control: "switch", + defaultValue: "1", + isPublic: false, + }, + { + key: "reward.signup.points", + label: "注册奖励积分", + helper: "新用户注册时发放的积分数量。", + group: "reward", + valueType: "number", + control: "number", + defaultValue: "300", + isPublic: false, + }, + { + key: "reward.invite.enabled", + label: "邀请奖励", + helper: "开启后,邀请关系成立时发放奖励。", + group: "reward", + valueType: "boolean", + control: "switch", + defaultValue: "1", + isPublic: false, + }, + { + key: "reward.invite.points", + label: "邀请奖励积分", + helper: "邀请成功后给邀请人的积分数量。", + group: "reward", + valueType: "number", + control: "number", + defaultValue: "500", + isPublic: false, + }, + { + key: "invite.code.enabled", + label: "邀请码系统", + helper: "关闭后不再展示邀请入口和邀请码能力。", + group: "invite", + valueType: "boolean", + control: "switch", + defaultValue: "1", + isPublic: false, + }, + { + key: "task.default_poll_interval_seconds", + label: "任务轮询间隔", + helper: "后台轮询供应商任务状态的默认秒数。", + group: "task", + valueType: "number", + control: "number", + defaultValue: "5", + isPublic: false, + }, +]; + +const editableKeys = new Set(editableConfigs.map((item) => item.key)); + +function isEnabled(value: string) { + return value === "1" || value === "true"; +} + export default function SystemConfigPage() { - const [form, setForm] = useState({ - config_key: "site.notice", - config_value: "当前为本地联调环境。", - value_type: "string", - group_name: "site", - description: "公告", - is_public: true, + const [draftValues, setDraftValues] = useState>({}); + const [customForm, setCustomForm] = useState({ + configKey: "", + configValue: "", + valueType: "string", + groupName: "custom", + description: "", + isPublic: false, }); + const [feedback, setFeedback] = useState(""); + const query = useQuery({ queryKey: ["system-config"], queryFn: () => api.get("/api/v1/admin/system-config"), }); - const mutation = useMutation({ - mutationFn: () => api.put("/api/v1/admin/system-config", form), - onSuccess: () => query.refetch(), + + const configByKey = useMemo( + () => new Map((query.data ?? []).map((item) => [item.configKey, item])), + [query.data], + ); + + const groupedConfigs = useMemo( + () => + groupMeta.map((group) => ({ + ...group, + configs: editableConfigs.filter((config) => config.group === group.key), + })), + [], + ); + + const otherConfigs = useMemo( + () => (query.data ?? []).filter((item) => !editableKeys.has(item.configKey)), + [query.data], + ); + + const saveMutation = useMutation({ + mutationFn: () => + Promise.all( + editableConfigs.map((config) => + api.put("/api/v1/admin/system-config", { + config_key: config.key, + config_value: + draftValues[config.key] ?? + configByKey.get(config.key)?.configValue ?? + config.defaultValue, + value_type: config.valueType, + group_name: config.group, + description: config.label, + is_public: config.isPublic, + }), + ), + ), + async onSuccess() { + setFeedback("系统设置已保存。"); + setDraftValues({}); + await query.refetch(); + }, + onError(error) { + setFeedback(error instanceof ApiError ? error.message : "保存失败,请稍后重试。"); + }, }); + const customMutation = useMutation({ + mutationFn: () => + api.put("/api/v1/admin/system-config", { + config_key: customForm.configKey.trim(), + config_value: customForm.configValue, + value_type: customForm.valueType, + group_name: customForm.groupName.trim() || "custom", + description: customForm.description.trim(), + is_public: customForm.isPublic, + }), + async onSuccess() { + setFeedback("高级配置已保存。"); + setCustomForm((previous) => ({ + ...previous, + configKey: "", + configValue: "", + description: "", + })); + await query.refetch(); + }, + onError(error) { + setFeedback(error instanceof ApiError ? error.message : "保存失败,请稍后重试。"); + }, + }); + + function getConfigValue(config: EditableConfig) { + return ( + draftValues[config.key] ?? + configByKey.get(config.key)?.configValue ?? + config.defaultValue + ); + } + + function renderControl(config: EditableConfig) { + const value = getConfigValue(config); + if (config.control === "switch") { + const enabled = isEnabled(value); + return ( + + ); + } + + if (config.control === "textarea") { + return ( +