Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
@@ -0,0 +1,444 @@
<template>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-12">
<Checkbox v-model="selectAllChecked" />
</TableHead>
<TableHead
v-if="visibleColumns.name"
class="cursor-pointer hover:bg-muted/50 select-none"
@click="toggleSort('name')"
>
<div class="flex items-center gap-2">
Shot Name
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead
v-if="visibleColumns.episode"
class="cursor-pointer hover:bg-muted/50 select-none"
>
<div class="flex items-center gap-2">
Episode
</div>
</TableHead>
<TableHead
v-if="visibleColumns.frameRange"
class="cursor-pointer hover:bg-muted/50 select-none"
@click="toggleSort('frame_start')"
>
<div class="flex items-center gap-2">
Frame Range
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead
v-if="visibleColumns.status"
class="cursor-pointer hover:bg-muted/50 select-none"
@click="toggleSort('status')"
>
<div class="flex items-center gap-2">
Status
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<!-- Task Status Columns -->
<TableHead
v-for="taskType in visibleTaskColumns"
:key="taskType"
class="cursor-pointer hover:bg-muted/50 select-none w-[140px]"
@click="toggleSort(`${taskType}_status`)"
>
<div class="flex items-center gap-2">
{{ formatTaskType(taskType) }}
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead v-if="visibleColumns.description">Description</TableHead>
<TableHead class="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="shot in sortedShots"
:key="shot.id"
class="cursor-pointer hover:bg-muted/50"
:class="{
'bg-muted/30': selectedShots[shot.id],
'opacity-60 bg-destructive/5': authStore.isAdmin && shot.deleted_at
}"
@click="handleRowClick(shot, $event)"
>
<TableCell>
<Checkbox
v-model="selectedShots[shot.id]"
@click.stop
/>
</TableCell>
<TableCell v-if="visibleColumns.name">
<div class="flex items-center gap-2">
<Camera class="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span class="font-medium" :class="{ 'line-through text-muted-foreground': shot.deleted_at }">
{{ shot.name }}
</span>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
</Badge>
</div>
</TableCell>
<TableCell v-if="visibleColumns.episode">
<Badge variant="outline" class="text-xs">
{{ getEpisodeName(shot.episode_id) }}
</Badge>
</TableCell>
<TableCell v-if="visibleColumns.frameRange">
<span class="text-sm">
{{ shot.frame_start }}-{{ shot.frame_end }}
<span class="text-muted-foreground ml-1">
({{ shot.frame_end - shot.frame_start + 1 }} frames)
</span>
</span>
</TableCell>
<TableCell v-if="visibleColumns.status">
<Badge :variant="getStatusVariant(shot.status)" class="text-xs">
{{ formatStatus(shot.status) }}
</Badge>
</TableCell>
<!-- Task Status Cells -->
<TableCell
v-for="taskType in visibleTaskColumns"
:key="taskType"
@click.stop
>
<EditableTaskStatus
:shot-id="shot.id"
:task-type="taskType"
:status="shot.task_status?.[taskType] || TaskStatus.NOT_STARTED"
:task-id="shot.task_ids?.[taskType]"
:project-id="projectId"
@status-updated="handleTaskStatusUpdated"
/>
</TableCell>
<TableCell v-if="visibleColumns.description">
<span class="text-sm text-muted-foreground truncate max-w-xs block">
{{ shot.description || "—" }}
</span>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@click.stop="$emit('edit', shot)"
:disabled="!!shot.deleted_at && !authStore.isAdmin"
>
<Edit class="h-4 w-4 mr-2" />
Edit Shot
</DropdownMenuItem>
<DropdownMenuItem
@click.stop="$emit('view-tasks', shot)"
:disabled="!!shot.deleted_at && !authStore.isAdmin"
>
<ListTodo class="h-4 w-4 mr-2" />
View Tasks
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Show recovery option for admins on deleted shots -->
<DropdownMenuItem
v-if="authStore.isAdmin && shot.deleted_at"
@click.stop="$emit('recover', shot)"
class="text-green-600 focus:text-green-600"
>
<RefreshCw class="h-4 w-4 mr-2" />
Recover Shot
</DropdownMenuItem>
<!-- Show delete option for active shots or permanent delete for admins -->
<DropdownMenuItem
v-if="!shot.deleted_at || authStore.isAdmin"
@click.stop="$emit('delete', shot)"
class="text-destructive focus:text-destructive"
>
<Trash2 class="h-4 w-4 mr-2" />
{{ shot.deleted_at ? 'Permanently Delete' : 'Delete Shot' }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Camera,
ArrowUpDown,
MoreHorizontal,
Edit,
ListTodo,
Trash2,
RefreshCw
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import EditableTaskStatus from './EditableTaskStatus.vue'
import { type Shot, ShotStatus, TaskStatus } from '@/services/shot'
import { useAuthStore } from '@/stores/auth'
interface Props {
shots: Shot[]
visibleColumns: {
name: boolean
episode: boolean
frameRange: boolean
status: boolean
description: boolean
[key: string]: boolean
}
episodes: Array<{ id: number; name: string }>
allTaskTypes: string[]
projectId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
select: [shot: Shot]
edit: [shot: Shot]
delete: [shot: Shot]
recover: [shot: Shot]
'view-tasks': [shot: Shot]
'sort-changed': [field: string, direction: 'asc' | 'desc']
'task-status-updated': [shotId: number, taskType: string, newStatus: TaskStatus]
}>()
// Auth store for admin check
const authStore = useAuthStore()
// State
const selectedShots = ref<Record<number, boolean>>({})
const sortField = ref<string | null>(null)
const sortDirection = ref<'asc' | 'desc'>('asc')
// Computed
const filteredShots = computed(() => {
// Filter out soft deleted shots unless user is admin
if (authStore.isAdmin) {
return props.shots // Admins can see all shots including deleted ones
} else {
return props.shots.filter(shot => !shot.deleted_at) // Regular users only see active shots
}
})
const visibleTaskColumns = computed(() => {
return props.allTaskTypes.filter(taskType =>
props.visibleColumns[taskType] !== false
)
})
const sortedShots = computed(() => {
if (!sortField.value) return filteredShots.value
return [...filteredShots.value].sort((a, b) => {
const field = sortField.value!
// Handle task status sorting
if (field.endsWith('_status')) {
const taskType = field.replace('_status', '')
const statusOrder = {
[TaskStatus.NOT_STARTED]: 0,
[TaskStatus.IN_PROGRESS]: 1,
[TaskStatus.SUBMITTED]: 2,
[TaskStatus.RETAKE]: 3,
[TaskStatus.APPROVED]: 4
}
const aStatus = a.task_status?.[taskType] || TaskStatus.NOT_STARTED
const bStatus = b.task_status?.[taskType] || TaskStatus.NOT_STARTED
const aOrder = statusOrder[aStatus] || 0
const bOrder = statusOrder[bStatus] || 0
return sortDirection.value === 'asc' ? aOrder - bOrder : bOrder - aOrder
}
// Handle regular field sorting
let aValue = (a as any)[field]
let bValue = (b as any)[field]
if (typeof aValue === 'string' && typeof bValue === 'string') {
aValue = aValue.toLowerCase()
bValue = bValue.toLowerCase()
}
if (aValue < bValue) {
return sortDirection.value === 'asc' ? -1 : 1
}
if (aValue > bValue) {
return sortDirection.value === 'asc' ? 1 : -1
}
return 0
})
})
// Methods
const toggleSort = (field: string) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
emit('sort-changed', field, sortDirection.value)
}
// Selection methods
const toggleSelectAll = (checked: boolean) => {
filteredShots.value.forEach(shot => {
selectedShots.value[shot.id] = checked;
});
};
// Selection computed property for select all checkbox
const selectAllChecked = computed({
get: () => {
return filteredShots.value.length > 0 &&
filteredShots.value.every(shot => selectedShots.value[shot.id]);
},
set: (checked: boolean) => {
toggleSelectAll(checked);
}
});
// Helper to get selected shot IDs
const getSelectedShotIds = () => {
return Object.keys(selectedShots.value)
.filter(id => selectedShots.value[Number(id)])
.map(id => Number(id));
};
const handleRowClick = (shot: Shot, event: MouseEvent) => {
if (event.ctrlKey || event.metaKey) {
// Multi-select with Ctrl/Cmd - toggle selection
selectedShots.value[shot.id] = !selectedShots.value[shot.id];
} else if (event.shiftKey && getSelectedShotIds().length > 0) {
// Range select with Shift
const selectedIds = getSelectedShotIds();
const lastSelectedId = selectedIds[selectedIds.length - 1];
const lastSelectedIndex = filteredShots.value.findIndex(
s => s.id === lastSelectedId
);
const currentIndex = filteredShots.value.findIndex(s => s.id === shot.id);
if (lastSelectedIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastSelectedIndex, currentIndex);
const end = Math.max(lastSelectedIndex, currentIndex);
// Clear all selections first
selectedShots.value = {};
// Select range
for (let i = start; i <= end; i++) {
selectedShots.value[filteredShots.value[i].id] = true;
}
}
} else {
// Single select
emit('select', shot);
}
}
const getEpisodeName = (episodeId: number) => {
const episode = props.episodes.find(e => e.id === episodeId)
return episode ? episode.name : `Episode ${episodeId}`
}
const formatTaskType = (taskType: string) => {
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
}
const formatDeletedDate = (deletedAt: string) => {
const date = new Date(deletedAt)
const now = new Date()
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60))
if (diffInHours < 24) {
return `${diffInHours}h ago`
} else {
const diffInDays = Math.floor(diffInHours / 24)
return `${diffInDays}d ago`
}
}
const formatStatus = (status: ShotStatus) => {
return status
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'secondary'
case ShotStatus.IN_PROGRESS:
return 'default'
case ShotStatus.ON_HOLD:
return 'outline'
case ShotStatus.COMPLETED:
return 'default'
case ShotStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const handleTaskStatusUpdated = (shotId: number, taskType: string, newStatus: string) => {
emit('task-status-updated', shotId, taskType, newStatus as TaskStatus)
}
// Watchers
watch(() => props.shots, () => {
selectedShots.value = {}
})
</script>