diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index fcb0c8d1a..23acfe9db 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -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 } diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 34d921be7..ddb435ea9 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -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 } diff --git a/web/src/pages/admin/components/admin-announcements-panel.tsx b/web/src/pages/admin/components/admin-announcements-panel.tsx index df25cee99..4f9173137 100644 --- a/web/src/pages/admin/components/admin-announcements-panel.tsx +++ b/web/src/pages/admin/components/admin-announcements-panel.tsx @@ -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(null); - const activeOverlay = pending ? "modal" : open ? "drawer" : null; + const activeOverlay = pending ? "confirm" : open ? "editor" : null; useAnnouncementOverlayFocus(activeOverlay, titleInputRef, returnFocusElement); return ( <> - { if (!isOpen) return; - document.querySelector(".admin-announcement-drawer .ant-drawer-body")?.scrollTo({ top: 0 }); + document.querySelector(".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({ } + styles={{ body: { maxHeight: "calc(100vh - 190px)", overflowY: "auto" } }} >
{editingAnnouncement ? "保存会重新向全体用户发布" : "发布成功后会进入用户公告中心"}

{editingAnnouncement ? "无论当前公告是否已关闭,保存都会刷新发布时间、恢复为发布中,并清除所有用户的旧已读状态。" : "已打开的页面会在下一次同步后看到(通常 5 分钟内);请写清影响范围、所需操作和预计恢复时间。"}

-
-
-

公告内容

-

支持换行以及链接、强调、列表等受控 HTML;Markdown 不会被解析。

-
-
+
+
+
+

公告内容

+

支持换行以及链接、强调、列表等受控 HTML;Markdown 不会被解析。

+
+
+ + + + + + + +
+ {imagePreviewUrl ? ( +
+ 公告配图预览 +
+ ) : ( +
暂未添加配图
+ )} + + +
+
- + - - - - -
- {imagePreviewUrl ? ( -
- 公告配图预览 -
- ) : ( -
暂未添加配图
- )} - - -
-
- - - -
+
-
-
- 用户端预览 -
- - {watchedPinned ? ( - - - 置顶 - - ) : null} +
+
+ 用户端预览 +
+ + {watchedPinned ? ( + + + 置顶 + + ) : null} +
-
-

{watchedTitle?.trim() || "公告标题将在这里显示"}

- {imagePreviewUrl ? 公告配图预览 : null} - {watchedContent?.trim() ? :

公告正文将在这里显示。

} -
+

{watchedTitle?.trim() || "公告标题将在这里显示"}

+ {imagePreviewUrl ? 公告配图预览 : null} + {watchedContent?.trim() ? :

公告正文将在这里显示。

} + + -
+ (null); const returnFocusRef = useRef(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(".ant-drawer-body")?.scrollTo({ top: 0 }); + if (activeOverlay === "editor") { + root.querySelector(".ant-modal-body")?.scrollTo({ top: 0 }); titleInputRef.current?.focus({ cursor: "end" }); return; } diff --git a/web/src/pages/admin/components/analytics-panel.tsx b/web/src/pages/admin/components/analytics-panel.tsx index 9d7342f3e..33e969df0 100644 --- a/web/src/pages/admin/components/analytics-panel.tsx +++ b/web/src/pages/admin/components/analytics-panel.tsx @@ -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(() => 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("volume"); const [analysisTab, setAnalysisTab] = useState("models"); const [pricingWorkspaceOpen, setPricingWorkspaceOpen] = useState(false); + const [pendingPricing, setPendingPricing] = useState(undefined); const [form] = Form.useForm(); 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(); + 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, 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 = (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) { <>
时间范围 - value?.[0] && value?.[1] && setRange([value[0], value[1]])} /> + { + if (value?.[0] && value?.[1]) { + setRangePreset(undefined); + setRange([value[0], value[1]]); + } + }} + />