Add admin settings UI and provider deletes
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user