mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): filter sessions by last activity (#1083)
* feat(web): filter sessions by last activity * test(web): make session date filter tests deterministic
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SessionSummary } from '@/types/api'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
@@ -104,6 +104,83 @@ describe('SessionList directory action', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionList time filter', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 6, 18, 12))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('filters after selecting a start and end date', () => {
|
||||
const recent = makeSession({
|
||||
id: 'recent',
|
||||
updatedAt: Date.now(),
|
||||
metadata: { path: '/work/recent', name: 'Recent session' }
|
||||
})
|
||||
const old = makeSession({
|
||||
id: 'old',
|
||||
updatedAt: new Date(2020, 0, 1).getTime(),
|
||||
metadata: { path: '/work/old', name: 'Old session' }
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<SessionList
|
||||
sessions={[recent, old]}
|
||||
selectedSessionId={null}
|
||||
onSelect={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
isLoading={false}
|
||||
renderHeader={false}
|
||||
api={null}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Old session/ })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by last activity' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() }))
|
||||
fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 18).toLocaleDateString() }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Old session/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the first calendar click as start and the second as end', () => {
|
||||
const session = makeSession({
|
||||
id: 'session-1',
|
||||
updatedAt: Date.now(),
|
||||
metadata: { path: '/work/hapi', name: 'Session' }
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<SessionList
|
||||
sessions={[session]}
|
||||
selectedSessionId={null}
|
||||
onSelect={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
isLoading={false}
|
||||
renderHeader={false}
|
||||
api={null}
|
||||
/>
|
||||
)
|
||||
|
||||
const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' })
|
||||
fireEvent.click(filterButton)
|
||||
fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 1).toLocaleDateString() }))
|
||||
expect(screen.getByText('Select end date')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 18).toLocaleDateString() }))
|
||||
|
||||
expect(filterButton).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(filterButton).toHaveAttribute('title', '2026-07-01 – 2026-07-18')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionList action menu parity', () => {
|
||||
it.each([
|
||||
['running', true],
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deduplicateSessionsByAgentId,
|
||||
expandSelectedSessionCollapseOverrides,
|
||||
filterActiveSessionsOnly,
|
||||
getSessionTimeRange,
|
||||
getNextSessionVisibleCount,
|
||||
getSessionDedupKey,
|
||||
getWorktreeSessionLabel,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
normalizeSearch,
|
||||
prepareSidebarSessions,
|
||||
sessionMatchesQuery,
|
||||
sessionMatchesTimeRange,
|
||||
shouldShowSessionInSidebar
|
||||
} from './SessionList'
|
||||
|
||||
@@ -308,6 +310,23 @@ describe('session list search helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('session list time filter helpers', () => {
|
||||
it('treats the selected end date as inclusive in local time', () => {
|
||||
const range = getSessionTimeRange('2026-07-01', '2026-07-18')
|
||||
expect(range).toEqual({
|
||||
start: new Date(2026, 6, 1).getTime(),
|
||||
end: new Date(2026, 6, 19).getTime()
|
||||
})
|
||||
expect(sessionMatchesTimeRange(makeSession({ id: 'inside', updatedAt: new Date(2026, 6, 18, 23, 59).getTime() }), range)).toBe(true)
|
||||
expect(sessionMatchesTimeRange(makeSession({ id: 'outside', updatedAt: new Date(2026, 6, 19).getTime() }), range)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not filter until both dates are selected', () => {
|
||||
expect(getSessionTimeRange('', '')).toBeNull()
|
||||
expect(getSessionTimeRange('2026-07-01', '')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVisibleSessionPreview', () => {
|
||||
it('keeps selected and pending sessions inside the collapsed preview without promoting them', () => {
|
||||
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
|
||||
|
||||
@@ -39,6 +39,37 @@ type SessionGroup = {
|
||||
hasActiveSession: boolean
|
||||
}
|
||||
|
||||
export type SessionTimeRange = {
|
||||
start: number | null
|
||||
end: number | null
|
||||
}
|
||||
|
||||
function parseLocalDate(value: string): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||
if (!match) return null
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null
|
||||
return date
|
||||
}
|
||||
|
||||
export function getSessionTimeRange(start: string, end: string): SessionTimeRange | null {
|
||||
const startDate = parseLocalDate(start)
|
||||
const endDate = parseLocalDate(end)
|
||||
if (!startDate || !endDate) return null
|
||||
if (endDate) endDate.setDate(endDate.getDate() + 1)
|
||||
return { start: startDate.getTime(), end: endDate.getTime() }
|
||||
}
|
||||
|
||||
export function sessionMatchesTimeRange(session: SessionSummary, range: SessionTimeRange | null): boolean {
|
||||
if (!range) return true
|
||||
if (range.start !== null && session.updatedAt < range.start) return false
|
||||
if (range.end !== null && session.updatedAt >= range.end) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function SessionsEmptyState(props: {
|
||||
onNewSession: () => void
|
||||
onBrowse?: () => void
|
||||
@@ -523,33 +554,178 @@ export function getVisibleSessionPreview(
|
||||
return visible
|
||||
}
|
||||
|
||||
function CalendarIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 10h18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateValue(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function SessionDateRangePicker(props: {
|
||||
start: string
|
||||
end: string
|
||||
onChange: (start: string, end: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const initialDate = parseLocalDate(props.start) ?? new Date()
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date(initialDate.getFullYear(), initialDate.getMonth(), 1))
|
||||
const firstWeekday = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1).getDay()
|
||||
const daysInMonth = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 0).getDate()
|
||||
const weekdays = Array.from({ length: 7 }, (_, day) => (
|
||||
new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(new Date(2026, 5, 7 + day))
|
||||
))
|
||||
|
||||
const selectDate = (value: string) => {
|
||||
if (!props.start || props.end) {
|
||||
props.onChange(value, '')
|
||||
return
|
||||
}
|
||||
props.onChange(value < props.start ? value : props.start, value < props.start ? props.start : value)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-full z-30 mt-2 w-72 rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] p-3 shadow-xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1))}
|
||||
className="rounded-lg p-1.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
|
||||
aria-label={t('sessions.timeFilter.previousMonth')}
|
||||
>
|
||||
<span aria-hidden="true">‹</span>
|
||||
</button>
|
||||
<div className="text-sm font-medium">
|
||||
{visibleMonth.toLocaleDateString(undefined, { year: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1))}
|
||||
className="rounded-lg p-1.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
|
||||
aria-label={t('sessions.timeFilter.nextMonth')}
|
||||
>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-1 grid grid-cols-7 text-center text-[10px] text-[var(--app-hint)]">
|
||||
{weekdays.map((weekday, index) => <div key={`${weekday}-${index}`} className="py-1">{weekday}</div>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{Array.from({ length: firstWeekday }, (_, index) => <div key={`blank-${index}`} />)}
|
||||
{Array.from({ length: daysInMonth }, (_, index) => {
|
||||
const date = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index + 1)
|
||||
const value = formatDateValue(date)
|
||||
const isEndpoint = value === props.start || value === props.end
|
||||
const isInRange = Boolean(props.start && props.end && value > props.start && value < props.end)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => selectDate(value)}
|
||||
aria-label={date.toLocaleDateString()}
|
||||
className={cn(
|
||||
'h-8 rounded-lg text-xs transition-colors',
|
||||
isEndpoint && 'bg-[var(--app-link)] text-white',
|
||||
isInRange && 'bg-[var(--app-link)]/15 text-[var(--app-link)]',
|
||||
!isEndpoint && !isInRange && 'hover:bg-[var(--app-subtle-bg)]'
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between border-t border-[var(--app-divider)] pt-2 text-xs">
|
||||
<span className="text-[var(--app-hint)]">
|
||||
{!props.start
|
||||
? t('sessions.timeFilter.pickStart')
|
||||
: !props.end
|
||||
? t('sessions.timeFilter.pickEnd')
|
||||
: `${props.start} – ${props.end}`}
|
||||
</span>
|
||||
{props.start ? (
|
||||
<button type="button" onClick={() => props.onChange('', '')} className="text-[var(--app-link)]">
|
||||
{t('sessions.timeFilter.clear')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionListSearch(props: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
customStart: string
|
||||
customEnd: string
|
||||
onDateRangeChange: (start: string, end: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const hasDateRange = Boolean(props.customStart && props.customEnd)
|
||||
return (
|
||||
<div className="relative px-3 pb-2">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-5 flex items-center pb-2 text-[var(--app-hint)]">
|
||||
<SearchIcon className="h-3.5 w-3.5" />
|
||||
<div className="px-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-[var(--app-hint)]">
|
||||
<SearchIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
placeholder={t('sessions.search.placeholder')}
|
||||
className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-8 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||
/>
|
||||
{props.value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange('')}
|
||||
className="absolute inset-y-0 right-2 flex items-center rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
|
||||
title={t('sessions.search.clear')}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDatePickerOpen(open => !open)}
|
||||
className={cn(
|
||||
'relative rounded-lg p-2 transition-colors hover:bg-[var(--app-subtle-bg)]',
|
||||
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)]'
|
||||
)}
|
||||
title={hasDateRange ? `${props.customStart} – ${props.customEnd}` : t('sessions.timeFilter.label')}
|
||||
aria-label={t('sessions.timeFilter.label')}
|
||||
aria-expanded={datePickerOpen}
|
||||
>
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
{hasDateRange ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--app-link)]" /> : null}
|
||||
</button>
|
||||
{datePickerOpen ? (
|
||||
<>
|
||||
<button type="button" aria-label={t('sessions.timeFilter.close')} className="fixed inset-0 z-20 cursor-default" onClick={() => setDatePickerOpen(false)} />
|
||||
<SessionDateRangePicker
|
||||
start={props.customStart}
|
||||
end={props.customEnd}
|
||||
onChange={props.onDateRangeChange}
|
||||
onClose={() => setDatePickerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
placeholder={t('sessions.search.placeholder')}
|
||||
className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-8 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||
/>
|
||||
{props.value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange('')}
|
||||
className="absolute inset-y-0 right-5 flex items-center pb-2 rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
|
||||
title={t('sessions.search.clear')}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -836,9 +1012,12 @@ export function SessionList(props: {
|
||||
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
|
||||
const showDetailedStatus = sessionListStatusMode === 'detailed'
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [customStart, setCustomStart] = useState('')
|
||||
const [customEnd, setCustomEnd] = useState('')
|
||||
const [, setCodexImportedSessionsVersion] = useState(0)
|
||||
const normalizedQuery = normalizeSearch(searchQuery)
|
||||
const isSearching = normalizedQuery.length > 0
|
||||
const timeRange = getSessionTimeRange(customStart, customEnd)
|
||||
const isFiltering = normalizedQuery.length > 0 || timeRange !== null
|
||||
|
||||
useEffect(() => {
|
||||
// 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。
|
||||
@@ -865,14 +1044,17 @@ export function SessionList(props: {
|
||||
[props.sessions, selectedSessionId, showActiveSessionsOnly]
|
||||
)
|
||||
const visibleSessions = useMemo(
|
||||
() => isSearching
|
||||
? allSessions.filter(session => sessionMatchesQuery(
|
||||
session,
|
||||
normalizedQuery,
|
||||
resolveMachineLabel(session.metadata?.machineId ?? null)
|
||||
() => isFiltering
|
||||
? allSessions.filter(session => (
|
||||
sessionMatchesTimeRange(session, timeRange)
|
||||
&& sessionMatchesQuery(
|
||||
session,
|
||||
normalizedQuery,
|
||||
resolveMachineLabel(session.metadata?.machineId ?? null)
|
||||
)
|
||||
))
|
||||
: allSessions,
|
||||
[allSessions, isSearching, normalizedQuery, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[allSessions, isFiltering, normalizedQuery, timeRange?.start, timeRange?.end, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
const allGroups = useMemo(
|
||||
() => groupSessionsByDirectory(allSessions),
|
||||
@@ -887,7 +1069,7 @@ export function SessionList(props: {
|
||||
)
|
||||
const autoExpandedSelectedSessionKeyRef = useRef<string | null>(null)
|
||||
const isGroupCollapsed = (group: SessionGroup): boolean => {
|
||||
if (isSearching) return false
|
||||
if (isFiltering) return false
|
||||
const override = collapseOverrides.get(group.key)
|
||||
if (override !== undefined) return override
|
||||
const hasSelectedSession = selectedSessionId
|
||||
@@ -936,7 +1118,7 @@ export function SessionList(props: {
|
||||
return getVisibleSessionPreview(
|
||||
group.sessions,
|
||||
{
|
||||
expanded: isSearching,
|
||||
expanded: isFiltering,
|
||||
selectedSessionId,
|
||||
limit: getGroupVisibleCount(group)
|
||||
}
|
||||
@@ -949,7 +1131,7 @@ export function SessionList(props: {
|
||||
)
|
||||
|
||||
const isMachineCollapsed = (mg: MachineGroup): boolean => {
|
||||
if (isSearching) return false
|
||||
if (isFiltering) return false
|
||||
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
|
||||
const override = collapseOverrides.get(key)
|
||||
if (override !== undefined) return override
|
||||
@@ -1035,7 +1217,7 @@ export function SessionList(props: {
|
||||
{renderHeader ? (
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<div className="text-xs text-[var(--app-hint)]">
|
||||
{isSearching
|
||||
{isFiltering
|
||||
? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
|
||||
: t('sessions.count', { n: allSessions.length, m: allGroups.length })}
|
||||
</div>
|
||||
@@ -1051,7 +1233,16 @@ export function SessionList(props: {
|
||||
) : null}
|
||||
|
||||
{props.sessions.length > 0 ? (
|
||||
<SessionListSearch value={searchQuery} onChange={setSearchQuery} />
|
||||
<SessionListSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
onDateRangeChange={(start, end) => {
|
||||
setCustomStart(start)
|
||||
setCustomEnd(end)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{props.sessions.length === 0 && (
|
||||
@@ -1061,7 +1252,7 @@ export function SessionList(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.sessions.length > 0 && isSearching && visibleSessions.length === 0 ? (
|
||||
{props.sessions.length > 0 && isFiltering && visibleSessions.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
|
||||
{t('sessions.search.noResults')}
|
||||
</div>
|
||||
@@ -1146,7 +1337,7 @@ export function SessionList(props: {
|
||||
showDetailedStatus={showDetailedStatus}
|
||||
/>
|
||||
))}
|
||||
{!isSearching && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
|
||||
{!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hiddenSessionCount > 0
|
||||
|
||||
@@ -52,7 +52,14 @@ export default {
|
||||
'sessions.search.placeholder': 'Search sessions…',
|
||||
'sessions.search.clear': 'Clear search',
|
||||
'sessions.search.count': '{n} of {total} sessions',
|
||||
'sessions.search.noResults': 'No sessions match your search.',
|
||||
'sessions.search.noResults': 'No sessions match your filters.',
|
||||
'sessions.timeFilter.label': 'Filter sessions by last activity',
|
||||
'sessions.timeFilter.pickStart': 'Select start date',
|
||||
'sessions.timeFilter.pickEnd': 'Select end date',
|
||||
'sessions.timeFilter.clear': 'Clear',
|
||||
'sessions.timeFilter.close': 'Close date picker',
|
||||
'sessions.timeFilter.previousMonth': 'Previous month',
|
||||
'sessions.timeFilter.nextMonth': 'Next month',
|
||||
'sessions.group.showMore': 'Show {n} more',
|
||||
'sessions.group.showLess': 'Show less',
|
||||
'sessions.group.new': 'New session in this directory',
|
||||
|
||||
@@ -52,7 +52,14 @@ export default {
|
||||
'sessions.search.placeholder': '搜索会话…',
|
||||
'sessions.search.clear': '清除搜索',
|
||||
'sessions.search.count': '{n} / {total} 个会话',
|
||||
'sessions.search.noResults': '没有匹配的会话。',
|
||||
'sessions.search.noResults': '没有符合筛选条件的会话。',
|
||||
'sessions.timeFilter.label': '按最后活动时间筛选会话',
|
||||
'sessions.timeFilter.pickStart': '选择开始日期',
|
||||
'sessions.timeFilter.pickEnd': '选择结束日期',
|
||||
'sessions.timeFilter.clear': '清除',
|
||||
'sessions.timeFilter.close': '关闭日期选择器',
|
||||
'sessions.timeFilter.previousMonth': '上个月',
|
||||
'sessions.timeFilter.nextMonth': '下个月',
|
||||
'sessions.group.showMore': '再显示 {n} 个',
|
||||
'sessions.group.showLess': '收起',
|
||||
'sessions.group.new': '在此目录新建会话',
|
||||
|
||||
Reference in New Issue
Block a user