feat(workload): add workload list

This commit is contained in:
zhangheng
2026-05-14 15:12:32 +08:00
parent 781b002a7b
commit 5c3b45b247
12 changed files with 1508 additions and 34 deletions

View File

@@ -0,0 +1,33 @@
"use client"
import { getLabelStatisticsList } from "@/components/label/api/workload"
import WorkloadMetricsTable from "./WorkloadMetricsTable"
export default function LabelWorkloadTable() {
return (
<WorkloadMetricsTable
title="标注工时"
sheetName="标注工时数据"
fileName="标注工时数据.xlsx"
fetchData={getLabelStatisticsList}
metrics={[
{ key: "work_time", title: "系统工时" },
{ key: "object_size", title: "新增对象数" },
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
{ key: "prelabel_object_size", title: "预标注对象数" },
{ key: "commit_label_1", title: "提交标注任务数" },
{ key: "commit_reviewed_1", title: "被审核任务数" },
{ key: "reviewed_size", title: "被审核对象数" },
{ key: "commented_size", title: "被批注对象数" },
{ key: "rejected_label_1", title: "被驳回任务数" },
{ key: "total_reviewed_size", title: "被审核过任务对象总数" },
{ key: "dynamic_size", title: "新增动态对象数" },
{ key: "static_size", title: "新增静态对象数" },
{ key: "key_frame_size", title: "新增关键帧数" },
{ key: "question_size", title: "新增问题数" },
{ key: "commented_question_size", title: "被批注问题数" },
{ key: "reviewed_question_size", title: "被审核问题数" },
]}
/>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { getReview1StatisticsList } from "@/components/label/api/workload"
import WorkloadMetricsTable from "./WorkloadMetricsTable"
export default function Review1WorkloadTable() {
return (
<WorkloadMetricsTable
title="审核工时"
sheetName="审核工时数据"
fileName="审核工时数据.xlsx"
fetchData={getReview1StatisticsList}
metrics={[
{ key: "work_time", title: "系统工时" },
{ key: "object_size", title: "新增对象数" },
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
{ key: "prelabel_object_size", title: "预标注对象数" },
{ key: "review_size", title: "审核对象数" },
{ key: "comment_size", title: "批注对象数" },
{ key: "reviewed_size", title: "被审核对象数" },
{ key: "commented_size", title: "被批注对象数" },
{ key: "commit_review_1", title: "提交审核任务数" },
{ key: "commit_reviewed_2", title: "被复审审核任务数" },
{ key: "reject_review_1", title: "审核驳回任务数" },
{ key: "rejected_review_1", title: "被驳回的审核任务数" },
{ key: "total_review_size", title: "已审核任务对象总数" },
{ key: "review_dynamic_size", title: "审核动态对象数" },
{ key: "review_static_size", title: "审核静态对象数" },
{ key: "total_reviewed_size", title: "已被审核过任务对象总数" },
{ key: "review_key_frame_size", title: "审核关键帧数" },
{ key: "review_question_size", title: "审核问题数" },
]}
/>
)
}

View File

@@ -0,0 +1,30 @@
"use client"
import { getReview2StatisticsList } from "@/components/label/api/workload"
import WorkloadMetricsTable from "./WorkloadMetricsTable"
export default function Review2WorkloadTable() {
return (
<WorkloadMetricsTable
title="复审工时"
sheetName="复审工时数据"
fileName="复审工时数据.xlsx"
fetchData={getReview2StatisticsList}
metrics={[
{ key: "work_time", title: "系统工时" },
{ key: "object_size", title: "新增对象数" },
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
{ key: "prelabel_object_size", title: "预标注对象数" },
{ key: "review_size", title: "复审对象数" },
{ key: "comment_size", title: "批注对象数" },
{ key: "commit_review_2", title: "提交复审任务数" },
{ key: "reject_review_2", title: "复审驳回任务数" },
{ key: "total_review_size", title: "已复审任务对象总数" },
{ key: "review_dynamic_size", title: "复审动态对象数" },
{ key: "review_static_size", title: "复审静态对象数" },
{ key: "review_key_frame_size", title: "复审关键帧数" },
{ key: "review_question_size", title: "复审问题数" },
]}
/>
)
}

View File

@@ -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<GroupedData>
}) {
const { title, sheetName, fileName, metrics, fetchData } = props
const [filters, setFilters] = useState(() => createInitialFilters())
const [appliedFilters, setAppliedFilters] = useState(() =>
createInitialFilters()
)
const [data, setData] = useState<GroupedData>(emptyGroupedData())
const [loading, setLoading] = useState(false)
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
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<DimensionType, DataTableColumn<any>[]> = {
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<string, number>
const metricCols = metrics.map((metric) => {
return {
accessor: metric.key,
title: metric.title,
width: 140,
sortable: true,
footer: totalsByKey[metric.key] ?? 0,
} as DataTableColumn<any>
})
return [
{
accessor: "index",
title: "序号",
width: 80,
footer: "总计",
render: (_record: any, index: number) => index + 1,
} as DataTableColumn<any>,
...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 (
<Stack h="100%" gap={12} style={{ minWidth: 0 }}>
<SettingFilterPanel>
<Stack gap={12}>
<div className={settingSurfaceClassNames.filterTopRow}>
<SettingInlineFilterField label="开始日期">
<DateInput
locale="zh-cn"
value={filters.startDate}
onChange={(e) =>
setFilters((s) => ({
...s,
startDate: dayjs(e).format("YYYY-MM-DD"),
}))
}
/>
</SettingInlineFilterField>
<SettingInlineFilterField label="结束日期">
<DateInput
locale="zh-cn"
value={filters.endDate}
onChange={(e) =>
setFilters((s) => ({
...s,
endDate: dayjs(e).format("YYYY-MM-DD"),
}))
}
/>
</SettingInlineFilterField>
<SettingInlineFilterField label="项目名称">
<TextInput
placeholder="请输入项目名称"
value={filters.projectName}
onChange={(e) =>
setFilters((prev) => ({
...prev,
projectName: e.target.value,
}))
}
/>
</SettingInlineFilterField>
<SettingFilterActions
mt={0}
className={settingSurfaceClassNames.filterActions}>
<Button
variant="transparent"
className={settingSurfaceClassNames.collapseToggle}
rightSection={
searchExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)
}
onClick={() => setSearchExpanded((v) => !v)}>
{searchExpanded ? "收起" : "展开"}
</Button>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</div>
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
<div className={settingSurfaceClassNames.filterExpandedGrid}>
<SettingInlineFilterField label="项目类型">
<TextInput
placeholder="请输入项目类型"
value={filters.projectType}
onChange={(e) =>
setFilters((prev) => ({
...prev,
projectType: e.target.value,
}))
}
/>
</SettingInlineFilterField>
</div>
</Collapse>
</Stack>
</SettingFilterPanel>
<SettingContentPanel
style={{ flex: 1, minHeight: 0, minWidth: 0, display: "flex" }}>
<Stack gap={12} style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
<SettingListHeader
title={title}
count={`${sortedRecords.length}`}
actions={
<SettingHeaderActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => void refresh(appliedFilters)}
loading={loading}>
</Button>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={handleExport}>
</Button>
</SettingHeaderActions>
}
/>
<SettingDataTable<any>
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}
/>
</Stack>
</SettingContentPanel>
</Stack>
)
}

View File

@@ -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[]
}

View File

@@ -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<WorkloadTab>("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 (
<SettingPage>
<Stack className={settingSurfaceClassNames.pageIntro}>
<Text className={settingSurfaceClassNames.breadcrumb}>
{moduleTitle} /{" "}
<span className={settingSurfaceClassNames.breadcrumbCurrent}>
</span>
</Text>
</Stack>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="pills"
color="#1874FF"
autoContrast
radius="sm"
classNames={settingTabsClassNames}
styles={{
root: {
flex: 1,
minHeight: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
},
...settingTabsStyles,
}}>
<Tabs.List>
<Tabs.Tab value="label"></Tabs.Tab>
<Tabs.Tab value="review1"></Tabs.Tab>
<Tabs.Tab value="review2"></Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="label">
{visitedTabs.label ? <LabelWorkloadTable /> : null}
</Tabs.Panel>
<Tabs.Panel value="review1">
{visitedTabs.review1 ? <Review1WorkloadTable /> : null}
</Tabs.Panel>
<Tabs.Panel value="review2">
{visitedTabs.review2 ? <Review2WorkloadTable /> : null}
</Tabs.Panel>
</Tabs>
</SettingPage>
)
}

View File

@@ -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<WorkLoad.LabelResponse>({
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<WorkLoad.Review1Response>({
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<WorkLoad.Review2Response>({
url:
BASE_LABEL_API +
"/api/v1/label_server/workload/review2_statistics/list_by_user",
method: "GET",
data: params,
})
}

View File

@@ -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[]
}
}

View File

@@ -82,6 +82,11 @@ const currentComponentList: MenuItem[] = [
url: "report",
icon: "ReportIcon",
},
{
title: "工时列表",
url: "workload",
icon: "WorkloadIcon",
},
],
},
]

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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<string | undefined>) {
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 (
<div
style={{ maxHeight, ...style }}
className={mergeClassName(classes.tableViewport, className)}>
{children}
</div>
)
}
export function SettingFilterPanel({
className,
...props
@@ -183,6 +216,56 @@ export function SettingFilterActions({
)
}
export function SettingFilterStack({
className,
gap = 12,
...props
}: PropsWithChildren<StackProps>) {
return (
<Stack
gap={gap}
className={mergeClassName(classes.filterStack, className)}
{...props}
/>
)
}
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 (
<div
style={style}
className={mergeClassName(classes.filterField, className)}>
<Text className={mergeClassName(classes.filterLabel, labelClassName)}>
{label}
</Text>
<div className={mergeClassName(classes.filterControl, controlClassName)}>
{children}
</div>
</div>
)
}
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<T = Record<string, unknown>>(
loadingText,
noRecordsText,
minHeight,
maxHeight,
height,
page,
onPageChange,
totalRecords,
recordsPerPage,
recordsPerPageLabel,
paginationText,
scrollAreaProps,
...rest
} = props
@@ -268,7 +353,7 @@ export function SettingDataTable<T = Record<string, unknown>>(
from: number
to: number
totalRecords: number
}) => `${from}-${to} / ${totalRecords}`),
}) => `显示 ${from}-${to} 条,共 ${totalRecords} 条记录`),
}
: {}
@@ -283,6 +368,12 @@ export function SettingDataTable<T = Record<string, unknown>>(
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<T = Record<string, unknown>>(
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