fix(admin): 管理后台 - 修复公告编辑与模型价格配置 (#355)
This commit is contained in:
@@ -735,7 +735,7 @@ func (r *Repository) AdminSystemChannels(keyword string, status string, limit in
|
||||
|
||||
func (r *Repository) AdminSystemChannelReferences() ([]model.ModelChannel, error) {
|
||||
var channels []model.ModelChannel
|
||||
err := r.db.Select("id", "name").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error
|
||||
err := r.db.Select("id", "name", "enabled").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
|
||||
@@ -73,9 +73,10 @@ type AdminUserReference struct {
|
||||
}
|
||||
|
||||
type AdminChannelReference struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Models []string `json:"models"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
type AdminReferenceData struct {
|
||||
@@ -189,7 +190,7 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error
|
||||
result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName})
|
||||
}
|
||||
for _, channel := range channels {
|
||||
items, itemErr := s.repo.ChannelModels(channel.ID, true)
|
||||
items, itemErr := s.repo.ChannelModels(channel.ID, false)
|
||||
if itemErr != nil {
|
||||
return nil, itemErr
|
||||
}
|
||||
@@ -197,7 +198,7 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error
|
||||
for _, item := range items {
|
||||
models = append(models, item.ModelKey)
|
||||
}
|
||||
result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Models: uniqueNonEmpty(models)})
|
||||
result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { App, Button, Drawer, Form, Input, Modal, Select, Switch } from "antd";
|
||||
import { App, Button, Form, Input, Modal, Select, Switch } from "antd";
|
||||
import type { InputRef } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { PencilLine, Pin, Plus, RefreshCw, Search, Send, Upload, X } from "lucide-react";
|
||||
@@ -718,20 +718,21 @@ function AnnouncementEditor({
|
||||
}) {
|
||||
const previewMeta = levelMeta[watchedLevel || "info"] || levelMeta.info;
|
||||
const titleInputRef = useRef<InputRef>(null);
|
||||
const activeOverlay = pending ? "modal" : open ? "drawer" : null;
|
||||
const activeOverlay = pending ? "confirm" : open ? "editor" : null;
|
||||
useAnnouncementOverlayFocus(activeOverlay, titleInputRef, returnFocusElement);
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
<Modal
|
||||
title={editingAnnouncement ? "编辑并重新发布公告" : "发布系统公告"}
|
||||
open={open && !pending}
|
||||
size="min(680px, 100vw)"
|
||||
onClose={onClose}
|
||||
rootClassName="admin-drawer admin-announcement-drawer"
|
||||
centered
|
||||
width="min(1120px, calc(100vw - 32px))"
|
||||
onCancel={onClose}
|
||||
rootClassName="admin-modal-root admin-announcement-editor-modal"
|
||||
forceRender
|
||||
afterOpenChange={(isOpen) => {
|
||||
if (!isOpen) return;
|
||||
document.querySelector<HTMLElement>(".admin-announcement-drawer .ant-drawer-body")?.scrollTo({ top: 0 });
|
||||
document.querySelector<HTMLElement>(".admin-announcement-editor-modal .ant-modal-body")?.scrollTo({ top: 0 });
|
||||
titleInputRef.current?.focus({ cursor: "end" });
|
||||
}}
|
||||
mask={{ closable: !saving && !pending }}
|
||||
@@ -747,95 +748,98 @@ function AnnouncementEditor({
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
styles={{ body: { maxHeight: "calc(100vh - 190px)", overflowY: "auto" } }}
|
||||
>
|
||||
<div className={`admin-announcement-editor-intro${editingAnnouncement ? " is-warning" : ""}`}>
|
||||
<strong>{editingAnnouncement ? "保存会重新向全体用户发布" : "发布成功后会进入用户公告中心"}</strong>
|
||||
<p>{editingAnnouncement ? "无论当前公告是否已关闭,保存都会刷新发布时间、恢复为发布中,并清除所有用户的旧已读状态。" : "已打开的页面会在下一次同步后看到(通常 5 分钟内);请写清影响范围、所需操作和预计恢复时间。"}</p>
|
||||
</div>
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={publishBlocked} initialValues={DEFAULT_ANNOUNCEMENT} scrollToFirstError={{ focus: true, block: "center" }} onFinish={onPreview}>
|
||||
<section className="admin-announcement-editor-section">
|
||||
<div className="admin-announcement-editor-section-heading">
|
||||
<h2>公告内容</h2>
|
||||
<p>支持换行以及链接、强调、列表等受控 HTML;Markdown 不会被解析。</p>
|
||||
</div>
|
||||
<div className="admin-announcement-form-grid">
|
||||
<div className="admin-announcement-editor-layout">
|
||||
<section className="admin-announcement-editor-section">
|
||||
<div className="admin-announcement-editor-section-heading">
|
||||
<h2>公告内容</h2>
|
||||
<p>支持换行以及链接、强调、列表等受控 HTML;Markdown 不会被解析。</p>
|
||||
</div>
|
||||
<div className="admin-announcement-form-grid">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="公告标题"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: "请填写公告标题" },
|
||||
{ max: 120, message: "标题不能超过 120 个字符" },
|
||||
]}
|
||||
>
|
||||
<Input ref={titleInputRef} maxLength={120} showCount placeholder="例如:视频模型已恢复正常使用" />
|
||||
</Form.Item>
|
||||
<Form.Item name="level" label="公告类型" extra={previewMeta.guidance} rules={[{ required: true, message: "请选择公告类型" }]}>
|
||||
<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="title"
|
||||
label="公告标题"
|
||||
name="content"
|
||||
label="公告正文"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: "请填写公告标题" },
|
||||
{ max: 120, message: "标题不能超过 120 个字符" },
|
||||
{ required: true, whitespace: true, message: "请填写公告正文" },
|
||||
{ max: 4000, message: "正文不能超过 4000 个字符" },
|
||||
]}
|
||||
>
|
||||
<Input ref={titleInputRef} maxLength={120} showCount placeholder="例如:视频模型已恢复正常使用" />
|
||||
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 14, maxRows: 24 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
|
||||
</Form.Item>
|
||||
<Form.Item name="level" label="公告类型" extra={previewMeta.guidance} rules={[{ required: true, message: "请选择公告类型" }]}>
|
||||
<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="公告正文"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: "请填写公告正文" },
|
||||
{ max: 4000, message: "正文不能超过 4000 个字符" },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 7, maxRows: 14 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
|
||||
</Form.Item>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className="admin-announcement-preview" data-level={watchedLevel || "info"} aria-label="用户端公告预览">
|
||||
<div className="admin-announcement-preview-heading">
|
||||
<span>用户端预览</span>
|
||||
<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}
|
||||
<section className="admin-announcement-preview" data-level={watchedLevel || "info"} aria-label="用户端公告预览">
|
||||
<div className="admin-announcement-preview-heading">
|
||||
<span>用户端预览</span>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={pending?.mode === "update" ? "确认编辑并重新发布" : "确认发布系统公告"}
|
||||
@@ -896,7 +900,7 @@ function AnnouncementEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function useAnnouncementOverlayFocus(activeOverlay: "drawer" | "modal" | null, titleInputRef: { current: InputRef | null }, returnFocusElement: HTMLElement | null) {
|
||||
function useAnnouncementOverlayFocus(activeOverlay: "editor" | "confirm" | null, titleInputRef: { current: InputRef | null }, returnFocusElement: HTMLElement | null) {
|
||||
const previousOverlayRef = useRef<typeof activeOverlay>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(returnFocusElement);
|
||||
if (returnFocusElement) returnFocusRef.current = returnFocusElement;
|
||||
@@ -908,7 +912,7 @@ function useAnnouncementOverlayFocus(activeOverlay: "drawer" | "modal" | null, t
|
||||
if (!previousOverlay) return;
|
||||
const restoreFocus = () => {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement === document.body || activeElement === null || activeElement?.closest(".admin-announcement-drawer, .admin-announcement-confirm-modal")) resolveAnnouncementReturnFocus(returnFocusRef.current)?.focus();
|
||||
if (activeElement === document.body || activeElement === null || activeElement?.closest(".admin-announcement-editor-modal, .admin-announcement-confirm-modal")) resolveAnnouncementReturnFocus(returnFocusRef.current)?.focus();
|
||||
};
|
||||
const frame = window.requestAnimationFrame(restoreFocus);
|
||||
const transitionFallback = window.setTimeout(restoreFocus, 360);
|
||||
@@ -918,12 +922,12 @@ function useAnnouncementOverlayFocus(activeOverlay: "drawer" | "modal" | null, t
|
||||
};
|
||||
}
|
||||
|
||||
const selector = activeOverlay === "modal" ? ".admin-announcement-confirm-modal" : ".admin-announcement-drawer";
|
||||
const selector = activeOverlay === "confirm" ? ".admin-announcement-confirm-modal" : ".admin-announcement-editor-modal";
|
||||
const focusOverlay = () => {
|
||||
const root = findVisibleOverlay(selector);
|
||||
if (!root) return;
|
||||
if (activeOverlay === "drawer") {
|
||||
root.querySelector<HTMLElement>(".ant-drawer-body")?.scrollTo({ top: 0 });
|
||||
if (activeOverlay === "editor") {
|
||||
root.querySelector<HTMLElement>(".ant-modal-body")?.scrollTo({ top: 0 });
|
||||
titleInputRef.current?.focus({ cursor: "end" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { App, Button, DatePicker, Drawer, Form, Input, InputNumber, Modal, Select, Tabs, Tag, Tooltip } from "antd";
|
||||
import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag, Tooltip } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, Pencil, Plus, RefreshCw, Settings2, Trash2, UsersRound, Workflow } from "lucide-react";
|
||||
@@ -32,16 +32,17 @@ type PricingFormValues = {
|
||||
model: string;
|
||||
capability: ModelPricing["capability"];
|
||||
currency: string;
|
||||
inputPerMillion?: number;
|
||||
outputPerMillion?: number;
|
||||
cachedPerMillion?: number;
|
||||
perRequest?: number;
|
||||
perMedia?: number;
|
||||
perVideoSecond?: number;
|
||||
inputPerMillion?: string;
|
||||
outputPerMillion?: string;
|
||||
cachedPerMillion?: string;
|
||||
perRequest?: string;
|
||||
perMedia?: string;
|
||||
perVideoSecond?: string;
|
||||
};
|
||||
|
||||
type TrendMetric = "volume" | "quality" | "activity";
|
||||
type AnalysisTab = "models" | "users" | "failures";
|
||||
type RangePreset = "7d" | "30d" | "60d";
|
||||
|
||||
const capabilityOptions = [
|
||||
{ label: "文本", value: "text" },
|
||||
@@ -53,7 +54,8 @@ const capabilityOptions = [
|
||||
export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>(() => [filterDate(searchParams.get("from"), dayjs().subtract(29, "day")), filterDate(searchParams.get("to"), dayjs())]);
|
||||
const [rangePreset, setRangePreset] = useState<RangePreset | undefined>(() => initialRangePreset(searchParams));
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>(() => initialAnalyticsRange(searchParams, rangePreset));
|
||||
const [userId, setUserId] = useState(searchParams.get("userId") || undefined);
|
||||
const [model, setModel] = useState(searchParams.get("model") || undefined);
|
||||
const [channelId, setChannelId] = useState(searchParams.get("channelId") || undefined);
|
||||
@@ -73,6 +75,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
const [trendMetric, setTrendMetric] = useState<TrendMetric>("volume");
|
||||
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("models");
|
||||
const [pricingWorkspaceOpen, setPricingWorkspaceOpen] = useState(false);
|
||||
const [pendingPricing, setPendingPricing] = useState<ModelPricing | null | undefined>(undefined);
|
||||
const [form] = Form.useForm<PricingFormValues>();
|
||||
const analyticsPageSize = 20;
|
||||
|
||||
@@ -107,9 +110,11 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
if (rangePreset) next.set("rangePreset", rangePreset);
|
||||
else next.delete("rangePreset");
|
||||
setSearchParams(next, { replace: true });
|
||||
void reload();
|
||||
}, [filters]);
|
||||
}, [filters, rangePreset]);
|
||||
|
||||
useEffect(() => {
|
||||
setModelPage(1);
|
||||
@@ -141,8 +146,17 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
return [...names].sort().map((name) => ({ label: name, value: name }));
|
||||
}, [channels, data?.models]);
|
||||
|
||||
const openPricing = (pricing?: ModelPricing) => {
|
||||
setEditingPricing(pricing || null);
|
||||
const pricingModelOptions = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
const sourceChannels = channels.filter((channel) => channel.enabled !== false);
|
||||
sourceChannels.forEach((channel) => channel.models?.forEach((name) => names.add(name)));
|
||||
if (editingPricing?.model) names.add(editingPricing.model);
|
||||
return [...names].sort().map((name) => ({ label: name, value: name }));
|
||||
}, [channels, editingPricing?.model]);
|
||||
|
||||
const preparePricingForm = (pricing: ModelPricing | null) => {
|
||||
setEditingPricing(pricing);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(
|
||||
pricing
|
||||
? {
|
||||
@@ -150,18 +164,50 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
model: pricing.model,
|
||||
capability: pricing.capability,
|
||||
currency: pricing.currency,
|
||||
inputPerMillion: fromMicros(pricing.inputPerMillionMicros),
|
||||
outputPerMillion: fromMicros(pricing.outputPerMillionMicros),
|
||||
cachedPerMillion: fromMicros(pricing.cachedPerMillionMicros),
|
||||
perRequest: fromMicros(pricing.perRequestMicros),
|
||||
perMedia: fromMicros(pricing.perMediaMicros),
|
||||
perVideoSecond: fromMicros(pricing.perVideoSecondMicros),
|
||||
inputPerMillion: formatPriceInput(pricing.inputPerMillionMicros),
|
||||
outputPerMillion: formatPriceInput(pricing.outputPerMillionMicros),
|
||||
cachedPerMillion: formatPriceInput(pricing.cachedPerMillionMicros),
|
||||
perRequest: formatPriceInput(pricing.perRequestMicros),
|
||||
perMedia: formatPriceInput(pricing.perMediaMicros),
|
||||
perVideoSecond: formatPriceInput(pricing.perVideoSecondMicros),
|
||||
}
|
||||
: { channelId: undefined, model: "", capability: "text", currency: "USD", inputPerMillion: 0, outputPerMillion: 0, cachedPerMillion: 0, perRequest: 0, perMedia: 0, perVideoSecond: 0 },
|
||||
: { channelId: undefined, model: "", capability: "text", currency: "USD", inputPerMillion: "", outputPerMillion: "", cachedPerMillion: "", perRequest: "", perMedia: "", perVideoSecond: "" },
|
||||
);
|
||||
setPricingModalOpen(true);
|
||||
};
|
||||
|
||||
const openPricing = (pricing?: ModelPricing) => {
|
||||
const nextPricing = pricing || null;
|
||||
if (pricingWorkspaceOpen) {
|
||||
setPendingPricing(nextPricing);
|
||||
setPricingWorkspaceOpen(false);
|
||||
} else {
|
||||
preparePricingForm(nextPricing);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePricingValuesChange = (changedValues: Partial<PricingFormValues>, values: PricingFormValues) => {
|
||||
if (Object.prototype.hasOwnProperty.call(changedValues, "channelId")) {
|
||||
const nextModels = channels
|
||||
.filter((channel) => channel.enabled !== false && (!values.channelId || channel.id === values.channelId))
|
||||
.flatMap((channel) => channel.models || []);
|
||||
if (values.model && !nextModels.includes(values.model)) form.setFieldValue("model", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(changedValues, "model") || !values.model) return;
|
||||
const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(values.model));
|
||||
if (values.channelId && matchingChannels.some((channel) => channel.id === values.channelId)) return;
|
||||
if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id);
|
||||
};
|
||||
|
||||
const handlePricingModelChange = (modelName: string) => {
|
||||
const currentChannelId = form.getFieldValue("channelId") as string | undefined;
|
||||
const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(modelName));
|
||||
const matchingChannel = matchingChannels.find((channel) => channel.id === currentChannelId) || matchingChannels[0];
|
||||
form.setFieldsValue({ model: modelName, channelId: matchingChannel?.id || currentChannelId });
|
||||
};
|
||||
|
||||
const savePricing = async () => {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
@@ -308,12 +354,12 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
const pricedModelCount = modelRows.filter((item) => item.costAvailable).length;
|
||||
const trendHasData = trendMetric === "volume" ? trend.some((item) => item.tasks > 0 || item.requests > 0) : trendMetric === "quality" ? trend.some((item) => item.requests > 0) : trend.some((item) => item.activeUsers > 0);
|
||||
const trendTitle = trendMetric === "volume" ? "任务与请求趋势" : trendMetric === "quality" ? "请求质量趋势" : "用户活跃趋势";
|
||||
const rangePreset = resolveRangePreset(range);
|
||||
const pageRows = <T,>(rows: T[], page: number) => rows.slice((page - 1) * analyticsPageSize, page * analyticsPageSize);
|
||||
|
||||
const applyRangePreset = (preset: "7d" | "30d" | "month") => {
|
||||
const applyRangePreset = (preset: RangePreset) => {
|
||||
const end = dayjs();
|
||||
const start = preset === "7d" ? end.subtract(6, "day") : preset === "30d" ? end.subtract(29, "day") : end.startOf("month");
|
||||
const start = preset === "7d" ? end.subtract(6, "day") : preset === "30d" ? end.subtract(29, "day") : end.subtract(59, "day");
|
||||
setRangePreset(preset);
|
||||
setRange([start, end]);
|
||||
};
|
||||
|
||||
@@ -345,7 +391,16 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
<>
|
||||
<div className="admin-analytics-range-picker" role="group" aria-label="时间范围">
|
||||
<span className="sr-only">时间范围</span>
|
||||
<DatePicker.RangePicker allowClear={false} value={range} onChange={(value) => value?.[0] && value?.[1] && setRange([value[0], value[1]])} />
|
||||
<DatePicker.RangePicker
|
||||
allowClear={false}
|
||||
value={range}
|
||||
onChange={(value) => {
|
||||
if (value?.[0] && value?.[1]) {
|
||||
setRangePreset(undefined);
|
||||
setRange([value[0], value[1]]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
|
||||
刷新
|
||||
@@ -375,7 +430,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
[
|
||||
["7d", "7 天"],
|
||||
["30d", "30 天"],
|
||||
["month", "本月"],
|
||||
["60d", "60 天"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button key={value} type="button" className={rangePreset === value ? "is-active" : undefined} aria-pressed={rangePreset === value} onClick={() => applyRangePreset(value)}>
|
||||
@@ -566,6 +621,12 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
open={pricingWorkspaceOpen}
|
||||
width="min(1120px, calc(100vw - 48px))"
|
||||
onClose={() => setPricingWorkspaceOpen(false)}
|
||||
afterOpenChange={(open) => {
|
||||
if (open || pendingPricing === undefined) return;
|
||||
const nextPricing = pendingPricing;
|
||||
setPendingPricing(undefined);
|
||||
preparePricingForm(nextPricing);
|
||||
}}
|
||||
extra={
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openPricing()}>
|
||||
新增价格
|
||||
@@ -581,14 +642,14 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal title={editingPricing ? "编辑模型价格" : "新增模型价格"} open={pricingModalOpen} onCancel={() => setPricingModalOpen(false)} onOk={() => void savePricing()} confirmLoading={savingPricing} okText="保存" cancelText="取消" width={760}>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Modal rootClassName="admin-modal-root admin-analytics-pricing-modal" title={editingPricing ? "编辑模型价格" : "新增模型价格"} open={pricingModalOpen} onCancel={() => setPricingModalOpen(false)} onOk={() => void savePricing()} confirmLoading={savingPricing} okText="保存" cancelText="取消" width={760} zIndex={1200} destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handlePricingValuesChange}>
|
||||
<div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
|
||||
<Form.Item name="model" label="模型" rules={[{ required: true, message: "请填写模型名" }]}>
|
||||
<Input />
|
||||
<Form.Item name="model" label="模型" rules={[{ required: true, message: "请选择模型" }]}>
|
||||
<Select showSearch optionFilterProp="label" placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"} options={pricingModelOptions} disabled={!pricingModelOptions.length} onChange={handlePricingModelChange} />
|
||||
</Form.Item>
|
||||
<Form.Item name="channelId" label="渠道范围">
|
||||
<Select allowClear placeholder="全部渠道" options={channels.map((channel) => ({ label: channel.name, value: channel.id }))} />
|
||||
<Select allowClear placeholder="全部渠道" options={channels.filter((channel) => channel.enabled !== false).map((channel) => ({ label: channel.name, value: channel.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="capability" label="能力类型" rules={[{ required: true }]}>
|
||||
<Select options={capabilityOptions} />
|
||||
@@ -679,12 +740,18 @@ function FilterSelect({
|
||||
|
||||
function PriceInput({ name, label }: { name: keyof PricingFormValues; label: string }) {
|
||||
return (
|
||||
<Form.Item name={name} label={`${label}(币种单位)`} rules={[{ type: "number", min: 0, message: "价格不能小于 0" }]}>
|
||||
<InputNumber min={0} precision={6} step={0.000001} className="w-full" />
|
||||
<Form.Item className="admin-analytics-price-field" name={name} label={`${label}(币种单位)`} rules={[{ validator: validatePriceInput }]}>
|
||||
<Input aria-label={`${label}价格`} disabled={false} readOnly={false} inputMode="decimal" maxLength={30} className="admin-analytics-price-input" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function validatePriceInput(_: unknown, value?: string) {
|
||||
const normalized = value?.trim() || "";
|
||||
if (!normalized || /^(?:\d+(?:\.\d{0,6})?|\.\d{1,6})$/.test(normalized)) return Promise.resolve();
|
||||
return Promise.reject(new Error("请输入非负价格,最多 6 位小数"));
|
||||
}
|
||||
|
||||
function capabilityLabel(value: string) {
|
||||
return capabilityOptions.find((item) => item.value === value)?.label || "未分类";
|
||||
}
|
||||
@@ -734,8 +801,13 @@ function fromMicros(value: number) {
|
||||
return value / 1_000_000;
|
||||
}
|
||||
|
||||
function toMicros(value?: number) {
|
||||
return Math.round((value || 0) * 1_000_000);
|
||||
function formatPriceInput(micros: number) {
|
||||
return fromMicros(micros).toFixed(6);
|
||||
}
|
||||
|
||||
function toMicros(value?: string | number) {
|
||||
const parsed = typeof value === "number" ? value : Number(value?.trim() || 0);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 1_000_000) : 0;
|
||||
}
|
||||
|
||||
function filterDate(value: string | null, fallback: Dayjs) {
|
||||
@@ -744,11 +816,38 @@ function filterDate(value: string | null, fallback: Dayjs) {
|
||||
return parsed.isValid() ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveRangePreset(range: [Dayjs, Dayjs]): "7d" | "30d" | "month" | undefined {
|
||||
function resolveRangePreset(range: [Dayjs, Dayjs]): RangePreset | undefined {
|
||||
const end = dayjs();
|
||||
if (!range[1].isSame(end, "day")) return undefined;
|
||||
if (range[0].isSame(end.startOf("month"), "day")) return "month";
|
||||
if (range[0].isSame(end.subtract(6, "day"), "day")) return "7d";
|
||||
if (range[0].isSame(end.subtract(29, "day"), "day")) return "30d";
|
||||
if (range[0].isSame(end.subtract(59, "day"), "day")) return "60d";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseRangePreset(value: string | null): RangePreset | undefined {
|
||||
return value === "7d" || value === "30d" || value === "60d" ? value : undefined;
|
||||
}
|
||||
|
||||
function initialRangePreset(searchParams: URLSearchParams): RangePreset | undefined {
|
||||
const explicit = parseRangePreset(searchParams.get("rangePreset"));
|
||||
if (explicit) return explicit;
|
||||
if (searchParams.has("from") || searchParams.has("to")) return resolveRangePresetFromQuery(searchParams.get("from"), searchParams.get("to"));
|
||||
return "30d";
|
||||
}
|
||||
|
||||
function initialAnalyticsRange(searchParams: URLSearchParams, preset: RangePreset | undefined): [Dayjs, Dayjs] {
|
||||
const end = dayjs();
|
||||
if (preset) {
|
||||
const days = preset === "7d" ? 6 : preset === "30d" ? 29 : 59;
|
||||
return [end.subtract(days, "day"), end];
|
||||
}
|
||||
return [filterDate(searchParams.get("from"), end.subtract(29, "day")), filterDate(searchParams.get("to"), end)];
|
||||
}
|
||||
|
||||
function resolveRangePresetFromQuery(from: string | null, to: string | null): RangePreset | undefined {
|
||||
const start = from ? dayjs(from) : null;
|
||||
const end = to ? dayjs(to) : null;
|
||||
if (!start?.isValid() || !end?.isValid()) return undefined;
|
||||
return resolveRangePreset([start, end]);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export type AnalyticsFilters = {
|
||||
|
||||
export type AdminReferenceData = {
|
||||
users: Array<{ id: string; username: string; displayName: string }>;
|
||||
channels: Array<{ id: string; name: string; models: string[] }>;
|
||||
channels: Array<{ id: string; name: string; enabled: boolean; models: string[] }>;
|
||||
};
|
||||
|
||||
export type AdminAnalytics = {
|
||||
|
||||
@@ -6628,6 +6628,39 @@
|
||||
background: var(--admin-layer-0);
|
||||
}
|
||||
|
||||
.admin-announcement-editor-modal {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.admin-announcement-editor-modal .ant-modal-container {
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.admin-announcement-editor-modal .ant-modal-body {
|
||||
padding: 24px 28px 8px;
|
||||
}
|
||||
|
||||
.admin-announcement-editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.admin-announcement-editor-layout .admin-announcement-preview {
|
||||
align-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.admin-announcement-editor-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-announcement-editor-layout .admin-announcement-preview {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-announcement-drawer .ant-drawer-header {
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid var(--admin-divider);
|
||||
@@ -7185,6 +7218,21 @@
|
||||
width: 246px;
|
||||
}
|
||||
|
||||
.admin-analytics-pricing-modal .admin-analytics-price-field,
|
||||
.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control,
|
||||
.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control-input,
|
||||
.admin-analytics-pricing-modal .admin-analytics-price-input,
|
||||
.admin-analytics-pricing-modal .admin-analytics-price-input input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
pointer-events: auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.admin-analytics-pricing-modal .admin-analytics-price-input {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-analytics-toolbar .admin-list-toolbar-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,39 @@ test("announcement editor preserves image and pinned fields through edit and sav
|
||||
expect(panel).toContain('imageResourceId: values.imageResourceId?.trim() || ""');
|
||||
expect(panel).toContain("pinned: Boolean(values.pinned)");
|
||||
expect(panel).toContain('(announcement?.imageResourceId || "") === (expectedContent.imageResourceId || "")');
|
||||
expect(panel).toContain('rootClassName="admin-modal-root admin-announcement-editor-modal"');
|
||||
expect(panel).toContain("centered");
|
||||
expect(panel).not.toContain("<Drawer");
|
||||
expect(safetySource).toContain("imageResourceId?: string");
|
||||
expect(safetySource).toContain("pinned?: boolean");
|
||||
});
|
||||
|
||||
test("analytics keeps fixed range presets distinct and uses enabled channel models for pricing", async () => {
|
||||
const source = compactSource(await Bun.file(new URL("../src/pages/admin/components/analytics-panel.tsx", import.meta.url)).text());
|
||||
|
||||
expect(source).toContain('type RangePreset = "7d" | "30d" | "60d"');
|
||||
expect(source).toContain('["60d", "60 天"]');
|
||||
expect(source).toContain('next.set("rangePreset", rangePreset)');
|
||||
expect(source).toContain("setRangePreset(undefined)");
|
||||
expect(source).toContain('placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"}');
|
||||
expect(source).toContain("onValuesChange={handlePricingValuesChange}");
|
||||
expect(source).toContain("onChange={handlePricingModelChange}");
|
||||
expect(source).toContain('hasOwnProperty.call(changedValues, "model")');
|
||||
expect(source).toContain("if (matchingChannels.length) form.setFieldValue(\"channelId\", matchingChannels[0].id)");
|
||||
expect(source).toContain("const sourceChannels = channels.filter((channel) => channel.enabled !== false)");
|
||||
expect(source).toContain('inputMode="decimal"');
|
||||
expect(source).toContain('className="admin-analytics-price-input"');
|
||||
expect(source).toContain('className="admin-analytics-price-field"');
|
||||
expect(source).toContain('rootClassName="admin-modal-root admin-analytics-pricing-modal"');
|
||||
expect(source).toContain("zIndex={1200}");
|
||||
expect(source).toContain("setPricingWorkspaceOpen(false)");
|
||||
expect(source).toContain("validator: validatePriceInput");
|
||||
expect(source).toContain('请输入非负价格,最多 6 位小数');
|
||||
expect(source).toContain("function formatPriceInput(micros: number)");
|
||||
expect(source).toContain("function toMicros(value?: string | number)");
|
||||
expect(source).not.toContain("<InputNumber");
|
||||
});
|
||||
|
||||
test("storage settings keep generic S3 controls and connection validation", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/admin/settings/storage-settings-page.tsx", import.meta.url)).text();
|
||||
const compacted = compactSource(source);
|
||||
|
||||
Reference in New Issue
Block a user