feat(admin): 管理后台 - 完善配置运营页面并修复导航、筛选与积分计费
This commit is contained in:
@@ -5,7 +5,7 @@ import { useBlocker } from "react-router";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAdminLinuxDOSetting, getAdminRegistrationSetting, updateAdminLinuxDOSetting, updateAdminRegistrationSetting, type LinuxDOSetting, type RegistrationSetting } from "@/services/api/wallet";
|
||||
import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
|
||||
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
|
||||
|
||||
type LinuxDOFormValues = Omit<LinuxDOSetting, "hasClientSecret" | "updatedAt">;
|
||||
|
||||
@@ -191,39 +191,17 @@ export default function AccessSettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLinuxDO = async (enabled: boolean) => {
|
||||
if (!linuxdo || savingLinuxDO || enabled === linuxdo.enabled) return;
|
||||
const values = (enabled ? { ...form.getFieldsValue(true), enabled } : { ...toLinuxDOFormValues(linuxdo), enabled, clientSecret: "" }) as LinuxDOFormValues;
|
||||
const validationError = validateLinuxDODraft(values, linuxdo);
|
||||
if (validationError) {
|
||||
form.setFieldValue("enabled", linuxdo.enabled);
|
||||
setDraftLinuxDOEnabled(linuxdo.enabled);
|
||||
message.error(validationError);
|
||||
return;
|
||||
}
|
||||
const toggleLinuxDO = (enabled: boolean) => {
|
||||
if (!linuxdo || savingLinuxDO) return;
|
||||
form.setFieldValue("enabled", enabled);
|
||||
setDraftLinuxDOEnabled(enabled);
|
||||
try {
|
||||
await saveLinuxDO(values);
|
||||
} catch {
|
||||
form.setFieldValue("enabled", linuxdo.enabled);
|
||||
setDraftLinuxDOEnabled(linuxdo.enabled);
|
||||
}
|
||||
setDirty(hasLinuxDOChanges({ ...form.getFieldsValue(true), enabled }, linuxdo));
|
||||
setSaveError("");
|
||||
};
|
||||
|
||||
if (loading && (!linuxdo || !registration)) {
|
||||
return (
|
||||
<div className="admin-settings-stack admin-access-settings" aria-label="正在读取登录与注册配置" role="status">
|
||||
<div className="admin-access-command-bar">
|
||||
<Skeleton active title={{ width: 180 }} paragraph={false} />
|
||||
</div>
|
||||
<div className="admin-access-overview">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="admin-stat-tile">
|
||||
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-access-loading-card">
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
@@ -250,8 +228,6 @@ export default function AccessSettingsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const configuredEndpointCount = [linuxdo.authorizationUrl, linuxdo.tokenUrl, linuxdo.userInfoUrl, linuxdo.redirectUrl].filter((value) => value?.trim()).length;
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack admin-access-settings">
|
||||
<div className={cn("admin-access-command-bar", dirty && "is-dirty")}>
|
||||
@@ -260,11 +236,8 @@ export default function AccessSettingsPanel() {
|
||||
<ShieldCheck className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>{dirty ? "Linux.do 有调整待保存" : "登录与注册状态已同步"}</strong>
|
||||
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
|
||||
</div>
|
||||
<p>{dirty ? "当前表单参数只在本页暂存;入口开关切换后立即保存。" : "注册策略与 Linux.do 配置分别读取、分别保存。"}</p>
|
||||
<strong>{dirty ? "Linux.do 有未保存的调整" : "登录与注册设置"}</strong>
|
||||
<p>{dirty ? "完成接入信息后保存生效。" : `新用户注册${registration.enabled ? "已开放" : "已关闭"} · Linux.do ${linuxdo.enabled ? "已启用" : "未启用"}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-access-command-actions">
|
||||
@@ -286,19 +259,12 @@ export default function AccessSettingsPanel() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-access-overview" aria-label="登录与注册配置概览">
|
||||
<AdminStatTile label="新用户注册" value={registration.enabled ? "已开放" : "已关闭"} detail={formatSettingTime(registration.updatedAt, "使用部署默认值")} />
|
||||
<AdminStatTile label="Linux.do 登录" value={draftLinuxDOEnabled ? "启用" : "停用"} detail={dirty ? "暂存状态预览" : "影响登录页第三方入口"} />
|
||||
<AdminStatTile label="应用密钥" value={linuxdo.hasClientSecret ? "已配置" : "未配置"} detail="Client Secret 仅保存在服务端" />
|
||||
<AdminStatTile label="OAuth 端点" value={`${configuredEndpointCount}/4`} detail={configuredEndpointCount === 4 ? "授权、Token、资料与回调齐全" : "启用前需要补全端点"} />
|
||||
</div>
|
||||
|
||||
<div id="admin-access-registration" className="admin-settings-anchor">
|
||||
<SettingsSectionCard
|
||||
className="admin-access-section admin-access-registration-section"
|
||||
icon={<UserPlus className="size-4" aria-hidden="true" />}
|
||||
title="用户注册策略"
|
||||
description="控制新用户能否创建账号,不影响已有账号登录。"
|
||||
title="1. 是否允许创建新账号"
|
||||
description="先决定是否开放注册。关闭后已有账号仍可登录,已有数据不会删除。"
|
||||
status={<AdminStatusBadge label={registration.enabled ? "已开放" : "已关闭"} tone={registration.enabled ? "success" : "neutral"} />}
|
||||
>
|
||||
<div className="admin-access-registration-policy">
|
||||
@@ -322,14 +288,14 @@ export default function AccessSettingsPanel() {
|
||||
<SettingsSectionCard
|
||||
className="admin-access-section admin-access-linuxdo-section"
|
||||
icon={<KeyRound className="size-4" aria-hidden="true" />}
|
||||
title="Linux.do 单点登录"
|
||||
description="连接 Linux.do OAuth,并明确管理入口状态、服务端凭据、端点与用户字段映射。"
|
||||
title="2. 是否开放 Linux.do 登录"
|
||||
description="先决定是否在登录与注册页展示 Linux.do。开启后再填写 OAuth 接入信息。"
|
||||
status={<AdminStatusBadge label={draftLinuxDOEnabled ? (dirty ? "待启用" : "运行中") : dirty && linuxdo.enabled ? "待停用" : "未启用"} tone={dirty ? "warning" : draftLinuxDOEnabled ? "success" : "neutral"} />}
|
||||
footer={
|
||||
<>
|
||||
<div className="admin-access-footer-note">
|
||||
<BadgeCheck className="size-4" aria-hidden="true" />
|
||||
<span>{formatSettingTime(linuxdo.updatedAt, "尚未保存 Linux.do 管理配置")} · Client Secret 加密保存且不回显明文</span>
|
||||
<span>{draftLinuxDOEnabled ? "完整填写 OAuth 接入信息后保存" : "关闭后隐藏入口,已有账号绑定不受影响"}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty ? (
|
||||
@@ -338,7 +304,7 @@ export default function AccessSettingsPanel() {
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={savingLinuxDO} disabled={!dirty || loading || refreshing} onClick={() => void submitLinuxDOSave()}>
|
||||
保存修改
|
||||
{draftLinuxDOEnabled ? "保存并启用" : "保存并关闭"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -356,86 +322,98 @@ export default function AccessSettingsPanel() {
|
||||
setSaveError("");
|
||||
}}
|
||||
>
|
||||
<div className="admin-access-form-section">
|
||||
<FormSectionTitle icon={<ShieldCheck className="size-4" />} title="入口状态与应用凭据" description="入口开关切换后立即保存;应用凭据与连接参数点击保存修改后生效。" />
|
||||
<div className="admin-access-form-grid is-credentials">
|
||||
<div className="admin-access-switch-field">
|
||||
<div>
|
||||
<label>启用 Linux.do 登录</label>
|
||||
<p>启用后,登录与注册页面会展示 Linux.do 入口。</p>
|
||||
</div>
|
||||
<Form.Item noStyle name="enabled" valuePropName="checked">
|
||||
<Switch aria-label="启用 Linux.do 登录,切换后立即保存" onChange={(checked) => void toggleLinuxDO(checked)} />
|
||||
</Form.Item>
|
||||
<div className="admin-access-provider-toggle">
|
||||
<span className="admin-access-policy-icon">
|
||||
<ShieldCheck className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="admin-access-policy-copy">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>在登录与注册页显示 Linux.do</strong>
|
||||
<AdminStatusBadge label="保存后生效" tone="info" />
|
||||
</div>
|
||||
<Form.Item name="clientAuthMethod" label="Token 请求鉴权方式" rules={[{ required: true, message: "请选择鉴权方式" }]} extra="应用未特别要求时使用 Client Secret Post。">
|
||||
<Select
|
||||
options={[
|
||||
{ label: "Client Secret Post(推荐)", value: "client_secret_post" },
|
||||
{ label: "Client Secret Basic", value: "client_secret_basic" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="clientId" label="Client ID">
|
||||
<Input autoComplete="off" placeholder="Linux.do OAuth 应用的 Client ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="clientSecret" label={linuxdo.hasClientSecret ? `Client Secret(${configuredSecretText})` : "Client Secret"}>
|
||||
<Input.Password autoComplete="new-password" placeholder={linuxdo.hasClientSecret ? "留空保留原密钥" : "Linux.do OAuth 应用的 Client Secret"} />
|
||||
</Form.Item>
|
||||
<p>开启后显示第三方登录入口;首次登录是否能创建账号仍受上方注册开关控制。</p>
|
||||
{!draftLinuxDOEnabled ? <span>当前关闭,因此 OAuth 凭据和端点配置已收起。</span> : null}
|
||||
</div>
|
||||
<Form.Item noStyle name="enabled" valuePropName="checked">
|
||||
<Switch aria-label="在登录与注册页显示 Linux.do" onChange={toggleLinuxDO} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="admin-access-form-section">
|
||||
<FormSectionTitle icon={<Globe2 className="size-4" />} title="OAuth 连接地址" description="授权、Token 和用户资料端点必须使用 HTTPS;本地回环回调可使用 HTTP。" />
|
||||
<div className="admin-access-form-grid">
|
||||
<Form.Item name="authorizationUrl" label="授权地址">
|
||||
<Input inputMode="url" placeholder="https://connect.linux.do/oauth2/authorize" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tokenUrl" label="Token 地址">
|
||||
<Input inputMode="url" placeholder="https://connect.linux.do/oauth2/token" />
|
||||
</Form.Item>
|
||||
<Form.Item name="userInfoUrl" label="用户资料地址">
|
||||
<Input inputMode="url" placeholder="https://connect.linux.do/api/user" />
|
||||
</Form.Item>
|
||||
<Form.Item name="redirectUrl" label="本站回调地址" extra="必须与 Linux.do OAuth 应用登记值完全一致;推荐使用 /oauth/linuxdo/callback。">
|
||||
<Input inputMode="url" placeholder="https://你的域名/oauth/linuxdo/callback" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopes" label="授权范围(Scopes)" className="admin-access-form-span-full" extra="通常使用 openid、profile、email;按 Linux.do 应用实际授权范围填写。">
|
||||
<Select mode="tags" tokenSeparators={[",", " "]} placeholder="输入后按回车添加" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
{draftLinuxDOEnabled ? (
|
||||
<>
|
||||
<div className="admin-access-form-section">
|
||||
<FormSectionTitle icon={<KeyRound className="size-4" />} title="应用凭据" description="填写 Linux.do OAuth 应用的客户端信息;密钥只保存在服务端。" />
|
||||
<div className="admin-access-form-grid is-credentials">
|
||||
<Form.Item name="clientAuthMethod" label="Token 请求鉴权方式" rules={[{ required: true, message: "请选择鉴权方式" }]} extra="应用未特别要求时使用 Client Secret Post。">
|
||||
<Select
|
||||
options={[
|
||||
{ label: "Client Secret Post(推荐)", value: "client_secret_post" },
|
||||
{ label: "Client Secret Basic", value: "client_secret_basic" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="clientId" label="Client ID">
|
||||
<Input autoComplete="off" placeholder="Linux.do OAuth 应用的 Client ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="clientSecret" label={linuxdo.hasClientSecret ? `Client Secret(${configuredSecretText})` : "Client Secret"}>
|
||||
<Input.Password autoComplete="new-password" placeholder={linuxdo.hasClientSecret ? "留空保留原密钥" : "Linux.do OAuth 应用的 Client Secret"} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="admin-access-advanced group">
|
||||
<summary>
|
||||
<span className="admin-access-advanced-icon">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<strong>高级:用户资料字段映射</strong>
|
||||
<small>指定从 Linux.do 响应中读取本地账号信息的字段路径。</small>
|
||||
</span>
|
||||
<AdminStatusBadge label="通常无需修改" tone="neutral" />
|
||||
<ChevronDown className="size-4 shrink-0 transition-transform group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="admin-access-form-grid is-mapping">
|
||||
<Form.Item name="subjectField" label="唯一用户 ID 字段" extra="账号绑定的唯一依据,必须长期稳定。">
|
||||
<Input placeholder="id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="usernameField" label="用户名字段" extra="用于生成本站用户名。">
|
||||
<Input placeholder="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayNameField" label="显示名称字段" extra="显示在用户菜单中的名称。">
|
||||
<Input placeholder="name" />
|
||||
</Form.Item>
|
||||
<Form.Item name="emailField" label="邮箱字段" extra="没有或无效时允许留空。">
|
||||
<Input placeholder="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="avatarField" label="头像地址字段" extra="支持 data.user.avatar_url 这类嵌套路径。">
|
||||
<Input placeholder="avatar_url" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</details>
|
||||
<div className="admin-access-form-section">
|
||||
<FormSectionTitle icon={<Globe2 className="size-4" />} title="OAuth 连接地址" description="授权、Token 和用户资料端点必须使用 HTTPS;本地回环回调可使用 HTTP。" />
|
||||
<div className="admin-access-form-grid">
|
||||
<Form.Item name="authorizationUrl" label="授权地址">
|
||||
<Input inputMode="url" placeholder="https://connect.linux.do/oauth2/authorize" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tokenUrl" label="Token 地址">
|
||||
<Input inputMode="url" placeholder="https://connect.linux.do/oauth2/token" />
|
||||
</Form.Item>
|
||||
<Form.Item name="userInfoUrl" label="用户资料地址">
|
||||
<Input inputMode="url" placeholder="https://connect.linux.do/api/user" />
|
||||
</Form.Item>
|
||||
<Form.Item name="redirectUrl" label="本站回调地址" extra="必须与 Linux.do OAuth 应用登记值完全一致;推荐使用 /oauth/linuxdo/callback。">
|
||||
<Input inputMode="url" placeholder="https://你的域名/oauth/linuxdo/callback" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopes" label="授权范围(Scopes)" className="admin-access-form-span-full" extra="通常使用 openid、profile、email;按 Linux.do 应用实际授权范围填写。">
|
||||
<Select mode="tags" tokenSeparators={[",", " "]} placeholder="输入后按回车添加" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="admin-access-advanced group">
|
||||
<summary>
|
||||
<span className="admin-access-advanced-icon">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<strong>高级:用户资料字段映射</strong>
|
||||
<small>指定从 Linux.do 响应中读取本地账号信息的字段路径。</small>
|
||||
</span>
|
||||
<AdminStatusBadge label="通常无需修改" tone="neutral" />
|
||||
<ChevronDown className="size-4 shrink-0 transition-transform group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="admin-access-form-grid is-mapping">
|
||||
<Form.Item name="subjectField" label="唯一用户 ID 字段" extra="账号绑定的唯一依据,必须长期稳定。">
|
||||
<Input placeholder="id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="usernameField" label="用户名字段" extra="用于生成本站用户名。">
|
||||
<Input placeholder="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayNameField" label="显示名称字段" extra="显示在用户菜单中的名称。">
|
||||
<Input placeholder="name" />
|
||||
</Form.Item>
|
||||
<Form.Item name="emailField" label="邮箱字段" extra="没有或无效时允许留空。">
|
||||
<Input placeholder="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="avatarField" label="头像地址字段" extra="支持 data.user.avatar_url 这类嵌套路径。">
|
||||
<Input placeholder="avatar_url" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : null}
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,9 @@ export type AnnouncementPendingReview = {
|
||||
previousTitle?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
imageResourceId?: string;
|
||||
level: "info" | "success" | "warning" | "critical";
|
||||
pinned?: boolean;
|
||||
notice: string;
|
||||
requestedAt: string;
|
||||
};
|
||||
@@ -21,7 +23,9 @@ export function readAnnouncementPendingReview(): AnnouncementPendingReview | nul
|
||||
(parsed.operation !== "create" && parsed.operation !== "update" && parsed.operation !== "close") ||
|
||||
typeof parsed.title !== "string" ||
|
||||
typeof parsed.content !== "string" ||
|
||||
(parsed.imageResourceId !== undefined && typeof parsed.imageResourceId !== "string") ||
|
||||
(parsed.level !== "info" && parsed.level !== "success" && parsed.level !== "warning" && parsed.level !== "critical") ||
|
||||
(parsed.pinned !== undefined && typeof parsed.pinned !== "boolean") ||
|
||||
typeof parsed.notice !== "string" ||
|
||||
!parsed.notice ||
|
||||
typeof parsed.requestedAt !== "string" ||
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { App, Button, Drawer, Form, Input, Modal, Select } from "antd";
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { App, Button, Drawer, Form, Input, Modal, Select, Switch } from "antd";
|
||||
import type { InputRef } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { PencilLine, Plus, RefreshCw, Search, Send } from "lucide-react";
|
||||
import { PencilLine, Pin, Plus, RefreshCw, Search, Send, Upload, X } from "lucide-react";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { AnnouncementContent } from "@/components/ui/announcement-content";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { closeAdminAnnouncement, createAdminAnnouncement, listAdminAnnouncements, updateAdminAnnouncement, type AnnouncementLevel, type AnnouncementStatus, type SystemAnnouncement } from "@/services/api/announcements";
|
||||
import {
|
||||
announcementImageUrl,
|
||||
closeAdminAnnouncement,
|
||||
createAdminAnnouncement,
|
||||
discardAdminAnnouncementImage,
|
||||
listAdminAnnouncements,
|
||||
updateAdminAnnouncement,
|
||||
uploadAdminAnnouncementImage,
|
||||
type AnnouncementLevel,
|
||||
type AnnouncementStatus,
|
||||
type SystemAnnouncement,
|
||||
} from "@/services/api/announcements";
|
||||
import { resourceFileUrl } from "@/services/api/resources";
|
||||
import { clearAnnouncementPendingReview, readAnnouncementPendingReview, writeAnnouncementPendingReview, type AnnouncementPendingReview } from "./admin-announcement-safety";
|
||||
import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
|
||||
|
||||
type AnnouncementFormValues = {
|
||||
title: string;
|
||||
content: string;
|
||||
imageResourceId?: string;
|
||||
level: AnnouncementLevel;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
type PendingAnnouncement =
|
||||
@@ -28,7 +42,7 @@ type PendingAnnouncement =
|
||||
previousTitle: string;
|
||||
});
|
||||
|
||||
const DEFAULT_ANNOUNCEMENT: AnnouncementFormValues = { title: "", content: "", level: "info" };
|
||||
const DEFAULT_ANNOUNCEMENT: AnnouncementFormValues = { title: "", content: "", imageResourceId: "", level: "info", pinned: false };
|
||||
const levelOptions: Array<{ value: AnnouncementLevel; label: string }> = [
|
||||
{ value: "info", label: "平台通知" },
|
||||
{ value: "success", label: "状态恢复" },
|
||||
@@ -70,6 +84,9 @@ export default function AdminAnnouncementsPanel({
|
||||
const [editingAnnouncement, setEditingAnnouncement] = useState<SystemAnnouncement | null>(null);
|
||||
const [pendingSave, setPendingSave] = useState<PendingAnnouncement | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState("");
|
||||
const [draftImageResourceId, setDraftImageResourceId] = useState("");
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [closingIds, setClosingIds] = useState<Set<string>>(() => new Set());
|
||||
const [pendingReview, setPendingReview] = useState<AnnouncementPendingReview | null>(() => readAnnouncementPendingReview());
|
||||
const [uncertainListReady, setUncertainListReady] = useState(false);
|
||||
@@ -82,10 +99,12 @@ export default function AdminAnnouncementsPanel({
|
||||
const closeInFlightRef = useRef(new Set<string>());
|
||||
const writeBlockedRef = useRef(publishBlocked);
|
||||
const editorReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
writeBlockedRef.current = publishBlocked;
|
||||
const watchedTitle = Form.useWatch("title", form);
|
||||
const watchedContent = Form.useWatch("content", form);
|
||||
const watchedLevel = Form.useWatch("level", form);
|
||||
const watchedPinned = Form.useWatch("pinned", form);
|
||||
|
||||
const reload = async (targetPage = page, targetPageSize = pageSize, queryOverride?: { keyword?: string; status?: "all" | AnnouncementStatus }) => {
|
||||
const requestId = ++listRequestRef.current;
|
||||
@@ -157,14 +176,71 @@ export default function AdminAnnouncementsPanel({
|
||||
if (publishReturnFocus) editorReturnFocusRef.current = publishReturnFocus;
|
||||
setEditingAnnouncement(null);
|
||||
form.setFieldsValue(DEFAULT_ANNOUNCEMENT);
|
||||
setImagePreviewUrl("");
|
||||
setDraftImageResourceId("");
|
||||
}, [form, publishOpen, publishReturnFocus]);
|
||||
|
||||
const editorOpen = publishOpen || Boolean(editingAnnouncement);
|
||||
const closeEditor = () => {
|
||||
if (saving || pendingSave) return;
|
||||
onPublishOpenChange(false);
|
||||
setEditingAnnouncement(null);
|
||||
form.resetFields();
|
||||
const discardDraftImage = async () => {
|
||||
if (!draftImageResourceId) return;
|
||||
await discardAdminAnnouncementImage(draftImageResourceId);
|
||||
setDraftImageResourceId("");
|
||||
};
|
||||
|
||||
const uploadAnnouncementImage = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
message.warning("公告配图必须是图片文件");
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
message.warning("公告配图不能超过 10MB");
|
||||
return;
|
||||
}
|
||||
setImageUploading(true);
|
||||
try {
|
||||
if (draftImageResourceId) await discardDraftImage();
|
||||
const { resource } = await uploadAdminAnnouncementImage(file);
|
||||
form.setFieldValue("imageResourceId", resource.id);
|
||||
setDraftImageResourceId(resource.id);
|
||||
setImagePreviewUrl(resourceFileUrl(resource.id));
|
||||
message.success("公告配图已上传");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "公告配图上传失败");
|
||||
} finally {
|
||||
setImageUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAnnouncementImage = async () => {
|
||||
setImageUploading(true);
|
||||
try {
|
||||
await discardDraftImage();
|
||||
form.setFieldValue("imageResourceId", "");
|
||||
setImagePreviewUrl("");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "公告配图清理失败");
|
||||
} finally {
|
||||
setImageUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (saving || pendingSave || imageUploading) return;
|
||||
setImageUploading(true);
|
||||
try {
|
||||
await discardDraftImage();
|
||||
onPublishOpenChange(false);
|
||||
setEditingAnnouncement(null);
|
||||
setImagePreviewUrl("");
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "公告配图草稿清理失败,请重试");
|
||||
} finally {
|
||||
setImageUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDrawer = (announcement: SystemAnnouncement) => {
|
||||
@@ -172,7 +248,15 @@ export default function AdminAnnouncementsPanel({
|
||||
if (document.activeElement instanceof HTMLElement) editorReturnFocusRef.current = document.activeElement;
|
||||
onPublishOpenChange(false);
|
||||
setEditingAnnouncement(announcement);
|
||||
form.setFieldsValue({ title: announcement.title, content: announcement.content, level: announcement.level });
|
||||
form.setFieldsValue({
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
imageResourceId: announcement.imageResourceId || "",
|
||||
level: announcement.level,
|
||||
pinned: announcement.pinned,
|
||||
});
|
||||
setImagePreviewUrl(announcementImageUrl(announcement));
|
||||
setDraftImageResourceId("");
|
||||
};
|
||||
|
||||
const previewSave = (values: AnnouncementFormValues) => {
|
||||
@@ -180,7 +264,13 @@ export default function AdminAnnouncementsPanel({
|
||||
message.warning("请先完成上一次公告操作的核对");
|
||||
return;
|
||||
}
|
||||
const normalized: AnnouncementFormValues = { title: values.title.trim(), content: values.content.trim(), level: values.level };
|
||||
const normalized: AnnouncementFormValues = {
|
||||
title: values.title.trim(),
|
||||
content: values.content.trim(),
|
||||
imageResourceId: values.imageResourceId?.trim() || "",
|
||||
level: values.level,
|
||||
pinned: Boolean(values.pinned),
|
||||
};
|
||||
if (editingAnnouncement) {
|
||||
setPendingSave({
|
||||
...normalized,
|
||||
@@ -206,9 +296,17 @@ export default function AdminAnnouncementsPanel({
|
||||
const isUpdate = pendingSave.mode === "update";
|
||||
const requestedAt = new Date().toISOString();
|
||||
try {
|
||||
const input = { title: pendingSave.title, content: pendingSave.content, level: pendingSave.level, pinned: false };
|
||||
const input = {
|
||||
title: pendingSave.title,
|
||||
content: pendingSave.content,
|
||||
imageResourceId: pendingSave.imageResourceId?.trim() || "",
|
||||
level: pendingSave.level,
|
||||
pinned: pendingSave.pinned,
|
||||
};
|
||||
const result = isUpdate ? await updateAdminAnnouncement(pendingSave.id, input) : await createAdminAnnouncement(input);
|
||||
assertAnnouncementMutationResult(result, "active", isUpdate ? pendingSave.id : undefined, input);
|
||||
setDraftImageResourceId("");
|
||||
setImagePreviewUrl("");
|
||||
setPendingSave(null);
|
||||
setEditingAnnouncement(null);
|
||||
onPublishOpenChange(false);
|
||||
@@ -226,6 +324,8 @@ export default function AdminAnnouncementsPanel({
|
||||
setEditingAnnouncement(null);
|
||||
onPublishOpenChange(false);
|
||||
form.resetFields();
|
||||
setDraftImageResourceId("");
|
||||
setImagePreviewUrl("");
|
||||
onPublishBlockedChange(true);
|
||||
setKeyword("");
|
||||
setStatus("all");
|
||||
@@ -238,7 +338,9 @@ export default function AdminAnnouncementsPanel({
|
||||
previousTitle: isUpdate ? pendingSave.previousTitle : undefined,
|
||||
title: pendingSave.title,
|
||||
content: pendingSave.content,
|
||||
imageResourceId: pendingSave.imageResourceId,
|
||||
level: pendingSave.level,
|
||||
pinned: pendingSave.pinned,
|
||||
notice,
|
||||
requestedAt,
|
||||
};
|
||||
@@ -311,16 +413,26 @@ export default function AdminAnnouncementsPanel({
|
||||
{
|
||||
title: "公告",
|
||||
dataIndex: "title",
|
||||
width: 330,
|
||||
width: 390,
|
||||
render: (_, announcement) => (
|
||||
<div className="min-w-0 py-0.5">
|
||||
<div className="truncate text-sm font-medium text-foreground" title={announcement.title}>
|
||||
{announcement.title}
|
||||
<div className="flex min-w-0 items-center gap-3 py-0.5">
|
||||
{announcement.imageUrl ? <img src={announcementImageUrl(announcement)} alt="" loading="lazy" decoding="async" className="size-12 shrink-0 rounded-md border border-border/70 bg-muted/20 object-contain p-0.5" /> : null}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground" title={announcement.title}>
|
||||
{announcement.title}
|
||||
</div>
|
||||
<AnnouncementContent content={announcement.content} className="admin-announcement-list-content mt-1 text-xs leading-5 text-foreground/50" />
|
||||
</div>
|
||||
<AnnouncementContent content={announcement.content} className="admin-announcement-list-content mt-1 text-xs leading-5 text-foreground/50" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "展示",
|
||||
dataIndex: "pinned",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (pinned: boolean) => (pinned ? <AdminStatusBadge label="置顶" tone="warning" /> : <span className="text-xs text-foreground/35">普通</span>),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "level",
|
||||
@@ -546,9 +658,15 @@ export default function AdminAnnouncementsPanel({
|
||||
watchedTitle={watchedTitle}
|
||||
watchedContent={watchedContent}
|
||||
watchedLevel={watchedLevel}
|
||||
watchedPinned={watchedPinned}
|
||||
imagePreviewUrl={imagePreviewUrl}
|
||||
imageUploading={imageUploading}
|
||||
imageInputRef={imageInputRef}
|
||||
onClose={closeEditor}
|
||||
onPreview={previewSave}
|
||||
onPendingChange={setPendingSave}
|
||||
onUploadImage={uploadAnnouncementImage}
|
||||
onClearImage={() => void clearAnnouncementImage()}
|
||||
onConfirm={() => void saveAnnouncement()}
|
||||
/>
|
||||
</div>
|
||||
@@ -566,9 +684,15 @@ function AnnouncementEditor({
|
||||
watchedTitle,
|
||||
watchedContent,
|
||||
watchedLevel,
|
||||
watchedPinned,
|
||||
imagePreviewUrl,
|
||||
imageUploading,
|
||||
imageInputRef,
|
||||
onClose,
|
||||
onPreview,
|
||||
onPendingChange,
|
||||
onUploadImage,
|
||||
onClearImage,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -581,9 +705,15 @@ function AnnouncementEditor({
|
||||
watchedTitle?: string;
|
||||
watchedContent?: string;
|
||||
watchedLevel?: AnnouncementLevel;
|
||||
watchedPinned?: boolean;
|
||||
imagePreviewUrl: string;
|
||||
imageUploading: boolean;
|
||||
imageInputRef: { current: HTMLInputElement | null };
|
||||
onClose: () => void;
|
||||
onPreview: (values: AnnouncementFormValues) => void;
|
||||
onPendingChange: (pending: PendingAnnouncement | null) => void;
|
||||
onUploadImage: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onClearImage: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const previewMeta = levelMeta[watchedLevel || "info"] || levelMeta.info;
|
||||
@@ -609,10 +739,10 @@ function AnnouncementEditor({
|
||||
closable={!saving && !pending}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button disabled={saving || Boolean(pending)} onClick={onClose}>
|
||||
<Button disabled={saving || imageUploading || Boolean(pending)} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} disabled={publishBlocked || Boolean(pending)} icon={<Send className="size-4" />} onClick={() => form.submit()}>
|
||||
<Button type="primary" loading={saving} disabled={publishBlocked || imageUploading || Boolean(pending)} icon={<Send className="size-4" />} onClick={() => form.submit()}>
|
||||
核对并继续
|
||||
</Button>
|
||||
</div>
|
||||
@@ -643,6 +773,38 @@ function AnnouncementEditor({
|
||||
<Select aria-label="选择公告类型" options={levelOptions} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="pinned" label="展示方式" valuePropName="checked" extra="置顶公告会优先于普通公告展示。">
|
||||
<Switch checkedChildren="置顶" unCheckedChildren="普通" />
|
||||
</Form.Item>
|
||||
<Form.Item name="imageResourceId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="公告配图" extra="可选,图片文件不超过 10MB;取消、替换或移除时会回收未发布草稿。">
|
||||
<div className="space-y-2">
|
||||
{imagePreviewUrl ? (
|
||||
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-md border border-border/70 bg-muted/20 p-2">
|
||||
<img src={imagePreviewUrl} alt="公告配图预览" className="max-h-44 w-full object-contain" />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
disabled={imageUploading}
|
||||
icon={<X className="size-3.5" />}
|
||||
className="!absolute right-1 top-1 !size-7 !min-w-7 !p-0"
|
||||
onClick={onClearImage}
|
||||
aria-label="移除公告配图"
|
||||
title="移除公告配图"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-border/80 bg-muted/10 text-xs text-foreground/45">暂未添加配图</div>
|
||||
)}
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadImage} />
|
||||
<Button icon={<Upload className="size-3.5" />} loading={imageUploading} onClick={() => imageInputRef.current?.click()}>
|
||||
{imagePreviewUrl ? "更换配图" : "上传配图"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="公告正文"
|
||||
@@ -658,9 +820,18 @@ function AnnouncementEditor({
|
||||
<section className="admin-announcement-preview" data-level={watchedLevel || "info"} aria-label="用户端公告预览">
|
||||
<div className="admin-announcement-preview-heading">
|
||||
<span>用户端预览</span>
|
||||
<AdminStatusBadge label={previewMeta.label} tone={previewMeta.tone} />
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminStatusBadge label={previewMeta.label} tone={previewMeta.tone} />
|
||||
{watchedPinned ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-500">
|
||||
<Pin className="size-3" />
|
||||
置顶
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<h3>{watchedTitle?.trim() || "公告标题将在这里显示"}</h3>
|
||||
{imagePreviewUrl ? <img src={imagePreviewUrl} alt="公告配图预览" className="mt-4 max-h-56 w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
|
||||
{watchedContent?.trim() ? <AnnouncementContent content={watchedContent.trim()} className="admin-announcement-preview-content" /> : <p className="admin-announcement-preview-placeholder">公告正文将在这里显示。</p>}
|
||||
</section>
|
||||
</Form>
|
||||
@@ -699,6 +870,14 @@ function AnnouncementEditor({
|
||||
<dt>公告类型</dt>
|
||||
<dd>{(levelMeta[pending.level] || levelMeta.info).label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>展示方式</dt>
|
||||
<dd>{pending.pinned ? "置顶" : "普通"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>公告配图</dt>
|
||||
<dd>{pending.imageResourceId ? "已配置" : "无配图"}</dd>
|
||||
</div>
|
||||
<div className="is-wide">
|
||||
<dt>公告标题</dt>
|
||||
<dd>{pending.title}</dd>
|
||||
@@ -821,13 +1000,23 @@ function isMutationResultUncertain(error: unknown) {
|
||||
return error.status === undefined || error.retryable || error.status >= 500;
|
||||
}
|
||||
|
||||
function assertAnnouncementMutationResult(result: unknown, expectedStatus: AnnouncementStatus, expectedId: string | undefined, expectedContent: Pick<SystemAnnouncement, "title" | "content" | "level">) {
|
||||
function assertAnnouncementMutationResult(
|
||||
result: unknown,
|
||||
expectedStatus: AnnouncementStatus,
|
||||
expectedId: string | undefined,
|
||||
expectedContent: Pick<SystemAnnouncement, "title" | "content" | "level" | "pinned"> & Pick<Partial<SystemAnnouncement>, "imageResourceId">,
|
||||
) {
|
||||
const announcement = (result as { announcement?: Partial<SystemAnnouncement> } | null)?.announcement;
|
||||
const publishedAt = announcement?.publishedAt;
|
||||
const closedAt = announcement?.closedAt;
|
||||
const validPublishedAt = typeof publishedAt === "string" && !Number.isNaN(new Date(publishedAt).getTime());
|
||||
const validClosedAt = expectedStatus === "closed" ? typeof closedAt === "string" && !Number.isNaN(new Date(closedAt).getTime()) : closedAt === null || closedAt === undefined;
|
||||
const contentMatches = announcement?.title === expectedContent.title && announcement?.content === expectedContent.content && announcement?.level === expectedContent.level;
|
||||
const contentMatches =
|
||||
announcement?.title === expectedContent.title &&
|
||||
announcement?.content === expectedContent.content &&
|
||||
announcement?.level === expectedContent.level &&
|
||||
announcement?.pinned === expectedContent.pinned &&
|
||||
(announcement?.imageResourceId || "") === (expectedContent.imageResourceId || "");
|
||||
if (!announcement || typeof announcement.id !== "string" || !announcement.id || (expectedId && announcement.id !== expectedId) || announcement.status !== expectedStatus || !validPublishedAt || !validClosedAt || !contentMatches) {
|
||||
throw new Error("服务返回的公告状态不完整,无法确认操作结果");
|
||||
}
|
||||
@@ -851,6 +1040,9 @@ function normalizeAnnouncementListItem(value: unknown): SystemAnnouncement {
|
||||
typeof value.content !== "string" ||
|
||||
!isKnownAnnouncementLevel(value.level) ||
|
||||
!isKnownAnnouncementStatus(value.status) ||
|
||||
typeof value.pinned !== "boolean" ||
|
||||
(value.imageResourceId !== null && value.imageResourceId !== undefined && typeof value.imageResourceId !== "string") ||
|
||||
(value.imageUrl !== null && value.imageUrl !== undefined && typeof value.imageUrl !== "string") ||
|
||||
typeof value.createdBy !== "string" ||
|
||||
!isValidDateTimeString(value.publishedAt) ||
|
||||
!isValidDateTimeString(value.createdAt) ||
|
||||
@@ -863,8 +1055,10 @@ function normalizeAnnouncementListItem(value: unknown): SystemAnnouncement {
|
||||
id: value.id,
|
||||
title: value.title,
|
||||
content: value.content,
|
||||
imageResourceId: typeof value.imageResourceId === "string" ? value.imageResourceId : undefined,
|
||||
imageUrl: typeof value.imageUrl === "string" ? value.imageUrl : undefined,
|
||||
level: value.level,
|
||||
pinned: value.pinned === true,
|
||||
pinned: value.pinned,
|
||||
status: value.status,
|
||||
createdBy: value.createdBy,
|
||||
publishedAt: value.publishedAt as string,
|
||||
@@ -892,7 +1086,12 @@ async function inspectPendingReview(review: AnnouncementPendingReview) {
|
||||
if (review.targetId) {
|
||||
const target = records.find((announcement) => announcement.id === review.targetId);
|
||||
if (!target) return `按请求前后标题检索后,未找到目标 ID ${review.targetId}。该请求可能未生效,请结合完整列表再人工确认。`;
|
||||
const contentMatches = target.title === review.title && target.content === review.content && target.level === review.level;
|
||||
const contentMatches =
|
||||
target.title === review.title &&
|
||||
target.content === review.content &&
|
||||
target.level === review.level &&
|
||||
(review.pinned === undefined || target.pinned === review.pinned) &&
|
||||
(review.imageResourceId === undefined || (target.imageResourceId || "") === review.imageResourceId);
|
||||
const expectedStatus: AnnouncementStatus = review.operation === "close" ? "closed" : "active";
|
||||
const statusMatches = target.status === expectedStatus;
|
||||
return `已定位目标 ID ${target.id}:当前为${target.status === "active" ? "发布中" : "已关闭"},标题、正文与类型${contentMatches ? "与请求一致" : "与请求不一致"},${statusMatches ? "状态符合预期" : "状态不符合预期"}。`;
|
||||
@@ -900,7 +1099,13 @@ async function inspectPendingReview(review: AnnouncementPendingReview) {
|
||||
|
||||
const requestedAt = new Date(review.requestedAt).getTime();
|
||||
const matchingRecords = records.filter(
|
||||
(announcement) => announcement.title === review.title && announcement.content === review.content && announcement.level === review.level && new Date(announcement.publishedAt).getTime() >= requestedAt - 5 * 60_000,
|
||||
(announcement) =>
|
||||
announcement.title === review.title &&
|
||||
announcement.content === review.content &&
|
||||
announcement.level === review.level &&
|
||||
(review.pinned === undefined || announcement.pinned === review.pinned) &&
|
||||
(review.imageResourceId === undefined || (announcement.imageResourceId || "") === review.imageResourceId) &&
|
||||
new Date(announcement.publishedAt).getTime() >= requestedAt - 5 * 60_000,
|
||||
);
|
||||
if (!matchingRecords.length) return "未找到请求时间附近与标题、正文和类型完全一致的新公告;该发布请求可能未生效。";
|
||||
const resultSummary = matchingRecords
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
CloudUpload,
|
||||
Coins,
|
||||
Database,
|
||||
FileClock,
|
||||
HardDrive,
|
||||
Home,
|
||||
@@ -62,6 +63,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
|
||||
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
|
||||
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
|
||||
{ path: "/admin/resources", label: "存储资源", description: "资源列表、容量与预览", icon: <Database className="size-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -76,15 +78,15 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{
|
||||
label: "系统配置",
|
||||
items: [
|
||||
{ path: "/admin/settings/features", label: "功能开放", description: "菜单、渠道与积分模式", icon: <ToggleLeft className="size-4" /> },
|
||||
{ path: "/admin/settings/features", label: "功能开放", description: "工作台、插件与模型能力", icon: <ToggleLeft className="size-4" /> },
|
||||
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },
|
||||
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
|
||||
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" /> },
|
||||
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
|
||||
{ path: "/admin/settings/access", label: "登录与注册", description: "账号创建与第三方登录", icon: <ShieldCheck className="size-4" /> },
|
||||
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码与 SMTP", icon: <Mail className="size-4" /> },
|
||||
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
|
||||
{ path: "/admin/settings/ark-private-assets", label: "方舟素材库", description: "Seedance 可信参考素材", icon: <CloudUpload className="size-4" /> },
|
||||
{ path: "/admin/settings/response-interception", label: "模型响应拦截", description: "替换用户可见的上游异常", icon: <ShieldAlert className="size-4" /> },
|
||||
{ path: "/admin/settings/third-party", label: "第三方参数配置", description: "集中维护第三方平台凭证", icon: <KeyRound className="size-4" /> },
|
||||
{ path: "/admin/settings/response-interception", label: "模型响应拦截", description: "先启用策略,再配置替换规则", icon: <ShieldAlert className="size-4" /> },
|
||||
{ path: "/admin/settings/third-party", label: "第三方参数配置", description: "先配置凭据,再开放用户入口", icon: <KeyRound className="size-4" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -167,8 +169,9 @@ export function AdminShell() {
|
||||
export function AdminPageFrame({ title, description, actions, back, scroll = false, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; scroll?: boolean; children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const currentSection = adminNavigation.find((section) => section.items.some((item) => isAdminNavigationPath(location.pathname, item.path)));
|
||||
const sectionLabel = currentSection?.label ?? "管理后台";
|
||||
const sectionPath = currentSection?.items[0]?.path ?? "/admin";
|
||||
const currentItem = currentSection?.items.find((item) => isAdminNavigationPath(location.pathname, item.path));
|
||||
const sectionLabel = back ? (currentItem?.label ?? currentSection?.label ?? "管理后台") : (currentSection?.label ?? "管理后台");
|
||||
const sectionPath = back ? (currentItem?.path ?? currentSection?.items[0]?.path ?? "/admin") : (currentSection?.items[0]?.path ?? "/admin");
|
||||
|
||||
return (
|
||||
<WorkspacePage scroll={scroll} fluid className={cn("admin-page-root", scroll && "admin-page-root-scrollable")}>
|
||||
|
||||
@@ -292,8 +292,8 @@ export function SettingsSectionCard({
|
||||
}) {
|
||||
const isStacked = layout === "stacked";
|
||||
return (
|
||||
<section className={cn("admin-settings-section", isStacked ? "is-stacked" : "lg:grid lg:grid-cols-4", className)}>
|
||||
<div className={cn("admin-settings-section-summary flex flex-wrap items-start justify-between gap-3 px-4 py-4", icon && "has-icon", isStacked ? "" : "lg:col-span-1 lg:block")}>
|
||||
<section className={cn("admin-settings-section", isStacked && "is-stacked", className)}>
|
||||
<div className={cn("admin-settings-section-summary flex flex-wrap items-start justify-between gap-3 px-4 py-4", icon && "has-icon")}>
|
||||
<div className="admin-settings-section-summary-main flex min-w-0 items-start gap-3">
|
||||
{icon ? <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/40">{icon}</span> : null}
|
||||
<div className="min-w-0">
|
||||
@@ -302,7 +302,7 @@ export function SettingsSectionCard({
|
||||
</div>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className={cn("admin-settings-section-status shrink-0", isStacked ? "" : "lg:mt-4")}>
|
||||
<div className="admin-settings-section-status shrink-0">
|
||||
{isStatusConfig(status) ? (
|
||||
<AdminStatusBadge label={status.label} tone={status.color === "success" ? "success" : status.color === "warning" ? "warning" : status.color === "error" ? "error" : status.color === "blue" ? "info" : "neutral"} />
|
||||
) : (
|
||||
@@ -311,7 +311,7 @@ export function SettingsSectionCard({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn("admin-settings-section-content min-w-0", isStacked ? "" : "lg:col-span-3", contentClassName)}>
|
||||
<div className={cn("admin-settings-section-content min-w-0", contentClassName)}>
|
||||
{children}
|
||||
{footer ? <div className="admin-settings-section-footer flex flex-wrap items-center justify-between gap-3 px-4 py-3">{footer}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { App, Button, Descriptions, Drawer, Empty, Skeleton, Tabs, Typography } from "antd";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { getAdminApiLog, queryAdminApiLogTask, type ApiCallLog } from "@/services/api/auth";
|
||||
import { AdminStatusBadge } from "./admin-ui";
|
||||
|
||||
@@ -19,7 +20,9 @@ export function ApiLogDetailDrawer({ logId, onClose, onLogUpdated }: { logId: st
|
||||
.then((result) => active && setLog(result.log))
|
||||
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取请求详情失败"))
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => { active = false; };
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [logId, message]);
|
||||
|
||||
const queryProviderTask = async () => {
|
||||
@@ -58,13 +61,20 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
|
||||
const items = [
|
||||
["时间", new Date(log.startedAt || log.createdAt).toLocaleString("zh-CN", { hour12: false })],
|
||||
["状态", <AdminStatusBadge label={failed ? "失败" : processing ? "处理中" : "成功"} tone={failed ? "error" : processing ? "warning" : "success"} />],
|
||||
["用户", <span>{log.userDisplayName || log.userAccount || "未知用户"}{log.userAccount ? <span className="ml-2 text-foreground/45">@{log.userAccount}</span> : null}</span>],
|
||||
[
|
||||
"用户",
|
||||
<span>
|
||||
{log.userDisplayName || log.userAccount || "未知用户"}
|
||||
{log.userAccount ? <span className="ml-2 text-foreground/45">@{log.userAccount}</span> : null}
|
||||
</span>,
|
||||
],
|
||||
["渠道 / 模型", `${log.channelName || "未记录渠道"} / ${log.model || "未识别模型"}`],
|
||||
["能力", capabilityText(log.capability)],
|
||||
["总耗时", formatDuration(log.durationMs)],
|
||||
["视频轮询", log.capability === "video" ? `${log.pollCount || 0} 次` : "--"],
|
||||
["Token", log.usageAvailable ? `${log.inputTokens} 输入 / ${log.outputTokens} 输出 / ${log.cachedTokens} 缓存` : "未返回"],
|
||||
["计费", log.costAvailable ? `${log.currency || "USD"} ${(log.estimatedCostMicros / 1_000_000).toFixed(6)}` : "未配置价格"],
|
||||
["积分计费", billingText(log)],
|
||||
["上游成本", log.costAvailable ? `${log.currency || "USD"} ${(log.estimatedCostMicros / 1_000_000).toFixed(6)}` : "未配置成本"],
|
||||
["错误信息", [log.errorCode, log.error].filter(Boolean).join(" · ") || "--"],
|
||||
["方法与路径", `${log.method} ${log.path}`],
|
||||
["请求 Content-Type", log.requestContentType || "--"],
|
||||
@@ -76,23 +86,55 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
|
||||
|
||||
const canQueryProviderTask = log.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId);
|
||||
|
||||
return <div className="space-y-6">
|
||||
{canQueryProviderTask ? <div className="flex justify-end"><Button icon={<RefreshCw className="size-4" />} loading={querying} onClick={onQueryProviderTask}>手动查询任务</Button></div> : null}
|
||||
<Descriptions bordered size="small" column={{ xs: 1, sm: 1, md: 2 }} items={items} />
|
||||
<section>
|
||||
<div className="mb-2 text-sm font-semibold text-foreground/85">原始报文</div>
|
||||
<Tabs items={[
|
||||
{ key: "request", label: "请求报文", children: <PayloadPanel value={log.requestBody} empty="该请求未记录请求报文" /> },
|
||||
{ key: "response", label: "响应报文", children: <PayloadPanel value={log.responseBody} empty="该请求未记录响应报文" /> },
|
||||
]} />
|
||||
</section>
|
||||
</div>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{canQueryProviderTask ? (
|
||||
<div className="flex justify-end">
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={querying} onClick={onQueryProviderTask}>
|
||||
手动查询任务
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Descriptions bordered size="small" column={{ xs: 1, sm: 1, md: 2 }} items={items} />
|
||||
<section>
|
||||
<div className="mb-2 text-sm font-semibold text-foreground/85">原始报文</div>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "request", label: "请求报文", children: <PayloadPanel value={log.requestBody} empty="该请求未记录请求报文" /> },
|
||||
{ key: "response", label: "响应报文", children: <PayloadPanel value={log.responseBody} empty="该请求未记录响应报文" /> },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function billingText(log: ApiCallLog) {
|
||||
if (!log.billingAvailable) return "未扣积分";
|
||||
const status = log.billingStatus || "reserved";
|
||||
const statusLabel = ({ settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" } as const)[status];
|
||||
return `${formatCredits(log.billingAmountMicrocredits)} 积分 · ${statusLabel}`;
|
||||
}
|
||||
|
||||
function PayloadPanel({ value, empty }: { value?: string; empty: string }) {
|
||||
if (!value) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={empty} />;
|
||||
return <div className="relative"><div className="absolute right-3 top-2 z-10"><Typography.Text copyable={{ text: value }} className="text-xs text-foreground/50">复制报文</Typography.Text></div><pre className="thin-scrollbar max-h-[46vh] overflow-auto whitespace-pre-wrap break-all rounded-md border border-border/70 bg-foreground/[.035] px-4 pb-4 pt-10 font-mono text-xs leading-5 text-foreground/75">{value}</pre></div>;
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute right-3 top-2 z-10">
|
||||
<Typography.Text copyable={{ text: value }} className="text-xs text-foreground/50">
|
||||
复制报文
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<pre className="thin-scrollbar max-h-[46vh] overflow-auto whitespace-pre-wrap break-all rounded-md border border-border/70 bg-foreground/[.035] px-4 pb-4 pt-10 font-mono text-xs leading-5 text-foreground/75">{value}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function capabilityText(value: string) { return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record<string, string>)[value] || "未知"; }
|
||||
function formatDuration(value: number) { if (value < 1_000) return `${value} ms`; if (value < 60_000) return `${(value / 1_000).toFixed(1)} 秒`; return `${Math.floor(value / 60_000)} 分 ${Math.round((value % 60_000) / 1_000)} 秒`; }
|
||||
function capabilityText(value: string) {
|
||||
return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record<string, string>)[value] || "未知";
|
||||
}
|
||||
function formatDuration(value: number) {
|
||||
if (value < 1_000) return `${value} ms`;
|
||||
if (value < 60_000) return `${(value / 1_000).toFixed(1)} 秒`;
|
||||
return `${Math.floor(value / 60_000)} 分 ${Math.round((value % 60_000) / 1_000)} 秒`;
|
||||
}
|
||||
|
||||
@@ -348,6 +348,7 @@ export default function CreditOperationsPanel({ users, activeOperation, onOperat
|
||||
title: "操作",
|
||||
width: 190,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (_, order) =>
|
||||
!canResolveBillingOrder(order) ? (
|
||||
<span className="text-xs text-foreground/45">处理完成</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBlocker } from "react-router";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from "@/services/api/wallet";
|
||||
import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
|
||||
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
|
||||
|
||||
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
|
||||
|
||||
@@ -171,39 +171,17 @@ export default function EmailSettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async (enabled: boolean) => {
|
||||
if (!setting || saving || enabled === setting.enabled) return;
|
||||
const values = (enabled ? { ...form.getFieldsValue(true), enabled } : { ...toEmailFormValues(setting), enabled, password: "" }) as EmailFormValues;
|
||||
const validationError = validateEmailDraft(values, setting);
|
||||
if (validationError) {
|
||||
form.setFieldValue("enabled", setting.enabled);
|
||||
setDraftEnabled(setting.enabled);
|
||||
message.error(validationError);
|
||||
return;
|
||||
}
|
||||
const toggleEnabled = (enabled: boolean) => {
|
||||
if (!setting || saving) return;
|
||||
form.setFieldValue("enabled", enabled);
|
||||
setDraftEnabled(enabled);
|
||||
try {
|
||||
await save(values);
|
||||
} catch {
|
||||
form.setFieldValue("enabled", setting.enabled);
|
||||
setDraftEnabled(setting.enabled);
|
||||
}
|
||||
setDirty(hasEmailChanges({ ...form.getFieldsValue(true), enabled }, setting));
|
||||
setSaveError("");
|
||||
};
|
||||
|
||||
if (loading && !setting) {
|
||||
return (
|
||||
<div className="admin-settings-stack admin-email-settings" aria-label="正在读取邮件服务配置" role="status">
|
||||
<div className="admin-email-command-bar">
|
||||
<Skeleton active title={{ width: 180 }} paragraph={false} />
|
||||
</div>
|
||||
<div className="admin-email-overview">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="admin-stat-tile">
|
||||
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-email-loading-card">
|
||||
<Skeleton active paragraph={{ rows: 7 }} />
|
||||
</div>
|
||||
@@ -231,10 +209,7 @@ export default function EmailSettingsPanel() {
|
||||
}
|
||||
|
||||
const currentValues = normalizeEmailFormValues(form.getFieldsValue(true));
|
||||
const connectionConfigured = Boolean((dirty ? currentValues.host : setting.host) && (dirty ? currentValues.port : setting.port));
|
||||
const authenticationConfigured = Boolean(dirty ? currentValues.username : setting.username);
|
||||
const senderConfigured = Boolean(dirty ? currentValues.fromEmail : setting.fromEmail);
|
||||
const currentEncryption = dirty ? currentValues.encryption : setting.encryption;
|
||||
const smtpReady = Boolean(currentValues.host && currentValues.port && currentValues.fromEmail);
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack admin-email-settings">
|
||||
@@ -244,11 +219,8 @@ export default function EmailSettingsPanel() {
|
||||
<MailCheck className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>{dirty ? "邮件服务有调整待保存" : "邮件服务配置已同步"}</strong>
|
||||
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
|
||||
</div>
|
||||
<p>{dirty ? "启用状态、SMTP 连接和发件身份只在本页暂存,核对保存后才会生效。" : "当前页面展示服务端生效值;读取和保存都不会触发测试邮件。"}</p>
|
||||
<strong>{dirty ? "有未保存的邮件调整" : `注册验证码:${setting.enabled ? "已启用" : "未启用"}`}</strong>
|
||||
<p>{dirty ? "完成当前配置后保存生效。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-email-command-actions">
|
||||
@@ -270,20 +242,33 @@ export default function EmailSettingsPanel() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-email-overview" aria-label="邮件服务配置概览">
|
||||
<AdminStatTile label="注册邮件" value={draftEnabled ? "启用" : "停用"} detail={dirty ? "暂存状态预览" : formatSettingTime(setting.updatedAt, "使用系统默认值")} />
|
||||
<AdminStatTile label="SMTP 服务器" value={connectionConfigured ? "已配置" : "未配置"} detail={connectionConfigured ? `${dirty ? currentValues.host : setting.host}:${dirty ? currentValues.port : setting.port}` : "启用前需要主机与端口"} />
|
||||
<AdminStatTile label="连接安全" value={encryptionLabel(currentEncryption)} detail={authenticationConfigured ? "使用 SMTP 账号验证" : "未使用 SMTP 账号验证"} />
|
||||
<AdminStatTile label="发件身份" value={senderConfigured ? "已配置" : "未配置"} detail={senderConfigured ? (dirty ? currentValues.fromEmail : setting.fromEmail) : "启用前需要发件邮箱"} />
|
||||
</div>
|
||||
|
||||
<div id="admin-email-delivery" className="admin-settings-anchor">
|
||||
<SettingsSectionCard
|
||||
className="admin-email-section admin-email-delivery-section"
|
||||
icon={<Send className="size-4" aria-hidden="true" />}
|
||||
title="注册验证码投递"
|
||||
description="控制注册流程是否通过 SMTP 发送邮箱验证码,调整会随整张表单一起保存。"
|
||||
title="1. 是否发送注册邮箱验证码"
|
||||
description="这是邮件服务的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
|
||||
status={<AdminStatusBadge label={draftEnabled ? (dirty && !setting.enabled ? "待启用" : "已启用") : dirty && setting.enabled ? "待停用" : "未启用"} tone={dirty ? "warning" : draftEnabled ? "success" : "neutral"} />}
|
||||
footer={
|
||||
!draftEnabled ? (
|
||||
<>
|
||||
<div className="admin-email-footer-note">
|
||||
<BadgeCheck className="size-4" aria-hidden="true" />
|
||||
<span>关闭后,普通邮箱注册不再要求邮箱验证码。</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty ? (
|
||||
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
|
||||
撤销
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="admin-email-delivery-policy">
|
||||
<span className="admin-email-policy-icon">
|
||||
@@ -292,131 +277,133 @@ export default function EmailSettingsPanel() {
|
||||
<div className="admin-email-policy-copy">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>发送注册邮箱验证码</strong>
|
||||
<AdminStatusBadge label="切换即保存" tone="info" />
|
||||
<AdminStatusBadge label="保存后生效" tone="info" />
|
||||
</div>
|
||||
<p>启用后,普通邮箱注册需要获取并校验 6 位验证码;邮件发送失败时不会创建可用验证码。</p>
|
||||
<span>关闭只停止后续验证码邮件,不改变新用户注册开关,也不影响已有账号。</span>
|
||||
</div>
|
||||
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送注册邮箱验证码,切换后立即保存" onChange={(checked) => void toggleEnabled(checked)} />
|
||||
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送注册邮箱验证码" onChange={toggleEnabled} />
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
|
||||
<div id="admin-email-smtp" className="admin-settings-anchor">
|
||||
<SettingsSectionCard
|
||||
className="admin-email-section admin-email-configuration-section"
|
||||
icon={<Server className="size-4" aria-hidden="true" />}
|
||||
title="SMTP 连接与发件身份"
|
||||
description="配置邮件服务器、身份验证与邮件中的发件人信息;页面不会主动探测或发送邮件。"
|
||||
status={<AdminStatusBadge label={dirty ? "有调整" : setting.host && setting.fromEmail ? "已配置" : "待配置"} tone={dirty ? "warning" : setting.host && setting.fromEmail ? "success" : "neutral"} />}
|
||||
footer={
|
||||
<>
|
||||
<div className="admin-email-footer-note">
|
||||
<BadgeCheck className="size-4" aria-hidden="true" />
|
||||
<span>{formatSettingTime(setting.updatedAt, "尚未保存邮件管理配置")} · SMTP 密码由服务端加密保存且不回显明文</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty ? (
|
||||
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
|
||||
撤销
|
||||
{draftEnabled ? (
|
||||
<div id="admin-email-smtp" className="admin-settings-anchor">
|
||||
<SettingsSectionCard
|
||||
className="admin-email-section admin-email-configuration-section"
|
||||
icon={<Server className="size-4" aria-hidden="true" />}
|
||||
title="2. 配置 SMTP 连接与发件身份"
|
||||
description="填写邮件服务器、身份验证和发件人信息。保存不会主动探测或发送测试邮件。"
|
||||
status={<AdminStatusBadge label={dirty ? "待保存" : smtpReady ? "已配置" : "待配置"} tone={dirty ? "warning" : smtpReady ? "success" : "neutral"} />}
|
||||
footer={
|
||||
<>
|
||||
<div className="admin-email-footer-note">
|
||||
<BadgeCheck className="size-4" aria-hidden="true" />
|
||||
<span>SMTP 密码由服务端加密保存且不回显明文</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty ? (
|
||||
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
|
||||
撤销
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
|
||||
保存并启用
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
disabled={loading || refreshing || saving}
|
||||
onValuesChange={() => {
|
||||
const values = form.getFieldsValue(true);
|
||||
setDraftEnabled(Boolean(values.enabled));
|
||||
setDirty(hasEmailChanges(values, setting));
|
||||
setSaveError("");
|
||||
}}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Item name="enabled" valuePropName="checked" hidden>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
disabled={loading || refreshing || saving}
|
||||
onValuesChange={() => {
|
||||
const values = form.getFieldsValue(true);
|
||||
setDraftEnabled(Boolean(values.enabled));
|
||||
setDirty(hasEmailChanges(values, setting));
|
||||
setSaveError("");
|
||||
}}
|
||||
>
|
||||
<Form.Item name="enabled" valuePropName="checked" hidden>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<Server className="size-4" />} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
|
||||
<div className="admin-email-form-grid is-connection">
|
||||
<Form.Item name="host" label="SMTP 主机" extra="仅填写主机名或 IP,不包含协议和端口。">
|
||||
<Input autoComplete="off" placeholder="smtp.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label="SMTP 端口"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: number | null | undefined) => (!draftEnabled || (Number(value) >= 1 && Number(value) <= 65535) ? Promise.resolve() : Promise.reject(new Error("请输入 1 至 65535 的端口"))),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber min={1} max={65535} precision={0} placeholder="587" controls={false} />
|
||||
</Form.Item>
|
||||
<Form.Item name="encryption" label="连接加密" rules={[{ required: true, message: "请选择连接加密方式" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "STARTTLS(推荐,通常 587)", value: "starttls" },
|
||||
{ label: "直接 TLS(通常 465)", value: "tls" },
|
||||
{ label: "无加密(仅限可信网络)", value: "none" },
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<Server className="size-4" />} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
|
||||
<div className="admin-email-form-grid is-connection">
|
||||
<Form.Item name="host" label="SMTP 主机" extra="仅填写主机名或 IP,不包含协议和端口。">
|
||||
<Input autoComplete="off" placeholder="smtp.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label="SMTP 端口"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: number | null | undefined) => (!draftEnabled || (Number(value) >= 1 && Number(value) <= 65535) ? Promise.resolve() : Promise.reject(new Error("请输入 1 至 65535 的端口"))),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
>
|
||||
<InputNumber min={1} max={65535} precision={0} placeholder="587" controls={false} />
|
||||
</Form.Item>
|
||||
<Form.Item name="encryption" label="连接加密" rules={[{ required: true, message: "请选择连接加密方式" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "STARTTLS(推荐,通常 587)", value: "starttls" },
|
||||
{ label: "直接 TLS(通常 465)", value: "tls" },
|
||||
{ label: "无加密(仅限可信网络)", value: "none" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<KeyRound className="size-4" />} title="SMTP 身份验证" description="服务器要求登录时填写账号和密码;密码留空会保留服务端已配置值。" />
|
||||
<div className="admin-email-form-grid">
|
||||
<Form.Item name="username" label="SMTP 用户名" extra="通常是完整邮箱地址;无需验证的服务器可留空。">
|
||||
<Input autoComplete="off" placeholder="mailer@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={setting.hasPassword ? `SMTP 密码(${configuredSecretText})` : "SMTP 密码"} extra="只在需要新增或替换密码时填写。">
|
||||
<Input.Password autoComplete="new-password" placeholder={setting.hasPassword ? "留空保留原密码" : "SMTP 密码或服务商授权码"} />
|
||||
</Form.Item>
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<KeyRound className="size-4" />} title="SMTP 身份验证" description="服务器要求登录时填写账号和密码;密码留空会保留服务端已配置值。" />
|
||||
<div className="admin-email-form-grid">
|
||||
<Form.Item name="username" label="SMTP 用户名" extra="通常是完整邮箱地址;无需验证的服务器可留空。">
|
||||
<Input autoComplete="off" placeholder="mailer@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={setting.hasPassword ? `SMTP 密码(${configuredSecretText})` : "SMTP 密码"} extra="只在需要新增或替换密码时填写。">
|
||||
<Input.Password autoComplete="new-password" placeholder={setting.hasPassword ? "留空保留原密码" : "SMTP 密码或服务商授权码"} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<AtSign className="size-4" />} title="发件人身份" description="这组名称和地址会显示在注册验证码邮件的 From 信息中。" />
|
||||
<div className="admin-email-form-grid">
|
||||
<Form.Item
|
||||
name="fromEmail"
|
||||
label="发件邮箱"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string | undefined) => (!draftEnabled || !value || isValidEmail(value.trim()) ? Promise.resolve() : Promise.reject(new Error("请输入有效的发件邮箱"))),
|
||||
},
|
||||
]}
|
||||
extra="通常需要与 SMTP 账号或服务商验证域名一致。"
|
||||
>
|
||||
<Input autoComplete="off" inputMode="email" placeholder="noreply@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="fromName"
|
||||
label="发件人名称"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string | undefined) => (!draftEnabled || !value || !/[\r\n]/.test(value) ? Promise.resolve() : Promise.reject(new Error("发件人名称不能包含换行"))),
|
||||
},
|
||||
]}
|
||||
extra="留空时服务端使用“影策”。"
|
||||
>
|
||||
<Input placeholder="影策" />
|
||||
</Form.Item>
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<AtSign className="size-4" />} title="发件人身份" description="这组名称和地址会显示在注册验证码邮件的 From 信息中。" />
|
||||
<div className="admin-email-form-grid">
|
||||
<Form.Item
|
||||
name="fromEmail"
|
||||
label="发件邮箱"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string | undefined) => (!draftEnabled || !value || isValidEmail(value.trim()) ? Promise.resolve() : Promise.reject(new Error("请输入有效的发件邮箱"))),
|
||||
},
|
||||
]}
|
||||
extra="通常需要与 SMTP 账号或服务商验证域名一致。"
|
||||
>
|
||||
<Input autoComplete="off" inputMode="email" placeholder="noreply@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="fromName"
|
||||
label="发件人名称"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string | undefined) => (!draftEnabled || !value || !/[\r\n]/.test(value) ? Promise.resolve() : Promise.reject(new Error("发件人名称不能包含换行"))),
|
||||
},
|
||||
]}
|
||||
extra="留空时服务端使用“影策”。"
|
||||
>
|
||||
<Input placeholder="影策" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -503,12 +490,6 @@ function isValidEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
function encryptionLabel(value: EmailSetting["encryption"]) {
|
||||
if (value === "tls") return "直接 TLS";
|
||||
if (value === "none") return "无加密";
|
||||
return "STARTTLS";
|
||||
}
|
||||
|
||||
function formatSettingTime(value: string | undefined, fallback: string) {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { App, Button, Skeleton, Switch } from "antd";
|
||||
import { AlertTriangle, Clapperboard, Coins, ListChecks, LockKeyhole, MonitorCog, PlugZap, RadioTower, RefreshCw, ShieldCheck, Sparkles } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth";
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { useAdminContext } from "../admin-context";
|
||||
import { AdminStatusBadge } from "./admin-ui";
|
||||
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers";
|
||||
type FeatureSelectionKey = FeatureKey | "desktopLocalChannelsEnabled";
|
||||
|
||||
type FeatureRow = {
|
||||
key: FeatureKey;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
description: string;
|
||||
enabledImpact: string;
|
||||
disabledImpact: string;
|
||||
icon: ReactNode;
|
||||
dependsOn?: FeatureKey;
|
||||
};
|
||||
@@ -28,37 +22,25 @@ const workspaceFeatureRows: FeatureRow[] = [
|
||||
{
|
||||
key: "shortDramaEnabled",
|
||||
title: "短剧创作",
|
||||
eyebrow: "项目与短剧入口",
|
||||
description: "控制短剧入口、项目列表、项目详情和项目接口。关闭不会删除已有项目数据。",
|
||||
enabledImpact: "普通用户可以继续进入短剧与项目工作区。",
|
||||
disabledImpact: "隐藏短剧入口并拦截项目列表、详情和项目接口,已有数据保留。",
|
||||
description: "开放短剧入口、项目列表与项目详情。关闭不删除已有项目。",
|
||||
icon: <Clapperboard className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
key: "taskCenterEnabled",
|
||||
title: "任务中心",
|
||||
eyebrow: "任务记录入口",
|
||||
description: "控制任务中心页面是否可见;生成任务本身仍会创建、执行、记录和恢复。",
|
||||
enabledImpact: "普通用户可以查看任务中心和任务记录。",
|
||||
disabledImpact: "隐藏并拦截任务中心页面,但不会停止生成任务。",
|
||||
description: "开放任务记录页面。关闭不会停止生成任务。",
|
||||
icon: <ListChecks className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
key: "creditsEnabled",
|
||||
title: "积分计费",
|
||||
eyebrow: "钱包与计费模式",
|
||||
description: "控制积分入口以及新任务、系统渠道请求是否冻结和消费积分。已有余额与流水保留。",
|
||||
enabledImpact: "新任务和系统渠道请求继续按积分规则预授权与结算。",
|
||||
disabledImpact: "隐藏积分入口,新任务和系统渠道请求不再冻结或消费积分;既有订单仍按原规则结算。",
|
||||
description: "控制钱包入口及新任务的积分预授权与结算。",
|
||||
icon: <Coins className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
key: "customChannelsEnabled",
|
||||
title: "自定义渠道",
|
||||
eyebrow: "用户渠道能力",
|
||||
description: "控制用户自定义渠道入口、模型目录拉取、渠道中转和使用自定义渠道创建新任务。",
|
||||
enabledImpact: "普通用户可以配置并使用自己的模型渠道。",
|
||||
disabledImpact: "隐藏渠道入口并拦截自定义渠道中转与新任务,已有渠道配置保留。",
|
||||
description: "允许用户配置并使用自己的模型渠道。",
|
||||
icon: <RadioTower className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
];
|
||||
@@ -67,19 +49,13 @@ const pluginFeatureRows: FeatureRow[] = [
|
||||
{
|
||||
key: "pluginCenterEnabled",
|
||||
title: "插件中心",
|
||||
eyebrow: "用户插件入口",
|
||||
description: "控制普通用户进入插件中心及调用插件中心接口;管理员仍可从后台恢复开关。",
|
||||
enabledImpact: "普通用户可以进入插件中心并使用允许的插件。",
|
||||
disabledImpact: "普通用户无法进入或调用插件中心,已有插件与配置不会删除。",
|
||||
description: "开放插件中心及插件调用能力。",
|
||||
icon: <PlugZap className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
key: "systemPluginsVisibleToUsers",
|
||||
title: "系统插件可见性",
|
||||
eyebrow: "插件内容范围",
|
||||
description: "控制协议类系统插件和管理员上传插件是否向普通用户展示;官方应用仍可由用户启用。",
|
||||
enabledImpact: "普通用户可以看到系统协议插件和管理员上传插件。",
|
||||
disabledImpact: "普通用户只看到官方应用插件,系统协议与管理员上传插件隐藏。",
|
||||
description: "向普通用户展示系统协议插件和管理员上传插件。",
|
||||
icon: <ShieldCheck className="size-4" aria-hidden="true" />,
|
||||
dependsOn: "pluginCenterEnabled",
|
||||
},
|
||||
@@ -89,10 +65,7 @@ const modelFeatureRows: FeatureRow[] = [
|
||||
{
|
||||
key: "frontendModelsEnabled",
|
||||
title: "前台模型目录",
|
||||
eyebrow: "用户模型来源",
|
||||
description: "开启后使用前台模型目录;关闭后直接使用系统渠道中的模型。已有前台模型配置不会删除。",
|
||||
enabledImpact: "用户模型选择使用前台模型目录与对应线路。",
|
||||
disabledImpact: "用户直接使用系统渠道模型,后台前台模型配置保留但入口隐藏。",
|
||||
description: "选择前台模型目录,或直接使用系统渠道中的模型。",
|
||||
icon: <Sparkles className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
];
|
||||
@@ -101,8 +74,7 @@ const allFeatureRows = [...workspaceFeatureRows, ...pluginFeatureRows, ...modelF
|
||||
const featureByKey = new Map(allFeatureRows.map((item) => [item.key, item]));
|
||||
|
||||
export default function FeatureAvailabilityPanel() {
|
||||
const { message } = App.useApp();
|
||||
const { references } = useAdminContext();
|
||||
const { message, modal } = App.useApp();
|
||||
const setGlobalFeatures = useUserStore((state) => state.setFeatures);
|
||||
const [savedFeatures, setSavedFeatures] = useState<FeatureAvailability | null>(null);
|
||||
const [draftFeatures, setDraftFeatures] = useState<FeatureAvailability | null>(null);
|
||||
@@ -111,9 +83,7 @@ export default function FeatureAvailabilityPanel() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [selectedFeatureKey, setSelectedFeatureKey] = useState<FeatureSelectionKey>("shortDramaEnabled");
|
||||
const requestVersionRef = useRef(0);
|
||||
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
|
||||
|
||||
const load = useCallback(
|
||||
async (initial = false, announce = false) => {
|
||||
@@ -177,6 +147,33 @@ export default function FeatureAvailabilityPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const requestFeatureChange = (key: FeatureKey, enabled: boolean) => {
|
||||
if (!savedFeatures || saving || savedFeatures[key] === enabled) return;
|
||||
if (key === "creditsEnabled" && !enabled) {
|
||||
modal.confirm({
|
||||
title: "关闭用户积分功能?",
|
||||
content: "保存后新创建的任务和系统渠道请求将不再扣减积分;已经冻结的计费订单仍按原规则结算,已有余额和流水继续保留。",
|
||||
okText: "确认关闭",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => setFeature(key, false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (key === "frontendModelsEnabled" && !enabled) {
|
||||
modal.confirm({
|
||||
title: "关闭前台模型功能?",
|
||||
content: "关闭后用户将直接使用系统渠道中配置的模型;管理后台仍保留前台模型配置入口,重新开启后即可继续使用。",
|
||||
okText: "确认关闭",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => setFeature(key, false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
void setFeature(key, enabled);
|
||||
};
|
||||
|
||||
if (loading && !draftFeatures) {
|
||||
return (
|
||||
<div className="admin-settings-stack admin-feature-availability" aria-label="正在读取功能开放配置" role="status">
|
||||
@@ -218,22 +215,14 @@ export default function FeatureAvailabilityPanel() {
|
||||
|
||||
const enabledWorkspaceFeatures = workspaceFeatureRows.filter((row) => effectiveFeatureValue(draftFeatures, row.key)).length;
|
||||
const enabledPluginFeatures = pluginFeatureRows.filter((row) => effectiveFeatureValue(draftFeatures, row.key)).length;
|
||||
const updateActor = savedFeatures.updatedBy ? userNameById.get(savedFeatures.updatedBy) || savedFeatures.updatedBy : "";
|
||||
const updateDetail = savedFeatures.configured ? `${formatTime(savedFeatures.updatedAt)}${updateActor ? ` · ${updateActor}` : ""}` : "尚未由管理员保存,使用系统默认值";
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack admin-feature-availability">
|
||||
<div className="admin-feature-command-bar">
|
||||
<div className="admin-feature-command-copy" aria-live="polite">
|
||||
<span className="admin-feature-command-icon">
|
||||
<ShieldCheck className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>{saving ? "正在保存功能状态" : "功能状态已同步"}</strong>
|
||||
<AdminStatusBadge label={saving ? "提交中" : savedFeatures.configured ? "已保存配置" : "系统默认"} tone={saving ? "warning" : savedFeatures.configured ? "success" : "neutral"} />
|
||||
</div>
|
||||
<p>{saving ? "正在写入服务端,请稍候。" : `配置记录:${updateDetail}。开关与模型来源切换后会立即保存。`}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>{saving ? "正在保存更改" : "开关切换后立即生效"}</strong>
|
||||
<AdminStatusBadge label={saving ? "提交中" : savedFeatures.configured ? "服务端配置" : "系统默认"} tone={saving ? "warning" : "neutral"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-feature-command-actions">
|
||||
@@ -252,47 +241,43 @@ export default function FeatureAvailabilityPanel() {
|
||||
|
||||
<div className="admin-feature-board" aria-label="功能开放控制台">
|
||||
<FeatureDomainPanel
|
||||
compact
|
||||
title="用户工作台"
|
||||
description="入口与对应服务能力"
|
||||
title="1. 用户工作台入口"
|
||||
description="先决定普通用户能进入哪些核心工作区"
|
||||
icon={<MonitorCog className="size-4" aria-hidden="true" />}
|
||||
status={<AdminStatusBadge label={`${enabledWorkspaceFeatures}/4 开放`} tone={enabledWorkspaceFeatures === 4 ? "success" : "neutral"} />}
|
||||
>
|
||||
{workspaceFeatureRows.map((row) => (
|
||||
<FeatureBoardToggleRow key={row.key} row={row} saved={savedFeatures} draft={draftFeatures} saving={saving} selected={selectedFeatureKey === row.key} onSelect={setSelectedFeatureKey} onChange={setFeature} />
|
||||
<FeatureSettingRow key={row.key} row={row} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} />
|
||||
))}
|
||||
</FeatureDomainPanel>
|
||||
|
||||
<FeatureDomainPanel
|
||||
title="插件策略"
|
||||
description="先开放入口,再限定范围"
|
||||
title="2. 插件开放范围"
|
||||
description="先开放插件中心,再决定系统插件是否可见"
|
||||
icon={<PlugZap className="size-4" aria-hidden="true" />}
|
||||
status={<AdminStatusBadge label={`${enabledPluginFeatures}/2 生效`} tone={enabledPluginFeatures === 2 ? "success" : "neutral"} />}
|
||||
>
|
||||
{pluginFeatureRows.map((row, index) => (
|
||||
<FeatureBoardToggleRow key={row.key} row={row} saved={savedFeatures} draft={draftFeatures} saving={saving} selected={selectedFeatureKey === row.key} onSelect={setSelectedFeatureKey} onChange={setFeature} step={index + 1} />
|
||||
))}
|
||||
<FeatureSettingRow row={pluginFeatureRows[0]} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} step={1} />
|
||||
{draftFeatures.pluginCenterEnabled ? <FeatureSettingRow row={pluginFeatureRows[1]} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} step={2} /> : null}
|
||||
</FeatureDomainPanel>
|
||||
|
||||
<FeatureDomainPanel
|
||||
title="模型与环境"
|
||||
description="模型路径与部署能力"
|
||||
title="3. 用户模型来源"
|
||||
description="决定用户选模型时读取前台目录还是系统渠道"
|
||||
icon={<Sparkles className="size-4" aria-hidden="true" />}
|
||||
status={<AdminStatusBadge label={draftFeatures.frontendModelsEnabled ? "前台模型目录" : "系统渠道"} tone="info" />}
|
||||
>
|
||||
<FeatureBoardSourceRow row={modelFeatureRows[0]} saved={savedFeatures} draft={draftFeatures} saving={saving} selected={selectedFeatureKey === "frontendModelsEnabled"} onSelect={setSelectedFeatureKey} onChange={setFeature} />
|
||||
<FeatureBoardRuntimeRow enabled={draftFeatures.desktopLocalChannelsEnabled} selected={selectedFeatureKey === "desktopLocalChannelsEnabled"} onSelect={setSelectedFeatureKey} />
|
||||
<FeatureSourceRow row={modelFeatureRows[0]} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} />
|
||||
<FeatureRuntimeRow enabled={draftFeatures.desktopLocalChannelsEnabled} />
|
||||
</FeatureDomainPanel>
|
||||
</div>
|
||||
|
||||
<FeatureDetailPanel selection={selectedFeatureKey} saved={savedFeatures} draft={draftFeatures} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureDomainPanel({ title, description, icon, status, children, compact = false }: { title: string; description: string; icon: ReactNode; status: ReactNode; children: ReactNode; compact?: boolean }) {
|
||||
function FeatureDomainPanel({ title, description, icon, status, children }: { title: string; description: string; icon: ReactNode; status: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className={cn("admin-feature-domain", compact && "is-compact")}>
|
||||
<section className="admin-feature-domain">
|
||||
<header className="admin-feature-domain-header">
|
||||
<span className="admin-feature-domain-icon">{icon}</span>
|
||||
<div className="admin-feature-domain-copy min-w-0 flex-1">
|
||||
@@ -306,108 +291,48 @@ function FeatureDomainPanel({ title, description, icon, status, children, compac
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureBoardToggleRow({
|
||||
row,
|
||||
saved,
|
||||
draft,
|
||||
saving,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
step,
|
||||
}: {
|
||||
row: FeatureRow;
|
||||
saved: FeatureAvailability;
|
||||
draft: FeatureAvailability;
|
||||
saving: boolean;
|
||||
selected: boolean;
|
||||
onSelect: (key: FeatureSelectionKey) => void;
|
||||
onChange: (key: FeatureKey, enabled: boolean) => void;
|
||||
step?: number;
|
||||
}) {
|
||||
function FeatureSettingRow({ row, saved, draft, saving, onChange, step }: { row: FeatureRow; saved: FeatureAvailability; draft: FeatureAvailability; saving: boolean; onChange: (key: FeatureKey, enabled: boolean) => void; step?: number }) {
|
||||
const changed = saved[row.key] !== draft[row.key];
|
||||
const dependencyDisabled = Boolean(row.dependsOn && !draft[row.dependsOn]);
|
||||
const enabled = effectiveFeatureValue(draft, row.key);
|
||||
|
||||
return (
|
||||
<article className={cn("admin-feature-board-row", selected && "is-selected", changed && "is-dirty", !enabled && "is-off", row.dependsOn && "is-dependent")}>
|
||||
<button type="button" className="admin-feature-board-row-select" aria-pressed={selected} aria-controls="admin-feature-detail" onClick={() => onSelect(row.key)}>
|
||||
<article className={cn("admin-feature-board-row", changed && "is-dirty", !enabled && "is-off", row.dependsOn && "is-dependent")}>
|
||||
<div className="admin-feature-board-row-copy">
|
||||
<span className="admin-feature-board-row-icon">{row.icon}</span>
|
||||
<span className="admin-feature-board-row-name">
|
||||
{step ? <small>第 {step} 步</small> : null}
|
||||
<strong>{row.title}</strong>
|
||||
<span>{row.description}</span>
|
||||
</span>
|
||||
{changed ? <span className="admin-feature-board-row-dirty">待保存</span> : null}
|
||||
</button>
|
||||
<div className="admin-feature-board-row-control" onFocusCapture={() => onSelect(row.key)}>
|
||||
{dependencyDisabled ? <span>未生效</span> : null}
|
||||
<Switch
|
||||
checked={draft[row.key]}
|
||||
disabled={saving || dependencyDisabled}
|
||||
onChange={(checked) => {
|
||||
onSelect(row.key);
|
||||
onChange(row.key, checked);
|
||||
}}
|
||||
aria-label={`设置${row.title},切换后立即保存`}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-feature-board-row-control">
|
||||
<span>{dependencyDisabled ? "依赖未开启" : enabled ? "已开放" : "已关闭"}</span>
|
||||
<Switch checked={draft[row.key]} disabled={saving || dependencyDisabled} onChange={(checked) => onChange(row.key, checked)} aria-label={`设置${row.title},切换后立即保存`} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureBoardSourceRow({
|
||||
row,
|
||||
saved,
|
||||
draft,
|
||||
saving,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
}: {
|
||||
row: FeatureRow;
|
||||
saved: FeatureAvailability;
|
||||
draft: FeatureAvailability;
|
||||
saving: boolean;
|
||||
selected: boolean;
|
||||
onSelect: (key: FeatureSelectionKey) => void;
|
||||
onChange: (key: FeatureKey, enabled: boolean) => void;
|
||||
}) {
|
||||
function FeatureSourceRow({ row, saved, draft, saving, onChange }: { row: FeatureRow; saved: FeatureAvailability; draft: FeatureAvailability; saving: boolean; onChange: (key: FeatureKey, enabled: boolean) => void }) {
|
||||
const changed = saved.frontendModelsEnabled !== draft.frontendModelsEnabled;
|
||||
return (
|
||||
<article className={cn("admin-feature-board-row is-source", selected && "is-selected", changed && "is-dirty")}>
|
||||
<button type="button" className="admin-feature-board-row-select" aria-pressed={selected} aria-controls="admin-feature-detail" onClick={() => onSelect(row.key)}>
|
||||
<article className={cn("admin-feature-board-row is-source", changed && "is-dirty")}>
|
||||
<div className="admin-feature-board-row-copy">
|
||||
<span className="admin-feature-board-row-icon">{row.icon}</span>
|
||||
<span className="admin-feature-board-row-name">
|
||||
<small>模型来源</small>
|
||||
<strong>用户模型目录</strong>
|
||||
<span>{row.description}</span>
|
||||
</span>
|
||||
{changed ? <span className="admin-feature-board-row-dirty">待保存</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-feature-board-source-selector" role="radiogroup" aria-label="选择用户模型目录来源">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={!draft.frontendModelsEnabled}
|
||||
disabled={saving}
|
||||
className={cn(!draft.frontendModelsEnabled && "is-selected")}
|
||||
onClick={() => {
|
||||
onSelect("frontendModelsEnabled");
|
||||
onChange("frontendModelsEnabled", false);
|
||||
}}
|
||||
>
|
||||
<button type="button" role="radio" aria-checked={!draft.frontendModelsEnabled} disabled={saving} className={cn(!draft.frontendModelsEnabled && "is-selected")} onClick={() => onChange("frontendModelsEnabled", false)}>
|
||||
系统渠道
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={draft.frontendModelsEnabled}
|
||||
disabled={saving}
|
||||
className={cn(draft.frontendModelsEnabled && "is-selected")}
|
||||
onClick={() => {
|
||||
onSelect("frontendModelsEnabled");
|
||||
onChange("frontendModelsEnabled", true);
|
||||
}}
|
||||
>
|
||||
<button type="button" role="radio" aria-checked={draft.frontendModelsEnabled} disabled={saving} className={cn(draft.frontendModelsEnabled && "is-selected")} onClick={() => onChange("frontendModelsEnabled", true)}>
|
||||
前台目录
|
||||
</button>
|
||||
</div>
|
||||
@@ -415,96 +340,15 @@ function FeatureBoardSourceRow({
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureBoardRuntimeRow({ enabled, selected, onSelect }: { enabled: boolean; selected: boolean; onSelect: (key: FeatureSelectionKey) => void }) {
|
||||
function FeatureRuntimeRow({ enabled }: { enabled: boolean }) {
|
||||
return (
|
||||
<article className={cn("admin-feature-board-row is-readonly", selected && "is-selected")}>
|
||||
<button type="button" className="admin-feature-board-row-select" aria-pressed={selected} aria-controls="admin-feature-detail" onClick={() => onSelect("desktopLocalChannelsEnabled")}>
|
||||
<span className="admin-feature-board-row-icon">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="admin-feature-board-row-name">
|
||||
<small>部署能力 · 只读</small>
|
||||
<strong>桌面本地渠道</strong>
|
||||
</span>
|
||||
</button>
|
||||
<div className="admin-feature-board-runtime-status">
|
||||
<AdminStatusBadge label={enabled ? "已启用" : "未启用"} tone={enabled ? "success" : "neutral"} />
|
||||
<span>只读</span>
|
||||
<aside className="admin-feature-runtime-note" aria-label="桌面本地渠道部署状态">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>桌面本地渠道由部署环境控制</strong>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureDetailPanel({ selection, saved, draft }: { selection: FeatureSelectionKey; saved: FeatureAvailability; draft: FeatureAvailability }) {
|
||||
if (selection === "desktopLocalChannelsEnabled") {
|
||||
const enabled = draft.desktopLocalChannelsEnabled;
|
||||
return (
|
||||
<section id="admin-feature-detail" className="admin-feature-detail" aria-live="polite">
|
||||
<FeatureDetailHeader
|
||||
icon={<LockKeyhole className="size-4" aria-hidden="true" />}
|
||||
eyebrow="部署环境能力 · 只读"
|
||||
title="桌面本地渠道"
|
||||
status={<AdminStatusBadge label={enabled ? "环境已启用" : "环境未启用"} tone={enabled ? "success" : "neutral"} />}
|
||||
/>
|
||||
<div className="admin-feature-detail-grid">
|
||||
<FeatureDetailCell label="当前状态" value={enabled ? "当前部署可以使用桌面本地渠道。" : "当前部署不能使用桌面本地渠道。"} />
|
||||
<FeatureDetailCell label="能力来源" value="由后端运行环境决定,不能通过管理页面修改。" />
|
||||
<FeatureDetailCell label="保存关系" value="这是只读运行状态,不包含在本次功能配置保存请求中。" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const row = featureByKey.get(selection)!;
|
||||
const changed = saved[row.key] !== draft[row.key];
|
||||
const dependencyDisabled = Boolean(row.dependsOn && !draft[row.dependsOn]);
|
||||
const effective = effectiveFeatureValue(draft, row.key);
|
||||
const currentImpact = dependencyDisabled ? "插件中心关闭时,此项保留偏好但当前不生效。" : draft[row.key] ? row.enabledImpact : row.disabledImpact;
|
||||
const alternateImpact = draft[row.key] ? row.disabledImpact : row.enabledImpact;
|
||||
const statusLabel = row.key === "frontendModelsEnabled" ? (draft.frontendModelsEnabled ? "前台模型目录" : "系统渠道") : dependencyDisabled ? "依赖未生效" : effective ? "开放" : "关闭";
|
||||
const alternateLabel = row.key === "frontendModelsEnabled" ? "切换来源后" : draft[row.key] ? "关闭后" : "开放后";
|
||||
|
||||
return (
|
||||
<section id="admin-feature-detail" className={cn("admin-feature-detail", changed && "is-dirty")} aria-live="polite">
|
||||
<FeatureDetailHeader
|
||||
icon={row.icon}
|
||||
eyebrow={row.eyebrow}
|
||||
title={row.key === "frontendModelsEnabled" ? "用户模型目录来源" : row.title}
|
||||
status={
|
||||
<div className="flex items-center gap-2">
|
||||
{changed ? <AdminStatusBadge label="待保存" tone="warning" /> : null}
|
||||
<AdminStatusBadge label={statusLabel} tone={dependencyDisabled ? "warning" : effective ? "success" : "neutral"} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="admin-feature-detail-grid">
|
||||
<FeatureDetailCell label={dependencyDisabled ? "当前依赖" : row.key === "frontendModelsEnabled" ? "当前来源" : "当前效果"} value={currentImpact} tone={dependencyDisabled ? "warning" : undefined} />
|
||||
<FeatureDetailCell label="控制范围" value={row.description} />
|
||||
<FeatureDetailCell label={alternateLabel} value={alternateImpact} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureDetailHeader({ icon, eyebrow, title, status }: { icon: ReactNode; eyebrow: string; title: string; status: ReactNode }) {
|
||||
return (
|
||||
<header className="admin-feature-detail-header">
|
||||
<span className="admin-feature-detail-icon">{icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{status}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureDetailCell({ label, value, tone }: { label: string; value: string; tone?: "warning" }) {
|
||||
return (
|
||||
<div className={cn("admin-feature-detail-cell", tone && `is-${tone}`)}>
|
||||
<span>{label}</span>
|
||||
<p>{value}</p>
|
||||
</div>
|
||||
<AdminStatusBadge label={enabled ? "当前可用" : "当前不可用"} tone={enabled ? "success" : "neutral"} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,10 +393,3 @@ function parseFeatureAvailability(value: unknown): FeatureAvailability {
|
||||
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return "更新时间未知";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "更新时间未知";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
@@ -7,15 +7,7 @@ import { useSearchParams } from "react-router";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
adminResourceFileUrl,
|
||||
deleteAdminResources,
|
||||
downloadAdminResource,
|
||||
getAdminStorageStats,
|
||||
listAdminResources,
|
||||
type AdminStorageResource,
|
||||
type AdminStorageStats,
|
||||
} from "@/services/api/admin-storage";
|
||||
import { adminResourceFileUrl, deleteAdminResources, downloadAdminResource, getAdminStorageStats, listAdminResources, type AdminStorageResource, type AdminStorageStats } from "@/services/api/admin-storage";
|
||||
import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
|
||||
|
||||
const pageSizes = [20, 50, 100];
|
||||
@@ -47,10 +39,7 @@ export default function StorageResourcesPanel() {
|
||||
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(patch).forEach(([key, value]) => {
|
||||
const isDefault = (key === "filter" || key === "userId") && value === ""
|
||||
|| ["kind", "status", "provider"].includes(key) && value === "all"
|
||||
|| key === "page" && value === 1
|
||||
|| key === "pageSize" && value === 20;
|
||||
const isDefault = ((key === "filter" || key === "userId") && value === "") || (["kind", "status", "provider"].includes(key) && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
|
||||
if (isDefault) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
@@ -72,15 +61,18 @@ export default function StorageResourcesPanel() {
|
||||
const sequence = ++requestSequence.current;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void listAdminResources({
|
||||
keyword: debouncedKeyword || undefined,
|
||||
kind: kind === "all" ? undefined : kind,
|
||||
status: status === "all" ? undefined : status,
|
||||
provider: provider === "all" ? undefined : provider,
|
||||
userId: debouncedUserId || undefined,
|
||||
page,
|
||||
limit: pageSize,
|
||||
}, controller.signal)
|
||||
void listAdminResources(
|
||||
{
|
||||
keyword: debouncedKeyword || undefined,
|
||||
kind: kind === "all" ? undefined : kind,
|
||||
status: status === "all" ? undefined : status,
|
||||
provider: provider === "all" ? undefined : provider,
|
||||
userId: debouncedUserId || undefined,
|
||||
page,
|
||||
limit: pageSize,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then((result) => {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setResources(result.items);
|
||||
@@ -95,17 +87,62 @@ export default function StorageResourcesPanel() {
|
||||
return () => controller.abort();
|
||||
}, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize, refreshKey]);
|
||||
|
||||
const columns = useMemo<ColumnsType<AdminStorageResource>>(() => [
|
||||
{ title: "资源", width: 260, render: (_, resource) => <div className="min-w-0"><div className="truncate font-medium text-foreground/85" title={resource.objectKey}>{fileName(resource.objectKey) || resource.id}</div><div className="admin-monospace truncate text-foreground/42" title={resource.id}>{resource.id}</div></div> },
|
||||
{ title: "用户", width: 170, render: (_, resource) => <div className="min-w-0"><div className="truncate text-foreground/78">{resource.userName || resource.userId}</div><div className="admin-monospace truncate text-foreground/38" title={resource.userId}>{resource.userId}</div></div> },
|
||||
{ title: "类型", dataIndex: "kind", width: 92, render: (value) => kindLabel(value) },
|
||||
{ title: "状态", dataIndex: "status", width: 104, render: (value) => <AdminStatusBadge label={statusLabel(value)} tone={statusTone(value)} /> },
|
||||
{ title: "存储", dataIndex: "provider", width: 110, render: (value) => providerLabel(value) },
|
||||
{ title: "大小", dataIndex: "size", width: 110, render: (value) => <span className="tabular-nums">{formatBytes(value)}</span> },
|
||||
{ title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) },
|
||||
{ title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime },
|
||||
{ title: "操作", width: 220, align: "right", fixed: "right", render: (_, resource) => <div className="flex justify-end gap-1"><Button type="text" size="small" icon={<Eye className="size-3.5" />} disabled={resource.status !== "ready"} onClick={() => setPreviewing(resource)}>预览</Button><Button type="text" size="small" icon={<Download className="size-3.5" />} loading={downloadingId === resource.id} disabled={resource.status !== "ready"} onClick={() => void download(resource)}>下载</Button><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} disabled={deleting} onClick={() => confirmDelete([resource.id])}>删除</Button></div> },
|
||||
], [downloadingId, deleting]);
|
||||
const columns = useMemo<ColumnsType<AdminStorageResource>>(
|
||||
() => [
|
||||
{
|
||||
title: "资源",
|
||||
width: 260,
|
||||
render: (_, resource) => (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground/85" title={resource.objectKey}>
|
||||
{fileName(resource.objectKey) || resource.id}
|
||||
</div>
|
||||
<div className="admin-monospace truncate text-foreground/42" title={resource.id}>
|
||||
{resource.id}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "用户",
|
||||
width: 170,
|
||||
render: (_, resource) => (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-foreground/78">{resource.userName || resource.userId}</div>
|
||||
<div className="admin-monospace truncate text-foreground/38" title={resource.userId}>
|
||||
{resource.userId}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "类型", dataIndex: "kind", width: 92, render: (value) => kindLabel(value) },
|
||||
{ title: "状态", dataIndex: "status", width: 104, render: (value) => <AdminStatusBadge label={statusLabel(value)} tone={statusTone(value)} /> },
|
||||
{ title: "存储", dataIndex: "provider", width: 110, render: (value) => providerLabel(value) },
|
||||
{ title: "大小", dataIndex: "size", width: 110, render: (value) => <span className="tabular-nums">{formatBytes(value)}</span> },
|
||||
{ title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) },
|
||||
{ title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime },
|
||||
{
|
||||
title: "操作",
|
||||
width: 220,
|
||||
align: "right",
|
||||
fixed: "right",
|
||||
render: (_, resource) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button type="text" size="small" icon={<Eye className="size-3.5" />} disabled={resource.status !== "ready"} onClick={() => setPreviewing(resource)}>
|
||||
预览
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<Download className="size-3.5" />} loading={downloadingId === resource.id} disabled={resource.status !== "ready"} onClick={() => void download(resource)}>
|
||||
下载
|
||||
</Button>
|
||||
<Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} disabled={deleting} onClick={() => confirmDelete([resource.id])}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[downloadingId, deleting],
|
||||
);
|
||||
|
||||
const download = async (resource: AdminStorageResource) => {
|
||||
setDownloadingId(resource.id);
|
||||
@@ -160,18 +197,79 @@ export default function StorageResourcesPanel() {
|
||||
<AdminStatTile label="存储分布" value={stats ? formatBytes(stats.remoteBytes) : "--"} detail={stats ? `本地 ${formatBytes(stats.localBytes)} · 远端` : undefined} />
|
||||
</div>
|
||||
<AdminDataTable
|
||||
toolbar={<><Input aria-label="搜索资源" autoComplete="off" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索资源 ID 或对象路径" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} /><Input aria-label="按用户 ID 筛选" autoComplete="off" allowClear className="w-48" value={userId} placeholder="用户 ID" onChange={(event) => updateUrl({ userId: event.target.value, page: 1 }, true)} /></>}
|
||||
toolbarFilters={<><Select className="w-32" value={kind} onChange={(value) => updateUrl({ kind: value, page: 1 })} options={kindOptions} /><Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={statusOptions} /><Select className="w-36" value={provider} onChange={(value) => updateUrl({ provider: value, page: 1 })} options={providerOptions} /></>}
|
||||
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}{userId ? <AdminFilterChip label={`用户:${userId}`} onRemove={() => updateUrl({ userId: "", page: 1 })} /> : null}{kind !== "all" ? <AdminFilterChip label={`类型:${kindLabel(kind)}`} onRemove={() => updateUrl({ kind: "all", page: 1 })} /> : null}{status !== "all" ? <AdminFilterChip label={`状态:${statusLabel(status)}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}{provider !== "all" ? <AdminFilterChip label={`存储:${providerLabel(provider)}`} onRemove={() => updateUrl({ provider: "all", page: 1 })} /> : null}</>}
|
||||
toolbar={
|
||||
<div className="admin-storage-resource-filters">
|
||||
<Input
|
||||
aria-label="搜索资源"
|
||||
autoComplete="off"
|
||||
allowClear
|
||||
className="app-list-search"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="资源 ID 或对象路径"
|
||||
onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)}
|
||||
/>
|
||||
<Input aria-label="按用户 ID 筛选" autoComplete="off" allowClear className="w-48" value={userId} placeholder="用户" onChange={(event) => updateUrl({ userId: event.target.value, page: 1 }, true)} />
|
||||
<Select aria-label="筛选资源类型" className="w-32" value={kind} onChange={(value) => updateUrl({ kind: value, page: 1 })} options={kindOptions} />
|
||||
<Select aria-label="筛选资源状态" className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={statusOptions} />
|
||||
<Select aria-label="筛选存储类型" className="w-36" value={provider} onChange={(value) => updateUrl({ provider: value, page: 1 })} options={providerOptions} />
|
||||
</div>
|
||||
}
|
||||
toolbarActiveFilters={
|
||||
<>
|
||||
{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}
|
||||
{userId ? <AdminFilterChip label={`用户:${userId}`} onRemove={() => updateUrl({ userId: "", page: 1 })} /> : null}
|
||||
{kind !== "all" ? <AdminFilterChip label={`类型:${kindLabel(kind)}`} onRemove={() => updateUrl({ kind: "all", page: 1 })} /> : null}
|
||||
{status !== "all" ? <AdminFilterChip label={`状态:${statusLabel(status)}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}
|
||||
{provider !== "all" ? <AdminFilterChip label={`存储:${providerLabel(provider)}`} onRemove={() => updateUrl({ provider: "all", page: 1 })} /> : null}
|
||||
</>
|
||||
}
|
||||
toolbarActive={hasFilters}
|
||||
onReset={() => updateUrl({ filter: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
||||
batchActions={<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}><Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deleting} onClick={() => confirmDelete(selectedIds)}>批量删除</Button></AdminBatchBar>}
|
||||
batchActions={
|
||||
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
|
||||
<Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deleting} onClick={() => confirmDelete(selectedIds)}>
|
||||
批量删除
|
||||
</Button>
|
||||
</AdminBatchBar>
|
||||
}
|
||||
skeletonColumns={9}
|
||||
table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: resources, rowSelection: { selectedRowKeys: selectedIds, preserveSelectedRowKeys: true, onChange: (keys) => { const next = keys.map(String); if (next.length > 100) message.warning("单次最多选择 100 个资源"); setSelectedIds(next.slice(0, 100)); } }, pagination: false, scroll: { x: 1390 } }}
|
||||
table={{
|
||||
className: "app-data-table",
|
||||
size: "small",
|
||||
rowKey: "id",
|
||||
loading,
|
||||
columns,
|
||||
dataSource: resources,
|
||||
rowSelection: {
|
||||
selectedRowKeys: selectedIds,
|
||||
preserveSelectedRowKeys: true,
|
||||
onChange: (keys) => {
|
||||
const next = keys.map(String);
|
||||
if (next.length > 100) message.warning("单次最多选择 100 个资源");
|
||||
setSelectedIds(next.slice(0, 100));
|
||||
},
|
||||
},
|
||||
pagination: false,
|
||||
scroll: { x: 1390 },
|
||||
}}
|
||||
empty={<AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "暂无资源记录"} description={hasFilters ? undefined : "资源上传或生成后会显示在这里。"} />}
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
|
||||
/>
|
||||
<Modal title={previewing ? fileName(previewing.objectKey) || "资源预览" : "资源预览"} open={Boolean(previewing)} width={880} onCancel={() => setPreviewing(null)} footer={previewing ? <Button icon={<Download className="size-4" />} loading={downloadingId === previewing.id} onClick={() => void download(previewing)}>下载原文件</Button> : null} destroyOnHidden>
|
||||
<Modal
|
||||
title={previewing ? fileName(previewing.objectKey) || "资源预览" : "资源预览"}
|
||||
open={Boolean(previewing)}
|
||||
width={880}
|
||||
onCancel={() => setPreviewing(null)}
|
||||
footer={
|
||||
previewing ? (
|
||||
<Button icon={<Download className="size-4" />} loading={downloadingId === previewing.id} onClick={() => void download(previewing)}>
|
||||
下载原文件
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
{previewing ? <ResourcePreview resource={previewing} /> : null}
|
||||
</Modal>
|
||||
</div>
|
||||
@@ -185,7 +283,15 @@ function DeleteBlockedSummary({ blocked }: { blocked: Array<{ id: string; reason
|
||||
<div key={item.id} className="rounded-md border border-border px-3 py-2">
|
||||
<div className="admin-monospace break-all text-foreground/75">{item.id}</div>
|
||||
<div className="mt-1 text-foreground/55">{item.reason}</div>
|
||||
{item.references.length > 0 ? <div className="mt-1 text-xs text-foreground/45">{item.references.slice(0, 4).map((reference) => `${reference.kind}「${reference.title || reference.id}」`).join("、")}{item.references.length > 4 ? ` 等 ${item.references.length} 处` : ""}</div> : null}
|
||||
{item.references.length > 0 ? (
|
||||
<div className="mt-1 text-xs text-foreground/45">
|
||||
{item.references
|
||||
.slice(0, 4)
|
||||
.map((reference) => `${reference.kind}「${reference.title || reference.id}」`)
|
||||
.join("、")}
|
||||
{item.references.length > 4 ? ` 等 ${item.references.length} 处` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -196,23 +302,87 @@ function ResourcePreview({ resource }: { resource: AdminStorageResource }) {
|
||||
const url = adminResourceFileUrl(resource.id);
|
||||
if (resource.kind === "image" || resource.mimeType.startsWith("image/")) return <img className="mx-auto max-h-[65vh] max-w-full object-contain" src={url} alt={fileName(resource.objectKey) || "资源预览"} />;
|
||||
if (resource.kind === "video" || resource.mimeType.startsWith("video/")) return <video className="mx-auto max-h-[65vh] max-w-full bg-black" src={url} controls playsInline />;
|
||||
if (resource.kind === "audio" || resource.mimeType.startsWith("audio/")) return <div className="py-12"><audio className="w-full" src={url} controls /></div>;
|
||||
if (resource.kind === "audio" || resource.mimeType.startsWith("audio/"))
|
||||
return (
|
||||
<div className="py-12">
|
||||
<audio className="w-full" src={url} controls />
|
||||
</div>
|
||||
);
|
||||
return <div className="rounded-md border border-border bg-muted/20 px-5 py-10 text-center text-sm text-foreground/55">该文件类型不支持内嵌预览,请下载后查看。</div>;
|
||||
}
|
||||
|
||||
const kindOptions = [{ label: "全部类型", value: "all" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }, { label: "文件", value: "file" }];
|
||||
const statusOptions = [{ label: "全部状态", value: "all" }, { label: "待处理", value: "pending" }, { label: "已就绪", value: "ready" }, { label: "失败", value: "failed" }, { label: "已删除", value: "deleted" }];
|
||||
const providerOptions = [{ label: "全部存储", value: "all" }, { label: "本地", value: "local" }, { label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }, { label: "S3", value: "s3" }];
|
||||
const kindOptions = [
|
||||
{ label: "全部类型", value: "all" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "音频", value: "audio" },
|
||||
{ label: "文件", value: "file" },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "待处理", value: "pending" },
|
||||
{ label: "已就绪", value: "ready" },
|
||||
{ label: "失败", value: "failed" },
|
||||
{ label: "已删除", value: "deleted" },
|
||||
];
|
||||
const providerOptions = [
|
||||
{ label: "全部存储", value: "all" },
|
||||
{ label: "本地", value: "local" },
|
||||
{ label: "阿里云 OSS", value: "aliyun" },
|
||||
{ label: "腾讯云 COS", value: "tencent" },
|
||||
{ label: "七牛云 Kodo", value: "qiniu" },
|
||||
{ label: "S3", value: "s3" },
|
||||
];
|
||||
|
||||
function normalizeOption(value: string | null, values: string[]) { return value && values.includes(value) ? value : "all"; }
|
||||
function positiveInt(value: string | null, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }
|
||||
function normalizePageSize(value: string | null) { const parsed = positiveInt(value, 20); return pageSizes.includes(parsed) ? parsed : 20; }
|
||||
function fileName(objectKey: string) { return objectKey.split("/").filter(Boolean).at(-1) || ""; }
|
||||
function kindLabel(kind: string) { return ({ image: "图片", video: "视频", audio: "音频", file: "文件" } as Record<string, string>)[kind] || kind || "未知"; }
|
||||
function statusLabel(status: string) { return ({ pending: "待处理", ready: "已就绪", failed: "失败", deleted: "已删除" } as Record<string, string>)[status] || status || "未知"; }
|
||||
function statusTone(status: string): "neutral" | "success" | "warning" | "error" { return status === "ready" ? "success" : status === "pending" ? "warning" : status === "failed" ? "error" : "neutral"; }
|
||||
function providerLabel(provider: string) { return ({ local: "本地", aliyun: "阿里云 OSS", tencent: "腾讯云 COS", qiniu: "七牛云 Kodo", s3: "S3" } as Record<string, string>)[provider] || provider || "本地"; }
|
||||
function formatBytes(bytes: number) { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); const value = bytes / 1024 ** unit; return `${value >= 100 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`; }
|
||||
function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
|
||||
function resourceDimensions(resource: AdminStorageResource) { if (resource.width > 0 && resource.height > 0) return <span className="tabular-nums">{resource.width} × {resource.height}</span>; if (resource.durationMs > 0) return <span className="tabular-nums">{formatDuration(resource.durationMs)}</span>; return <span className="text-foreground/30">--</span>; }
|
||||
function formatDuration(durationMs: number) { const seconds = Math.round(durationMs / 1000); const minutes = Math.floor(seconds / 60); return minutes > 0 ? `${minutes}:${String(seconds % 60).padStart(2, "0")}` : `${seconds} 秒`; }
|
||||
function normalizeOption(value: string | null, values: string[]) {
|
||||
return value && values.includes(value) ? value : "all";
|
||||
}
|
||||
function positiveInt(value: string | null, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
function normalizePageSize(value: string | null) {
|
||||
const parsed = positiveInt(value, 20);
|
||||
return pageSizes.includes(parsed) ? parsed : 20;
|
||||
}
|
||||
function fileName(objectKey: string) {
|
||||
return objectKey.split("/").filter(Boolean).at(-1) || "";
|
||||
}
|
||||
function kindLabel(kind: string) {
|
||||
return ({ image: "图片", video: "视频", audio: "音频", file: "文件" } as Record<string, string>)[kind] || kind || "未知";
|
||||
}
|
||||
function statusLabel(status: string) {
|
||||
return ({ pending: "待处理", ready: "已就绪", failed: "失败", deleted: "已删除" } as Record<string, string>)[status] || status || "未知";
|
||||
}
|
||||
function statusTone(status: string): "neutral" | "success" | "warning" | "error" {
|
||||
return status === "ready" ? "success" : status === "pending" ? "warning" : status === "failed" ? "error" : "neutral";
|
||||
}
|
||||
function providerLabel(provider: string) {
|
||||
return ({ local: "本地", aliyun: "阿里云 OSS", tencent: "腾讯云 COS", qiniu: "七牛云 Kodo", s3: "S3" } as Record<string, string>)[provider] || provider || "本地";
|
||||
}
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / 1024 ** unit;
|
||||
return `${value >= 100 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
function resourceDimensions(resource: AdminStorageResource) {
|
||||
if (resource.width > 0 && resource.height > 0)
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{resource.width} × {resource.height}
|
||||
</span>
|
||||
);
|
||||
if (resource.durationMs > 0) return <span className="tabular-nums">{formatDuration(resource.durationMs)}</span>;
|
||||
return <span className="text-foreground/30">--</span>;
|
||||
}
|
||||
function formatDuration(durationMs: number) {
|
||||
const seconds = Math.round(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return minutes > 0 ? `${minutes}:${String(seconds % 60).padStart(2, "0")}` : `${seconds} 秒`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user