| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- "use client";
- import {
- TrendingUp,
- Download,
- Info,
- CheckCircle2,
- AlertTriangle,
- RefreshCw,
- } from "lucide-react";
- import { useMemo } from "react";
- import { useSearchParams } from "next/navigation";
- import { useAuth } from "@/components/AuthProvider";
- import { useD4Metrics } from "@/hooks/useD4Metrics";
- import { toD4UserId } from "@/lib/d4Metrics";
- import { buildRuntimeMetrics, getRuntimeMetric, type MetricDataState } from "@/lib/metricRuntime";
- export default function MetricsContent({ metricId }: { metricId: string }) {
- const searchParams = useSearchParams();
- const { user } = useAuth();
- const normalizedMetricId = metricId.toUpperCase();
- const isD4Metric = normalizedMetricId === "D4";
- const selectedStudentId = searchParams.get("student") || (user?.role === "stu" ? user.id : null);
- const selectedD4UserId = toD4UserId(selectedStudentId);
- const d4Query = useMemo(() => (selectedD4UserId ? { userId: selectedD4UserId } : {}), [selectedD4UserId]);
- const d4Metrics = useD4Metrics(d4Query, { enabled: isD4Metric });
- const runtimeMetrics = useMemo(
- () => buildRuntimeMetrics({ data: d4Metrics.data, error: d4Metrics.error, isLoading: d4Metrics.isLoading }),
- [d4Metrics.data, d4Metrics.error, d4Metrics.isLoading],
- );
- const metricObj = getRuntimeMetric(runtimeMetrics, normalizedMetricId);
- if (!metricObj) {
- return (
- <div className="flex-grow flex items-center justify-center p-8">
- <h2 className="text-xl text-secondary">未找到指标 {normalizedMetricId} 的详情</h2>
- </div>
- );
- }
- const getStateLabel = (state: MetricDataState, score: number | null) => {
- if (state === "loading") return { label: "加载中", class: "bg-primary/10 text-primary border-primary/30" };
- if (state === "error") return { label: "接口异常", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
- if (state === "unavailable") return { label: "未接入", class: "bg-surface-variant text-on-surface-variant border-outline-variant" };
- if (score == null) return { label: "已同步", class: "bg-primary/10 text-primary border-primary/30" };
- if (score >= 80) return { label: "良好", class: "bg-metric-high/10 text-metric-high border-metric-high/30" };
- if (score >= 70) return { label: "一般", class: "bg-metric-warn/10 text-metric-warn border-metric-warn/30" };
- return { label: "需改进", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
- };
- const scoreNum = metricObj.score;
- const chartScore = scoreNum ?? 0;
- const metricStatus = getStateLabel(metricObj.state, metricObj.score);
- return (
- <main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full grid grid-cols-1 lg:grid-cols-12 gap-6">
- <aside className="lg:col-span-4 xl:col-span-3 space-y-6">
- <div className="bg-surface rounded-2xl p-6 shadow-elevation-1 border border-outline-variant">
- <div className="flex items-center gap-2 mb-4">
- <span className={`w-2 h-8 rounded-full bg-${metricObj.colorClass}`}></span>
- <h2 className="text-lg font-medium text-on-surface">
- {metricObj.id} - {metricObj.name}
- </h2>
- </div>
- <div className="flex flex-col items-center justify-center py-8 relative">
- <div
- className="absolute w-40 h-40 rounded-full opacity-20 blur-2xl"
- style={{ backgroundColor: `var(--color-${metricObj.colorClass})` }}
- ></div>
- <div className="relative w-40 h-40 flex items-center justify-center rounded-full bg-surface shadow-elevation-2 border border-outline-variant/30">
- <svg
- className="absolute w-full h-full transform -rotate-90 drop-shadow-md"
- viewBox="0 0 100 100"
- >
- <circle
- cx="50"
- cy="50"
- r="44"
- fill="transparent"
- className="stroke-surface-variant flex-shrink-0"
- strokeWidth="6"
- />
- <circle
- cx="50"
- cy="50"
- fill="transparent"
- r="44"
- style={{ stroke: scoreNum == null ? "var(--color-outline-variant)" : `var(--color-${metricObj.colorClass})` }}
- strokeDasharray={`${(chartScore / 100) * 276.46} 276.46`}
- strokeDashoffset="0"
- strokeLinecap="round"
- strokeWidth="8"
- className="transition-all duration-1000 ease-out"
- ></circle>
- </svg>
- <div className="text-center z-10 flex flex-col items-center justify-center bg-surface w-28 h-28 rounded-full shadow-inner border border-outline-variant/10">
- <span
- className="text-4xl font-display font-bold tracking-tight"
- style={{ color: scoreNum == null ? "var(--color-outline)" : `var(--color-${metricObj.colorClass})` }}
- >
- {scoreNum ?? "--"}
- </span>
- <span className="block text-[0.65rem] text-on-surface-variant uppercase tracking-widest mt-1 font-medium">
- 指标值
- </span>
- </div>
- </div>
- </div>
- <div className="space-y-4 mt-2">
- <div className="flex justify-between items-center text-sm">
- <span className="text-on-surface-variant">
- 数据状态
- </span>
- <span className={`flex items-center font-medium px-2 py-0.5 rounded-md border ${metricStatus.class}`}>
- <TrendingUp className="w-4 h-4 mr-1" /> {metricStatus.label}
- </span>
- </div>
- <p className="text-xs text-outline leading-relaxed">
- {metricObj.summary}
- </p>
- </div>
- <div className="mt-6 pt-6 border-t border-outline-variant space-y-3">
- {isD4Metric && (
- <button
- type="button"
- onClick={d4Metrics.refetch}
- className="w-full border border-outline-variant text-on-surface h-10 rounded-full font-medium text-sm hover:bg-surface-container-high transition-all flex items-center justify-center gap-2 cursor-pointer"
- >
- <RefreshCw className="w-4 h-4" /> 刷新数据
- </button>
- )}
- <button className="w-full bg-primary text-on-primary h-10 rounded-full font-medium text-sm shadow-md hover:shadow-lg transition-all flex items-center justify-center gap-2 cursor-pointer">
- <Download className="w-5 h-5" /> 导出报告
- </button>
- </div>
- </div>
- <div className={`${metricObj.bgClass} rounded-2xl p-6`}>
- <h3 className="font-medium mb-2 flex items-center gap-2">
- <Info className="w-5 h-5" /> 指标说明
- </h3>
- <p className="text-sm opacity-90 leading-relaxed font-light">
- {metricObj.description}
- </p>
- </div>
- </aside>
- <section className="lg:col-span-8 xl:col-span-9 space-y-6">
- <div className="flex flex-col md:flex-row md:items-center justify-between mb-2">
- <div>
- <h2 className="text-2xl font-medium text-on-background">
- 子指标分析
- </h2>
- <p className="text-on-surface-variant text-sm mt-1">
- 关于 {metricObj.id} 的详细数据维度拆解
- </p>
- </div>
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- {metricObj.subMetrics.map((sub) => {
- const subStatus = getStateLabel(sub.state, sub.score);
- const isWarn = sub.state === "error" || (sub.score != null && sub.score < 70);
- return (
- <article key={sub.id} className="bg-surface rounded-xl p-0 shadow-elevation-1 border border-outline-variant overflow-hidden flex flex-col h-full hover:shadow-elevation-2 hover:bg-surface-container transition-all duration-300 group">
- <div className="p-5 border-b border-outline-variant bg-surface-variant/20">
- <div className="flex justify-between items-start gap-4">
- <div>
- <span className={`text-xs font-bold tracking-wider uppercase text-${metricObj.colorClass}`}>
- {sub.id}
- </span>
- <h3 className="text-lg font-medium text-on-surface mt-1">
- {sub.name}
- </h3>
- </div>
- <span className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${isWarn ? "bg-metric-warn/10 text-metric-warn" : "bg-metric-high/10 text-metric-high"}`}>
- {isWarn ? <AlertTriangle className="w-5 h-5" /> : <CheckCircle2 className="w-5 h-5" />}
- </span>
- </div>
- </div>
- <div className="p-5 flex-grow space-y-5">
- {sub.values.map((val, idx) => {
- const valueStatus = getStateLabel(val.state, val.score);
- return (
- <div key={idx} className="flex flex-col gap-2">
- <div className="flex items-start justify-between gap-3">
- <div className="flex items-start gap-3 min-w-0">
- <CheckCircle2 className="text-on-surface-variant w-5 h-5 mt-0.5 shrink-0" />
- <div className="min-w-0">
- <p className="text-sm font-medium text-on-surface leading-tight">
- {val.name}
- </p>
- {val.helperText && (
- <p className="text-xs text-outline mt-1 leading-relaxed">
- {val.helperText}
- </p>
- )}
- </div>
- </div>
- <div className="text-right whitespace-nowrap ml-2">
- <span className={`block text-xl font-bold ${val.score == null ? "text-on-surface" : val.score < 70 ? "text-metric-warn" : "text-metric-high"}`}>
- {val.displayValue}
- </span>
- <span className={`inline-flex text-[10px] font-bold border px-2 py-0.5 rounded-full mt-1 ${valueStatus.class}`}>
- {valueStatus.label}
- </span>
- </div>
- </div>
- {val.progress != null && (
- <div className="w-full bg-surface-variant rounded-full h-1.5 mt-1">
- <div
- className={`h-1.5 rounded-full ${val.progress < 70 ? "bg-metric-warn" : "bg-metric-high"}`}
- style={{ width: `${val.progress}%` }}
- ></div>
- </div>
- )}
- </div>
- );
- })}
- <div className="w-full bg-surface-variant rounded-full h-1.5 mt-2">
- <div
- className={`h-1.5 rounded-full ${sub.score != null && sub.score < 70 ? "bg-metric-warn" : "bg-metric-high"}`}
- style={{ width: `${sub.score ?? 0}%` }}
- ></div>
- </div>
- </div>
- <div className="px-5 py-3 bg-surface-variant/10 flex justify-end border-t border-outline-variant/50">
- <span className={`text-xs font-bold px-3 py-1 rounded-full border ${subStatus.class}`}>
- {subStatus.label}
- </span>
- </div>
- </article>
- );
- })}
- </div>
- </section>
- </main>
- );
- }
|