Files
safelist-plus/index.html
Julian Freeman bd4e9033e4 init
2025-12-21 21:30:51 -04:00

243 lines
11 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.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' }
</script>
<style>
[v-cloak] { display: none; }
.row-hover:hover { background-color: rgba(59, 130, 246, 0.04); }
.dark .row-hover:hover { background-color: rgba(59, 130, 246, 0.08); }
</style>
</head>
<body class="bg-gray-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-300 min-h-screen">
<div id="app" v-cloak class="max-w-7xl mx-auto px-4 py-6">
<header class="flex flex-col md:flex-row justify-between items-center mb-8 gap-4 bg-white dark:bg-slate-900 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-800">
<div class="text-center md:text-left">
<h1 class="text-2xl font-black bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent italic">DIRECTORY</h1>
<p class="text-[10px] uppercase tracking-[0.2em] text-slate-400 font-bold mt-1">{{ ui[lang].subtitle }}</p>
</div>
<div class="flex flex-wrap items-center gap-3 w-full md:w-auto">
<div class="relative flex-grow md:flex-grow-0">
<input
v-model="searchTerm"
type="text"
:placeholder="ui[lang].searchPlaceholder"
class="w-full md:w-72 pl-10 pr-4 py-2.5 bg-slate-100 dark:bg-slate-800 border-none rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
>
<span class="absolute left-3 top-3 opacity-30">🔍</span>
</div>
<select v-model="lang" class="bg-slate-100 dark:bg-slate-800 border-none rounded-2xl p-2.5 text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer">
<option value="cn">中文</option>
<option value="en">ENGLISH</option>
<option value="es">ESPAÑOL</option>
</select>
<button @click="toggleDark" class="p-2.5 bg-slate-100 dark:bg-slate-800 rounded-2xl hover:bg-slate-200 dark:hover:bg-slate-700 transition-all shadow-inner">
<span v-if="isDark">☀️</span><span v-else>🌙</span>
</button>
</div>
</header>
<div class="mb-4 px-4 flex justify-between items-center">
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">
{{ ui[lang].showing }}: <span class="text-blue-500">{{ filteredItems.length }}</span>
</div>
<div v-if="loading" class="text-xs text-blue-500 animate-pulse font-bold">{{ ui[lang].syncing }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-[2rem] shadow-2xl shadow-slate-200/50 dark:shadow-none overflow-hidden border border-slate-100 dark:border-slate-800">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead class="bg-slate-50/50 dark:bg-slate-800/30 text-slate-400 text-[10px] uppercase font-black tracking-tighter">
<tr>
<th class="px-8 py-5">{{ ui[lang].colName }}</th>
<th class="px-6 py-5">{{ ui[lang].colType }}</th>
<th class="px-6 py-5 text-center">Platforms</th>
<th class="px-6 py-5">{{ ui[lang].colSafety }}</th>
<th class="px-6 py-5 text-right">{{ ui[lang].colDate }}</th>
<th class="px-8 py-5 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800">
<tr v-for="item in filteredItems" :key="item.id" class="row-hover transition-all group">
<td class="px-8 py-6">
<div class="flex items-center gap-5">
<div class="relative shrink-0">
<img :src="item.icon" @error="e => e.target.src = defaultIcon" class="w-12 h-12 rounded-2xl shadow-md object-cover bg-white ring-1 ring-slate-100 dark:ring-slate-800">
<div class="absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-white dark:border-slate-900" :class="getSafetyDot(item.safety)"></div>
</div>
<div class="max-w-[180px] md:max-w-xs">
<div class="font-bold text-slate-800 dark:text-slate-100 group-hover:text-blue-600 transition-colors">{{ item.name }}</div>
<div class="text-[11px] text-slate-400 mt-1 leading-relaxed line-clamp-2">{{ item.note || '--' }}</div>
</div>
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<span :class="getTypeStyle(item.type)" class="px-3 py-1 rounded-full text-[10px] font-black uppercase italic tracking-widest border border-current opacity-80">
{{ translateStatic(item.type) }}
</span>
</td>
<td class="px-6 py-6">
<div class="flex flex-wrap justify-center gap-1.5">
<span v-for="p in splitPlatform(item.platform)" class="px-2 py-0.5 bg-slate-50 dark:bg-slate-800/50 text-slate-500 dark:text-slate-400 rounded-md text-[9px] font-bold border border-slate-100 dark:border-slate-700">
{{ p }}
</span>
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div :class="getSafetyTextStyle(item.safety)" class="text-[11px] font-black uppercase flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-current"></span>
{{ translateStatic(item.safety) }}
</div>
</td>
<td class="px-6 py-6 text-right text-[10px] text-slate-400 font-mono">
{{ formatDate(item.date) }}
</td>
<td class="px-8 py-6 text-right">
<a v-if="item.url" :href="item.url" target="_blank" class="inline-flex items-center px-4 py-1.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-xl text-[10px] font-black hover:bg-blue-600 hover:text-white transition-all">
{{ ui[lang].linkBtn }}
</a>
<span v-else class="text-slate-200 dark:text-slate-800"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="loading" class="py-32 flex flex-col items-center">
<div class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin mb-4"></div>
<p class="text-xs font-bold text-slate-400 tracking-widest">{{ ui[lang].syncing }}</p>
</div>
<div v-else-if="filteredItems.length === 0" class="py-32 text-center">
<div class="text-slate-200 dark:text-slate-800 text-6xl font-black mb-4">NULL</div>
<p class="text-slate-400 text-xs font-bold">{{ ui[lang].noResults }}</p>
</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';
// 核心 UI 提示信息的翻译
const ui = {
cn: { subtitle: '全球数据资源索引', searchPlaceholder: '搜索名称、备注或链接...', colName: '项目描述', colType: '资源类型', colSafety: '安全性', colDate: '最近更新', linkBtn: '访问官网', showing: '收录项目', syncing: '同步云端数据中', noResults: '未发现匹配项' },
en: { subtitle: 'GLOBAL RESOURCE INDEX', searchPlaceholder: 'Search name, note, url...', colName: 'Description', colType: 'Type', colSafety: 'Security', colDate: 'Updated', linkBtn: 'VISIT', showing: 'Total Items', syncing: 'SYNCING CLOUD DATA', noResults: 'NO DATA FOUND' },
es: { subtitle: 'ÍNDICE DE RECURSOS GLOBALES', searchPlaceholder: 'Buscar nombre, nota, url...', colName: 'Descripción', colType: 'Tipo', colSafety: 'Seguridad', colDate: 'Actualizado', linkBtn: 'VISITAR', showing: 'Proyectos', syncing: 'SINCRONIZANDO DATOS', noResults: 'SIN RESULTADOS' }
};
// 类型和安全性的硬编码翻译
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 transTable = rawData.value[lang.value] || [];
const transMap = {};
transTable.forEach(row => { transMap[row["序号"]] = row; });
return main.map(row => {
const id = row["序号"];
const t = transMap[id] || {};
return {
id: 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();
if (!s) return true;
return 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));
});
const translateStatic = (val) => (staticMap[val] && staticMap[val][lang.value]) || val;
const splitPlatform = (str) => str ? str.split(/[,]/).map(s => s.trim()) : [];
const getTypeStyle = (type) => {
if (type === '网站') return 'text-cyan-500 border-cyan-500/30 bg-cyan-500/5';
if (type === '软件') return 'text-indigo-500 border-indigo-500/30 bg-indigo-500/5';
if (type === '浏览器扩展') return 'text-amber-500 border-amber-500/30 bg-amber-500/5';
return 'text-slate-400 border-slate-300';
};
const getSafetyDot = (val) => {
if (val === '安全') return 'bg-emerald-500';
if (val === '不安全') return 'bg-rose-500';
return 'bg-amber-400';
};
const getSafetyTextStyle = (val) => {
if (val === '安全') return 'text-emerald-500';
if (val === '不安全') return 'text-rose-500';
return 'text-amber-500';
};
const formatDate = (dateStr) => {
if (!dateStr) return 'N/A';
const d = new Date(dateStr);
return d.toLocaleDateString(lang.value === 'cn' ? 'zh-CN' : 'en-US', {
year: 'numeric', month: '2-digit', day: '2-digit'
});
};
const toggleDark = () => {
isDark.value = !isDark.value;
document.documentElement.classList.toggle('dark');
};
onMounted(fetchData);
return {
lang, searchTerm, loading, isDark, filteredItems, ui, defaultIcon,
toggleDark, translateStatic, getTypeStyle, getSafetyDot, getSafetyTextStyle, formatDate, splitPlatform
};
}
}).mount('#app');
</script>
</body>
</html>