MetricsContent.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. "use client";
  2. import {
  3. TrendingUp,
  4. Download,
  5. Info,
  6. CheckCircle2,
  7. AlertTriangle,
  8. RefreshCw,
  9. } from "lucide-react";
  10. import { useMemo } from "react";
  11. import { useSearchParams } from "next/navigation";
  12. import { useAuth } from "@/components/AuthProvider";
  13. import { useD4Metrics } from "@/hooks/useD4Metrics";
  14. import { toD4UserId } from "@/lib/d4Metrics";
  15. import { buildRuntimeMetrics, getRuntimeMetric, type MetricDataState } from "@/lib/metricRuntime";
  16. export default function MetricsContent({ metricId }: { metricId: string }) {
  17. const searchParams = useSearchParams();
  18. const { user } = useAuth();
  19. const normalizedMetricId = metricId.toUpperCase();
  20. const isD4Metric = normalizedMetricId === "D4";
  21. const selectedStudentId = searchParams.get("student") || (user?.role === "stu" ? user.id : null);
  22. const selectedD4UserId = toD4UserId(selectedStudentId);
  23. const d4Query = useMemo(() => (selectedD4UserId ? { userId: selectedD4UserId } : {}), [selectedD4UserId]);
  24. const d4Metrics = useD4Metrics(d4Query, { enabled: isD4Metric });
  25. const runtimeMetrics = useMemo(
  26. () => buildRuntimeMetrics({ data: d4Metrics.data, error: d4Metrics.error, isLoading: d4Metrics.isLoading }),
  27. [d4Metrics.data, d4Metrics.error, d4Metrics.isLoading],
  28. );
  29. const metricObj = getRuntimeMetric(runtimeMetrics, normalizedMetricId);
  30. if (!metricObj) {
  31. return (
  32. <div className="flex-grow flex items-center justify-center p-8">
  33. <h2 className="text-xl text-secondary">未找到指标 {normalizedMetricId} 的详情</h2>
  34. </div>
  35. );
  36. }
  37. const getStateLabel = (state: MetricDataState, score: number | null) => {
  38. if (state === "loading") return { label: "加载中", class: "bg-primary/10 text-primary border-primary/30" };
  39. if (state === "error") return { label: "接口异常", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
  40. if (state === "unavailable") return { label: "未接入", class: "bg-surface-variant text-on-surface-variant border-outline-variant" };
  41. if (score == null) return { label: "已同步", class: "bg-primary/10 text-primary border-primary/30" };
  42. if (score >= 80) return { label: "良好", class: "bg-metric-high/10 text-metric-high border-metric-high/30" };
  43. if (score >= 70) return { label: "一般", class: "bg-metric-warn/10 text-metric-warn border-metric-warn/30" };
  44. return { label: "需改进", class: "bg-metric-low/10 text-metric-low border-metric-low/30" };
  45. };
  46. const scoreNum = metricObj.score;
  47. const chartScore = scoreNum ?? 0;
  48. const metricStatus = getStateLabel(metricObj.state, metricObj.score);
  49. return (
  50. <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">
  51. <aside className="lg:col-span-4 xl:col-span-3 space-y-6">
  52. <div className="bg-surface rounded-2xl p-6 shadow-elevation-1 border border-outline-variant">
  53. <div className="flex items-center gap-2 mb-4">
  54. <span className={`w-2 h-8 rounded-full bg-${metricObj.colorClass}`}></span>
  55. <h2 className="text-lg font-medium text-on-surface">
  56. {metricObj.id} - {metricObj.name}
  57. </h2>
  58. </div>
  59. <div className="flex flex-col items-center justify-center py-8 relative">
  60. <div
  61. className="absolute w-40 h-40 rounded-full opacity-20 blur-2xl"
  62. style={{ backgroundColor: `var(--color-${metricObj.colorClass})` }}
  63. ></div>
  64. <div className="relative w-40 h-40 flex items-center justify-center rounded-full bg-surface shadow-elevation-2 border border-outline-variant/30">
  65. <svg
  66. className="absolute w-full h-full transform -rotate-90 drop-shadow-md"
  67. viewBox="0 0 100 100"
  68. >
  69. <circle
  70. cx="50"
  71. cy="50"
  72. r="44"
  73. fill="transparent"
  74. className="stroke-surface-variant flex-shrink-0"
  75. strokeWidth="6"
  76. />
  77. <circle
  78. cx="50"
  79. cy="50"
  80. fill="transparent"
  81. r="44"
  82. style={{ stroke: scoreNum == null ? "var(--color-outline-variant)" : `var(--color-${metricObj.colorClass})` }}
  83. strokeDasharray={`${(chartScore / 100) * 276.46} 276.46`}
  84. strokeDashoffset="0"
  85. strokeLinecap="round"
  86. strokeWidth="8"
  87. className="transition-all duration-1000 ease-out"
  88. ></circle>
  89. </svg>
  90. <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">
  91. <span
  92. className="text-4xl font-display font-bold tracking-tight"
  93. style={{ color: scoreNum == null ? "var(--color-outline)" : `var(--color-${metricObj.colorClass})` }}
  94. >
  95. {scoreNum ?? "--"}
  96. </span>
  97. <span className="block text-[0.65rem] text-on-surface-variant uppercase tracking-widest mt-1 font-medium">
  98. 指标值
  99. </span>
  100. </div>
  101. </div>
  102. </div>
  103. <div className="space-y-4 mt-2">
  104. <div className="flex justify-between items-center text-sm">
  105. <span className="text-on-surface-variant">
  106. 数据状态
  107. </span>
  108. <span className={`flex items-center font-medium px-2 py-0.5 rounded-md border ${metricStatus.class}`}>
  109. <TrendingUp className="w-4 h-4 mr-1" /> {metricStatus.label}
  110. </span>
  111. </div>
  112. <p className="text-xs text-outline leading-relaxed">
  113. {metricObj.summary}
  114. </p>
  115. </div>
  116. <div className="mt-6 pt-6 border-t border-outline-variant space-y-3">
  117. {isD4Metric && (
  118. <button
  119. type="button"
  120. onClick={d4Metrics.refetch}
  121. 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"
  122. >
  123. <RefreshCw className="w-4 h-4" /> 刷新数据
  124. </button>
  125. )}
  126. <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">
  127. <Download className="w-5 h-5" /> 导出报告
  128. </button>
  129. </div>
  130. </div>
  131. <div className={`${metricObj.bgClass} rounded-2xl p-6`}>
  132. <h3 className="font-medium mb-2 flex items-center gap-2">
  133. <Info className="w-5 h-5" /> 指标说明
  134. </h3>
  135. <p className="text-sm opacity-90 leading-relaxed font-light">
  136. {metricObj.description}
  137. </p>
  138. </div>
  139. </aside>
  140. <section className="lg:col-span-8 xl:col-span-9 space-y-6">
  141. <div className="flex flex-col md:flex-row md:items-center justify-between mb-2">
  142. <div>
  143. <h2 className="text-2xl font-medium text-on-background">
  144. 子指标分析
  145. </h2>
  146. <p className="text-on-surface-variant text-sm mt-1">
  147. 关于 {metricObj.id} 的详细数据维度拆解
  148. </p>
  149. </div>
  150. </div>
  151. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  152. {metricObj.subMetrics.map((sub) => {
  153. const subStatus = getStateLabel(sub.state, sub.score);
  154. const isWarn = sub.state === "error" || (sub.score != null && sub.score < 70);
  155. return (
  156. <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">
  157. <div className="p-5 border-b border-outline-variant bg-surface-variant/20">
  158. <div className="flex justify-between items-start gap-4">
  159. <div>
  160. <span className={`text-xs font-bold tracking-wider uppercase text-${metricObj.colorClass}`}>
  161. {sub.id}
  162. </span>
  163. <h3 className="text-lg font-medium text-on-surface mt-1">
  164. {sub.name}
  165. </h3>
  166. </div>
  167. <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"}`}>
  168. {isWarn ? <AlertTriangle className="w-5 h-5" /> : <CheckCircle2 className="w-5 h-5" />}
  169. </span>
  170. </div>
  171. </div>
  172. <div className="p-5 flex-grow space-y-5">
  173. {sub.values.map((val, idx) => {
  174. const valueStatus = getStateLabel(val.state, val.score);
  175. return (
  176. <div key={idx} className="flex flex-col gap-2">
  177. <div className="flex items-start justify-between gap-3">
  178. <div className="flex items-start gap-3 min-w-0">
  179. <CheckCircle2 className="text-on-surface-variant w-5 h-5 mt-0.5 shrink-0" />
  180. <div className="min-w-0">
  181. <p className="text-sm font-medium text-on-surface leading-tight">
  182. {val.name}
  183. </p>
  184. {val.helperText && (
  185. <p className="text-xs text-outline mt-1 leading-relaxed">
  186. {val.helperText}
  187. </p>
  188. )}
  189. </div>
  190. </div>
  191. <div className="text-right whitespace-nowrap ml-2">
  192. <span className={`block text-xl font-bold ${val.score == null ? "text-on-surface" : val.score < 70 ? "text-metric-warn" : "text-metric-high"}`}>
  193. {val.displayValue}
  194. </span>
  195. <span className={`inline-flex text-[10px] font-bold border px-2 py-0.5 rounded-full mt-1 ${valueStatus.class}`}>
  196. {valueStatus.label}
  197. </span>
  198. </div>
  199. </div>
  200. {val.progress != null && (
  201. <div className="w-full bg-surface-variant rounded-full h-1.5 mt-1">
  202. <div
  203. className={`h-1.5 rounded-full ${val.progress < 70 ? "bg-metric-warn" : "bg-metric-high"}`}
  204. style={{ width: `${val.progress}%` }}
  205. ></div>
  206. </div>
  207. )}
  208. </div>
  209. );
  210. })}
  211. <div className="w-full bg-surface-variant rounded-full h-1.5 mt-2">
  212. <div
  213. className={`h-1.5 rounded-full ${sub.score != null && sub.score < 70 ? "bg-metric-warn" : "bg-metric-high"}`}
  214. style={{ width: `${sub.score ?? 0}%` }}
  215. ></div>
  216. </div>
  217. </div>
  218. <div className="px-5 py-3 bg-surface-variant/10 flex justify-end border-t border-outline-variant/50">
  219. <span className={`text-xs font-bold px-3 py-1 rounded-full border ${subStatus.class}`}>
  220. {subStatus.label}
  221. </span>
  222. </div>
  223. </article>
  224. );
  225. })}
  226. </div>
  227. </section>
  228. </main>
  229. );
  230. }