perf:优化webui配置展示,优化log显示,修复表达审核

This commit is contained in:
SengokuCola
2026-05-05 18:34:20 +08:00
parent 424287387a
commit 16de259955
13 changed files with 326 additions and 164 deletions

View File

@@ -80,6 +80,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
// 快速审核模式状态 // 快速审核模式状态
const [quickFilterType, setQuickFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked') const [quickFilterType, setQuickFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked')
const [quickExpressions, setQuickExpressions] = useState<Expression[]>([]) const [quickExpressions, setQuickExpressions] = useState<Expression[]>([])
const quickExpressionsRef = useRef<Expression[]>([])
const [quickCurrentIndex, setQuickCurrentIndex] = useState(0) const [quickCurrentIndex, setQuickCurrentIndex] = useState(0)
const [quickLoading, setQuickLoading] = useState(false) const [quickLoading, setQuickLoading] = useState(false)
const [quickTotal, setQuickTotal] = useState(0) const [quickTotal, setQuickTotal] = useState(0)
@@ -92,6 +93,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
const cardRef = useRef<HTMLDivElement>(null) const cardRef = useRef<HTMLDivElement>(null)
const dragStartRef = useRef<{ x: number; y: number } | null>(null) const dragStartRef = useRef<{ x: number; y: number } | null>(null)
const isDraggingRef = useRef(false) const isDraggingRef = useRef(false)
useEffect(() => {
quickExpressionsRef.current = quickExpressions
}, [quickExpressions])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [statsLoading, setStatsLoading] = useState(false) const [statsLoading, setStatsLoading] = useState(false)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -180,9 +185,13 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
setQuickLoading(true) setQuickLoading(true)
const pageToLoad = append ? quickPage + 1 : quickPage const pageToLoad = append ? quickPage + 1 : quickPage
const result = await getReviewList({ const result = await getReviewList({
page: pageToLoad, page: quickFilterType === 'unchecked' ? 1 : pageToLoad,
page_size: 20, page_size: 20,
filter_type: quickFilterType, filter_type: quickFilterType,
order: quickFilterType === 'unchecked' ? 'random' : 'latest',
exclude_ids: quickFilterType === 'unchecked' && append
? quickExpressionsRef.current.map((expr) => expr.id)
: undefined,
}) })
if (result.success) { if (result.success) {

View File

@@ -442,16 +442,20 @@ export async function getReviewList(params: {
page?: number page?: number
page_size?: number page_size?: number
filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all' filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all'
order?: 'latest' | 'random'
search?: string search?: string
chat_id?: string chat_id?: string
exclude_ids?: number[]
}): Promise<ApiResponse<ReviewListResponse>> { }): Promise<ApiResponse<ReviewListResponse>> {
const queryParams = new URLSearchParams() const queryParams = new URLSearchParams()
if (params.page) queryParams.append('page', params.page.toString()) if (params.page) queryParams.append('page', params.page.toString())
if (params.page_size) queryParams.append('page_size', params.page_size.toString()) if (params.page_size) queryParams.append('page_size', params.page_size.toString())
if (params.filter_type) queryParams.append('filter_type', params.filter_type) if (params.filter_type) queryParams.append('filter_type', params.filter_type)
if (params.order) queryParams.append('order', params.order)
if (params.search) queryParams.append('search', params.search) if (params.search) queryParams.append('search', params.search)
if (params.chat_id) queryParams.append('chat_id', params.chat_id) if (params.chat_id) queryParams.append('chat_id', params.chat_id)
params.exclude_ids?.forEach((id) => queryParams.append('exclude_ids', id.toString()))
const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`) const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`)

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { parse as parseToml } from 'smol-toml' import { parse as parseToml } from 'smol-toml'
import { AlertDescription, Alert } from '@/components/ui/alert' import { AlertDescription, Alert } from '@/components/ui/alert'
@@ -23,11 +23,13 @@ import { useToast } from '@/hooks/use-toast'
import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, updateBotConfigRaw } from '@/lib/config-api' import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, updateBotConfigRaw } from '@/lib/config-api'
import { fieldHooks } from '@/lib/field-hooks' import { fieldHooks } from '@/lib/field-hooks'
import { RestartProvider, useRestart } from '@/lib/restart-context' import { RestartProvider, useRestart } from '@/lib/restart-context'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react' import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react'
import type { ConfigSchema } from '@/types/config-schema' import type { ConfigSchema } from '@/types/config-schema'
import { import {
BotPlatformsHook,
ChatPromptsHook, ChatPromptsHook,
ChatTalkValueRulesHook, ChatTalkValueRulesHook,
ExpressionGroupsHook, ExpressionGroupsHook,
@@ -413,6 +415,7 @@ function BotConfigPageContent() {
useEffect(() => { useEffect(() => {
const hookEntries = [ const hookEntries = [
['bot.platforms', BotPlatformsHook],
['chat.chat_prompts', ChatPromptsHook], ['chat.chat_prompts', ChatPromptsHook],
['chat.talk_value_rules', ChatTalkValueRulesHook], ['chat.talk_value_rules', ChatTalkValueRulesHook],
['expression.expression_groups', ExpressionGroupsHook], ['expression.expression_groups', ExpressionGroupsHook],
@@ -773,7 +776,23 @@ function BotConfigPageContent() {
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"></p> <p className="text-muted-foreground mt-1 text-xs sm:text-sm"></p>
</div> </div>
{/* 按钮组 - 桌面端靠右 */} {/* 按钮组 - 桌面端靠右 */}
<div className="flex gap-2 flex-shrink-0"> <div className="flex flex-wrap gap-2 flex-shrink-0 sm:justify-end">
<Tabs
value={editMode}
onValueChange={(v) => handleModeChange(v as 'visual' | 'source')}
className="w-full min-w-[13rem] sm:w-[14rem]"
>
<TabsList className="grid h-8 w-full grid-cols-2 sm:h-9">
<TabsTrigger value="visual" className="px-2 text-xs">
<Layout className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</TabsTrigger>
<TabsTrigger value="source" className="px-2 text-xs">
<Code2 className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button <Button
onClick={handleReloadFromFile} onClick={handleReloadFromFile}
disabled={saving || autoSaving || isRestarting} disabled={saving || autoSaving || isRestarting}
@@ -833,22 +852,6 @@ function BotConfigPageContent() {
</AlertDialog> </AlertDialog>
</div> </div>
</div> </div>
{/* 模式切换 - 单独一行 */}
<div className="flex">
<Tabs value={editMode} onValueChange={(v) => handleModeChange(v as 'visual' | 'source')} className="w-full">
<TabsList className="h-8 sm:h-9 w-full grid grid-cols-2">
<TabsTrigger value="visual" className="text-xs sm:text-sm">
<Layout className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="source" className="text-xs sm:text-sm">
<Code2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div> </div>
{/* 重启提示 */} {/* 重启提示 */}
@@ -975,6 +978,9 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
? tabGroups ? tabGroups
: tabGroups.filter((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id)) : tabGroups.filter((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
const hasCollapsibleTabs = tabGroups.some((tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)) const hasCollapsibleTabs = tabGroups.some((tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
const firstExpandedTabId = visibleTabGroups.find(
(tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
)?.id
const toggleExpanded = () => { const toggleExpanded = () => {
setExpanded((current) => { setExpanded((current) => {
@@ -1033,15 +1039,26 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
return ( return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1"> <TabsList className="flex flex-wrap h-auto gap-1 p-1">
{visibleTabGroups.map((tab) => ( {visibleTabGroups.map((tab) => {
const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
return (
<Fragment key={tab.id}>
{tab.id === firstExpandedTabId && (
<span className="mx-1 hidden h-6 w-px bg-border/80 sm:block" />
)}
<TabsTrigger <TabsTrigger
key={tab.id}
value={tab.id} value={tab.id}
className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm" className={cn(
"text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm",
isExpandedOnlyTab &&
"border border-dashed border-border/70 bg-background/45 text-muted-foreground/80 hover:bg-background/70 data-[state=active]:border-primary/45 data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=active]:shadow-none"
)}
> >
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} </Fragment>
)
})}
{hasCollapsibleTabs && ( {hasCollapsibleTabs && (
<Button <Button
type="button" type="button"

View File

@@ -28,6 +28,11 @@ interface ExpressionGroupValue {
expression_groups: ExpressionGroupTarget[] expression_groups: ExpressionGroupTarget[]
} }
interface PlatformAccountRow {
platform: string
account: string
}
const ruleTypeLabel = (rule: unknown) => { const ruleTypeLabel = (rule: unknown) => {
if (rule === 'private') return '私聊' if (rule === 'private') return '私聊'
if (rule === 'group') return '群聊' if (rule === 'group') return '群聊'
@@ -102,6 +107,30 @@ const formatExpressionTarget = (target: ExpressionGroupTarget): string => {
return `${platform}:${itemId} · ${rule}` return `${platform}:${itemId} · ${rule}`
} }
const normalizePlatformAccounts = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return value.map((item) => String(item ?? ''))
}
const parsePlatformAccount = (value: string): PlatformAccountRow => {
const separatorIndex = value.indexOf(':')
if (separatorIndex < 0) {
return { platform: '', account: value }
}
return {
platform: value.slice(0, separatorIndex),
account: value.slice(separatorIndex + 1),
}
}
const formatPlatformAccount = (row: PlatformAccountRow): string => {
const platform = row.platform.trim()
const account = row.account.trim()
if (!platform) return account
if (!account) return `${platform}:`
return `${platform}:${account}`
}
export const ChatTalkValueRulesHook = createListItemEditorHook({ export const ChatTalkValueRulesHook = createListItemEditorHook({
addLabel: '添加发言频率规则', addLabel: '添加发言频率规则',
addButtonPlacement: 'top', addButtonPlacement: 'top',
@@ -175,6 +204,96 @@ export const ExpressionLearningListHook = createListItemEditorHook({
}, },
}) })
export const BotPlatformsHook: FieldHookComponent = ({ onChange, value }) => {
const platforms = normalizePlatformAccounts(value)
const rows = platforms.map(parsePlatformAccount)
const updateRows = (nextRows: PlatformAccountRow[]) => {
onChange?.(nextRows.map(formatPlatformAccount))
}
const addRow = () => {
updateRows([...rows, { platform: '', account: '' }])
}
const removeRow = (rowIndex: number) => {
updateRows(rows.filter((_, index) => index !== rowIndex))
}
const updateRow = (rowIndex: number, patch: Partial<PlatformAccountRow>) => {
updateRows(
rows.map((row, index) =>
index === rowIndex ? { ...row, ...patch } : row
)
)
}
return (
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<Label className="text-sm font-medium"></Label>
<p className="text-xs text-muted-foreground">
platform:account wx:114514
</p>
</div>
<Button type="button" size="sm" variant="outline" onClick={addRow}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{rows.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 px-4 py-5 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className="grid gap-2 rounded-md border bg-muted/20 p-3 sm:grid-cols-[minmax(7rem,0.6fr)_minmax(10rem,1fr)_auto]"
>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={row.platform}
placeholder="wx"
onChange={(event) =>
updateRow(rowIndex, { platform: event.target.value })
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
className="font-mono"
value={row.account}
placeholder="114514"
onChange={(event) =>
updateRow(rowIndex, { account: event.target.value })
}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`删除其他平台 ${rowIndex + 1}`}
onClick={() => removeRow(rowIndex)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)
}
export const KeywordRulesHook = createListItemEditorHook({ export const KeywordRulesHook = createListItemEditorHook({
addLabel: '添加关键词规则', addLabel: '添加关键词规则',
helperText: '匹配命中后会用 reaction 内容作为额外上下文。keywords 至少填一条,或使用正则模式。', helperText: '匹配命中后会用 reaction 内容作为额外上下文。keywords 至少填一条,或使用正则模式。',
@@ -279,8 +398,8 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
} }
return ( return (
<div className="space-y-4 rounded-lg border bg-card p-4 sm:p-6"> <div className="space-y-3 rounded-lg border bg-card p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-base font-semibold"></h3> <h3 className="text-base font-semibold"></h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -299,13 +418,13 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2">
{groups.map((group, groupIndex) => ( {groups.map((group, groupIndex) => (
<div <div
key={groupIndex} key={groupIndex}
className="space-y-3 rounded-md border bg-muted/20 p-3 sm:p-4" className="space-y-2 rounded-md border bg-muted/20 p-2.5 sm:p-3"
> >
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{groupIndex + 1} {groupIndex + 1}
@@ -341,15 +460,16 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-1.5">
{group.expression_groups.map((member, memberIndex) => ( {group.expression_groups.map((member, memberIndex) => (
<div <div
key={`${groupIndex}-${memberIndex}`} key={`${groupIndex}-${memberIndex}`}
className="grid gap-3 rounded-md bg-background/80 p-3 md:grid-cols-[minmax(7rem,0.75fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_auto]" className="grid items-end gap-2 rounded-md bg-background/80 px-2.5 py-2 md:grid-cols-[minmax(6rem,0.65fr)_minmax(9rem,1fr)_minmax(7rem,0.75fr)_2.25rem]"
> >
<div className="space-y-1"> <div className="space-y-0.5">
<Label className="text-xs"></Label> <Label className="text-[11px] leading-none text-muted-foreground"></Label>
<Input <Input
className="h-8"
value={member.platform} value={member.platform}
placeholder="qq" placeholder="qq"
onChange={(event) => onChange={(event) =>
@@ -359,10 +479,10 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
} }
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-0.5">
<Label className="text-xs"> / </Label> <Label className="text-[11px] leading-none text-muted-foreground"> / </Label>
<Input <Input
className="font-mono" className="h-8 font-mono"
value={member.item_id} value={member.item_id}
placeholder="123456" placeholder="123456"
onChange={(event) => onChange={(event) =>
@@ -372,8 +492,8 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
} }
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-0.5">
<Label className="text-xs"></Label> <Label className="text-[11px] leading-none text-muted-foreground"></Label>
<Select <Select
value={member.rule_type} value={member.rule_type}
onValueChange={(nextRuleType) => onValueChange={(nextRuleType) =>
@@ -382,7 +502,7 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
}) })
} }
> >
<SelectTrigger> <SelectTrigger className="h-8">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -399,6 +519,7 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8"
aria-label={`删除互通组 ${groupIndex + 1} 的成员 ${memberIndex + 1}`} aria-label={`删除互通组 ${groupIndex + 1} 的成员 ${memberIndex + 1}`}
onClick={() => removeMember(groupIndex, memberIndex)} onClick={() => removeMember(groupIndex, memberIndex)}
> >
@@ -409,16 +530,6 @@ export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) =>
))} ))}
</div> </div>
)} )}
{group.expression_groups.length > 0 && (
<div className="flex flex-wrap gap-2">
{group.expression_groups.map((member, memberIndex) => (
<Badge key={memberIndex} variant="outline">
{formatExpressionTarget(member)}
</Badge>
))}
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -11,6 +11,7 @@ export type {
UseAutoSaveReturnGeneric, UseAutoSaveReturnGeneric,
} from './useAutoSave' } from './useAutoSave'
export { export {
BotPlatformsHook,
ChatPromptsHook, ChatPromptsHook,
ChatTalkValueRulesHook, ChatTalkValueRulesHook,
ExpressionGroupsHook, ExpressionGroupsHook,

View File

@@ -449,8 +449,8 @@ export function EmojiManagementPage() {
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 border-t"> <div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4"> <div className="flex flex-wrap items-center gap-3">
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{selectedIds.size} {selectedIds.size}
@@ -477,8 +477,41 @@ export function EmojiManagementPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={loadEmojiList}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
{selectedIds.size > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setBatchDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</>
)}
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 sm:ml-auto">
<Label <Label
htmlFor="emoji-page-size" htmlFor="emoji-page-size"
className="text-sm whitespace-nowrap" className="text-sm whitespace-nowrap"
@@ -503,41 +536,8 @@ export function EmojiManagementPage() {
<SelectItem value="100">100</SelectItem> <SelectItem value="100">100</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{selectedIds.size > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setBatchDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</>
)}
</div> </div>
</div> </div>
<div className="flex justify-end pt-4 border-t">
<Button
variant="outline"
size="sm"
onClick={loadEmojiList}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -19,10 +19,10 @@
"startup.event_loop_closed": "[主程序] 事件循环已关闭", "startup.event_loop_closed": "[主程序] 事件循环已关闭",
"startup.file_not_found": "{file_type} 文件不存在", "startup.file_not_found": "{file_type} 文件不存在",
"startup.graceful_shutdown_error": "优雅关闭时发生错误: {error}", "startup.graceful_shutdown_error": "优雅关闭时发生错误: {error}",
"startup.initialization_completed_banner": "\n--------------------------------\n全部系统初始化完成{nickname} 已成功唤醒\n--------------------------------\n如果想要自定义 {nickname} 的功能请查阅https://docs.mai-mai.org/manual/usage/\n或者遇到了问题请访问我们的文档https://docs.mai-mai.org/\n--------------------------------\n如果你想要编写或了解插件相关内容请访问开发文档 https://docs.mai-mai.org/develop/\n--------------------------------\n如果你需要查阅模型的消耗以及麦麦的统计数据请访问根目录的 maibot_statistics.html 文件\n", "startup.initialization_completed_banner": "全部系统初始化完成,{nickname} 已成功唤醒",
"startup.initialization_completed_cycles": "初始化完成,神经元放电 {init_time} 次", "startup.initialization_completed_cycles": "初始化完成,神经元放电 {init_time} 次",
"startup.interrupt_received": "收到中断信号,正在优雅关闭...", "startup.interrupt_received": "收到中断信号,正在优雅关闭...",
"startup.launching_script": "正在启动 {script_file}...", "startup.launching_script": "正在启动MaiBot",
"startup.logging_shutdown_error": "关闭日志系统时出错: {error}", "startup.logging_shutdown_error": "关闭日志系统时出错: {error}",
"startup.main_error": "主程序发生异常: {error}", "startup.main_error": "主程序发生异常: {error}",
"startup.opensource_free_notice": " 本项目是完全免费的开源软件,基于 GPL-3.0 协议发布", "startup.opensource_free_notice": " 本项目是完全免费的开源软件,基于 GPL-3.0 协议发布",
@@ -52,7 +52,7 @@
"startup.shutdown_failed": "麦麦关闭失败: {error}", "startup.shutdown_failed": "麦麦关闭失败: {error}",
"startup.shutdown_started": "正在优雅关闭麦麦...", "startup.shutdown_started": "正在优雅关闭麦麦...",
"startup.waking_up": "正在唤醒 {nickname}......", "startup.waking_up": "正在唤醒 {nickname}......",
"startup.webui_access_token": "🔑 WebUI Access Token: {token}", "startup.webui_access_token": "🔑 WebUI 登录 Token: {token}",
"startup.webui_access_token_failed": "❌ 获取 Access Token 失败: {error}", "startup.webui_access_token_failed": "❌ 获取 Access Token 失败: {error}",
"startup.webui_access_token_login_hint": "💡 请使用此 Token 登录 WebUI", "startup.webui_access_token_login_hint": "💡 请使用此 Token 登录 WebUI",
"startup.webui_anti_crawler_config_failed": "❌ 配置防爬虫中间件失败: {error}", "startup.webui_anti_crawler_config_failed": "❌ 配置防爬虫中间件失败: {error}",

View File

@@ -1,16 +1,16 @@
"""Expression routes pytest tests""" """Expression routes pytest tests"""
from typing import Generator from typing import Generator
from unittest.mock import MagicMock
import pytest import pytest
from fastapi import FastAPI, APIRouter from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine, select from sqlmodel import Session, SQLModel, create_engine, select
from src.common.database.database_model import Expression from src.common.database.database_model import Expression, ModifiedBy
from src.webui.dependencies import require_auth
def create_test_app() -> FastAPI: def create_test_app() -> FastAPI:
@@ -63,6 +63,7 @@ def client_fixture(test_session: Session, monkeypatch) -> Generator[TestClient,
@contextmanager @contextmanager
def get_test_db_session(): def get_test_db_session():
yield test_session yield test_session
test_session.commit()
monkeypatch.setattr("src.webui.routers.expression.get_db_session", get_test_db_session) monkeypatch.setattr("src.webui.routers.expression.get_db_session", get_test_db_session)
@@ -71,10 +72,11 @@ def client_fixture(test_session: Session, monkeypatch) -> Generator[TestClient,
@pytest.fixture(name="mock_auth") @pytest.fixture(name="mock_auth")
def mock_auth_fixture(monkeypatch): def mock_auth_fixture():
"""Mock authentication to always return True""" """Mock authentication to always return True"""
mock_verify = MagicMock(return_value=True) app.dependency_overrides[require_auth] = lambda: "test-token"
monkeypatch.setattr("src.webui.routers.expression.verify_auth_token_from_cookie_or_header", mock_verify) yield
app.dependency_overrides.clear()
@pytest.fixture(name="sample_expression") @pytest.fixture(name="sample_expression")
@@ -82,8 +84,8 @@ def sample_expression_fixture(test_session: Session) -> Expression:
"""Insert a sample expression into test database""" """Insert a sample expression into test database"""
test_session.execute( test_session.execute(
text( text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " "INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '测试情景', '测试风格', '测试上下文', '测试上文', '[\"测试内容1\", \"测试内容2\"]', 10, '2026-02-17 12:00:00', '2026-02-15 10:00:00', 'test_chat_001')" "VALUES (1, '测试情景', '测试风格', '[\"测试内容1\", \"测试内容2\"]', 10, '2026-02-17 12:00:00', '2026-02-15 10:00:00', 'test_chat_001', 0, 0)"
) )
) )
test_session.commit() test_session.commit()
@@ -131,8 +133,8 @@ def test_list_expressions_pagination(client: TestClient, mock_auth, test_session
for i in range(5): for i in range(5):
test_session.execute( test_session.execute(
text( text(
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '', '', '[]', 0, '2026-02-17 12:0{i}:00', '2026-02-15 10:00:00', 'chat_{i}')" f"VALUES ({i + 1}, '情景{i}', '风格{i}', '[]', 0, '2026-02-17 12:0{i}:00', '2026-02-15 10:00:00', 'chat_{i}', 0, 0)"
) )
) )
test_session.commit() test_session.commit()
@@ -158,14 +160,14 @@ def test_list_expressions_search(client: TestClient, mock_auth, test_session: Se
"""Test GET /expression/list with search filter""" """Test GET /expression/list with search filter"""
test_session.execute( test_session.execute(
text( text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " "INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '找人吃饭', '热情', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_001')" "VALUES (1, '找人吃饭', '热情', '[]', 0, datetime('now'), datetime('now'), 'chat_001', 0, 0)"
) )
) )
test_session.execute( test_session.execute(
text( text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " "INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (2, '拒绝邀请', '礼貌', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_002')" "VALUES (2, '拒绝邀请', '礼貌', '[]', 0, datetime('now'), datetime('now'), 'chat_002', 0, 0)"
) )
) )
test_session.commit() test_session.commit()
@@ -183,14 +185,14 @@ def test_list_expressions_chat_filter(client: TestClient, mock_auth, test_sessio
"""Test GET /expression/list with chat_id filter""" """Test GET /expression/list with chat_id filter"""
test_session.execute( test_session.execute(
text( text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " "INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '情景A', '风格A', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_A')" "VALUES (1, '情景A', '风格A', '[]', 0, datetime('now'), datetime('now'), 'chat_A', 0, 0)"
) )
) )
test_session.execute( test_session.execute(
text( text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " "INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (2, '情景B', '风格B', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_B')" "VALUES (2, '情景B', '风格B', '[]', 0, datetime('now'), datetime('now'), 'chat_B', 0, 0)"
) )
) )
test_session.commit() test_session.commit()
@@ -378,8 +380,8 @@ def test_batch_delete_expressions_success(client: TestClient, mock_auth, test_se
for i in range(3): for i in range(3):
test_session.execute( test_session.execute(
text( text(
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
f"VALUES ({i + 1}, '批量删除{i}', '风格{i}', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_{i}')" f"VALUES ({i + 1}, '批量删除{i}', '风格{i}', '[]', 0, datetime('now'), datetime('now'), 'chat_{i}', 0, 0)"
) )
) )
expression_ids.append(i + 1) expression_ids.append(i + 1)
@@ -416,8 +418,8 @@ def test_get_expression_stats(client: TestClient, mock_auth, test_session: Sessi
for i in range(3): for i in range(3):
test_session.execute( test_session.execute(
text( text(
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_{i % 2}')" f"VALUES ({i + 1}, '情景{i}', '风格{i}', '[]', 0, datetime('now'), datetime('now'), 'chat_{i % 2}', 0, 0)"
) )
) )
test_session.commit() test_session.commit()
@@ -432,11 +434,11 @@ def test_get_expression_stats(client: TestClient, mock_auth, test_session: Sessi
def test_get_review_stats(client: TestClient, mock_auth, test_session: Session): def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
"""Test GET /expression/review/stats returns hardcoded 0 counts""" """Test GET /expression/review/stats returns review status counts"""
test_session.execute( test_session.execute(
text( text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) " "INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '待审核', '风格', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_001')" "VALUES (1, '待审核', '风格', '[]', 0, datetime('now'), datetime('now'), 'chat_001', 0, 0)"
) )
) )
test_session.commit() test_session.commit()
@@ -445,9 +447,8 @@ def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Verify all review counts are 0 (hardcoded in refactored code)
assert data["total"] == 1 # Total expressions exists assert data["total"] == 1 # Total expressions exists
assert data["unchecked"] == 0 assert data["unchecked"] == 1
assert data["passed"] == 0 assert data["passed"] == 0
assert data["rejected"] == 0 assert data["rejected"] == 0
assert data["ai_checked"] == 0 assert data["ai_checked"] == 0
@@ -455,14 +456,14 @@ def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
def test_get_review_list_filter_unchecked(client: TestClient, mock_auth, sample_expression: Expression): def test_get_review_list_filter_unchecked(client: TestClient, mock_auth, sample_expression: Expression):
"""Test GET /expression/review/list with filter_type=unchecked returns empty (legacy behavior)""" """Test GET /expression/review/list with filter_type=unchecked returns unchecked expressions"""
# filter_type=unchecked should return no results (legacy removed)
response = client.get("/api/webui/expression/review/list?filter_type=unchecked") response = client.get("/api/webui/expression/review/list?filter_type=unchecked")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["success"] is True
assert data["total"] == 0 # No results (legacy fields removed) assert data["total"] == 1
assert len(data["data"]) == 1
def test_get_review_list_filter_all(client: TestClient, mock_auth, sample_expression: Expression): def test_get_review_list_filter_all(client: TestClient, mock_auth, sample_expression: Expression):
@@ -476,8 +477,8 @@ def test_get_review_list_filter_all(client: TestClient, mock_auth, sample_expres
assert len(data["data"]) == 1 assert len(data["data"]) == 1
def test_batch_review_expressions_unsupported(client: TestClient, mock_auth, sample_expression: Expression): def test_batch_review_expressions_with_unchecked_marker(client: TestClient, mock_auth, sample_expression: Expression):
"""Test POST /expression/review/batch returns failure for require_unchecked=True""" """Test POST /expression/review/batch succeeds with require_unchecked=True"""
review_payload = {"items": [{"id": sample_expression.id, "rejected": False, "require_unchecked": True}]} review_payload = {"items": [{"id": sample_expression.id, "rejected": False, "require_unchecked": True}]}
response = client.post("/api/webui/expression/review/batch", json=review_payload) response = client.post("/api/webui/expression/review/batch", json=review_payload)
@@ -485,8 +486,34 @@ def test_batch_review_expressions_unsupported(client: TestClient, mock_auth, sam
data = response.json() data = response.json()
assert data["success"] is True assert data["success"] is True
assert data["failed"] == 1 # Should fail because require_unchecked=True assert data["succeeded"] == 1
assert "不支持审核状态过滤" in data["results"][0]["message"] assert data["results"][0]["success"] is True
def test_batch_review_expressions_overwrites_ai_checked(
client: TestClient, mock_auth, test_session: Session, sample_expression: Expression
):
"""Test POST /expression/review/batch lets manual review override AI checked state"""
sample_expression.checked = True
sample_expression.rejected = True
sample_expression.modified_by = ModifiedBy.AI
test_session.add(sample_expression)
test_session.commit()
review_payload = {"items": [{"id": sample_expression.id, "rejected": False, "require_unchecked": True}]}
response = client.post("/api/webui/expression/review/batch", json=review_payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["succeeded"] == 1
test_session.expire_all()
reviewed_expression = test_session.exec(select(Expression).where(Expression.id == sample_expression.id)).first()
assert reviewed_expression is not None
assert reviewed_expression.checked is True
assert reviewed_expression.rejected is False
assert reviewed_expression.modified_by == ModifiedBy.USER
def test_batch_review_expressions_no_unchecked_check(client: TestClient, mock_auth, sample_expression: Expression): def test_batch_review_expressions_no_unchecked_check(client: TestClient, mock_auth, sample_expression: Expression):

View File

@@ -78,7 +78,7 @@ class ChatManager:
"""初始化聊天管理器""" """初始化聊天管理器"""
try: try:
await self.load_all_sessions_from_db() await self.load_all_sessions_from_db()
logger.info(f"已加载 {len(self.sessions)} 个会话记录到内存中") logger.debug(f"已加载 {len(self.sessions)} 个会话记录到内存中")
except Exception as e: except Exception as e:
logger.error(f"初始化聊天管理器出现错误: {e}") logger.error(f"初始化聊天管理器出现错误: {e}")

View File

@@ -829,20 +829,19 @@ def initialize_logging(verbose: bool = True):
reconfigure_existing_loggers() reconfigure_existing_loggers()
# 启动日志清理任务 # 启动日志清理任务
start_log_cleanup_task(verbose=verbose) start_log_cleanup_task()
# 只在 verbose=True 时输出详细的初始化信息 # 只在 verbose=True 时输出详细的初始化信息
if verbose: if verbose:
logger = get_logger("logger") logger = get_logger("logger")
console_level = LOG_CONFIG.get("console_log_level", LOG_CONFIG.get("log_level", "INFO")) console_level = LOG_CONFIG.get("console_log_level", LOG_CONFIG.get("log_level", "INFO"))
file_level = LOG_CONFIG.get("file_log_level", LOG_CONFIG.get("log_level", "INFO")) file_level = LOG_CONFIG.get("file_log_level", LOG_CONFIG.get("log_level", "INFO"))
logger.info("日志系统已初始化:")
logger.info(f" - 控制台级别: {console_level}")
logger.info(f" - 文件级别: {file_level}")
max_log_files = max(1, int(LOG_CONFIG.get("max_log_files", 30) or 30)) max_log_files = max(1, int(LOG_CONFIG.get("max_log_files", 30) or 30))
log_cleanup_days = max(1, int(LOG_CONFIG.get("log_cleanup_days", 30) or 30)) log_cleanup_days = max(1, int(LOG_CONFIG.get("log_cleanup_days", 30) or 30))
logger.info(f" - 轮转份数: {max_log_files}个文件|自动清理: {log_cleanup_days}天前的日志") logger.info(
f"日志系统已初始化:控制台={console_level},文件={file_level}"
f"轮转={max_log_files}个文件,清理={log_cleanup_days}天前"
)
def cleanup_old_logs(): def cleanup_old_logs():
@@ -875,12 +874,8 @@ def cleanup_old_logs():
logger.error(f"清理旧日志文件时出错: {e}") logger.error(f"清理旧日志文件时出错: {e}")
def start_log_cleanup_task(verbose: bool = True): def start_log_cleanup_task():
"""启动日志清理任务 """启动日志清理任务"""
Args:
verbose: 是否输出启动信息。默认为 True。
"""
global _cleanup_task_started global _cleanup_task_started
# 防止重复启动清理任务 # 防止重复启动清理任务
@@ -897,12 +892,6 @@ def start_log_cleanup_task(verbose: bool = True):
cleanup_thread = threading.Thread(target=cleanup_task, daemon=True) cleanup_thread = threading.Thread(target=cleanup_task, daemon=True)
cleanup_thread.start() cleanup_thread.start()
if verbose:
logger = get_logger("logger")
max_log_files = max(1, int(LOG_CONFIG.get("max_log_files", 30) or 30))
log_cleanup_days = max(1, int(LOG_CONFIG.get("log_cleanup_days", 30) or 30))
logger.info(f"已启动日志清理任务,将自动清理{log_cleanup_days}天前的日志文件(轮转份数限制: {max_log_files}个文件)")
def shutdown_logging(): def shutdown_logging():
"""优雅关闭日志系统,释放所有文件句柄""" """优雅关闭日志系统,释放所有文件句柄"""

View File

@@ -40,6 +40,7 @@ class BotConfig(ConfigBase):
"x-icon": "wifi", "x-icon": "wifi",
"x-layout": "inline-right", "x-layout": "inline-right",
"x-input-width": "12rem", "x-input-width": "12rem",
"x-row": "bot-platform-account",
}, },
) )
"""平台""" """平台"""
@@ -51,6 +52,7 @@ class BotConfig(ConfigBase):
"x-icon": "user", "x-icon": "user",
"x-layout": "inline-right", "x-layout": "inline-right",
"x-input-width": "12rem", "x-input-width": "12rem",
"x-row": "bot-platform-account",
}, },
) )
"""QQ账号""" """QQ账号"""

View File

@@ -134,7 +134,7 @@ def _setup_anti_crawler(app: FastAPI):
"basic": t("startup.webui_anti_crawler_mode_basic"), "basic": t("startup.webui_anti_crawler_mode_basic"),
} }
mode_desc = mode_descriptions.get(anti_crawler_mode, t("startup.webui_anti_crawler_mode_basic")) mode_desc = mode_descriptions.get(anti_crawler_mode, t("startup.webui_anti_crawler_mode_basic"))
logger.info(t("startup.webui_anti_crawler_configured", mode_desc=mode_desc)) logger.debug(t("startup.webui_anti_crawler_configured", mode_desc=mode_desc))
except Exception as e: except Exception as e:
logger.error(t("startup.webui_anti_crawler_config_failed", error=e), exc_info=True) logger.error(t("startup.webui_anti_crawler_config_failed", error=e), exc_info=True)
@@ -159,7 +159,7 @@ def _register_api_routes(app: FastAPI):
for router in get_all_routers(): for router in get_all_routers():
app.include_router(router) app.include_router(router)
logger.info(t("startup.webui_api_routes_registered")) logger.debug(t("startup.webui_api_routes_registered"))
except Exception as e: except Exception as e:
logger.error(t("startup.webui_api_routes_register_failed", error=e), exc_info=True) logger.error(t("startup.webui_api_routes_register_failed", error=e), exc_info=True)
@@ -217,7 +217,7 @@ def _setup_static_files(app: FastAPI):
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return response return response
logger.info(t("startup.webui_static_files_configured", static_path=static_path)) logger.debug(t("startup.webui_static_files_configured", static_path=static_path))
def _resolve_static_path() -> Path | None: def _resolve_static_path() -> Path | None:
@@ -247,6 +247,5 @@ def show_access_token():
token_manager = get_token_manager() token_manager = get_token_manager()
current_token = token_manager.get_token() current_token = token_manager.get_token()
logger.info(t("startup.webui_access_token", token=current_token)) logger.info(t("startup.webui_access_token", token=current_token))
logger.info(t("startup.webui_access_token_login_hint"))
except Exception as e: except Exception as e:
logger.error(t("startup.webui_access_token_failed", error=e)) logger.error(t("startup.webui_access_token_failed", error=e))

View File

@@ -15,6 +15,7 @@ from src.common.logger import get_logger
from src.webui.dependencies import require_auth from src.webui.dependencies import require_auth
logger = get_logger("webui.expression") logger = get_logger("webui.expression")
EXCLUDE_IDS_QUERY = Query(None, description="需要排除的表达方式 ID")
# 创建路由器 # 创建路由器
router = APIRouter(prefix="/expression", tags=["Expression"], dependencies=[Depends(require_auth)]) router = APIRouter(prefix="/expression", tags=["Expression"], dependencies=[Depends(require_auth)])
@@ -660,8 +661,10 @@ async def get_review_list(
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"), page_size: int = Query(20, ge=1, le=100, description="每页数量"),
filter_type: str = Query("unchecked", description="筛选类型: unchecked/passed/rejected/all"), filter_type: str = Query("unchecked", description="筛选类型: unchecked/passed/rejected/all"),
order: str = Query("latest", description="排序方式: latest/random"),
search: Optional[str] = Query(None, description="搜索关键词"), search: Optional[str] = Query(None, description="搜索关键词"),
chat_id: Optional[str] = Query(None, description="聊天ID筛选"), chat_id: Optional[str] = Query(None, description="聊天ID筛选"),
exclude_ids: Optional[List[int]] = EXCLUDE_IDS_QUERY,
) -> ReviewListResponse: ) -> ReviewListResponse:
"""获取待审核或已审核的表达方式列表。 """获取待审核或已审核的表达方式列表。
@@ -669,8 +672,10 @@ async def get_review_list(
page: 页码。 page: 页码。
page_size: 每页数量。 page_size: 每页数量。
filter_type: 筛选类型,可选 unchecked、passed、rejected 或 all。 filter_type: 筛选类型,可选 unchecked、passed、rejected 或 all。
order: 排序方式,可选 latest 或 random。
search: 搜索关键词。 search: 搜索关键词。
chat_id: 聊天 ID 筛选条件。 chat_id: 聊天 ID 筛选条件。
exclude_ids: 需要排除的表达方式 ID。
Returns: Returns:
ReviewListResponse: 审核列表响应。 ReviewListResponse: 审核列表响应。
@@ -689,6 +694,12 @@ async def get_review_list(
if chat_id: if chat_id:
statement = statement.where(col(Expression.session_id) == chat_id) statement = statement.where(col(Expression.session_id) == chat_id)
if exclude_ids:
statement = statement.where(~col(Expression.id).in_(exclude_ids))
if order == "random":
statement = statement.order_by(func.random())
else:
# 排序:创建时间倒序 # 排序:创建时间倒序
statement = statement.order_by( statement = statement.order_by(
case((col(Expression.create_time).is_(None), 1), else_=0), case((col(Expression.create_time).is_(None), 1), else_=0),
@@ -731,7 +742,7 @@ class BatchReviewItem(BaseModel):
id: int id: int
rejected: bool rejected: bool
require_unchecked: bool = True # 默认要求未检查状态 require_unchecked: bool = True # 前端保留的来源标记,人工审核提交时不再阻断覆盖
class BatchReviewRequest(BaseModel): class BatchReviewRequest(BaseModel):
@@ -790,14 +801,6 @@ async def batch_review_expressions(
failed += 1 failed += 1
continue continue
# 冲突检测:未审核列表发起的操作只允许处理仍处于未审核状态的条目。
if item.require_unchecked and expression.checked:
results.append(
BatchReviewResultItem(id=item.id, success=False, message="该表达方式已被审核,请刷新列表后重试")
)
failed += 1
continue
# 更新状态 # 更新状态
with get_db_session() as session: with get_db_session() as session:
db_expression = session.exec( db_expression = session.exec(