admin page & components

This commit is contained in:
2026-02-27 16:58:38 +01:00
parent 081240c83e
commit 0f774e8f2e
6 changed files with 1159 additions and 4 deletions

View File

@@ -0,0 +1,119 @@
<template>
<div class="admin-stats">
<h2 class="admin-stats__title">Statistics</h2>
<div class="admin-stats__grid">
<div class="stat-card">
<span class="stat-card__value">{{ stats.totalUsers }}</span>
<span class="stat-card__label">Total Users</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.activeTorrents }}</span>
<span class="stat-card__label">Active Torrents</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.totalRequests }}</span>
<span class="stat-card__label">Total Requests</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.pendingRequests }}</span>
<span class="stat-card__label">Pending Requests</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.approvedRequests }}</span>
<span class="stat-card__label">Approved</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.totalStorage }}</span>
<span class="stat-card__label">Storage Used</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
interface Stats {
totalUsers: number;
activeTorrents: number;
totalRequests: number;
pendingRequests: number;
approvedRequests: number;
totalStorage: string;
}
const stats = ref<Stats>({
totalUsers: 0,
activeTorrents: 0,
totalRequests: 0,
pendingRequests: 0,
approvedRequests: 0,
totalStorage: "0 GB"
});
onMounted(() => {
stats.value = {
totalUsers: 142,
activeTorrents: 23,
totalRequests: 856,
pendingRequests: 12,
approvedRequests: 712,
totalStorage: "2.4 TB"
};
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.admin-stats {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&__title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
}
&__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
}
}
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
text-align: center;
&__value {
font-size: 1.75rem;
font-weight: 600;
color: var(--highlight-color);
}
&__label {
font-size: 0.85rem;
color: $text-color-70;
margin-top: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="activity-feed">
<h2 class="activity-feed__title">Recent Activity</h2>
<div class="activity-feed__list">
<div
class="activity-item"
v-for="activity in activities"
:key="activity.id"
>
<div class="activity-item__icon">
<component :is="getIcon(activity.type)" />
</div>
<div class="activity-item__content">
<span class="activity-item__message">{{ activity.message }}</span>
<span class="activity-item__time">{{
formatTime(activity.timestamp)
}}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconProfile from "@/icons/IconProfile.vue";
interface Activity {
id: number;
type: "request" | "download" | "user" | "movie";
message: string;
timestamp: Date;
}
const activities = ref<Activity[]>([]);
const getIcon = (type: string) => {
const icons: Record<string, any> = {
request: IconRequest,
download: IconPlay,
user: IconProfile,
movie: IconMovie
};
return icons[type] || IconMovie;
};
const formatTime = (date: Date): string => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
onMounted(() => {
activities.value = [
{
id: 1,
type: "request",
message: "New request: Interstellar (2014)",
timestamp: new Date(Date.now() - 5 * 60000)
},
{
id: 2,
type: "download",
message: "Torrent completed: Dune Part Two",
timestamp: new Date(Date.now() - 23 * 60000)
},
{
id: 3,
type: "user",
message: "New user registered: john_doe",
timestamp: new Date(Date.now() - 45 * 60000)
},
{
id: 4,
type: "movie",
message: "Movie added to library: The Batman",
timestamp: new Date(Date.now() - 2 * 3600000)
},
{
id: 5,
type: "request",
message: "Request approved: Oppenheimer",
timestamp: new Date(Date.now() - 3 * 3600000)
},
{
id: 6,
type: "download",
message: "Torrent started: Poor Things",
timestamp: new Date(Date.now() - 5 * 3600000)
},
{
id: 7,
type: "user",
message: "User upgraded to VIP: sarah_s",
timestamp: new Date(Date.now() - 8 * 3600000)
},
{
id: 8,
type: "movie",
message: "Library scan completed: 12 new items",
timestamp: new Date(Date.now() - 12 * 3600000)
}
];
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.activity-feed {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&__title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
}
&__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 400px;
overflow-y: auto;
}
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-40);
}
&__icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--highlight-color);
border-radius: 50%;
svg {
width: 16px;
height: 16px;
fill: $white;
}
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__message {
font-size: 0.9rem;
color: $text-color;
line-height: 1.3;
}
&__time {
font-size: 0.75rem;
color: $text-color-50;
}
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="system-status">
<h2 class="system-status__title">System Status</h2>
<div class="system-status__items">
<div class="status-item" v-for="item in systemItems" :key="item.name">
<div class="status-item__header">
<span class="status-item__name">{{ item.name }}</span>
<span
:class="[
'status-item__indicator',
`status-item__indicator--${item.status}`
]"
></span>
</div>
<div class="status-item__details">
<span class="status-item__value">{{ item.value }}</span>
<span class="status-item__description">{{ item.description }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
interface SystemItem {
name: string;
status: "online" | "warning" | "offline";
value: string;
description: string;
}
const systemItems = ref<SystemItem[]>([]);
onMounted(() => {
systemItems.value = [
{
name: "API Server",
status: "online",
value: "Running",
description: "All endpoints responding"
},
{
name: "Database",
status: "online",
value: "Connected",
description: "Latency: 12ms"
},
{
name: "Cache Server",
status: "online",
value: "Active",
description: "Hit rate: 87%"
},
{
name: "Disk Space",
status: "warning",
value: "45% Used",
description: "1.2 TB / 2.7 TB"
},
{
name: "Background Jobs",
status: "online",
value: "3 Running",
description: "Queue size: 12"
},
{
name: "Plex Connection",
status: "online",
value: "Connected",
description: "Server: Home"
}
];
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.system-status {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&__title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
}
&__items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
}
.status-item {
padding: 0.75rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
&__name {
font-weight: 500;
color: $text-color;
font-size: 0.95rem;
}
&__indicator {
width: 10px;
height: 10px;
border-radius: 50%;
&--online {
background-color: var(--color-success-highlight);
box-shadow: 0 0 6px var(--color-success);
}
&--warning {
background-color: var(--color-warning-highlight);
box-shadow: 0 0 6px var(--color-warning);
}
&--offline {
background-color: var(--color-error-highlight);
box-shadow: 0 0 6px var(--color-error);
}
}
&__details {
display: flex;
justify-content: space-between;
align-items: center;
}
&__value {
font-size: 0.85rem;
color: $text-color-70;
}
&__description {
font-size: 0.8rem;
color: $text-color-50;
}
}
</style>

View File

@@ -0,0 +1,607 @@
<template>
<div class="torrent-management">
<div class="torrent-management__header">
<h2 class="torrent-management__title">Torrent Management</h2>
<div class="torrent-management__controls">
<input
v-model="searchQuery"
type="text"
placeholder="Search torrents..."
class="torrent-management__search"
/>
<select v-model="statusFilter" class="torrent-management__filter">
<option value="">All Status</option>
<option value="seeding">Seeding</option>
<option value="downloading">Downloading</option>
<option value="paused">Paused</option>
<option value="stopped">Stopped</option>
</select>
<button class="refresh-btn" @click="fetchTorrents" :disabled="loading">
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="torrent-management__loading">
Loading torrents...
</div>
<div v-else-if="error" class="torrent-management__error">{{ error }}</div>
<table v-else class="torrent-management__table">
<thead>
<tr>
<th @click="sortBy('name')" class="sortable">
Name
<span v-if="sortColumn === 'name'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th @click="sortBy('size')" class="sortable">
Size
<span v-if="sortColumn === 'size'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th @click="sortBy('seeders')" class="sortable">
Seeders
<span v-if="sortColumn === 'seeders'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th>Leechers</th>
<th>Uploaded</th>
<th>Downloaded</th>
<th>Ratio</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="torrent in filteredTorrents"
:key="torrent.id"
:class="{ processing: torrent.processing }"
>
<td class="torrent-name" :title="torrent.name">{{ torrent.name }}</td>
<td>{{ torrent.size }}</td>
<td>{{ torrent.seeders }}</td>
<td>{{ torrent.leechers }}</td>
<td>{{ torrent.uploaded }}</td>
<td>{{ torrent.downloaded }}</td>
<td>
<span
:class="[
'ratio',
{
'ratio--good': torrent.ratio >= 1,
'ratio--bad': torrent.ratio < 1
}
]"
>
{{ torrent.ratio.toFixed(2) }}
</span>
</td>
<td>
<span :class="['status-badge', `status-badge--${torrent.status}`]">
{{ torrent.status }}
</span>
</td>
<td class="actions">
<button
v-if="
torrent.status === 'seeding' || torrent.status === 'downloading'
"
class="action-btn"
title="Pause"
@click="pauseTorrent(torrent)"
:disabled="torrent.processing"
>
<IconStop />
</button>
<button
v-if="torrent.status === 'paused' || torrent.status === 'stopped'"
class="action-btn"
title="Resume"
@click="resumeTorrent(torrent)"
:disabled="torrent.processing"
>
<IconPlay />
</button>
<button
class="action-btn action-btn--danger"
title="Delete"
@click="deleteTorrent(torrent)"
:disabled="torrent.processing"
>
<IconClose />
</button>
<button
class="action-btn"
title="Details"
@click="showDetails(torrent)"
>
<IconInfo />
</button>
</td>
</tr>
</tbody>
</table>
<div class="torrent-management__footer">
<span class="torrent-count"
>{{ filteredTorrents.length }} of {{ torrents.length }} torrents</span
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import IconStop from "@/icons/IconStop.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconClose from "@/icons/IconClose.vue";
import IconInfo from "@/icons/IconInfo.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Torrent {
id: number;
name: string;
size: string;
seeders: number;
leechers: number;
uploaded: string;
downloaded: string;
ratio: number;
status: "seeding" | "downloading" | "paused" | "stopped";
processing?: boolean;
}
const torrents = ref<Torrent[]>([]);
const loading = ref(false);
const error = ref("");
const searchQuery = ref("");
const statusFilter = ref("");
const sortColumn = ref<keyof Torrent>("name");
const sortDirection = ref<"asc" | "desc">("asc");
const filteredTorrents = computed(() => {
let result = [...torrents.value];
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(t => t.name.toLowerCase().includes(query));
}
if (statusFilter.value) {
result = result.filter(t => t.status === statusFilter.value);
}
result.sort((a, b) => {
const aVal = a[sortColumn.value];
const bVal = b[sortColumn.value];
if (typeof aVal === "string" && typeof bVal === "string") {
return sortDirection.value === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
if (typeof aVal === "number" && typeof bVal === "number") {
return sortDirection.value === "asc" ? aVal - bVal : bVal - aVal;
}
return 0;
});
return result;
});
function sortBy(column: keyof Torrent) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
} else {
sortColumn.value = column;
sortDirection.value = "asc";
}
}
async function fetchTorrents() {
loading.value = true;
error.value = "";
try {
await new Promise(resolve => setTimeout(resolve, 500));
torrents.value = [
{
id: 1,
name: "Movie.Name.2024.1080p.BluRay.x264",
size: "2.4 GB",
seeders: 156,
leechers: 23,
uploaded: "45.2 GB",
downloaded: "2.4 GB",
ratio: 18.83,
status: "seeding"
},
{
id: 2,
name: "TV.Show.S01E01.720p.WEB-DL",
size: "1.2 GB",
seeders: 89,
leechers: 12,
uploaded: "12.8 GB",
downloaded: "1.2 GB",
ratio: 10.67,
status: "seeding"
},
{
id: 3,
name: "Documentary.2024.HDRip",
size: "890 MB",
seeders: 45,
leechers: 8,
uploaded: "2.1 GB",
downloaded: "650 MB",
ratio: 3.31,
status: "downloading"
},
{
id: 4,
name: "Anime.Series.S02E10.1080p",
size: "1.8 GB",
seeders: 234,
leechers: 56,
uploaded: "89.4 GB",
downloaded: "1.8 GB",
ratio: 49.67,
status: "seeding"
},
{
id: 5,
name: "Concert.2024.4K.UHD",
size: "12.5 GB",
seeders: 67,
leechers: 5,
uploaded: "0 B",
downloaded: "0 B",
ratio: 0,
status: "paused"
},
{
id: 6,
name: "Drama.Series.2024.S01E05.1080p",
size: "2.1 GB",
seeders: 112,
leechers: 34,
uploaded: "8.9 GB",
downloaded: "2.1 GB",
ratio: 4.24,
status: "seeding"
},
{
id: 7,
name: "Action.Movie.2024.BRRip",
size: "1.5 GB",
seeders: 0,
leechers: 0,
uploaded: "0 B",
downloaded: "0 B",
ratio: 0,
status: "stopped"
}
];
} catch (e) {
error.value = "Failed to load torrents";
} finally {
loading.value = false;
}
}
async function pauseTorrent(torrent: Torrent) {
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrent.status = "paused";
torrent.processing = false;
}
async function resumeTorrent(torrent: Torrent) {
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrent.status = "seeding";
torrent.processing = false;
}
async function deleteTorrent(torrent: Torrent) {
if (!confirm(`Are you sure you want to delete "${torrent.name}"?`)) return;
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrents.value = torrents.value.filter(t => t.id !== torrent.id);
}
function showDetails(torrent: Torrent) {
alert(
`Torrent Details:\n\nName: ${torrent.name}\nSize: ${torrent.size}\nSeeders: ${torrent.seeders}\nLeechers: ${torrent.leechers}\nUploaded: ${torrent.uploaded}\nDownloaded: ${torrent.downloaded}\nRatio: ${torrent.ratio.toFixed(2)}\nStatus: ${torrent.status}`
);
}
onMounted(() => {
fetchTorrents();
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.torrent-management {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 1rem;
}
&__title {
margin: 0;
font-size: 1.25rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
&__search {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__filter {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__loading,
&__error {
padding: 2rem;
text-align: center;
color: $text-color-70;
}
&__error {
color: var(--color-error-highlight);
}
&__footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--background-40);
}
&__table {
width: 100%;
border-spacing: 0;
border-radius: 0.5rem;
overflow: hidden;
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--background-40);
}
th {
background-color: var(--table-background-color);
color: var(--table-header-text-color);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
font-weight: 400;
&.sortable {
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--background-80);
}
}
}
td {
font-size: 0.85rem;
color: $text-color;
}
tbody tr {
background-color: var(--background-color);
transition: background-color 0.2s;
&:nth-child(even) {
background-color: var(--background-70);
}
&:hover {
background-color: var(--background-ui);
}
&.processing {
opacity: 0.6;
pointer-events: none;
}
}
}
}
.torrent-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include mobile-only {
max-width: 120px;
}
}
.ratio {
font-weight: 500;
&--good {
color: var(--color-success-highlight);
}
&--bad {
color: var(--color-error-highlight);
}
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 500;
&--seeding {
background-color: var(--color-success);
color: var(--color-success-text);
}
&--downloading {
background-color: var(--color-warning);
color: $black;
}
&--paused {
background-color: var(--background-40);
color: $text-color-70;
}
&--stopped {
background-color: var(--color-error);
color: $white;
}
}
.actions {
display: flex;
gap: 0.25rem;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.35rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(:disabled) {
background-color: var(--background-40);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--danger:hover:not(:disabled) {
background-color: var(--color-error);
svg {
fill: $white;
}
}
svg {
width: 16px;
height: 16px;
fill: $text-color;
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
&.spin {
animation: spin 1s linear infinite;
}
}
}
.torrent-count {
font-size: 0.8rem;
color: $text-color-50;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

76
src/pages/AdminPage.vue Normal file
View File

@@ -0,0 +1,76 @@
<template>
<section class="admin">
<h1 class="admin__title">Admin Dashboard</h1>
<div class="admin__grid">
<AdminStats class="admin__stats" />
<SystemStatusPanel class="admin__system-status" />
</div>
<div class="admin__torrents">
<TorrentManagementGrid />
</div>
<div class="admin__activity">
<RecentActivityFeed />
</div>
</section>
</template>
<script setup lang="ts">
import AdminStats from "@/components/admin/AdminStats.vue";
import TorrentManagementGrid from "@/components/admin/TorrentManagementGrid.vue";
import SystemStatusPanel from "@/components/admin/SystemStatusPanel.vue";
import RecentActivityFeed from "@/components/admin/RecentActivityFeed.vue";
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.admin {
padding: 3rem;
@include mobile-only {
padding: 1rem;
}
&__title {
margin: 0 0 2rem 0;
font-size: 2rem;
font-weight: 300;
color: $text-color;
}
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
&__stats {
grid-column: 1;
}
&__system-status {
grid-column: 2;
@include mobile-only {
grid-column: 1;
}
}
&__torrents {
margin-bottom: 1.5rem;
}
&__activity {
margin-bottom: 1.5rem;
}
}
</style>

View File

@@ -74,10 +74,11 @@ const routes: RouteRecordRaw[] = [
// } // }
// }, // },
{ {
name: "404", name: "admin",
path: "/404", path: "/admin",
component: () => import("./pages/404Page.vue") meta: { requiresAuth: true },
} component: () => import("./pages/AdminPage.vue")
},
// { // {
// path: "*", // path: "*",
// redirect: "/" // redirect: "/"