fix(platform): 存储与 Agent - 修复配额准入、Live2D 与用户筛选(#651)
fix(platform): 存储与 Agent - 修复配额准入、Live2D 与用户筛选
This commit is contained in:
@@ -13,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
type AdminResourceQuery struct {
|
||||
Keyword string
|
||||
Kind string
|
||||
Status string
|
||||
Provider string
|
||||
UserID string
|
||||
Page int
|
||||
Limit int
|
||||
Keyword string
|
||||
Kind string
|
||||
Status string
|
||||
Provider string
|
||||
UserQuery string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type AdminStorageResourceView struct {
|
||||
@@ -149,13 +149,13 @@ func (s *Service) PrepareResourceDeliveryAsAdmin(actor *model.User, id string, o
|
||||
func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminResourceFilter, int, int, error) {
|
||||
page, limit := normalizeAdminPage(query.Page, query.Limit)
|
||||
filter := repository.AdminResourceFilter{
|
||||
Keyword: strings.TrimSpace(query.Keyword),
|
||||
Kind: strings.ToLower(strings.TrimSpace(query.Kind)),
|
||||
Status: strings.ToLower(strings.TrimSpace(query.Status)),
|
||||
Provider: strings.ToLower(strings.TrimSpace(query.Provider)),
|
||||
UserID: strings.TrimSpace(query.UserID),
|
||||
Limit: limit,
|
||||
Offset: (page - 1) * limit,
|
||||
Keyword: strings.TrimSpace(query.Keyword),
|
||||
Kind: strings.ToLower(strings.TrimSpace(query.Kind)),
|
||||
Status: strings.ToLower(strings.TrimSpace(query.Status)),
|
||||
Provider: strings.ToLower(strings.TrimSpace(query.Provider)),
|
||||
UserQuery: strings.TrimSpace(query.UserQuery),
|
||||
Limit: limit,
|
||||
Offset: (page - 1) * limit,
|
||||
}
|
||||
if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file", "live2d") {
|
||||
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源类型筛选无效")
|
||||
@@ -166,7 +166,7 @@ func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminReso
|
||||
if filter.Provider != "" && !oneOf(filter.Provider, "local", aliyunOSSProvider, tencentCOSProvider, qiniuKodoProvider, s3Provider) {
|
||||
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源存储位置筛选无效")
|
||||
}
|
||||
if len(filter.Keyword) > 200 || len(filter.UserID) > 64 {
|
||||
if len(filter.Keyword) > 200 || len(filter.UserQuery) > 160 {
|
||||
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源筛选条件过长")
|
||||
}
|
||||
return filter, page, limit, nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -29,7 +30,7 @@ func TestAdminStorageListStatsAndPreview(t *testing.T) {
|
||||
}
|
||||
users := []model.User{
|
||||
{ID: "admin", Username: "admin", DisplayName: "管理员", Role: model.UserRoleAdmin, Status: model.UserStatusActive},
|
||||
{ID: "user-1", Username: "creator", DisplayName: "创作者", Role: model.UserRoleUser, Status: model.UserStatusActive},
|
||||
{ID: "user-1", Username: "creator", Email: "creator@example.com", DisplayName: "创作者", Role: model.UserRoleUser, Status: model.UserStatusActive},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -66,6 +67,28 @@ func TestAdminStorageListStatsAndPreview(t *testing.T) {
|
||||
t.Fatalf("physical bytes = %+v", page.Items)
|
||||
}
|
||||
|
||||
for _, userQuery := range []string{"user-1", "creator", "创作者", "creator@example.com"} {
|
||||
filtered, filterErr := svc.AdminResourcePage(admin, AdminResourceQuery{UserQuery: userQuery, Page: 1, Limit: 20})
|
||||
if filterErr != nil {
|
||||
t.Fatalf("AdminResourcePage(UserQuery=%q) error = %v", userQuery, filterErr)
|
||||
}
|
||||
if filtered.Total != 2 || len(filtered.Items) != 2 {
|
||||
t.Fatalf("AdminResourcePage(UserQuery=%q) = %+v", userQuery, filtered)
|
||||
}
|
||||
for _, item := range filtered.Items {
|
||||
if item.UserID != "user-1" {
|
||||
t.Fatalf("AdminResourcePage(UserQuery=%q) returned user %q", userQuery, item.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
missing, missingErr := svc.AdminResourcePage(admin, AdminResourceQuery{UserQuery: "missing-user", Page: 1, Limit: 20})
|
||||
if missingErr != nil {
|
||||
t.Fatal(missingErr)
|
||||
}
|
||||
if missing.Total != 0 || len(missing.Items) != 0 {
|
||||
t.Fatalf("missing user filter returned resources: %+v", missing)
|
||||
}
|
||||
|
||||
stats, err := svc.AdminStorageStats(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -103,6 +126,9 @@ func TestAdminStorageRejectsNonAdminAndInvalidFilters(t *testing.T) {
|
||||
if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{Status: "unknown"}); err == nil {
|
||||
t.Fatal("expected invalid status filter to be rejected")
|
||||
}
|
||||
if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{UserQuery: strings.Repeat("x", 161)}); err == nil {
|
||||
t.Fatal("expected oversized user filter to be rejected")
|
||||
}
|
||||
if _, err := svc.AdminStorageStats(user); err == nil {
|
||||
t.Fatal("expected non-admin stats to be rejected")
|
||||
}
|
||||
|
||||
@@ -118,6 +118,10 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
|
||||
if activeTasks >= int64(policy.Task.ActiveTaskLimit) {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
|
||||
}
|
||||
// 媒体任务在扣费和调用上游前确认账号文件容量,避免容量已满仍然计费却无法回存产物。
|
||||
if err := s.requireStoredFileCapacityForTask(userID, taskType, policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := model.Task{ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model}
|
||||
if req.admission != nil {
|
||||
task.ID = req.admission.ID
|
||||
|
||||
44
backend/internal/app/task_storage_admission.go
Normal file
44
backend/internal/app/task_storage_admission.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 产出媒体文件的任务类型:这些任务成功后必须把结果回存为资源文件。
|
||||
// 文本任务只增长任务历史数据,不占用账号文件容量。
|
||||
func taskTypeProducesStoredFile(taskType string) bool {
|
||||
switch taskType {
|
||||
case "canvas_image", "canvas_video", "canvas_audio":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(taskType, "video_")
|
||||
}
|
||||
|
||||
// requireStoredFileCapacityForTask 是生成任务 admission 阶段的账号文件容量校验。
|
||||
//
|
||||
// 账号文件容量此前只在产物回存时校验,上游调用已经发出并扣费,失败只能表现为
|
||||
// 媒体无法入库。生成任务必须在扣费前确认账号还有可用容量,容量已满时直接拒绝。
|
||||
func (s *Service) requireStoredFileCapacityForTask(userID string, taskType string, policy RuntimePolicySetting) error {
|
||||
if !taskTypeProducesStoredFile(taskType) {
|
||||
return nil
|
||||
}
|
||||
storedLimit := gigabytes(policy.Resource.StoredFileGB)
|
||||
if storedLimit <= 0 {
|
||||
return nil
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
storedBytes, err := s.repo.UserStoredFileBytes(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateStoredFileCapacity(storedBytes, s.pendingStorage[userID], storedLimit)
|
||||
}
|
||||
|
||||
func validateStoredFileCapacity(storedBytes int64, pendingBytes int64, storedLimit int64) error {
|
||||
if storedLimit > 0 && storedBytes+pendingBytes >= storedLimit {
|
||||
return QuotaExceeded(fmt.Sprintf("账号文件容量已达到 %s 上限,请先清理素材后再生成", formatStorageLimit(storedLimit)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
45
backend/internal/app/task_storage_admission_test.go
Normal file
45
backend/internal/app/task_storage_admission_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskTypeProducesStoredFile(t *testing.T) {
|
||||
for _, taskType := range []string{"canvas_image", "canvas_video", "canvas_audio", "video_generate"} {
|
||||
if !taskTypeProducesStoredFile(taskType) {
|
||||
t.Fatalf("taskTypeProducesStoredFile(%q) = false", taskType)
|
||||
}
|
||||
}
|
||||
for _, taskType := range []string{"text", "canvas_text"} {
|
||||
if taskTypeProducesStoredFile(taskType) {
|
||||
t.Fatalf("taskTypeProducesStoredFile(%q) = true", taskType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStoredFileCapacityRejectsFullAccount(t *testing.T) {
|
||||
limit := gigabytes(defaultRuntimePolicy().Resource.StoredFileGB)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
stored int64
|
||||
pending int64
|
||||
}{
|
||||
{name: "stored files fill capacity", stored: limit},
|
||||
{name: "pending upload fills capacity", stored: limit - 1, pending: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateStoredFileCapacity(test.stored, test.pending, limit)
|
||||
if err == nil || !strings.Contains(err.Error(), "账号文件容量已达到") {
|
||||
t.Fatalf("validateStoredFileCapacity() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStoredFileCapacityAllowsRemainingSpace(t *testing.T) {
|
||||
limit := gigabytes(defaultRuntimePolicy().Resource.StoredFileGB)
|
||||
if err := validateStoredFileCapacity(limit-2, 1, limit); err != nil {
|
||||
t.Fatalf("validateStoredFileCapacity() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,13 @@ func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
userQuery := c.Query("user")
|
||||
if userQuery == "" {
|
||||
userQuery = c.Query("userId")
|
||||
}
|
||||
result, err := svc.AdminResourcePage(user, service.AdminResourceQuery{
|
||||
Keyword: c.Query("keyword"), Kind: c.Query("kind"), Status: c.Query("status"),
|
||||
Provider: c.Query("provider"), UserID: c.Query("userId"), Page: page, Limit: limit,
|
||||
Provider: c.Query("provider"), UserQuery: userQuery, Page: page, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
)
|
||||
|
||||
type AdminResourceFilter struct {
|
||||
Kind string
|
||||
Status string
|
||||
Provider string
|
||||
UserID string
|
||||
Keyword string
|
||||
Limit int
|
||||
Offset int
|
||||
Kind string
|
||||
Status string
|
||||
Provider string
|
||||
UserQuery string
|
||||
Keyword string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type ResourceKindStat struct {
|
||||
@@ -52,8 +52,14 @@ func (r *Repository) AdminResources(filter AdminResourceFilter) ([]model.Resourc
|
||||
if filter.Provider != "" {
|
||||
query = query.Where(resourceProviderExpression()+" = ?", filter.Provider)
|
||||
}
|
||||
if filter.UserID != "" {
|
||||
query = query.Where("user_id = ?", filter.UserID)
|
||||
if filter.UserQuery != "" {
|
||||
pattern := "%" + strings.ToLower(filter.UserQuery) + "%"
|
||||
matchingUsers := r.db.Model(&model.User{}).Select("id").Where(
|
||||
"lower(id) LIKE ? OR lower(username) LIKE ? OR lower(display_name) LIKE ? OR lower(email) LIKE ?",
|
||||
pattern, pattern, pattern, pattern,
|
||||
)
|
||||
// 保留对已删除用户资源的 ID 搜索,同时支持按现存用户的账号信息筛选。
|
||||
query = query.Where("(lower(user_id) LIKE ? OR user_id IN (?))", pattern, matchingUsers)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
pattern := "%" + strings.ToLower(filter.Keyword) + "%"
|
||||
|
||||
@@ -2828,3 +2828,26 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客
|
||||
画布角色卡仍为 `text` 节点,但角色设定由项目角色资产按当前版或固定版读取;Agent 的节点目录标注 `kind: character`,精读返回角色设定、形象/声音状态及图片参考就绪情况,不再把空 `content` 当作空角色卡。选择角色卡作为媒体参考时,后端从同一角色版本解析三视图图片,并把角色设定加入本次生成提示词;只选 `sourceNodeId` 则使用文字设定。审批期间角色版本变化会阻止旧输入提交。生成仍需用户确认或符合 auto 准入,不在读取时发起付费任务。
|
||||
|
||||
待登录态页面验收:对「扶颦」精读应返回实际外貌和服装;选择该卡生成时,审批/任务输入应同时包含该版设定与已绑定三视图;故意改动角色版本后旧审批不能提交。声音档案可精读,音频任务在未显式选择音色时采用角色绑定的 voiceKey 与声音指令;跨模型适配及真实音色效果仍需单独验收。此轮不代表生产已部署。
|
||||
|
||||
|
||||
## 存储满额生成拦截与 Live2D 开关稳定性(2026-09-30)
|
||||
|
||||
- 已修复:图片、视频、音频生成任务在创建、积分预留和上游调用之前检查账号文件容量;容量已满时返回配额错误并提示先清理素材,文本任务不受文件容量限制。普通画布与 Agent 媒体生成共用该 admission 边界。
|
||||
- 已修复:Agent 面板开合时保留 Live2D 入口组件和已加载模型,仅隐藏入口并暂停不可见渲染,不再因反复卸载、重建 PIXI/Live2D 上下文而短暂回退默认动态球。
|
||||
- 自动验证:后端纯 admission 专项测试通过(CGO 关闭,3 项);前端 Live2D 生命周期专项测试通过;前端生产构建通过。既有 Agent 位置测试在当前 Windows 环境因 Bun 将磁盘路径解析为 `/E:/...` 而未进入测试逻辑。
|
||||
|
||||
仍待真实环境验收:
|
||||
|
||||
- [ ] 将测试账号文件容量填满后,从普通画布和 Agent 分别提交图片、视频、音频生成,确认立即显示容量提示、没有创建计费订单、没有调用上游;清理出容量后可恢复生成。
|
||||
- [ ] 使用自定义 Live2D 模型连续开合 Agent 面板,确认不出现默认动态球闪回,拖动、窗口缩放和重新进入画布仍正常。
|
||||
|
||||
|
||||
## 后台存储资源用户筛选(2026-09-30)
|
||||
|
||||
- 已修复:后台存储资源列表的用户筛选不再只接受完整用户 ID;现在支持按用户 ID、用户名、昵称和邮箱进行不区分大小写的模糊匹配,并可与资源类型、状态、存储位置、资源关键词组合使用。
|
||||
- API 新增可读性更高的 `user` 查询参数,后端继续兼容旧 `userId` 参数;前端请求同时发送两者,避免滚动升级期间旧后端忽略新参数并错误返回全量资源。
|
||||
- 自动验证:后台 UI 回归 17 项通过;前端生产构建通过;后端参数规范化专项通过。SQLite 集成用例已补充四种用户字段匹配,但当前 Windows 本机 CGO/GCC 无法解析生成的对象文件,未在本机实际执行该数据库用例。
|
||||
|
||||
仍待真实环境验收:
|
||||
|
||||
- [ ] 在管理员后台存储资源页分别输入完整/部分用户名、昵称、邮箱和用户 ID,确认返回同一用户资源;再叠加类型、状态和存储位置筛选,确认分页与清空筛选正常。
|
||||
|
||||
@@ -32,7 +32,9 @@ import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-
|
||||
import { markdownPlainText } from "@/lib/markdown-plain-text";
|
||||
import type { ApprovalState } from "./canvas-cloud-agent-events";
|
||||
|
||||
export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) {
|
||||
// hidden 为 true 时入口保持挂载但不显示:Live2D 模型加载开销大,面板开合不能卸载它,
|
||||
// 否则每次重新挂载都要重新下载模型并重建渲染上下文,期间只能显示默认形象。
|
||||
export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, hidden = false, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; hidden?: boolean; onOpen: () => void }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE;
|
||||
const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry);
|
||||
const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
||||
@@ -50,7 +52,9 @@ export function AgentLauncher({ theme, statusColor, approvalPending, reducedMoti
|
||||
aria-label={`打开${appearance.agentName}`}
|
||||
title={`${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`}
|
||||
className={cn("canvas-agent-launcher fixed z-[calc(var(--z-toast)+1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging", live && "canvas-agent-launcher-live2d")}
|
||||
style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow, ...(hidden ? { display: "none" } : null) } as CSSProperties}
|
||||
aria-hidden={hidden || undefined}
|
||||
tabIndex={hidden ? -1 : undefined}
|
||||
data-canvas-no-zoom
|
||||
{...handlers}
|
||||
whileHover={reducedMotion || dragging ? undefined : { scale: 1.035 }}
|
||||
|
||||
@@ -857,7 +857,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open ? <AgentLauncher theme={theme} statusColor={statusColor} approvalPending={Boolean(approval)} reducedMotion={Boolean(reducedMotion)} onOpen={onOpen} /> : null}
|
||||
<AgentLauncher theme={theme} statusColor={statusColor} approvalPending={Boolean(approval)} reducedMotion={Boolean(reducedMotion)} hidden={open} onOpen={onOpen} />
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.aside
|
||||
|
||||
@@ -20,11 +20,11 @@ export default function StorageResourcesPanel() {
|
||||
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file", "live2d"]);
|
||||
const status = normalizeOption(searchParams.get("status"), ["pending", "ready", "failed", "deleted"]);
|
||||
const provider = normalizeOption(searchParams.get("provider"), ["local", "aliyun", "tencent", "qiniu", "s3"]);
|
||||
const userId = searchParams.get("userId") || "";
|
||||
const userQuery = searchParams.get("user") || searchParams.get("userId") || "";
|
||||
const page = positiveInt(searchParams.get("page"), 1);
|
||||
const pageSize = normalizePageSize(searchParams.get("pageSize"));
|
||||
const debouncedKeyword = useDebouncedValue(keyword);
|
||||
const debouncedUserId = useDebouncedValue(userId);
|
||||
const debouncedUserQuery = useDebouncedValue(userQuery);
|
||||
const [resources, setResources] = useState<AdminStorageResource[]>([]);
|
||||
const [stats, setStats] = useState<AdminStorageStats | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -35,12 +35,12 @@ export default function StorageResourcesPanel() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const requestSequence = useRef(0);
|
||||
const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all");
|
||||
const hasFilters = Boolean(keyword || userQuery || kind !== "all" || status !== "all" || provider !== "all");
|
||||
|
||||
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(patch).forEach(([key, value]) => {
|
||||
const isDefault = ((key === "filter" || key === "userId") && value === "") || (["kind", "status", "provider"].includes(key) && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
|
||||
const isDefault = ((key === "filter" || key === "user" || key === "userId") && value === "") || (["kind", "status", "provider"].includes(key) && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
|
||||
if (isDefault) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
@@ -68,7 +68,8 @@ export default function StorageResourcesPanel() {
|
||||
kind: kind === "all" ? undefined : kind,
|
||||
status: status === "all" ? undefined : status,
|
||||
provider: provider === "all" ? undefined : provider,
|
||||
userId: debouncedUserId || undefined,
|
||||
user: debouncedUserQuery || undefined,
|
||||
userId: debouncedUserQuery || undefined,
|
||||
page,
|
||||
pageSize: pageSize,
|
||||
},
|
||||
@@ -86,7 +87,7 @@ export default function StorageResourcesPanel() {
|
||||
})
|
||||
.finally(() => sequence === requestSequence.current && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize, refreshKey]);
|
||||
}, [debouncedKeyword, debouncedUserQuery, kind, status, provider, page, pageSize, refreshKey]);
|
||||
|
||||
const columns = useMemo<ColumnsType<AdminStorageResource>>(
|
||||
() => [
|
||||
@@ -210,7 +211,7 @@ export default function StorageResourcesPanel() {
|
||||
placeholder="资源 ID 或对象路径"
|
||||
onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)}
|
||||
/>
|
||||
<Input aria-label="按用户 ID 筛选" autoComplete="off" allowClear className="w-48" value={userId} placeholder="用户" onChange={(event) => updateUrl({ userId: event.target.value, page: 1 }, true)} />
|
||||
<Input aria-label="按用户名、昵称、邮箱或用户 ID 筛选" autoComplete="off" allowClear className="w-64" value={userQuery} placeholder="用户名 / 昵称 / 邮箱 / 用户 ID" onChange={(event) => updateUrl({ user: event.target.value, userId: "", page: 1 }, true)} />
|
||||
<Select aria-label="筛选资源类型" className="w-32" value={kind} onChange={(value) => updateUrl({ kind: value, page: 1 })} options={kindOptions} />
|
||||
<Select aria-label="筛选资源状态" className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={statusOptions} />
|
||||
<Select aria-label="筛选存储类型" className="w-36" value={provider} onChange={(value) => updateUrl({ provider: value, page: 1 })} options={providerOptions} />
|
||||
@@ -219,14 +220,14 @@ export default function StorageResourcesPanel() {
|
||||
toolbarActiveFilters={
|
||||
<>
|
||||
{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}
|
||||
{userId ? <AdminFilterChip label={`用户:${userId}`} onRemove={() => updateUrl({ userId: "", page: 1 })} /> : null}
|
||||
{userQuery ? <AdminFilterChip label={`用户:${userQuery}`} onRemove={() => updateUrl({ user: "", userId: "", page: 1 })} /> : null}
|
||||
{kind !== "all" ? <AdminFilterChip label={`类型:${kindLabel(kind)}`} onRemove={() => updateUrl({ kind: "all", page: 1 })} /> : null}
|
||||
{status !== "all" ? <AdminFilterChip label={`状态:${statusLabel(status)}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}
|
||||
{provider !== "all" ? <AdminFilterChip label={`存储:${providerLabel(provider)}`} onRemove={() => updateUrl({ provider: "all", page: 1 })} /> : null}
|
||||
</>
|
||||
}
|
||||
toolbarActive={hasFilters}
|
||||
onReset={() => updateUrl({ filter: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
||||
onReset={() => updateUrl({ filter: "", user: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
||||
batchActions={
|
||||
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
|
||||
<Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deleting} onClick={() => confirmDelete(selectedIds)}>
|
||||
|
||||
@@ -49,6 +49,7 @@ export type AdminResourceQuery = {
|
||||
kind?: string;
|
||||
status?: string;
|
||||
provider?: string;
|
||||
user?: string;
|
||||
userId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
|
||||
@@ -248,7 +248,9 @@ test("admin tables keep requested filters and actions in the intended positions"
|
||||
const storageToolbar = sourceSection(storageSource, "toolbar={", "toolbarActiveFilters=");
|
||||
expect(storageToolbar).toContain('className="admin-storage-resource-filters"');
|
||||
expect(storageToolbar).toContain('placeholder="资源 ID 或对象路径"');
|
||||
expect(storageToolbar).toContain('placeholder="用户"');
|
||||
expect(storageToolbar).toContain('placeholder="用户名 / 昵称 / 邮箱 / 用户 ID"');
|
||||
expect(storageSource).toContain("user: debouncedUserQuery || undefined");
|
||||
expect(storageSource).toContain("userId: debouncedUserQuery || undefined");
|
||||
expect(storageToolbar).toContain('aria-label="筛选资源类型"');
|
||||
expect(storageToolbar).toContain('aria-label="筛选资源状态"');
|
||||
expect(storageToolbar).toContain('aria-label="筛选存储类型"');
|
||||
|
||||
12
web/test/agent-live2d-lifecycle.test.ts
Normal file
12
web/test/agent-live2d-lifecycle.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import panelSource from "../src/components/canvas/canvas-cloud-agent-panel.tsx" with { type: "text" };
|
||||
import partsSource from "../src/components/canvas/canvas-cloud-agent-panel-parts.tsx" with { type: "text" };
|
||||
|
||||
test("Live2D launcher remains mounted while the Agent panel is open", () => {
|
||||
expect(panelSource).toContain("<AgentLauncher");
|
||||
expect(panelSource).toContain("hidden={open}");
|
||||
expect(panelSource).not.toContain("{!open ? <AgentLauncher");
|
||||
expect(partsSource).toContain("hidden = false");
|
||||
expect(partsSource).toContain('display: "none"');
|
||||
});
|
||||
Reference in New Issue
Block a user