Files
safelist-plus/index.html
Julian Freeman 45de3d9b3f init 2
2025-12-21 21:53:29 -04:00

421 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<base target="_top">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
/* --- 基础变量 --- */
:root {
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border-color: #e2e8f0;
--accent-blue: #2563eb;
--card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--color-safe: #10b981;
--color-unsafe: #ef4444;
--color-unknown: #f59e0b;
font-size: 16px;
}
/* --- 暗黑模式变量 --- */
body.dark-mode {
--bg-color: #0f172a;
--card-bg: #1e293b;
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--border-color: #334155;
}
/* --- 全局样式 --- */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s, color 0.3s;
}
[v-cloak] { display: none; }
.container {
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1rem;
}
/* --- 头部 --- */
.header {
background: var(--card-bg);
padding: 1.5rem 2rem;
border-radius: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
}
.brand h1 {
font-size: 1.5rem;
font-weight: 800;
color: var(--accent-blue);
font-style: italic;
}
.brand p {
font-size: 0.8rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
}
input[type="text"] {
padding: 0.6rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-main);
font-size: 0.9rem;
width: 260px;
outline: none;
}
select {
padding: 0.6rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-main);
cursor: pointer;
}
.theme-btn {
background: var(--bg-color);
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
}
/* --- 表格样式 --- */
.table-container {
background: var(--card-bg);
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th {
background: rgba(0, 0, 0, 0.02);
padding: 1rem 1.5rem;
text-align: left;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
td {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
font-size: 0.95rem;
}
tr:hover { background: rgba(37, 99, 235, 0.02); }
.item-cell { display: flex; align-items: center; gap: 1rem; }
.icon-box {
position: relative;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.75rem;
border: 1px solid var(--border-color);
}
.status-dot {
position: absolute;
bottom: -2px;
right: -2px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--card-bg);
}
.item-desc h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.15rem;
}
.item-desc p {
font-size: 0.85rem;
color: var(--text-muted);
}
.type-label {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid currentColor;
background: rgba(0,0,0,0.03);
}
.safety-text {
font-weight: 400;
display: flex;
align-items: center;
gap: 0.4rem;
}
.plat-tag {
font-size: 0.75rem;
background: var(--bg-color);
padding: 0.2rem 0.4rem;
border-radius: 0.3rem;
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.date-val {
font-family: monospace;
font-size: 0.85rem;
color: var(--text-muted);
}
.btn-link {
display: inline-block;
padding: 0.5rem 1rem;
background: #eff6ff;
color: var(--accent-blue);
text-decoration: none;
border-radius: 0.5rem;
font-size: 0.85rem;
font-weight: 700;
transition: 0.2s;
}
.btn-link:hover {
background: var(--accent-blue);
color: white;
}
@media (max-width: 1024px) {
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
}
</style>
</head>
<body>
<div id="app" v-cloak class="container">
<header class="header">
<div class="brand">
<h1>DIRECTORY</h1>
<p>{{ ui[lang].subtitle }}</p>
</div>
<div class="controls">
<input v-model="searchTerm" type="text" :placeholder="ui[lang].searchPlaceholder">
<select v-model="lang">
<option value="cn">中文</option>
<option value="en">ENGLISH</option>
<option value="es">ESPAÑOL</option>
</select>
<button @click="toggleDark" class="theme-btn">{{ isDark ? '☀️' : '🌙' }}</button>
</div>
</header>
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 35%;">{{ ui[lang].colName }}</th>
<th style="width: 12%;">{{ ui[lang].colType }}</th>
<th style="width: 15%;">{{ ui[lang].colPlat }}</th>
<th style="width: 12%;">{{ ui[lang].colSafety }}</th>
<th style="width: 13%;">{{ ui[lang].colDate }}</th>
<th style="width: 13%; text-align: right;">{{ ui[lang].colAction }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item.id">
<td>
<div class="item-cell">
<div class="icon-box">
<img :src="item.icon" @error="e => e.target.src = defaultIcon" class="icon-img">
<div class="status-dot" :style="{ background: getSafetyColor(item.safety) }"></div>
</div>
<div class="item-desc">
<h3>{{ item.name }}</h3>
<p>{{ item.note }}</p>
</div>
</div>
</td>
<td>
<span class="type-label" :style="{ color: getTypeColor(item.type) }">
{{ translateStatic(item.type) }}
</span>
</td>
<td>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<span v-for="p in splitPlatform(item.platform)" class="plat-tag">{{ p }}</span>
</div>
</td>
<td>
<div class="safety-text" :style="{ color: getSafetyColor(item.safety) }">
● {{ translateStatic(item.safety) }}
</div>
</td>
<td>
<span class="date-val">{{ formatDate(item.date) }}</span>
</td>
<td style="text-align: right;">
<a v-if="item.url" :href="item.url" target="_blank" class="btn-link">{{ ui[lang].linkBtn }}</a>
</td>
</tr>
</tbody>
</table>
<div v-if="loading" style="padding: 5rem; text-align: center; color: var(--text-muted);">
{{ ui[lang].syncing }}
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
createApp({
setup() {
const rawData = ref({ main: [], cn: [], en: [], es: [] });
const lang = ref('cn');
const searchTerm = ref('');
const loading = ref(true);
const isDark = ref(false);
const defaultIcon = 'https://cdn-icons-png.flaticon.com/512/684/684831.png';
const ui = {
cn: { subtitle: '全球数据资源索引', searchPlaceholder: '搜索...', colName: '项目描述', colType: '类型', colPlat: '平台', colSafety: '安全性', colDate: '最近更新', colAction: '操作', linkBtn: '访问官网', syncing: '同步云端数据中...' },
en: { subtitle: 'GLOBAL INDEX', searchPlaceholder: 'Search...', colName: 'Description', colType: 'Type', colPlat: 'Platform', colSafety: 'Security', colDate: 'Updated', colAction: 'Action', linkBtn: 'VISIT', syncing: 'Syncing...' },
es: { subtitle: 'ÍNDICE GLOBAL', searchPlaceholder: 'Buscar...', colName: 'Descripción', colType: 'Tipo', colPlat: 'Plataforma', colSafety: 'Seguridad', colDate: 'Fecha', colAction: 'Acción', linkBtn: 'VISITAR', syncing: 'Sincronizando...' }
};
const staticMap = {
'网站': { cn: '网站', en: 'Website', es: 'Sitio Web' },
'软件': { cn: '软件', en: 'Software', es: 'Software' },
'浏览器扩展': { cn: '浏览器扩展', en: 'Extension', es: 'Extensión' },
'安全': { cn: '安全', en: 'Safe', es: 'Seguro' },
'不安全': { cn: '不安全', en: 'Unsafe', es: 'Inseguro' },
'未知': { cn: '未知', en: 'Unknown', es: 'Desconocido' }
};
const fetchData = () => {
google.script.run
.withSuccessHandler(data => {
rawData.value = data;
loading.value = false;
})
.getAllData();
};
const filteredItems = computed(() => {
const main = rawData.value.main || [];
const transMap = {};
(rawData.value[lang.value] || []).forEach(row => { transMap[row["序号"]] = row; });
return main.map(row => {
const id = row["序号"];
const t = transMap[id] || {};
return {
id,
name: t[lang.value + "名字"] || row["名字"],
note: t[lang.value + "备注"] || row["备注"],
icon: row["图标链接"] || defaultIcon,
type: row["类型"],
platform: String(row["平台"] || ""),
url: row["官网链接"],
safety: row["安全性"],
date: row["更新日期"]
};
})
.filter(item => {
const s = searchTerm.value.toLowerCase();
return !s || item.name.toLowerCase().includes(s) || item.note.toLowerCase().includes(s);
})
.sort((a, b) => new Date(b.date) - new Date(a.date));
});
const translateStatic = (val) => (staticMap[val] && staticMap[val][lang.value]) || val;
const splitPlatform = (str) => str ? str.split(/[,]/).map(s => s.trim()) : [];
const getSafetyColor = (val) => {
if (val === '安全') return '#10b981';
if (val === '不安全') return '#ef4444';
return '#f59e0b';
};
const getTypeColor = (type) => {
if (type === '网站') return '#06b6d4';
if (type === '软件') return '#6366f1';
return '#f59e0b';
};
const formatDate = (dateStr) => {
if (!dateStr) return '--';
const d = new Date(dateStr);
return d.toISOString().split('T')[0].replace(/-/g, '/');
};
// 修复切换逻辑:通过 JavaScript 直接操作 body 类名
const toggleDark = () => {
isDark.value = !isDark.value;
if (isDark.value) {
document.body.classList.add('dark-mode');
} else {
document.body.classList.remove('dark-mode');
}
};
onMounted(fetchData);
return { lang, searchTerm, loading, isDark, filteredItems, ui, defaultIcon, toggleDark, translateStatic, getSafetyColor, getTypeColor, formatDate, splitPlatform };
}
}).mount('#app');
</script>
</body>
</html>