383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { ArrowLeft, ChevronDown } from "lucide-react";
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
import { cn } from "~/lib/utils";
|
|
import type { AbnormalLevel, ModelGrade, ModelStatus } from "./modelData";
|
|
|
|
export function OperationsPageHeader({
|
|
title,
|
|
description,
|
|
actions,
|
|
onBack,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
actions?: ReactNode;
|
|
onBack?: () => void;
|
|
}) {
|
|
return (
|
|
<header className="flex items-start justify-between gap-6">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
{onBack && (
|
|
<Button aria-label="返回" variant="outline" size="icon-sm" onClick={onBack}>
|
|
<ArrowLeft />
|
|
</Button>
|
|
)}
|
|
<div className="min-w-0">
|
|
<span className="text-2xs font-medium-plus tracking-[0.08em] text-primary">
|
|
A CARD MODEL OPERATIONS
|
|
</span>
|
|
<h2 className="mt-1 text-2xl font-bold tracking-tight text-foreground">{title}</h2>
|
|
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">{description}</p>
|
|
</div>
|
|
</div>
|
|
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
const gradeClasses: Record<ModelGrade, string> = {
|
|
A: "bg-success-soft text-success-strong hover:bg-success-soft/80",
|
|
B: "bg-warning-soft text-warning hover:bg-warning-soft/80",
|
|
C: "bg-danger-soft text-danger hover:bg-danger-soft/80",
|
|
};
|
|
|
|
export function GradeBadge({
|
|
grade,
|
|
onClick,
|
|
suffix,
|
|
}: {
|
|
grade: ModelGrade;
|
|
onClick?: () => void;
|
|
suffix?: string;
|
|
}) {
|
|
const label = `${grade}${suffix ? ` ${suffix}` : ""}`;
|
|
if (onClick) {
|
|
return (
|
|
<Button
|
|
className={cn("min-w-8 font-semibold", gradeClasses[grade])}
|
|
variant="ghost"
|
|
size="xs"
|
|
onClick={onClick}
|
|
>
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
data-slot="grade-badge"
|
|
className={cn("inline-flex h-6 items-center rounded-full px-2.5 text-xs font-semibold", gradeClasses[grade])}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const abnormalClasses: Record<AbnormalLevel, string> = {
|
|
三级: "bg-danger-soft text-danger",
|
|
二级: "bg-warning-soft text-warning",
|
|
一级: "bg-brand-soft text-primary",
|
|
正常: "bg-success-soft text-success-strong",
|
|
};
|
|
|
|
export function AbnormalBadge({ level }: { level: AbnormalLevel }) {
|
|
return (
|
|
<span
|
|
data-slot="abnormal-badge"
|
|
className={cn("inline-flex h-6 items-center rounded-full px-2.5 text-xs font-medium", abnormalClasses[level])}
|
|
>
|
|
{level}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const statusClasses: Record<ModelStatus, string> = {
|
|
正常: "bg-success-soft text-success-strong",
|
|
陪跑: "bg-brand-soft text-primary",
|
|
陪跑结束: "bg-muted text-muted-foreground",
|
|
下线: "bg-muted text-muted-foreground",
|
|
};
|
|
|
|
export function StatusBadge({ status }: { status: ModelStatus }) {
|
|
return (
|
|
<span
|
|
data-slot="model-status-badge"
|
|
className={cn("inline-flex h-6 items-center rounded-full px-2.5 text-xs font-medium", statusClasses[status])}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function FilterSelect({
|
|
label,
|
|
value,
|
|
options,
|
|
onChange,
|
|
allLabel = "全部",
|
|
className,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
options: Array<string | { label: string; value: string }>;
|
|
onChange: (value: string) => void;
|
|
allLabel?: string;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<label className={cn("flex min-w-0 flex-col gap-1.5", className)}>
|
|
<span className="text-xs font-medium text-ink-caption">{label}</span>
|
|
<span className="relative">
|
|
<select
|
|
data-slot="operations-filter-select"
|
|
className="h-9 w-full appearance-none rounded-3xl border border-transparent bg-input/50 px-3 pr-8 text-sm text-foreground outline-none transition-colors focus:border-ring"
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
>
|
|
<option value="">{allLabel}</option>
|
|
{options.map((option) => {
|
|
const item = typeof option === "string" ? { label: option, value: option } : option;
|
|
return <option key={item.value} value={item.value}>{item.label}</option>;
|
|
})}
|
|
</select>
|
|
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function MultiSelectFilter({
|
|
label,
|
|
values,
|
|
options,
|
|
onChange,
|
|
allLabel = "全部",
|
|
className,
|
|
}: {
|
|
label: string;
|
|
values: string[];
|
|
options: Array<string | { label: string; value: string }>;
|
|
onChange: (values: string[]) => void;
|
|
allLabel?: string;
|
|
className?: string;
|
|
}) {
|
|
const normalized = options.map((option) => typeof option === "string" ? { label: option, value: option } : option);
|
|
const selectedLabels = normalized.filter((option) => values.includes(option.value)).map((option) => option.label);
|
|
const summary = !selectedLabels.length
|
|
? allLabel
|
|
: selectedLabels.length <= 2
|
|
? selectedLabels.join("、")
|
|
: `已选 ${selectedLabels.length} 项`;
|
|
|
|
const toggle = (value: string) => {
|
|
onChange(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex min-w-0 flex-col gap-1.5", className)} data-slot="operations-multi-select">
|
|
<span className="text-xs font-medium text-ink-caption">{label}</span>
|
|
<details className="group relative">
|
|
<summary className="flex h-9 cursor-pointer list-none items-center justify-between gap-2 rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-colors hover:bg-input focus-visible:border-ring [&::-webkit-details-marker]:hidden">
|
|
<span className="truncate">{summary}</span>
|
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
|
</summary>
|
|
<div className="absolute left-0 z-30 mt-1.5 min-w-full rounded-3xl bg-popover p-2 shadow-lg ring-1 ring-foreground/5">
|
|
<div className="mb-1 flex items-center justify-between border-b border-border px-2 pb-2">
|
|
<span className="text-xs text-muted-foreground">{values.length ? `已选 ${values.length} 项` : allLabel}</span>
|
|
{values.length > 0 && <button className="text-xs font-medium text-primary hover:underline" type="button" onClick={() => onChange([])}>清空</button>}
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto py-1">
|
|
{normalized.map((option) => (
|
|
<label className="flex cursor-pointer items-center gap-2 rounded-xl px-2 py-2 text-sm hover:bg-muted/70" key={option.value}>
|
|
<input className="size-4 accent-brand" type="checkbox" checked={values.includes(option.value)} onChange={() => toggle(option.value)} />
|
|
<span className="whitespace-nowrap">{option.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type Threshold = {
|
|
value: number;
|
|
label: string;
|
|
tone?: "warning" | "danger";
|
|
};
|
|
|
|
export function MetricLineChart({
|
|
months,
|
|
values,
|
|
name,
|
|
unit = "%",
|
|
thresholds = [],
|
|
comparison,
|
|
}: {
|
|
months: string[];
|
|
values: number[];
|
|
name: string;
|
|
unit?: string;
|
|
thresholds?: Threshold[];
|
|
comparison?: { name: string; values: number[]; tone?: "success" | "warning" };
|
|
}) {
|
|
const width = 640;
|
|
const height = 190;
|
|
const left = 48;
|
|
const right = 20;
|
|
const top = 18;
|
|
const bottom = 34;
|
|
const plotWidth = width - left - right;
|
|
const plotHeight = height - top - bottom;
|
|
const allValues = [...values, ...(comparison?.values ?? []), ...thresholds.map((item) => item.value)];
|
|
const rawMin = Math.min(...allValues);
|
|
const rawMax = Math.max(...allValues);
|
|
const padding = Math.max(1, (rawMax - rawMin) * 0.16);
|
|
const min = Math.max(0, rawMin - padding);
|
|
const max = rawMax + padding;
|
|
const range = Math.max(1, max - min);
|
|
const x = (index: number) => left + (months.length <= 1 ? plotWidth / 2 : index * plotWidth / (months.length - 1));
|
|
const y = (value: number) => top + (max - value) / range * plotHeight;
|
|
const path = values.map((value, index) => `${index === 0 ? "M" : "L"}${x(index)},${y(value)}`).join(" ");
|
|
const comparisonPath = comparison?.values.map((value, index) => `${index === 0 ? "M" : "L"}${x(index)},${y(value)}`).join(" ") ?? "";
|
|
const ticks = Array.from({ length: 4 }, (_, index) => max - range * index / 3);
|
|
|
|
return (
|
|
<div data-slot="metric-line-chart" className="w-full">
|
|
<svg className="h-auto w-full" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${name}趋势图`}>
|
|
{ticks.map((tick) => (
|
|
<g key={tick}>
|
|
<line className="stroke-border" x1={left} x2={width - right} y1={y(tick)} y2={y(tick)} />
|
|
<text className="fill-ink-subtle text-3xs" x={left - 8} y={y(tick) + 3} textAnchor="end">
|
|
{tick.toFixed(1)}{unit}
|
|
</text>
|
|
</g>
|
|
))}
|
|
{thresholds.map((threshold) => (
|
|
<g key={threshold.label}>
|
|
<line
|
|
className={cn(
|
|
"[stroke-dasharray:6_5] [stroke-width:1.5]",
|
|
threshold.tone === "danger" ? "stroke-danger" : "stroke-warning",
|
|
)}
|
|
x1={left}
|
|
x2={width - right}
|
|
y1={y(threshold.value)}
|
|
y2={y(threshold.value)}
|
|
/>
|
|
<text
|
|
className={threshold.tone === "danger" ? "fill-danger text-3xs" : "fill-warning text-3xs"}
|
|
x={width - right}
|
|
y={y(threshold.value) - 5}
|
|
textAnchor="end"
|
|
>
|
|
{threshold.label}
|
|
</text>
|
|
</g>
|
|
))}
|
|
<path className="fill-none stroke-primary [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:3]" d={path} />
|
|
{values.map((value, index) => (
|
|
<circle
|
|
className="fill-card stroke-primary [stroke-width:3]"
|
|
key={`${months[index]}-${value}`}
|
|
cx={x(index)}
|
|
cy={y(value)}
|
|
r="4"
|
|
/>
|
|
))}
|
|
{comparison && <path className={cn("fill-none [stroke-dasharray:7_5] [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:2.5]", comparison.tone === "warning" ? "stroke-warning" : "stroke-success")} d={comparisonPath} />}
|
|
{comparison?.values.map((value, index) => (
|
|
<circle className={cn("fill-card [stroke-width:2.5]", comparison.tone === "warning" ? "stroke-warning" : "stroke-success")} key={`comparison-${months[index]}-${value}`} cx={x(index)} cy={y(value)} r="3.5" />
|
|
))}
|
|
{months.map((month, index) => (
|
|
<text
|
|
className="fill-ink-subtle text-3xs"
|
|
key={month}
|
|
x={x(index)}
|
|
y={height - 10}
|
|
textAnchor="middle"
|
|
>
|
|
{month}
|
|
</text>
|
|
))}
|
|
</svg>
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<i className="h-0.5 w-5 rounded-full bg-primary" />
|
|
{name}
|
|
{comparison && <><i className={cn("ml-3 h-0.5 w-5 rounded-full", comparison.tone === "warning" ? "bg-warning" : "bg-success")} />{comparison.name}</>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SortingComboChart({
|
|
bins,
|
|
counts,
|
|
badRates,
|
|
}: {
|
|
bins: readonly string[];
|
|
counts: readonly number[];
|
|
badRates: readonly number[];
|
|
}) {
|
|
const width = 760;
|
|
const height = 300;
|
|
const left = 56;
|
|
const right = 54;
|
|
const top = 22;
|
|
const bottom = 52;
|
|
const plotWidth = width - left - right;
|
|
const plotHeight = height - top - bottom;
|
|
const maxCount = Math.ceil(Math.max(...counts) / 1000) * 1000;
|
|
const maxRate = 12;
|
|
const step = plotWidth / bins.length;
|
|
const barWidth = step * 0.34;
|
|
const x = (index: number) => left + step * index + step / 2;
|
|
const countY = (value: number) => top + (1 - value / maxCount) * plotHeight;
|
|
const rateY = (value: number) => top + (1 - value / maxRate) * plotHeight;
|
|
const linePath = badRates.map((value, index) => `${index === 0 ? "M" : "L"}${x(index)},${rateY(value)}`).join(" ");
|
|
const rateTicks = [0, 2, 4, 6, 8, 10, 12];
|
|
|
|
return (
|
|
<div data-slot="sorting-combo-chart" className="w-full">
|
|
<svg className="h-auto w-full" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="排序性趋势(当月)">
|
|
{rateTicks.map((tick) => (
|
|
<g key={tick}>
|
|
<line className="stroke-border" x1={left} x2={width - right} y1={rateY(tick)} y2={rateY(tick)} />
|
|
<text className="fill-ink-subtle text-3xs" x={left - 8} y={rateY(tick) + 3} textAnchor="end">{tick.toFixed(0)}%</text>
|
|
<text className="fill-ink-subtle text-3xs" x={width - right + 8} y={rateY(tick) + 3} textAnchor="start">
|
|
{Math.round(tick / maxRate * maxCount)}
|
|
</text>
|
|
</g>
|
|
))}
|
|
{counts.map((count, index) => (
|
|
<g key={`${bins[index]}-${count}`}>
|
|
<rect
|
|
className="fill-primary/75"
|
|
x={x(index) - barWidth / 2}
|
|
y={countY(count)}
|
|
width={barWidth}
|
|
height={top + plotHeight - countY(count)}
|
|
rx="3"
|
|
/>
|
|
<text className="fill-foreground text-3xs" x={x(index)} y={countY(count) - 7} textAnchor="middle">{count}</text>
|
|
<text className="fill-ink-muted text-3xs" x={x(index)} y={height - 24} textAnchor="middle">{bins[index]}</text>
|
|
</g>
|
|
))}
|
|
<path className="fill-none stroke-warning [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:3]" d={linePath} />
|
|
{badRates.map((rate, index) => (
|
|
<g key={`${bins[index]}-${rate}`}>
|
|
<circle className="fill-warning" cx={x(index)} cy={rateY(rate)} r="4" />
|
|
<text className="fill-foreground text-3xs" x={x(index) + 6} y={rateY(rate) - 8}>{rate.toFixed(2)}%</text>
|
|
</g>
|
|
))}
|
|
</svg>
|
|
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-2"><i className="h-2.5 w-5 rounded bg-primary/75" />客户数</span>
|
|
<span className="flex items-center gap-2"><i className="h-0.5 w-5 rounded bg-warning" />坏客户占比</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|