Add admin settings UI and provider deletes

This commit is contained in:
2026-04-27 18:04:00 +08:00
parent 5eb32b32a6
commit 4a8974e387
6 changed files with 714 additions and 54 deletions

View File

@@ -38,6 +38,15 @@ def update_provider_account(
return success_response(ProvidersService(db).update_account(account_id, payload)) 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") @router.get("/provider-models")
def list_provider_models( def list_provider_models(
_=Depends(require_admin_permission()), _=Depends(require_admin_permission()),
@@ -64,3 +73,11 @@ def update_provider_model(
): ):
return success_response(ProvidersService(db).update_model(model_id, payload)) 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))

View File

@@ -1,7 +1,13 @@
from sqlalchemy import delete, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.common.errors.app_error import NotFoundAppError from app.common.errors.app_error import BusinessAppError, NotFoundAppError
from app.models.entities import ProviderAccount, ProviderModel from app.models.entities import (
ProviderAccount,
ProviderModel,
VideoGenerationTask,
VideoModelSupplierBinding,
)
from app.modules.providers.repository import ProvidersRepository from app.modules.providers.repository import ProvidersRepository
@@ -50,6 +56,43 @@ class ProvidersService:
self.db.commit() self.db.commit()
return self.serialize_account(item) 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]: def list_models(self) -> list[dict]:
accounts = {item.id: item for item in self.repository.list_accounts().all()} 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()] 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) account = self.repository.get_account(item.provider_account_id)
return self.serialize_model(item, {account.id: account} if account else {}) 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 @staticmethod
def serialize_account(item: ProviderAccount) -> dict: def serialize_account(item: ProviderAccount) -> dict:
return { return {
@@ -109,4 +177,3 @@ class ProvidersService:
"defaultResolution": item.default_resolution, "defaultResolution": item.default_resolution,
"status": item.status, "status": item.status,
} }

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useMutation, useQuery } from "@tanstack/react-query"; 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 { useState } from "react";
import { api } from "@/lib/api"; import { api, ApiError } from "@/lib/api";
type ProviderAccountRow = { type ProviderAccountRow = {
id: number; 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 ( return (
<div className="two-col-grid"> <div className="two-col-grid">
<section className="panel"> <section className="panel">
@@ -228,9 +240,25 @@ export default function ProviderAccountsPage() {
<strong>{item.providerName}</strong> <strong>{item.providerName}</strong>
<div className="muted">{item.providerCode}</div> <div className="muted">{item.providerCode}</div>
</div> </div>
<div className="row">
<span className={item.status === 1 ? "status-badge tone-success" : "status-badge tone-ghost"}> <span className={item.status === 1 ? "status-badge tone-success" : "status-badge tone-ghost"}>
{item.status === 1 ? "启用" : "停用"} {item.status === 1 ? "启用" : "停用"}
</span> </span>
<button
className="danger-button compact-button admin-icon-action"
disabled={deleteMutation.isPending}
onClick={() => {
if (window.confirm(`确定删除渠道「${item.providerName}」吗?该渠道下未使用的供应商模型也会一起删除。`)) {
deleteMutation.mutate(item.id);
}
}}
title="删除渠道"
type="button"
>
<Trash2 size={14} />
</button>
</div>
</div> </div>
<div className="admin-meta-grid"> <div className="admin-meta-grid">
<span>{protocolOptions.find((option) => option.value === item.apiFormat)?.label ?? item.apiFormat}</span> <span>{protocolOptions.find((option) => option.value === item.apiFormat)?.label ?? item.apiFormat}</span>

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useMutation, useQuery } from "@tanstack/react-query"; 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 { useMemo, useState } from "react";
import { api } from "@/lib/api"; import { api, ApiError } from "@/lib/api";
type ProviderAccountRow = { type ProviderAccountRow = {
id: number; 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 ( return (
<div className="two-col-grid"> <div className="two-col-grid">
<section className="panel"> <section className="panel">
@@ -279,9 +290,25 @@ export default function ProviderModelsPage() {
<strong>{item.modelName || item.modelCode}</strong> <strong>{item.modelName || item.modelCode}</strong>
<div className="muted">{item.modelCode}</div> <div className="muted">{item.modelCode}</div>
</div> </div>
<div className="row">
<span className={item.status === 1 ? "status-badge tone-success" : "status-badge tone-ghost"}> <span className={item.status === 1 ? "status-badge tone-success" : "status-badge tone-ghost"}>
{item.status === 1 ? "启用" : "停用"} {item.status === 1 ? "启用" : "停用"}
</span> </span>
<button
className="danger-button compact-button admin-icon-action"
disabled={deleteMutation.isPending}
onClick={() => {
if (window.confirm(`确定删除模型「${item.modelName || item.modelCode}」吗?相关模型绑定会一起删除。`)) {
deleteMutation.mutate(item.id);
}
}}
title="删除模型"
type="button"
>
<Trash2 size={14} />
</button>
</div>
</div> </div>
<div className="admin-meta-grid"> <div className="admin-meta-grid">
<span>{item.providerName}</span> <span>{item.providerName}</span>

View File

@@ -1,73 +1,466 @@
"use client"; "use client";
import { useMutation, useQuery } from "@tanstack/react-query"; 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 = { type ConfigRow = {
configKey: string; configKey: string;
configValue: string; configValue: string;
valueType: string;
groupName: string; groupName: string;
description: string;
isPublic: boolean;
}; };
const fieldLabels: Record<string, string> = { type EditableConfig = {
config_key: "配置键", key: string;
config_value: "配置值", label: string;
value_type: "值类型", helper: string;
group_name: "配置分组", group: string;
description: "配置说明", valueType: "string" | "number" | "boolean";
is_public: "是否公开", 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() { export default function SystemConfigPage() {
const [form, setForm] = useState({ const [draftValues, setDraftValues] = useState<Record<string, string>>({});
config_key: "site.notice", const [customForm, setCustomForm] = useState({
config_value: "当前为本地联调环境。", configKey: "",
value_type: "string", configValue: "",
group_name: "site", valueType: "string",
description: "公告", groupName: "custom",
is_public: true, description: "",
isPublic: false,
}); });
const [feedback, setFeedback] = useState("");
const query = useQuery({ const query = useQuery({
queryKey: ["system-config"], queryKey: ["system-config"],
queryFn: () => api.get<ConfigRow[]>("/api/v1/admin/system-config"), queryFn: () => api.get<ConfigRow[]>("/api/v1/admin/system-config"),
}); });
const mutation = useMutation({
mutationFn: () => api.put("/api/v1/admin/system-config", form), const configByKey = useMemo(
onSuccess: () => query.refetch(), () => 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 : "保存失败,请稍后重试。");
},
}); });
return ( const customMutation = useMutation({
<div className="two-col-grid"> mutationFn: () =>
<section className="panel"> api.put("/api/v1/admin/system-config", {
<h3></h3> config_key: customForm.configKey.trim(),
<div className="form-stack"> config_value: customForm.configValue,
{Object.entries(form).map(([key, value]) => ( value_type: customForm.valueType,
<label className="field-label" key={key}> group_name: customForm.groupName.trim() || "custom",
{fieldLabels[key] ?? key} description: customForm.description.trim(),
<input is_public: customForm.isPublic,
value={String(value)} }),
onChange={(event) => async onSuccess() {
setForm((previous) => ({ setFeedback("高级配置已保存。");
setCustomForm((previous) => ({
...previous, ...previous,
[key]: configKey: "",
typeof value === "boolean" configValue: "",
? event.target.value === "true" description: "",
: event.target.value, }));
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 (
<button
className={enabled ? "admin-switch is-on" : "admin-switch"}
onClick={() =>
setDraftValues((previous) => ({
...previous,
[config.key]: enabled ? "0" : "1",
}))
}
type="button"
>
<span>{enabled ? "已开启" : "已关闭"}</span>
</button>
);
}
if (config.control === "textarea") {
return (
<textarea
className="admin-setting-textarea"
value={value}
onChange={(event) =>
setDraftValues((previous) => ({
...previous,
[config.key]: event.target.value,
}))
}
/>
);
}
return (
<input
min={config.control === "number" ? 0 : undefined}
type={config.control === "number" ? "number" : "text"}
value={value}
onChange={(event) =>
setDraftValues((previous) => ({
...previous,
[config.key]: event.target.value,
}))
}
/>
);
}
return (
<div className="admin-config-page">
<section className="panel">
<div className="toolbar">
<div>
<span className="header-kicker"></span>
<h3></h3>
<p className="muted"></p>
</div>
<Settings2 size={22} />
</div>
<div className="admin-settings-grid">
{groupedConfigs.map((group) => (
<article className="admin-setting-group" key={group.key}>
<div>
<strong>{group.title}</strong>
<p>{group.subtitle}</p>
</div>
<div className="admin-setting-list">
{group.configs.map((config) => (
<label className="admin-setting-card" key={config.key}>
<span>
<strong>{config.label}</strong>
<small>{config.helper}</small>
</span>
<div className="admin-setting-control">{renderControl(config)}</div>
</label>
))}
</div>
</article>
))}
</div>
{feedback ? <div className="inline-feedback">{feedback}</div> : null}
<button
className="primary-button admin-save-button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
type="button"
>
<Save size={16} />
</button>
</section>
<section className="two-col-grid">
<div className="panel">
<div className="toolbar">
<div>
<span className="header-kicker"></span>
<h3> / </h3>
<p className="muted"></p>
</div>
<SlidersHorizontal size={22} />
</div>
<div className="form-stack admin-simple-form">
<div className="admin-form-grid">
<label className="field-label">
<input
placeholder="例如 site.footer"
value={customForm.configKey}
onChange={(event) =>
setCustomForm((previous) => ({
...previous,
configKey: event.target.value,
})) }))
} }
/> />
</label> </label>
))} <label className="field-label">
<button className="primary-button" onClick={() => mutation.mutate()}>
<input
value={customForm.groupName}
onChange={(event) =>
setCustomForm((previous) => ({
...previous,
groupName: event.target.value,
}))
}
/>
</label>
</div>
<label className="field-label">
<textarea
className="admin-setting-textarea"
value={customForm.configValue}
onChange={(event) =>
setCustomForm((previous) => ({
...previous,
configValue: event.target.value,
}))
}
/>
</label>
<div className="admin-form-grid">
<label className="field-label">
<select
value={customForm.valueType}
onChange={(event) =>
setCustomForm((previous) => ({
...previous,
valueType: event.target.value,
}))
}
>
<option value="string"></option>
<option value="number"></option>
<option value="boolean"></option>
<option value="json">JSON</option>
</select>
</label>
<label className="field-label">
<input
value={customForm.description}
onChange={(event) =>
setCustomForm((previous) => ({
...previous,
description: event.target.value,
}))
}
/>
</label>
</div>
<label className="toggle-card admin-public-toggle">
<input
checked={customForm.isPublic}
onChange={(event) =>
setCustomForm((previous) => ({
...previous,
isPublic: event.target.checked,
}))
}
type="checkbox"
/>
<span>
<small></small>
</span>
</label>
<button
className="primary-button"
disabled={customMutation.isPending || !customForm.configKey.trim()}
onClick={() => customMutation.mutate()}
type="button"
>
</button> </button>
</div> </div>
</section> </div>
<section className="panel">
<h3></h3> <div className="panel">
<pre className="code-block">{JSON.stringify(query.data ?? [], null, 2)}</pre> <div className="toolbar">
<div>
<span className="header-kicker"></span>
<h3></h3>
</div>
<span className="mini-note">{otherConfigs.length} </span>
</div>
<div className="admin-card-list">
{otherConfigs.length ? (
otherConfigs.map((item) => (
<article className="admin-data-card" key={item.configKey}>
<div className="toolbar">
<div>
<strong>{item.description || item.configKey}</strong>
<div className="muted">{item.configKey}</div>
</div>
<span className="status-badge tone-ghost">{item.groupName}</span>
</div>
<div className="admin-meta-grid">
<span>{item.valueType}</span>
<span>{item.isPublic ? "公开" : "后台"}</span>
</div>
<p className="admin-config-value">{item.configValue}</p>
</article>
))
) : (
<div className="empty-state"></div>
)}
</div>
</div>
</section> </section>
</div> </div>
); );

View File

@@ -2053,6 +2053,11 @@ p {
color: var(--admin-text-soft); color: var(--admin-text-soft);
} }
.admin-icon-action {
min-height: 34px;
padding: 8px 11px;
}
.admin-model-textarea { .admin-model-textarea {
min-height: 112px; min-height: 112px;
} }
@@ -2100,6 +2105,127 @@ p {
margin-top: 14px; margin-top: 14px;
} }
.admin-config-page {
display: grid;
gap: 18px;
}
.admin-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
}
.admin-setting-group {
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid var(--admin-border);
border-radius: var(--radius-card);
background: #ffffff;
}
.admin-setting-group > div:first-child strong {
display: block;
font-size: 17px;
}
.admin-setting-group > div:first-child p {
margin: 6px 0 0;
color: var(--admin-text-soft);
font-size: 13px;
line-height: 1.55;
}
.admin-setting-list {
display: grid;
gap: 10px;
}
.admin-setting-card {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, 0.42fr);
gap: 14px;
align-items: center;
padding: 13px;
border-radius: var(--radius-card);
border: 1px solid var(--admin-border);
background: #fbf8f1;
}
.admin-setting-card strong,
.admin-setting-card small {
display: block;
}
.admin-setting-card strong {
font-size: 14px;
}
.admin-setting-card small {
margin-top: 5px;
color: var(--admin-text-soft);
line-height: 1.45;
}
.admin-setting-control input,
.admin-setting-control textarea,
.admin-setting-textarea {
width: 100%;
padding: 12px 13px;
border: 1px solid var(--admin-border-strong);
border-radius: var(--radius-control);
background: #ffffff;
color: var(--admin-text);
outline: none;
}
.admin-setting-textarea {
min-height: 104px;
resize: vertical;
line-height: 1.65;
}
.admin-switch {
width: 100%;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--admin-border-strong);
border-radius: 999px;
background: #ffffff;
color: var(--admin-text-soft);
cursor: pointer;
font-weight: 850;
}
.admin-switch.is-on {
border-color: rgba(0, 122, 114, 0.34);
background: #e8f6f3;
color: var(--admin-accent);
}
.admin-save-button {
margin-top: 16px;
}
.admin-public-toggle {
margin-top: 0;
box-shadow: none;
}
.admin-config-value {
margin: 0;
padding: 12px;
border-radius: var(--radius-card);
background: #fbf8f1;
color: var(--admin-text-soft);
line-height: 1.6;
overflow-wrap: anywhere;
}
.admin-app-shell .panel, .admin-app-shell .panel,
.admin-app-shell .stat-card, .admin-app-shell .stat-card,
.admin-app-shell .pulse-card, .admin-app-shell .pulse-card,
@@ -2290,7 +2416,9 @@ p {
.upload-row, .upload-row,
.admin-auth-grid, .admin-auth-grid,
.marketing-ops-grid, .marketing-ops-grid,
.admin-form-grid { .admin-form-grid,
.admin-settings-grid,
.admin-setting-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }