'分系列表详情'

This commit is contained in:
张磊
2026-04-03 16:18:43 +08:00
parent 7257605338
commit 9357b54bb9
5 changed files with 1239 additions and 303 deletions
+849
View File
@@ -0,0 +1,849 @@
<template>
<div class="archive-detail-page" v-loading="loading">
<el-card v-if="!loading && !canRequest" class="state-card" shadow="never">
<el-empty description="缺少 user_id 或 id 参数,无法加载" />
</el-card>
<template v-else-if="!loading && !firstRecord">
<el-card class="state-card" shadow="never">
<el-empty description="暂无检测详情数据" />
</el-card>
</template>
<template v-else>
<header class="page-head">
<h1 class="page-title">分析对比详情</h1>
</header>
<el-card class="detail-card" shadow="hover">
<div class="detail-body">
<div class="viewers-col">
<div class="compare-row" :class="{ 'compare-row--single': !secondRecord }">
<section v-if="firstRecord" class="compare-pane">
<header class="pane-head">
<span class="pane-tag pane-tag--a">{{ paneTitleFirst }}</span>
<span class="pane-time">
<span class="pane-time__label">检测时间</span>
{{ firstRecord.create_time || '—' }}
</span>
</header>
<div class="viewer-card">
<div v-if="hasTriple1" class="triptych">
<div class="triptych-panel triptych-side" :class="{ 'is-empty': !c1Left }"
@click="moveRight1" title="与中间图互换">
<img v-if="c1Left" :src="c1Left" class="triptych-img triptych-img--side"
alt="" />
<span v-else class="panel-empty"></span>
</div>
<div class="triptych-panel triptych-center" :class="{ 'is-empty': !c1Front }">
<img v-if="c1Front" :src="c1Front" class="triptych-img triptych-img--center"
alt="" />
<span v-else class="panel-empty"></span>
</div>
<div class="triptych-panel triptych-side" :class="{ 'is-empty': !c1Right }"
@click="moveLeft1" title="与中间图互换">
<img v-if="c1Right" :src="c1Right" class="triptych-img triptych-img--side"
alt="" />
<span v-else class="panel-empty"></span>
</div>
</div>
<div v-else-if="singleUrl1" class="single-wrap">
<img :src="singleUrl1" class="single-img" alt="" />
</div>
<div v-else class="pane-empty">暂无分析图</div>
</div>
</section>
<section v-if="secondRecord" class="compare-pane compare-pane--b">
<header class="pane-head">
<span class="pane-tag pane-tag--b">{{ paneTitleSecond }}</span>
<span class="pane-time">
<span class="pane-time__label">检测时间</span>
{{ secondRecord.create_time || '—' }}
</span>
</header>
<div class="viewer-card">
<div v-if="hasTriple2" class="triptych">
<div class="triptych-panel triptych-side" :class="{ 'is-empty': !c2Left }"
@click="moveRight2" title="与中间图互换">
<img v-if="c2Left" :src="c2Left" class="triptych-img triptych-img--side"
alt="" />
<span v-else class="panel-empty"></span>
</div>
<div class="triptych-panel triptych-center" :class="{ 'is-empty': !c2Front }">
<img v-if="c2Front" :src="c2Front" class="triptych-img triptych-img--center"
alt="" />
<span v-else class="panel-empty"></span>
</div>
<div class="triptych-panel triptych-side" :class="{ 'is-empty': !c2Right }"
@click="moveLeft2" title="与中间图互换">
<img v-if="c2Right" :src="c2Right" class="triptych-img triptych-img--side"
alt="" />
<span v-else class="panel-empty"></span>
</div>
</div>
<div v-else-if="singleUrl2" class="single-wrap">
<img :src="singleUrl2" class="single-img" alt="" />
</div>
<div v-else class="pane-empty">暂无分析图</div>
</div>
</section>
</div>
<div v-if="thumbKeys.length" class="thumb-section">
<div class="thumb-section-head">
<span class="thumb-section-title">模式图</span>
<div class="thumb-section-actions">
<span class="thumb-section-active" :title="activeLabel">{{ activeLabel }}</span>
<el-button type="primary" link @click="thumbExpanded = !thumbExpanded">
{{ thumbExpanded ? '收起' : '展开' }}
</el-button>
</div>
</div>
<div v-show="thumbExpanded" class="thumb-strip-wrap">
<div class="thumb-strip">
<button v-for="key in thumbKeys" :key="key" type="button" class="thumb-btn"
:class="{ active: key === activeImageKey }" @click="switchMode(key)">
<span class="thumb-btn__frame">
<img v-if="thumbUrlFor(key)" :src="thumbUrlFor(key)" alt="" />
<span v-else class="thumb-btn__placeholder"></span>
</span>
<span class="thumb-btn__label">{{ labelForKey(key) }}</span>
</button>
</div>
</div>
</div>
</div>
<aside class="metrics-aside">
<div v-if="metricRecord" class="metric-card">
<div class="metric-card-head">
<h3>综合评估</h3>
<p v-if="metricTimeHint" class="metric-sub">{{ metricTimeHint }}</p>
</div>
<div class="metric-card-body">
<div v-for="m in metricRows(metricRecord)" :key="m.key" class="metric-row">
<div class="metric-head">
<span class="metric-name">{{ m.label }}</span>
<span class="metric-num">{{ m.value }}</span>
</div>
<el-progress :percentage="m.pct" :color="progressColors" :stroke-width="8"
:show-text="false" />
</div>
<div v-if="!metricRows(metricRecord).length" class="metric-empty">暂无评估数据</div>
</div>
</div>
</aside>
</div>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { getUserDetectionDetail } from '@/api/user';
import { ElMessage } from 'element-plus';
import {
buildImageEntries,
orderedImageKeys,
type ImageEntry,
type StateAngles,
} from '@/utils/userArchiveImages';
const CONTENT_LABELS: Record<string, string> = {
brightness: '亮度',
pigment: '色素',
red_zone: '红区',
skin_color: '肤色',
yanmin: '炎敏',
yumin: '肤质综合',
};
const route = useRoute();
const loading = ref(false);
const thumbExpanded = ref(true);
const firstRecord = ref<any>(null);
const secondRecord = ref<any>(null);
const entriesFirst = ref<ImageEntry[]>([]);
const entriesSecond = ref<ImageEntry[]>([]);
const activeImageKey = ref('');
const c1Left = ref('');
const c1Front = ref('');
const c1Right = ref('');
const c2Left = ref('');
const c2Front = ref('');
const c2Right = ref('');
const progressColors = [
{ color: '#f56c6c', percentage: 40 },
{ color: '#e6a23c', percentage: 70 },
{ color: '#67c23a', percentage: 100 },
];
const requestParams = computed(() => {
const uid = route.query.user_id;
const id = route.query.id;
const u = uid == null || uid === '' ? NaN : Number(Array.isArray(uid) ? uid[0] : uid);
const i = id == null || id === '' ? NaN : Number(Array.isArray(id) ? id[0] : id);
const body: Record<string, number> = {};
if (Number.isFinite(u)) body.user_id = u;
if (Number.isFinite(i)) body.id = i;
return body;
});
const canRequest = computed(() => Object.keys(requestParams.value).length > 0);
const paneTitleFirst = computed(
() => firstRecord.value?.nickname || firstRecord.value?.name || firstRecord.value?.username || '检测一'
);
const paneTitleSecond = computed(
() => secondRecord.value?.nickname || secondRecord.value?.name || secondRecord.value?.username || '检测二'
);
const pageSubtitle = computed(() => {
const uid = firstRecord.value?.user_id;
if (uid == null || uid === '') return '';
return `用户 ID${uid}`;
});
const thumbKeys = computed(() => orderedImageKeys(entriesFirst.value, entriesSecond.value));
const activeLabel = computed(() => {
const k = activeImageKey.value;
return labelForKey(k);
});
function labelForKey(key: string): string {
return (
entriesFirst.value.find((e) => e.key === key)?.label ||
entriesSecond.value.find((e) => e.key === key)?.label ||
key
);
}
function thumbUrlFor(key: string): string {
return (
entriesFirst.value.find((e) => e.key === key)?.thumbUrl ||
entriesSecond.value.find((e) => e.key === key)?.thumbUrl ||
''
);
}
const hasTriple1 = computed(() => !!(c1Left.value || c1Front.value || c1Right.value));
const hasTriple2 = computed(() => !!(c2Left.value || c2Front.value || c2Right.value));
const singleUrl1 = computed(() => c1Front.value || c1Left.value || c1Right.value || '');
const singleUrl2 = computed(() => c2Front.value || c2Left.value || c2Right.value || '');
/** 同一人对比:评估指标只展示一份,优先用检测一有 contents,否则用检测二 */
const metricRecord = computed(() => {
const a = firstRecord.value;
const b = secondRecord.value;
const hasContents = (r: any) => r?.contents && typeof r.contents === 'object' && Object.keys(r.contents).length;
if (a && hasContents(a)) return a;
if (b && hasContents(b)) return b;
return a ?? b ?? null;
});
const metricTimeHint = computed(() => {
const a = firstRecord.value?.create_time;
const b = secondRecord.value?.create_time;
if (secondRecord.value && a && b && a !== b) {
return `检测一 ${a} · 检测二 ${b}`;
}
return metricRecord.value?.create_time || '';
});
function metricRows(record: any) {
const c = record?.contents;
if (!c || typeof c !== 'object') return [];
return Object.entries(c).map(([key, val]) => {
const n = Number(val);
const pct = Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0;
return {
key,
label: CONTENT_LABELS[key] || key,
value: String(val ?? '--'),
pct,
};
});
}
function anglesFor(entries: ImageEntry[], key: string): StateAngles {
const e = entries.find((x) => x.key === key);
return e?.angles ?? { left: '', front: '', right: '' };
}
function applyActiveKey() {
const key = activeImageKey.value;
const a1 = anglesFor(entriesFirst.value, key);
const a2 = anglesFor(entriesSecond.value, key);
c1Left.value = a1.left;
c1Front.value = a1.front;
c1Right.value = a1.right;
c2Left.value = a2.left;
c2Front.value = a2.front;
c2Right.value = a2.right;
}
function switchMode(key: string) {
activeImageKey.value = key;
applyActiveKey();
}
function moveRight1() {
if (!c1Left.value) return;
const t = c1Front.value;
c1Front.value = c1Left.value;
c1Left.value = t;
}
function moveLeft1() {
if (!c1Right.value) return;
const t = c1Front.value;
c1Front.value = c1Right.value;
c1Right.value = t;
}
function moveRight2() {
if (!c2Left.value) return;
const t = c2Front.value;
c2Front.value = c2Left.value;
c2Left.value = t;
}
function moveLeft2() {
if (!c2Right.value) return;
const t = c2Front.value;
c2Front.value = c2Right.value;
c2Right.value = t;
}
function normalizePayload(raw: any): { first: any; second: any } {
const payload = raw?.data?.data ?? raw?.data ?? raw;
if (!payload || typeof payload !== 'object') return { first: null, second: null };
if (payload.first != null && payload.second != null) {
return { first: payload.first, second: payload.second };
}
return { first: payload, second: null };
}
async function loadDetail() {
if (!canRequest.value) {
firstRecord.value = null;
secondRecord.value = null;
return;
}
loading.value = true;
try {
const res = await getUserDetectionDetail(requestParams.value);
const { first, second } = normalizePayload(res);
firstRecord.value = first;
secondRecord.value = second;
entriesFirst.value = buildImageEntries(first);
entriesSecond.value = second ? buildImageEntries(second) : [];
const keys = orderedImageKeys(entriesFirst.value, entriesSecond.value);
activeImageKey.value = keys[0] ?? '';
applyActiveKey();
} catch (e: any) {
firstRecord.value = null;
secondRecord.value = null;
entriesFirst.value = [];
entriesSecond.value = [];
ElMessage.error(e?.message || '获取检测详情失败');
} finally {
loading.value = false;
}
}
watch(
() => [route.query.user_id, route.query.id] as const,
() => loadDetail(),
{ immediate: true }
);
</script>
<style lang="scss" scoped>
$accent: #52a857;
$accent-blue: #409eff;
.archive-detail-page {
min-height: calc(100vh - 120px);
padding: 20px 24px 28px;
background: var(--el-fill-color-extra-light);
box-sizing: border-box;
}
.state-card {
border-radius: 12px;
border: 1px solid var(--el-border-color-lighter);
}
.page-head {
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
letter-spacing: 0.02em;
}
.page-subtitle {
margin: 8px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.detail-card {
border-radius: 12px;
border: 1px solid var(--el-border-color-lighter);
overflow: hidden;
}
:deep(.detail-card .el-card__body) {
padding: 20px 22px 24px;
}
.detail-body {
display: flex;
gap: 22px;
align-items: flex-start;
}
.viewers-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.compare-row {
display: flex;
gap: 18px;
align-items: stretch;
&--single .compare-pane {
max-width: 100%;
}
}
.compare-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
overflow: hidden;
&--b .pane-head {
background: linear-gradient(180deg, rgba(64, 158, 255, 0.06) 0%, var(--el-bg-color) 100%);
}
}
.pane-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
background: linear-gradient(180deg, rgba(82, 168, 87, 0.08) 0%, var(--el-bg-color) 100%);
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.pane-tag {
font-size: 13px;
font-weight: 600;
padding: 5px 12px;
border-radius: 20px;
letter-spacing: 0.02em;
&--a {
color: darken($accent, 8%);
background: rgba(82, 168, 87, 0.14);
border: 1px solid rgba(82, 168, 87, 0.25);
}
&--b {
color: $accent-blue;
background: rgba(64, 158, 255, 0.1);
border: 1px solid rgba(64, 158, 255, 0.22);
}
}
.pane-time {
font-size: 13px;
color: var(--el-text-color-regular);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pane-time__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.viewer-card {
flex: 1;
min-height: 300px;
max-height: 52vh;
margin: 0 12px 4px;
padding: 14px;
background: var(--el-fill-color-dark);
border-radius: 10px;
border: 1px solid var(--el-border-color);
}
html.dark .viewer-card {
background: #0d0d10;
border-color: var(--el-border-color);
}
.viewer-hint {
margin: 0 12px 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
line-height: 1.5;
}
.triptych {
display: flex;
gap: 12px;
height: 100%;
min-height: 272px;
max-height: 47vh;
}
.triptych-panel {
position: relative;
background: #0a0a0c;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
&.is-empty {
background: rgba(0, 0, 0, 0.35);
}
}
.triptych-center {
flex: 1;
min-width: 0;
border-color: rgba(82, 168, 87, 0.45);
box-shadow: 0 0 0 1px rgba(82, 168, 87, 0.12);
}
.triptych-side {
flex: 0 0 20%;
min-width: 76px;
max-width: 148px;
&:not(.is-empty) {
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
border-color: rgba(82, 168, 87, 0.65);
box-shadow: 0 0 0 1px rgba(82, 168, 87, 0.2);
}
}
}
.triptych-img {
width: 100%;
height: 100%;
display: block;
}
.triptych-img--side {
object-fit: cover;
object-position: center;
}
.triptych-img--center {
object-fit: contain;
object-position: center;
max-height: 45vh;
}
.panel-empty,
.pane-empty {
font-size: 12px;
color: #6b6b75;
}
.pane-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--el-text-color-placeholder);
}
.single-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 248px;
}
.single-img {
max-width: 100%;
max-height: 45vh;
object-fit: contain;
}
.thumb-section {
padding: 14px 16px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.thumb-section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.thumb-section-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.thumb-section-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.thumb-section-active {
font-size: 12px;
color: $accent;
font-weight: 500;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thumb-strip-wrap {
padding-top: 2px;
}
.thumb-strip {
display: flex;
flex-wrap: nowrap;
gap: 10px;
overflow-x: auto;
padding-bottom: 6px;
scrollbar-width: thin;
scrollbar-color: var(--el-border-color) transparent;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 999px;
}
}
.thumb-btn {
flex: 0 0 auto;
width: 76px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: var(--el-text-color-secondary);
font-size: 11px;
text-align: center;
transition: color 0.15s ease;
&__frame {
display: block;
padding: 3px;
border-radius: 10px;
border: 2px solid transparent;
background: var(--el-fill-color-light);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 7px;
display: block;
}
&__placeholder {
width: 100%;
aspect-ratio: 1;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color);
}
&__label {
display: block;
margin-top: 6px;
padding: 0 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
color: var(--el-text-color-primary);
.thumb-btn__frame {
border-color: var(--el-border-color);
}
}
&.active {
// color: darken($accent, 5%);
.thumb-btn__frame {
border-color: $accent;
box-shadow: 0 2px 12px rgba(82, 168, 87, 0.2);
}
}
}
.metrics-aside {
width: 292px;
flex-shrink: 0;
position: sticky;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.metric-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
overflow: hidden;
background: var(--el-bg-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.metric-card-head {
padding: 14px 16px;
border-bottom: 1px solid var(--el-border-color-extra-light);
background: var(--el-fill-color-extra-light);
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.metric-sub {
margin: 8px 0 0;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.45;
}
.metric-card-body {
padding: 12px 14px 16px;
}
.metric-row {
padding: 10px 12px 12px;
margin-bottom: 8px;
border-radius: 10px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-extra-light);
&:last-child {
margin-bottom: 0;
}
}
.metric-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
}
.metric-name {
color: var(--el-text-color-regular);
}
.metric-num {
color: $accent;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.metric-empty {
font-size: 13px;
color: var(--el-text-color-secondary);
text-align: center;
padding: 16px 0;
}
:deep(.el-progress-bar__outer) {
border-radius: 999px;
background-color: var(--el-fill-color-dark);
}
:deep(.el-progress-bar__inner) {
border-radius: 999px;
}
@media (max-width: 1200px) {
.detail-body {
flex-direction: column;
}
.metrics-aside {
width: 100%;
position: static;
max-height: none;
}
.compare-row {
flex-direction: column;
}
}
</style>
+11 -4
View File
@@ -45,7 +45,8 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { getUserDetectionList, getUserarchiveDetail, userDetectionOperation } from '@/api/user';
import { useRouter } from 'vue-router';
import { getUserDetectionList, userDetectionOperation } from '@/api/user';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search, Refresh } from '@element-plus/icons-vue';
@@ -94,10 +95,16 @@ const resetQuery = () => {
queryParams.page = 1;
loadList();
};
const router = useRouter();
// 档案详情
const archiveDetail = async (row: any) => {
await getUserarchiveDetail({ id: row.id });
const archiveDetail = (row: any) => {
const { href } = router.resolve({
path: '/user/archiveDetail',
query: {
...(row.user_id != null ? { user_id: row.user_id } : {}),
},
});
window.open(href, '_blank');
};
// 快速操作(OK → state=5,问题 → state=3