diff --git a/frontend/src/assets/icons/collaborator-count.svg b/frontend/src/assets/icons/collaborator-count.svg new file mode 100644 index 0000000000..b13d72044c --- /dev/null +++ b/frontend/src/assets/icons/collaborator-count.svg @@ -0,0 +1,14 @@ + + + 协作者数量 + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/file-count.svg b/frontend/src/assets/icons/file-count.svg new file mode 100644 index 0000000000..9b8b96b9d2 --- /dev/null +++ b/frontend/src/assets/icons/file-count.svg @@ -0,0 +1,17 @@ + + + 文件数量 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/statistics.svg b/frontend/src/assets/icons/statistics.svg new file mode 100644 index 0000000000..1d1c151eb4 --- /dev/null +++ b/frontend/src/assets/icons/statistics.svg @@ -0,0 +1,20 @@ + + + + +app-statistics + + + + + + diff --git a/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js index d3be66e802..dcc68aa489 100644 --- a/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js +++ b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js @@ -10,6 +10,7 @@ export const KEY_ADD_VIEW_MAP = { ADD_GALLERY: 'ADD_GALLERY', ADD_KANBAN: 'ADD_KANBAN', ADD_MAP: 'ADD_MAP', + ADD_STATISTICS: 'ADD_STATISTICS', }; const ADD_VIEW_OPTIONS = [ @@ -25,6 +26,10 @@ const ADD_VIEW_OPTIONS = [ key: KEY_ADD_VIEW_MAP.ADD_KANBAN, type: VIEW_TYPE.KANBAN, }, + { + key: KEY_ADD_VIEW_MAP.ADD_STATISTICS, + type: VIEW_TYPE.STATISTICS, + } ]; const translateLabel = (type) => { @@ -37,6 +42,8 @@ const translateLabel = (type) => { return gettext('Kanban'); case VIEW_TYPE.MAP: return gettext('Map'); + case VIEW_TYPE.STATISTICS: + return gettext('Statistics'); default: return type; } diff --git a/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js b/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js index 0d0a3c8409..ae9da4f00f 100644 --- a/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js +++ b/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js @@ -36,6 +36,10 @@ const ViewsMoreOperations = ({ menuProps }) => { addView(VIEW_TYPE.MAP); return; } + case KEY_ADD_VIEW_MAP.ADD_STATISTICS: { + addView(VIEW_TYPE.STATISTICS); + return; + } default: { return; } diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js index ed3a54ec82..38a820d8bd 100644 --- a/frontend/src/metadata/api.js +++ b/frontend/src/metadata/api.js @@ -408,6 +408,12 @@ class MetadataManagerAPI { return this.req.post(url, params); }; + // statistics + getStatistics = (repoID) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/statistics/'; + return this.req.get(url); + }; + } const metadataAPI = new MetadataManagerAPI(); diff --git a/frontend/src/metadata/constants/view/index.js b/frontend/src/metadata/constants/view/index.js index ea8771ec7d..f1ca373700 100644 --- a/frontend/src/metadata/constants/view/index.js +++ b/frontend/src/metadata/constants/view/index.js @@ -7,6 +7,7 @@ import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_CO export * from './gallery'; export * from './kanban'; export * from './map'; +export * from './statistics'; export * from './table'; export const METADATA_VIEWS_KEY = 'sf-metadata-views'; @@ -25,6 +26,7 @@ export const VIEW_TYPE = { FACE_RECOGNITION: 'face_recognition', KANBAN: 'kanban', MAP: 'map', + STATISTICS: 'statistics', }; export const FACE_RECOGNITION_VIEW_ID = '_face_recognition'; @@ -35,6 +37,7 @@ export const VIEW_TYPE_ICON = { [VIEW_TYPE.FACE_RECOGNITION]: 'face-recognition-view', [VIEW_TYPE.KANBAN]: 'kanban', [VIEW_TYPE.MAP]: 'map', + [VIEW_TYPE.STATISTICS]: 'statistics', 'image': 'image' }; @@ -92,6 +95,13 @@ export const VIEW_TYPE_DEFAULT_BASIC_FILTER = { filter_term: [] }, ], + [VIEW_TYPE.STATISTICS]: [ + { + column_key: PRIVATE_COLUMN_KEY.IS_DIR, + filter_predicate: FILTER_PREDICATE_TYPE.IS, + filter_term: 'file' + }, + ], }; export const VIEW_TYPE_DEFAULT_SORTS = { @@ -100,6 +110,7 @@ export const VIEW_TYPE_DEFAULT_SORTS = { [VIEW_TYPE.FACE_RECOGNITION]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }], [VIEW_TYPE.KANBAN]: [], [VIEW_TYPE.MAP]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }], + [VIEW_TYPE.STATISTICS]: [], }; export const VIEW_SORT_COLUMN_RULES = { diff --git a/frontend/src/metadata/constants/view/statistics.js b/frontend/src/metadata/constants/view/statistics.js new file mode 100644 index 0000000000..0756191bc0 --- /dev/null +++ b/frontend/src/metadata/constants/view/statistics.js @@ -0,0 +1,36 @@ +import { gettext } from '../../../utils/constants'; +import { PREDEFINED_FILE_TYPE_OPTION_KEY } from '../column/predefined'; + +export const FILE_TYPE_NAMES = { + [PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE]: gettext('Pictures'), + [PREDEFINED_FILE_TYPE_OPTION_KEY.DOCUMENT]: gettext('Documents'), + [PREDEFINED_FILE_TYPE_OPTION_KEY.VIDEO]: gettext('Videos'), + [PREDEFINED_FILE_TYPE_OPTION_KEY.AUDIO]: gettext('Audios'), + [PREDEFINED_FILE_TYPE_OPTION_KEY.CODE]: gettext('Codes'), + [PREDEFINED_FILE_TYPE_OPTION_KEY.COMPRESSED]: gettext('Compresseds'), + [PREDEFINED_FILE_TYPE_OPTION_KEY.DIAGRAM]: gettext('Diagrams'), + other: gettext('Others'), +}; + +export const FILE_TYPE_COLORS = { + [PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE]: '#9867ba', + [PREDEFINED_FILE_TYPE_OPTION_KEY.DOCUMENT]: '#4ecdc4', + [PREDEFINED_FILE_TYPE_OPTION_KEY.VIDEO]: '#93c4fd', + [PREDEFINED_FILE_TYPE_OPTION_KEY.AUDIO]: '#79ddcb', + [PREDEFINED_FILE_TYPE_OPTION_KEY.CODE]: '#f6c038', + [PREDEFINED_FILE_TYPE_OPTION_KEY.COMPRESSED]: '#9397fd', + [PREDEFINED_FILE_TYPE_OPTION_KEY.DIAGRAM]: '#74b9ff', + other: '#636e72', +}; + +export const TIME_GROUPING_OPTIONS = [ + { value: 'created', label: gettext('Created time') }, + { value: 'modified', label: gettext('Modified time') }, +]; + +export const CREATOR_SORT_OPTIONS = [ + { value: 'count-asc', text: gettext('Ascending by count') }, + { value: 'count-desc', text: gettext('Descending by count') }, + { value: 'name-asc', text: gettext('Ascending by name') }, + { value: 'name-desc', text: gettext('Descending by name') } +]; diff --git a/frontend/src/metadata/views/statistics/charts/BarChart.js b/frontend/src/metadata/views/statistics/charts/BarChart.js new file mode 100644 index 0000000000..e9bd74d23a --- /dev/null +++ b/frontend/src/metadata/views/statistics/charts/BarChart.js @@ -0,0 +1,454 @@ +import React, { useEffect, useRef } from 'react'; +import * as d3 from 'd3'; + +export const BarChart = ({ data, unit }) => { + const svgRef = useRef(); + const containerRef = useRef(); + + const isDark = document.body.getAttribute('data-bs-theme') === 'dark'; + + useEffect(() => { + if (!data || data.length === 0) return; + + const container = containerRef.current; + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + const containerWidth = container.offsetWidth; + const barWidth = 24; + const minBarSpacing = 24; + const totalBarWidth = barWidth + minBarSpacing; + + const requiredWidth = data.length * totalBarWidth; + const marginReserve = 40; + const shouldScroll = requiredWidth > containerWidth - marginReserve; + const chartWidth = shouldScroll ? requiredWidth : Math.max(containerWidth, requiredWidth); + + const margin = { + top: 15, + right: 30, + bottom: 60, + left: 20 + }; + + const width = chartWidth - margin.left - margin.right; + const height = 250 - margin.top - margin.bottom; + + const g = svg + .attr('width', chartWidth) + .attr('height', 250) + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + const xScale = d3.scaleBand() + .domain(data.map(d => d.name)) + .range([0, width]) + .paddingInner(minBarSpacing / totalBarWidth) + .paddingOuter(0.1); + + const actualBarWidth = barWidth; + const maxValue = d3.max(data, d => d.value); + const yScale = d3.scaleLinear().domain([0, maxValue]).range([height, 0]).nice(); + + const yAxisTickFormat = maxValue > 1000 ? d3.format('.1s') : d3.format('d'); + const yAxis = g.append('g') + .call(d3.axisLeft(yScale) + .tickSize(-width) + .tickFormat(yAxisTickFormat) + .ticks(4) + ); + + yAxis.selectAll('text') + .style('font-size', '12px') + .style('fill', 'var(--bs-body-secondary-color)') + .style('color', 'var(--bs-body-secondary-color)'); + + yAxis.selectAll('.tick line') + .style('stroke', isDark ? 'var(--bs-border-color)' : '#f5f5f5') + .style('stroke-width', 1) + .style('opacity', 0.7); + + yAxis.select('.domain').remove(); + + g.selectAll('.bar') + .data(data) + .enter() + .append('path') + .attr('class', 'bar') + .attr('fill', '#ff9800') + .attr('d', d => { + const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2; + const width = actualBarWidth; + const height = 0; + const y = height; + const radius = Math.min(4, actualBarWidth / 6); + + return `M${x},${y} + L${x},${y - height + radius} + Q${x},${y - height} ${x + radius},${y - height} + L${x + width - radius},${y - height} + Q${x + width},${y - height} ${x + width},${y - height + radius} + L${x + width},${y} + Z`; + }) + .transition() + .duration(400) + .delay((d, i) => i * 100) + .attr('d', d => { + const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2; + const width = actualBarWidth; + const barHeight = height - yScale(d.value); + const y = height; + const radius = Math.min(4, barHeight / 2, actualBarWidth / 6); + + if (barHeight <= 0) return ''; + + return `M${x},${y} + L${x},${y - barHeight + radius} + Q${x},${y - barHeight} ${x + radius},${y - barHeight} + L${x + width - radius},${y - barHeight} + Q${x + width},${y - barHeight} ${x + width},${y - barHeight + radius} + L${x + width},${y} + Z`; + }); + + const xAxis = g.append('g') + .attr('transform', `translate(0,${height})`) + .call(d3.axisBottom(xScale).tickSize(0)); + + xAxis.selectAll('text').remove(); + + xAxis.selectAll('.custom-tick-text') + .data(data) + .enter() + .append('g') + .attr('class', 'custom-tick-text') + .attr('transform', d => `translate(${xScale(d.name) + xScale.bandwidth() / 2}, 0)`) + .each(function (d) { + const tickGroup = d3.select(this); + const dateText = d.name; + + const monthYearMatch = dateText.match(/^([A-Za-z]{3,})\s+(\d{4})$/); + if (monthYearMatch) { + const month = monthYearMatch[1]; + const year = monthYearMatch[2]; + + tickGroup.append('text') + .attr('x', 0) + .attr('y', 12) + .attr('text-anchor', 'middle') + .style('font-size', '11px') + .style('fill', 'var(--bs-body-secondary-color)') + .style('color', 'var(--bs-body-secondary-color)') + .text(month); + + tickGroup.append('text') + .attr('x', 0) + .attr('y', 26) + .attr('text-anchor', 'middle') + .style('font-size', '10px') + .style('fill', 'var(--bs-body-secondary-color)') + .style('color', 'var(--bs-body-secondary-color)') + .text(year); + } else { + tickGroup.append('text') + .attr('x', 0) + .attr('y', 16) + .attr('text-anchor', 'middle') + .style('font-size', '11px') + .style('fill', 'var(--bs-body-secondary-color)') + .style('color', 'var(--bs-body-secondary-color)') + .text(dateText.length > 8 ? dateText.substring(0, 6) + '...' : dateText); + } + }); + + xAxis.select('.domain').remove(); + + g.selectAll('.value-label') + .data(data) + .enter() + .append('text') + .attr('class', 'value-label') + .attr('x', d => xScale(d.name) + xScale.bandwidth() / 2) + .attr('y', d => yScale(d.value) - 4) + .attr('text-anchor', 'middle') + .style('font-size', '11px') + .style('fill', 'var(--bs-body-secondary-color)') + .style('color', 'var(--bs-body-secondary-color)') + .style('opacity', 0) + .text(d => yAxisTickFormat(d.value)) + .transition() + .duration(400) + .delay((d, i) => i * 100 + 400) + .style('opacity', 1); + + g.selectAll('.bar') + .on('mouseover', function (event, d) { + d3.select(this).transition().duration(200).attr('fill', '#e65100'); + }) + .on('mouseout', function (event, d) { + d3.select(this).transition().duration(200).attr('fill', '#ff9800'); + }); + + }, [data, unit, isDark]); + + const barWidth = 24; + const minBarSpacing = 24; + const totalBarWidth = barWidth + minBarSpacing; + const requiredWidth = (data?.length || 0) * totalBarWidth; + const needsScrolling = requiredWidth > 400; + + return ( +
+ + +
+ ); +}; + +export const HorizontalBarChart = ({ data }) => { + const svgRef = useRef(); + const containerRef = useRef(); + + const isDark = document.body.getAttribute('data-bs-theme') === 'dark'; + + useEffect(() => { + if (!data || data.length === 0) return; + + const container = containerRef.current; + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + const containerWidth = container.offsetWidth; + const isMobile = containerWidth < 480; + const margin = { + top: 10, + right: isMobile ? 60 : 80, + bottom: 30, + left: isMobile ? 100 : 120 + }; + + const width = Math.max(350, containerWidth - margin.left - margin.right); + + const minBarSpacing = 26; + const barHeight = 18; + const totalItemHeight = barHeight + minBarSpacing; + const requiredContentHeight = data.length * totalItemHeight; + + const containerHeight = container.offsetHeight || 340; + const availableHeight = containerHeight - margin.top - margin.bottom; + + const shouldScroll = requiredContentHeight > availableHeight; + const svgHeight = shouldScroll ? requiredContentHeight + margin.top + margin.bottom : containerHeight; + + const actualContentHeight = shouldScroll ? requiredContentHeight : availableHeight; + const centerOffset = shouldScroll ? 0 : Math.max(0, (availableHeight - requiredContentHeight) / 2); + + const g = svg + .attr('width', '100%') + .attr('height', svgHeight) + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + const maxValue = d3.max(data, d => d.value); + + const getNiceNumber = (value, round = true) => { + const exponent = Math.floor(Math.log10(value)); + const fraction = value / Math.pow(10, exponent); + let niceFraction; + + if (round) { + if (fraction < 1.5) niceFraction = 1; + else if (fraction < 3) niceFraction = 2; + else if (fraction < 7) niceFraction = 5; + else niceFraction = 10; + } else { + if (fraction <= 1) niceFraction = 1; + else if (fraction <= 2) niceFraction = 2; + else if (fraction <= 5) niceFraction = 5; + else niceFraction = 10; + } + + return niceFraction * Math.pow(10, exponent); + }; + + const range = getNiceNumber(maxValue, false); + const stepSize = getNiceNumber(range / 4, true); + const niceMax = Math.ceil(maxValue / stepSize) * stepSize; + const adjustedMax = Math.max(niceMax, stepSize * 4); + const finalStepSize = adjustedMax / 4; + + const xScale = d3.scaleLinear().domain([0, adjustedMax]).range([0, width - 60]); + + const yScale = d3.scaleBand() + .domain(data.map(d => d.name)) + .range([centerOffset, centerOffset + (shouldScroll ? requiredContentHeight : Math.min(requiredContentHeight, availableHeight))]) + .paddingInner(minBarSpacing / totalItemHeight) + .paddingOuter(0.1); + + const xAxis = g.append('g') + .attr('transform', `translate(0,${actualContentHeight})`) + .call(d3.axisBottom(xScale) + .tickValues([0, finalStepSize, finalStepSize * 2, finalStepSize * 3, adjustedMax]) + .tickFormat(d3.format('d')) + .tickSize(-actualContentHeight) + ); + + xAxis.selectAll('text') + .style('font-size', isMobile ? '10px' : '11px') + .style('color', '#666') + .attr('dy', '20px'); + + xAxis.selectAll('.tick line') + .style('stroke', isDark ? 'var(--bs-border-color)' : '#f5f5f5') + .style('stroke-width', 1); + + xAxis.select('.domain').remove(); + + const barY = d => yScale(d.name) + (yScale.bandwidth() - barHeight) / 2; + + g.selectAll('.bar') + .data(data) + .enter() + .append('path') + .attr('class', 'bar') + .attr('fill', '#6395fa') + .attr('d', d => { + const width = 0; + const height = barHeight; + const y = barY(d); + return `M0,${y} L${width},${y} L${width},${y + height} L0,${y + height} Z`; + }) + .transition() + .duration(400) + .delay((d, i) => i * 100) + .attr('d', d => { + const width = xScale(d.value); + const height = barHeight; + const y = barY(d); + const radius = Math.min(3, width / 2); + + if (width <= 0) return ''; + + return `M0,${y} + L${width - radius},${y} + Q${width},${y} ${width},${y + radius} + L${width},${y + height - radius} + Q${width},${y + height} ${width - radius},${y + height} + L0,${y + height} + Z`; + }); + + const yAxisGroup = g.append('g').attr('class', 'y-axis'); + + data.forEach((d, i) => { + const yPos = yScale(d.name) + yScale.bandwidth() / 2; + const itemGroup = yAxisGroup.append('g').attr('transform', `translate(0, ${yPos})`); + const avatarCenterX = -margin.left + 4 + 14; + + itemGroup.append('circle') + .attr('cx', avatarCenterX) + .attr('cy', 0) + .attr('r', 14) + .attr('fill', '#f0f0f0') + .attr('stroke', '#ddd') + .attr('stroke-width', 1); + + itemGroup.append('clipPath') + .attr('id', `avatar-clip-${i}`) + .append('circle') + .attr('cx', avatarCenterX) + .attr('cy', 0) + .attr('r', 14); + + itemGroup.append('image') + .attr('x', avatarCenterX - 14) + .attr('y', -14) + .attr('width', 28) + .attr('height', 28) + .attr('href', d.avatarUrl) + .attr('clip-path', `url(#avatar-clip-${i})`) + .style('opacity', 0) + .transition() + .duration(400) + .delay(i * 100) + .style('opacity', 1); + + const nameX = avatarCenterX + 14 + 12; + const maxNameWidth = 70; + + const nameText = itemGroup.append('text') + .attr('x', nameX) + .attr('y', 0) + .attr('dy', '0.35em') + .attr('text-anchor', 'start') + .style('font-size', '13px') + .style('fill', isDark ? 'var(--bs-body-secondary-color)' : '#333') + .style('color', isDark ? 'var(--bs-body-secondary-color)' : '#333'); + + itemGroup.select('image').append('title').text(d.displayName); + itemGroup.select('text').append('title').text(d.displayName); + + nameText.text(d.displayName); + let textWidth = nameText.node().getBBox().width; + + if (textWidth > maxNameWidth) { + let truncatedName = d.displayName; + while (truncatedName.length > 3) { + truncatedName = truncatedName.slice(0, -1); + nameText.text(truncatedName + '...'); + textWidth = nameText.node().getBBox().width; + if (textWidth <= maxNameWidth) break; + } + } + }); + + g.selectAll('.value-label') + .data(data) + .enter() + .append('text') + .attr('class', 'value-label') + .attr('x', d => xScale(d.value) + 4) + .attr('y', d => barY(d) + barHeight / 2) + .attr('dy', '0.35em') + .style('font-size', '12px') + .style('fill', '#666') + .style('color', '#666') + .style('opacity', 0) + .text(d => d.value) + .transition() + .duration(400) + .delay((d, i) => i * 100 + 400) + .style('opacity', 1); + + }, [data, isDark]); + + const minBarSpacing = 26; + const barHeight = 18; + const totalItemHeight = barHeight + minBarSpacing; + const requiredHeight = (data?.length || 0) * totalItemHeight; + const needsScrolling = requiredHeight > 300; + + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/metadata/views/statistics/charts/PieChart.js b/frontend/src/metadata/views/statistics/charts/PieChart.js new file mode 100644 index 0000000000..dfc449b7a5 --- /dev/null +++ b/frontend/src/metadata/views/statistics/charts/PieChart.js @@ -0,0 +1,192 @@ +import React, { useEffect, useRef } from 'react'; +import * as d3 from 'd3'; + +const PieChart = ({ data }) => { + const svgRef = useRef(); + const containerRef = useRef(); + + const isDark = document.body.getAttribute('data-bs-theme') === 'dark'; + + useEffect(() => { + if (!data || data.length === 0) return; + + const container = containerRef.current; + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + const tooltip = d3.select(container) + .append('div') + .attr('class', 'pie-chart-tooltip') + .style('position', 'absolute') + .style('background', 'rgba(0, 0, 0, 0.8)') + .style('color', 'white') + .style('padding', '8px 12px') + .style('border-radius', '4px') + .style('font-size', '12px') + .style('pointer-events', 'none') + .style('opacity', 0) + .style('z-index', '1000'); + + const containerWidth = container.offsetWidth; + const isMobile = containerWidth < 480; + const radius = 150; + const pieSize = (radius + 20) * 2; + + svg.attr('width', '100%').attr('height', pieSize); + + const g = svg.append('g') + .attr('transform', `translate(${pieSize / 2}, ${pieSize / 2})`); + + const pie = d3.pie().value(d => d.value).sort(null); + const arc = d3.arc().innerRadius(0).outerRadius(radius); + + const arcs = g.selectAll('.arc') + .data(pie(data)) + .enter() + .append('g') + .attr('class', 'arc'); + + arcs.append('path') + .attr('d', arc) + .attr('fill', d => d.data.color || '#95a5a6') + .attr('stroke', '#fff') + .attr('stroke-width', 2) + .style('opacity', 0.9) + .on('mouseover', function (event, d) { + const total = d3.sum(data, d => d.value); + const percentage = Math.round((d.data.value / total) * 100); + + d3.select(this) + .style('opacity', 1) + .transition() + .duration(200) + .attr('transform', function (d) { + const centroid = arc.centroid(d); + return `translate(${centroid[0] * 0.05}, ${centroid[1] * 0.05})`; + }); + + const containerRect = container.getBoundingClientRect(); + tooltip + .style('opacity', 1) + .html(`${d.data.label}
Count: ${d.data.value}
Percentage: ${percentage}%`) + .style('left', (event.clientX - containerRect.left + 50) + 'px') + .style('top', (event.clientY - containerRect.top + 100) + 'px'); + }) + .on('mousemove', function (event, d) { + const containerRect = container.getBoundingClientRect(); + tooltip + .style('left', (event.clientX - containerRect.left + 50) + 'px') + .style('top', (event.clientY - containerRect.top + 100) + 'px'); + }) + .on('mouseout', function (event, d) { + d3.select(this) + .style('opacity', 0.9) + .transition() + .duration(200) + .attr('transform', 'translate(0,0)'); + + tooltip.style('opacity', 0); + }); + + const total = d3.sum(data, d => d.value); + const minSlicePercentage = 0.04; + + arcs.filter(d => (d.data.value / total) >= minSlicePercentage) + .each(function (d) { + const textGroup = d3.select(this); + const sliceCenterAngle = (d.startAngle + d.endAngle) / 2; + const percentage = Math.round((d.data.value / total) * 100); + const labelText = `${d.data.value}(${percentage}%)`; + + const isRightSide = sliceCenterAngle < Math.PI; + const baselineRotation = (sliceCenterAngle * 180 / Math.PI) - 90; + const textRotation = isRightSide ? baselineRotation : baselineRotation + 180; + + const measurementText = textGroup.append('text') + .style('font-size', '13px') + .text(labelText) + .style('opacity', 0); + + const labelWidth = measurementText.node().getBBox().width; + measurementText.remove(); + + const edgeMargin = 10; + const maxLabelDistance = radius - edgeMargin; + const safeLabelDistance = Math.min(maxLabelDistance, radius - labelWidth - edgeMargin); + + const labelX = Math.cos(sliceCenterAngle - Math.PI / 2) * safeLabelDistance; + const labelY = Math.sin(sliceCenterAngle - Math.PI / 2) * safeLabelDistance; + + const textAnchor = isRightSide ? 'start' : 'end'; + + textGroup.append('text') + .attr('transform', `translate(${labelX}, ${labelY}) rotate(${textRotation})`) + .attr('text-anchor', textAnchor) + .attr('dominant-baseline', 'central') + .style('font-size', '13px') + .style('font-weight', '400') + .style('fill', 'none') + .style('stroke', '#fff') + .style('stroke-width', '2px') + .style('stroke-linejoin', 'round') + .text(labelText); + + // Then draw the main text (colored fill, no stroke) + textGroup.append('text') + .attr('transform', `translate(${labelX}, ${labelY}) rotate(${textRotation})`) + .attr('text-anchor', textAnchor) + .attr('dominant-baseline', 'central') + .style('font-size', '13px') + .style('font-weight', '400') + .style('fill', '#666') + .style('color', '#666') + .text(labelText); + }); + + const legendContainer = svg.append('g') + .attr('class', 'legend') + .attr('transform', `translate(${pieSize + 15}, ${pieSize / 2 - (data.length * 11)})`); + + const legend = legendContainer.selectAll('.legend-item') + .data(data) + .enter() + .append('g') + .attr('class', 'legend-item') + .attr('transform', (d, i) => `translate(0, ${i * 22})`); + + legend.append('rect') + .attr('width', 20) + .attr('height', 6) + .attr('rx', 3) + .attr('fill', d => d.color || '#95a5a6'); + + legend.append('text') + .attr('x', 26) + .attr('y', 3) + .attr('dy', '0.35em') + .style('font-size', '13px') + .style('color', isDark ? 'var(--bs-body-secondary-color)' : '#333') + .style('fill', isDark ? 'var(--bs-body-secondary-color)' : '#333') + .text(d => { + const maxLength = isMobile ? 10 : 15; + const label = d.label.length > maxLength ? d.label.substring(0, maxLength) + '...' : d.label; + return label; + }); + + }, [data, isDark]); + + useEffect(() => { + const currentContainer = containerRef.current; + return () => { + d3.select(currentContainer).selectAll('.pie-chart-tooltip').remove(); + }; + }, []); + + return ( +
+ +
+ ); +}; + +export default PieChart; diff --git a/frontend/src/metadata/views/statistics/charts/SummaryCards.js b/frontend/src/metadata/views/statistics/charts/SummaryCards.js new file mode 100644 index 0000000000..bae6c05d4a --- /dev/null +++ b/frontend/src/metadata/views/statistics/charts/SummaryCards.js @@ -0,0 +1,25 @@ +import { gettext } from '../../../../utils/constants'; +import Icon from '../../../../components/icon'; + +const SummaryCards = ({ totalFiles, totalCollaborators }) => { + return ( +
+
+ +
+
{totalFiles.toLocaleString()}
+
{gettext('File count')}
+
+
+
+ +
+
{totalCollaborators.toLocaleString()}
+
{gettext('Collaborator count')}
+
+
+
+ ); +}; + +export default SummaryCards; diff --git a/frontend/src/metadata/views/statistics/charts/index.js b/frontend/src/metadata/views/statistics/charts/index.js new file mode 100644 index 0000000000..c4ce4f8bae --- /dev/null +++ b/frontend/src/metadata/views/statistics/charts/index.js @@ -0,0 +1,3 @@ +export { default as PieChart } from './PieChart'; +export { BarChart, HorizontalBarChart } from './BarChart'; +export { default as SummaryCards } from './SummaryCards'; diff --git a/frontend/src/metadata/views/statistics/index.css b/frontend/src/metadata/views/statistics/index.css new file mode 100644 index 0000000000..6ed6971458 --- /dev/null +++ b/frontend/src/metadata/views/statistics/index.css @@ -0,0 +1,413 @@ +.statistics-view { + height: 100%; + overflow: auto; + padding: 20px; + box-sizing: border-box; +} + +.statistics-container { + width: 100%; + min-width: 1200px; + min-height: 100%; + height: max-content; + gap: 20px; + display: grid; + grid-template-columns: 550px 1fr auto; + grid-template-rows: 420px 340px; + grid-template-areas: + "file-type creator creator" + "time time summary"; +} + +.chart-container { + background: var(--bs-body-bg); + border-radius: 12px; + padding: 20px; + box-shadow: 0 -2px 4px #f0f0f0; + border: 1px solid #f0f0f0; + display: flex; + flex-direction: column; + overflow: hidden; + transition: box-shadow 0.2s ease; + min-height: 300px; +} + +[data-bs-theme=dark] .chart-container { + border-color: var(--bs-border-color); + box-shadow: none; +} + +.chart-container:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); +} + +.chart-container:focus-within { + outline: 2px solid #3498db; + outline-offset: 2px; +} + +.chart-container.file-type-chart-container { + grid-area: file-type; +} + +.chart-container.creator-chart-container { + grid-area: creator; +} + +.chart-container.time-chart-container { + grid-area: time; + gap: 15px; +} + +.chart-container.summary-chart-container { + grid-area: summary; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 24px; + width: fit-content; +} + +.chart-header { + width: 100%; + height: 36px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} + +.chart-header h4 { + color: var(--bs-body-color); + font-size: 14px; + margin: 0; + border-bottom: none; + padding-bottom: 0; +} + +.chart-header .dropdown { + color: var(--bs-icon-color); +} + +.chart-header .dropdown:hover { + background-color: var(--bs-hover-bg); + border-radius: 3px; +} + +.pie-chart-container, +.horizontal-bar-chart-container, +.bar-chart-container { + flex: 1; + display: flex; + min-height: 0; +} + +.pie-chart-container { + justify-content: center; + align-items: center; + min-height: 320px; +} + +.bar-chart-container { + height: 240px; + overflow-x: auto; +} + +.horizontal-bar-chart-container { + width: 100%; + height: 340px; + overflow-x: auto; + justify-content: center; + align-items: center; +} + +.bar-chart-responsive { + width: 100%; + height: 240px; + display: flex; + flex-direction: column; + min-width: 400px; +} + +.bar-chart-responsive svg { + flex: 1; + height: 240px; +} + +.card-container { + display: flex; + gap: 20px; +} + +.summary-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 50px 20px; + background: #fef7ea; + border-radius: 10px; + width: 240px; + height: 240px; + flex-shrink: 0; +} + +.summary-card:nth-child(2) { + background: #f8f9fd; +} + +.summary-icon { + width: 50px; + height: 50px; + font-size: 32px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; +} + +.summary-content { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + justify-content: space-between; +} + +.summary-number { + font-size: 42px; + font-weight: 700; + color: #212529; + line-height: 1; + margin: 0; +} + +.summary-label { + font-size: 16px; + color: #666; + font-weight: 500; + letter-spacing: 0.5px; + margin: 0; +} + +.chart-container.time-chart-container .sf-metadata-radio-group { + height: 28px; + border: none; + padding: 2px; + background-color: #f3f3f3; +} + +.chart-container.time-chart-container .sf-metadata-radio-group::before { + content: none; +} + +[data-bs-theme=dark] .chart-container.time-chart-container .sf-metadata-radio-group { + background-color: var(--bs-nav-active-bg); +} + +.chart-container.time-chart-container .sf-metadata-radio-group .sf-metadata-radio-group-option.active { + background-color: var(--bs-body-bg); +} + +.chart-container.time-chart-container .sf-metadata-time-grouping-setter .sf-metadata-radio-group-option { + height: 24px; + font-size: 13px; + padding: 0 6px; + background-color: transparent; + border-radius: 3px; +} + +.sf-metadata-time-grouping-setter[data-active="created"]::before { + transform: translateX(0); +} + +.sf-metadata-time-grouping-setter[data-active="modified"]::before { + transform: translateX(98px); +} + +.statistics-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + min-height: 400px; +} + +.statistics-error { + text-align: center; + padding: 48px 24px; + color: #666; + font-size: 16px; + background: white; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + border: 1px solid var(--bs-border-color); +} + +.no-data-message { + text-align: center; + padding: 40px 20px; + color: #666; + font-size: 14px; + font-style: italic; + background: #f8f9fa; + border-radius: 8px; +} + +.arc path, +.bar { + transition: all 0.2s ease; +} + +.arc path:hover { + opacity: 0.8; + stroke-width: 3px; +} + +.bar:hover { + opacity: 0.8; +} + +.chart-wrapper { + animation: fadeInUp 0.6s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.arc path, +.bar { + animation: fadeIn 0.5s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.statistics-view .domain { + stroke: #999; +} + +.statistics-view .tick line { + stroke: #999; +} + +.statistics-view .tick text { + fill: #666; + font-size: 12px; +} + +@media (max-width: 1023.98px) { + .statistics-view { + height: 100%; + overflow-y: auto; + overflow-x: hidden; + min-height: 100%; + padding: 20px; + } + + .statistics-container { + grid-template-columns: 1fr; + grid-template-rows: 420px 420px minmax(420px, auto) 420px; + grid-template-areas: + "file-type" + "creator" + "time" + "summary"; + max-width: 550px; + min-width: auto; + margin: 0 auto; + height: auto; + } + + .chart-container.summary-chart-container { + justify-content: space-between; + align-items: center; + padding: 40px 24px; + } + + .summary-card { + width: 240px; + height: 240px; + padding: 24px; + } + + .summary-number { + font-size: 24px; + } + + .summary-icon { + font-size: 28px; + width: 40px; + height: 40px; + } +} + +@media (max-width: 768px) { + .statistics-view { + padding: 0; + } + + .statistics-container { + padding: 16px; + gap: 16px; + max-width: 100%; + grid-template-rows: repeat(4, 380px); + } + + .chart-container { + padding: 20px; + } + + .chart-container h4 { + font-size: 14px; + margin-bottom: 16px; + padding-bottom: 8px; + } + + .chart-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; + } + + .chart-container.summary-chart-container { + flex-direction: column; + justify-content: center; + gap: 16px; + padding: 24px 20px; + } + + .summary-card { + width: 100%; + max-width: 300px; + height: 100px; + padding: 20px; + align-self: center; + } + + .summary-number { + font-size: 20px; + } + + .summary-icon { + font-size: 24px; + width: 36px; + height: 36px; + } +} diff --git a/frontend/src/metadata/views/statistics/index.js b/frontend/src/metadata/views/statistics/index.js new file mode 100644 index 0000000000..611acb1c24 --- /dev/null +++ b/frontend/src/metadata/views/statistics/index.js @@ -0,0 +1,143 @@ +import React, { useState, useMemo } from 'react'; +import { gettext } from '../../../utils/constants'; +import Loading from '../../../components/loading'; +import SortMenu from '../../../components/sort-menu'; +import RadioGroup from '../../components/radio-group'; +import EmptyTip from '../../../components/empty-tip'; +import { useMetadataView } from '../../hooks/metadata-view'; +import { useCollaborators } from '../../hooks/collaborators'; +import { PieChart, BarChart, HorizontalBarChart, SummaryCards } from './charts'; +import { useStatisticsData } from './useStatisticsData'; +import { processFileTypeData, processTimeData, processCreatorData } from './utils'; +import { TIME_GROUPING_OPTIONS, CREATOR_SORT_OPTIONS } from '../../constants/view/statistics'; + +import './index.css'; + +const Statistics = () => { + const { repoID } = useMetadataView(); + const { collaborators, getCollaborator: getCollaboratorFromHook } = useCollaborators(); + const { isLoading, statisticsData } = useStatisticsData(repoID); + + const [timeGrouping, setTimeGrouping] = useState('created'); + const [creatorSortBy, setCreatorSortBy] = useState('count'); + const [creatorSortOrder, setCreatorSortOrder] = useState('desc'); + + const pieChartData = useMemo(() => + processFileTypeData(statisticsData?.fileTypeStats), + [statisticsData] + ); + + const timeChartData = useMemo(() => + processTimeData(statisticsData?.timeStats, timeGrouping), + [statisticsData, timeGrouping] + ); + + const creatorChartData = useMemo(() => + processCreatorData( + statisticsData?.creatorStats, + collaborators, + getCollaboratorFromHook, + creatorSortBy, + creatorSortOrder + ), + [statisticsData, collaborators, getCollaboratorFromHook, creatorSortBy, creatorSortOrder] + ); + + const handleTimeGroupingChange = (newGrouping) => { + setTimeGrouping(newGrouping); + }; + + const handleCreatorSortChange = (item) => { + const [sortBy, sortOrder] = item.value.split('-'); + setCreatorSortBy(sortBy); + setCreatorSortOrder(sortOrder); + }; + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!statisticsData) { + return ( + + ); + } + + return ( +
+
+
+
+

{gettext('Files by type')}

+
+
+ +
+
+ +
+
+

{gettext('Files by creator')}

+ +
+
+ {creatorChartData.length > 0 ? ( + + ) : ( +
+ {gettext('No creator data available')} +
+ )} +
+
+ +
+
+

{gettext('Distributed by time')}

+ +
+
+ {timeChartData.length > 0 ? ( + + ) : ( +
+ {gettext('No time-based data available')} +
+ )} +
+
+ +
+
+

{gettext('Library')}

+
+ +
+
+
+ ); +}; + +export default Statistics; diff --git a/frontend/src/metadata/views/statistics/useStatisticsData.js b/frontend/src/metadata/views/statistics/useStatisticsData.js new file mode 100644 index 0000000000..f770bc8a52 --- /dev/null +++ b/frontend/src/metadata/views/statistics/useStatisticsData.js @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; +import metadataAPI from '../../api'; + +export const useStatisticsData = (repoID) => { + const [isLoading, setIsLoading] = useState(true); + const [statisticsData, setStatisticsData] = useState(null); + + const fetchStatisticsData = useCallback(async () => { + if (!repoID) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + const response = await metadataAPI.getStatistics(repoID); + + if (response.data && response.data.summary_stats.total_files !== 0) { + const transformedData = { + fileTypeStats: response.data.file_type_stats.map(item => ({ + type: item.type, + count: item.count + })), + timeStats: { + created: { + unit: response.data.time_stats.created.unit || 'year', + data: (response.data.time_stats.created.data || []).map(item => ({ + period: item.period || item.year || item.month || item.day, + label: item.label || item.year?.toString() || item.period, + count: item.count + })) + }, + modified: { + unit: response.data.time_stats.modified.unit || 'year', + data: (response.data.time_stats.modified.data || []).map(item => ({ + period: item.period || item.year || item.month || item.day, + label: item.label || item.year?.toString() || item.period, + count: item.count + })) + } + }, + creatorStats: response.data.creator_stats.map(item => ({ + creator: item.creator, + count: item.count + })), + totalFiles: response.data.summary_stats.total_files, + totalCollaborators: response.data.summary_stats.total_collaborators + }; + + setStatisticsData(transformedData); + } else { + setStatisticsData(null); + } + } catch (error) { + toaster.danger(Utils.getErrorMsg(error)); + setStatisticsData(null); + } finally { + setIsLoading(false); + } + }, [repoID]); + + useEffect(() => { + fetchStatisticsData(); + }, [fetchStatisticsData]); + + return { isLoading, statisticsData, refetch: fetchStatisticsData }; +}; diff --git a/frontend/src/metadata/views/statistics/utils.js b/frontend/src/metadata/views/statistics/utils.js new file mode 100644 index 0000000000..25eadacfcc --- /dev/null +++ b/frontend/src/metadata/views/statistics/utils.js @@ -0,0 +1,54 @@ +import { mediaUrl } from '../../../utils/constants'; +import { getCollaborator } from '../../utils/cell/column/collaborator'; +import { FILE_TYPE_NAMES, FILE_TYPE_COLORS } from '../../constants/view/statistics'; + +export const processFileTypeData = (fileTypeStats) => { + if (!fileTypeStats) return []; + + return fileTypeStats.map(item => ({ + label: FILE_TYPE_NAMES[item.type] || item.type, + value: item.count, + color: FILE_TYPE_COLORS[item.type] || '#636e72', + type: item.type + })); +}; + +export const processTimeData = (timeStats, timeGrouping) => { + if (!timeStats?.[timeGrouping]?.data) return []; + + return timeStats[timeGrouping].data.map(item => ({ + name: item.label, + value: item.count, + period: item.period + })); +}; + +export const processCreatorData = (creatorStats, collaborators, getCollaboratorFromHook, sortBy, sortOrder) => { + if (!creatorStats) return []; + + const processed = creatorStats.map(item => { + const collaborator = getCollaboratorFromHook + ? getCollaboratorFromHook(item.creator) + : getCollaborator(collaborators, item.creator); + + return { + name: item.creator, + displayName: collaborator?.name || item.creator, + avatarUrl: collaborator?.avatar_url || `${mediaUrl}/avatars/default.png`, + value: item.count, + collaborator: collaborator + }; + }); + + return processed.sort((a, b) => { + let compareValue = 0; + + if (sortBy === 'count') { + compareValue = a.value - b.value; + } else if (sortBy === 'name') { + compareValue = a.displayName.localeCompare(b.displayName); + } + + return sortOrder === 'asc' ? compareValue : -compareValue; + }); +}; diff --git a/frontend/src/metadata/views/view.js b/frontend/src/metadata/views/view.js index 3a17243841..57adb76c8e 100644 --- a/frontend/src/metadata/views/view.js +++ b/frontend/src/metadata/views/view.js @@ -6,6 +6,7 @@ import Gallery from './gallery'; import FaceRecognition from './face-recognition'; import Kanban from './kanban'; import Map from './map'; +import Statistics from './statistics'; import { useMetadataView } from '../hooks/metadata-view'; import { VIEW_TYPE } from '../constants'; import { gettext } from '../../utils/constants'; @@ -32,6 +33,9 @@ const View = () => { case VIEW_TYPE.MAP: { return ; } + case VIEW_TYPE.STATISTICS: { + return ; + } default: return null; } diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py index edae20515e..ea52a3832b 100644 --- a/seahub/repo_metadata/apis.py +++ b/seahub/repo_metadata/apis.py @@ -3270,3 +3270,349 @@ class MetadataImportTags(APIView): logger.exception(e) return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') return Response({'success': True}) + + +class MetadataStatistics(APIView): + """ + API for retrieving library metadata statistics for the statistics view. + """ + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request, repo_id): + """ + Get statistics data for metadata view including: + - File type distribution + - Files by creation/modification time + - Files by creator + - Summary statistics + """ + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if not can_read_metadata(request, repo_id): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + record = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not record or not record.enabled: + error_msg = f'The metadata module is disabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + from seafevents.repo_metadata.constants import METADATA_TABLE + + statistics = self._get_sql_statistics(metadata_server_api, METADATA_TABLE) + + return Response(statistics) + + except Exception as e: + logger.error(f'Error generating metadata statistics for repo {repo_id}: {e}') + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + def _get_sql_statistics(self, metadata_server_api, METADATA_TABLE): + default_response = { + 'file_type_stats': [], + 'time_stats': { + 'created': {'unit': 'year', 'data': [], 'grouping': 'created'}, + 'modified': {'unit': 'year', 'data': [], 'grouping': 'modified'} + }, + 'creator_stats': [], + 'summary_stats': { + 'total_files': 0, + 'total_collaborators': 0 + } + } + + try: + file_type_stats = self._get_file_type_stats_sql(metadata_server_api, METADATA_TABLE) + creator_stats = self._get_creator_stats_sql(metadata_server_api, METADATA_TABLE) + summary_stats = self._get_summary_stats_sql(metadata_server_api, METADATA_TABLE) + time_stats = self._get_time_stats_sql(metadata_server_api, METADATA_TABLE) + + return { + 'file_type_stats': file_type_stats, + 'time_stats': time_stats, + 'creator_stats': creator_stats, + 'summary_stats': summary_stats + } + + except Exception as e: + logger.error(f'Error in SQL statistics processing: {e}') + return default_response + + def _get_file_type_stats_sql(self, metadata_server_api, METADATA_TABLE): + sql = f''' + SELECT + `{METADATA_TABLE.columns.file_type.name}` as file_type, + COUNT(*) as count + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + GROUP BY `{METADATA_TABLE.columns.file_type.name}` + ORDER BY count DESC + ''' + + results = metadata_server_api.query_rows(sql, []) + if not results or 'results' not in results: + return [] + + processed_results = [] + for row in results['results']: + file_type = row.get('file_type', '') or 'other' + if not file_type.strip(): + file_type = 'other' + processed_results.append({'type': file_type, 'count': row['count']}) + + return processed_results + + def _get_creator_stats_sql(self, metadata_server_api, METADATA_TABLE): + sql = f''' + SELECT + `{METADATA_TABLE.columns.file_creator.name}` as creator, + COUNT(*) as count + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + AND `{METADATA_TABLE.columns.file_creator.name}` IS NOT NULL + AND `{METADATA_TABLE.columns.file_creator.name}` != '' + GROUP BY `{METADATA_TABLE.columns.file_creator.name}` + ORDER BY count DESC + ''' + + results = metadata_server_api.query_rows(sql, []) + if not results or 'results' not in results: + return [] + + return [ + {'creator': row['creator'], 'count': row['count']} + for row in results['results'] + ] + + def _get_summary_stats_sql(self, metadata_server_api, METADATA_TABLE): + sql = f''' + SELECT + COUNT(*) as total_files + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + ''' + + results = metadata_server_api.query_rows(sql, []) + if not results or 'results' not in results or not results['results']: + return {'total_files': 0, 'total_collaborators': 0} + + row = results['results'][0] + + collaborators_set = set() + + all_creators_sql = f''' + SELECT DISTINCT `{METADATA_TABLE.columns.file_creator.name}` as creator + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + AND `{METADATA_TABLE.columns.file_creator.name}` IS NOT NULL + AND `{METADATA_TABLE.columns.file_creator.name}` != '' + ''' + + creator_list_results = metadata_server_api.query_rows(all_creators_sql, []) + if creator_list_results and 'results' in creator_list_results: + for creator_row in creator_list_results['results']: + collaborators_set.add(creator_row['creator']) + + all_modifiers_sql = f''' + SELECT DISTINCT `{METADATA_TABLE.columns.file_modifier.name}` as modifier + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + AND `{METADATA_TABLE.columns.file_modifier.name}` IS NOT NULL + AND `{METADATA_TABLE.columns.file_modifier.name}` != '' + ''' + + modifier_list_results = metadata_server_api.query_rows(all_modifiers_sql, []) + if modifier_list_results and 'results' in modifier_list_results: + for modifier_row in modifier_list_results['results']: + collaborators_set.add(modifier_row['modifier']) + + return { + 'total_files': row['total_files'], + 'total_collaborators': len(collaborators_set) + } + + def _get_time_stats_sql(self, metadata_server_api, METADATA_TABLE): + date_range_sql = f''' + SELECT + MIN(`{METADATA_TABLE.columns.file_ctime.name}`) as min_ctime, + MAX(`{METADATA_TABLE.columns.file_ctime.name}`) as max_ctime, + MIN(`{METADATA_TABLE.columns.file_mtime.name}`) as min_mtime, + MAX(`{METADATA_TABLE.columns.file_mtime.name}`) as max_mtime, + COUNT(*) as total_files + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + AND (`{METADATA_TABLE.columns.file_ctime.name}` IS NOT NULL + OR `{METADATA_TABLE.columns.file_mtime.name}` IS NOT NULL) + ''' + + range_results = metadata_server_api.query_rows(date_range_sql, []) + if not range_results or 'results' not in range_results or not range_results['results']: + return { + 'created': {'unit': 'year', 'data': [], 'grouping': 'created'}, + 'modified': {'unit': 'year', 'data': [], 'grouping': 'modified'} + } + + range_data = range_results['results'][0] + + created_stats = self._get_time_period_stats_sql( + metadata_server_api, METADATA_TABLE, 'created', + range_data['min_ctime'], range_data['max_ctime'] + ) + + modified_stats = self._get_time_period_stats_sql( + metadata_server_api, METADATA_TABLE, 'modified', + range_data['min_mtime'], range_data['max_mtime'] + ) + + return { + 'created': created_stats, + 'modified': modified_stats + } + + def _get_time_period_stats_sql(self, metadata_server_api, METADATA_TABLE, time_type, min_date, max_date): + from datetime import datetime + + if not min_date or not max_date: + return {'unit': 'year', 'data': [], 'grouping': time_type} + try: + min_dt = self._format_datetime(min_date) + max_dt = self._format_datetime(max_date) + time_span = max_dt - min_dt + years_span = time_span.days / 365.25 + months_span = time_span.days / 30.44 + + except (ValueError, AttributeError): + return {'unit': 'year', 'data': [], 'grouping': time_type} + + date_column = METADATA_TABLE.columns.file_ctime.name if time_type == 'created' else METADATA_TABLE.columns.file_mtime.name + + if years_span >= 3: + sql = f''' + SELECT + `{date_column}` as date_value, + COUNT(*) as count + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + AND `{date_column}` IS NOT NULL + GROUP BY `{date_column}` + ORDER BY `{date_column}` + ''' + + results = metadata_server_api.query_rows(sql, []) + if not results or 'results' not in results: + return {'unit': 'year', 'data': [], 'grouping': time_type} + + year_counts = {} + for row in results['results']: + try: + dt = self._format_datetime(row['date_value']) + year = dt.year + year_counts[year] = year_counts.get(year, 0) + row['count'] + except (ValueError, TypeError, AttributeError): + continue + + data = [ + { + 'period': str(year), + 'label': str(year), + 'count': count + } + for year, count in sorted(year_counts.items()) + ] + + return {'unit': 'year', 'data': data, 'grouping': time_type} + + elif months_span >= 6: + sql = f''' + SELECT + `{date_column}` as date_value, + COUNT(*) as count + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + AND `{date_column}` IS NOT NULL + GROUP BY `{date_column}` + ORDER BY `{date_column}` + ''' + + results = metadata_server_api.query_rows(sql, []) + if not results or 'results' not in results: + return {'unit': 'month', 'data': [], 'grouping': time_type} + + import calendar + month_counts = {} + for row in results['results']: + try: + dt = self._format_datetime(row['date_value']) + month_key = f"{dt.year}-{dt.month:02d}" + month_counts[month_key] = month_counts.get(month_key, 0) + row['count'] + except (ValueError, TypeError, AttributeError): + continue + + data = [] + for month_key, count in sorted(month_counts.items()): + try: + year, month = month_key.split('-') + month_name = calendar.month_abbr[int(month)] + label = f"{month_name} {year}" + data.append({ + 'period': month_key, + 'label': label, + 'count': count + }) + except (ValueError, IndexError): + continue + + return {'unit': 'month', 'data': data, 'grouping': time_type} + + else: + sql = f''' + SELECT + `{date_column}` as date_value, + COUNT(*) as count + FROM `{METADATA_TABLE.name}` + WHERE `{METADATA_TABLE.columns.is_dir.name}` = false + AND `{date_column}` IS NOT NULL + GROUP BY `{date_column}` + ORDER BY `{date_column}` + ''' + + results = metadata_server_api.query_rows(sql, []) + if not results or 'results' not in results: + return {'unit': 'day', 'data': [], 'grouping': time_type} + + day_counts = {} + for row in results['results']: + try: + dt = self._format_datetime(row['date_value']) + day_key = dt.strftime('%Y-%m-%d') + day_counts[day_key] = day_counts.get(day_key, 0) + row['count'] + except (ValueError, TypeError, AttributeError): + continue + + data = [] + for day_key, count in sorted(day_counts.items()): + try: + date_obj = datetime.strptime(day_key, '%Y-%m-%d') + label = date_obj.strftime('%b %d') + data.append({ + 'period': day_key, + 'label': label, + 'count': count + }) + except (ValueError, TypeError): + continue + + return {'unit': 'day', 'data': data, 'grouping': time_type} + + def _format_datetime(self, dt): + if isinstance(dt, str): + return datetime.fromisoformat(dt) + return dt diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py index 1c5a88ddf7..99281adb00 100644 --- a/seahub/repo_metadata/urls.py +++ b/seahub/repo_metadata/urls.py @@ -4,7 +4,7 @@ from .apis import MetadataRecognizeFaces, MetadataRecords, MetadataManage, Metad FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \ PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView, \ - MetadataBatchRecords + MetadataBatchRecords, MetadataStatistics urlpatterns = [ re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'), @@ -48,4 +48,7 @@ urlpatterns = [ re_path(r'^migrate-tags/$', MetadataMigrateTags.as_view(), name='api-v2.1-metadata-migrate-tags'), re_path(r'^export-tags/$', MetadataExportTags.as_view(), name='api-v2.1-metadata-export-tags'), re_path(r'^import-tags/$', MetadataImportTags.as_view(), name='api-v2.1-metadata-import-tags'), + + # statistics api + re_path(r'^statistics/$', MetadataStatistics.as_view(), name='api-v2.1-metadata-statistics'), ]