Phase 4: Architecture & tooling investment

Fixes a real N+1 API-call bug (per-cell project-member fetches in shot/asset
task-status cells, now a shared cached store), replaces a polling-based
in-flight request de-dup with promise memoization, adds markRaw() around
icon components in reactive state, and fixes a deep watcher that re-scanned
the whole projects array just to trigger thumbnail loading.

Introduces useAsyncAction and usePermission composables to cut duplicated
store boilerplate and duplicated role/admin checks, applied only where the
existing code was a clean match rather than forced onto everything. Extracts
assets.ts's shared per-asset optimistic-update/rollback helper without
merging the single and bulk API paths, which hit genuinely different
endpoints. Adds toast-on-error for previously-silent secondary loads and
fixes a dead try/catch in the router's auth-init guard along the way.

Also fixes a thumbnail-loading regression introduced earlier in this same
pass: the new ID-keyed watcher never fired on a repeat visit to the Projects
page when the store already held the same project list, so thumbnails
(local component state, reset per mount) silently stopped loading after the
first visit. Replaced the watcher with a direct call after each fetch.

Table virtualization, splitting the two largest view files, broad store
caching, and moving domain types into types/ were scoped out as separate,
higher-risk follow-ups (documented in frontend_tasks.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 04:03:58 +08:00
parent 841e786fdd
commit 4f9deeb57a
26 changed files with 453 additions and 492 deletions
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import type { ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
@@ -202,11 +203,12 @@ const { getAvatarUrl } = useAvatarUrl()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const searchQuery = ref('')
// Filtered project members based on search query
@@ -306,17 +308,12 @@ const fetchStatuses = async () => {
}
}
// Load project members
// Load project members (shared cache across all EditableTaskStatus instances for this project)
const loadProjectMembers = async () => {
if (projectMembers.value.length > 0) return // Already loaded
isLoadingMembers.value = true
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
} finally {
isLoadingMembers.value = false
}
}
@@ -400,10 +397,9 @@ onMounted(() => {
loadProjectMembers()
})
// Refetch statuses when projectId changes
// Refetch statuses and members when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
loadProjectMembers()
})
</script>
@@ -83,7 +83,7 @@ import {
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { episodeService, type Episode } from '@/services/episode'
interface Props {
@@ -100,7 +100,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const router = useRouter()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
// Reactive state
const episodes = ref<Episode[]>([])
@@ -108,11 +108,7 @@ const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed properties
const canCreateEpisodes = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
const sortedEpisodes = computed(() => {
return [...episodes.value].sort((a, b) => {
@@ -123,10 +123,12 @@ import {
} from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import ProjectSwitcher from './ProjectSwitcher.vue'
import UserMenu from './UserMenu.vue'
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { state } = useSidebar()
const route = useRoute()
const user = computed(() => authStore.user)
@@ -150,7 +152,7 @@ const navigationItems = computed(() => {
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
]
if (userRole.value === 'coordinator' || authStore.isAdmin) {
if (isCoordinatorOrAdmin.value) {
baseItems.push(
{ title: 'Projects', url: '/projects', icon: FolderOpen },
{ title: 'Team', url: '/users', icon: Users }
@@ -135,11 +135,13 @@ import {
} from "@/components/ui/sidebar";
import { useAuthStore } from "@/stores/auth";
import { useProjectsStore } from "@/stores/projects";
import { usePermission } from "@/composables/usePermission";
const router = useRouter();
const { isMobile } = useSidebar();
const authStore = useAuthStore();
const projectsStore = useProjectsStore();
const { isCoordinatorOrAdmin } = usePermission();
// Get projects and active project from store
const projects = computed(() => projectsStore.availableProjects);
@@ -148,10 +150,7 @@ const isLoading = computed(() => projectsStore.isLoading);
const error = computed(() => projectsStore.error);
// Check if user can create projects
const canCreateProjects = computed(() => {
const user = authStore.user;
return user?.is_admin || user?.role === "coordinator";
});
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value);
const setActiveProject = (project: any) => {
projectsStore.setActiveProject(project);
@@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue'
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
import { notificationService } from '@/services/notifications'
@@ -97,7 +97,7 @@ interface Props {
const props = defineProps<Props>()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { toast } = useToast()
// State
@@ -108,11 +108,7 @@ const isEditing = ref(false)
const showNotification = ref(false)
// Computed properties
const canEdit = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canEdit = computed(() => isCoordinatorOrAdmin.value)
// Methods
const loadSpecs = async () => {
@@ -55,7 +55,7 @@ import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
@@ -73,7 +73,7 @@ interface Emits {
const props = defineProps<Props>()
defineEmits<Emits>()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
// State
const isOpen = ref(props.defaultOpen || false)
@@ -82,11 +82,7 @@ const error = ref<string | null>(null)
const specs = ref<ProjectTechnicalSpecs | undefined>()
// Computed properties
const canEdit = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canEdit = computed(() => isCoordinatorOrAdmin.value)
const hasSpecs = computed(() => {
if (!specs.value) return false
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import type { ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
interface StatusOption {
id: string
@@ -198,11 +199,12 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const searchQuery = ref('')
// Filtered project members based on search query
@@ -306,27 +308,18 @@ const fetchStatuses = async () => {
}
}
// Load project members
// Load project members (shared cache across all EditableTaskStatus instances for this project)
const loadProjectMembers = async () => {
if (projectMembers.value.length > 0) return // Already loaded
isLoadingMembers.value = true
try {
console.log('Loading project members for project:', props.projectId)
projectMembers.value = await projectService.getProjectMembers(props.projectId)
console.log('Loaded project members:', projectMembers.value)
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
} finally {
isLoadingMembers.value = false
}
}
// Ensure members are loaded when popover is about to open
const ensureMembersLoaded = () => {
console.log('Ensuring project members are loaded')
if (projectMembers.value.length === 0) {
console.log('Loading project members on button click')
loadProjectMembers()
}
}
@@ -407,7 +400,6 @@ onMounted(() => {
// Refetch statuses when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
loadProjectMembers()
})
</script>
+24 -4
View File
@@ -449,44 +449,64 @@ const loadShots = async () => {
const loadEpisodes = async () => {
if (!props.projectId) return
try {
const data = await episodeService.getEpisodes(props.projectId)
episodes.value = data
} catch (err) {
console.error('Failed to load episodes:', err)
toast({
title: 'Failed to load episodes',
description: err instanceof Error ? err.message : 'Episode filtering may be unavailable',
variant: 'destructive'
})
}
}
const loadTaskTypes = async () => {
if (!props.projectId) return
try {
const data = await customTaskTypeService.getAllTaskTypes(props.projectId)
allTaskTypes.value = data.shot_task_types || []
} catch (err) {
console.error('Failed to load task types:', err)
toast({
title: 'Failed to load task types',
description: err instanceof Error ? err.message : 'Task columns may be unavailable',
variant: 'destructive'
})
}
}
const loadTaskStatuses = async () => {
if (!props.projectId) return
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (err) {
console.error('Failed to load task statuses:', err)
toast({
title: 'Failed to load task statuses',
description: err instanceof Error ? err.message : 'Task status options may be unavailable',
variant: 'destructive'
})
}
}
const loadProjectContext = async () => {
if (!props.projectId) return
try {
const project = await projectService.getProject(props.projectId)
projectContext.value = project
} catch (err) {
console.error('Failed to load project context:', err)
toast({
title: 'Failed to load project context',
description: err instanceof Error ? err.message : 'Some shot validation checks may be unavailable',
variant: 'destructive'
})
}
}
@@ -21,7 +21,7 @@
{{ formatStatus(shot.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
</Badge>
</template>
@@ -350,9 +350,9 @@ import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useAuthStore } from '@/stores/auth'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission'
// Use TaskStatusInfo from shot service instead of local Task interface
interface Task extends TaskStatusInfo {
@@ -384,9 +384,9 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
const { getAvatarUrl } = useAvatarUrl()
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
// Reactive state
const shot = ref<Shot | null>(null)
@@ -432,25 +432,17 @@ const taskStatusCounts = computed(() => {
const canCreateTask = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
const canCreateNote = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const canLinkAssets = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
const canUploadReferences = computed(() => {
return true // All users can upload references
})
const canEditDesign = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
@@ -320,6 +320,7 @@ const emit = defineEmits<{
const { toast } = useToast()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const task = ref<Task | null>(null)
const loading = ref(false)
@@ -350,10 +351,7 @@ const canSubmitWork = computed(() => {
)
})
const canReassign = computed(() => {
if (!authStore.user) return false
return authStore.user.is_admin || authStore.user.role === 'coordinator'
})
const canReassign = computed(() => isCoordinatorOrAdmin.value)
async function loadTask() {
loading.value = true
@@ -478,6 +476,7 @@ function getUserInitials(member: ProjectMember): string {
}
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission'
const { getAvatarUrl } = useAvatarUrl()
+3 -5
View File
@@ -299,7 +299,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useTasksStore } from '@/stores/tasks'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { AlertCircle, Clock, UserPlus, Loader2, Eye } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -344,7 +344,7 @@ const emit = defineEmits<{
}>()
const tasksStore = useTasksStore()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { toast } = useToast()
const searchQuery = ref('')
@@ -361,9 +361,7 @@ const assignmentDepartmentFilter = ref('all')
const projectMembers = ref<ProjectMember[]>([])
const assigningTask = ref(false)
const canAssignTasks = computed(() => {
return authStore.user?.is_admin || authStore.user?.role === 'coordinator'
})
const canAssignTasks = computed(() => isCoordinatorOrAdmin.value)
const showDepartmentFilter = computed(() => {
return canAssignTasks.value