Files
vue-tickets/src/views/ClientView.vue
2026-05-26 14:08:13 +08:00

370 lines
12 KiB
Vue

<template>
<div class="cv-wrap">
<!-- Header -->
<header class="cv-header">
<div class="cv-brand">
<div class="cv-logo">
<el-icon><DataLine /></el-icon>
</div>
<div>
<div class="cv-title">方舟星河</div>
<div class="cv-subtitle">采集数据平台 · Monitor v2.0</div>
</div>
</div>
<div class="cv-user">
<div class="cv-avatar">{{ currentUser.username?.charAt(0) || 'U' }}</div>
<span class="cv-username">{{ currentUser.username }}</span>
<el-button class="cv-logout-btn" @click="handleLogout" :icon="SwitchButton" size="small">退出</el-button>
</div>
</header>
<!-- Stats -->
<section class="cv-stats">
<div
v-for="stat in summaryStats"
:key="stat.label"
class="cv-stat-card"
:class="{ 'is-active': activeStatus === stat.status }"
:style="{ '--c': stat.color }"
@click="handleStatFilter(stat.status)"
>
<div class="cv-stat-icon">
<el-icon><component :is="stat.icon" /></el-icon>
</div>
<div class="cv-stat-body">
<div class="cv-stat-val">{{ stat.value }}</div>
<div class="cv-stat-label">{{ stat.label }}</div>
</div>
<div class="cv-stat-bar"></div>
</div>
</section>
<!-- Table Panel -->
<main class="cv-panel">
<div class="cv-panel-header">
<div class="cv-panel-title">
<span class="cv-title-dot"></span>
实时交付看板
<el-tag
v-if="activeStatus"
closable
size="small"
class="cv-filter-tag"
@close="activeStatus = null"
>{{ activeStatus }}</el-tag>
</div>
<el-button type="primary" link :loading="loading" @click="loadData" :icon="Refresh" size="small">
同步数据
</el-button>
</div>
<el-table
:data="paginatedTickets"
v-loading="loading"
class="cv-table"
:header-cell-style="{ background: 'var(--cv-th-bg)', color: 'var(--text-muted)', fontWeight: '500', fontSize: '12px' }"
:row-style="{ height: '52px' }"
>
<el-table-column label="单号" width="80" align="center">
<template #default="{ row }">
<span class="cv-id">#{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column label="网站地址" min-width="240">
<template #default="{ row }">
<div class="cv-site-cell">
<div class="cv-site-favicon">
<el-icon><Monitor /></el-icon>
</div>
<span class="cv-site-name" :title="row.website">{{ row.website }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="交付进度" width="220">
<template #default="{ row }">
<div class="cv-progress-cell">
<el-progress
:percentage="Number(row.progress || 0)"
:status="Number(row.progress) === 100 ? 'success' : ''"
:stroke-width="6"
:show-text="false"
striped
striped-flow
/>
<span class="cv-progress-pct" :class="{ 'is-done': Number(row.progress) === 100 }">
{{ row.progress || 0 }}%
</span>
</div>
</template>
</el-table-column>
<el-table-column label="数据量" width="130" align="right">
<template #default="{ row }">
<span class="cv-datanum">{{ formatNumber(row.datanum) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<span :class="['cv-status-badge', getStatusClass(row.status)]">
<i class="cv-status-dot"></i>{{ statusLabel(row.status) }}
</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="120" align="center">
<template #default="{ row }">
<span class="cv-date">{{ formatDate(row.lock_time || row.update_time || row.create_time) }}</span>
</template>
</el-table-column>
</el-table>
<div class="cv-footer">
<el-pagination
v-model:current-page="page"
v-model:page-size="limit"
:total="total"
layout="total, prev, pager, next"
background
small
@current-change="handlePageChange"
/>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { resetRouter } from "@/router";
import {
DataLine, SwitchButton, User, Refresh,
Files, Timer, CircleCheck, Monitor,
} from "@element-plus/icons-vue";
import { clientApi, userApi } from "@/api/ticket";
import { ElMessage } from "element-plus";
import { formatDate } from "@/utils/format";
const router = useRouter();
const loading = ref(false);
const tickets = ref([]);
const page = ref(1);
const limit = ref(15);
const activeStatus = ref(null);
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
const statusLabel = (s) => statusMap[Number(s)] || "未知";
const currentUser = computed(() => {
const info = localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
return info ? JSON.parse(info) : { username: "访客" };
});
const loadData = async () => {
loading.value = true;
try {
const res = await clientApi.getTickets();
if (res.code === 200) tickets.value = res.data || [];
} catch {
ElMessage.error("数据同步失败");
} finally {
loading.value = false;
}
};
const baseTickets = computed(() => tickets.value.filter((t) => Number(t.status) !== 3));
const filteredTickets = computed(() => {
if (!activeStatus.value) return baseTickets.value;
return baseTickets.value.filter((t) => statusLabel(t.status) === activeStatus.value);
});
const paginatedTickets = computed(() => {
const start = (page.value - 1) * limit.value;
return filteredTickets.value.slice(start, start + limit.value);
});
const total = computed(() => filteredTickets.value.length);
const summaryStats = computed(() => [
{ label: "采集项目总数", value: baseTickets.value.length, color: "#6366f1", icon: Monitor, status: null },
{ label: "等待排期", value: baseTickets.value.filter((t) => Number(t.status) === 0).length, color: "#94a3b8", icon: Files, status: "未分配" },
{ label: "正在采集", value: baseTickets.value.filter((t) => Number(t.status) === 1).length, color: "#f59e0b", icon: Timer, status: "进行中" },
{ label: "已就绪", value: baseTickets.value.filter((t) => Number(t.status) === 2).length, color: "#10b981", icon: CircleCheck, status: "已完成" },
]);
const handleStatFilter = (status) => { activeStatus.value = status; page.value = 1; };
const formatNumber = (val) => {
if (!val || val === "0") return "—";
const n = Number(val);
return isNaN(n) ? val : n.toLocaleString();
};
const getStatusClass = (s) => ({ 0: "s-wait", 1: "s-run", 2: "s-done" })[Number(s)] || "";
const handlePageChange = (p) => { page.value = p; window.scrollTo({ top: 0, behavior: "smooth" }); };
const handleLogout = async () => {
try { await userApi.logout(); } catch {}
resetRouter();
router.push({ name: "login" });
};
onMounted(loadData);
</script>
<style scoped>
/* ── tokens ── */
.cv-wrap {
--cv-th-bg: #f8f9fb;
--cv-radius: 14px;
padding: 8px 4px 24px;
}
/* ── Header ── */
.cv-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
padding: 0 2px;
}
.cv-brand { display: flex; align-items: center; gap: 14px; }
.cv-logo {
width: 42px; height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #6366f1, #818cf8);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 20px;
box-shadow: 0 4px 14px rgba(99,102,241,.32);
flex-shrink: 0;
}
.cv-title { font-size: 17px; font-weight: 700; color: var(--text-primary); line-height: 1.3; letter-spacing: -0.2px; }
.cv-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; letter-spacing: 0.2px; }
.cv-user { display: flex; align-items: center; gap: 12px; }
.cv-avatar {
width: 32px; height: 32px; border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #a78bfa);
color: #fff; font-size: 13px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(99,102,241,.25);
}
.cv-username { font-size: 13px; color: var(--text-secondary); }
.cv-logout-btn { border-radius: 8px !important; }
/* ── Stats ── */
.cv-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.cv-stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--cv-radius);
padding: 20px 22px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
}
.cv-stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,.08);
}
.cv-stat-card.is-active {
border-color: var(--c);
background: color-mix(in srgb, var(--c) 5%, #fff);
box-shadow: 0 6px 20px color-mix(in srgb, var(--c) 18%, transparent);
}
.cv-stat-bar {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 3px;
background: var(--c);
opacity: 0;
transition: opacity 0.18s;
border-radius: 0 0 var(--cv-radius) var(--cv-radius);
}
.cv-stat-card.is-active .cv-stat-bar,
.cv-stat-card:hover .cv-stat-bar { opacity: 1; }
.cv-stat-icon {
width: 46px; height: 46px; border-radius: 12px;
background: color-mix(in srgb, var(--c) 10%, transparent);
color: var(--c);
display: flex; align-items: center; justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.cv-stat-val { font-size: 28px; font-weight: 700; color: var(--text-primary); line-height: 1.1; letter-spacing: -0.5px; }
.cv-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; letter-spacing: 0.1px; }
/* ── Panel ── */
.cv-panel {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--cv-radius);
padding: 24px 28px 20px;
}
.cv-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.cv-panel-title {
display: flex; align-items: center; gap: 10px;
font-size: 14px; font-weight: 600; color: var(--text-primary);
}
.cv-title-dot {
width: 3px; height: 16px;
background: linear-gradient(180deg, #6366f1, #a78bfa);
border-radius: 2px;
}
.cv-filter-tag { border-radius: 6px; }
/* ── Table cells ── */
.cv-table { width: 100%; }
.cv-id { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); }
.cv-site-cell { display: flex; align-items: center; gap: 10px; }
.cv-site-favicon {
width: 28px; height: 28px; border-radius: 7px;
background: var(--cv-th-bg);
display: flex; align-items: center; justify-content: center;
color: var(--text-muted); font-size: 13px; flex-shrink: 0;
}
.cv-site-name {
font-size: 13px; font-weight: 500; color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px;
}
.cv-progress-cell { display: flex; align-items: center; gap: 10px; }
.cv-progress-cell .el-progress { flex: 1; }
.cv-progress-pct { font-size: 11px; font-weight: 600; color: var(--text-muted); width: 32px; text-align: right; flex-shrink: 0; }
.cv-progress-pct.is-done { color: #10b981; }
.cv-datanum { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--text-primary); }
.cv-date { font-size: 12px; color: var(--text-muted); }
/* ── Status badge ── */
.cv-status-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 11px; border-radius: 20px;
font-size: 11px; font-weight: 500;
}
.cv-status-dot {
width: 5px; height: 5px; border-radius: 50%;
background: currentColor; display: inline-block;
}
.s-wait { background: #f1f5f9; color: #94a3b8; }
.s-run { background: #fffbeb; color: #f59e0b; }
.s-done { background: #ecfdf5; color: #10b981; }
/* ── Footer ── */
.cv-footer { margin-top: 24px; padding-bottom: 4px; display: flex; justify-content: center; }
</style>