821 lines
47 KiB
HTML
821 lines
47 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>E-Commerce Order Audit System V9.4 (Full Stack)</title>
|
|
<style>
|
|
:root {
|
|
/* Color System - Maintain V9.2 Modern Style */
|
|
--primary: #2563eb;
|
|
--primary-hover: #1d4ed8;
|
|
--primary-light: #eff6ff;
|
|
--bg-body: #f1f5f9;
|
|
--bg-card: #ffffff;
|
|
--bg-sidebar: #ffffff;
|
|
--bg-header: #1e293b;
|
|
--text-main: #0f172a;
|
|
--text-muted: #64748b;
|
|
--border: #e2e8f0;
|
|
|
|
/* Status Colors */
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
--info: #06b6d4;
|
|
|
|
/* Dimensions & Shadows */
|
|
--sidebar-w: 260px;
|
|
--header-h: 60px;
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
--trans: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
/* Dark Mode Variables */
|
|
[data-theme="dark"] {
|
|
--bg-body: #0f172a;
|
|
--bg-card: #1e293b;
|
|
--bg-sidebar: #1e293b;
|
|
--bg-header: #020617;
|
|
--text-main: #f1f5f9;
|
|
--text-muted: #94a3b8;
|
|
--border: #334155;
|
|
--primary-light: rgba(37, 99, 235, 0.1);
|
|
}
|
|
|
|
/* --- Basic Resets --- */
|
|
* { box-sizing: border-box; outline: none; }
|
|
body { font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: var(--bg-body); margin: 0; color: var(--text-main); font-size: 14px; overflow: hidden; transition: background 0.3s; }
|
|
a, button { cursor: pointer; transition: var(--trans); color: inherit; text-decoration: none; }
|
|
ul { list-style: none; padding: 0; margin: 0; }
|
|
|
|
.icon { width: 16px; height: 16px; fill: currentColor; vertical-align: middle; margin-right: 4px; }
|
|
|
|
/* --- Layout Framework --- */
|
|
.app-container { display: flex; flex-direction: column; height: 100vh; }
|
|
|
|
.header { height: var(--header-h); background: var(--bg-header); color: white; display: flex; justify-content: space-between; align-items: center; padding: 0 20px; z-index: 50; box-shadow: var(--shadow-md); }
|
|
.brand { font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
|
.brand span { padding: 4px 8px; background: var(--primary); border-radius: 4px; font-size: 14px; }
|
|
|
|
.nav-top button { padding: 8px 12px; color: #94a3b8; font-size: 13px; border-radius: 4px; background: transparent; border: none; cursor: pointer; }
|
|
.nav-top button:hover, .nav-top button.active { color: white; background: rgba(255,255,255,0.1); }
|
|
|
|
.user-area { display: flex; align-items: center; gap: 15px; }
|
|
.avatar { width: 32px; height: 32px; background: var(--primary); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 2px solid rgba(255,255,255,0.2); }
|
|
|
|
.main-body { display: flex; flex: 1; overflow: hidden; }
|
|
|
|
.sidebar { width: var(--sidebar-w); background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding-top: 10px; transition: background 0.3s; }
|
|
.menu-group { margin-bottom: 20px; }
|
|
.menu-header { font-size: 11px; text-transform: uppercase; color: var(--text-muted); padding: 0 20px; margin-bottom: 8px; font-weight: 700; }
|
|
.menu-item { padding: 10px 20px; display: flex; align-items: center; color: var(--text-muted); font-weight: 500; border-left: 3px solid transparent; background: transparent; border: none; width: 100%; cursor: pointer; text-align: left; }
|
|
.menu-item:hover { background: var(--bg-body); color: var(--text-main); }
|
|
.menu-item.active { background: var(--primary-light); color: var(--primary); border-left-color: var(--primary); }
|
|
.badge { margin-left: auto; background: var(--danger); color: white; font-size: 10px; padding: 2px 6px; border-radius: 10px; }
|
|
|
|
.content { flex: 1; overflow-y: auto; padding: 25px; position: relative; }
|
|
.view-panel { display: none; animation: fadeIn 0.3s ease; }
|
|
.view-panel.active { display: block; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
|
/* --- 组件样式 --- */
|
|
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; box-shadow: var(--shadow-sm); margin-bottom: 20px; transition: background 0.3s; }
|
|
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
|
|
|
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
|
|
.stat-box { background: var(--bg-card); padding: 20px; border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; }
|
|
.stat-label { font-size: 12px; color: var(--text-muted); }
|
|
.stat-num { font-size: 28px; font-weight: 700; margin: 10px 0; }
|
|
.stat-trend { font-size: 12px; display: flex; align-items: center; gap: 5px; }
|
|
.trend-up { color: var(--success); }
|
|
.trend-down { color: var(--danger); }
|
|
|
|
.filter-panel { background: var(--bg-card); padding: 15px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 20px; display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end; }
|
|
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
|
.form-group label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
|
.input-control { padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-body); color: var(--text-main); width: 180px; font-size: 13px; }
|
|
|
|
.btn { padding: 8px 16px; border-radius: 4px; border: none; font-size: 13px; font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
|
|
.btn-primary { background: var(--primary); color: white; }
|
|
.btn-secondary { background: var(--bg-card); border: 1px solid var(--border); color: var(--text-main); }
|
|
.btn-danger { background: var(--danger); color: white; }
|
|
|
|
/* Table Styles (Enhanced: Sticky Header Support) */
|
|
.order-table-wrapper { border: 1px solid var(--border); border-radius: 8px; overflow: auto; background: var(--bg-card); max-height: calc(100vh - 250px); /* Limit height for scrolling */ }
|
|
.order-table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
|
|
|
|
/* Sticky Header */
|
|
.order-table th { background: var(--bg-body); padding: 12px 15px; font-weight: 600; color: var(--text-muted); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
|
|
|
|
.order-table td { padding: 12px 15px; border-bottom: 1px solid var(--border); color: var(--text-main); vertical-align: top; }
|
|
.order-table tr:hover td { background: var(--primary-light); }
|
|
|
|
.tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; display: inline-block; font-weight: 600; margin-right: 4px; }
|
|
.tag-urgent { background: #fee2e2; color: #b91c1c; border: 1px solid #fecaca; }
|
|
.tag-large { background: #e0f2fe; color: #0369a1; border: 1px solid #bae6fd; }
|
|
.tag-risk { background: #fef3c7; color: #b45309; border: 1px solid #fde68a; }
|
|
.tag-vip { background: #f3e8ff; color: #7e22ce; border: 1px solid #d8b4fe; }
|
|
|
|
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
|
.status-pending { background: #f59e0b; }
|
|
.status-approved { background: #10b981; }
|
|
.status-rejected { background: #ef4444; }
|
|
|
|
.risk-bar-container { width: 80px; height: 6px; background: #e2e8f0; border-radius: 3px; margin-top: 5px; overflow: hidden; }
|
|
.risk-bar-fill { height: 100%; transition: width 0.3s; }
|
|
|
|
/* Drawer & Modals */
|
|
.drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; display: none; opacity: 0; transition: opacity 0.3s; }
|
|
.drawer { position: fixed; top: 0; right: -600px; width: 500px; height: 100vh; background: var(--bg-card); z-index: 101; box-shadow: -5px 0 20px rgba(0,0,0,0.1); transition: right 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); display: flex; flex-direction: column; }
|
|
.drawer.open { right: 0; }
|
|
.drawer-backdrop.open { display: block; opacity: 1; }
|
|
|
|
.drawer-header { padding: 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-body); }
|
|
.drawer-body { flex: 1; padding: 25px; overflow-y: auto; }
|
|
.drawer-footer { padding: 15px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 10px; background: var(--bg-body); }
|
|
|
|
.detail-section { margin-bottom: 25px; }
|
|
.detail-section h4 { margin: 0 0 10px 0; font-size: 14px; border-left: 3px solid var(--primary); padding-left: 8px; color: var(--text-main); }
|
|
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 13px; }
|
|
.info-label { color: var(--text-muted); }
|
|
|
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; display: none; justify-content: center; align-items: center; backdrop-filter: blur(2px); }
|
|
.modal { background: var(--bg-card); width: 400px; border-radius: 8px; padding: 0; box-shadow: 0 10px 25px rgba(0,0,0,0.2); transform: scale(0.9); transition: transform 0.2s; }
|
|
.modal.open { transform: scale(1); }
|
|
.modal-body { padding: 20px; text-align: center; }
|
|
.modal-actions { display: flex; border-top: 1px solid var(--border); }
|
|
.modal-actions button { flex: 1; padding: 15px; background: transparent; border: none; border-right: 1px solid var(--border); font-weight: 600; }
|
|
.modal-actions button:last-child { border-right: none; }
|
|
.modal-actions button.confirm { color: var(--danger); }
|
|
|
|
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 300; display: flex; flex-direction: column; gap: 10px; }
|
|
.toast { background: var(--bg-card); padding: 12px 20px; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border-left: 4px solid var(--primary); font-size: 13px; display: flex; align-items: center; gap: 10px; transform: translateX(120%); transition: transform 0.3s ease; }
|
|
.toast.show { transform: translateX(0); }
|
|
|
|
.switch { position: relative; display: inline-block; width: 40px; height: 20px; }
|
|
.switch input { opacity: 0; width: 0; height: 0; }
|
|
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #cbd5e1; transition: .4s; border-radius: 20px; }
|
|
.slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .4s; border-radius: 50%; }
|
|
input:checked + .slider { background-color: var(--primary); }
|
|
input:checked + .slider:before { transform: translateX(20px); }
|
|
|
|
.loader { border: 2px solid #f3f3f3; border-radius: 50%; border-top: 2px solid var(--primary); width: 16px; height: 16px; animation: spin 1s linear infinite; display: inline-block; }
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<svg style="display: none;">
|
|
<symbol id="i-home" viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></symbol>
|
|
<symbol id="i-list" viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></symbol>
|
|
<symbol id="i-risk" viewBox="0 0 24 24"><path d="M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z"/></symbol>
|
|
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.04.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.57 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.08-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></symbol>
|
|
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></symbol>
|
|
<symbol id="i-check" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></symbol>
|
|
</svg>
|
|
|
|
<div class="app-container">
|
|
<header class="header" role="banner">
|
|
<div class="brand">
|
|
<svg class="icon" style="width:24px; height:24px;" aria-hidden="true"><use xlink:href="#i-check"></use></svg>
|
|
GlobalGuard <span>Audit V9.4</span>
|
|
</div>
|
|
<nav class="nav-top" aria-label="Main navigation">
|
|
<button onclick="app.nav('dashboard')" class="active" aria-current="page">Workspace</button>
|
|
<button onclick="app.nav('orders')">Order Queue (200+)</button>
|
|
<button onclick="app.nav('settings')">Settings</button>
|
|
</nav>
|
|
<div class="user-area">
|
|
<div style="text-align: right; font-size:12px;">
|
|
<div>Auditor_4421</div>
|
|
<div style="color:rgba(255,255,255,0.6)">East China Audit Group</div>
|
|
</div>
|
|
<div class="avatar" aria-label="User avatar">A</div>
|
|
<button class="btn" style="background:rgba(255,255,255,0.1); padding:5px 10px;" onclick="app.showLogout()">Logout</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="main-body">
|
|
<aside class="sidebar" role="navigation" aria-label="Sidebar navigation">
|
|
<nav class="menu-group">
|
|
<h2 class="menu-header">Core Operations</h2>
|
|
<button class="menu-item active" onclick="app.nav('dashboard')" id="menu-dashboard" role="menuitem" aria-current="page">
|
|
<svg class="icon" aria-hidden="true"><use xlink:href="#i-home"></use></svg> Dashboard Overview
|
|
</button>
|
|
<button class="menu-item" onclick="app.nav('orders')" id="menu-orders" role="menuitem">
|
|
<svg class="icon" aria-hidden="true"><use xlink:href="#i-list"></use></svg> Audit Queue
|
|
<span class="badge" id="sidebar-count" aria-label="Pending orders count">--</span>
|
|
</button>
|
|
</nav>
|
|
<nav class="menu-group">
|
|
<h2 class="menu-header">Tools</h2>
|
|
<button class="menu-item" onclick="app.nav('settings')" id="menu-settings" role="menuitem">
|
|
<svg class="icon" aria-hidden="true"><use xlink:href="#i-settings"></use></svg> System Settings
|
|
</button>
|
|
</nav>
|
|
<div style="margin-top:auto; padding:20px;">
|
|
<div style="background:var(--bg-body); padding:10px; border-radius:4px; font-size:11px; color:var(--text-muted);" role="status" aria-live="polite">
|
|
<strong>Server Status:</strong><br>
|
|
<span style="color:var(--success)">●</span> Live Data Stream<br>
|
|
<span style="color:var(--success)">●</span> Database (OK)
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="content">
|
|
|
|
<div id="view-dashboard" class="view-panel active">
|
|
<h2 style="margin-bottom:20px;">Today's Data Overview (Live)</h2>
|
|
|
|
<div class="stat-grid" role="region" aria-label="Today's statistics">
|
|
<article class="stat-box">
|
|
<span class="stat-label">Pending Orders</span>
|
|
<span class="stat-num" id="dash-pending" role="status" aria-live="polite">--</span>
|
|
<span class="stat-trend trend-down">Action Required</span>
|
|
</article>
|
|
<article class="stat-box">
|
|
<span class="stat-label">High Risk Intercepts</span>
|
|
<span class="stat-num" style="color:var(--danger)" id="dash-risk" role="status" aria-live="polite">--</span>
|
|
<span class="stat-trend">Ratio <span id="dash-risk-rate" aria-label="Risk ratio percentage">--%</span></span>
|
|
</article>
|
|
<article class="stat-box">
|
|
<span class="stat-label">Total Amount (CNY)</span>
|
|
<span class="stat-num" id="dash-amount" role="status" aria-live="polite">--</span>
|
|
<span class="stat-trend trend-up">vs Yesterday ↑ 23%</span>
|
|
</article>
|
|
<article class="stat-box">
|
|
<span class="stat-label">My Performance (Today)</span>
|
|
<span class="stat-num" style="color:var(--success)">42</span>
|
|
<span class="stat-trend">KPI Achievement 80%</span>
|
|
</article>
|
|
</div>
|
|
|
|
<section class="card" role="region" aria-labelledby="announcements-title">
|
|
<div class="card-header">
|
|
<h3 id="announcements-title" class="card-title" style="margin:0;">System Announcements</h3>
|
|
<span class="tag tag-urgent" aria-label="New announcements">New</span>
|
|
</div>
|
|
<div style="font-size:13px; color:var(--text-muted); line-height:1.6;">
|
|
<p><strong>[Security Alert] Notification on Risk Upgrade for Graphics Card Proxy Buying</strong></p>
|
|
<p>Recently detected bulk ordering behavior targeting RTX 5090 series graphics cards. Auditors please focus on "fuzzy matching of same shipping address" and "large payments from new accounts". High-risk orders should be transferred to Level 2 Audit.</p>
|
|
<hr style="border:0; border-top:1px dashed var(--border); margin:10px 0;">
|
|
<p><strong>[System Update] V9.4 Patch Notes</strong></p>
|
|
<p>Integrated mock data generation engine, demo environment now supports 200+ concurrent order displays.</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div id="view-orders" class="view-panel">
|
|
<h2 style="margin-bottom:20px;">Manual Audit Queue</h2>
|
|
|
|
<fieldset class="filter-panel" aria-label="Order filters">
|
|
<legend style="display:none;">Filter orders</legend>
|
|
<div class="form-group">
|
|
<label for="search-keyword">Keywords (Order/User/Product)</label>
|
|
<input type="text" class="input-control" id="search-keyword" placeholder="Enter keywords..." aria-describedby="keyword-help">
|
|
<span id="keyword-help" style="display:none;">Search by order ID, user name, or product name</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="search-risk">Risk Level</label>
|
|
<select class="input-control" id="search-risk" aria-label="Filter by risk level">
|
|
<option value="all">All Levels</option>
|
|
<option value="high">High (High Risk)</option>
|
|
<option value="medium">Medium (Medium Risk)</option>
|
|
<option value="low">Low (Low Risk)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="search-status">Order Status</label>
|
|
<select class="input-control" id="search-status" aria-label="Filter by order status">
|
|
<option value="pending">Pending Audit</option>
|
|
<option value="all_history">All History</option>
|
|
</select>
|
|
</div>
|
|
<div style="margin-left:auto; display:flex; gap:10px;">
|
|
<button class="btn btn-secondary" onclick="app.resetFilter()" aria-label="Reset all filters">Reset</button>
|
|
<button class="btn btn-primary" onclick="app.applyFilter()" aria-label="Apply filters and search">
|
|
<svg class="icon" aria-hidden="true"><use xlink:href="#i-list"></use></svg> Search
|
|
</button>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div class="order-table-wrapper" role="region" aria-label="Orders table">
|
|
<table class="order-table" role="table" aria-label="Audit queue orders">
|
|
<thead role="rowgroup">
|
|
<tr role="row">
|
|
<th role="columnheader" style="width:40px;"><input type="checkbox" id="check-all" onclick="app.toggleSelectAll()" aria-label="Select all orders"></th>
|
|
<th role="columnheader">Order Info</th>
|
|
<th role="columnheader">User Profile</th>
|
|
<th role="columnheader">Product Overview</th>
|
|
<th role="columnheader">Amount</th>
|
|
<th role="columnheader">Risk Score</th>
|
|
<th role="columnheader">Status</th>
|
|
<th role="columnheader" style="text-align:right;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="table-body" role="rowgroup">
|
|
</tbody>
|
|
</table>
|
|
<div id="empty-state" style="padding:40px; text-align:center; color:var(--text-muted); display:none;" role="status" aria-live="polite">
|
|
No orders found matching your criteria
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:15px; display:flex; gap:10px; align-items:center;">
|
|
<button class="btn btn-primary" onclick="app.batchAction('approve')" aria-label="Batch approve selected orders">Batch Approve</button>
|
|
<button class="btn btn-danger" onclick="app.batchAction('reject')" aria-label="Batch reject selected orders">Batch Reject</button>
|
|
<span style="margin-left:auto; font-size:12px; color:var(--text-muted);" role="status" aria-live="polite">
|
|
Showing <strong id="current-count">0</strong> results / Total <span id="total-db-count">0</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="view-settings" class="view-panel">
|
|
<h2>System Preferences</h2>
|
|
<section class="card" style="margin-top:20px; max-width:600px;" role="region" aria-labelledby="settings-title">
|
|
<h3 id="settings-title" style="display:none;">Settings</h3>
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
|
<div>
|
|
<h4 style="margin:0 0 5px 0;">Dark Mode</h4>
|
|
<div style="font-size:12px; color:var(--text-muted);">Switch interface to dark theme, suitable for night work.</div>
|
|
</div>
|
|
<label class="switch" for="theme-toggle">
|
|
<input type="checkbox" id="theme-toggle" onchange="app.toggleTheme()" aria-label="Toggle dark mode">
|
|
<span class="slider" aria-hidden="true"></span>
|
|
</label>
|
|
</div>
|
|
<hr style="border:0; border-top:1px solid var(--border); margin:15px 0;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
<div>
|
|
<h4 style="margin:0 0 5px 0;">Auto-Refresh List</h4>
|
|
<div style="font-size:12px; color:var(--text-muted);">Automatically fetch new orders every 60 seconds.</div>
|
|
</div>
|
|
<label class="switch" for="auto-refresh-toggle">
|
|
<input type="checkbox" id="auto-refresh-toggle" checked aria-label="Toggle auto-refresh">
|
|
<span class="slider" aria-hidden="true"></span>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<div class="drawer-backdrop" id="drawer-backdrop" onclick="app.closeDrawer()" role="presentation" aria-hidden="true"></div>
|
|
<aside class="drawer" id="drawer-panel" role="complementary" aria-label="Order details panel" aria-modal="true">
|
|
<div class="drawer-header">
|
|
<h3 style="margin:0;">Order Details <span id="d-id" style="font-weight:400; color:var(--text-muted);"></span></h3>
|
|
<button class="btn btn-secondary" style="padding:4px 8px;" onclick="app.closeDrawer()" aria-label="Close order details panel">×</button>
|
|
</div>
|
|
<div class="drawer-body" id="d-content" role="region" aria-label="Order information">
|
|
</div>
|
|
<div class="drawer-footer" id="d-footer">
|
|
<button class="btn btn-danger" onclick="app.rejectCurrent()" aria-label="Reject this order">Reject Order</button>
|
|
<button class="btn btn-primary" onclick="app.approveCurrent()" aria-label="Approve this order">Approve Order</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="modal-overlay" id="modal-reject" role="presentation" aria-hidden="true">
|
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="reject-title">
|
|
<div class="modal-body">
|
|
<h3 id="reject-title" style="margin-top:0;">Confirm Reject?</h3>
|
|
<p style="color:var(--text-muted); font-size:13px; margin-bottom:15px;">Please select a reason for rejection. The system will notify the user.</p>
|
|
<label for="reject-reason" style="display:block; margin-bottom:8px; font-weight:600; font-size:13px;">Rejection Reason</label>
|
|
<select class="input-control" id="reject-reason" style="width:100%; margin-bottom:10px;" aria-label="Select rejection reason">
|
|
<option value="风险拦截-疑似盗刷">Risk Intercept - Suspected Fraud</option>
|
|
<option value="信息不全-地址模糊">Incomplete Info - Vague Address</option>
|
|
<option value="限购限制-超出数量">Purchase Limit - Exceeded Quantity</option>
|
|
<option value="其他原因">Other Reason</option>
|
|
</select>
|
|
<label for="reject-note" style="display:block; margin-bottom:8px; font-weight:600; font-size:13px;">Additional Notes</label>
|
|
<textarea class="input-control" id="reject-note" style="width:100%; height:80px;" placeholder="Notes (Optional)..." aria-label="Additional rejection notes"></textarea>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button onclick="app.closeModal('modal-reject')" aria-label="Cancel rejection">Cancel</button>
|
|
<button class="confirm" onclick="app.confirmReject()" aria-label="Confirm order rejection">Confirm Reject</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay" id="modal-logout" role="presentation" aria-hidden="true">
|
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="logout-title">
|
|
<div class="modal-body">
|
|
<h3 id="logout-title">System Logout</h3>
|
|
<p style="color:var(--text-muted);">Are you sure you want to log out? Unsaved changes will be lost.</p>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button onclick="app.closeModal('modal-logout')" aria-label="Cancel logout">Cancel</button>
|
|
<button class="confirm" onclick="location.reload()" aria-label="Confirm logout">Confirm Logout</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-container" id="toast-root" role="region" aria-live="polite" aria-atomic="true" aria-label="Notifications"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const app = {
|
|
// Data Center
|
|
data: {
|
|
orders: [], // Store all orders
|
|
currentId: null, // Currently viewed/operated ID
|
|
selectedIds: new Set(),
|
|
filter: {
|
|
keyword: '',
|
|
risk: 'all',
|
|
status: 'pending'
|
|
}
|
|
},
|
|
|
|
// Initialization
|
|
init() {
|
|
// 1. Keep core "Story" data (V9.2)
|
|
const storyOrders = [
|
|
{ id: '20251221-A001', user: 'user_9921', userScore: 780, items: [{name: 'RTX 5090 Graphics Card', qty: 2, price: 17499}], total: 34999.00, address: 'Zhongguancun Software Park, Haidian District, Beijing...', date: '2025-12-21 14:30', risk: 'low', score: 12, status: 'pending', tags: ['urgent'], logs: 'Payment fingerprint match. IP location consistent.' },
|
|
{ id: '20251221-A002', user: 'temp_user_001', userScore: null, items: [{name: 'Game Card Top-up (Virtual)', qty: 50, price: 100}], total: 5000.00, address: 'Virtual Delivery (Auto)', date: '2025-12-21 14:31', risk: 'high', score: 92, status: 'pending', tags: ['risk'], logs: 'Warning: This IP failed 5 order attempts today. Interception recommended.' },
|
|
{ id: '20251221-A003', user: 'loyal_cust_88', userScore: 900, items: [{name: 'Aptamil Infant Formula Stage 3', qty: 6, price: 200}], total: 1200.00, address: 'Lujiazui, Pudong New Area, Shanghai...', date: '2025-12-21 14:32', risk: 'low', score: 5, status: 'pending', tags: [], logs: 'History match. Routine repurchase.' },
|
|
{ id: '20251221-A004', user: 'company_buyer_22', userScore: 850, items: [{name: 'Ergonomic Office Chair', qty: 50, price: 300}], total: 15000.00, address: 'Science Park, Nanshan District, Shenzhen...', date: '2025-12-21 14:35', risk: 'medium', score: 45, status: 'pending', tags: ['large'], logs: 'First large purchase. Phone verification of business qualifications required.' },
|
|
{ id: '20251221-A007', user: 'unknown_proxy', userScore: 120, items: [{name: 'Amazon Gift Card $100', qty: 10, price: 7100}], total: 7100.00, address: 'Email Delivery', date: '2025-12-21 14:40', risk: 'high', score: 98, status: 'pending', tags: ['risk'], logs: 'High-risk proxy IP detected. Immediate block recommended.' }
|
|
];
|
|
|
|
// 2. Hybrid Data Gen: Auto-generate 200 simulation records
|
|
const randomData = this.generateBulkData(200);
|
|
|
|
this.data.orders = [...storyOrders, ...randomData];
|
|
|
|
// Render Initial View
|
|
this.renderTable();
|
|
this.updateDashboard();
|
|
|
|
console.log(`System Initialized: Loaded ${this.data.orders.length} orders.`);
|
|
this.showToast(`System ready, loaded ${this.data.orders.length} records`, 'success');
|
|
},
|
|
|
|
// --- Data Gen Engine (Adapted V9.2) ---
|
|
generateBulkData(count) {
|
|
const products = [
|
|
{n: 'iPhone 16 Pro Max', p: 9999}, {n: 'AirPods Pro 3', p: 1899}, {n: 'Dyson Hair Dryer', p: 2999},
|
|
{n: 'Sony WH-1000XM5', p: 2499}, {n: 'Nike Dunk Low', p: 899}, {n: 'Logitech MX Master 3S', p: 799},
|
|
{n: 'Starbucks Gift Card', p: 200}, {n: 'Switch OLED (JP Ver)', p: 2100}, {n: 'La Mer Cream', p: 3500}
|
|
];
|
|
const users = ['alex', 'bill', 'candy', 'david', 'echo', 'frank', 'grace', 'helen', 'ivy', 'jack'];
|
|
const cities = ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Hangzhou', 'Chengdu', 'Wuhan', 'Nanjing'];
|
|
const res = [];
|
|
|
|
for(let i=0; i<count; i++) {
|
|
const prod = products[Math.floor(Math.random() * products.length)];
|
|
const qty = Math.floor(Math.random() * 3) + 1;
|
|
const score = Math.floor(Math.random() * 100);
|
|
|
|
// Determine risk level
|
|
let risk = 'low';
|
|
if(score > 60) risk = 'medium';
|
|
if(score > 85) risk = 'high';
|
|
|
|
// Random status (80% pending)
|
|
const status = Math.random() > 0.8 ? (Math.random()>0.5 ? 'approved' : 'rejected') : 'pending';
|
|
|
|
// Random tags
|
|
const tags = [];
|
|
if(risk === 'high') tags.push('risk');
|
|
if(prod.p * qty > 10000) tags.push('large');
|
|
if(Math.random() > 0.9) tags.push('vip');
|
|
|
|
res.push({
|
|
id: `20251222-R${(1000+i).toString()}`,
|
|
user: `${users[Math.floor(Math.random()*users.length)]}_${Math.floor(Math.random()*999)}`,
|
|
userScore: Math.floor(Math.random() * 500) + 300,
|
|
items: [{ name: prod.n, qty: qty, price: prod.p }],
|
|
total: prod.p * qty,
|
|
address: `${cities[Math.floor(Math.random()*cities.length)]} City...`,
|
|
date: `2025-12-22 14:${Math.floor(Math.random()*60).toString().padStart(2,'0')}`,
|
|
risk: risk,
|
|
score: score,
|
|
status: status,
|
|
tags: tags,
|
|
logs: 'System generated mock log record.'
|
|
});
|
|
}
|
|
return res;
|
|
},
|
|
|
|
// --- Navigation & View ---
|
|
nav(viewId) {
|
|
document.querySelectorAll('.view-panel').forEach(el => el.classList.remove('active'));
|
|
document.getElementById(`view-${viewId}`).classList.add('active');
|
|
|
|
document.querySelectorAll('.menu-item').forEach(el => el.classList.remove('active'));
|
|
const menuId = `menu-${viewId}`;
|
|
const menuEl = document.getElementById(menuId);
|
|
if(menuEl) menuEl.classList.add('active');
|
|
|
|
document.querySelectorAll('.nav-top button').forEach(el => el.classList.remove('active'));
|
|
if(viewId === 'orders') document.querySelector('.nav-top button:nth-child(2)').classList.add('active');
|
|
if(viewId === 'dashboard') document.querySelector('.nav-top button:nth-child(1)').classList.add('active');
|
|
if(viewId === 'settings') document.querySelector('.nav-top button:nth-child(3)').classList.add('active');
|
|
},
|
|
|
|
toggleTheme() {
|
|
const isDark = document.getElementById('theme-toggle').checked;
|
|
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
|
},
|
|
|
|
// --- Core Business Logic: Filter & Render ---
|
|
applyFilter() {
|
|
const btn = event.target.closest('button');
|
|
if(btn) {
|
|
const originalHtml = btn.innerHTML;
|
|
btn.innerHTML = '<span class="loader"></span> Searching...';
|
|
btn.disabled = true;
|
|
setTimeout(() => {
|
|
this._executeFilter();
|
|
this.showToast('Filters updated');
|
|
btn.innerHTML = originalHtml;
|
|
btn.disabled = false;
|
|
}, 300);
|
|
} else {
|
|
this._executeFilter();
|
|
}
|
|
},
|
|
|
|
_executeFilter() {
|
|
this.data.filter.keyword = document.getElementById('search-keyword').value.toLowerCase();
|
|
this.data.filter.risk = document.getElementById('search-risk').value;
|
|
this.data.filter.status = document.getElementById('search-status').value;
|
|
this.renderTable();
|
|
},
|
|
|
|
resetFilter() {
|
|
document.getElementById('search-keyword').value = '';
|
|
document.getElementById('search-risk').value = 'all';
|
|
document.getElementById('search-status').value = 'pending';
|
|
this.applyFilter();
|
|
},
|
|
|
|
renderTable() {
|
|
const tbody = document.getElementById('table-body');
|
|
tbody.innerHTML = '';
|
|
|
|
const filtered = this.data.orders.filter(order => {
|
|
const matchKw = !this.data.filter.keyword ||
|
|
order.id.toLowerCase().includes(this.data.filter.keyword) ||
|
|
order.user.toLowerCase().includes(this.data.filter.keyword) ||
|
|
order.items.some(i => i.name.toLowerCase().includes(this.data.filter.keyword));
|
|
|
|
const matchRisk = this.data.filter.risk === 'all' || order.risk === this.data.filter.risk;
|
|
const matchStatus = this.data.filter.status === 'all_history' ? true : order.status === this.data.filter.status;
|
|
return matchKw && matchRisk && matchStatus;
|
|
});
|
|
|
|
// Limit render count for performance (if > 500)
|
|
const displayList = filtered.slice(0, 500);
|
|
|
|
// Update counts
|
|
const pendingCount = this.data.orders.filter(o => o.status === 'pending').length;
|
|
document.getElementById('sidebar-count').innerText = pendingCount;
|
|
document.getElementById('empty-state').style.display = displayList.length ? 'none' : 'block';
|
|
document.getElementById('current-count').innerText = displayList.length;
|
|
document.getElementById('total-db-count').innerText = this.data.orders.length;
|
|
|
|
// Generate HTML
|
|
let htmlBuffer = '';
|
|
displayList.forEach(order => {
|
|
let riskColor = '#10b981';
|
|
if(order.risk === 'medium') riskColor = '#f59e0b';
|
|
if(order.risk === 'high') riskColor = '#ef4444';
|
|
|
|
let statusBadge = `<span class="status-dot status-${order.status}"></span>Pending`;
|
|
if(order.status === 'approved') statusBadge = `<span class="status-dot status-approved"></span>Approved`;
|
|
if(order.status === 'rejected') statusBadge = `<span class="status-dot status-rejected"></span>Rejected`;
|
|
|
|
const itemSummary = `${order.items[0].name} ${order.items.length > 1 ? `and ${order.items.length} more` : ''}`;
|
|
|
|
const tagsHtml = order.tags.map(t => {
|
|
if(t === 'urgent') return `<span class="tag tag-urgent">Urgent</span>`;
|
|
if(t === 'large') return `<span class="tag tag-large">Large</span>`;
|
|
if(t === 'risk') return `<span class="tag tag-risk">Risk</span>`;
|
|
if(t === 'vip') return `<span class="tag tag-vip">VIP</span>`;
|
|
return '';
|
|
}).join('');
|
|
|
|
htmlBuffer += `
|
|
<tr role="row">
|
|
<td role="cell"><input type="checkbox" class="row-check" value="${order.id}" onchange="app.toggleSelect('${order.id}')" aria-label="Select order ${order.id}" ${this.data.selectedIds.has(order.id) ? 'checked' : ''} ${order.status !== 'pending' ? 'disabled' : ''}></td>
|
|
<td role="cell">
|
|
<div style="font-weight:600; font-family:'Consolas', monospace;">${order.id}</div>
|
|
<div style="font-size:12px; color:var(--text-muted);">${order.date}</div>
|
|
<div style="margin-top:4px;">${tagsHtml}</div>
|
|
</td>
|
|
<td role="cell">
|
|
<div>${order.user}</div>
|
|
<div style="font-size:11px; color:var(--text-muted);">Credit Score: ${order.userScore || 'N/A'}</div>
|
|
</td>
|
|
<td role="cell"><div title="${itemSummary}">${itemSummary}</div></td>
|
|
<td role="cell" style="font-weight:600; color:var(--text-main);">¥ ${order.total.toLocaleString()}</td>
|
|
<td role="cell">
|
|
<div style="font-weight:bold; color:${riskColor};">${order.score} / 100</div>
|
|
<div class="risk-bar-container" role="progressbar" aria-valuenow="${order.score}" aria-valuemin="0" aria-valuemax="100" aria-label="Risk score progress">
|
|
<div class="risk-bar-fill" style="width: ${order.score}%; background: ${riskColor};"></div>
|
|
</div>
|
|
</td>
|
|
<td role="cell">${statusBadge}</td>
|
|
<td role="cell" style="text-align:right;">
|
|
<button class="btn btn-secondary" style="padding:4px 8px;" onclick="app.openDrawer('${order.id}')" aria-label="View details for order ${order.id}">
|
|
<svg class="icon" aria-hidden="true" style="margin:0;"><use xlink:href="#i-eye"></use></svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
tbody.innerHTML = htmlBuffer;
|
|
|
|
document.getElementById('check-all').checked = false;
|
|
},
|
|
|
|
updateDashboard() {
|
|
const pending = this.data.orders.filter(o => o.status === 'pending');
|
|
const highRisk = this.data.orders.filter(o => o.risk === 'high' && o.status === 'pending');
|
|
const totalAmt = pending.reduce((sum, o) => sum + o.total, 0);
|
|
|
|
document.getElementById('dash-pending').innerText = pending.length;
|
|
document.getElementById('dash-risk').innerText = highRisk.length;
|
|
document.getElementById('dash-risk-rate').innerText = ((highRisk.length / (pending.length || 1)) * 100).toFixed(1) + '%';
|
|
document.getElementById('dash-amount').innerText = totalAmt.toLocaleString();
|
|
},
|
|
|
|
// --- Drawer Logic ---
|
|
openDrawer(id) {
|
|
this.data.currentId = id;
|
|
const order = this.data.orders.find(o => o.id === id);
|
|
if(!order) return;
|
|
|
|
document.getElementById('d-id').innerText = `#${order.id}`;
|
|
const content = document.getElementById('d-content');
|
|
|
|
let itemsHtml = order.items.map(item => `
|
|
<div style="display:flex; justify-content:space-between; border-bottom:1px dashed #eee; padding:8px 0;" role="row">
|
|
<span role="cell">${item.name} x ${item.qty}</span>
|
|
<span role="cell" style="font-weight:600;">¥ ${(item.price * item.qty).toLocaleString()}</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
content.innerHTML = `
|
|
<section class="detail-section">
|
|
<h4>Risk Control Decision Engine</h4>
|
|
<div style="background:${order.risk === 'high' ? '#fff5f5' : '#f0fdf4'}; padding:10px; border-radius:4px; border:1px solid ${order.risk === 'high' ? '#feb2b2' : '#bbf7d0'};" role="status" aria-live="polite">
|
|
<div style="font-weight:bold; color:${order.risk === 'high' ? '#c53030' : '#22543d'};">
|
|
Composite Risk Score: ${order.score} (${order.risk.toUpperCase()})
|
|
</div>
|
|
<p style="margin:5px 0 0 0; font-size:12px; color:var(--text-muted);">${order.logs}</p>
|
|
</div>
|
|
</section>
|
|
<section class="detail-section">
|
|
<h4>Shipping Info</h4>
|
|
<div class="info-grid">
|
|
<span class="info-label">Consignee:</span> <span class="info-val">${order.user}</span>
|
|
<span class="info-label">Address:</span> <span class="info-val" style="grid-column:span 2">${order.address}</span>
|
|
</div>
|
|
</section>
|
|
<section class="detail-section">
|
|
<h4>Item List</h4>
|
|
<div role="table" aria-label="Order items">
|
|
${itemsHtml}
|
|
</div>
|
|
<div style="text-align:right; margin-top:10px; font-size:16px; font-weight:700;">
|
|
Total: ¥ ${order.total.toLocaleString()}
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
const footer = document.getElementById('d-footer');
|
|
footer.style.display = order.status !== 'pending' ? 'none' : 'flex';
|
|
|
|
document.getElementById('drawer-backdrop').classList.add('open');
|
|
document.getElementById('drawer-panel').classList.add('open');
|
|
},
|
|
|
|
closeDrawer() {
|
|
document.getElementById('drawer-backdrop').classList.remove('open');
|
|
document.getElementById('drawer-panel').classList.remove('open');
|
|
this.data.currentId = null;
|
|
},
|
|
|
|
// --- Action Logic ---
|
|
approveCurrent() {
|
|
const id = this.data.currentId;
|
|
const order = this.data.orders.find(o => o.id === id);
|
|
if(order && order.status === 'pending') {
|
|
order.status = 'approved';
|
|
this.showToast(`Order ${id} approved`, 'success');
|
|
this.refreshAll();
|
|
this.closeDrawer();
|
|
}
|
|
},
|
|
|
|
rejectCurrent() {
|
|
document.getElementById('modal-reject').style.display = 'flex';
|
|
setTimeout(() => document.getElementById('modal-reject').querySelector('.modal').classList.add('open'), 10);
|
|
},
|
|
|
|
confirmReject() {
|
|
const id = this.data.currentId;
|
|
const reason = document.getElementById('reject-reason').value;
|
|
|
|
if (id === 'batch') {
|
|
let count = 0;
|
|
this.data.selectedIds.forEach(sid => {
|
|
const o = this.data.orders.find(x => x.id === sid);
|
|
if(o && o.status === 'pending') {
|
|
o.status = 'rejected';
|
|
count++;
|
|
}
|
|
});
|
|
this.data.selectedIds.clear();
|
|
this.showToast(`Batch rejected ${count} orders`, 'success');
|
|
} else {
|
|
const order = this.data.orders.find(o => o.id === id);
|
|
if(order) {
|
|
order.status = 'rejected';
|
|
order.logs += ` [Manual Rejection: ${reason}]`;
|
|
}
|
|
this.showToast(`Order ${id} rejected`, 'success');
|
|
}
|
|
|
|
this.closeModal('modal-reject');
|
|
this.refreshAll();
|
|
this.closeDrawer();
|
|
},
|
|
|
|
toggleSelectAll() {
|
|
const checkAll = document.getElementById('check-all').checked;
|
|
const checkboxes = document.querySelectorAll('.row-check:not(:disabled)');
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = checkAll;
|
|
this.toggleSelect(cb.value);
|
|
});
|
|
},
|
|
|
|
toggleSelect(id) {
|
|
if(this.data.selectedIds.has(id)) this.data.selectedIds.delete(id);
|
|
else this.data.selectedIds.add(id);
|
|
},
|
|
|
|
batchAction(type) {
|
|
if(this.data.selectedIds.size === 0) return this.showToast('Please select orders first', 'error');
|
|
|
|
if(type === 'approve') {
|
|
let count = 0;
|
|
this.data.selectedIds.forEach(id => {
|
|
const o = this.data.orders.find(x => x.id === id);
|
|
if(o && o.status === 'pending') {
|
|
o.status = 'approved';
|
|
count++;
|
|
}
|
|
});
|
|
this.showToast(`Successfully batch approved ${count} orders`, 'success');
|
|
this.data.selectedIds.clear();
|
|
this.refreshAll();
|
|
} else if (type === 'reject') {
|
|
this.data.currentId = 'batch';
|
|
this.rejectCurrent();
|
|
}
|
|
},
|
|
|
|
refreshAll() {
|
|
this.renderTable();
|
|
this.updateDashboard();
|
|
},
|
|
|
|
showLogout() {
|
|
document.getElementById('modal-logout').style.display = 'flex';
|
|
setTimeout(() => document.getElementById('modal-logout').querySelector('.modal').classList.add('open'), 10);
|
|
},
|
|
|
|
closeModal(id) {
|
|
document.getElementById(id).querySelector('.modal').classList.remove('open');
|
|
setTimeout(() => document.getElementById(id).style.display = 'none', 200);
|
|
},
|
|
|
|
showToast(msg, type='info') {
|
|
const container = document.getElementById('toast-root');
|
|
const el = document.createElement('div');
|
|
el.className = `toast ${type}`;
|
|
el.setAttribute('role', 'status');
|
|
el.setAttribute('aria-live', 'polite');
|
|
el.setAttribute('aria-atomic', 'true');
|
|
el.innerHTML = `<span>${msg}</span>`;
|
|
container.appendChild(el);
|
|
setTimeout(() => el.classList.add('show'), 10);
|
|
setTimeout(() => {
|
|
el.classList.remove('show');
|
|
setTimeout(() => el.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('DOMContentLoaded', () => app.init());
|
|
</script>
|
|
</body>
|
|
</html> |