feat: add sorting functionality for apps by created_at, updated_at, and name

This commit is contained in:
bowenliang123 2026-03-24 10:56:53 +08:00
parent 0589fa423b
commit c897b20ca1
21 changed files with 171 additions and 5 deletions

View File

@ -68,6 +68,9 @@ class AppListQuery(BaseModel):
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Comma-separated tag IDs")
is_created_by_me: bool | None = Field(default=None, description="Filter by creator")
sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at", "name", "-name"] | None = Field(
default=None, description="Sort by field, prefix '-' for descending order"
)
@field_validator("tag_ids", mode="before")
@classmethod

View File

@ -0,0 +1,75 @@
"""add indexes for apps sorting
Revision ID: 18d02aad1f89
Revises: 6b5f9f8b1a2c
Create Date: 2026-03-24 10:29:39.502252
"""
from alembic import op
import models as models
import sqlalchemy as sa
revision = '18d02aad1f89'
down_revision = '6b5f9f8b1a2c'
branch_labels = None
depends_on = None
def _is_pg(conn) -> bool:
return conn.dialect.name == "postgresql"
def upgrade():
conn = op.get_bind()
if _is_pg(conn):
with op.get_context().autocommit_block():
op.create_index(
"apps_tenant_created_idx",
"apps",
["tenant_id", "created_at"],
postgresql_concurrently=True,
)
op.create_index(
"apps_tenant_updated_idx",
"apps",
["tenant_id", "updated_at"],
postgresql_concurrently=True,
)
op.create_index(
"apps_tenant_name_idx",
"apps",
["tenant_id", "name"],
postgresql_concurrently=True,
)
else:
op.create_index(
"apps_tenant_created_idx",
"apps",
["tenant_id", "created_at"],
)
op.create_index(
"apps_tenant_updated_idx",
"apps",
["tenant_id", "updated_at"],
)
op.create_index(
"apps_tenant_name_idx",
"apps",
["tenant_id", "name"],
)
def downgrade():
conn = op.get_bind()
if _is_pg(conn):
with op.get_context().autocommit_block():
op.drop_index("apps_tenant_created_idx", postgresql_concurrently=True)
op.drop_index("apps_tenant_updated_idx", postgresql_concurrently=True)
op.drop_index("apps_tenant_name_idx", postgresql_concurrently=True)
else:
op.drop_index("apps_tenant_created_idx", table_name="apps")
op.drop_index("apps_tenant_updated_idx", table_name="apps")
op.drop_index("apps_tenant_name_idx", table_name="apps")

View File

@ -60,7 +60,6 @@ class AppService:
name = args["name"][:30]
escaped_name = escape_like_pattern(name)
filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\"))
# Check if tag_ids is not empty to avoid WHERE false condition
if args.get("tag_ids") and len(args["tag_ids"]) > 0:
target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, args["tag_ids"])
if target_ids and len(target_ids) > 0:
@ -68,8 +67,19 @@ class AppService:
else:
return None
sort_by = args.get("sort_by", "-created_at")
order_by_map = {
"created_at": App.created_at.asc(),
"-created_at": App.created_at.desc(),
"updated_at": App.updated_at.asc(),
"-updated_at": App.updated_at.desc(),
"name": App.name.asc(),
"-name": App.name.desc(),
}
order_by = order_by_map.get(sort_by, App.created_at.desc())
app_models = db.paginate(
sa.select(App).where(*filters).order_by(App.created_at.desc()),
sa.select(App).where(*filters).order_by(order_by),
page=args["page"],
per_page=args["limit"],
error_out=False,

View File

@ -1,10 +1,15 @@
import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
export type AppSortField = 'created_at' | 'updated_at' | 'name'
export type AppSortBy = `${AppSortField}` | `-${AppSortField}`
type AppsQuery = {
tagIDs?: string[]
keywords?: string
isCreatedByMe?: boolean
sortBy?: AppSortBy
}
const normalizeKeywords = (value: string | null) => value || undefined
@ -15,6 +20,7 @@ function useAppsQueryState() {
tagIDs: parseAsArrayOf(parseAsString, ';'),
keywords: parseAsString,
isCreatedByMe: parseAsBoolean,
sortBy: parseAsString,
},
{
history: 'push',
@ -25,7 +31,8 @@ function useAppsQueryState() {
tagIDs: urlQuery.tagIDs ?? undefined,
keywords: normalizeKeywords(urlQuery.keywords),
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
}), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
sortBy: (urlQuery.sortBy as AppSortBy) || '-created_at',
}), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs, urlQuery.sortBy])
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
const buildPatch = (patch: AppsQuery) => {
@ -36,6 +43,8 @@ function useAppsQueryState() {
result.keywords = patch.keywords ? patch.keywords : null
if ('isCreatedByMe' in patch)
result.isCreatedByMe = patch.isCreatedByMe ? true : null
if ('sortBy' in patch)
result.sortBy = patch.sortBy || '-created_at'
return result
}
@ -44,6 +53,7 @@ function useAppsQueryState() {
tagIDs: prev.tagIDs ?? undefined,
keywords: normalizeKeywords(prev.keywords),
isCreatedByMe: prev.isCreatedByMe ?? false,
sortBy: (prev.sortBy as AppSortBy) || '-created_at',
})))
return
}

View File

@ -1,11 +1,13 @@
'use client'
import type { FC } from 'react'
import type { AppSortBy, AppSortField } from '@/app/components/apps/hooks/use-apps-query-state'
import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Sort from '@/app/components/base/sort'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
@ -60,8 +62,9 @@ const List: FC<Props> = ({
parseAsAppListCategory,
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false, sortBy: querySortBy = '-created_at' }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [sortBy, setSortBy] = useState<AppSortBy>(querySortBy)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
@ -74,6 +77,11 @@ const List: FC<Props> = ({
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
}, [setQuery])
const handleSortChange = useCallback((value: string) => {
const newSortBy = value as AppSortBy
setSortBy(newSortBy)
setQuery(prev => ({ ...prev, sortBy: newSortBy }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
setDroppedDSLFile(file)
@ -92,6 +100,7 @@ const List: FC<Props> = ({
name: searchKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
sort_by: sortBy,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@ -212,6 +221,16 @@ const List: FC<Props> = ({
onChange={handleCreatedByMeChange}
/>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
<Sort
order={sortBy.startsWith('-') ? '-' : ''}
value={sortBy.replace('-', '') as AppSortField}
items={[
{ value: 'created_at', name: t('sortBy.createdAt', { ns: 'app' }) },
{ value: 'updated_at', name: t('sortBy.updatedAt', { ns: 'app' }) },
{ value: 'name', name: t('sortBy.name', { ns: 'app' }) },
]}
onSelect={handleSortChange}
/>
<Input
showLeftIcon
showClearIcon

View File

@ -201,6 +201,9 @@
"removeOriginal": "Ursprüngliche App löschen",
"roadmap": "Sehen Sie unseren Fahrplan",
"showMyCreatedAppsOnly": "Nur meine erstellten Apps anzeigen",
"sortBy.createdAt": "Erstellt am",
"sortBy.name": "App-Name",
"sortBy.updatedAt": "Bearbeitet am",
"structOutput.LLMResponse": "LLM-Antwort",
"structOutput.configure": "Konfigurieren",
"structOutput.modelNotSupported": "Modell nicht unterstützt",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Delete the original app",
"roadmap": "See our roadmap",
"showMyCreatedAppsOnly": "Created by me",
"sortBy.createdAt": "Created",
"sortBy.name": "AppName",
"sortBy.updatedAt": "Edited",
"structOutput.LLMResponse": "LLM Response",
"structOutput.configure": "Configure",
"structOutput.modelNotSupported": "Model not supported",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Eliminar la app original",
"roadmap": "Ver nuestro plan de desarrollo",
"showMyCreatedAppsOnly": "Mostrar solo mis aplicaciones creadas",
"sortBy.createdAt": "Creado",
"sortBy.name": "Nombre de la app",
"sortBy.updatedAt": "Editado",
"structOutput.LLMResponse": "Respuesta del LLM",
"structOutput.configure": "Configurar",
"structOutput.modelNotSupported": "Modelo no soportado",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Supprimer l'application d'origine",
"roadmap": "Voir notre feuille de route",
"showMyCreatedAppsOnly": "Afficher uniquement mes applications créées",
"sortBy.createdAt": "Créé le",
"sortBy.name": "Nom de l'application",
"sortBy.updatedAt": "Modifié le",
"structOutput.LLMResponse": "Réponse LLM",
"structOutput.configure": "Configurer",
"structOutput.modelNotSupported": "Modèle non pris en charge",

View File

@ -201,6 +201,9 @@
"removeOriginal": "मूल ऐप हटाएँ",
"roadmap": "हमारा रोडमैप देखें",
"showMyCreatedAppsOnly": "केवल मेरे बनाए गए ऐप्स दिखाएं",
"sortBy.createdAt": "निर्मित",
"sortBy.name": "ऐप का नाम",
"sortBy.updatedAt": "संपादित",
"structOutput.LLMResponse": "LLM प्रतिक्रिया",
"structOutput.configure": "कॉन्फ़िगर करें",
"structOutput.modelNotSupported": "मॉडल का समर्थन नहीं किया गया",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Elimina l'app originale",
"roadmap": "Vedi la nostra roadmap",
"showMyCreatedAppsOnly": "Mostra solo le mie app create",
"sortBy.createdAt": "Creato il",
"sortBy.name": "Nome dell'app",
"sortBy.updatedAt": "Modificato il",
"structOutput.LLMResponse": "LLM Risposta",
"structOutput.configure": "Configura",
"structOutput.modelNotSupported": "Modello non supportato",

View File

@ -201,6 +201,9 @@
"removeOriginal": "元のアプリを削除する",
"roadmap": "ロードマップを見る",
"showMyCreatedAppsOnly": "自分が作成したアプリ",
"sortBy.createdAt": "作成時間",
"sortBy.name": "アプリ名",
"sortBy.updatedAt": "編集時間",
"structOutput.LLMResponse": "LLM のレスポンス",
"structOutput.configure": "設定",
"structOutput.modelNotSupported": "モデルが対応していません",

View File

@ -201,6 +201,9 @@
"removeOriginal": "원본 앱 제거하기",
"roadmap": "로드맵 보기",
"showMyCreatedAppsOnly": "내가 만든 앱만 보기",
"sortBy.createdAt": "생성일",
"sortBy.name": "앱 이름",
"sortBy.updatedAt": "편집일",
"structOutput.LLMResponse": "LLM 응답",
"structOutput.configure": "설정",
"structOutput.modelNotSupported": "모델이 지원되지 않습니다.",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Usuń oryginalną aplikację",
"roadmap": "Zobacz naszą mapę drogową",
"showMyCreatedAppsOnly": "Pokaż tylko moje utworzone aplikacje",
"sortBy.createdAt": "Utworzono",
"sortBy.name": "Nazwa aplikacji",
"sortBy.updatedAt": "Edytowano",
"structOutput.LLMResponse": "Odpowiedź LLM",
"structOutput.configure": "Konfiguruj",
"structOutput.modelNotSupported": "Model nie jest obsługiwany",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Excluir o aplicativo original",
"roadmap": "Veja nosso roteiro",
"showMyCreatedAppsOnly": "Mostrar apenas meus aplicativos criados",
"sortBy.createdAt": "Criado em",
"sortBy.name": "Nome do aplicativo",
"sortBy.updatedAt": "Editado em",
"structOutput.LLMResponse": "Resposta do LLM",
"structOutput.configure": "Configurar",
"structOutput.modelNotSupported": "Modelo não suportado",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Удалить исходное приложение",
"roadmap": "Посмотреть наш roadmap",
"showMyCreatedAppsOnly": "Показать только созданные мной приложения",
"sortBy.createdAt": "Создано",
"sortBy.name": "Название приложения",
"sortBy.updatedAt": "Изменено",
"structOutput.LLMResponse": "Ответ LLM",
"structOutput.configure": "Настроить",
"structOutput.modelNotSupported": "Модель не поддерживается",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Orijinal uygulamayı sil",
"roadmap": "Yol haritamızı görün",
"showMyCreatedAppsOnly": "Sadece oluşturduğum uygulamaları göster",
"sortBy.createdAt": "Oluşturulma",
"sortBy.name": "Uygulama adı",
"sortBy.updatedAt": "Düzenlenme",
"structOutput.LLMResponse": "LLM Yanıtı",
"structOutput.configure": "Yapılandır",
"structOutput.modelNotSupported": "Model desteklenmiyor",

View File

@ -201,6 +201,9 @@
"removeOriginal": "Видалити початковий додаток",
"roadmap": "Переглянути наш план розвитку",
"showMyCreatedAppsOnly": "Показати лише створені мною додатки",
"sortBy.createdAt": "Створено",
"sortBy.name": "Назва додатку",
"sortBy.updatedAt": "Змінено",
"structOutput.LLMResponse": "Відповідь ЛЛМ",
"structOutput.configure": "Налаштувати",
"structOutput.modelNotSupported": "Модель не підтримується",

View File

@ -201,7 +201,10 @@
"removeOriginal": "删除原应用",
"roadmap": "产品路线图",
"showMyCreatedAppsOnly": "我创建的",
"structOutput.LLMResponse": "LLM 的响应",
"sortBy.createdAt": "创建时间",
"sortBy.name": "应用名称",
"sortBy.updatedAt": "编辑时间",
"structOutput.LLMResponse": "LLM 回复",
"structOutput.configure": "配置",
"structOutput.modelNotSupported": "模型不支持",
"structOutput.modelNotSupportedTip": "当前模型不支持此功能,将自动降级为提示注入。",

View File

@ -201,6 +201,9 @@
"removeOriginal": "刪除原應用",
"roadmap": "產品路線圖",
"showMyCreatedAppsOnly": "我建立的",
"sortBy.createdAt": "建立時間",
"sortBy.name": "應用程式名稱",
"sortBy.updatedAt": "編輯時間",
"structOutput.LLMResponse": "LLM 回應",
"structOutput.configure": "配置",
"structOutput.modelNotSupported": "模型不支持",

View File

@ -1,4 +1,5 @@
import type { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import type { AppSortBy } from '@/app/components/apps/hooks/use-apps-query-state'
import type {
ApiKeysListResponse,
AppDailyConversationsResponse,
@ -32,6 +33,7 @@ type AppListParams = {
mode?: AppModeEnum | 'all'
tag_ids?: string[]
is_created_by_me?: boolean
sort_by?: AppSortBy
}
type DateRangeParams = {
@ -57,6 +59,7 @@ const normalizeAppListParams = (params: AppListParams) => {
mode,
tag_ids,
is_created_by_me,
sort_by,
} = params
const safeMode = allowedModes.has((mode as any)) ? mode : undefined
@ -68,6 +71,7 @@ const normalizeAppListParams = (params: AppListParams) => {
...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}),
...(tag_ids?.length ? { tag_ids } : {}),
...(is_created_by_me ? { is_created_by_me } : {}),
...(sort_by ? { sort_by } : {}),
}
}