feat(channels): add drag mode for channel priority ordering

Support priority ordering in table and card views with automatic priority calculation, same-tier and between-tier drop targets, live previews, conflict detection, and undo.

Keep drag mode off by default and place its toggle before Batch Operations. Consolidate sorting controls and preserve the shared visibility and view controls.
This commit is contained in:
CaIon committed 2026-10-17 20:19:41 +08:00
1 parent 5a59d69153
commit 5e9595190e
33 files changed
+3216 -192

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
package controller
import (
"errors"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func GetChannelPriorityOrder(c *gin.Context) {
order, err := model.GetChannelPriorityOrder()
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": order})
}
func ReorderChannelPriority(c *gin.Context) {
var move model.ChannelPriorityMove
if err := common.DecodeJson(c.Request.Body, &move); err != nil {
common.ApiErrorT(c, "Invalid channel priority move")
return
}
order, err := model.ReorderChannelPriority(move)
if err != nil {
if errors.Is(err, model.ErrChannelPriorityConflict) || errors.Is(err, model.ErrChannelPriorityInvalid) || errors.Is(err, model.ErrChannelPriorityRange) {
common.ApiErrorT(c, err.Error())
} else {
common.ApiError(c, err)
}
return
}
if !move.Preview && len(order.Changes) > 0 {
model.InitChannelCache()
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": order})
}
+9 -4
View File
@@ -105,10 +105,15 @@ func NewChannelSortOptions(sortBy string, sortOrder string, idSort bool) Channel
func (options ChannelSortOptions) Apply(query *gorm.DB) *gorm.DB {
if columnName, ok := channelSortColumns[options.SortBy]; ok {
column := clause.Column{Name: columnName}
if columnName == "priority" {
// Routing treats a missing priority as zero on every database.
column = clause.Column{Name: "COALESCE(priority, 0)", Raw: true}
}
return query.Order(clause.OrderByColumn{
Column: clause.Column{Name: columnName},
Column: column,
Desc: options.SortOrder != "asc",
})
}).Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true})
}
if options.IDSort {
return query.Order(clause.OrderByColumn{
@@ -117,9 +122,9 @@ func (options ChannelSortOptions) Apply(query *gorm.DB) *gorm.DB {
})
}
return query.Order(clause.OrderByColumn{
Column: clause.Column{Name: "priority"},
Column: clause.Column{Name: "COALESCE(priority, 0)", Raw: true},
Desc: true,
})
}).Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true})
}
func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) ChannelSortOptions {
+244
View File
@@ -0,0 +1,244 @@
package model
import (
"crypto/sha256"
"errors"
"fmt"
"slices"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// Keep priorities lossless in the web console's JSON number representation.
const maxSortablePriority int64 = 1<<53 - 1
var ErrChannelPriorityConflict = errors.New("Channel priorities changed. Refresh and try again.")
var ErrChannelPriorityInvalid = errors.New("Invalid channel priority move")
var ErrChannelPriorityRange = errors.New("Channel priority is outside the sortable range")
type ChannelPriority struct {
ID int `json:"id"`
Priority *int64 `json:"priority"`
}
func (item ChannelPriority) value() int64 {
if item.Priority == nil {
return 0
}
return *item.Priority
}
type ChannelPriorityChange struct {
ID int `json:"id"`
Previous *int64 `json:"previous"`
Priority *int64 `json:"priority"`
}
type ChannelPriorityOrder struct {
Revision string `json:"revision"`
Items []ChannelPriority `json:"items"`
Changes []ChannelPriorityChange `json:"changes"`
}
type ChannelPriorityMove struct {
Revision string `json:"revision"`
ID int `json:"id"`
TargetID int `json:"target_id"`
Position string `json:"position"` // before / after use descending priority order
Preview bool `json:"preview"`
Restore []ChannelPriorityChange `json:"restore,omitempty"`
}
func channelPriorityRevision(items []ChannelPriority) string {
data, _ := common.Marshal(items)
return fmt.Sprintf("%x", sha256.Sum256(data))
}
func GetChannelPriorityOrder() (ChannelPriorityOrder, error) {
result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}}
err := DB.Model(&Channel{}).Select("id", "priority").Order("id").Find(&result.Items).Error
result.Revision = channelPriorityRevision(result.Items)
return result, err
}
// ReorderChannelPriority updates only priorities, including the routing copy in
// abilities. A snapshot check also protects preview/commit and undo from stale
// clients; row locks serialize concurrent reorders on MySQL and PostgreSQL.
func ReorderChannelPriority(move ChannelPriorityMove) (ChannelPriorityOrder, error) {
result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}}
err := DB.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&Channel{}).Select("id", "priority").Order("id")
if !move.Preview {
query = lockForUpdate(query)
}
if err := query.Find(&result.Items).Error; err != nil {
return err
}
if move.Revision == "" || move.Revision != channelPriorityRevision(result.Items) {
return ErrChannelPriorityConflict
}
changes, err := planChannelPriorityMove(result.Items, move)
if err != nil {
return err
}
result.Changes = changes
byID := make(map[int]*ChannelPriority, len(result.Items))
for i := range result.Items {
byID[result.Items[i].ID] = &result.Items[i]
}
for _, change := range changes {
byID[change.ID].Priority = change.Priority
if move.Preview {
continue
}
if err := tx.Model(&Channel{}).Where("id = ?", change.ID).Update("priority", change.Priority).Error; err != nil {
return err
}
if err := tx.Model(&Ability{}).Where("channel_id = ?", change.ID).Update("priority", change.Priority).Error; err != nil {
return err
}
}
result.Revision = channelPriorityRevision(result.Items)
return nil
})
return result, err
}
// Equal-priority channels remain together. Opening a slot shifts the shorter
// contiguous run of occupied integer priorities, measured by affected channels.
func planChannelPriorityMove(items []ChannelPriority, move ChannelPriorityMove) ([]ChannelPriorityChange, error) {
changes := []ChannelPriorityChange{}
byID := make(map[int]ChannelPriority, len(items))
for _, item := range items {
if item.value() < -maxSortablePriority || item.value() > maxSortablePriority {
return nil, ErrChannelPriorityRange
}
byID[item.ID] = item
}
if len(move.Restore) > 0 {
if move.ID != 0 || move.TargetID != 0 || move.Position != "" {
return nil, ErrChannelPriorityInvalid
}
seen := make(map[int]bool, len(move.Restore))
for _, change := range move.Restore {
item, ok := byID[change.ID]
if !ok || seen[change.ID] || (change.Priority != nil && (*change.Priority < -maxSortablePriority || *change.Priority > maxSortablePriority)) {
return nil, ErrChannelPriorityInvalid
}
if !equalPriority(item.Priority, change.Previous) {
return nil, ErrChannelPriorityConflict
}
seen[change.ID] = true
if !equalPriority(item.Priority, change.Priority) {
changes = append(changes, change)
}
}
return changes, nil
}
item, ok := byID[move.ID]
target, targetOK := byID[move.TargetID]
if !ok || !targetOK || move.ID == move.TargetID || !slices.Contains([]string{"before", "after", "same"}, move.Position) {
return nil, ErrChannelPriorityInvalid
}
values := make(map[int]int64, len(items))
for _, entry := range items {
values[entry.ID] = entry.value()
}
if move.Position == "same" {
values[item.ID] = target.value()
} else {
counts := make(map[int64]int)
for _, entry := range items {
if entry.ID != item.ID {
counts[entry.value()]++
}
}
levels := make([]int64, 0, len(counts))
for level := range counts {
levels = append(levels, level)
}
slices.SortFunc(levels, func(a, b int64) int {
if a > b {
return -1
}
if a < b {
return 1
}
return 0
})
index := slices.Index(levels, target.value())
if move.Position == "after" {
index++
}
hi, lo := maxSortablePriority+1, -maxSortablePriority-1
if index > 0 {
hi = levels[index-1]
}
if index < len(levels) {
lo = levels[index]
}
// Dropping into the channel's existing slot must not renumber it.
if item.value() < hi && item.value() > lo {
return changes, nil
}
if hi-lo < 2 {
up, down := index-1, index
upCost, downCost := 0, 0
for up >= 0 {
upCost += counts[levels[up]]
if (up == 0 && levels[up] < maxSortablePriority) || (up > 0 && levels[up-1]-levels[up] > 1) {
break
}
up--
}
for down < len(levels) {
downCost += counts[levels[down]]
if (down == len(levels)-1 && levels[down] > -maxSortablePriority) || (down+1 < len(levels) && levels[down]-levels[down+1] > 1) {
break
}
down++
}
canUp, canDown := up >= 0 && index > 0, down < len(levels) && index < len(levels)
if !canUp && !canDown {
return nil, ErrChannelPriorityRange
}
if canUp && (!canDown || upCost < downCost) {
for id, value := range values {
if id != item.ID && value >= levels[index-1] && value <= levels[up] {
values[id]++
}
}
hi++
} else {
for id, value := range values {
if id != item.ID && value <= levels[index] && value >= levels[down] {
values[id]--
}
}
lo--
}
}
value := lo + (hi-lo)/2
if index == 0 {
value = min(maxSortablePriority, lo+10)
} else if index == len(levels) {
value = max(-maxSortablePriority, hi-10)
}
values[item.ID] = value
}
for _, entry := range items {
value := values[entry.ID]
if value != entry.value() {
changes = append(changes, ChannelPriorityChange{ID: entry.ID, Previous: entry.Priority, Priority: &value})
}
}
return changes, nil
}
func equalPriority(a, b *int64) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
+209
View File
@@ -0,0 +1,209 @@
package model
import (
"errors"
"os"
"sync"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestChannelPriorityPlan(t *testing.T) {
for _, tc := range []struct {
name string
values []int64
id, target int
position string
want map[int]int64
}{
{"midpoint", []int64{100, 80, 0}, 3, 2, "before", map[int]int64{3: 90}},
{"top preserves peers", []int64{0, 0, 0}, 3, 1, "before", map[int]int64{3: 10}},
{"bottom", []int64{0, 0, 0}, 1, 3, "after", map[int]int64{1: -10}},
{"same tier", []int64{100, 80, 0}, 3, 2, "same", map[int]int64{3: 80}},
{"same slot", []int64{100, 80, 0}, 2, 1, "after", map[int]int64{}},
{"same tier no op", []int64{0, 0}, 1, 2, "same", map[int]int64{}},
{"shift smaller upper tier", []int64{100, 99, 99, 0}, 4, 2, "before", map[int]int64{1: 101, 4: 100}},
{"shift smaller lower tier", []int64{100, 100, 99, 0}, 4, 3, "before", map[int]int64{3: 98, 4: 99}},
{"shift complete adjacent tiers", []int64{102, 101, 100, 99, 0}, 5, 3, "after", map[int]int64{4: 98, 5: 99}},
{"upper safe bound", []int64{maxSortablePriority, 0}, 2, 1, "before", map[int]int64{1: maxSortablePriority - 1, 2: maxSortablePriority}},
{"lower safe bound", []int64{-maxSortablePriority, 0}, 2, 1, "after", map[int]int64{1: -maxSortablePriority + 1, 2: -maxSortablePriority}},
} {
t.Run(tc.name, func(t *testing.T) {
items := make([]ChannelPriority, len(tc.values))
for i, value := range tc.values {
items[i] = ChannelPriority{ID: i + 1, Priority: &value}
}
changes, err := planChannelPriorityMove(items, ChannelPriorityMove{ID: tc.id, TargetID: tc.target, Position: tc.position})
require.NoError(t, err)
got := map[int]int64{}
for _, change := range changes {
require.NotNil(t, change.Priority)
got[change.ID] = *change.Priority
}
assert.Equal(t, tc.want, got)
})
}
_, err := planChannelPriorityMove([]ChannelPriority{{ID: 1}, {ID: 2}}, ChannelPriorityMove{ID: 1, TargetID: 2, Position: "invalid"})
assert.ErrorIs(t, err, ErrChannelPriorityInvalid)
}
func TestChannelPriorityDatabaseMatrix(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver gorm.Dialector
switch dialect {
case "sqlite":
driver = sqlite.Open(":memory:")
case "mysql":
dsn := os.Getenv("TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("TEST_MYSQL_DSN not configured")
}
driver = mysql.Open(dsn)
case "postgres":
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN not configured")
}
driver = postgres.Open(dsn)
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "priority_test_"}})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
previousDB, previousType := DB, common.MainDatabaseType()
DB = db
common.SetMainDatabaseType(common.DatabaseType(dialect))
initCol()
t.Cleanup(func() {
require.NoError(t, db.Migrator().DropTable(&Ability{}, &Channel{}))
DB = previousDB
common.SetMainDatabaseType(previousType)
initCol()
require.NoError(t, sqlDB.Close())
})
require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{}))
var version string
query := "SELECT version()"
if dialect == "sqlite" {
query = "SELECT sqlite_version()"
}
require.NoError(t, db.Raw(query).Scan(&version).Error)
t.Logf("database version: %s", version)
high, low, zero := int64(100), int64(99), int64(0)
w1, w2, w3, w4 := uint(11), uint(22), uint(33), uint(44)
channels := []Channel{
{Name: "high", Priority: &high, Weight: &w1, Key: "preserved", Status: 1},
{Name: "low", Priority: &low, Weight: &w2, Status: 1},
{Name: "peer", Priority: &low, Weight: &w3, Status: 1},
{Name: "moving", Priority: &zero, Weight: &w4, Status: 1},
}
require.NoError(t, db.Create(&channels).Error)
for _, channel := range channels {
for _, group := range []string{"default", "second"} {
require.NoError(t, db.Create(&Ability{Group: group, Model: "test", ChannelId: channel.Id, Priority: channel.Priority, Weight: uint(*channel.Weight), Enabled: true}).Error)
}
}
initial, err := GetChannelPriorityOrder()
require.NoError(t, err)
move := ChannelPriorityMove{Revision: initial.Revision, ID: channels[3].Id, TargetID: channels[1].Id, Position: "before", Preview: true}
preview, err := ReorderChannelPriority(move)
require.NoError(t, err)
require.Len(t, preview.Changes, 2)
unchanged, err := GetChannelPriorityOrder()
require.NoError(t, err)
assert.Equal(t, initial, unchanged, "preview does not persist")
move.Preview = false
applied, err := ReorderChannelPriority(move)
require.NoError(t, err)
assert.Equal(t, preview, applied)
for _, change := range applied.Changes {
var abilities []Ability
require.NoError(t, db.Where("channel_id = ?", change.ID).Find(&abilities).Error)
require.Len(t, abilities, 2)
for _, ability := range abilities {
assert.Equal(t, change.Priority, ability.Priority)
assert.True(t, ability.Enabled)
}
}
var saved Channel
require.NoError(t, db.First(&saved, channels[0].Id).Error)
assert.Equal(t, "preserved", saved.Key)
assert.Equal(t, channels[0].Weight, saved.Weight)
_, err = ReorderChannelPriority(move)
assert.ErrorIs(t, err, ErrChannelPriorityConflict)
undo := ChannelPriorityMove{Revision: applied.Revision}
for _, change := range applied.Changes {
undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous})
}
restored, err := ReorderChannelPriority(undo)
require.NoError(t, err)
assert.Equal(t, initial.Items, restored.Items)
assert.Equal(t, initial.Revision, restored.Revision)
if dialect != "sqlite" {
sqlDB.SetMaxOpenConns(2)
}
start := make(chan struct{})
errorsCh := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Go(func() { <-start; _, moveErr := ReorderChannelPriority(move); errorsCh <- moveErr })
}
close(start)
workers.Wait()
close(errorsCh)
succeeded, conflicted := 0, 0
for moveErr := range errorsCh {
if moveErr == nil {
succeeded++
} else {
require.ErrorIs(t, moveErr, ErrChannelPriorityConflict)
conflicted++
}
}
assert.Equal(t, 1, succeeded)
assert.Equal(t, 1, conflicted)
_, err = ReorderChannelPriority(undo)
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
// An ability write failure must roll back every channel change too.
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("priority_test_failure", func(tx *gorm.DB) {
if tx.Statement.Table == "priority_test_abilities" {
tx.AddError(errors.New("forced routing write failure"))
}
}))
_, err = ReorderChannelPriority(move)
require.ErrorContains(t, err, "forced routing write failure")
require.NoError(t, db.Callback().Update().Remove("priority_test_failure"))
unchanged, err = GetChannelPriorityOrder()
require.NoError(t, err)
assert.Equal(t, initial, unchanged)
// Null priority is an effective zero and undo must restore the null itself.
require.NoError(t, db.Model(&Channel{}).Where("id = ?", channels[3].Id).Update("priority", nil).Error)
nullable, err := GetChannelPriorityOrder()
require.NoError(t, err)
var sorted []ChannelPriority
require.NoError(t, NewChannelSortOptions("priority", "asc", false).Apply(db.Model(&Channel{}).Select("id", "priority")).Find(&sorted).Error)
assert.Equal(t, []int{channels[3].Id, channels[2].Id, channels[1].Id, channels[0].Id}, []int{sorted[0].ID, sorted[1].ID, sorted[2].ID, sorted[3].ID})
move.Revision = nullable.Revision
applied, err = ReorderChannelPriority(move)
require.NoError(t, err)
undo = ChannelPriorityMove{Revision: applied.Revision}
for _, change := range applied.Changes {
undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous})
}
restored, err = ReorderChannelPriority(undo)
require.NoError(t, err)
assert.Equal(t, nullable.Items, restored.Items)
})
}
}
+2
View File
@@ -55,6 +55,8 @@ func registerChannelRoutes(apiRouter *gin.RouterGroup) {
}
var channelPermissionRoutes = []permissionRoute{
{method: http.MethodGet, path: "/priority/order", permission: authz.ChannelRead, handler: controller.GetChannelPriorityOrder},
{method: http.MethodPost, path: "/priority/reorder", permission: authz.ChannelWrite, handler: controller.ReorderChannelPriority},
{method: http.MethodGet, path: "/", permission: authz.ChannelRead, handler: controller.GetAllChannels},
{method: http.MethodGet, path: "/search", permission: authz.ChannelRead, handler: controller.SearchChannels},
{method: http.MethodGet, path: "/models", permission: authz.ChannelRead, handler: controller.ChannelListModels},
+2
View File
@@ -25,6 +25,8 @@ func TestChannelDefaultBaseURLsRequireReadPermission(t *testing.T) {
}
func TestChannelStatusRoutesUseExpectedPermissions(t *testing.T) {
assertChannelRoutePermission(t, http.MethodGet, "/priority/order", authz.ChannelRead, controller.GetChannelPriorityOrder)
assertChannelRoutePermission(t, http.MethodPost, "/priority/reorder", authz.ChannelWrite, controller.ReorderChannelPriority)
assertChannelRoutePermission(t, http.MethodGet, "/:id/vllm/status", authz.ChannelRead, controller.GetVLLMChannelStatus)
assertChannelRoutePermission(t, http.MethodGet, "/:id/sglang/status", authz.ChannelRead, controller.GetSGLangChannelStatus)
assertChannelRoutePermission(t, http.MethodPost, "/:id/status", authz.ChannelOperate, controller.UpdateChannelStatus)
@@ -63,6 +63,7 @@ function DataTableRowInner<TData>({
return (
<TableRow
data-sortable-id={row.id}
data-state={isSelected ? 'selected' : undefined}
className={className}
{...rowProps}
@@ -183,6 +183,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
<div
key={key}
data-slot='data-table-card'
data-sortable-id={row.id}
data-state={isSelected ? 'selected' : undefined}
className={cn(
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
@@ -98,7 +98,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</h2>
</div>
{actions != null && (
<div className='flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
<div className='flex max-w-full shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
{actions}
</div>
)}
+19
View File
@@ -20,6 +20,7 @@ import { getGroups as getUserGroups } from '@/features/users/api'
import { api, type ApiRequestConfig } from '@/lib/api'
import { requireServerSuccess } from '@/lib/server-error-message'
import type { PriorityMove, PriorityOrder } from './lib/channel-priority-order'
import type { InferenceStatus } from './lib/inference-status'
import type {
AddChannelRequest,
@@ -50,6 +51,24 @@ const channelActionConfig = (
skipErrorHandler: true,
})
export async function getChannelPriorityOrder(): Promise<PriorityOrder> {
const response = await api.get<{ success: boolean; data: PriorityOrder }>(
'/api/channel/priority/order',
{ disableDuplicate: true }
)
return requireServerSuccess(response.data).data
}
export async function reorderChannelPriority(
move: PriorityMove
): Promise<PriorityOrder> {
const response = await api.post<{ success: boolean; data: PriorityOrder }>(
'/api/channel/priority/reorder',
move
)
return requireServerSuccess(response.data).data
}
export async function getInferenceStatus(
channelId: number,
provider: 'vllm' | 'sglang',
@@ -0,0 +1,728 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import type {
PriorityMove,
PriorityOrder,
} from '../../lib/channel-priority-order'
import type { Channel } from '../../types'
import { ChannelsPrimaryButtons } from '../channels-primary-buttons'
import { ChannelsProvider } from '../channels-provider'
import { ChannelsTable } from '../channels-table'
const initial: PriorityOrder = {
revision: 'initial',
items: [
{ id: 1, priority: 100 },
{ id: 2, priority: 80 },
{ id: 3, priority: 0 },
],
changes: [],
}
const applied: PriorityOrder = {
revision: 'applied',
items: [
{ id: 1, priority: 100 },
{ id: 2, priority: 80 },
{ id: 3, priority: 90 },
],
changes: [{ id: 3, previous: 0, priority: 90 }],
}
let current: PriorityOrder
let requests: PriorityMove[]
let failSave: boolean
const pointerMethods = [
'setPointerCapture',
'releasePointerCapture',
'hasPointerCapture',
] as const
const originalPointerMethods = pointerMethods.map(
(name) =>
[
name,
Object.getOwnPropertyDescriptor(HTMLElement.prototype, name),
] as const
)
afterEach(() => {
vi.unstubAllGlobals()
for (const [name, descriptor] of originalPointerMethods) {
if (descriptor) {
Object.defineProperty(HTMLElement.prototype, name, descriptor)
} else Reflect.deleteProperty(HTMLElement.prototype, name)
}
localStorage.clear()
})
function channel(item: { id: number; priority: number | null }): Channel {
return {
id: item.id,
priority: item.priority,
name: `Channel ${item.id}`,
type: 1,
status: 1,
group: 'default',
models: 'test-model',
weight: 10,
balance: 0,
response_time: 0,
test_time: 0,
channel_info: {},
} as Channel
}
beforeEach(() => {
current = initial
requests = []
failSave = false
localStorage.clear()
useAuthStore.getState().auth.setUser({
id: 1,
username: 'test',
display_name: 'Test',
role: 100,
group: 'default',
})
vi.spyOn(api, 'get').mockImplementation(async (url, config) => {
if (url === '/api/channel/priority/order') {
return { data: { success: true, data: current } }
}
if (url === '/api/channel' || url === '/api/channel/search') {
const field = config?.params?.sort_by ?? 'priority'
const desc = config?.params?.sort_order !== 'asc'
const items = current.items
.map(channel)
.sort(
(a, b) =>
((Number(a[field as keyof Channel]) || 0) -
(Number(b[field as keyof Channel]) || 0)) *
(desc ? -1 : 1) || b.id - a.id
)
return {
data: {
success: true,
data: { items, total: 3, type_counts: { 1: 3 } },
},
}
}
if (url === '/api/group/') {
return { data: { success: true, data: ['default'] } }
}
return { data: { success: true, data: [] } }
})
vi.spyOn(api, 'post').mockImplementation(async (url, body) => {
if (url !== '/api/channel/priority/reorder') {
return { data: { success: true } }
}
const move = body as PriorityMove
requests.push(move)
if (failSave && !move.preview) {
return {
data: {
success: false,
message: 'Channel priorities changed. Refresh and try again.',
},
}
}
const joined: PriorityOrder = {
revision: 'joined',
items: [
{ id: 1, priority: 100 },
{ id: 2, priority: 80 },
{ id: 3, priority: 80 },
],
changes: [{ id: 3, previous: 0, priority: 80 }],
}
let result = applied
if (move.restore) result = initial
else if (move.position === 'same') result = joined
if (!move.preview) current = result
return { data: { success: true, data: result } }
})
})
async function mount(view: 'table' | 'card' = 'card', enabled = true) {
localStorage.setItem('channels:view-mode', view)
const root = createRootRoute()
const authenticated = createRoute({
getParentRoute: () => root,
id: '_authenticated',
})
const channels = createRoute({
getParentRoute: () => authenticated,
path: '/channels/',
validateSearch: (search) => ({ page: 1, pageSize: 10, ...search }),
component: () => (
<ChannelsProvider>
<header>
<ChannelsPrimaryButtons />
</header>
<ChannelsTable />
</ChannelsProvider>
),
})
const router = createRouter({
routeTree: root.addChildren([authenticated.addChildren([channels])]),
history: createMemoryHistory({ initialEntries: ['/channels/'] }),
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
)
await screen.findByRole('button', { name: 'Drag mode' })
if (enabled) {
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled()
)
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Drag mode' }))
await screen.findByRole('button', { name: 'Reorder channel 3' })
}
return { client, router }
}
function orderInView() {
return [...document.querySelectorAll<HTMLElement>('[data-sortable-id]')].map(
(element) => element.dataset.sortableId
)
}
test.each(['table', 'card'] as const)(
'%s keyboard reorder saves priority and undo restores it',
async (view) => {
await mount(view)
const user = userEvent.setup()
fireEvent.keyDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ key: 'ArrowUp' }
)
await screen.findByRole('button', { name: 'Undo' })
await waitFor(() =>
expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2'])
)
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
])
await user.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() =>
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
)
expect(requests.at(-1)).toEqual({
revision: 'applied',
restore: [{ id: 3, previous: 90, priority: 0 }],
})
}
)
test('switching sort exits reorder mode without automatically reopening it', async () => {
await mount()
const user = userEvent.setup()
await user.click(screen.getByRole('combobox', { name: 'Sort channels by' }))
await user.click(await screen.findByRole('option', { name: /^ID$/ }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
)
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: 'Sort channels by' }))
await user.click(await screen.findByRole('option', { name: /^Priority$/ }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled()
)
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
expect(requests).toEqual([])
})
test('failed save restores the visible order and does not offer undo', async () => {
failSave = true
await mount()
fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), {
key: 'ArrowUp',
})
await waitFor(() => expect(requests).toHaveLength(1))
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Reorder channel 3' })
).toBeEnabled()
)
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument()
})
test('card and table views share the saved order', async () => {
await mount()
const user = userEvent.setup()
fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), {
key: 'ArrowUp',
})
await screen.findByRole('button', { name: 'Undo' })
const views = within(screen.getByRole('group', { name: 'View mode' }))
views.getByRole('button', { name: 'Card view' }).focus()
await user.tab()
expect(views.getByRole('button', { name: 'Table view' })).toHaveFocus()
await user.keyboard('{Enter}')
await screen.findByRole('table')
expect(views.getByRole('button', { name: 'Table view' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(views.getByRole('button', { name: 'Card view' })).toHaveAttribute(
'aria-pressed',
'false'
)
expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2'])
})
test('mobile view controls stay available with the filter panel collapsed', async () => {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: query === '(max-width: 640px)',
}))
await mount('card', false)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(
screen.queryByPlaceholderText('Filter by name, ID, or key...')
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Table view' }))
expect(await screen.findByRole('table')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'View' }))
expect(
await screen.findByRole('menuitemcheckbox', { name: 'Weight' })
).toBeVisible()
})
function pointerGeometry() {
class TestPointerEvent extends MouseEvent {
pointerId: number
constructor(type: string, init: PointerEventInit) {
super(type, init)
this.pointerId = init.pointerId ?? 1
}
}
vi.stubGlobal('PointerEvent', TestPointerEvent)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function (this: HTMLElement) {
if (this.dataset.sortableId) {
const index = [
...document.querySelectorAll('[data-sortable-id]'),
].indexOf(this)
if (this.tagName === 'TR') {
return new DOMRect(0, 100 + index * 60, 1000, 60)
}
return new DOMRect(
(index % 3) * 320,
100 + Math.floor(index / 3) * 220,
300,
200
)
}
return new DOMRect(0, 0, 1200, 800)
}
)
for (const name of [
'setPointerCapture',
'releasePointerCapture',
'hasPointerCapture',
]) {
Object.defineProperty(HTMLElement.prototype, name, {
configurable: true,
value: () => false,
})
}
}
test('a quick card pointer drag saves even when released before an animation frame', async () => {
await mount()
pointerGeometry()
const handle = screen.getByRole('button', { name: 'Reorder channel 3' })
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(handle, {
pointerId: 1,
button: 0,
clientX: 660,
clientY: 130,
})
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 340, clientY: 150 })
fireEvent.lostPointerCapture(scope, { pointerId: 1 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
])
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
vi.unstubAllGlobals()
})
test('Escape cancels a card drag without saving and removes its ghost', async () => {
await mount()
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
)
fireEvent.keyDown(window, { key: 'Escape' })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
expect(requests.filter((request) => !request.preview)).toEqual([])
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
vi.unstubAllGlobals()
})
test('moving from a card edge to its center joins the tier despite the moving placeholder', async () => {
await mount()
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
)
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
expect(requests.every((request) => request.preview)).toBe(true)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 470, clientY: 150 })
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 470, clientY: 150 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
])
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
vi.unstubAllGlobals()
})
test('tag grouping disables reorder mode', async () => {
localStorage.setItem('enable-tag-mode', 'true')
await mount('card', false)
expect(
screen.getByRole('button', {
name: 'Drag mode',
})
).toBeDisabled()
expect(requests).toEqual([])
})
test.each(['table', 'card'] as const)(
'%s drag handles are hidden by default',
async (view) => {
await mount(view, false)
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
'aria-pressed',
'false'
)
}
)
test.each(['table', 'card'] as const)(
'%s reordering can be explicitly enabled and finished',
async (view) => {
await mount(view, false)
const user = userEvent.setup()
screen.getByRole('button', { name: 'Drag mode' }).focus()
await user.keyboard('{Enter}')
expect(
screen.getByRole('button', { name: 'Reorder channel 3' })
).toBeEnabled()
expect(
screen.getByRole('button', { name: 'Finish reordering' })
).toHaveAttribute('aria-pressed', 'true')
await user.click(screen.getByRole('button', { name: 'Finish reordering' }))
expect(
screen.queryByRole('button', { name: 'Reorder channel 3' })
).not.toBeInTheDocument()
expect(requests).toEqual([])
expect(api.get).not.toHaveBeenCalledWith('/api/channel/priority/order')
}
)
test('the page header provides the single sorting control without an ID switch', async () => {
await mount('card', false)
expect(
screen.queryByRole('switch', { name: 'Sort by ID' })
).not.toBeInTheDocument()
expect(
screen.getAllByRole('combobox', { name: 'Sort channels by' })
).toHaveLength(1)
expect(
within(screen.getByRole('banner')).getByRole('combobox', {
name: 'Sort channels by',
})
).toBeEnabled()
expect(
within(screen.getByRole('region', { name: 'Channels' })).queryByRole(
'combobox',
{ name: 'Sort channels by' }
)
).not.toBeInTheDocument()
})
test('the header places reorder before batch mode while shared display controls stay in the table toolbar', async () => {
await mount('card', false)
const user = userEvent.setup()
const header = within(screen.getByRole('banner'))
const toolbar = within(screen.getByRole('region', { name: 'Channels' }))
const reorder = header.getByRole('button', { name: 'Drag mode' })
reorder.focus()
await user.tab()
expect(header.getByRole('switch', { name: 'Batch Operations' })).toHaveFocus()
expect(
toolbar.queryByRole('button', { name: 'Drag mode' })
).not.toBeInTheDocument()
expect(toolbar.getByRole('button', { name: 'Hide' })).toBeVisible()
expect(toolbar.getByRole('group', { name: 'View mode' })).toBeVisible()
})
test('the header honors the saved ID preference and changes the shared sort direction', async () => {
localStorage.setItem('channels-id-sort', 'true')
await mount('card', false)
const header = within(screen.getByRole('banner'))
expect(
header.getByRole('combobox', { name: 'Sort channels by' })
).toHaveTextContent('ID')
await waitFor(() =>
expect(orderInView()).toEqual(['channel:3', 'channel:2', 'channel:1'])
)
const sort = within(header.getByRole('group', { name: 'Sort channels by' }))
sort.getByRole('combobox', { name: 'Sort channels by' }).focus()
const user = userEvent.setup()
await user.tab()
expect(sort.getByRole('button', { name: 'Descending' })).toHaveFocus()
await user.keyboard('{Enter}')
await waitFor(() =>
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
)
expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
})
test('the compact more-actions button opens by keyboard and restores focus on Escape', async () => {
await mount('card', false)
const user = userEvent.setup()
const more = within(screen.getByRole('banner')).getByRole('button', {
name: 'More actions',
})
more.focus()
await user.keyboard('{Enter}')
expect(more).toHaveAttribute('aria-expanded', 'true')
expect(
await screen.findByRole('menuitem', { name: 'Test All Channels' })
).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() => expect(more).toHaveFocus())
expect(more).toHaveAttribute('aria-expanded', 'false')
})
test('sorting a table column updates the header selection and remains active in card view', async () => {
await mount('table', false)
const user = userEvent.setup()
await user.click(
within(screen.getByRole('table')).getByRole('button', {
name: 'Name',
})
)
await user.click(await screen.findByRole('menuitem', { name: 'Asc' }))
const header = within(screen.getByRole('banner'))
expect(
header.getByRole('combobox', { name: 'Sort channels by' })
).toHaveTextContent('Name')
expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Card view' }))
expect(
header.getByRole('combobox', { name: 'Sort channels by' })
).toHaveTextContent('Name')
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
})
test('dropping onto a card that moved into the original slot joins its priority tier', async () => {
await mount()
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
)
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 150 })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
)
// Channel 2 now occupies the last slot that channel 3 originally occupied.
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 790, clientY: 150 })
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(
'Join the same priority tier'
)
)
expect(
document.querySelector(
'[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]'
)
).toHaveTextContent('80')
expect(
document.querySelector('[data-sortable-id="channel:2"]')
).toHaveAttribute('data-priority-drop', 'same')
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 790, clientY: 150 })
await screen.findByRole('button', { name: 'Undo' })
expect(requests.filter((request) => !request.preview)).toEqual([
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
])
})
test.each(['table', 'card'] as const)(
'%s keeps a held tier preview until Escape cancels it',
async (view) => {
await mount(view)
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
const source = document.querySelector(
'[data-sortable-id="channel:3"]'
) as HTMLElement
const start =
view === 'table'
? { clientX: 20, clientY: 250 }
: { clientX: 660, clientY: 130 }
const target =
view === 'table'
? { clientX: 20, clientY: 190 }
: { clientX: 470, clientY: 150 }
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, ...start }
)
fireEvent.pointerMove(scope, { pointerId: 1, ...target })
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(
'Join the same priority tier'
)
)
expect(source.isConnected).toBe(true)
expect(source).toHaveStyle({ opacity: '0.2' })
expect(
document.querySelector(
'[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]'
)
).toHaveTextContent('80')
expect(
document.querySelector('[data-sortable-id="channel:2"]')
).toHaveAttribute('data-priority-drop', 'same')
fireEvent.keyDown(window, { key: 'Escape' })
await waitFor(() =>
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
)
expect(
document.querySelector('[data-priority-drop]')
).not.toBeInTheDocument()
expect(source.style.opacity).toBe('')
expect(source.style.transform).toBe('')
expect(requests.filter((request) => !request.preview)).toEqual([])
}
)
test.each(['table', 'card'] as const)(
'%s distinguishes inserting a new tier from joining the target tier before release',
async (view) => {
await mount(view)
pointerGeometry()
const scope = screen.getByRole('region', { name: 'Channels' })
const start =
view === 'table'
? { clientX: 20, clientY: 250 }
: { clientX: 660, clientY: 130 }
const edge =
view === 'table'
? { clientX: 20, clientY: 165 }
: { clientX: 340, clientY: 150 }
const center =
view === 'table'
? { clientX: 20, clientY: 190 }
: { clientX: 470, clientY: 150 }
fireEvent.pointerDown(
screen.getByRole('button', { name: 'Reorder channel 3' }),
{ pointerId: 1, button: 0, ...start }
)
fireEvent.pointerMove(scope, { pointerId: 1, ...edge })
await screen.findByText('Release to insert a new priority tier')
expect(document.querySelector('[data-priority-insertion]')).toBeVisible()
expect(
document.querySelector('[data-priority-drop="same"]')
).not.toBeInTheDocument()
await screen.findByText('New priority: 90')
fireEvent.pointerMove(scope, { pointerId: 1, ...center })
await screen.findByText('Release to join priority 80')
expect(
screen.queryByText('Release to insert a new priority tier')
).not.toBeInTheDocument()
expect(
document.querySelector('[data-priority-insertion]')
).not.toBeVisible()
expect(
document.querySelector('[data-sortable-id="channel:2"]')
).toHaveAttribute('data-priority-drop', 'same')
fireEvent.keyDown(window, { key: 'Escape' })
await waitFor(() =>
expect(
screen.queryByText('Release to join priority 80')
).not.toBeInTheDocument()
)
}
)
@@ -144,7 +144,8 @@ it('refetches the channel list with the current filters and marks Refresh busy w
gate = new Promise((resolve) => {
release = resolve
})
await userEvent.click(refresh)
refresh.focus()
await userEvent.setup().keyboard('{Enter}')
await waitFor(() => expect(searchCalls()).toHaveLength(2))
expect(searchCalls()[1][1]?.params).toEqual(searchCalls()[0][1]?.params)
@@ -42,9 +42,11 @@ const SENSITIVE_MASK = '••••'
function ChannelCardComponent({
row,
isSelected,
reorderEnabled = false,
}: {
row: Row<Channel>
isSelected: boolean
reorderEnabled?: boolean
}) {
const { t } = useTranslation()
const { sensitiveVisible } = useChannels()
@@ -61,6 +63,7 @@ function ChannelCardComponent({
const groups = parseGroupsList(row.original.group ?? '')
const reorderCell = reorderEnabled ? renderCell('reorder') : null
const selectCell = renderCell('select')
const typeCell = renderCell('type')
const nameCell = renderCell('name')
@@ -91,6 +94,7 @@ function ChannelCardComponent({
{/* Row 1: selection + type, with status badge + actions menu */}
<div className='flex items-center justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{reorderCell}
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
@@ -114,7 +118,10 @@ function ChannelCardComponent({
)}
{nameCell}
</div>
<div className='flex min-w-0 flex-col gap-1'>
<div
data-column-id='priority'
className='flex min-w-0 flex-col gap-1'
>
<span className={labelClass}>{t('Priority')}</span>
{priorityCell}
</div>
@@ -0,0 +1,29 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createContext, type KeyboardEvent, type PointerEvent } from 'react'
type PriorityControls = {
reason: 'sort' | 'tag' | 'permission' | 'busy' | null
start: (event: PointerEvent<HTMLButtonElement>, id: number) => void
keyDown: (event: KeyboardEvent<HTMLButtonElement>, id: number) => void
}
export const ChannelPriorityContext = createContext<PriorityControls | null>(
null
)
@@ -0,0 +1,233 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowDown, ArrowUp, GripVertical, Undo2 } from 'lucide-react'
import { useContext, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { formatNumber } from '@/lib/format'
import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
import type { ChannelSortBy } from '../types'
import { ChannelPriorityContext } from './channel-priority-context'
export function ChannelPriorityHandle(props: { id: number }) {
const controls = useContext(ChannelPriorityContext)
const { t } = useTranslation()
if (!controls) return null
let hint = t(
'Drag to change priority. Use arrow keys to move, Escape to cancel.'
)
if (controls.reason === 'sort') hint = t('Sort by priority to drag channels')
if (controls.reason === 'tag') {
hint = t('Disable tag grouping to drag channels')
}
if (controls.reason === 'permission') {
hint = t('You do not have permission to edit channels')
}
if (controls.reason === 'busy') hint = t('Saving...')
return (
<Tooltip>
<TooltipTrigger render={<span className='inline-flex shrink-0' />}>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='text-muted-foreground cursor-grab touch-none active:cursor-grabbing'
disabled={!!controls.reason}
aria-label={
controls.reason === 'tag'
? hint
: t('Reorder channel {{id}}', { id: props.id })
}
onPointerDown={(event) => controls.start(event, props.id)}
onKeyDown={(event) => controls.keyDown(event, props.id)}
>
<GripVertical className='size-4' aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
)
}
export function ChannelSortControl(props: {
field: ChannelSortBy
desc: boolean
disabled?: boolean
onChange: (field: ChannelSortBy, desc: boolean) => void
}) {
const { t } = useTranslation()
const options = [
{ value: 'priority', label: t('Priority') },
{ value: 'id', label: t('ID') },
{ value: 'name', label: t('Name') },
{ value: 'balance', label: t('Balance') },
{ value: 'response_time', label: t('Response') },
{ value: 'test_time', label: t('Last Tested') },
]
return (
<ButtonGroup aria-label={t('Sort channels by')} className='shrink-0'>
<Select
value={props.field}
disabled={props.disabled}
onValueChange={(value) => {
if (value) props.onChange(value as ChannelSortBy, props.desc)
}}
items={options}
>
<SelectTrigger
aria-label={t('Sort channels by')}
className='bg-background min-w-24'
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type='button'
size='icon'
variant='outline'
disabled={props.disabled}
aria-label={props.desc ? t('Descending') : t('Ascending')}
title={props.desc ? t('Descending') : t('Ascending')}
onClick={() => props.onChange(props.field, !props.desc)}
>
{props.desc ? (
<ArrowDown aria-hidden className='size-4' />
) : (
<ArrowUp aria-hidden className='size-4' />
)}
</Button>
</ButtonGroup>
)
}
export function ChannelPriorityModeToggle(props: {
active: boolean
reason: 'sort' | 'tag' | 'permission' | 'busy' | null
onChange: () => void
}) {
const { t } = useTranslation()
let hint = t('Drag mode')
if (props.reason === 'sort') hint = t('Sort by priority to drag channels')
if (props.reason === 'tag') hint = t('Disable tag grouping to drag channels')
if (props.reason === 'permission') {
hint = t('You do not have permission to edit channels')
}
if (props.reason === 'busy') hint = t('Saving...')
return (
<Tooltip>
<TooltipTrigger render={<span className='inline-flex' />}>
<Button
type='button'
variant='outline'
className='text-muted-foreground aria-pressed:border-primary/30 aria-pressed:bg-primary/10 aria-pressed:text-primary aria-pressed:hover:bg-primary/15'
aria-pressed={props.active}
disabled={!!props.reason}
onClick={props.onChange}
>
<GripVertical className='size-4' aria-hidden />
{props.active ? t('Finish reordering') : t('Drag mode')}
</Button>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
)
}
export function ChannelPriorityStatus(props: {
order: ReturnType<typeof useChannelPriorityOrder>
}) {
const { t, i18n } = useTranslation()
const order = props.order
const hover = useSyncExternalStore(
order.previewStore.subscribe,
order.previewStore.getSnapshot
)
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
let message = ''
if (order.saving) message = t('Saving...')
else if (hover.preparing) message = t('Calculating priority...')
else if (hover.preview && hover.drop) {
const value =
hover.preview.items.find((item) => item.id === hover.drop?.id)
?.priority ?? 0
const visible = new Set(order.items.map((item) => item.id))
const hidden = hover.preview.changes.filter(
(item) => !visible.has(item.id)
).length
message = t('New priority: {{priority}} · {{count}} channels affected', {
priority: formatNumber(value, locale),
count: hover.preview.changes.length,
})
if (hidden) {
message += ` · ${t('{{count}} outside this view', { count: hidden })}`
}
if (hover.drop.position === 'same') {
message += ` · ${t('Join the same priority tier')}`
} else {
message += ` · ${t('Release to insert a new priority tier')}`
}
} else if (order.active) {
message = t(
'Drop between tiers to change priority, or in the center to join a tier'
)
}
if (!message && !order.undo?.changes.length) return null
return (
<div className='text-muted-foreground flex shrink-0 flex-wrap items-center justify-between gap-2 px-1 text-xs'>
<span role='status' aria-live='polite'>
{message || t('Channel priorities saved')}
</span>
{!!order.undo?.changes.length && (
<Button
type='button'
variant='ghost'
size='sm'
disabled={order.active || order.saving}
onClick={() => void order.restore()}
>
<Undo2 className='size-3.5' aria-hidden />
{t('Undo')}
</Button>
)}
</div>
)
}
@@ -0,0 +1,86 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowDownToLine, Layers } from 'lucide-react'
import { useSyncExternalStore } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { toIntlLocale } from '@/i18n/languages'
import { formatNumber } from '@/lib/format'
import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
import type { Channel } from '../types'
export function ChannelPriorityDropHint(props: {
container: HTMLElement | null
order: ReturnType<typeof useChannelPriorityOrder>
channels: Channel[]
}) {
const { t, i18n } = useTranslation()
const hover = useSyncExternalStore(
props.order.previewStore.subscribe,
props.order.previewStore.getSnapshot
)
if (!props.container || !hover.drop) return null
const joining = hover.drop.position === 'same'
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const targetPriority =
props.channels.find((item) => item.id === hover.drop?.target_id)
?.priority ?? 0
const priority =
hover.preview?.items.find((item) => item.id === hover.drop?.id)?.priority ??
targetPriority
let detail = t('Use the same priority as the highlighted channels')
if (!joining) {
detail = hover.preview
? t('New priority: {{priority}}', {
priority: formatNumber(priority, locale),
})
: t('Calculating priority...')
}
return createPortal(
<div
data-priority-hint
className='bg-popover text-popover-foreground rounded-xl border p-3 shadow-xl'
>
<Badge
variant={joining ? 'default' : 'warning'}
className='h-auto min-h-7 w-full justify-start gap-2 rounded-lg px-2.5 py-1.5 text-sm leading-snug whitespace-normal'
>
{joining ? (
<Layers aria-hidden className='shrink-0' />
) : (
<ArrowDownToLine aria-hidden className='shrink-0' />
)}
<span>
{joining
? t('Release to join priority {{priority}}', {
priority: formatNumber(priority, locale),
})
: t('Release to insert a new priority tier')}
</span>
</Badge>
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
{detail}
</p>
</div>,
props.container
)
}
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
Plus,
MoreHorizontal,
@@ -26,7 +27,6 @@ import {
TestTube,
DollarSign,
ListChecks,
SortAsc,
RefreshCw,
ArrowUpFromLine,
} from 'lucide-react'
@@ -45,6 +45,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
@@ -64,19 +65,25 @@ import {
handleTestAllChannels,
handleUpdateAllBalances,
} from '../lib'
import type { ChannelSortBy } from '../types'
import { ChannelSortControl } from './channel-priority-controls'
import { useChannels } from './channels-provider'
const route = getRouteApi('/_authenticated/channels/')
export function ChannelsPrimaryButtons() {
const { t } = useTranslation()
const navigate = route.useNavigate()
const {
setOpen,
setCurrentRow,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
sorting,
setSorting,
batchMode,
setBatchMode,
setPriorityActionContainer,
upstream,
} = useChannels()
const queryClient = useQueryClient()
@@ -95,57 +102,60 @@ export function ChannelsPrimaryButtons() {
setEnableTagMode(checked)
}
const handleIdSortToggle = (checked: boolean) => {
localStorage.setItem('channels-id-sort', String(checked))
setIdSort(checked)
}
const handleBatchModeToggle = (checked: boolean) => {
setBatchMode(checked)
}
return (
<>
<div className='flex items-center gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<div ref={setPriorityActionContainer} className='empty:hidden' />
{/* Desktop: Toggle switches visible */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<ListChecks className='text-muted-foreground h-4 w-4' />
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer text-sm'
>
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
<div className='bg-muted/40 hidden h-8 shrink-0 items-center gap-2.5 rounded-lg border px-2.5 sm:flex'>
<div className='text-muted-foreground has-data-checked:text-foreground flex items-center gap-2'>
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer gap-1.5 text-xs whitespace-nowrap'
>
<ListChecks className='size-3.5' aria-hidden />
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
size='sm'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
<Separator orientation='vertical' className='my-2' />
<div className='text-muted-foreground has-data-checked:text-foreground flex items-center gap-2'>
<Label
htmlFor='tag-mode'
className='cursor-pointer gap-1.5 text-xs whitespace-nowrap'
>
<Tags className='size-3.5' aria-hidden />
{t('Tag Mode')}
</Label>
<Switch
id='tag-mode'
size='sm'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<Tags className='text-muted-foreground h-4 w-4' />
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
{t('Tag Mode')}
</Label>
<Switch
id='tag-mode'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<SortAsc className='text-muted-foreground h-4 w-4' />
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
{t('Sort by ID')}
</Label>
<Switch
id='id-sort'
checked={idSort}
onCheckedChange={handleIdSortToggle}
/>
</div>
<ChannelSortControl
field={(sorting[0]?.id ?? 'priority') as ChannelSortBy}
desc={sorting[0]?.desc ?? true}
onChange={(field, desc) => {
setSorting([{ id: field, desc }])
void navigate({
search: (previous) => ({ ...previous, page: 1 }),
replace: true,
})
}}
/>
{/* Create Channel */}
<Tooltip>
@@ -156,7 +166,6 @@ export function ChannelsPrimaryButtons() {
setCurrentRow(null)
setOpen('create-channel')
}}
size='sm'
disabled={!canEditSensitive}
>
<Plus className='h-4 w-4' />
@@ -173,8 +182,11 @@ export function ChannelsPrimaryButtons() {
{/* More Actions */}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
<MoreHorizontal className='h-4 w-4' />
<DropdownMenuTrigger
render={<Button variant='outline' size='icon' />}
aria-label={t('More actions')}
>
<MoreHorizontal className='size-4' aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{/* Mobile-only: toggle switches */}
@@ -196,15 +208,6 @@ export function ChannelsPrimaryButtons() {
{t('Tag Mode')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={idSort}
onCheckedChange={handleIdSortToggle}
>
<SortAsc className='mr-2 h-4 w-4' />
{t('Sort by ID')}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator className='sm:hidden' />
<DropdownMenuItem
@@ -18,12 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
import { useQueryClient } from '@tanstack/react-query'
import type { OnChangeFn, SortingState } from '@tanstack/react-table'
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
} from 'react'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
@@ -59,12 +61,14 @@ type ChannelsContextType = {
setCurrentTag: (tag: string | null) => void
enableTagMode: boolean
setEnableTagMode: (enabled: boolean) => void
idSort: boolean
setIdSort: (enabled: boolean) => void
sorting: SortingState
setSorting: OnChangeFn<SortingState>
batchMode: boolean
setBatchMode: (enabled: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
priorityActionContainer: HTMLDivElement | null
setPriorityActionContainer: (container: HTMLDivElement | null) => void
upstream: UpstreamUpdateState
}
@@ -87,11 +91,22 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
const [enableTagMode, setEnableTagMode] = useState(() => {
return localStorage.getItem('enable-tag-mode') === 'true'
})
const [idSort, setIdSort] = useState(() => {
return localStorage.getItem('channels-id-sort') === 'true'
})
const [sorting, setSorting] = useState<SortingState>(() => [
{
id:
localStorage.getItem('channels-id-sort') === 'true' ? 'id' : 'priority',
desc: true,
},
])
const idSort = sorting[0]?.id === 'id'
useEffect(() => {
// Retain the existing ID-sort preference for returning users.
localStorage.setItem('channels-id-sort', String(idSort))
}, [idSort])
const [batchMode, setBatchMode] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
const [priorityActionContainer, setPriorityActionContainer] =
useState<HTMLDivElement | null>(null)
const queryClient = useQueryClient()
const refreshChannels = useCallback(async () => {
@@ -112,12 +127,14 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
setCurrentTag,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
sorting,
setSorting,
batchMode,
setBatchMode,
sensitiveVisible,
setSensitiveVisible,
priorityActionContainer,
setPriorityActionContainer,
upstream,
}),
[
@@ -125,9 +142,10 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
currentRow,
currentTag,
enableTagMode,
idSort,
sorting,
batchMode,
sensitiveVisible,
priorityActionContainer,
upstream,
]
)
@@ -23,9 +23,11 @@ import type {
OnChangeFn,
SortingState,
Row,
ColumnDef,
} from '@tanstack/react-table'
import { Eye, EyeOff, RefreshCw } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import {
@@ -34,6 +36,7 @@ import {
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
useDataTableViewMode,
} from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -44,8 +47,10 @@ import {
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { hasPermission } from '@/lib/admin-permissions'
import { requireServerSuccess } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import { getChannels, searchChannels, getGroups } from '../api'
import {
@@ -53,6 +58,8 @@ import {
CHANNEL_STATUS,
CHANNEL_STATUS_OPTIONS,
} from '../constants'
import { useChannelPriorityDrag } from '../hooks/use-channel-priority-drag'
import { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
import {
channelsQueryKeys,
aggregateChannelsByTag,
@@ -62,6 +69,13 @@ import {
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { ChannelCard } from './channel-card'
import { ChannelPriorityContext } from './channel-priority-context'
import {
ChannelPriorityHandle,
ChannelPriorityModeToggle,
ChannelPriorityStatus,
} from './channel-priority-controls'
import { ChannelPriorityDropHint } from './channel-priority-drop-hint'
import { ChannelTypeLogo } from './channel-type-badge'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
@@ -92,15 +106,16 @@ export function ChannelsTable() {
const { t } = useTranslation()
const {
enableTagMode,
idSort,
sorting,
setSorting,
batchMode,
sensitiveVisible,
setSensitiveVisible,
priorityActionContainer,
} = useChannels()
const isMobile = useMediaQuery('(max-width: 640px)')
// Table state
const [sorting, setSorting] = useState<SortingState>([])
const idSort = sorting[0]?.id === 'id'
// URL state management
const {
@@ -195,13 +210,10 @@ export function ChannelsTable() {
}, [sorting])
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
setSorting((previous) => {
const next = typeof updater === 'function' ? updater(previous) : updater
if (pagination.pageIndex > 0) {
onPaginationChange({ ...pagination, pageIndex: 0 })
}
return next
})
setSorting(updater)
if (pagination.pageIndex > 0) {
onPaginationChange({ ...pagination, pageIndex: 0 })
}
}
// Fetch groups for filter
@@ -309,12 +321,89 @@ export function ChannelsTable() {
const totalCount = data?.data?.total || 0
const typeCounts = data?.data?.type_counts
// Columns configuration
const columns = useChannelsColumns({ enableSelection: batchMode })
const [viewMode, setViewMode] = useDataTableViewMode({
storageKey: CHANNELS_VIEW_MODE_STORAGE_KEY,
defaultMode: 'card',
})
const user = useAuthStore((state) => state.auth.user)
const sortField = (sorting[0]?.id ??
(idSort ? 'id' : 'priority')) as ChannelSortBy
const sortDesc = sorting[0]?.desc ?? true
const [reorderEnabled, setReorderEnabled] = useState(false)
const canReorder =
sortField === 'priority' &&
!enableTagMode &&
hasPermission(user, 'channel', 'write')
const reorderActive = reorderEnabled && canReorder
useEffect(() => {
if (!canReorder) setReorderEnabled(false)
}, [canReorder])
const reorderScope = JSON.stringify([
viewMode,
reorderActive,
sorting,
idSort,
enableTagMode,
columnFilters,
globalFilter,
pagination,
channels.map((channel) => [channel.id, channel.priority]),
])
const priorityOrder = useChannelPriorityOrder({
channels,
desc: sortDesc,
scope: reorderScope,
enabled: reorderActive,
})
const drag = useChannelPriorityDrag({
items: channels,
desc: sortDesc,
enabled: reorderActive && !priorityOrder.saving && !isFetching,
scope: reorderScope,
begin: priorityOrder.begin,
select: priorityOrder.select,
commit: priorityOrder.commit,
cancel: priorityOrder.cancel,
previewStore: priorityOrder.previewStore,
})
let dragReason: 'sort' | 'tag' | 'permission' | 'busy' | null = null
if (sortField !== 'priority') dragReason = 'sort'
else if (enableTagMode) dragReason = 'tag'
else if (!hasPermission(user, 'channel', 'write')) dragReason = 'permission'
else if (priorityOrder.saving || isFetching) dragReason = 'busy'
const dragControls = useMemo(
() => ({ reason: dragReason, start: drag.start, keyDown: drag.keyDown }),
[dragReason, drag.start, drag.keyDown]
)
// Reuse the same handle cell in the table and channel cards.
const baseColumns = useChannelsColumns({ enableSelection: batchMode })
const columns = useMemo<ColumnDef<Channel>[]>(
() =>
reorderActive
? [
{
id: 'reorder',
header: '',
size: 40,
minSize: 40,
maxSize: 40,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: { mobileHidden: true },
cell: ({ row }) => <ChannelPriorityHandle id={row.original.id} />,
},
...baseColumns,
]
: baseColumns,
[baseColumns, reorderActive]
)
// React Table instance
const { table } = useDataTable({
data: channels,
data: priorityOrder.items,
columns,
totalCount,
sorting,
@@ -412,110 +501,149 @@ export function ChannelsTable() {
]
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Channels Found')}
emptyDescription={t(
'No channels available. Create your first channel to get started.'
)}
skeletonKeyPrefix='channel-skeleton'
enableCardView
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row, { isSelected }) => (
<ChannelCard row={row} isSelected={isSelected} />
)}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
applyHeaderSize
toolbarProps={{
collapsibleOnMobile: true,
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
filters: [
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
],
preActions: (
<>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
{sensitiveVisible ? <Eye /> : <EyeOff />}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => void refetch()}
aria-label={t('Refresh')}
aria-busy={isFetching}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
<RefreshCw className={cn(isFetching && 'animate-spin')} />
</TooltipTrigger>
<TooltipContent>{t('Refresh')}</TooltipContent>
</Tooltip>
</>
),
}}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledChannelRow(row.original)) {
return undefined
}
if (isMobile) {
return DISABLED_ROW_MOBILE
}
return DISABLED_ROW_DESKTOP
}}
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
/>
<ChannelPriorityContext.Provider value={dragControls}>
{priorityActionContainer &&
createPortal(
<ChannelPriorityModeToggle
active={reorderActive}
reason={dragReason}
onChange={() => setReorderEnabled((value) => !value)}
/>,
priorityActionContainer
)}
<ChannelPriorityDropHint
container={drag.feedbackContainer}
order={priorityOrder}
channels={channels}
/>
<div
ref={drag.root}
role='region'
aria-label={t('Channels')}
{...drag.rootEvents}
className='relative h-full min-h-0 data-[priority-dragging=true]:select-none data-[priority-dragging=true]:[&_[data-sortable-id]]:pointer-events-none'
>
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Channels Found')}
emptyDescription={t(
'No channels available. Create your first channel to get started.'
)}
skeletonKeyPrefix='channel-skeleton'
enableCardView
viewMode={viewMode}
onViewModeChange={setViewMode}
renderCard={(row, { isSelected }) => (
<ChannelCard
row={row}
isSelected={isSelected}
reorderEnabled={reorderActive}
/>
)}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
applyHeaderSize
afterTable={<ChannelPriorityStatus order={priorityOrder} />}
toolbarProps={{
collapsibleOnMobile: true,
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
filters: [
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
],
preActions: (
<>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
{sensitiveVisible ? (
<Eye aria-hidden />
) : (
<EyeOff aria-hidden />
)}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => void refetch()}
aria-label={t('Refresh')}
aria-busy={isFetching}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
<RefreshCw
aria-hidden
className={cn(isFetching && 'animate-spin')}
/>
</TooltipTrigger>
<TooltipContent>{t('Refresh')}</TooltipContent>
</Tooltip>
</>
),
}}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledChannelRow(row.original)) {
return undefined
}
if (isMobile) {
return DISABLED_ROW_MOBILE
}
return DISABLED_ROW_DESKTOP
}}
bulkActions={
batchMode ? <DataTableBulkActions table={table} /> : null
}
/>
</div>
</ChannelPriorityContext.Provider>
)
}
@@ -167,6 +167,7 @@ export function NumericSpinnerInput({
{editing ? (
<input
data-slot='numeric-spinner-value'
ref={inputRef}
type='text'
value={localValue}
@@ -178,6 +179,7 @@ export function NumericSpinnerInput({
/>
) : (
<button
data-slot='numeric-spinner-value'
type='button'
onClick={handleStartEdit}
disabled={disabled}
@@ -0,0 +1,663 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { animate, type AnimationPlaybackControls } from 'motion'
import { useReducedMotion } from 'motion/react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
} from 'react'
import {
previewPriorityDrop,
type PriorityDrop,
type PriorityPosition,
} from '../lib/channel-priority-order'
import type { Channel } from '../types'
import type { useChannelPriorityOrder } from './use-channel-priority-order'
function scrollParent(element: HTMLElement): HTMLElement {
let parent = element.parentElement
while (parent) {
if (/(auto|scroll)/.test(getComputedStyle(parent).overflowY)) return parent
parent = parent.parentElement
}
return document.documentElement
}
type DragOptions = {
items: Channel[]
desc: boolean
enabled: boolean
scope: string
begin: () => void
select: (drop: PriorityDrop | null) => void
commit: () => Promise<void>
cancel: () => void
previewStore: ReturnType<typeof useChannelPriorityOrder>['previewStore']
}
type DragSlot = {
id: number
element: HTMLElement
rect: DOMRect
transform: string
minHeight: string
willChange: string
boxShadow: string
x: number
y: number
}
type DragPoint = {
pointerId: number
id: number
startX: number
startY: number
x: number
y: number
moved: boolean
clone: HTMLElement | null
overlay: HTMLElement | null
feedback: HTMLElement | null
insertion: HTMLElement | null
drop: PriorityDrop | null
source: HTMLElement
opacity: string
outline: string
scroller: HTMLElement
scrollBounds: DOMRect
rootBounds: DOMRect
target: string
slots: DragSlot[]
slotsByID: Map<number, DragSlot>
items: Channel[]
ordered: Channel[]
hitIndex: number
hitTarget: number | null
horizontal: boolean
destination: DOMRect | null
scrollTop: number
scrollLeft: number
lastFrame: number
commit: boolean
}
/** The gesture keeps React rows and DOM order fixed. Only transforms change. */
export function useChannelPriorityDrag(options: DragOptions) {
const root = useRef<HTMLDivElement>(null)
const point = useRef<DragPoint | null>(null)
const [feedbackContainer, setFeedbackContainer] =
useState<HTMLElement | null>(null)
const frame = useRef<number | undefined>(undefined)
const released = useRef<DragPoint | null>(null)
const landing = useRef<AnimationPlaybackControls | null>(null)
const animations = useRef(new Map<HTMLElement, AnimationPlaybackControls>())
const latest = useRef(options)
latest.current = options
const reduced = useReducedMotion()
const transition = useMemo(
() =>
reduced
? { duration: 0 }
: { type: 'spring' as const, stiffness: 450, damping: 38 },
[reduced]
)
const clearVisuals = useCallback((current: DragPoint) => {
current.overlay?.remove()
setFeedbackContainer(null)
current.source.style.opacity = current.opacity
current.source.style.outline = current.outline
for (const slot of current.slots) {
animations.current.get(slot.element)?.stop()
Object.assign(slot.element.style, {
transform: slot.transform,
minHeight: slot.minHeight,
willChange: slot.willChange,
boxShadow: slot.boxShadow,
})
delete slot.element.dataset.priorityDrop
}
animations.current.clear()
if (root.current) delete root.current.dataset.priorityDragging
released.current = null
}, [])
const preview = useCallback(
(current: DragPoint, drop: PriorityDrop | null) => {
const ordered = drop
? previewPriorityDrop(current.items, drop, latest.current.desc)
: current.items
current.ordered = ordered
current.drop = drop
if (current.clone) {
current.clone.style.outline =
drop && drop.position !== 'same'
? '1px solid var(--warning)'
: '1px solid var(--primary)'
}
const targetPriority =
current.items.find((item) => item.id === drop?.target_id)?.priority ?? 0
for (const [index, item] of ordered.entries()) {
const slot = current.slotsByID.get(item.id)
const destination = current.slots[index]?.rect
if (!slot || !destination) continue
const joining =
drop?.position === 'same' &&
item.id !== current.id &&
(item.priority ?? 0) === targetPriority
if (joining) slot.element.dataset.priorityDrop = 'same'
else delete slot.element.dataset.priorityDrop
slot.element.style.boxShadow = joining
? 'inset 0 0 0 2px var(--primary)'
: slot.boxShadow
const x = destination.left - slot.rect.left
const y = destination.top - slot.rect.top
if (item.id === current.id) current.destination = destination
if (slot.x === x && slot.y === y) continue
slot.x = x
slot.y = y
const transform = `translate3d(${x}px, ${y}px, 0)`
animations.current.get(slot.element)?.stop()
if (item.id === current.id) slot.element.style.transform = transform
else {
animations.current.set(
slot.element,
animate(slot.element, { transform }, transition)
)
}
}
},
[transition]
)
const stop = useCallback(
(commit = false) => {
const current = point.current
if (!current) {
if (released.current && !commit) {
released.current.commit = false
landing.current?.complete()
}
return
}
point.current = null
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = undefined
if (root.current?.hasPointerCapture(current.pointerId)) {
root.current.releasePointerCapture(current.pointerId)
}
if (!current.moved || !current.clone) return
if (current.feedback) current.feedback.hidden = true
if (current.insertion) current.insertion.hidden = true
current.commit = commit && !!current.target
released.current = current
if (!current.commit) preview(current, null)
const origin = current.slotsByID.get(current.id)?.rect
const target = current.destination ?? origin
if (!origin || !target) {
clearVisuals(current)
latest.current.cancel()
return
}
const x =
target.left -
origin.left -
current.scroller.scrollLeft +
current.scrollLeft
const y =
target.top - origin.top - current.scroller.scrollTop + current.scrollTop
landing.current = animate(
current.clone,
{ transform: `translate3d(${x}px, ${y}px, 0)` },
{
...transition,
onComplete: () => {
clearVisuals(current)
// Commit changes React's order exactly once, after the visual landing.
if (current.commit) void latest.current.commit()
else latest.current.cancel()
},
}
)
},
[clearVisuals, preview, transition]
)
const tick = useCallback(
(time: number) => {
frame.current = undefined
const current = point.current
if (!current || !root.current) return
if (!current.moved) {
if (
Math.hypot(current.x - current.startX, current.y - current.startY) <=
4
) {
return
}
// Batch every layout read before adding the ghost or writing styles.
const elements = [
...root.current.querySelectorAll<HTMLElement>(
'[data-sortable-id^="channel:"]'
),
]
current.slots = elements.map((element) => ({
id: Number(element.dataset.sortableId?.split(':')[1]),
element,
rect: element.getBoundingClientRect(),
transform: element.style.transform,
minHeight: element.style.minHeight,
willChange: element.style.willChange,
boxShadow: element.style.boxShadow,
x: 0,
y: 0,
}))
current.slotsByID = new Map(
current.slots.map((slot) => [slot.id, slot])
)
const origin = current.slotsByID.get(current.id)?.rect
if (!origin) return
const cellWidths =
current.source.tagName === 'TR'
? [...current.source.children].map(
(cell) => cell.getBoundingClientRect().width
)
: []
current.scrollBounds = current.scroller.getBoundingClientRect()
current.rootBounds = root.current.getBoundingClientRect()
current.scrollTop = current.scroller.scrollTop
current.scrollLeft = current.scroller.scrollLeft
current.horizontal =
current.source.tagName !== 'TR' &&
current.slots.some(
(slot) =>
slot.id !== current.id && Math.abs(slot.rect.top - origin.top) < 4
)
current.destination = origin
current.moved = true
latest.current.begin()
const clone = current.source.cloneNode(true) as HTMLElement
clone.style.animation = 'none'
clone.removeAttribute('id')
clone.removeAttribute('data-sortable-id')
clone
.querySelectorAll('[id]')
.forEach((element) => element.removeAttribute('id'))
let ghost = clone
if (current.source.tagName === 'TR') {
const table = document.createElement('table')
table.className = current.source.closest('table')?.className ?? ''
const cells = [...clone.children]
cells.forEach((cell, index) => {
;(cell as HTMLElement).style.width = `${cellWidths[index]}px`
})
const body = document.createElement('tbody')
body.append(clone)
table.append(body)
ghost = table
}
ghost.dataset.originX = String(origin.left)
ghost.dataset.originY = String(origin.top)
Object.assign(ghost.style, {
position: 'absolute',
left: `${origin.left}px`,
top: `${origin.top}px`,
margin: '0',
width: `${origin.width}px`,
height: `${origin.height}px`,
pointerEvents: 'none',
background: 'var(--card)',
borderRadius: 'var(--radius)',
boxShadow: '0 12px 28px #0003',
outline: '1px solid var(--primary)',
overflow: 'hidden',
willChange: 'transform',
})
const overlay = document.createElement('div')
Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
overflow: 'hidden',
pointerEvents: 'none',
zIndex: '1000',
})
overlay.setAttribute('aria-hidden', 'true')
overlay.inert = true
const insertion = document.createElement('div')
insertion.dataset.priorityInsertion = ''
insertion.hidden = true
Object.assign(insertion.style, {
position: 'absolute',
left: '0',
top: '0',
background: 'var(--warning)',
borderRadius: '999px',
boxShadow: '0 0 0 2px var(--background)',
pointerEvents: 'none',
})
const feedback = document.createElement('div')
Object.assign(feedback.style, {
position: 'absolute',
left: '0',
top: '0',
width: '300px',
maxWidth: 'calc(100vw - 16px)',
pointerEvents: 'none',
willChange: 'transform',
})
overlay.append(ghost, insertion, feedback)
document.body.append(overlay)
current.feedback = feedback
current.insertion = insertion
setFeedbackContainer(feedback)
current.overlay = overlay
current.clone = ghost
for (const slot of current.slots) {
slot.element.style.willChange = 'transform'
if (slot.element.tagName !== 'TR') {
slot.element.style.minHeight = `${slot.rect.height}px`
}
}
current.source.style.opacity = '0.2'
current.source.style.outline = '2px dashed var(--primary)'
root.current.dataset.priorityDragging = 'true'
}
// No geometry reads, sorting or React table updates in the pointer frame.
const scrollBefore = current.scroller.scrollTop
const bounds = current.scrollBounds
const elapsed = current.lastFrame
? Math.min(32, time - current.lastFrame)
: 16
current.lastFrame = time
if (current.x >= bounds.left && current.x <= bounds.right) {
if (current.y < bounds.top + 32) {
current.scroller.scrollTop -= elapsed * 0.45
} else if (current.y > bounds.bottom - 32) {
current.scroller.scrollTop += elapsed * 0.45
}
}
const scrollX = current.scroller.scrollLeft - current.scrollLeft
const scrollY = current.scroller.scrollTop - current.scrollTop
const pointerX = current.x + scrollX
const pointerY = current.y + scrollY
let hitIndex = -1
let distance = Infinity
for (const [index, slot] of current.slots.entries()) {
const dx = pointerX - slot.rect.left - slot.rect.width / 2
const dy = pointerY - slot.rect.top - slot.rect.height / 2
const squared = dx * dx + dy * dy
if (squared < distance) {
hitIndex = index
distance = squared
}
}
let next: PriorityDrop | null = null
const slot = current.slots[hitIndex]
if (
slot &&
current.x >= current.rootBounds.left &&
current.x <= current.rootBounds.right &&
current.y >= current.rootBounds.top &&
current.y <= current.rootBounds.bottom
) {
if (hitIndex !== current.hitIndex) {
current.hitIndex = hitIndex
const occupant = current.ordered[hitIndex]
// Resolve the card/row now occupying this slot, not its original ID.
// A moving placeholder retains the previous target instead of resetting.
if (occupant && occupant.id !== current.id) {
current.hitTarget = occupant.id
}
}
if (current.hitTarget !== null) {
const fraction = current.horizontal
? (pointerX - slot.rect.left) / slot.rect.width
: (pointerY - slot.rect.top) / slot.rect.height
let position: PriorityPosition = 'same'
if (fraction < 0.25) {
position = latest.current.desc ? 'before' : 'after'
} else if (fraction > 0.75) {
position = latest.current.desc ? 'after' : 'before'
}
next = { id: current.id, target_id: current.hitTarget, position }
}
} else {
current.hitIndex = -1
current.hitTarget = null
}
if (current.clone) {
current.clone.style.transform = `translate3d(${current.x - current.startX}px, ${current.y - current.startY}px, 0)`
}
const key = next ? `${next.target_id}:${next.position}` : ''
if (key !== current.target) {
current.target = key
preview(current, next)
latest.current.select(next)
}
if (current.feedback) {
const left = Math.max(
8,
Math.min(current.x + 16, window.innerWidth - 308)
)
const top = Math.max(
8,
Math.min(current.y + 20, window.innerHeight - 148)
)
current.feedback.style.transform = `translate3d(${left}px, ${top}px, 0)`
}
const destination = current.destination
if (current.insertion) {
current.insertion.hidden =
!current.drop || current.drop.position === 'same' || !destination
if (!current.insertion.hidden && destination) {
const left = destination.left - scrollX
const top = destination.top - scrollY
const vertical = current.horizontal
Object.assign(current.insertion.style, {
transform: `translate3d(${vertical ? left - 6 : Math.max(left, current.scrollBounds.left)}px, ${vertical ? top : top - 2}px, 0)`,
width: vertical
? '4px'
: `${Math.min(destination.right - scrollX, current.scrollBounds.right) - Math.max(left, current.scrollBounds.left)}px`,
height: vertical ? `${destination.height}px` : '4px',
})
}
}
// Idle pointers do not run a perpetual frame loop. Continue only to scroll.
if (current.scroller.scrollTop !== scrollBefore) {
frame.current = requestAnimationFrame(tick)
}
},
[preview]
)
const start = useCallback(
(event: PointerEvent<HTMLButtonElement>, id: number) => {
if (
!latest.current.enabled ||
point.current ||
released.current ||
event.button !== 0
) {
return
}
const source = root.current?.querySelector<HTMLElement>(
`[data-sortable-id="channel:${id}"]`
)
if (!source || !root.current) return
event.preventDefault()
event.currentTarget.focus({ preventScroll: true })
point.current = {
pointerId: event.pointerId,
id,
startX: event.clientX,
startY: event.clientY,
x: event.clientX,
y: event.clientY,
moved: false,
clone: null,
overlay: null,
feedback: null,
insertion: null,
drop: null,
source,
opacity: source.style.opacity,
outline: source.style.outline,
scroller: scrollParent(source),
scrollBounds: new DOMRect(),
rootBounds: new DOMRect(),
target: '',
slots: [],
slotsByID: new Map(),
items: latest.current.items,
ordered: latest.current.items,
hitIndex: -1,
hitTarget: null,
horizontal: false,
destination: null,
scrollTop: 0,
scrollLeft: 0,
lastFrame: 0,
commit: false,
}
root.current.setPointerCapture(event.pointerId)
},
[]
)
const keyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, id: number) => {
if (event.key === 'Escape') {
stop()
latest.current.cancel()
return
}
if (!latest.current.enabled || point.current || released.current) return
const index = latest.current.items.findIndex((item) => item.id === id)
let delta = 0
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') delta = -1
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') delta = 1
if (!delta) return
event.preventDefault()
const target = latest.current.items[index + delta]
if (!target) return
latest.current.begin()
latest.current.select({
id,
target_id: target.id,
position: delta < 0 === latest.current.desc ? 'before' : 'after',
})
void latest.current.commit()
},
[stop]
)
useEffect(
() =>
options.previewStore.subscribe(() => {
const current = point.current ?? released.current
if (!current?.clone) return
const { drop, preview } = options.previewStore.getSnapshot()
let priority =
current.items.find((item) => item.id === current.id)?.priority ?? 0
if (drop?.id === current.id) {
if (drop.position === 'same') {
priority =
current.items.find((item) => item.id === drop.target_id)
?.priority ?? 0
}
if (preview) {
priority =
preview.items.find((item) => item.id === current.id)?.priority ??
priority
}
}
const value = current.clone.querySelector<HTMLElement>(
'[data-column-id="priority"] [data-slot="numeric-spinner-value"]'
)
if (value instanceof HTMLInputElement) value.value = String(priority)
else if (value) {
value.textContent = String(priority)
value.title = String(priority)
}
}),
[options.previewStore]
)
useEffect(() => {
stop()
}, [options.scope, options.enabled, stop])
useEffect(() => {
const cancel = () => stop()
const key = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') cancel()
}
window.addEventListener('blur', cancel)
window.addEventListener('resize', cancel)
window.addEventListener('keydown', key)
return () => {
window.removeEventListener('blur', cancel)
window.removeEventListener('resize', cancel)
window.removeEventListener('keydown', key)
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
if (released.current) released.current.commit = false
landing.current?.stop()
if (point.current) clearVisuals(point.current)
if (released.current) clearVisuals(released.current)
point.current = null
}
}, [clearVisuals, stop])
return {
root,
feedbackContainer,
start,
keyDown,
rootEvents: {
onPointerMove: (event: PointerEvent<HTMLDivElement>) => {
if (point.current?.pointerId !== event.pointerId) return
point.current.x = event.clientX
point.current.y = event.clientY
if (frame.current === undefined) {
frame.current = requestAnimationFrame(tick)
}
},
onPointerUp: (event: PointerEvent<HTMLDivElement>) => {
if (point.current?.pointerId !== event.pointerId) return
point.current.x = event.clientX
point.current.y = event.clientY
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
tick(performance.now())
stop(true)
},
onPointerCancel: () => {
if (point.current) stop()
},
onLostPointerCapture: () => {
if (point.current) stop()
},
},
}
}
@@ -0,0 +1,251 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { handleServerError } from '@/lib/handle-server-error'
import { getChannelPriorityOrder, reorderChannelPriority } from '../api'
import { channelsQueryKeys } from '../lib'
import {
applyPriorityPreview,
previewPriorityDrop,
type PriorityDrop,
type PriorityMove,
type PriorityOrder,
} from '../lib/channel-priority-order'
import type { Channel } from '../types'
export function useChannelPriorityOrder(props: {
channels: Channel[]
desc: boolean
scope: string
enabled: boolean
}) {
const { t } = useTranslation()
const client = useQueryClient()
const [optimisticDrop, setOptimisticDrop] = useState<PriorityDrop | null>(
null
)
const [savedPreview, setSavedPreview] = useState<PriorityOrder | null>(null)
// Only the status strip subscribes to hover/priority-preview updates. The table's
// data and row objects stay unchanged throughout the pointer gesture.
const [previewStore] = useState(() => {
const listeners = new Set<() => void>()
let value: {
drop: PriorityDrop | null
preview: PriorityOrder | null
preparing: boolean
} = { drop: null, preview: null, preparing: false }
return {
getSnapshot: () => value,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
publish: (next: typeof value) => {
value = next
for (const listener of listeners) listener()
},
}
})
const [undo, setUndo] = useState<PriorityOrder | null>(null)
const [active, setActive] = useState(false)
const [saving, setSaving] = useState(false)
const session = useRef<{
snapshot: Promise<PriorityOrder>
drop: PriorityDrop | null
serial: number
} | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const mutation = useMutation({
mutationFn: reorderChannelPriority,
meta: { errorToast: false },
})
const busy = useRef(false)
const cancel = useCallback(() => {
clearTimeout(timer.current)
session.current = null
setActive(false)
setOptimisticDrop(null)
setSavedPreview(null)
previewStore.publish({ drop: null, preview: null, preparing: false })
}, [previewStore])
function begin() {
if (!props.enabled || busy.current) return
cancel()
// Read a fresh snapshot on every gesture, including keyboard moves.
const snapshot = getChannelPriorityOrder()
// Mark rejection handled until the gesture actually needs the result.
void snapshot.catch(() => undefined)
session.current = { snapshot, drop: null, serial: 0 }
setActive(true)
}
function select(next: PriorityDrop | null) {
const current = session.current
if (!current || busy.current) return
clearTimeout(timer.current)
current.drop = next
current.serial++
previewStore.publish({ drop: next, preview: null, preparing: !!next })
if (!next) return
const serial = current.serial
timer.current = setTimeout(() => {
void current.snapshot
.then((snapshot) => {
if (
session.current !== current ||
current.serial !== serial ||
busy.current
) {
return null
}
return reorderChannelPriority({
...next,
revision: snapshot.revision,
preview: true,
})
})
.then((result) => {
if (
result &&
session.current === current &&
current.serial === serial &&
!busy.current
) {
previewStore.publish({
drop: next,
preview: result,
preparing: false,
})
}
})
.catch((error: unknown) => {
if (
session.current === current &&
current.serial === serial &&
!busy.current
) {
cancel()
handleServerError(error)
}
})
}, 200)
}
async function save(move: PriorityMove) {
const result = await mutation.mutateAsync(move)
setSavedPreview(result)
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
setUndo(move.restore ? null : result)
if (result.changes.length > 0) toast.success(t('Channel priorities saved'))
}
async function commit() {
const current = session.current
if (busy.current) return
if (!current?.drop) {
cancel()
return
}
busy.current = true
setSaving(true)
clearTimeout(timer.current)
setOptimisticDrop(current.drop)
previewStore.publish({ drop: current.drop, preview: null, preparing: true })
try {
const snapshot = await current.snapshot
if (session.current !== current) return
await save({ ...current.drop, revision: snapshot.revision })
} catch (error) {
handleServerError(error)
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} finally {
busy.current = false
setSaving(false)
cancel()
}
}
async function restore() {
if (!undo || busy.current) return
busy.current = true
setSaving(true)
try {
await save({
revision: undo.revision,
restore: undo.changes.map((change) => ({
id: change.id,
previous: change.priority,
priority: change.previous,
})),
})
} catch (error) {
setUndo(null)
handleServerError(error)
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} finally {
busy.current = false
setSaving(false)
cancel()
}
}
useEffect(() => {
cancel()
// View/filter/page changes cancel a pending gesture, but not an in-flight save.
}, [props.scope, props.enabled, cancel])
useEffect(
() => () => {
clearTimeout(timer.current)
session.current = null
},
[]
)
const items = useMemo(() => {
if (savedPreview) {
return applyPriorityPreview(props.channels, savedPreview, props.desc)
}
if (optimisticDrop) {
return previewPriorityDrop(props.channels, optimisticDrop, props.desc)
}
return props.channels
}, [props.channels, props.desc, optimisticDrop, savedPreview])
return {
items,
previewStore,
active,
saving,
undo,
begin,
select,
commit,
cancel,
restore,
}
}
@@ -0,0 +1,94 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { expect, test } from 'vitest'
import type { Channel } from '../../types'
import {
applyPriorityPreview,
previewPriorityDrop,
} from '../channel-priority-order'
const channels = [
{ id: 4, priority: 100 },
{ id: 3, priority: 80 },
{ id: 2, priority: 80 },
{ id: 1, priority: 0 },
] as Channel[]
test('before and after target the entire equal-priority tier', () => {
expect(
previewPriorityDrop(
channels,
{ id: 1, target_id: 2, position: 'before' },
true
).map((c) => c.id)
).toEqual([4, 1, 3, 2])
expect(
previewPriorityDrop(
channels,
{ id: 4, target_id: 3, position: 'after' },
true
).map((c) => c.id)
).toEqual([3, 2, 4, 1])
expect(channels.map((c) => c.id)).toEqual([4, 3, 2, 1])
})
test('ascending view reverses tier boundaries but preserves the ID tie break', () => {
const ascending = [...channels].sort(
(a, b) => (a.priority ?? 0) - (b.priority ?? 0) || b.id - a.id
)
expect(
previewPriorityDrop(
ascending,
{ id: 4, target_id: 3, position: 'after' },
false
).map((c) => c.id)
).toEqual([1, 4, 3, 2])
expect(
previewPriorityDrop(
channels,
{ id: 4, target_id: 3, position: 'same' },
true
).map((c) => c.id)
).toEqual([4, 3, 2, 1])
})
test('server preview applies visible changes and keeps deterministic null-zero ordering', () => {
const preview = applyPriorityPreview(
channels,
{
revision: 'next',
changes: [],
items: [
{ id: 4, priority: null },
{ id: 3, priority: 80 },
{ id: 2, priority: 80 },
{ id: 1, priority: 90 },
],
},
true
)
expect(preview.map((c) => [c.id, c.priority])).toEqual([
[1, 90],
[3, 80],
[2, 80],
[4, 0],
])
expect(channels[0].priority).toBe(100)
})
@@ -0,0 +1,86 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Channel } from '../types'
export type PriorityPosition = 'before' | 'after' | 'same'
export type PriorityDrop = {
id: number
target_id: number
position: PriorityPosition
}
export type PriorityChange = {
id: number
previous: number | null
priority: number | null
}
export type PriorityOrder = {
revision: string
items: { id: number; priority: number | null }[]
changes: PriorityChange[]
}
export type PriorityMove = Partial<PriorityDrop> & {
revision: string
preview?: boolean
restore?: PriorityChange[]
}
/** Positions are canonical (descending priority), independent of the view. */
export function previewPriorityDrop(
channels: Channel[],
drop: PriorityDrop,
desc: boolean
): Channel[] {
const moving = channels.find((channel) => channel.id === drop.id)
const target = channels.find((channel) => channel.id === drop.target_id)
if (!moving || !target) return channels
const remaining = channels.filter((channel) => channel.id !== moving.id)
const tier = target.priority ?? 0
const indices = remaining.flatMap((channel, index) =>
(channel.priority ?? 0) === tier ? [index] : []
)
let index = indices[0]
if (drop.position === 'same') {
index =
indices.find((i) => remaining[i].id < moving.id) ??
(indices.at(-1) ?? 0) + 1
} else if ((drop.position === 'after') === desc) {
index = (indices.at(-1) ?? 0) + 1
}
remaining.splice(index, 0, moving)
return remaining
}
export function applyPriorityPreview(
channels: Channel[],
order: PriorityOrder,
desc: boolean
): Channel[] {
const priorities = new Map(
order.items.map((item) => [item.id, item.priority])
)
return channels
.map((channel) => ({
...channel,
priority: priorities.get(channel.id) ?? 0,
}))
.sort(
(a, b) =>
((a.priority ?? 0) - (b.priority ?? 0)) * (desc ? -1 : 1) || b.id - a.id
)
}
+24
View File
@@ -3,6 +3,7 @@
"360": "360",
"1000": "1000",
"10000": "10000",
"{{count}} outside this view": "{{count}} outside this view",
"_copy": "_copy",
",": ", ",
", and": ", and",
@@ -605,6 +606,7 @@
"API version": "API version",
"API Version": "API Version",
"API2GPT": "API2GPT",
"Ascending": "Ascending",
"Audio input cost {{quota}}": "Audio input cost {{quota}}",
"App": "App",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.",
@@ -917,6 +919,7 @@
"Binding your {{provider}} account": "Binding your {{provider}} account",
"Binding...": "Binding...",
"Bindings": "Bindings",
"Calculating priority...": "Calculating priority...",
"Callback URL configuration error": "Callback URL configuration error",
"Blacklist": "Blacklist",
"Blacklist (Block listed domains)": "Blacklist (Block listed domains)",
@@ -1046,6 +1049,9 @@
"Change your display name and personal settings.": "Change your display name and personal settings.",
"Changed": "Changed",
"Changed / Total": "Changed / Total",
"Channel priorities changed. Refresh and try again.": "Channel priorities changed. Refresh and try again.",
"Channel priorities saved": "Channel priorities saved",
"Channel priority is outside the sortable range": "Channel priority is outside the sortable range",
"Check-in failed, please try again later": "Check-in failed, please try again later",
"Check-in failed: quota update error": "Check-in failed: quota update error",
"Check-in feature is not enabled": "Check-in feature is not enabled",
@@ -1860,6 +1866,8 @@
"Deleted a deployment": "Deleted a deployment",
"Deleted a model": "Deleted a model",
"Deleted a passkey": "Deleted a passkey",
"Descending": "Descending",
"Disable tag grouping to drag channels": "Disable tag grouping to drag channels",
"Disabled {{count}} keys": "Disabled {{count}} keys",
"Deleted a prefill group": "Deleted a prefill group",
"Deleted a redemption code": "Deleted a redemption code",
@@ -2013,6 +2021,8 @@
"Downgrade to this group after the subscription expires": "Downgrade to this group after the subscription expires",
"Download": "Download",
"Drag {{group}} to reorder": "Drag {{group}} to reorder",
"Drag mode": "Drag mode",
"Drag to change priority. Use arrow keys to move, Escape to cancel.": "Drag to change priority. Use arrow keys to move, Escape to cancel.",
"Drag to move": "Drag to move",
"Drag to resize": "Drag to resize",
"Draw": "Draw",
@@ -2022,6 +2032,7 @@
"Drawing task polling": "Drawing task polling",
"Drawing task records": "Drawing task records",
"Drop a JavaScript plugin file here": "Drop a JavaScript plugin file here",
"Drop between tiers to change priority, or in the center to join a tier": "Drop between tiers to change priority, or in the center to join a tier",
"Dry run result": "Dry run result",
"Duplicate": "Duplicate",
"Duplicate group names: {{names}}": "Duplicate group names: {{names}}",
@@ -2765,6 +2776,7 @@
"Find": "Find",
"Find the billing group.": "Find the billing group.",
"Find the ratio.": "Find the ratio.",
"Finish reordering": "Finish reordering",
"Finish Time": "Finish Time",
"Finished requests": "Finished requests",
"First API request": "First API request",
@@ -3146,6 +3158,7 @@
"Important": "Important",
"Invalid channel ID": "Invalid channel ID",
"Importing metadata does not add channels, enable model access, or configure prices.": "Importing metadata does not add channels, enable model access, or configure prices.",
"Invalid channel priority move": "Invalid channel priority move",
"Invalid channel settings format: {{error}}": "Invalid channel settings format: {{error}}",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.",
@@ -3268,6 +3281,7 @@
"Invalid JSON string.": "Invalid JSON string.",
"Invalid model mapping format": "Invalid model mapping format",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.",
"Join the same priority tier": "Join the same priority tier",
"Key deleted": "Key deleted",
"Key disabled": "Key disabled",
"Key enabled": "Key enabled",
@@ -3821,6 +3835,8 @@
"ms": "ms",
"Multi-key channel: Keys will be": "Multi-key channel: Keys will be",
"Multi-Key Management": "Multi-Key Management",
"New priority: {{priority}}": "New priority: {{priority}}",
"New priority: {{priority}} · {{count}} channels affected": "New priority: {{priority}} · {{count}} channels affected",
"New user registration has been disabled by administrator": "New user registration has been disabled by administrator",
"New user sign-up bonus {{quota}}": "New user sign-up bonus {{quota}}",
"Multi-key management {{action}} on channel (ID: {{id}})": "Multi-key management {{action}} on channel (ID: {{id}})",
@@ -5073,6 +5089,8 @@
"Reinstall latest": "Reinstall latest",
"Reject Reason": "Reject Reason",
"Release details": "Release details",
"Release to insert a new priority tier": "Release to insert a new priority tier",
"Release to join priority {{priority}}": "Release to join priority {{priority}}",
"Released": "Released",
"Reload pricing": "Reload pricing",
"Reload vendor details": "Reload vendor details",
@@ -5130,6 +5148,7 @@
"Renamed an access token": "Renamed an access token",
"Renamed successfully": "Renamed successfully",
"Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.": "Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.",
"Reorder channel {{id}}": "Reorder channel {{id}}",
"Repair": "Repair",
"Repair Channel Consistency": "Repair Channel Consistency",
"Repair channel consistency?": "Repair channel consistency?",
@@ -5777,6 +5796,8 @@
"Simulated request body": "Simulated request body",
"Simulated request cost": "Simulated request cost",
"Simulated request headers": "Simulated request headers",
"Sort by priority to drag channels": "Sort by priority to drag channels",
"Sort channels by": "Sort channels by",
"State parameter is empty or mismatched": "State parameter is empty or mismatched",
"Simulation context is missing.": "Simulation context is missing.",
"Simulation time": "Simulation time",
@@ -6563,6 +6584,7 @@
"Trading insights, accounting, advisory": "Trading insights, accounting, advisory",
"Traditional Chinese": "Traditional Chinese",
"Transfer": "Transfer",
"Undo": "Undo",
"Unknown OAuth provider": "Unknown OAuth provider",
"Unknown rule name": "Unknown rule name",
"Unknown setting type: {{type}}": "Unknown setting type: {{type}}",
@@ -6801,6 +6823,7 @@
"URL": "URL",
"URL is required": "URL is required",
"URL to your logo image (optional)": "URL to your logo image (optional)",
"Use the same priority as the highlighted channels": "Use the same priority as the highlighted channels",
"User does not exist": "User does not exist",
"US dollar (USD)": "US dollar (USD)",
"Usage": "Usage",
@@ -7241,6 +7264,7 @@
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.",
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.",
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.",
"You do not have permission to edit channels": "You do not have permission to edit channels",
"You do not have permission to edit sensitive channel settings.": "You do not have permission to edit sensitive channel settings.",
"You do not have permission to perform this action.": "You do not have permission to perform this action.",
"You don't have necessary permission": "You don't have necessary permission",
+24
View File
@@ -3,6 +3,7 @@
"360": "360",
"1000": "1000",
"10000": "10000",
"{{count}} outside this view": "{{count}} hors de cette vue",
"_copy": "_copie",
",": ", ",
", and": ", et",
@@ -605,6 +606,7 @@
"API version": "Version API",
"API Version": "Version de l'API",
"API2GPT": "API2GPT",
"Ascending": "Croissant",
"Audio input cost {{quota}}": "Coût de l'entrée audio {{quota}}",
"App": "Application",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "Les classements d'applications présentés ici sont simulés à des fins de prévisualisation et seront remplacés par des données réelles une fois l'intégration du backend terminée.",
@@ -917,6 +919,7 @@
"Binding your {{provider}} account": "Liaison de votre compte {{provider}}",
"Binding...": "Liaison en cours...",
"Bindings": "Liages",
"Calculating priority...": "Calcul de la priorité…",
"Callback URL configuration error": "Erreur de configuration de l'URL de rappel",
"Blacklist": "Liste noire",
"Blacklist (Block listed domains)": "Liste noire (Bloquer les domaines listés)",
@@ -1046,6 +1049,9 @@
"Change your display name and personal settings.": "Modifier votre nom d’affichage et vos paramètres personnels.",
"Changed": "Modifications",
"Changed / Total": "Modifiés / Total",
"Channel priorities changed. Refresh and try again.": "Les priorités ont changé. Actualisez et réessayez.",
"Channel priorities saved": "Priorités des canaux enregistrées",
"Channel priority is outside the sortable range": "La priorité dépasse la plage de tri",
"Check-in failed, please try again later": "Échec de l'enregistrement, veuillez réessayer plus tard",
"Check-in failed: quota update error": "Échec de l'enregistrement : erreur de mise à jour du quota",
"Check-in feature is not enabled": "La fonction d'enregistrement n'est pas activée",
@@ -1860,6 +1866,8 @@
"Deleted a deployment": "Déploiement supprimé",
"Deleted a model": "Modèle supprimé",
"Deleted a passkey": "Clé d'accès supprimée",
"Descending": "Décroissant",
"Disable tag grouping to drag channels": "Désactivez le regroupement par étiquette pour déplacer les canaux",
"Disabled {{count}} keys": "{{count}} clés désactivées",
"Deleted a prefill group": "Groupe de préremplissage supprimé",
"Deleted a redemption code": "Code de réduction supprimé",
@@ -2013,6 +2021,8 @@
"Downgrade to this group after the subscription expires": "Rétrograder vers ce groupe après l'expiration de l'abonnement",
"Download": "Télécharger",
"Drag {{group}} to reorder": "Faites glisser {{group}} pour réorganiser",
"Drag mode": "Mode glisser-déposer",
"Drag to change priority. Use arrow keys to move, Escape to cancel.": "Glissez pour modifier la priorité. Flèches pour déplacer, Échap pour annuler.",
"Drag to move": "Glisser pour déplacer",
"Drag to resize": "Glisser pour redimensionner",
"Draw": "Dessin",
@@ -2022,6 +2032,7 @@
"Drawing task polling": "Interrogation des tâches de dessin",
"Drawing task records": "Historique des tâches de dessin",
"Drop a JavaScript plugin file here": "Déposez ici un fichier de plugin JavaScript",
"Drop between tiers to change priority, or in the center to join a tier": "Déposez entre les niveaux pour changer la priorité, au centre pour rejoindre un niveau",
"Dry run result": "Résultat de la simulation",
"Duplicate": "Dupliquer",
"Duplicate group names: {{names}}": "Noms de groupe en double : {{names}}",
@@ -2765,6 +2776,7 @@
"Find": "Rechercher",
"Find the billing group.": "Trouver le groupe de facturation.",
"Find the ratio.": "Trouver le taux.",
"Finish reordering": "Terminer le tri",
"Finish Time": "Heure de fin",
"Finished requests": "Requêtes terminées",
"First API request": "Première requête API",
@@ -3146,6 +3158,7 @@
"Important": "Important",
"Invalid channel ID": "ID de canal invalide",
"Importing metadata does not add channels, enable model access, or configure prices.": "L’import ne crée pas de canaux, n’ouvre pas l’accès aux modèles et ne configure pas les prix.",
"Invalid channel priority move": "Déplacement de priorité non valide",
"Invalid channel settings format: {{error}}": "Format des paramètres du canal invalide : {{error}}",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Dans BotFather, ouvrez Login Widget, enregistrez cette URL de rappel et copiez le Client ID et le Client Secret. Les comptes Telegram déjà liés fonctionneront après la configuration.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "En JSON, le groupe d’utilisateurs est la clé externe et le groupe de facturation la clé interne. L’exemple ci-dessous signifie : les utilisateurs vip paient 0,8 sous standard et 0,3 sous premium.",
@@ -3268,6 +3281,7 @@
"Invalid JSON string.": "Chaîne JSON invalide.",
"Invalid model mapping format": "Format de mappage de modèle invalide",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Domaine de clé d’accès invalide. Saisissez un domaine sans protocole, port, chemin ni caractère générique.",
"Join the same priority tier": "Rejoindre le même niveau de priorité",
"Key deleted": "Clé supprimée",
"Key disabled": "Clé désactivée",
"Key enabled": "Clé activée",
@@ -3821,6 +3835,8 @@
"ms": "ms",
"Multi-key channel: Keys will be": "Canal multi-clés : Les clés seront",
"Multi-Key Management": "Gestion multi-clés",
"New priority: {{priority}}": "Nouvelle priorité : {{priority}}",
"New priority: {{priority}} · {{count}} channels affected": "Nouvelle priorité : {{priority}} · {{count}} canaux concernés",
"New user registration has been disabled by administrator": "L’inscription de nouveaux utilisateurs a été désactivée par l’administrateur",
"New user sign-up bonus {{quota}}": "Bonus d'inscription {{quota}}",
"Multi-key management {{action}} on channel (ID: {{id}})": "Gestion multi-clés {{action}} sur le canal (ID : {{id}})",
@@ -5073,6 +5089,8 @@
"Reinstall latest": "Réinstaller la dernière",
"Reject Reason": "Raison du rejet",
"Release details": "Détails de la version",
"Release to insert a new priority tier": "Relâcher pour insérer un nouveau niveau de priorité",
"Release to join priority {{priority}}": "Relâcher pour rejoindre la priorité {{priority}}",
"Released": "Sorti",
"Reload pricing": "Recharger les tarifs",
"Reload vendor details": "Recharger les informations",
@@ -5130,6 +5148,7 @@
"Renamed an access token": "Jeton d’accès renommé",
"Renamed successfully": "Renommé avec succès",
"Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.": "Renommer les métadonnées ne renomme pas les modèles des canaux et ne déplace pas les tarifs, qui restent liés au nom d’origine.",
"Reorder channel {{id}}": "Réordonner le canal {{id}}",
"Repair": "Réparer",
"Repair Channel Consistency": "Réparer la cohérence des canaux",
"Repair channel consistency?": "Réparer la cohérence des canaux ?",
@@ -5777,6 +5796,8 @@
"Simulated request body": "Corps de la requête simulée",
"Simulated request cost": "Coût de la requête simulée",
"Simulated request headers": "En-têtes de la requête simulée",
"Sort by priority to drag channels": "Triez par priorité pour déplacer les canaux",
"Sort channels by": "Trier les canaux par",
"State parameter is empty or mismatched": "Le paramètre state est vide ou ne correspond pas",
"Simulation context is missing.": "Le contexte nécessaire à la simulation est manquant.",
"Simulation time": "Heure de simulation",
@@ -6563,6 +6584,7 @@
"Trading insights, accounting, advisory": "Analyses de marché, comptabilité, conseil",
"Traditional Chinese": "Chinois traditionnel",
"Transfer": "Transférer",
"Undo": "Annuler",
"Unknown OAuth provider": "Fournisseur OAuth inconnu",
"Unknown rule name": "Nom de règle inconnu",
"Unknown setting type: {{type}}": "Type de paramètre inconnu : {{type}}",
@@ -6801,6 +6823,7 @@
"URL": "URL",
"URL is required": "L'URL est requise",
"URL to your logo image (optional)": "URL de votre image de logo (facultatif)",
"Use the same priority as the highlighted channels": "Utiliser la même priorité que les canaux surlignés",
"User does not exist": "L’utilisateur n’existe pas",
"US dollar (USD)": "Dollar américain (USD)",
"Usage": "Utilisation",
@@ -7241,6 +7264,7 @@
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Vous pouvez toujours modifier les champs opérationnels non sensibles, comme les modèles, les groupes, la priorité et le poids.",
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Vous vous engagez à ne pas utiliser ce système pour mettre en œuvre, faciliter ou indirectement réaliser des actes violant les lois et règlements applicables, les exigences réglementaires, les règles des plateformes, l’intérêt public ou les droits et intérêts légitimes de tiers.",
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Vous vous engagez à utiliser les API, comptes, clés, quotas et capacités de service en amont uniquement dans le cadre d’une autorisation légale obtenue auprès des fournisseurs de services en amont, fournisseurs de modèles ou ayants droit concernés, et à ne pas effectuer de revente, trafic, distribution ou autre commercialisation non conforme sans autorisation.",
"You do not have permission to edit channels": "Vous n’avez pas le droit de modifier les canaux",
"You do not have permission to edit sensitive channel settings.": "Vous n’avez pas l’autorisation de modifier les paramètres sensibles des canaux.",
"You do not have permission to perform this action.": "Vous n’avez pas l’autorisation d’effectuer cette action.",
"You don't have necessary permission": "Vous n'avez pas la permission nécessaire",
+24
View File
@@ -3,6 +3,7 @@
"360": "360",
"1000": "1000",
"10000": "10000",
"{{count}} outside this view": "うち {{count}} 件は表示範囲外",
"_copy": "_copy",
",": "、",
", and": "、および",
@@ -605,6 +606,7 @@
"API version": "API バージョン",
"API Version": "API バージョン",
"API2GPT": "API2GPT",
"Ascending": "昇順",
"Audio input cost {{quota}}": "音声入力の費用 {{quota}}",
"App": "アプリ",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "ここに表示されているアプリランキングはプレビュー用のシミュレーションデータです。バックエンド連携の完了後、実データに置き換えられます。",
@@ -917,6 +919,7 @@
"Binding your {{provider}} account": "{{provider}} アカウントをバインド中",
"Binding...": "連携中...",
"Bindings": "バインド",
"Calculating priority...": "優先度を計算中…",
"Callback URL configuration error": "コールバック URL の設定エラー",
"Blacklist": "ブラックリスト",
"Blacklist (Block listed domains)": "ブラックリスト (ブロックされたドメイン)",
@@ -1046,6 +1049,9 @@
"Change your display name and personal settings.": "表示名と個人設定を変更します。",
"Changed": "変更",
"Changed / Total": "変更件数 / 合計",
"Channel priorities changed. Refresh and try again.": "優先度が変更されました。更新して再試行してください。",
"Channel priorities saved": "チャンネルの優先度を保存しました",
"Channel priority is outside the sortable range": "優先度が並べ替え可能な範囲外です",
"Check-in failed, please try again later": "チェックインに失敗しました。しばらくしてから再試行してください",
"Check-in failed: quota update error": "チェックインに失敗しました:クォータの更新でエラーが発生しました",
"Check-in feature is not enabled": "チェックイン機能は有効になっていません",
@@ -1860,6 +1866,8 @@
"Deleted a deployment": "デプロイメントを削除しました",
"Deleted a model": "モデルを削除しました",
"Deleted a passkey": "パスキーを削除しました",
"Descending": "降順",
"Disable tag grouping to drag channels": "タグのグループ化を解除するとドラッグできます",
"Disabled {{count}} keys": "{{count}} 個のキーを無効にしました",
"Deleted a prefill group": "プリフィルグループを削除しました",
"Deleted a redemption code": "引換コードを削除しました",
@@ -2013,6 +2021,8 @@
"Downgrade to this group after the subscription expires": "サブスクリプションの有効期限が切れた後、このグループにダウングレードします",
"Download": "ダウンロード",
"Drag {{group}} to reorder": "{{group}} をドラッグして並べ替え",
"Drag mode": "ドラッグモード",
"Drag to change priority. Use arrow keys to move, Escape to cancel.": "ドラッグで優先度を変更。矢印キーで移動、Esc でキャンセル。",
"Drag to move": "ドラッグして移動",
"Drag to resize": "ドラッグしてサイズ変更",
"Draw": "描画",
@@ -2022,6 +2032,7 @@
"Drawing task polling": "描画タスクのポーリング",
"Drawing task records": "描画タスク記録",
"Drop a JavaScript plugin file here": "JavaScript プラグインファイルをここにドロップ",
"Drop between tiers to change priority, or in the center to join a tier": "優先度を変えるには段階の間、同じ段階にするには中央にドロップ",
"Dry run result": "ドライラン結果",
"Duplicate": "複製",
"Duplicate group names: {{names}}": "重複するグループ名: {{names}}",
@@ -2765,6 +2776,7 @@
"Find": "検索文字",
"Find the billing group.": "課金グループを特定する。",
"Find the ratio.": "倍率を特定する。",
"Finish reordering": "並べ替えを完了",
"Finish Time": "完了時刻",
"Finished requests": "終了したリクエスト",
"First API request": "最初の API リクエスト",
@@ -3146,6 +3158,7 @@
"Important": "重要",
"Invalid channel ID": "チャネル ID が無効です",
"Importing metadata does not add channels, enable model access, or configure prices.": "メタデータのインポートでは、チャネルの追加、モデルへのアクセス許可、料金設定は行いません。",
"Invalid channel priority move": "チャンネルの優先度変更が無効です",
"Invalid channel settings format: {{error}}": "チャネル設定の形式が正しくありません:{{error}}",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "BotFather で Login Widget を開き、このコールバック URL を登録して Client ID と Client Secret をコピーしてください。設定後は既存の Telegram 連携を引き続き利用できます。",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "JSONでは外側のキーがユーザーグループ、内側のキーが課金グループです。以下の例は、vip ユーザーが standard として課金されると 0.8、premium として課金されると 0.3 を意味します。",
@@ -3268,6 +3281,7 @@
"Invalid JSON string.": "無効な JSON 文字列です。",
"Invalid model mapping format": "無効なモデルマッピング形式",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "パスキードメインが無効です。プロトコル、ポート、パス、ワイルドカードを含めず、ドメインを入力してください。",
"Join the same priority tier": "同じ優先度に設定",
"Key deleted": "キーを削除しました",
"Key disabled": "キーを無効にしました",
"Key enabled": "キーを有効にしました",
@@ -3821,6 +3835,8 @@
"ms": "ms",
"Multi-key channel: Keys will be": "マルチキーチャネル: キーは",
"Multi-Key Management": "マルチキー管理",
"New priority: {{priority}}": "新しい優先度:{{priority}}",
"New priority: {{priority}} · {{count}} channels affected": "新しい優先度:{{priority}} · {{count}} チャンネルに影響",
"New user registration has been disabled by administrator": "管理者により新規ユーザー登録が無効化されています",
"New user sign-up bonus {{quota}}": "新規登録ボーナス {{quota}}",
"Multi-key management {{action}} on channel (ID: {{id}})": "チャネル(ID: {{id}})でマルチキー管理操作 {{action}} を実行しました",
@@ -5073,6 +5089,8 @@
"Reinstall latest": "最新版を再インストール",
"Reject Reason": "拒否理由",
"Release details": "リリース詳細",
"Release to insert a new priority tier": "離して新しい優先度を挿入",
"Release to join priority {{priority}}": "離して優先度 {{priority}} に統合",
"Released": "公開日",
"Reload pricing": "料金を再読み込み",
"Reload vendor details": "プロバイダー情報を再読み込み",
@@ -5130,6 +5148,7 @@
"Renamed an access token": "アクセストークンの名前を変更しました",
"Renamed successfully": "名前が正常に変更されました",
"Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.": "メタデータの名前を変更しても、チャネルのモデル名や料金は移行されません。既存の料金は元のモデル名に紐付きます。",
"Reorder channel {{id}}": "チャンネル {{id}} の優先度を変更",
"Repair": "修復",
"Repair Channel Consistency": "チャネル整合性を修復",
"Repair channel consistency?": "チャネル整合性を修復しますか?",
@@ -5777,6 +5796,8 @@
"Simulated request body": "シミュレーション用リクエスト本文",
"Simulated request cost": "シミュレーションしたリクエストの料金",
"Simulated request headers": "シミュレーション用リクエストヘッダー",
"Sort by priority to drag channels": "優先度順にするとドラッグできます",
"Sort channels by": "チャンネルの並べ替え基準",
"State parameter is empty or mismatched": "state パラメーターが空か一致しません",
"Simulation context is missing.": "シミュレーションに必要なコンテキストがありません。",
"Simulation time": "シミュレーション時刻",
@@ -6563,6 +6584,7 @@
"Trading insights, accounting, advisory": "トレーディング分析・会計・アドバイザリー",
"Traditional Chinese": "繁体字中国語",
"Transfer": "振替",
"Undo": "元に戻す",
"Unknown OAuth provider": "不明な OAuth プロバイダーです",
"Unknown rule name": "不明なルール名です",
"Unknown setting type: {{type}}": "不明な設定タイプです:{{type}}",
@@ -6801,6 +6823,7 @@
"URL": "URL",
"URL is required": "URL は必須です",
"URL to your logo image (optional)": "ロゴ画像のURL (オプション)",
"Use the same priority as the highlighted channels": "強調表示されたチャネルと同じ優先度にします",
"User does not exist": "ユーザーが存在しません",
"US dollar (USD)": "米ドル(USD)",
"Usage": "使用量",
@@ -7241,6 +7264,7 @@
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "モデル、グループ、優先度、重みなどの非機密の運用項目は引き続き編集できます。",
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "適用される法令、規制要件、プラットフォーム規則、公共の利益、または第三者の正当な権利利益に違反する行為を、このシステムを用いて実施、支援、または間接的に実施しないことを約束します。",
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "上流 API、アカウント、キー、クォータ、サービス機能を、上流サービス提供者、モデルサービス提供者、または関連する権利者から取得した合法的な許可の範囲内でのみ使用し、無許可の再販売、転売、配布、その他の不適切な商業利用を行わないことを約束します。",
"You do not have permission to edit channels": "チャンネルを編集する権限がありません",
"You do not have permission to edit sensitive channel settings.": "機密チャネル設定を編集する権限がありません。",
"You do not have permission to perform this action.": "この操作を実行する権限がありません。",
"You don't have necessary permission": "必要な権限がありません",
+24
View File
@@ -3,6 +3,7 @@
"360": "360",
"1000": "1000",
"10000": "10000",
"{{count}} outside this view": "{{count}} вне текущего представления",
"_copy": "_копировать",
",": ", ",
", and": ", и",
@@ -605,6 +606,7 @@
"API version": "Версия API",
"API Version": "Версия API",
"API2GPT": "API2GPT",
"Ascending": "По возрастанию",
"Audio input cost {{quota}}": "Стоимость аудиовхода {{quota}}",
"App": "Приложение",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "Показанный рейтинг приложений сгенерирован для предпросмотра и будет заменён реальными данными после интеграции бэкенда.",
@@ -917,6 +919,7 @@
"Binding your {{provider}} account": "Привязка вашего аккаунта {{provider}}",
"Binding...": "Привязка...",
"Bindings": "Привязки",
"Calculating priority...": "Расчёт приоритета…",
"Callback URL configuration error": "Ошибка настройки URL обратного вызова",
"Blacklist": "Чёрный список",
"Blacklist (Block listed domains)": "Черный список (Блокировка доменов из списка)",
@@ -1046,6 +1049,9 @@
"Change your display name and personal settings.": "Изменение отображаемого имени и личных настроек.",
"Changed": "Изменено",
"Changed / Total": "Изменено / Всего",
"Channel priorities changed. Refresh and try again.": "Приоритеты изменились. Обновите страницу и повторите попытку.",
"Channel priorities saved": "Приоритеты каналов сохранены",
"Channel priority is outside the sortable range": "Приоритет канала вне допустимого диапазона сортировки",
"Check-in failed, please try again later": "Не удалось отметиться, попробуйте позже",
"Check-in failed: quota update error": "Не удалось отметиться: ошибка обновления квоты",
"Check-in feature is not enabled": "Функция отметок не включена",
@@ -1860,6 +1866,8 @@
"Deleted a deployment": "Удалено развёртывание",
"Deleted a model": "Удалена модель",
"Deleted a passkey": "Ключ доступа удалён",
"Descending": "По убыванию",
"Disable tag grouping to drag channels": "Отключите группировку по тегам для перетаскивания",
"Disabled {{count}} keys": "Отключено ключей: {{count}}",
"Deleted a prefill group": "Удалена группа предзаполнения",
"Deleted a redemption code": "Код погашения удалён",
@@ -2013,6 +2021,8 @@
"Downgrade to this group after the subscription expires": "Понизить до этой группы после истечения подписки",
"Download": "Скачать",
"Drag {{group}} to reorder": "Перетащите {{group}}, чтобы изменить порядок",
"Drag mode": "Режим перетаскивания",
"Drag to change priority. Use arrow keys to move, Escape to cancel.": "Перетащите для изменения приоритета. Стрелки — перемещение, Esc — отмена.",
"Drag to move": "Перетащите, чтобы переместить",
"Drag to resize": "Перетащите, чтобы изменить размер",
"Draw": "Рисование",
@@ -2022,6 +2032,7 @@
"Drawing task polling": "Опрос задач рисования",
"Drawing task records": "Записи задач рисования",
"Drop a JavaScript plugin file here": "Перетащите сюда файл плагина JavaScript",
"Drop between tiers to change priority, or in the center to join a tier": "Перетащите между уровнями для изменения приоритета или в центр для объединения",
"Dry run result": "Результат пробного запуска",
"Duplicate": "Дублировать",
"Duplicate group names: {{names}}": "Повторяющиеся имена групп: {{names}}",
@@ -2765,6 +2776,7 @@
"Find": "Найти",
"Find the billing group.": "Определите тарифную группу.",
"Find the ratio.": "Определите коэффициент.",
"Finish reordering": "Завершить сортировку",
"Finish Time": "Время завершения",
"Finished requests": "Завершённые запросы",
"First API request": "Первый API-запрос",
@@ -3146,6 +3158,7 @@
"Important": "Важно",
"Invalid channel ID": "Неверный ID канала",
"Importing metadata does not add channels, enable model access, or configure prices.": "Импорт метаданных не добавляет каналы, не открывает доступ к моделям и не задаёт цены.",
"Invalid channel priority move": "Недопустимое изменение приоритета канала",
"Invalid channel settings format: {{error}}": "Неверный формат настроек канала: {{error}}",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Откройте Login Widget в BotFather, зарегистрируйте этот URL обратного вызова и скопируйте Client ID и Client Secret. После настройки существующие привязки Telegram продолжат работать.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "В JSON внешний ключ — группа пользователя, внутренний — тарифная группа. Пример ниже означает: пользователи vip платят 0,8 по standard и 0,3 по premium.",
@@ -3268,6 +3281,7 @@
"Invalid JSON string.": "Недопустимая строка JSON.",
"Invalid model mapping format": "Неверный формат сопоставления моделей",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Недопустимый домен ключа доступа. Введите домен без протокола, порта, пути и подстановочных символов.",
"Join the same priority tier": "Присоединиться к тому же уровню приоритета",
"Key deleted": "Ключ удалён",
"Key disabled": "Ключ отключён",
"Key enabled": "Ключ включён",
@@ -3821,6 +3835,8 @@
"ms": "мс",
"Multi-key channel: Keys will be": "Многоключевой канал: Ключи будут",
"Multi-Key Management": "Управление несколькими ключами",
"New priority: {{priority}}": "Новый приоритет: {{priority}}",
"New priority: {{priority}} · {{count}} channels affected": "Новый приоритет: {{priority}} · затронуто каналов: {{count}}",
"New user registration has been disabled by administrator": "Администратор отключил регистрацию новых пользователей",
"New user sign-up bonus {{quota}}": "Бонус за регистрацию {{quota}}",
"Multi-key management {{action}} on channel (ID: {{id}})": "Управление мультиключами {{action}} для канала (ID: {{id}})",
@@ -5073,6 +5089,8 @@
"Reinstall latest": "Переустановить последнюю",
"Reject Reason": "Причина отклонения",
"Release details": "Детали релиза",
"Release to insert a new priority tier": "Отпустите, чтобы вставить новый уровень приоритета",
"Release to join priority {{priority}}": "Отпустите, чтобы присоединиться к приоритету {{priority}}",
"Released": "Выпущено",
"Reload pricing": "Перезагрузить цены",
"Reload vendor details": "Перезагрузить данные поставщика",
@@ -5130,6 +5148,7 @@
"Renamed an access token": "Переименован токен доступа",
"Renamed successfully": "Успешно переименовано",
"Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.": "Переименование метаданных не меняет имена моделей каналов и не переносит цены. Цены остаются у исходного имени модели.",
"Reorder channel {{id}}": "Изменить порядок канала {{id}}",
"Repair": "Восстановить",
"Repair Channel Consistency": "Восстановить согласованность каналов",
"Repair channel consistency?": "Восстановить согласованность каналов?",
@@ -5777,6 +5796,8 @@
"Simulated request body": "Тело моделируемого запроса",
"Simulated request cost": "Стоимость моделируемого запроса",
"Simulated request headers": "Заголовки моделируемого запроса",
"Sort by priority to drag channels": "Для перетаскивания выберите сортировку по приоритету",
"Sort channels by": "Сортировать каналы по",
"State parameter is empty or mismatched": "Параметр state пуст или не совпадает",
"Simulation context is missing.": "Отсутствует контекст для моделирования.",
"Simulation time": "Время моделирования",
@@ -6563,6 +6584,7 @@
"Trading insights, accounting, advisory": "Торговые инсайты, учёт, консалтинг",
"Traditional Chinese": "Традиционный китайский",
"Transfer": "Перевод",
"Undo": "Отменить",
"Unknown OAuth provider": "Неизвестный поставщик OAuth",
"Unknown rule name": "Неизвестное имя правила",
"Unknown setting type: {{type}}": "Неизвестный тип настройки: {{type}}",
@@ -6801,6 +6823,7 @@
"URL": "URL",
"URL is required": "URL обязателен",
"URL to your logo image (optional)": "URL изображения вашего логотипа (необязательно)",
"Use the same priority as the highlighted channels": "Использовать тот же приоритет, что и у выделенных каналов",
"User does not exist": "Пользователь не существует",
"US dollar (USD)": "Доллар США (USD)",
"Usage": "Использование",
@@ -7241,6 +7264,7 @@
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Вы по-прежнему можете изменять нечувствительные операционные поля, такие как модели, группы, приоритет и вес.",
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Вы обязуетесь не использовать эту систему для совершения, содействия или косвенного совершения действий, нарушающих применимые законы и нормы, регуляторные требования, правила платформ, общественные интересы либо законные права и интересы третьих лиц.",
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Вы обязуетесь использовать вышестоящие API, аккаунты, ключи, квоты и сервисные возможности только в пределах законного разрешения, полученного от вышестоящих поставщиков услуг, поставщиков моделей или соответствующих правообладателей, и не осуществлять несанкционированную перепродажу, оборот, распространение или иную несоответствующую коммерциализацию.",
"You do not have permission to edit channels": "У вас нет прав на редактирование каналов",
"You do not have permission to edit sensitive channel settings.": "У вас нет права изменять чувствительные настройки каналов.",
"You do not have permission to perform this action.": "У вас нет прав на выполнение этого действия.",
"You don't have necessary permission": "У вас нет необходимых разрешений",
+24
View File
@@ -3,6 +3,7 @@
"360": "360",
"1000": "1000",
"10000": "10000",
"{{count}} outside this view": "{{count}} kênh ngoài chế độ xem này",
"_copy": "_bản sao",
",": ", ",
", and": ", và",
@@ -605,6 +606,7 @@
"API version": "Phiên bản API",
"API Version": "Phiên bản API",
"API2GPT": "API2GPT",
"Ascending": "Tăng dần",
"Audio input cost {{quota}}": "Chi phí đầu vào âm thanh {{quota}}",
"App": "Ứng dụng",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "Bảng xếp hạng ứng dụng hiển thị tại đây là dữ liệu mô phỏng để xem trước và sẽ được thay thế bằng dữ liệu thực sau khi tích hợp backend.",
@@ -917,6 +919,7 @@
"Binding your {{provider}} account": "Đang liên kết tài khoản {{provider}} của bạn",
"Binding...": "Đang liên kết...",
"Bindings": "Ràng buộc",
"Calculating priority...": "Đang tính độ ưu tiên…",
"Callback URL configuration error": "Lỗi cấu hình URL callback",
"Blacklist": "Danh sách đen",
"Blacklist (Block listed domains)": "Danh sách đen (Các tên miền bị chặn)",
@@ -1046,6 +1049,9 @@
"Change your display name and personal settings.": "Thay đổi tên hiển thị và cài đặt cá nhân.",
"Changed": "Đã thay đổi",
"Changed / Total": "Đã thay đổi / Tổng số",
"Channel priorities changed. Refresh and try again.": "Độ ưu tiên đã thay đổi. Hãy làm mới và thử lại.",
"Channel priorities saved": "Đã lưu độ ưu tiên kênh",
"Channel priority is outside the sortable range": "Độ ưu tiên kênh nằm ngoài phạm vi sắp xếp",
"Check-in failed, please try again later": "Điểm danh thất bại, vui lòng thử lại sau",
"Check-in failed: quota update error": "Điểm danh thất bại: lỗi cập nhật hạn mức",
"Check-in feature is not enabled": "Tính năng điểm danh chưa được bật",
@@ -1860,6 +1866,8 @@
"Deleted a deployment": "Đã xóa một triển khai",
"Deleted a model": "Đã xóa một mô hình",
"Deleted a passkey": "Đã xóa một passkey",
"Descending": "Giảm dần",
"Disable tag grouping to drag channels": "Tắt nhóm theo thẻ để kéo kênh",
"Disabled {{count}} keys": "Đã tắt {{count}} khóa",
"Deleted a prefill group": "Đã xóa một nhóm điền sẵn",
"Deleted a redemption code": "Đã xóa một mã đổi thưởng",
@@ -2013,6 +2021,8 @@
"Downgrade to this group after the subscription expires": "Hạ xuống nhóm này sau khi đăng ký hết hạn",
"Download": "Tải xuống",
"Drag {{group}} to reorder": "Kéo {{group}} để sắp xếp lại",
"Drag mode": "Chế độ kéo thả",
"Drag to change priority. Use arrow keys to move, Escape to cancel.": "Kéo để đổi độ ưu tiên. Dùng phím mũi tên để di chuyển, Esc để hủy.",
"Drag to move": "Kéo để di chuyển",
"Drag to resize": "Kéo để thay đổi kích cỡ",
"Draw": "Vẽ",
@@ -2022,6 +2032,7 @@
"Drawing task polling": "Thăm dò tác vụ vẽ",
"Drawing task records": "Lịch sử tác vụ vẽ",
"Drop a JavaScript plugin file here": "Kéo tệp plugin JavaScript vào đây",
"Drop between tiers to change priority, or in the center to join a tier": "Thả giữa các mức để đổi độ ưu tiên, hoặc vào giữa để tham gia cùng mức",
"Dry run result": "Kết quả chạy thử",
"Duplicate": "Nhân bản",
"Duplicate group names: {{names}}": "Tên nhóm bị trùng: {{names}}",
@@ -2765,6 +2776,7 @@
"Find": "Tìm",
"Find the billing group.": "Xác định nhóm tính phí.",
"Find the ratio.": "Xác định hệ số.",
"Finish reordering": "Hoàn tất sắp xếp",
"Finish Time": "Thời gian hoàn thành",
"Finished requests": "Yêu cầu đã kết thúc",
"First API request": "Yêu cầu API đầu tiên",
@@ -3146,6 +3158,7 @@
"Important": "Quan trọng",
"Invalid channel ID": "ID kênh không hợp lệ",
"Importing metadata does not add channels, enable model access, or configure prices.": "Nhập siêu dữ liệu không thêm kênh, mở quyền truy cập mô hình hay cấu hình giá.",
"Invalid channel priority move": "Thay đổi độ ưu tiên kênh không hợp lệ",
"Invalid channel settings format: {{error}}": "Định dạng cài đặt kênh không hợp lệ: {{error}}",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Trong BotFather, mở Login Widget, đăng ký URL gọi lại này và sao chép Client ID cùng Client Secret. Các liên kết Telegram hiện có sẽ tiếp tục hoạt động sau khi cấu hình.",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "Trong JSON, khóa ngoài là nhóm người dùng, khóa trong là nhóm tính phí. Ví dụ dưới đây nghĩa là: người dùng vip trả 0.8 khi tính phí theo standard và 0.3 khi theo premium.",
@@ -3268,6 +3281,7 @@
"Invalid JSON string.": "Chuỗi JSON không hợp lệ.",
"Invalid model mapping format": "Định dạng ánh xạ mô hình không hợp lệ",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Tên miền khóa truy cập không hợp lệ. Chỉ nhập tên miền, không có giao thức, cổng, đường dẫn hoặc ký tự đại diện.",
"Join the same priority tier": "Tham gia cùng mức ưu tiên",
"Key deleted": "Đã xóa khóa",
"Key disabled": "Đã tắt khóa",
"Key enabled": "Đã bật khóa",
@@ -3821,6 +3835,8 @@
"ms": "ms",
"Multi-key channel: Keys will be": "Kênh đa khóa: Các khóa sẽ là",
"Multi-Key Management": "Quản lý đa khóa",
"New priority: {{priority}}": "Mức ưu tiên mới: {{priority}}",
"New priority: {{priority}} · {{count}} channels affected": "Độ ưu tiên mới: {{priority}} · ảnh hưởng {{count}} kênh",
"New user registration has been disabled by administrator": "Quản trị viên đã tắt đăng ký người dùng mới",
"New user sign-up bonus {{quota}}": "Thưởng đăng ký người dùng mới {{quota}}",
"Multi-key management {{action}} on channel (ID: {{id}})": "Quản lý đa khóa {{action}} trên kênh (ID: {{id}})",
@@ -5073,6 +5089,8 @@
"Reinstall latest": "Cài lại bản mới nhất",
"Reject Reason": "Lý do từ chối",
"Release details": "Chi tiết phiên bản",
"Release to insert a new priority tier": "Thả để chèn mức ưu tiên mới",
"Release to join priority {{priority}}": "Thả để ghép vào mức ưu tiên {{priority}}",
"Released": "Phát hành",
"Reload pricing": "Tải lại giá",
"Reload vendor details": "Tải lại thông tin nhà cung cấp",
@@ -5130,6 +5148,7 @@
"Renamed an access token": "Đã đổi tên token truy cập",
"Renamed successfully": "Đổi tên thành công",
"Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.": "Đổi tên siêu dữ liệu không đổi tên mô hình trong kênh hay chuyển giá. Giá hiện có vẫn gắn với tên mô hình ban đầu.",
"Reorder channel {{id}}": "Đổi thứ tự kênh {{id}}",
"Repair": "Sửa",
"Repair Channel Consistency": "Sửa tính nhất quán kênh",
"Repair channel consistency?": "Sửa tính nhất quán kênh?",
@@ -5777,6 +5796,8 @@
"Simulated request body": "Nội dung yêu cầu mô phỏng",
"Simulated request cost": "Chi phí yêu cầu mô phỏng",
"Simulated request headers": "Tiêu đề yêu cầu mô phỏng",
"Sort by priority to drag channels": "Sắp xếp theo độ ưu tiên để kéo kênh",
"Sort channels by": "Sắp xếp kênh theo",
"State parameter is empty or mismatched": "Tham số state trống hoặc không khớp",
"Simulation context is missing.": "Thiếu ngữ cảnh cần thiết để mô phỏng.",
"Simulation time": "Thời gian mô phỏng",
@@ -6563,6 +6584,7 @@
"Trading insights, accounting, advisory": "Phân tích giao dịch, kế toán, tư vấn",
"Traditional Chinese": "Tiếng Trung phồn thể",
"Transfer": "Chuyển",
"Undo": "Hoàn tác",
"Unknown OAuth provider": "Nhà cung cấp OAuth không xác định",
"Unknown rule name": "Tên quy tắc không xác định",
"Unknown setting type: {{type}}": "Loại cài đặt không xác định: {{type}}",
@@ -6801,6 +6823,7 @@
"URL": "URL",
"URL is required": "URL là bắt buộc",
"URL to your logo image (optional)": "URL hình ảnh logo của bạn (tùy chọn)",
"Use the same priority as the highlighted channels": "Dùng cùng mức ưu tiên với các kênh được tô sáng",
"User does not exist": "Người dùng không tồn tại",
"US dollar (USD)": "Đô la Mỹ (USD)",
"Usage": "Sử dụng",
@@ -7241,6 +7264,7 @@
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Bạn vẫn có thể chỉnh sửa các trường vận hành không nhạy cảm như mô hình, nhóm, độ ưu tiên và trọng số.",
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Bạn cam kết không sử dụng hệ thống này để thực hiện, hỗ trợ hoặc gián tiếp thực hiện các hành vi vi phạm luật và quy định hiện hành, yêu cầu quản lý, quy tắc nền tảng, lợi ích công cộng hoặc quyền và lợi ích hợp pháp của bên thứ ba.",
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Bạn cam kết chỉ sử dụng API upstream, tài khoản, khóa, hạn mức và năng lực dịch vụ trong phạm vi ủy quyền hợp pháp nhận được từ nhà cung cấp dịch vụ upstream, nhà cung cấp mô hình hoặc chủ thể quyền liên quan, và sẽ không thực hiện bán lại, giao dịch, phân phối trái phép hoặc thương mại hóa không tuân thủ khác.",
"You do not have permission to edit channels": "Bạn không có quyền chỉnh sửa kênh",
"You do not have permission to edit sensitive channel settings.": "Bạn không có quyền chỉnh sửa cài đặt kênh nhạy cảm.",
"You do not have permission to perform this action.": "Bạn không có quyền thực hiện thao tác này.",
"You don't have necessary permission": "Bạn không có quyền cần thiết",
+24
View File
@@ -3,6 +3,7 @@
"360": "360",
"1000": "1000",
"10000": "10000",
"{{count}} outside this view": "其中 {{count}} 個不在目前檢視",
"_copy": "_複製",
",": ",",
", and": ",和",
@@ -605,6 +606,7 @@
"API version": "API 版本",
"API Version": "API 版本",
"API2GPT": "API2GPT",
"Ascending": "升冪",
"Audio input cost {{quota}}": "音訊輸入花費 {{quota}}",
"App": "套用程式",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "此處展示的套用排行僅為預覽用模擬數據,待後端對接完成後將替換為真實數據。",
@@ -917,6 +919,7 @@
"Binding your {{provider}} account": "正在連結您的 {{provider}} 賬號",
"Binding...": "連結中...",
"Bindings": "連結",
"Calculating priority...": "正在計算優先級…",
"Callback URL configuration error": "回調位址設定錯誤",
"Blacklist": "黑名單",
"Blacklist (Block listed domains)": "黑名單(已阻止的域)",
@@ -1046,6 +1049,9 @@
"Change your display name and personal settings.": "修改顯示名稱和個人設定。",
"Changed": "變更",
"Changed / Total": "已變更 / 總數",
"Channel priorities changed. Refresh and try again.": "通道優先級已變更,請重新整理後再試。",
"Channel priorities saved": "通道優先級已儲存",
"Channel priority is outside the sortable range": "通道優先級超出可排序範圍",
"Check-in failed, please try again later": "簽到失敗,請稍後重試",
"Check-in failed: quota update error": "簽到失敗:更新額度出錯",
"Check-in feature is not enabled": "簽到功能未啟用",
@@ -1860,6 +1866,8 @@
"Deleted a deployment": "刪除了一個部署",
"Deleted a model": "刪除了一個模型",
"Deleted a passkey": "解綁了一個 Passkey",
"Descending": "降冪",
"Disable tag grouping to drag channels": "關閉標籤彙整後可拖曳通道",
"Disabled {{count}} keys": "已停用 {{count}} 個金鑰",
"Deleted a prefill group": "刪除了一個預填組",
"Deleted a redemption code": "刪除了一個兌換碼",
@@ -2013,6 +2021,8 @@
"Downgrade to this group after the subscription expires": "訂閱過期後降級到該分組",
"Download": "下載",
"Drag {{group}} to reorder": "拖曳 {{group}} 以重新排序",
"Drag mode": "拖曳模式",
"Drag to change priority. Use arrow keys to move, Escape to cancel.": "拖曳調整優先級,也可使用方向鍵移動,Esc 取消。",
"Drag to move": "拖曳以移動",
"Drag to resize": "拖曳以調整大小",
"Draw": "繪圖",
@@ -2022,6 +2032,7 @@
"Drawing task polling": "繪圖任務輪詢",
"Drawing task records": "繪圖任務記錄",
"Drop a JavaScript plugin file here": "將 JavaScript 外掛檔案拖放到此處",
"Drop between tiers to change priority, or in the center to join a tier": "拖到級別之間調整優先級,拖到中間加入同級",
"Dry run result": "試跑結果",
"Duplicate": "重複",
"Duplicate group names: {{names}}": "存在重複的分組名稱:{{names}}",
@@ -2765,6 +2776,7 @@
"Find": "尋找",
"Find the billing group.": "確定收費分組。",
"Find the ratio.": "確定倍率。",
"Finish reordering": "完成排序",
"Finish Time": "完成時間",
"Finished requests": "已結束請求",
"First API request": "首個 API 請求",
@@ -3146,6 +3158,7 @@
"Important": "重要",
"Invalid channel ID": "渠道 ID 格式錯誤",
"Importing metadata does not add channels, enable model access, or configure prices.": "匯入中繼資料不會新增渠道、開放模型存取或設定價格。",
"Invalid channel priority move": "無效的通道優先級調整",
"Invalid channel settings format: {{error}}": "渠道額外設定格式錯誤:{{error}}",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中開啟 Login Widget,登記此回呼網址並複製 Client ID 和 Client Secret。完成設定後,現有 Telegram 綁定可繼續使用。",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外層鍵是用戶分組,內層鍵是收費分組。下面的示例表示:vip 用戶按 standard 收費時用 0.8,按 premium 收費時用 0.3。",
@@ -3268,6 +3281,7 @@
"Invalid JSON string.": "無效的 JSON 字串。",
"Invalid model mapping format": "無效的模型映射格式",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。",
"Join the same priority tier": "加入同一優先級",
"Key deleted": "金鑰已刪除",
"Key disabled": "金鑰已停用",
"Key enabled": "金鑰已啟用",
@@ -3821,6 +3835,8 @@
"ms": "毫秒",
"Multi-key channel: Keys will be": "多金鑰渠道:金鑰將",
"Multi-Key Management": "多金鑰管理",
"New priority: {{priority}}": "新優先級:{{priority}}",
"New priority: {{priority}} · {{count}} channels affected": "新優先級:{{priority}} · 影響 {{count}} 個通道",
"New user registration has been disabled by administrator": "管理員關閉了新使用者註冊",
"New user sign-up bonus {{quota}}": "新使用者註冊贈送 {{quota}}",
"Multi-key management {{action}} on channel (ID: {{id}})": "對渠道(ID: {{id}})執行多金鑰管理操作 {{action}}",
@@ -5073,6 +5089,8 @@
"Reinstall latest": "重新安裝最新版",
"Reject Reason": "拒絕原因",
"Release details": "版本詳情",
"Release to insert a new priority tier": "放開以插入新優先級",
"Release to join priority {{priority}}": "放開以加入同級 · 優先級 {{priority}}",
"Released": "發佈於",
"Reload pricing": "重新載入定價",
"Reload vendor details": "重新載入供應商資料",
@@ -5130,6 +5148,7 @@
"Renamed an access token": "已重新命名存取令牌",
"Renamed successfully": "重新命名成功",
"Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.": "修改中繼資料名稱不會重新命名渠道模型或移轉定價,既有價格仍屬於原模型名稱。",
"Reorder channel {{id}}": "調整通道 {{id}} 的優先級",
"Repair": "修復",
"Repair Channel Consistency": "修復渠道一致性",
"Repair channel consistency?": "修復渠道一致性?",
@@ -5777,6 +5796,8 @@
"Simulated request body": "模擬請求主體",
"Simulated request cost": "模擬請求費用",
"Simulated request headers": "模擬請求標頭",
"Sort by priority to drag channels": "依優先級排序後可拖曳通道",
"Sort channels by": "通道排序依據",
"State parameter is empty or mismatched": "state 參數為空或不相符",
"Simulation context is missing.": "缺少模擬所需的內容。",
"Simulation time": "模擬時間",
@@ -6563,6 +6584,7 @@
"Trading insights, accounting, advisory": "交易洞察、記賬與財務建議",
"Traditional Chinese": "繁體中文",
"Transfer": "轉移",
"Undo": "復原",
"Unknown OAuth provider": "未知的 OAuth 供應商",
"Unknown rule name": "未知規則名稱",
"Unknown setting type: {{type}}": "未知的設定類型:{{type}}",
@@ -6801,6 +6823,7 @@
"URL": "URL",
"URL is required": "URL 為必填項",
"URL to your logo image (optional)": "您的徽標圖片 URL(可選)",
"Use the same priority as the highlighted channels": "與醒目顯示的渠道使用相同優先級",
"User does not exist": "使用者不存在",
"US dollar (USD)": "美元(USD)",
"Usage": "用量",
@@ -7241,6 +7264,7 @@
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "你仍可編輯模型、分組、優先級和權重等非敏感運維欄位。",
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "你承諾不會使用本系統實施、協助實施或間接實施違反適用法律法規、監管要求、平台規則、公共利益或第三方合法權益的行為。",
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "你承諾僅在從上游服務供應商、模型服務供應商或相關權利人處獲得合法授權的範圍內使用上游 API、用戶、金鑰、額度和服務能力,並不會進行未經授權的轉售、倒賣、分發或其他不合規商業化行為。",
"You do not have permission to edit channels": "你沒有編輯通道的權限",
"You do not have permission to edit sensitive channel settings.": "你沒有權限編輯敏感渠道設定。",
"You do not have permission to perform this action.": "你沒有執行此操作的權限。",
"You don't have necessary permission": "您沒有必要的權限",
+24
View File
@@ -3,6 +3,7 @@
"360": "360",
"1000": "1000",
"10000": "10000",
"{{count}} outside this view": "其中 {{count}} 个不在当前视图",
"_copy": "_复制",
",": ",",
", and": ",和",
@@ -605,6 +606,7 @@
"API version": "API 版本",
"API Version": "API 版本",
"API2GPT": "API2GPT",
"Ascending": "升序",
"Audio input cost {{quota}}": "音频输入花费 {{quota}}",
"App": "应用",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "此处展示的应用排行为预览模拟数据,待后端对接完成后将替换为真实数据。",
@@ -917,6 +919,7 @@
"Binding your {{provider}} account": "正在绑定您的 {{provider}} 账号",
"Binding...": "绑定中...",
"Bindings": "绑定",
"Calculating priority...": "正在计算优先级…",
"Callback URL configuration error": "回调地址配置错误",
"Blacklist": "黑名单",
"Blacklist (Block listed domains)": "黑名单(已阻止的域)",
@@ -1046,6 +1049,9 @@
"Change your display name and personal settings.": "修改显示名称和个人设置。",
"Changed": "变更",
"Changed / Total": "已变更 / 总数",
"Channel priorities changed. Refresh and try again.": "渠道优先级已变更,请刷新后重试。",
"Channel priorities saved": "渠道优先级已保存",
"Channel priority is outside the sortable range": "渠道优先级超出可排序范围",
"Check-in failed, please try again later": "签到失败,请稍后重试",
"Check-in failed: quota update error": "签到失败:更新额度出错",
"Check-in feature is not enabled": "签到功能未启用",
@@ -1860,6 +1866,8 @@
"Deleted a deployment": "删除了一个部署",
"Deleted a model": "删除了一个模型",
"Deleted a passkey": "解绑了一个 Passkey",
"Descending": "降序",
"Disable tag grouping to drag channels": "关闭标签聚合后可拖拽渠道",
"Disabled {{count}} keys": "已禁用 {{count}} 个密钥",
"Deleted a prefill group": "删除了一个预填组",
"Deleted a redemption code": "删除了一个兑换码",
@@ -2013,6 +2021,8 @@
"Downgrade to this group after the subscription expires": "订阅过期后降级到该分组",
"Download": "下载",
"Drag {{group}} to reorder": "拖动 {{group}} 以重新排序",
"Drag mode": "拖拽模式",
"Drag to change priority. Use arrow keys to move, Escape to cancel.": "拖拽调整优先级,也可使用方向键移动,Esc 取消。",
"Drag to move": "拖动以移动",
"Drag to resize": "拖动以调整大小",
"Draw": "绘图",
@@ -2022,6 +2032,7 @@
"Drawing task polling": "绘图任务轮询",
"Drawing task records": "绘图任务记录",
"Drop a JavaScript plugin file here": "将 JavaScript 插件文件拖放到此处",
"Drop between tiers to change priority, or in the center to join a tier": "拖到档位之间调整优先级,拖到中间加入同级",
"Dry run result": "干跑结果",
"Duplicate": "重复",
"Duplicate group names: {{names}}": "存在重复的分组名称:{{names}}",
@@ -2765,6 +2776,7 @@
"Find": "查找",
"Find the billing group.": "确定计费分组。",
"Find the ratio.": "确定倍率。",
"Finish reordering": "完成排序",
"Finish Time": "完成时间",
"Finished requests": "已结束请求",
"First API request": "首个 API 请求",
@@ -3146,6 +3158,7 @@
"Important": "重要",
"Invalid channel ID": "渠道ID格式错误",
"Importing metadata does not add channels, enable model access, or configure prices.": "导入元信息不会添加渠道、开放模型访问或配置价格。",
"Invalid channel priority move": "无效的渠道优先级调整",
"Invalid channel settings format: {{error}}": "渠道额外设置格式错误:{{error}}",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中打开 Login Widget,登记此回调地址并复制 Client ID 和 Client Secret。完成配置后,现有 Telegram 绑定可继续使用。",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外层键是用户分组,内层键是计费分组。下面的示例表示:vip 用户按 standard 计费时用 0.8,按 premium 计费时用 0.3。",
@@ -3268,6 +3281,7 @@
"Invalid JSON string.": "无效的 JSON 字符串。",
"Invalid model mapping format": "无效的模型映射格式",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。",
"Join the same priority tier": "加入同一优先级",
"Key deleted": "密钥已删除",
"Key disabled": "密钥已禁用",
"Key enabled": "密钥已启用",
@@ -3821,6 +3835,8 @@
"ms": "毫秒",
"Multi-key channel: Keys will be": "多密钥渠道:密钥将",
"Multi-Key Management": "多密钥管理",
"New priority: {{priority}}": "新优先级:{{priority}}",
"New priority: {{priority}} · {{count}} channels affected": "新优先级:{{priority}} · 影响 {{count}} 个渠道",
"New user registration has been disabled by administrator": "管理员关闭了新用户注册",
"New user sign-up bonus {{quota}}": "新用户注册赠送 {{quota}}",
"Multi-key management {{action}} on channel (ID: {{id}})": "对渠道(ID: {{id}})执行多密钥管理操作 {{action}}",
@@ -5073,6 +5089,8 @@
"Reinstall latest": "重装最新版",
"Reject Reason": "拒绝原因",
"Release details": "版本详情",
"Release to insert a new priority tier": "松手插入新优先级",
"Release to join priority {{priority}}": "松手加入同级 · 优先级 {{priority}}",
"Released": "发布于",
"Reload pricing": "重新加载定价",
"Reload vendor details": "重新加载供应商资料",
@@ -5130,6 +5148,7 @@
"Renamed an access token": "重命名了访问令牌",
"Renamed successfully": "重命名成功",
"Renaming metadata does not rename channel models or move pricing. Existing prices stay with the original model name.": "修改元信息名称不会重命名渠道模型或迁移定价,已有价格仍归原模型名称。",
"Reorder channel {{id}}": "调整渠道 {{id}} 的优先级",
"Repair": "修复",
"Repair Channel Consistency": "修复渠道一致性",
"Repair channel consistency?": "修复渠道一致性?",
@@ -5777,6 +5796,8 @@
"Simulated request body": "模拟请求体",
"Simulated request cost": "模拟请求费用",
"Simulated request headers": "模拟请求头",
"Sort by priority to drag channels": "按优先级排序后可拖拽渠道",
"Sort channels by": "渠道排序依据",
"State parameter is empty or mismatched": "state 参数为空或不匹配",
"Simulation context is missing.": "缺少模拟所需的上下文。",
"Simulation time": "模拟时间",
@@ -6563,6 +6584,7 @@
"Trading insights, accounting, advisory": "交易洞察、记账与财务建议",
"Traditional Chinese": "繁体中文",
"Transfer": "转移",
"Undo": "撤销",
"Unknown OAuth provider": "未知的 OAuth 提供商",
"Unknown rule name": "未知规则名称",
"Unknown setting type: {{type}}": "未知的设置类型:{{type}}",
@@ -6801,6 +6823,7 @@
"URL": "URL",
"URL is required": "URL 为必填项",
"URL to your logo image (optional)": "您的徽标图片 URL(可选)",
"Use the same priority as the highlighted channels": "与高亮渠道使用相同优先级",
"User does not exist": "用户不存在",
"US dollar (USD)": "美元(USD)",
"Usage": "用量",
@@ -7241,6 +7264,7 @@
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "你仍可编辑模型、分组、优先级和权重等非敏感运维字段。",
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "你承诺不会使用本系统实施、协助实施或间接实施违反适用法律法规、监管要求、平台规则、公共利益或第三方合法权益的行为。",
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "你承诺仅在从上游服务提供商、模型服务提供商或相关权利人处获得合法授权的范围内使用上游 API、账户、密钥、额度和服务能力,并不会进行未经授权的转售、倒卖、分发或其他不合规商业化行为。",
"You do not have permission to edit channels": "你没有编辑渠道的权限",
"You do not have permission to edit sensitive channel settings.": "你没有权限编辑敏感渠道设置。",
"You do not have permission to perform this action.": "你没有执行此操作的权限。",
"You don't have necessary permission": "您没有必要的权限",
+4
View File
@@ -900,4 +900,8 @@ export const STATIC_I18N_KEYS = [
'Verification method',
'Admin permissions updated',
'Provider ID',
// Channel priority ordering API errors.
'Channel priorities changed. Refresh and try again.',
'Invalid channel priority move',
'Channel priority is outside the sortable range',
] as const
+2 -2
View File
@@ -585,9 +585,9 @@ For commercial licensing, please contact support@quantumnous.com
transition: background-color 120ms ease;
}
/* Table row stagger-in animation */
/* Only fill the entrance delay; completed animations must release transform/opacity. */
[data-slot='table'] tbody tr {
animation: tableRowEnter 0.2s cubic-bezier(0.33, 1, 0.68, 1) both;
animation: tableRowEnter 0.2s cubic-bezier(0.33, 1, 0.68, 1) backwards;
}
[data-slot='table'] tbody tr:nth-child(2) {
animation-delay: 25ms;