diff --git a/app/person/workload/LabelWorkloadTable.tsx b/app/person/workload/LabelWorkloadTable.tsx new file mode 100644 index 0000000..186e51e --- /dev/null +++ b/app/person/workload/LabelWorkloadTable.tsx @@ -0,0 +1,33 @@ +"use client" + +import { getLabelStatisticsList } from "@/components/label/api/workload" +import WorkloadMetricsTable from "./WorkloadMetricsTable" + +export default function LabelWorkloadTable() { + return ( + + ) +} diff --git a/app/person/workload/Review1WorkloadTable.tsx b/app/person/workload/Review1WorkloadTable.tsx new file mode 100644 index 0000000..8fb76f6 --- /dev/null +++ b/app/person/workload/Review1WorkloadTable.tsx @@ -0,0 +1,35 @@ +"use client" + +import { getReview1StatisticsList } from "@/components/label/api/workload" +import WorkloadMetricsTable from "./WorkloadMetricsTable" + +export default function Review1WorkloadTable() { + return ( + + ) +} diff --git a/app/person/workload/Review2WorkloadTable.tsx b/app/person/workload/Review2WorkloadTable.tsx new file mode 100644 index 0000000..2eb5816 --- /dev/null +++ b/app/person/workload/Review2WorkloadTable.tsx @@ -0,0 +1,30 @@ +"use client" + +import { getReview2StatisticsList } from "@/components/label/api/workload" +import WorkloadMetricsTable from "./WorkloadMetricsTable" + +export default function Review2WorkloadTable() { + return ( + + ) +} diff --git a/app/person/workload/WorkloadMetricsTable.tsx b/app/person/workload/WorkloadMetricsTable.tsx new file mode 100644 index 0000000..80548ee --- /dev/null +++ b/app/person/workload/WorkloadMetricsTable.tsx @@ -0,0 +1,404 @@ +"use client" + +import { TimeFilter } from "@/components/label/api/workload/typing" +import { + SettingContentPanel, + SettingDataTable, + SettingFilterActions, + SettingFilterPanel, + SettingHeaderActions, + SettingInlineFilterField, + SettingListHeader, + settingSurfaceClassNames, +} from "@/components/setting/PageSurface" +import { Button, Collapse, Stack, TextInput } from "@mantine/core" +import { DateInput } from "@mantine/dates" +import { notifications } from "@mantine/notifications" +import { + IconChevronDown, + IconChevronUp, + IconDownload, + IconRefresh, + IconSearch, +} from "@tabler/icons-react" +import dayjs from "dayjs" +import "dayjs/locale/zh-cn" +import { DataTableColumn, DataTableSortStatus } from "mantine-datatable" +import { useEffect, useMemo, useState } from "react" +import * as XLSX from "xlsx-js-style" +import { DimensionType, GroupedData, MetricColumn } from "./config" + +function emptyGroupedData(): GroupedData { + return { + all: [], + order_by_group_name: [], + order_by_project_name: [], + order_by_project_type: [], + order_by_user_name: [], + } +} + +function createInitialFilters() { + const currentDate = dayjs().subtract(1, "day").format("YYYY-MM-DD") + return { + startDate: currentDate, + endDate: currentDate, + dimension: "all" as DimensionType, + projectName: "", + projectType: "", + userName: "", + groupName: "", + } +} + +export default function WorkloadMetricsTable(props: { + title: string + sheetName: string + fileName: string + metrics: MetricColumn[] + fetchData: (params: TimeFilter) => Promise +}) { + const { title, sheetName, fileName, metrics, fetchData } = props + + const [filters, setFilters] = useState(() => createInitialFilters()) + const [appliedFilters, setAppliedFilters] = useState(() => + createInitialFilters() + ) + const [data, setData] = useState(emptyGroupedData()) + const [loading, setLoading] = useState(false) + + const [sortStatus, setSortStatus] = useState({ + columnAccessor: "work_time", + direction: "desc", + }) + + const refresh = async (nextFilters = appliedFilters) => { + if (!nextFilters.startDate || !nextFilters.endDate) { + notifications.show({ + color: "yellow", + title: "参数不完整", + message: "请选择起止日期", + }) + return + } + try { + setLoading(true) + const res = await fetchData({ + start_date: nextFilters.startDate, + end_date: nextFilters.endDate, + }) + setData({ + ...emptyGroupedData(), + ...(res ?? {}), + }) + } catch (e) { + notifications.show({ + color: "red", + title: "加载失败", + message: e instanceof Error ? e.message : "请求失败", + }) + setData(emptyGroupedData()) + } finally { + setLoading(false) + } + } + + useEffect(() => { + const initialFilters = createInitialFilters() + queueMicrotask(() => refresh(initialFilters)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const filteredRecords = useMemo(() => { + let records = [...(data[appliedFilters.dimension] ?? [])] + if (appliedFilters.projectName.trim()) { + records = records.filter((item) => + String(item.project_name ?? "") + .toLowerCase() + .includes(appliedFilters.projectName.trim().toLowerCase()) + ) + } + if (appliedFilters.projectType.trim()) { + records = records.filter((item) => + String(item.project_type ?? "") + .toLowerCase() + .includes(appliedFilters.projectType.trim().toLowerCase()) + ) + } + if (appliedFilters.userName.trim()) { + records = records.filter((item) => + String(item.user_name ?? "") + .toLowerCase() + .includes(appliedFilters.userName.trim().toLowerCase()) + ) + } + if (appliedFilters.groupName.trim()) { + records = records.filter((item) => + String(item.group_name ?? "") + .toLowerCase() + .includes(appliedFilters.groupName.trim().toLowerCase()) + ) + } + return records + }, [appliedFilters, data]) + + const sortedRecords = useMemo(() => { + const list = [...filteredRecords] + const accessor = sortStatus.columnAccessor + const dir = sortStatus.direction === "asc" ? 1 : -1 + list.sort((a, b) => { + const av = a?.[accessor as string] + const bv = b?.[accessor as string] + const an = Number(av) + const bn = Number(bv) + if (Number.isFinite(an) && Number.isFinite(bn)) { + return (an - bn) * dir + } + return String(av ?? "").localeCompare(String(bv ?? "")) * dir + }) + return list + }, [filteredRecords, sortStatus.columnAccessor, sortStatus.direction]) + + const baseColumns = useMemo(() => { + const allCols: Record[]> = { + all: [ + { accessor: "project_name", title: "项目名称", width: 180 }, + { accessor: "project_type", title: "项目类型", width: 280 }, + { accessor: "user_name", title: "用户名", width: 120 }, + { accessor: "group_name", title: "用户组", width: 160 }, + ], + order_by_group_name: [ + { accessor: "group_name", title: "用户组", width: 180 }, + ], + order_by_project_name: [ + { accessor: "project_name", title: "项目名称", width: 220 }, + ], + order_by_project_type: [ + { accessor: "project_type", title: "项目类型", width: 320 }, + ], + order_by_user_name: [ + { accessor: "user_name", title: "用户名", width: 180 }, + ], + } + return allCols[appliedFilters.dimension] + }, [appliedFilters.dimension]) + + const columns = useMemo(() => { + const totalsByKey = Object.fromEntries( + metrics.map((metric) => { + const sum = sortedRecords.reduce((acc, item) => { + const value = Number(item?.[metric.key] ?? 0) + return acc + (Number.isFinite(value) ? value : 0) + }, 0) + return [metric.key, sum] + }) + ) as Record + + const metricCols = metrics.map((metric) => { + return { + accessor: metric.key, + title: metric.title, + width: 140, + sortable: true, + footer: totalsByKey[metric.key] ?? 0, + } as DataTableColumn + }) + return [ + { + accessor: "index", + title: "序号", + width: 80, + footer: "总计", + render: (_record: any, index: number) => index + 1, + } as DataTableColumn, + ...baseColumns, + ...metricCols, + ] + }, [baseColumns, metrics, sortedRecords]) + + const handleExport = () => { + if (!sortedRecords.length) { + notifications.show({ + color: "yellow", + title: "暂无数据", + message: "当前筛选条件下无可导出数据", + }) + return + } + const baseHeaders = baseColumns.map((c) => String(c.title ?? c.accessor)) + const metricHeaders = metrics.map((m) => m.title) + const header = ["序号", ...baseHeaders, ...metricHeaders] + + const baseAccessors = baseColumns.map((c) => String(c.accessor)) + const rows = sortedRecords.map((item, index) => { + return [ + index + 1, + ...baseAccessors.map((key) => item?.[key] ?? "-"), + ...metrics.map((m) => item?.[m.key] ?? 0), + ] + }) + + const wb = XLSX.utils.book_new() + const ws = XLSX.utils.aoa_to_sheet([header, ...rows]) + XLSX.utils.book_append_sheet(wb, ws, sheetName) + XLSX.writeFile(wb, fileName) + } + + const rowKey = useMemo(() => { + if (appliedFilters.dimension === "all") { + return (record: any) => + `${record.project_id ?? "p"}_${record.uid ?? "u"}_${record.project_name ?? ""}_${record.user_name ?? ""}` + } + if (appliedFilters.dimension === "order_by_group_name") return "group_name" + if (appliedFilters.dimension === "order_by_project_name") + return "project_name" + if (appliedFilters.dimension === "order_by_project_type") + return "project_type" + return "user_name" + }, [appliedFilters.dimension]) + + const handleSearch = () => { + setAppliedFilters(filters) + void refresh(filters) + } + + const handleReset = () => { + const nextFilters = createInitialFilters() + setFilters(nextFilters) + setAppliedFilters(nextFilters) + void refresh(nextFilters) + } + + const [searchExpanded, setSearchExpanded] = useState(false) + + return ( + + + +
+ + + setFilters((s) => ({ + ...s, + startDate: dayjs(e).format("YYYY-MM-DD"), + })) + } + /> + + + + setFilters((s) => ({ + ...s, + endDate: dayjs(e).format("YYYY-MM-DD"), + })) + } + /> + + + + setFilters((prev) => ({ + ...prev, + projectName: e.target.value, + })) + } + /> + + + + + + + +
+ +
+ + + setFilters((prev) => ({ + ...prev, + projectType: e.target.value, + })) + } + /> + +
+
+
+
+ + + + + + + + } + /> + + fetching={loading} + records={sortedRecords} + columns={columns} + idAccessor={rowKey as any} + minHeight={0} + scrollAreaProps={{ type: "auto" }} + style={{ width: "100%", flex: 1, minWidth: 0, minHeight: 0 }} + sortStatus={sortStatus} + onSortStatusChange={setSortStatus} + /> + + +
+ ) +} diff --git a/app/person/workload/config.ts b/app/person/workload/config.ts new file mode 100644 index 0000000..607093e --- /dev/null +++ b/app/person/workload/config.ts @@ -0,0 +1,27 @@ +export type DimensionType = + | "all" + | "order_by_user_name" + | "order_by_group_name" + | "order_by_project_name" + | "order_by_project_type" + +export const dimensionOpts = [ + { label: "全部", value: "all" }, + { label: "用户名", value: "order_by_user_name" }, + { label: "用户组", value: "order_by_group_name" }, + { label: "项目名称", value: "order_by_project_name" }, + { label: "项目类型", value: "order_by_project_type" }, +] + +export type MetricColumn = { + key: string + title: string +} + +export type GroupedData = { + all: any[] + order_by_group_name: any[] + order_by_project_name: any[] + order_by_project_type: any[] + order_by_user_name: any[] +} diff --git a/app/person/workload/page.tsx b/app/person/workload/page.tsx new file mode 100644 index 0000000..f618eaf --- /dev/null +++ b/app/person/workload/page.tsx @@ -0,0 +1,87 @@ +"use client" + +import { + SettingPage, + settingSurfaceClassNames, + settingTabsClassNames, + settingTabsStyles, +} from "@/components/setting/PageSurface" +import { Stack, Tabs, Text } from "@mantine/core" +import { useState } from "react" +import LabelWorkloadTable from "./LabelWorkloadTable" +import Review1WorkloadTable from "./Review1WorkloadTable" +import Review2WorkloadTable from "./Review2WorkloadTable" + +type WorkloadTab = "label" | "review1" | "review2" + +function createVisitedTabs() { + return { + label: true, + review1: false, + review2: false, + task: false, + } +} + +export default function TeamWorkloadPage() { + const moduleTitle = "个人中心" + const [activeTab, setActiveTab] = useState("label") + const [visitedTabs, setVisitedTabs] = useState(createVisitedTabs) + + const handleTabChange = (value: string | null) => { + if (!value) return + + const nextTab = value as WorkloadTab + setActiveTab(nextTab) + setVisitedTabs((current) => + current[nextTab] ? current : { ...current, [nextTab]: true } + ) + } + + return ( + + + + {moduleTitle} /{" "} + + 工时列表 + + + + + + 标注工时 + 审核工时 + 复审工时 + + + + {visitedTabs.label ? : null} + + + {visitedTabs.review1 ? : null} + + + {visitedTabs.review2 ? : null} + + + + ) +} diff --git a/components/label/api/workload/index.ts b/components/label/api/workload/index.ts new file mode 100644 index 0000000..4d97925 --- /dev/null +++ b/components/label/api/workload/index.ts @@ -0,0 +1,36 @@ +import httpFetch from "@/api/fetch" +import { BASE_LABEL_API } from "../const" +import { TimeFilter, WorkLoad } from "./typing" + +// 标注个人工作量统计 +export const getLabelStatisticsList = (params: TimeFilter) => { + return httpFetch({ + url: + BASE_LABEL_API + + "/api/v1/label_server/workload/label_statistics/list_by_user", + method: "GET", + data: params, + }) +} + +// 审核个人工作量统计 +export const getReview1StatisticsList = (params: TimeFilter) => { + return httpFetch({ + url: + BASE_LABEL_API + + "/api/v1/label_server/workload/review1_statistics/list_by_user", + method: "GET", + data: params, + }) +} + +// 复审个人工作量统计 +export const getReview2StatisticsList = (params: TimeFilter) => { + return httpFetch({ + url: + BASE_LABEL_API + + "/api/v1/label_server/workload/review2_statistics/list_by_user", + method: "GET", + data: params, + }) +} diff --git a/components/label/api/workload/typing.ts b/components/label/api/workload/typing.ts new file mode 100644 index 0000000..b5e0767 --- /dev/null +++ b/components/label/api/workload/typing.ts @@ -0,0 +1,107 @@ +export interface TimeFilter { + start_date: string + end_date: string +} + +export namespace WorkLoad { + export interface LabelData { + commented_question_size: number + commented_size: number + commit_label_1: number + commit_reviewed_1: number + confirmed_prelabel_size: number + dynamic_size: number + group_name?: string + key_frame_size: number + object_size: number + prelabel_object_size: number + project_id: number + project_name: string + project_type: string + question_size: number + rejected_label_1: number + reviewed_question_size: number + reviewed_size: number + static_size: number + total_reviewed_size: number + uid: number + user_name: string + work_time: number + [property: string]: any + } + + export interface LabelResponse { + all: LabelData[] + order_by_group_name: any[] + order_by_project_name: any[] + order_by_project_type: any[] + order_by_user_name: any[] + } + + export interface Review1Data { + comment_size: number + commented_size: number + commit_review_1: number + commit_reviewed_2: number + confirmed_prelabel_size: number + group_name?: string + object_size: number + prelabel_object_size: number + project_id: number + project_name: string + project_type: string + reject_review_1: number + rejected_review_1: number + review_dynamic_size: number + review_key_frame_size: number + review_question_size: number + review_size: number + review_static_size: number + reviewed_size: number + total_review_size: number + total_reviewed_size: number + uid: number + user_name: string + work_time: number + [property: string]: any + } + + export interface Review1Response { + all: Review1Data[] + order_by_group_name: any[] + order_by_project_name: any[] + order_by_project_type: any[] + order_by_user_name: any[] + } + + export interface Review2Data { + comment_size: number + commit_review_2: number + confirmed_prelabel_size: number + group_name?: string + object_size: number + prelabel_object_size: number + project_id: number + project_name: string + project_type: string + reject_review_2: number + review_dynamic_size: number + review_key_frame_size: number + review_question_size: number + review_size: number + review_static_size: number + total_review_size: number + uid: number + user_name: string + work_time: number + [property: string]: any + } + + export interface Review2Response { + all: Review2Data[] + order_by_group_name: any[] + order_by_project_name: any[] + order_by_project_type: any[] + order_by_user_name: any[] + } +} diff --git a/components/layout/common.ts b/components/layout/common.ts index e3af905..6252b0f 100644 --- a/components/layout/common.ts +++ b/components/layout/common.ts @@ -82,6 +82,11 @@ const currentComponentList: MenuItem[] = [ url: "report", icon: "ReportIcon", }, + { + title: "工时列表", + url: "workload", + icon: "WorkloadIcon", + }, ], }, ] diff --git a/components/layout/components/ClientIcon.tsx b/components/layout/components/ClientIcon.tsx index 480455a..5d57b90 100644 --- a/components/layout/components/ClientIcon.tsx +++ b/components/layout/components/ClientIcon.tsx @@ -7,6 +7,7 @@ import { IconHierarchy3, IconLayoutDashboard, IconPhoto, + IconReportMedical, IconSettings, IconUserCircle, IconUsersGroup, @@ -26,6 +27,7 @@ const iconMap = { ProjectCategoryIcon: IconCategory2, ProjectManagementIcon: IconFolders, ReportIcon: IconFileText, + WorkloadIcon: IconReportMedical, TeamManagementIcon: IconHierarchy3, VideoAnnotationIcon: IconVideo, SystemIcon: IconSettings, diff --git a/components/setting/PageSurface.module.css b/components/setting/PageSurface.module.css index cf1104a..4fd1682 100644 --- a/components/setting/PageSurface.module.css +++ b/components/setting/PageSurface.module.css @@ -13,6 +13,35 @@ box-shadow: none; } +.pageIntro { + gap: 4px; +} + +.breadcrumb { + color: #86909c; + font-size: 13px; + line-height: 20px; +} + +.breadcrumbLink { + border: 0; + padding: 0; + background: transparent; + color: #86909c; + font: inherit; + line-height: inherit; + cursor: pointer; + transition: color 0.2s ease; +} + +.breadcrumbLink:hover { + color: #1874ff; +} + +.breadcrumbCurrent { + color: #4e5969; +} + .modalContent { overflow: hidden; border-radius: 8px; @@ -84,10 +113,517 @@ color: #c9cdd4 !important; } +.modalForm { + gap: 12px; +} + +.modalMetaText { + color: #4e5969; + font-size: 14px; + line-height: 20px; +} + +.modalActions { + gap: 12px; +} + +.modalForm :global(.mantine-InputWrapper-label) { + color: #4e5969; + font-size: 14px; + font-weight: 500; + line-height: 20px; + margin-bottom: 4px; +} + +.modalForm :global(.mantine-Switch-label), +.modalForm :global(.mantine-Checkbox-label) { + color: #4e5969; + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +.modalForm :global(.mantine-Input-input), +.modalForm :global(.mantine-Select-input), +.modalForm :global(.mantine-DateInput-input), +.modalForm :global(.mantine-NumberInput-input), +.modalForm :global(.mantine-PillsInput-root), +.modalForm :global(.mantine-Textarea-input) { + min-height: 32px; + border-color: #e5e6eb; + border-radius: 4px; + background: #f7f8fa; + color: #1d2129; + font-size: 14px; + box-shadow: none; + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease; +} + +.modalForm :global(.mantine-PillsInput-root) { + padding-inline: 12px; +} + +.modalForm :global(.mantine-PillsInput-input) { + min-height: 30px; + background: transparent; + color: #1d2129; +} + +.modalForm :global(.mantine-Textarea-input) { + min-height: 96px; +} + +.modalForm :global(.mantine-Input-input::placeholder), +.modalForm :global(.mantine-Select-input::placeholder), +.modalForm :global(.mantine-DateInput-input::placeholder), +.modalForm :global(.mantine-NumberInput-input::placeholder), +.modalForm :global(.mantine-PillsInput-input::placeholder), +.modalForm :global(.mantine-Textarea-input::placeholder) { + color: #86909c; +} + +.modalForm :global(.mantine-Input-input:focus), +.modalForm :global(.mantine-Select-input:focus), +.modalForm :global(.mantine-DateInput-input:focus), +.modalForm :global(.mantine-NumberInput-input:focus), +.modalForm :global(.mantine-PillsInput-root:focus-within), +.modalForm :global(.mantine-Textarea-input:focus) { + border-color: #1874ff; + background: #ffffff; + box-shadow: 0 0 0 2px rgba(24, 116, 255, 0.12); +} + +.modalForm :global(.mantine-Input-input[readonly]), +.modalForm :global(.mantine-DateInput-input[readonly]), +.modalForm :global(.mantine-NumberInput-input[readonly]), +.modalForm :global(.mantine-Textarea-input[readonly]) { + background: #f7f8fa; + color: #86909c; +} + +.modalForm :global(.mantine-Input-section), +.modalForm :global(.mantine-Select-section), +.modalForm :global(.mantine-DateInput-section), +.modalForm :global(.mantine-NumberInput-section) { + color: #86909c; +} + +.modalForm :global(.mantine-Switch-root) { + --switch-height: 20px; + --switch-width: 34px; + --switch-thumb-size: 14px; + --switch-radius: 999px; + --switch-color: #1874ff; +} + +.modalForm :global(.mantine-Checkbox-root) { + --checkbox-color: #1874ff; + --checkbox-radius: 4px; +} + +.modalForm :global(.mantine-Checkbox-input) { + border-color: #c9cdd4; + background: #ffffff; +} + +.modalForm :global(.mantine-Button-root) { + min-height: 32px; + border-radius: 4px; + padding-inline: 12px; + font-size: 14px; + font-weight: 500; + box-shadow: none; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; +} + +.modalForm :global(.mantine-Button-root[data-variant="filled"]) { + --button-bg: #1874ff; + --button-hover: #0f6ae8; + --button-color: #ffffff; + --button-bd: transparent; + background: #1874ff !important; + color: #ffffff !important; +} + +.modalForm :global(.mantine-Button-root[data-variant="filled"]:hover) { + background: #0f6ae8 !important; +} + +.modalForm :global(.mantine-Button-root[data-variant="default"]) { + border-color: #e5e6eb; + background: #ffffff; + color: #4e5969; +} + +.modalForm :global(.mantine-Button-root[data-disabled]), +.modalForm :global(.mantine-Button-root:disabled) { + border-color: #e5e6eb !important; + background: #f2f3f5 !important; + color: #c9cdd4 !important; + cursor: not-allowed; + opacity: 1; +} + +.modalForm :global(.mantine-Button-root[data-disabled]:hover), +.modalForm :global(.mantine-Button-root:disabled:hover) { + border-color: #e5e6eb !important; + background: #f2f3f5 !important; + color: #c9cdd4 !important; +} + .filterPanel { position: relative; } +.detailContent { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; +} + +.detailForm { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 12px; +} + +.detailIntroGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 20px; +} + +.detailSection { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 12px; +} + +.detailActionBar { + padding-top: 12px; + border-top: 1px solid #e5e6eb; +} + +.detailScrollArea { + flex: 1; + min-height: 0; +} + +.readValue { + min-height: 32px; + border-color: #e5e6eb; + border-radius: 4px; + background: #f7f8fa; + display: flex; + align-items: center; +} + +.readValueText { + width: 100%; + color: #1d2129; + font-size: 14px; + line-height: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailForm :global(.mantine-InputWrapper-label) { + color: #4e5969; + font-size: 14px; + font-weight: 500; + line-height: 20px; + margin-bottom: 4px; +} + +.detailForm :global(.mantine-Input-input), +.detailForm :global(.mantine-Select-input), +.detailForm :global(.mantine-ColorInput-input), +.detailForm :global(.mantine-NumberInput-input), +.detailForm :global(.mantine-PillsInput-root), +.detailForm :global(.mantine-Textarea-input) { + min-height: 32px; + border-color: #e5e6eb; + border-radius: 4px; + background: #f7f8fa; + color: #1d2129; + font-size: 14px; + box-shadow: none; + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease; +} + +.detailForm :global(.mantine-PillsInput-root) { + padding-inline: 12px; +} + +.detailForm :global(.mantine-PillsInput-input) { + min-height: 30px; + background: transparent; + color: #1d2129; +} + +.detailForm :global(.mantine-Textarea-input) { + min-height: 96px; +} + +.detailForm :global(.mantine-Input-input::placeholder), +.detailForm :global(.mantine-Select-input::placeholder), +.detailForm :global(.mantine-ColorInput-input::placeholder), +.detailForm :global(.mantine-NumberInput-input::placeholder), +.detailForm :global(.mantine-PillsInput-input::placeholder), +.detailForm :global(.mantine-Textarea-input::placeholder) { + color: #86909c; +} + +.detailForm :global(.mantine-Input-input:focus), +.detailForm :global(.mantine-Select-input:focus), +.detailForm :global(.mantine-ColorInput-input:focus), +.detailForm :global(.mantine-NumberInput-input:focus), +.detailForm :global(.mantine-PillsInput-root:focus-within), +.detailForm :global(.mantine-Textarea-input:focus) { + border-color: #1874ff; + background: #ffffff; + box-shadow: 0 0 0 2px rgba(24, 116, 255, 0.12); +} + +.detailForm :global(.mantine-Input-input[readonly]), +.detailForm :global(.mantine-ColorInput-input[readonly]), +.detailForm :global(.mantine-NumberInput-input[readonly]), +.detailForm :global(.mantine-Textarea-input[readonly]) { + background: #f7f8fa; + color: #86909c; +} + +.detailForm :global(.mantine-Input-section), +.detailForm :global(.mantine-Select-section), +.detailForm :global(.mantine-ColorInput-section), +.detailForm :global(.mantine-NumberInput-section) { + color: #86909c; +} + +.detailForm :global(.mantine-Pill-root) { + background: #e7efff; + color: #1874ff; +} + +.detailForm :global(.mantine-Button-root) { + min-height: 32px; + border-radius: 4px; + padding-inline: 12px; + font-size: 14px; + font-weight: 500; + box-shadow: none; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; +} + +.detailForm :global(.mantine-Button-root[data-variant="filled"]) { + --button-bg: #1874ff; + --button-hover: #0f6ae8; + --button-color: #ffffff; + --button-bd: transparent; + background: #1874ff !important; + color: #ffffff !important; +} + +.detailForm :global(.mantine-Button-root[data-variant="filled"]:hover) { + background: #0f6ae8 !important; +} + +.detailForm :global(.mantine-Button-root[data-variant="default"]), +.detailForm :global(.mantine-Button-root[data-variant="light"]) { + border-color: #e5e6eb; + background: #ffffff; + color: #4e5969; +} + +.detailForm :global(.mantine-ActionIcon-root) { + width: 28px; + height: 28px; + border-radius: 4px; + box-shadow: none; +} + +.detailForm :global(.mantine-Table-table) { + border-color: #e5e6eb; + background: #ffffff; + font-size: 14px; +} + +.detailForm :global(.mantine-Table-th) { + background: #f7f8fa; + color: #4e5969; + font-size: 14px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; +} + +.detailForm :global(.mantine-Table-th), +.detailForm :global(.mantine-Table-td) { + border-color: #e5e6eb; + padding: 10px 12px; +} + +.detailForm :global(.mantine-Table-td) { + color: #1d2129; + line-height: 20px; +} + +.tableViewport { + min-width: 0; + max-width: 100%; + overflow: auto; + overscroll-behavior: contain; +} + +.filterStack { + gap: 12px; +} + +.filterTopRow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px 20px; + align-items: center; +} + +.filterExpandedGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px 20px; + padding-top: 12px; + border-top: 1px solid #e5e6eb; +} + +.filterField { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.filterLabel { + flex: 0 0 var(--setting-inline-filter-label-width, 80px); + color: #4e5969; + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +.filterControl { + flex: 1; + min-width: 0; +} + +.filterControl :global(.mantine-InputWrapper-root), +.filterControl :global(.mantine-PillsInput-root) { + width: 100%; +} + +.filterControl :global(.mantine-InputWrapper-label) { + display: none; +} + +.filterActionsAlignEnd { + display: inline-flex; + align-items: center; + justify-self: end; + grid-column: -2 / -1; +} + +.collapseToggle { + min-width: auto; + padding-inline: 0; + align-self: center; + justify-self: start; +} + +.operationGroup { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.operationLink { + border: 0; + padding: 0; + background: transparent; + color: #1874ff; + font-size: 13px; + line-height: 20px; + cursor: pointer; + transition: + color 0.2s ease, + opacity 0.2s ease; +} + +.operationLinkDanger { + color: #f53f3f; +} + +.operationLinkDisabled { + color: #c9cdd4; + cursor: not-allowed; +} + +.operationDivider { + width: 1px; + height: 12px; + background: #e5e6eb; +} + +.statusBadge { + min-height: 24px; + padding: 0 10px; + border: 0; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} + +.statusPositive { + background: #e8ffea; + color: #00b42a; +} + +.statusNegative { + background: #ffece8; + color: #f53f3f; +} + +.statusWarning { + background: #fff7e8; + color: #ff7d00; +} + +.statusInfo { + background: #e8f3ff; + color: #1874ff; +} + +.statusNeutral { + background: #f2f3f5; + color: #4e5969; +} + .filterPanel :global(.mantine-InputWrapper-label) { color: #4e5969; font-size: 14px; @@ -235,6 +771,11 @@ } .contentPanel :global(.mantine-datatable-pagination) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; padding: 12px 16px !important; color: #4e5969; } @@ -274,6 +815,21 @@ --pagination-control-fz: 12px; } +.contentPanel :global(.mantine-datatable-pagination .mantine-Select-input) { + min-height: 28px; + height: 28px; + border-color: #e5e6eb; + border-radius: 4px; + background: #ffffff; + color: #4e5969; + font-size: 12px; + box-shadow: none; +} + +.contentPanel :global(.mantine-datatable-pagination .mantine-Select-section) { + color: #86909c; +} + .contentPanel :global(.mantine-Pagination-dots) { color: #86909c; } @@ -327,6 +883,19 @@ margin-bottom: 12px; } +.listHeaderTitle { + color: #1d2129; + font-size: 16px; + font-weight: 500; + line-height: 24px; +} + +.listHeaderCount { + color: #86909c; + font-size: 12px; + line-height: 20px; +} + .sectionHeader { padding-bottom: 12px; border-bottom: 1px solid #e5e6eb; @@ -354,38 +923,42 @@ line-height: 20px; } -.listHeaderTitle { - color: #1d2129; - font-size: 14px; - font-weight: 500; - line-height: 22px; -} - -.listHeaderCount { - color: #86909c; - font-size: 12px; - line-height: 20px; -} - .settingTabsList { padding: 4px; + border: 1px solid #e5e6eb; + border-radius: 4px; + background: #f7f8fa; gap: 8px; - border: 1px solid #eef2f6; - border-radius: 10px; - background: #f8f9fb; } .settingTabsTab { - min-height: 36px; - border-radius: 8px; - color: #56606a; - font-weight: 600; + min-height: 32px; + border-radius: 4px; + padding: 0 12px; + color: #4e5969; + font-size: 14px; + font-weight: 500; + line-height: 20px; + transition: + background-color 0.2s ease, + color 0.2s ease; } -.settingTabsTab[data-active] { - background: #ffffff; - color: #1f2329; - box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08); +.settingTabsTab[data-active], +.settingTabsTab[data-active]:hover { + background: #1874ff; + color: #ffffff; +} + +.settingTabsTabLabel { + color: inherit; + font-size: 14px; + line-height: 20px; +} + +.settingTabsTab[data-active] .settingTabsTabLabel, +.settingTabsTab[data-active]:hover .settingTabsTabLabel { + color: #ffffff; } .settingTabsPanel { @@ -394,3 +967,39 @@ min-height: 0; padding-top: 16px; } + +@media (max-width: 1200px) { + .filterTopRow, + .filterExpandedGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filterActionsAlignEnd { + justify-self: stretch; + } +} + +@media (max-width: 720px) { + .detailIntroGrid { + grid-template-columns: 1fr; + } + + .filterTopRow, + .filterExpandedGrid { + grid-template-columns: 1fr; + } + + .filterField { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .filterLabel { + flex-basis: auto; + } + + .filterActionsAlignEnd { + grid-column: auto; + } +} diff --git a/components/setting/PageSurface.tsx b/components/setting/PageSurface.tsx index 690c1b7..30fee19 100644 --- a/components/setting/PageSurface.tsx +++ b/components/setting/PageSurface.tsx @@ -9,18 +9,36 @@ import { type PaperProps, type StackProps, } from "@mantine/core" -import type { PropsWithChildren, ReactNode } from "react" import { DataTable, type DataTableDefaultColumnProps, type DataTableProps, } from "mantine-datatable" +import type { CSSProperties, PropsWithChildren, ReactNode } from "react" import classes from "./PageSurface.module.css" function mergeClassName(...classNames: Array) { return classNames.filter(Boolean).join(" ") } +export const settingModalClassNames = { + content: classes.modalContent, + header: classes.modalHeader, + title: classes.modalTitle, + body: classes.modalBody, +} + +export const settingModalFormClassName = classes.modalForm +export const settingModalActionsClassName = classes.modalActions +export const settingModalMetaTextClassName = classes.modalMetaText +export const settingSurfaceClassNames = classes + +type SettingTableViewportProps = PropsWithChildren<{ + maxHeight: number | string + className?: string + style?: CSSProperties +}> + export function SettingPage({ className, ...props @@ -57,6 +75,21 @@ export function SettingPanel({ ) } +export function SettingTableViewport({ + maxHeight, + className, + style, + children, +}: SettingTableViewportProps) { + return ( +
+ {children} +
+ ) +} + export function SettingFilterPanel({ className, ...props @@ -183,6 +216,56 @@ export function SettingFilterActions({ ) } +export function SettingFilterStack({ + className, + gap = 12, + ...props +}: PropsWithChildren) { + return ( + + ) +} + +type SettingInlineFilterFieldProps = { + label: ReactNode + children: ReactNode + className?: string + labelClassName?: string + controlClassName?: string + labelWidth?: number | string +} + +export function SettingInlineFilterField({ + label, + children, + className, + labelClassName, + controlClassName, + labelWidth = 80, +}: SettingInlineFilterFieldProps) { + const style = { + "--setting-inline-filter-label-width": + typeof labelWidth === "number" ? `${labelWidth}px` : labelWidth, + } as CSSProperties + + return ( +
+ + {label} + +
+ {children} +
+
+ ) +} + const settingDataTableStyles = { header: { backgroundColor: "#F7F8FA", @@ -192,7 +275,6 @@ const settingDataTableStyles = { fontWeight: 500, }, root: { - height: "100%", maxWidth: "100%", minWidth: 0, fontSize: "14px", @@ -238,12 +320,15 @@ export function SettingDataTable>( loadingText, noRecordsText, minHeight, + maxHeight, + height, page, onPageChange, totalRecords, recordsPerPage, recordsPerPageLabel, paginationText, + scrollAreaProps, ...rest } = props @@ -268,7 +353,7 @@ export function SettingDataTable>( from: number to: number totalRecords: number - }) => `${from}-${to} / ${totalRecords}`), + }) => `显示 ${from}-${to} 条,共 ${totalRecords} 条记录`), } : {} @@ -283,6 +368,12 @@ export function SettingDataTable>( loaderBackgroundBlur: loaderBackgroundBlur ?? 0, noRecordsText: noRecordsText ?? "暂无数据", minHeight: minHeight ?? 360, + maxHeight, + height: height ?? (maxHeight !== undefined ? "auto" : undefined), + scrollAreaProps: { + type: "auto", + ...scrollAreaProps, + }, styles: styles ? { ...settingDataTableStyles, ...styles } : settingDataTableStyles, @@ -296,16 +387,23 @@ export function SettingDataTable>( export const settingTabsStyles = { list: { padding: 4, - backgroundColor: "#F8F9FB", - border: "1px solid #EEF2F6", - borderRadius: 10, + backgroundColor: "#F7F8FA", + border: "1px solid #E5E6EB", + borderRadius: 4, gap: 8, }, tab: { - minHeight: 36, - borderRadius: 8, - color: "#56606A", - fontWeight: 600, + minHeight: 32, + borderRadius: 4, + padding: "0 12px", + fontSize: 14, + fontWeight: 500, + lineHeight: "20px", + }, + tabLabel: { + color: "inherit", + fontSize: 14, + lineHeight: "20px", }, panel: { flex: 1, @@ -318,5 +416,6 @@ export const settingTabsStyles = { export const settingTabsClassNames = { list: classes.settingTabsList, tab: classes.settingTabsTab, + tabLabel: classes.settingTabsTabLabel, panel: classes.settingTabsPanel, } as const