feat: Initial commit - Task Reporter incident response system
Complete implementation of the production line incident response system (生產線異常即時反應系統) including: Backend (FastAPI): - User authentication with AD integration and session management - Chat room management (create, list, update, members, roles) - Real-time messaging via WebSocket (typing indicators, reactions) - File storage with MinIO (upload, download, image preview) Frontend (React + Vite): - Authentication flow with token management - Room list with filtering, search, and pagination - Real-time chat interface with WebSocket - File upload with drag-and-drop and image preview - Member management and room settings - Breadcrumb navigation - 53 unit tests (Vitest) Specifications: - authentication: AD auth, sessions, JWT tokens - chat-room: rooms, members, templates - realtime-messaging: WebSocket, messages, reactions - file-storage: MinIO integration, file management - frontend-core: React SPA structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
65
frontend/src/stores/authStore.test.ts
Normal file
65
frontend/src/stores/authStore.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('setAuth', () => {
|
||||
it('should set user and token', () => {
|
||||
useAuthStore.getState().setAuth('test-token-123', 'Test User', 'testuser')
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toEqual({ username: 'testuser', display_name: 'Test User' })
|
||||
expect(state.token).toBe('test-token-123')
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAuth', () => {
|
||||
it('should clear user and token', () => {
|
||||
// First set some auth data
|
||||
useAuthStore.setState({
|
||||
user: { username: 'testuser', display_name: 'Test User' },
|
||||
token: 'test-token-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
useAuthStore.getState().clearAuth()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user properties', () => {
|
||||
// First set some auth data
|
||||
useAuthStore.setState({
|
||||
user: { username: 'testuser', display_name: 'Test User' },
|
||||
token: 'test-token-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
useAuthStore.getState().updateUser({ display_name: 'Updated Name' })
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user?.display_name).toBe('Updated Name')
|
||||
expect(state.user?.username).toBe('testuser')
|
||||
})
|
||||
|
||||
it('should not update if user is null', () => {
|
||||
useAuthStore.getState().updateUser({ display_name: 'New Name' })
|
||||
|
||||
expect(useAuthStore.getState().user).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
51
frontend/src/stores/authStore.ts
Normal file
51
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
|
||||
// Actions
|
||||
setAuth: (token: string, displayName: string, username: string) => void
|
||||
clearAuth: () => void
|
||||
updateUser: (user: Partial<User>) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
setAuth: (token: string, displayName: string, username: string) =>
|
||||
set({
|
||||
token,
|
||||
user: { username, display_name: displayName },
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
|
||||
clearAuth: () =>
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
|
||||
updateUser: (userData: Partial<User>) =>
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...userData } : null,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
172
frontend/src/stores/chatStore.test.ts
Normal file
172
frontend/src/stores/chatStore.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useChatStore } from './chatStore'
|
||||
import type { Message } from '../types'
|
||||
|
||||
describe('chatStore', () => {
|
||||
const mockMessage: Message = {
|
||||
message_id: 'msg-1',
|
||||
room_id: 'room-1',
|
||||
sender_id: 'user-1',
|
||||
content: 'Hello, world!',
|
||||
message_type: 'text',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
sequence_number: 1,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useChatStore.setState({
|
||||
currentRoomId: null,
|
||||
messages: [],
|
||||
hasMoreMessages: true,
|
||||
connectionStatus: 'disconnected',
|
||||
typingUsers: new Set(),
|
||||
onlineUsers: new Set(),
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentRoom', () => {
|
||||
it('should set current room and clear messages', () => {
|
||||
useChatStore.setState({ messages: [mockMessage] })
|
||||
|
||||
useChatStore.getState().setCurrentRoom('room-2')
|
||||
|
||||
const state = useChatStore.getState()
|
||||
expect(state.currentRoomId).toBe('room-2')
|
||||
expect(state.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('should clear typing and online users', () => {
|
||||
useChatStore.setState({
|
||||
typingUsers: new Set(['user-1']),
|
||||
onlineUsers: new Set(['user-2']),
|
||||
})
|
||||
|
||||
useChatStore.getState().setCurrentRoom('room-2')
|
||||
|
||||
const state = useChatStore.getState()
|
||||
expect(state.typingUsers.size).toBe(0)
|
||||
expect(state.onlineUsers.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMessages', () => {
|
||||
it('should set messages array', () => {
|
||||
const messages = [mockMessage, { ...mockMessage, message_id: 'msg-2' }]
|
||||
|
||||
useChatStore.getState().setMessages(messages)
|
||||
|
||||
expect(useChatStore.getState().messages).toEqual(messages)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addMessage', () => {
|
||||
it('should add a new message', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(1)
|
||||
expect(useChatStore.getState().messages[0]).toEqual(mockMessage)
|
||||
})
|
||||
|
||||
it('should append to existing messages', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
useChatStore.getState().addMessage({ ...mockMessage, message_id: 'msg-2' })
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMessage', () => {
|
||||
it('should update an existing message', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
useChatStore.getState().updateMessage('msg-1', { content: 'Updated content' })
|
||||
|
||||
const updatedMessage = useChatStore.getState().messages[0]
|
||||
expect(updatedMessage.content).toBe('Updated content')
|
||||
})
|
||||
|
||||
it('should not update non-existent message', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
useChatStore.getState().updateMessage('non-existent', { content: 'New content' })
|
||||
|
||||
expect(useChatStore.getState().messages[0].content).toBe('Hello, world!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMessage', () => {
|
||||
it('should remove a message by id', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
useChatStore.getState().removeMessage('msg-1')
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setConnectionStatus', () => {
|
||||
it('should update connection status', () => {
|
||||
useChatStore.getState().setConnectionStatus('connected')
|
||||
|
||||
expect(useChatStore.getState().connectionStatus).toBe('connected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setUserTyping', () => {
|
||||
it('should add user to typing set when typing', () => {
|
||||
useChatStore.getState().setUserTyping('user-1', true)
|
||||
|
||||
expect(useChatStore.getState().typingUsers.has('user-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should remove user from typing set when not typing', () => {
|
||||
useChatStore.setState({ typingUsers: new Set(['user-1']) })
|
||||
|
||||
useChatStore.getState().setUserTyping('user-1', false)
|
||||
|
||||
expect(useChatStore.getState().typingUsers.has('user-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('online users', () => {
|
||||
it('should add online user', () => {
|
||||
useChatStore.getState().addOnlineUser('user-1')
|
||||
|
||||
expect(useChatStore.getState().onlineUsers.has('user-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should remove online user', () => {
|
||||
useChatStore.setState({ onlineUsers: new Set(['user-1']) })
|
||||
|
||||
useChatStore.getState().removeOnlineUser('user-1')
|
||||
|
||||
expect(useChatStore.getState().onlineUsers.has('user-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prependMessages', () => {
|
||||
it('should prepend messages to beginning of list', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
const newMessage = { ...mockMessage, message_id: 'msg-0', sequence_number: 0 }
|
||||
|
||||
useChatStore.getState().prependMessages([newMessage])
|
||||
|
||||
const messages = useChatStore.getState().messages
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0].message_id).toBe('msg-0')
|
||||
expect(messages[1].message_id).toBe('msg-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMessages', () => {
|
||||
it('should clear all messages', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
useChatStore.getState().addMessage({ ...mockMessage, message_id: 'msg-2' })
|
||||
|
||||
useChatStore.getState().clearMessages()
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
125
frontend/src/stores/chatStore.ts
Normal file
125
frontend/src/stores/chatStore.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
interface ChatState {
|
||||
// Connection state
|
||||
connectionStatus: ConnectionStatus
|
||||
currentRoomId: string | null
|
||||
|
||||
// Messages
|
||||
messages: Message[]
|
||||
hasMoreMessages: boolean
|
||||
|
||||
// Typing indicators
|
||||
typingUsers: Set<string>
|
||||
|
||||
// Online users
|
||||
onlineUsers: Set<string>
|
||||
|
||||
// Actions
|
||||
setConnectionStatus: (status: ConnectionStatus) => void
|
||||
setCurrentRoom: (roomId: string | null) => void
|
||||
|
||||
// Message actions
|
||||
setMessages: (messages: Message[]) => void
|
||||
addMessage: (message: Message) => void
|
||||
updateMessage: (messageId: string, updates: Partial<Message>) => void
|
||||
removeMessage: (messageId: string) => void
|
||||
prependMessages: (messages: Message[]) => void
|
||||
setHasMoreMessages: (hasMore: boolean) => void
|
||||
clearMessages: () => void
|
||||
|
||||
// Typing actions
|
||||
setUserTyping: (userId: string, isTyping: boolean) => void
|
||||
clearTypingUsers: () => void
|
||||
|
||||
// Online users actions
|
||||
setOnlineUsers: (users: string[]) => void
|
||||
addOnlineUser: (userId: string) => void
|
||||
removeOnlineUser: (userId: string) => void
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
// Initial state
|
||||
connectionStatus: 'disconnected',
|
||||
currentRoomId: null,
|
||||
messages: [],
|
||||
hasMoreMessages: true,
|
||||
typingUsers: new Set(),
|
||||
onlineUsers: new Set(),
|
||||
|
||||
// Connection actions
|
||||
setConnectionStatus: (status) => set({ connectionStatus: status }),
|
||||
|
||||
setCurrentRoom: (roomId) =>
|
||||
set({
|
||||
currentRoomId: roomId,
|
||||
messages: [],
|
||||
hasMoreMessages: true,
|
||||
typingUsers: new Set(),
|
||||
onlineUsers: new Set(),
|
||||
connectionStatus: 'disconnected',
|
||||
}),
|
||||
|
||||
// Message actions
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
messages: [...state.messages, message],
|
||||
})),
|
||||
|
||||
updateMessage: (messageId, updates) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.message_id === messageId ? { ...msg, ...updates } : msg
|
||||
),
|
||||
})),
|
||||
|
||||
removeMessage: (messageId) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((msg) => msg.message_id !== messageId),
|
||||
})),
|
||||
|
||||
prependMessages: (newMessages) =>
|
||||
set((state) => ({
|
||||
messages: [...newMessages, ...state.messages],
|
||||
})),
|
||||
|
||||
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
||||
|
||||
clearMessages: () => set({ messages: [], hasMoreMessages: true }),
|
||||
|
||||
// Typing actions
|
||||
setUserTyping: (userId, isTyping) =>
|
||||
set((state) => {
|
||||
const newTypingUsers = new Set(state.typingUsers)
|
||||
if (isTyping) {
|
||||
newTypingUsers.add(userId)
|
||||
} else {
|
||||
newTypingUsers.delete(userId)
|
||||
}
|
||||
return { typingUsers: newTypingUsers }
|
||||
}),
|
||||
|
||||
clearTypingUsers: () => set({ typingUsers: new Set() }),
|
||||
|
||||
// Online users actions
|
||||
setOnlineUsers: (users) => set({ onlineUsers: new Set(users) }),
|
||||
|
||||
addOnlineUser: (userId) =>
|
||||
set((state) => {
|
||||
const newOnlineUsers = new Set(state.onlineUsers)
|
||||
newOnlineUsers.add(userId)
|
||||
return { onlineUsers: newOnlineUsers }
|
||||
}),
|
||||
|
||||
removeOnlineUser: (userId) =>
|
||||
set((state) => {
|
||||
const newOnlineUsers = new Set(state.onlineUsers)
|
||||
newOnlineUsers.delete(userId)
|
||||
return { onlineUsers: newOnlineUsers }
|
||||
}),
|
||||
}))
|
||||
2
frontend/src/stores/index.ts
Normal file
2
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useAuthStore } from './authStore'
|
||||
export { useChatStore } from './chatStore'
|
||||
Reference in New Issue
Block a user