Files
safelist-plus/index.html
Julian Freeman da1106543d 更改标题
2025-12-21 22:20:56 -04:00

318 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);
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);
-webkit-font-smoothing: antialiased;
transition: background-color 0.3s, color 0.3s;
}
[v-cloak] { display: none; }
.container { max-width: 1280px; margin: 0 auto; padding: 2rem 1rem; }
/* Header */
.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);
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);
}
.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;
}
/* Table */
.table-container {
background: var(--card-bg);
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
margin-bottom: 1.5rem;
}
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; }
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;
}
.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); margin-right: 4px; }
.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;
}
.btn-link:hover { background: var(--accent-blue); color: white; }
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
padding: 1rem 0;
}
.page-info { font-size: 0.9rem; color: var(--text-muted); font-weight: 600; }
.page-btn {
padding: 0.5rem 1.25rem;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
cursor: pointer;
color: var(--text-main);
font-weight: 600;
transition: 0.2s;
}
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.page-btn:hover:not(:disabled) { border-color: var(--accent-blue); color: var(--accent-blue); }
@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>SECURITY</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 paginatedItems" :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>
<span v-for="p in splitPlatform(item.platform)" class="plat-tag">{{ p }}</span>
</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 v-else-if="filteredItems.length === 0" style="padding: 5rem; text-align: center; color: var(--text-muted);">No results.</div>
</div>
<div class="pagination" v-if="totalPages > 1">
<button class="page-btn" @click="currentPage--" :disabled="currentPage === 1">{{ ui[lang].prev }}</button>
<div class="page-info">{{ ui[lang].page }} {{ currentPage }} / {{ totalPages }}</div>
<button class="page-btn" @click="currentPage++" :disabled="currentPage === totalPages">{{ ui[lang].next }}</button>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, watch } = 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);
// Pagination state
const currentPage = ref(1);
const pageSize = 20;
const defaultIcon = 'https://cdn-icons-png.flaticon.com/512/684/684831.png';
const ui = {
cn: { subtitle: '安全软件列表Plus', searchPlaceholder: '搜索...', colName: '项目描述', colType: '类型', colPlat: '平台', colSafety: '安全性', colDate: '最近更新', colAction: '操作', linkBtn: '访问官网', syncing: '同步云端数据中...', prev: '上一页', next: '下一页', page: '页码' },
en: { subtitle: 'Security Software List', searchPlaceholder: 'Search...', colName: 'Description', colType: 'Type', colPlat: 'Platform', colSafety: 'Security', colDate: 'Updated', colAction: 'Action', linkBtn: 'VISIT', syncing: 'Syncing...', prev: 'Prev', next: 'Next', page: 'Page' },
es: { subtitle: 'Lista de software de seguridad', searchPlaceholder: 'Buscar...', colName: 'Descripción', colType: 'Tipo', colPlat: 'Plataforma', colSafety: 'Seguridad', colDate: 'Fecha', colAction: 'Acción', linkBtn: 'VISITAR', syncing: 'Sincronizando...', prev: 'Anterior', next: 'Siguiente', page: 'Página' }
};
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();
};
// 1. 全局筛选结果
const filteredItems = computed(() => {
const main = rawData.value.main || [];
const transMap = {};
(rawData.value[lang.value] || []).forEach(row => { transMap[row["序号"]] = row; });
const results = main.map(row => {
const id = row["序号"];
const t = transMap[id] || {};
const name = t[lang.value + "名字"] || row["名字"];
const note = t[lang.value + "备注"] || row["备注"];
return { id, name, note, 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) || (item.url && item.url.toLowerCase().includes(s));
})
.sort((a, b) => new Date(b.date) - new Date(a.date));
return results;
});
// 2. 当前分页结果
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * pageSize;
const end = start + pageSize;
return filteredItems.value.slice(start, end);
});
const totalPages = computed(() => Math.ceil(filteredItems.value.length / pageSize));
// Watchers: Reset to page 1 on search or lang change
watch([searchTerm, ], () => { currentPage.value = 1; });
const translateStatic = (val) => (staticMap[val] && staticMap[val][lang.value]) || val;
const splitPlatform = (str) => str ? str.split(/[,]/).map(s => s.trim()) : [];
const getSafetyColor = (val) => val === '安全' ? '#10b981' : (val === '不安全' ? '#ef4444' : '#f59e0b');
const getTypeColor = (type) => type === '网站' ? '#06b6d4' : (type === '软件' ? '#6366f1' : '#f59e0b');
const formatDate = (dateStr) => dateStr ? new Date(dateStr).toISOString().split('T')[0].replace(/-/g, '/') : '--';
const toggleDark = () => {
isDark.value = !isDark.value;
document.body.classList.toggle('dark-mode', isDark.value);
};
onMounted(fetchData);
return { lang, searchTerm, loading, isDark, filteredItems, paginatedItems, totalPages, currentPage, ui, defaultIcon, toggleDark, translateStatic, getSafetyColor, getTypeColor, formatDate, splitPlatform };
}
}).mount('#app');
</script>
</body>
</html>