diff --git a/frontend/src/components/common/ActivityFeed.vue b/frontend/src/components/common/ActivityFeed.vue
new file mode 100644
index 0000000..2f43b5d
--- /dev/null
+++ b/frontend/src/components/common/ActivityFeed.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
+ {{ group.day }}
+
+
+
+
+
+
+
+
{{ item.time }}
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/relay/DrivePage.vue b/frontend/src/pages/relay/DrivePage.vue
index 79323e4..7e4c4bd 100644
--- a/frontend/src/pages/relay/DrivePage.vue
+++ b/frontend/src/pages/relay/DrivePage.vue
@@ -8,6 +8,7 @@ import AppShell from '@/layouts/AppShell.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import LoadMore from '@/components/common/LoadMore.vue'
+import ActivityFeed from '@/components/common/ActivityFeed.vue'
import DriveNameModal from '@/components/drive/DriveNameModal.vue'
import { useDriveStore } from '@/stores/drive.store'
import { useDialog } from '@/composables/useDialog'
@@ -18,7 +19,7 @@ import {
isAllowedDriveMime,
resolveMimeType,
} from '@/shared/utils/drive'
-import type { DriveActivity, DriveFile, DriveFolder } from '@/types/drive'
+import type { DriveFile, DriveFolder } from '@/types/drive'
const route = useRoute()
const router = useRouter()
@@ -33,7 +34,7 @@ const {
uploads,
itemCount,
isEmpty,
- activities,
+ activityDays,
activityLoading,
hasMoreActivity,
} = storeToRefs(store)
@@ -188,56 +189,6 @@ function openActivity(): void {
showActivity.value = true
void store.fetchActivity() // 열 때마다 최신으로 갱신
}
-
-// payload(자유형) 안전 추출
-function ps(payload: Record, key: string): string {
- const v = payload[key]
- return typeof v === 'string' ? v : ''
-}
-// 행위자 표시명(탈퇴 시 대체 문구)
-function actorName(a: DriveActivity): string {
- return a.actor?.name ?? '(탈퇴한 사용자)'
-}
-// 행위 문구 조립 — 대상 이름을 강조하기 위해 앞/대상/뒤 3토막으로 반환
-function actionParts(a: DriveActivity): { pre: string; target: string; post: string } {
- const p = a.payload
- switch (a.type) {
- case 'file_uploaded':
- return { pre: '파일 ', target: ps(p, 'name'), post: '을(를) 업로드했습니다' }
- case 'file_deleted':
- return { pre: '파일 ', target: ps(p, 'name'), post: '을(를) 삭제했습니다' }
- case 'file_renamed':
- return {
- pre: '파일 이름을 ',
- target: `${ps(p, 'fromName')} → ${ps(p, 'toName')}`,
- post: '(으)로 변경했습니다',
- }
- case 'folder_created':
- return { pre: '폴더 ', target: ps(p, 'name'), post: '을(를) 만들었습니다' }
- case 'folder_deleted': {
- const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0
- return {
- pre: '폴더 ',
- target: ps(p, 'name'),
- post: `을(를) 삭제했습니다 (파일 ${cnt}개 포함)`,
- }
- }
- case 'folder_renamed':
- return {
- pre: '폴더 이름을 ',
- target: `${ps(p, 'fromName')} → ${ps(p, 'toName')}`,
- post: '(으)로 변경했습니다',
- }
- default:
- return { pre: '', target: '', post: '' }
- }
-}
-// 타입별 아이콘 톤(업로드/생성=accent, 삭제=danger, 이름변경=muted)
-function actionTone(type: DriveActivity['type']): 'up' | 'del' | 'edit' {
- if (type === 'file_deleted' || type === 'folder_deleted') return 'del'
- if (type === 'file_renamed' || type === 'folder_renamed') return 'edit'
- return 'up'
-}
@@ -603,31 +554,17 @@ function actionTone(type: DriveActivity['type']): 'up' | 'del' | 'edit' {
누가 무엇을 추가·변경·삭제했는지 전체 기록입니다.
-
+
불러오는 중…
-
+
아직 기록이 없습니다.
-
- -
-
-
- {{ actorName(a) }}님이 {{ actionParts(a).pre }}{{ actionParts(a).target }}{{ actionParts(a).post }}
- {{ relativeTimeKo(a.createdAt) }}
-
-
-
+ :days="activityDays"
+ bare
+ />
// 프로젝트 활동 — 날짜 그룹 단위 활동 피드 (API 연동)
-import { computed, defineComponent, h, ref, watch } from 'vue'
+import { computed, ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
-import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
+import ActivityFeed from '@/components/common/ActivityFeed.vue'
import { useProjectStore } from '@/stores/project.store'
import { useActivityStore } from '@/stores/activity.store'
-import type { Project, ActivityIcon } from '@/mock/relay.mock'
+import type { Project } from '@/mock/relay.mock'
type Filter = 'all' | 'task' | 'member' | 'setting'
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
@@ -82,32 +82,6 @@ const memberCount = computed(() =>
)
const loading = computed(() => activityStore.loading)
const isEmpty = computed(() => !loading.value && days.value.length === 0)
-
-// 활동 아이콘 — name 으로 SVG path 분기
-const ACT_PATHS: Record = {
- check: '',
- pencil: '',
- 'member-add': '',
- 'member-check': '',
- 'member-remove': '',
- lock: '',
- repo: '',
-}
-const ActIcon = defineComponent({
- props: { name: { type: String, required: true } },
- setup(props) {
- return () =>
- h('svg', {
- viewBox: '0 0 24 24',
- fill: 'none',
- stroke: 'currentColor',
- 'stroke-width': '2',
- 'stroke-linecap': 'round',
- 'stroke-linejoin': 'round',
- innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
- })
- },
-})
@@ -187,42 +161,10 @@ const ActIcon = defineComponent({
-
-
-
-
-
-
- {{ group.day }}
-
-
-
-
-
-
-
-
{{ item.time }}
-
-
-
-
+ :days="days"
+ />
diff --git a/frontend/src/stores/drive.store.ts b/frontend/src/stores/drive.store.ts
index 91a6d19..4f4f657 100644
--- a/frontend/src/stores/drive.store.ts
+++ b/frontend/src/stores/drive.store.ts
@@ -1,6 +1,11 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useDrive } from '@/composables/useDrive'
+import {
+ dayGroupKo,
+ escapeHtml,
+ relativeTimeKo,
+} from '@/shared/utils/format'
import type {
DriveActivity,
DriveCrumb,
@@ -8,10 +13,56 @@ import type {
DriveFolder,
} from '@/types/drive'
import type { PresignedUpload } from '@/types/drive'
+import type { ProjectActivityDay, ProjectActivityItem } from '@/mock/relay.mock'
// 감사 로그 페이지 크기
const ACTIVITY_PAGE_SIZE = 20
+// payload(자유형)에서 문자열 안전 추출
+function pstr(payload: Record, key: string): string {
+ const v = payload[key]
+ return typeof v === 'string' ? v : ''
+}
+
+// 드라이브 감사 로그 1건 → 공용 활동 아이템(프로젝트 활동과 동일 디자인 토큰).
+// 드라이브 행위는 모두 tone 'setting'(앰버), 추가/생성=repo·변경/삭제=pencil 아이콘.
+// 사용자 입력(파일/폴더명)은 escapeHtml 로 감싸 XSS 를 방지한다.
+function toDriveActivityItem(a: DriveActivity): ProjectActivityItem {
+ const base: Pick<
+ ProjectActivityItem,
+ 'avatarUrl' | 'actorId' | 'actorName' | 'time'
+ > = {
+ avatarUrl: a.actor?.avatarUrl ?? null,
+ actorId: a.actor?.id ?? null,
+ actorName: a.actor?.name ?? null,
+ time: relativeTimeKo(a.createdAt),
+ }
+ const actor = escapeHtml(a.actor?.name ?? '알 수 없음')
+ const p = a.payload
+ const name = `${escapeHtml(pstr(p, 'name'))}`
+ const renamePair = `${escapeHtml(pstr(p, 'fromName'))} → ${escapeHtml(pstr(p, 'toName'))}`
+
+ switch (a.type) {
+ case 'file_uploaded':
+ return { ...base, tone: 'setting', icon: 'repo', html: `${actor}님이 파일 ${name}을(를) 업로드했습니다` }
+ case 'file_renamed':
+ return { ...base, tone: 'setting', icon: 'pencil', html: `${actor}님이 파일 이름을 ${renamePair}(으)로 변경했습니다` }
+ case 'file_deleted':
+ return { ...base, tone: 'setting', icon: 'pencil', html: `${actor}님이 파일 ${name}을(를) 삭제했습니다` }
+ case 'folder_created':
+ return { ...base, tone: 'setting', icon: 'repo', html: `${actor}님이 폴더 ${name}을(를) 만들었습니다` }
+ case 'folder_renamed':
+ return { ...base, tone: 'setting', icon: 'pencil', html: `${actor}님이 폴더 이름을 ${renamePair}(으)로 변경했습니다` }
+ case 'folder_deleted': {
+ const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0
+ const suffix = cnt > 0 ? ` (파일 ${cnt}개 포함)` : ''
+ return { ...base, tone: 'setting', icon: 'pencil', html: `${actor}님이 폴더 ${name}을(를) 삭제했습니다${suffix}` }
+ }
+ default:
+ return { ...base, tone: 'setting', icon: 'repo', html: `${actor}님의 활동` }
+ }
+}
+
// 진행 중 업로드 1건(UI 표시용)
export interface UploadItem {
id: number // 로컬 임시 id
@@ -75,6 +126,21 @@ export const useDriveStore = defineStore('drive', () => {
() => activities.value.length < activityTotal.value,
)
+ // 누적 감사 로그 → 날짜 그룹(최신순이라 같은 날은 연속) — 공용 ActivityFeed 입력
+ const activityDays = computed(() => {
+ const groups: ProjectActivityDay[] = []
+ let current: ProjectActivityDay | null = null
+ for (const a of activities.value) {
+ const day = dayGroupKo(a.createdAt)
+ if (!current || current.day !== day) {
+ current = { day, items: [] }
+ groups.push(current)
+ }
+ current.items.push(toDriveActivityItem(a))
+ }
+ return groups
+ })
+
const isEmpty = computed(
() => folders.value.length === 0 && files.value.length === 0,
)
@@ -210,6 +276,7 @@ export const useDriveStore = defineStore('drive', () => {
loading,
uploads,
activities,
+ activityDays,
activityLoading,
hasMoreActivity,
isEmpty,