Add files via upload
This commit is contained in:
+104
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<router-view v-if="isAdminPage || isLoginPage" />
|
||||
<template v-else>
|
||||
<div class="background"></div>
|
||||
<Home />
|
||||
<footer>
|
||||
<span>© {{ footerYearText }} Made in <a href="/" target="_blank">{{ userName }}</a></span>
|
||||
<a v-if="icpNumber && icpNumber !== '暂未填写' && icpNumber.trim() !== ''" href="https://beian.miit.gov.cn/" target="_blank">{{ icpNumber }}</a>
|
||||
<a v-if="policenumber && policenumber !== '暂未填写' && policenumber.trim() !== ''" :href="`https://beian.mps.gov.cn/#/query/webSearch?police=${policenumber}`" target="_blank" class="police_link">
|
||||
<span class="police_img"></span> {{ policenumber }}
|
||||
</a>
|
||||
</footer>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Home from './components/Home.vue';
|
||||
import { getSiteConfig } from './api';
|
||||
|
||||
const route = useRoute();
|
||||
const userName = ref(import.meta.env.VITE_APP_USER_NAME || '用户');
|
||||
const icpNumber = ref(import.meta.env.VITE_APP_ICP_NUMBER || '');
|
||||
const policenumber = ref(import.meta.env.VITE_APP_POLICE_NUMBER || '');
|
||||
|
||||
// 底部年份(支持起止年份)
|
||||
const footerYearStart = ref('');
|
||||
const footerYearEnd = ref('');
|
||||
|
||||
const footerYearText = computed(() => {
|
||||
const currentYear = new Date().getFullYear().toString();
|
||||
const start = (footerYearStart.value || '').trim();
|
||||
const end = (footerYearEnd.value || '').trim();
|
||||
|
||||
// 后台未配置时,默认显示当前年份
|
||||
if (!start && !end) {
|
||||
return currentYear;
|
||||
}
|
||||
|
||||
// 只配置了起始年份
|
||||
if (start && !end) {
|
||||
return start;
|
||||
}
|
||||
|
||||
// 起止年份都有且不同
|
||||
if (start && end && start !== end) {
|
||||
return `${start}~${end}`;
|
||||
}
|
||||
|
||||
// 其他情况(例如起止相同),只显示一个年份
|
||||
return start || end || currentYear;
|
||||
});
|
||||
|
||||
const isAdminPage = computed(() => route.path === '/admin');
|
||||
const isLoginPage = computed(() => route.path === '/login');
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await getSiteConfig();
|
||||
// 同步所有配置信息
|
||||
if (res.data.siteName) {
|
||||
// 站点名称可用于页面显示
|
||||
}
|
||||
if (res.data.siteURL) {
|
||||
// 站点URL可用于链接等
|
||||
}
|
||||
if (res.data.siteDescription) {
|
||||
// 站点描述已通过frontendConfig.js同步到meta标签
|
||||
}
|
||||
if (res.data.siteKeywords) {
|
||||
// 站点关键词已通过frontendConfig.js同步到meta标签
|
||||
}
|
||||
// 只有非空且不是"暂未填写"时才显示备案信息
|
||||
if (res.data.icpNumber && res.data.icpNumber !== '暂未填写' && res.data.icpNumber.trim() !== '') {
|
||||
icpNumber.value = res.data.icpNumber;
|
||||
} else {
|
||||
icpNumber.value = '';
|
||||
}
|
||||
if (res.data.policeNumber && res.data.policeNumber !== '暂未填写' && res.data.policeNumber.trim() !== '') {
|
||||
policenumber.value = res.data.policeNumber;
|
||||
} else {
|
||||
policenumber.value = '';
|
||||
}
|
||||
if (res.data.userName) userName.value = res.data.userName;
|
||||
|
||||
// 底部年份配置
|
||||
if (typeof res.data.footerYearStart === 'string') {
|
||||
footerYearStart.value = res.data.footerYearStart;
|
||||
}
|
||||
if (typeof res.data.footerYearEnd === 'string') {
|
||||
footerYearEnd.value = res.data.footerYearEnd;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAdminPage.value && !isLoginPage.value) {
|
||||
loadConfig();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器 - 添加JWT token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器 - 处理错误
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
// 可以在这里跳转到登录页
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 公开API
|
||||
export const getSites = () => api.get('/sites')
|
||||
export const getContacts = () => api.get('/contacts')
|
||||
export const getSiteConfig = () => api.get('/config')
|
||||
export const getFrontendConfig = () => api.get('/frontend-config')
|
||||
|
||||
// 认证API
|
||||
export const login = (username, password) =>
|
||||
api.post('/auth/login', { username, password })
|
||||
|
||||
// 管理API
|
||||
export const adminAPI = {
|
||||
// 站点管理
|
||||
getSites: () => api.get('/admin/sites'),
|
||||
createSite: (data) => api.post('/admin/sites', data),
|
||||
updateSite: (id, data) => api.put(`/admin/sites/${id}`, data),
|
||||
deleteSite: (id) => api.delete(`/admin/sites/${id}`),
|
||||
|
||||
// 联系方式管理
|
||||
getContacts: () => api.get('/admin/contacts'),
|
||||
createContact: (data) => api.post('/admin/contacts', data),
|
||||
updateContact: (id, data) => api.put(`/admin/contacts/${id}`, data),
|
||||
deleteContact: (id) => api.delete(`/admin/contacts/${id}`),
|
||||
|
||||
// 站点配置管理
|
||||
getSiteConfig: () => api.get('/admin/config'),
|
||||
updateSiteConfig: (data) => api.put('/admin/config', data),
|
||||
|
||||
// 文件上传
|
||||
uploadFile: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post('/admin/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 统计API
|
||||
getStats: () => api.get('/admin/stats'),
|
||||
getChartData: (period) => api.get(`/admin/charts?period=${period}`),
|
||||
getRecentVisits: (limit = 5) => api.get(`/admin/recent-visits?limit=${limit}`),
|
||||
|
||||
// 热重载通知
|
||||
notifyConfigUpdate: () => api.post('/admin/notify-update'),
|
||||
|
||||
// 用户管理
|
||||
changePassword: (oldPassword, newPassword) =>
|
||||
api.put('/admin/change-password', { oldPassword, newPassword }),
|
||||
|
||||
// 日志API
|
||||
getBackendLogs: (lines = 100) => api.get(`/admin/logs?lines=${lines}`),
|
||||
|
||||
// 登录历史API
|
||||
getLoginHistory: (limit = 20) => api.get(`/admin/login-history?limit=${limit}`),
|
||||
|
||||
// 轮换文本配置API
|
||||
getRotatingTexts: () => api.get('/api/rotating-texts'),
|
||||
updateRotatingTexts: (texts) => api.put('/admin/rotating-texts', { texts }),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="about-page" @click.stop>
|
||||
<div class="about-modal">
|
||||
<div class="about-modal-content">
|
||||
<div class="tech-stack">
|
||||
<h3>使用的技术栈</h3>
|
||||
<ul class="tech-list">
|
||||
<li v-for="tech in techStack" :key="tech.name" :class="['tech-item', tech.name.toLowerCase()]">
|
||||
<i :class="tech.icon"></i>
|
||||
{{ tech.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="github-info">
|
||||
<h3>开源地址</h3>
|
||||
<div class="github-links">
|
||||
<a href="https://github.com/JLinMr/Home-Vue" target="_blank" class="github-link">
|
||||
<i class="fab fa-github"></i>
|
||||
<div class="link-content">
|
||||
<span class="link-title">静态原项目</span>
|
||||
<span class="link-desc">Home-Vue</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://github.com/JLinMr/Home-Vue-go" target="_blank" class="github-link">
|
||||
<i class="fab fa-github"></i>
|
||||
<div class="link-content">
|
||||
<span class="link-title">动态现项目</span>
|
||||
<span class="link-desc">Home-Vue-go</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeModal" class="close-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const techStack = [
|
||||
// 前端技术栈
|
||||
{ name: 'Vue3', icon: 'fab fa-vuejs' },
|
||||
{ name: 'Vite', icon: 'fas fa-bolt' },
|
||||
{ name: 'CSS3', icon: 'fab fa-css3-alt' },
|
||||
{ name: 'HTML5', icon: 'fab fa-html5' },
|
||||
{ name: 'JavaScript', icon: 'fab fa-js' },
|
||||
// 后端技术栈
|
||||
{ name: 'Go', icon: 'fab fa-golang' },
|
||||
{ name: 'Gin', icon: 'fas fa-server' },
|
||||
{ name: 'SQLite', icon: 'fas fa-database' },
|
||||
{ name: 'Ent', icon: 'fas fa-code-branch' },
|
||||
{ name: 'JWT', icon: 'fas fa-key' }
|
||||
];
|
||||
const closeModal = () => emit('close');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about-page {
|
||||
width: 500px;
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: rgba(var(--background-color-rgb), 0.9);
|
||||
padding: 40px;
|
||||
border-radius: var(--border-radius);
|
||||
position: relative;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 20px;
|
||||
width: 90%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.tech-stack ul,
|
||||
.tech-list {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tech-stack li,
|
||||
.tech-item {
|
||||
padding: 10px 15px;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tech-stack li:hover,
|
||||
.tech-item:hover {
|
||||
background-color: var(--hover-other-color);
|
||||
color: var(--hover-link-color);
|
||||
}
|
||||
|
||||
.tech-stack li i,
|
||||
.tech-item i {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.tech-stack li,
|
||||
.tech-item {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.github-links {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background-color: #040404d0;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
flex: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: #1a1a1a;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.5em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.link-desc {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.github-links {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
color: #7f8c8d;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #c61b09;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.about-modal-content > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0.2s; }
|
||||
&:nth-child(2) { animation-delay: 0.3s; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<div class="user-profile-container">
|
||||
<div class="user-profile-image" v-motion-pop>
|
||||
<img :src="profileImage" alt="头像" @click.stop="toggleInfo">
|
||||
<span class="status-ball"></span>
|
||||
</div>
|
||||
<div class="user-name" v-motion-slide-left>
|
||||
<h1>Hi,</h1>
|
||||
<h1>I'm <span class="name-style">{{ userName }}</span></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p ref="descriptionElement"></p>
|
||||
</div>
|
||||
<div class="contact-section" v-motion-pop>
|
||||
<template v-for="contact in contacts" :key="contact.type">
|
||||
<a v-if="contact.url" :href="contact.url" target="_blank" class="contact-item" :style="{ '--hover-color': contact.hoverColor }">
|
||||
<i :class="contact.icon"></i>
|
||||
<span class="tooltip">{{ contact.type }}</span>
|
||||
</a>
|
||||
<span v-else @click="toggleQRCode(contact.qrCode)" class="contact-item" :style="{ '--hover-color': contact.hoverColor }">
|
||||
<i :class="contact.icon"></i>
|
||||
<span class="tooltip">{{ contact.type }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span class="contact-item" @click="toggleDarkMode" :style="{ '--hover-color': isDarkMode ? '#ffcc00' : '#666' }">
|
||||
<i :class="darkModeIconClass"></i>
|
||||
<span class="tooltip">{{ isDarkMode ? '浅色' : '深色' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Website />
|
||||
<!-- 使用v-if确保组件完全从DOM中移除,包括所有class -->
|
||||
<VisitTimer v-if="showVisitTimer" :key="showVisitTimer ? 'visit-timer-show' : 'visit-timer-hide'" />
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showAbout" class="overlay" @click="showAbout = false">
|
||||
<div class="modal-content">
|
||||
<AboutPage @close="showAbout = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showQR" class="overlay" @click="hideQRCode">
|
||||
<div class="modal-content">
|
||||
<img :src="qrCodeSrc" alt="QR Code" class="qr-image" @click.stop>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import { getContacts, getSiteConfig } from '../api';
|
||||
import api from '../api';
|
||||
import Website from './Website.vue';
|
||||
import AboutPage from './AboutPage.vue';
|
||||
import VisitTimer from './VisitTimer.vue';
|
||||
import Typed from 'typed.js';
|
||||
|
||||
const contacts = ref([]);
|
||||
const showQR = ref(false);
|
||||
const showAbout = ref(false);
|
||||
const qrCodeSrc = ref('');
|
||||
const profileImage = ref('');
|
||||
const userName = ref('');
|
||||
const siteConfig = ref({});
|
||||
const descriptionElement = ref(null);
|
||||
const showVisitTimer = ref(true);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [contactsRes, configRes] = await Promise.all([
|
||||
getContacts(),
|
||||
getSiteConfig(),
|
||||
]);
|
||||
contacts.value = contactsRes.data;
|
||||
siteConfig.value = configRes.data;
|
||||
userName.value = configRes.data.userName || import.meta.env.VITE_APP_USER_NAME || '用户';
|
||||
profileImage.value = configRes.data.profileImageURL || import.meta.env.VITE_APP_PROFILE_IMAGE_URL || '';
|
||||
// 确保正确读取 showVisitTimer,明确处理 false 值
|
||||
const timerValue = configRes.data.showVisitTimer;
|
||||
const newValue = timerValue !== undefined && timerValue !== null
|
||||
? Boolean(timerValue)
|
||||
: true;
|
||||
// 使用赋值触发响应式更新
|
||||
const oldValue = showVisitTimer.value;
|
||||
showVisitTimer.value = newValue;
|
||||
console.log('加载配置 - showVisitTimer:', {
|
||||
oldValue,
|
||||
newValue,
|
||||
rawValue: timerValue,
|
||||
type: typeof timerValue,
|
||||
isFalse: timerValue === false
|
||||
});
|
||||
|
||||
// 如果从true变为false,确保DOM被移除
|
||||
if (oldValue && !newValue) {
|
||||
await nextTick();
|
||||
const timerElements = document.querySelectorAll('.visit-timer-container, .visit-timer');
|
||||
if (timerElements.length > 0) {
|
||||
console.warn('检测到需要移除的visit-timer元素,数量:', timerElements.length);
|
||||
timerElements.forEach(el => {
|
||||
console.log('移除元素:', el);
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
// 如果API失败,使用环境变量或默认值
|
||||
userName.value = import.meta.env.VITE_APP_USER_NAME || '用户';
|
||||
profileImage.value = import.meta.env.VITE_APP_PROFILE_IMAGE_URL || '';
|
||||
showVisitTimer.value = true; // 默认显示
|
||||
}
|
||||
};
|
||||
|
||||
const predefinedDescriptions = ref([
|
||||
"你好鸭,欢迎来到我的主页!!",
|
||||
"随时可以联系我,期待与你交流。",
|
||||
"愿你历尽千帆,归来仍是少年。",
|
||||
"梦想还是要有的,万一实现了呢?",
|
||||
"I hope you have a happy day every day."
|
||||
]);
|
||||
|
||||
let typedInstance = null;
|
||||
|
||||
const loadRotatingTexts = async () => {
|
||||
try {
|
||||
const res = await api.get('/rotating-texts');
|
||||
if (res.data?.texts && res.data.texts.length > 0) {
|
||||
predefinedDescriptions.value = res.data.texts;
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('加载轮换文本失败,使用默认文本:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeTyped = () => {
|
||||
if (typedInstance) {
|
||||
typedInstance.destroy();
|
||||
}
|
||||
typedInstance = new Typed(descriptionElement.value, {
|
||||
strings: predefinedDescriptions.value,
|
||||
typeSpeed: 120,
|
||||
backSpeed: 80,
|
||||
showCursor: true,
|
||||
cursorChar: '|',
|
||||
loop: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 记录访问统计
|
||||
const trackVisit = async () => {
|
||||
try {
|
||||
const path = window.location.pathname;
|
||||
const referer = document.referrer || '';
|
||||
await api.post('/track-visit', {
|
||||
path: path,
|
||||
referer: referer,
|
||||
});
|
||||
} catch (error) {
|
||||
// 静默失败,不影响用户体验
|
||||
console.debug('访问统计记录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听配置更新消息
|
||||
let broadcastChannel = null
|
||||
if (window.BroadcastChannel) {
|
||||
broadcastChannel = new BroadcastChannel('config-update')
|
||||
broadcastChannel.onmessage = async (event) => {
|
||||
if (event.data.type === 'config-updated') {
|
||||
console.log('收到配置更新消息,重新加载数据...')
|
||||
const oldTimerValue = showVisitTimer.value
|
||||
await loadData()
|
||||
await loadRotatingTexts()
|
||||
if (typedInstance) {
|
||||
typedInstance.destroy()
|
||||
}
|
||||
initializeTyped()
|
||||
// 停留时间显示状态会在loadData中更新
|
||||
// 使用 nextTick 确保 DOM 更新
|
||||
await nextTick()
|
||||
console.log('配置更新后 - showVisitTimer:', showVisitTimer.value, '之前的值:', oldTimerValue, 'DOM已更新')
|
||||
|
||||
// 如果从true变为false,强制检查并移除残留的DOM元素
|
||||
if (oldTimerValue && !showVisitTimer.value) {
|
||||
console.log('showVisitTimer从true变为false,强制清理残留元素')
|
||||
const timerElements = document.querySelectorAll('.visit-timer-container, .visit-timer, [class*="visit-timer"]')
|
||||
if (timerElements.length > 0) {
|
||||
console.warn('发现残留的visit-timer相关元素,强制移除:', timerElements.length)
|
||||
timerElements.forEach(el => {
|
||||
console.log('移除残留元素:', el.className, el)
|
||||
el.remove()
|
||||
})
|
||||
}
|
||||
// 再次等待DOM更新
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听showVisitTimer变化,确保DOM正确更新
|
||||
watch(showVisitTimer, async (newValue, oldValue) => {
|
||||
console.log('showVisitTimer变化:', { oldValue, newValue })
|
||||
// 等待DOM更新完成
|
||||
await nextTick()
|
||||
// 如果设置为false,强制检查并移除任何残留的visit-timer元素
|
||||
if (!newValue) {
|
||||
const timerElements = document.querySelectorAll('.visit-timer-container, .visit-timer')
|
||||
if (timerElements.length > 0) {
|
||||
console.warn('发现残留的visit-timer元素,强制移除:', timerElements.length)
|
||||
timerElements.forEach(el => el.remove())
|
||||
}
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
await loadRotatingTexts();
|
||||
initializeTyped();
|
||||
trackVisit(); // 记录访问
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.close()
|
||||
}
|
||||
if (typedInstance) {
|
||||
typedInstance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQRCode = (qrCode) => {
|
||||
qrCodeSrc.value = qrCode || '';
|
||||
showQR.value = !showQR.value;
|
||||
};
|
||||
|
||||
const hideQRCode = () => {
|
||||
showQR.value = false;
|
||||
};
|
||||
|
||||
const toggleInfo = () => {
|
||||
showAbout.value = !showAbout.value;
|
||||
};
|
||||
|
||||
const isDarkMode = ref(false);
|
||||
const darkModeIconClass = ref('fas fa-moon');
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
isDarkMode.value = !isDarkMode.value;
|
||||
document.body.classList.toggle('dark-mode', isDarkMode.value);
|
||||
|
||||
localStorage.setItem('darkMode', isDarkMode.value);
|
||||
|
||||
darkModeIconClass.value = isDarkMode.value ? 'fas fa-sun' : 'fas fa-moon';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const savedDarkMode = localStorage.getItem('darkMode');
|
||||
if (savedDarkMode !== null) {
|
||||
isDarkMode.value = savedDarkMode === 'true';
|
||||
document.body.classList.toggle('dark-mode', isDarkMode.value);
|
||||
}
|
||||
|
||||
darkModeIconClass.value = isDarkMode.value ? 'fas fa-sun' : 'fas fa-moon';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
|
||||
.user-profile-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.user-profile-image {
|
||||
display: flex;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
padding: 5px;
|
||||
border: 3px solid var(--border-color);
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.status-ball {
|
||||
position: absolute;
|
||||
background: #00c800;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 20px;
|
||||
border: 3px solid #eee;
|
||||
bottom: 5px;
|
||||
right: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "在线中";
|
||||
color: #00c800;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out, color 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
width: 4.5em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
color: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
font-size: 1.3em;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.name-style {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
z-index: -1;
|
||||
content: "";
|
||||
background: #ffcc00ad;
|
||||
height: 30%;
|
||||
width: 110%;
|
||||
transition: height 0.3s ease-in-out;
|
||||
}
|
||||
&:hover::before {
|
||||
height: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 1.2rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '"';
|
||||
font-size: 1.5em;
|
||||
color: #999;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
.contact-item {
|
||||
color: var(--text-color);
|
||||
font-size: var(--icon-size);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease-in-out, color 0.3s ease-in-out;
|
||||
position: relative;
|
||||
|
||||
.fas.fa-moon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px) rotate(10deg);
|
||||
color: var(--hover-color);
|
||||
|
||||
.tooltip {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 10px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
background-color: rgba(var(--background-color-rgb), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease-out;
|
||||
|
||||
.modal-content {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.modal-content {
|
||||
transform: translateY(30px) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-to,
|
||||
.fade-leave-from {
|
||||
opacity: 1;
|
||||
|
||||
.modal-content {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 4px 8px var(--shadow-color);
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
&:hover {
|
||||
transform: scale(1.03) translateY(-5px);
|
||||
box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.content {
|
||||
gap: 15px;
|
||||
}
|
||||
.content .user-profile-container {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="icon-picker">
|
||||
<div class="icon-picker-header">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索图标..."
|
||||
class="icon-search"
|
||||
@input="filterIcons"
|
||||
/>
|
||||
</div>
|
||||
<div class="icon-picker-body">
|
||||
<div class="icon-categories">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.name"
|
||||
@click="activeCategory = category.name"
|
||||
:class="['category-btn', { active: activeCategory === category.name }]"
|
||||
>
|
||||
<i :class="category.icon"></i>
|
||||
<span>{{ category.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="icon-grid" ref="iconGrid">
|
||||
<div
|
||||
v-for="icon in filteredIcons"
|
||||
:key="icon"
|
||||
@click="selectIcon(icon)"
|
||||
:class="['icon-item', { active: modelValue === icon }]"
|
||||
:title="icon"
|
||||
>
|
||||
<i :class="icon"></i>
|
||||
<span class="icon-name">{{ getIconName(icon) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon-picker-footer" v-if="modelValue">
|
||||
<div class="selected-icon">
|
||||
<span>已选择:</span>
|
||||
<i :class="modelValue"></i>
|
||||
<code>{{ modelValue }}</code>
|
||||
</div>
|
||||
<div class="icon-picker-actions">
|
||||
<button @click="clearIcon" class="btn-clear">
|
||||
<i class="fas fa-times"></i>
|
||||
清除
|
||||
</button>
|
||||
<button @click="closePicker" class="btn-close">
|
||||
<i class="fas fa-check"></i>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeCategory = ref('all')
|
||||
|
||||
// Font Awesome 常用图标分类
|
||||
const categories = [
|
||||
{ name: 'all', label: '全部', icon: 'fas fa-th' },
|
||||
{ name: 'web', label: '网页', icon: 'fas fa-globe' },
|
||||
{ name: 'social', label: '社交', icon: 'fas fa-share-alt' },
|
||||
{ name: 'media', label: '媒体', icon: 'fas fa-photo-video' },
|
||||
{ name: 'business', label: '商业', icon: 'fas fa-briefcase' },
|
||||
{ name: 'tech', label: '技术', icon: 'fas fa-code' },
|
||||
{ name: 'other', label: '其他', icon: 'fas fa-ellipsis-h' },
|
||||
]
|
||||
|
||||
// 常用图标列表(按分类)
|
||||
const iconLibrary = {
|
||||
web: [
|
||||
'fas fa-home', 'fas fa-globe', 'fas fa-link', 'fas fa-external-link-alt',
|
||||
'fas fa-bookmark', 'fas fa-star', 'fas fa-heart', 'fas fa-thumbs-up',
|
||||
],
|
||||
social: [
|
||||
'fab fa-github', 'fab fa-twitter', 'fab fa-facebook', 'fab fa-instagram',
|
||||
'fab fa-linkedin', 'fab fa-youtube', 'fab fa-telegram', 'fab fa-discord',
|
||||
'fab fa-weixin', 'fab fa-qq', 'fab fa-weibo', 'fab fa-bilibili',
|
||||
],
|
||||
media: [
|
||||
'fas fa-image', 'fas fa-video', 'fas fa-music', 'fas fa-film',
|
||||
'fas fa-camera', 'fas fa-microphone', 'fas fa-headphones',
|
||||
],
|
||||
business: [
|
||||
'fas fa-briefcase', 'fas fa-building', 'fas fa-chart-line', 'fas fa-dollar-sign',
|
||||
'fas fa-shopping-cart', 'fas fa-credit-card', 'fas fa-handshake',
|
||||
],
|
||||
tech: [
|
||||
'fas fa-code', 'fas fa-terminal', 'fas fa-server', 'fas fa-database',
|
||||
'fas fa-cloud', 'fas fa-mobile-alt', 'fas fa-laptop', 'fas fa-keyboard',
|
||||
],
|
||||
other: [
|
||||
'fas fa-envelope', 'fas fa-phone', 'fas fa-map-marker-alt', 'fas fa-calendar',
|
||||
'fas fa-clock', 'fas fa-bell', 'fas fa-cog', 'fas fa-user', 'fas fa-users',
|
||||
],
|
||||
}
|
||||
|
||||
// 所有图标
|
||||
const allIcons = computed(() => {
|
||||
const icons = []
|
||||
Object.values(iconLibrary).forEach(categoryIcons => {
|
||||
icons.push(...categoryIcons)
|
||||
})
|
||||
return icons
|
||||
})
|
||||
|
||||
// 过滤后的图标
|
||||
const filteredIcons = computed(() => {
|
||||
let icons = activeCategory.value === 'all'
|
||||
? allIcons.value
|
||||
: iconLibrary[activeCategory.value] || []
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
icons = icons.filter(icon =>
|
||||
icon.toLowerCase().includes(query) ||
|
||||
getIconName(icon).toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return icons
|
||||
})
|
||||
|
||||
const getIconName = (icon) => {
|
||||
// 从 "fas fa-home" 提取 "home"
|
||||
const parts = icon.split(' ')
|
||||
return parts[parts.length - 1] || icon
|
||||
}
|
||||
|
||||
const selectIcon = (icon) => {
|
||||
emit('update:modelValue', icon)
|
||||
}
|
||||
|
||||
const clearIcon = () => {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
const closePicker = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const filterIcons = () => {
|
||||
// 搜索时自动切换到"全部"分类
|
||||
if (searchQuery.value.trim() && activeCategory.value !== 'all') {
|
||||
activeCategory.value = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal) {
|
||||
// 如果选择了图标,可以高亮显示
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
background: rgba(var(--background-color-rgb), 0.98);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-picker-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.icon-search {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--background-color-rgb), 0.6);
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-search:focus {
|
||||
outline: none;
|
||||
border-color: #007aff;
|
||||
background: rgba(var(--background-color-rgb), 0.8);
|
||||
}
|
||||
|
||||
.icon-picker-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.icon-categories {
|
||||
width: 180px;
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
/* 自定义滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--hover-link-color) rgba(var(--background-color-rgb), 0.3);
|
||||
}
|
||||
|
||||
.icon-categories::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.icon-categories::-webkit-scrollbar-track {
|
||||
background: rgba(var(--background-color-rgb), 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.icon-categories::-webkit-scrollbar-thumb {
|
||||
background: var(--hover-link-color);
|
||||
border-radius: 3px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-categories::-webkit-scrollbar-thumb:hover {
|
||||
background: #ffd700;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--background-color-rgb), 0.6);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
background: rgba(var(--background-color-rgb), 0.8);
|
||||
border-color: var(--hover-link-color);
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: var(--hover-link-color);
|
||||
color: #333;
|
||||
border-color: var(--hover-link-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
/* 自定义滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--hover-link-color) rgba(var(--background-color-rgb), 0.3);
|
||||
}
|
||||
|
||||
.icon-grid::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.icon-grid::-webkit-scrollbar-track {
|
||||
background: rgba(var(--background-color-rgb), 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.icon-grid::-webkit-scrollbar-thumb {
|
||||
background: var(--hover-link-color);
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: #ffd700;
|
||||
}
|
||||
|
||||
.icon-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 8px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--background-color-rgb), 0.6);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.icon-item:hover {
|
||||
background: rgba(var(--background-color-rgb), 0.8);
|
||||
border-color: var(--hover-link-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
}
|
||||
|
||||
.icon-item.active {
|
||||
background: var(--hover-link-color);
|
||||
border-color: var(--hover-link-color);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.icon-item i {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon-item.active i {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.icon-name {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.icon-picker-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: rgba(var(--background-color-rgb), 0.98);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selected-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.selected-icon i {
|
||||
font-size: 20px;
|
||||
color: var(--hover-link-color);
|
||||
}
|
||||
|
||||
.selected-icon code {
|
||||
background: rgba(var(--background-color-rgb), 0.8);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.icon-picker-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-clear,
|
||||
.btn-close {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--background-color-rgb), 0.8);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-color: #f44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: var(--hover-link-color);
|
||||
color: #333;
|
||||
border-color: var(--hover-link-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: #ffd700;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(255, 204, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.icon-picker-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.icon-categories {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<div class="icon-selector">
|
||||
<div class="icon-tabs">
|
||||
<button
|
||||
v-if="defaultIconPath"
|
||||
:class="['tab-btn', { active: iconMode === 'default' }]"
|
||||
@click="iconMode = 'default'"
|
||||
>
|
||||
默认图标
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-btn', { active: iconMode === 'upload' }]"
|
||||
@click="iconMode = 'upload'"
|
||||
>
|
||||
上传图标
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-btn', { active: iconMode === 'url' }]"
|
||||
@click="iconMode = 'url'"
|
||||
>
|
||||
URL图标
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 默认图标 -->
|
||||
<div v-if="iconMode === 'default' && defaultIconPath" class="icon-content">
|
||||
<div class="default-icon-preview">
|
||||
<img :src="defaultIconPath" alt="默认图标" class="icon-preview" />
|
||||
<p class="icon-hint">使用默认本地图标:{{ defaultIconPath }}</p>
|
||||
</div>
|
||||
<button @click="selectDefault" class="select-btn">使用默认图标</button>
|
||||
</div>
|
||||
|
||||
<!-- 上传图标 -->
|
||||
<div v-if="iconMode === 'upload'" class="icon-content">
|
||||
<div class="upload-area">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
@change="handleFileSelect"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
/>
|
||||
<div v-if="!uploadedIconUrl" class="upload-placeholder">
|
||||
<p>点击选择图标文件</p>
|
||||
<p class="hint">支持 jpg、png、jpeg、webp、avif、svg、ico 等格式</p>
|
||||
</div>
|
||||
<div v-else class="uploaded-preview">
|
||||
<img :src="uploadedIconUrl" alt="上传的图标" class="icon-preview" />
|
||||
<p class="icon-hint">已上传的图标</p>
|
||||
</div>
|
||||
<button @click="$refs.fileInput.click()" class="upload-btn">
|
||||
{{ uploadedIconUrl ? '重新选择' : '选择文件' }}
|
||||
</button>
|
||||
<div v-if="uploading" class="upload-status">上传中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL图标 -->
|
||||
<div v-if="iconMode === 'url'" class="icon-content">
|
||||
<div class="url-input-group">
|
||||
<label>图标URL</label>
|
||||
<input
|
||||
v-model="iconUrl"
|
||||
type="text"
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
class="url-input"
|
||||
@input="handleUrlInput"
|
||||
/>
|
||||
<small class="form-hint">支持重定向图片URL,格式:avif, png, jpg, jpeg, webp, svg, ico等</small>
|
||||
<div v-if="urlValidating" class="url-status">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>正在验证图片URL...</span>
|
||||
</div>
|
||||
<div v-if="urlError" class="url-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>{{ urlError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="iconUrl && !urlError && urlValidated" class="url-preview">
|
||||
<img :src="validatedUrl" alt="URL图标" class="icon-preview" @error="handleImageError" />
|
||||
<p class="icon-hint">URL图标预览(支持重定向)</p>
|
||||
</div>
|
||||
<button @click="selectUrl" class="select-btn" :disabled="!iconUrl || urlValidating || !!urlError">
|
||||
{{ urlValidating ? '验证中...' : '使用URL图标' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 当前选择的图标预览 -->
|
||||
<div v-if="currentIcon" class="current-icon">
|
||||
<p class="current-label">当前图标:</p>
|
||||
<img :src="currentIcon" alt="当前图标" class="icon-preview" @error="handleImageError" />
|
||||
<p class="icon-hint">{{ currentIcon }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { adminAPI } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultIconPath: {
|
||||
type: String,
|
||||
default: '/favicon.ico',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const iconMode = ref('default')
|
||||
const iconUrl = ref('')
|
||||
const uploadedIconUrl = ref('')
|
||||
const uploading = ref(false)
|
||||
const currentIcon = ref(props.modelValue || props.defaultIconPath)
|
||||
const urlValidating = ref(false)
|
||||
const urlValidated = ref(false)
|
||||
const urlError = ref('')
|
||||
const validatedUrl = ref('')
|
||||
|
||||
// 支持的图片格式
|
||||
const allowedImageFormats = ['avif', 'png', 'jpg', 'jpeg', 'webp', 'svg', 'ico', 'gif', 'bmp']
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
currentIcon.value = newVal
|
||||
// 根据值判断模式
|
||||
if (props.defaultIconPath && newVal === props.defaultIconPath) {
|
||||
iconMode.value = 'default'
|
||||
} else if (newVal.startsWith('http://') || newVal.startsWith('https://')) {
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = newVal
|
||||
} else if (newVal.startsWith('/uploads/')) {
|
||||
iconMode.value = 'upload'
|
||||
uploadedIconUrl.value = newVal
|
||||
} else {
|
||||
// 如果值不为空但不是已知格式,默认使用URL模式
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = newVal
|
||||
}
|
||||
} else {
|
||||
currentIcon.value = props.defaultIconPath || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// 检查文件类型,支持常见图标格式包括ico
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/ico', 'image/icon']
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||||
const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'avif', 'bmp']
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
|
||||
alert('不支持的文件格式,请上传 jpg、png、gif、webp、svg、ico 等格式的图片')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const res = await adminAPI.uploadFile(file)
|
||||
uploadedIconUrl.value = res.data.url
|
||||
currentIcon.value = res.data.url
|
||||
emit('update:modelValue', res.data.url)
|
||||
} catch (error) {
|
||||
alert('上传失败: ' + (error.response?.data?.error || error.message))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectDefault = () => {
|
||||
currentIcon.value = props.defaultIconPath
|
||||
emit('update:modelValue', props.defaultIconPath)
|
||||
}
|
||||
|
||||
// 验证图片URL(支持重定向)
|
||||
const validateImageUrl = async (url) => {
|
||||
if (!url) {
|
||||
urlError.value = ''
|
||||
urlValidated.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
urlValidating.value = true
|
||||
urlError.value = ''
|
||||
urlValidated.value = false
|
||||
|
||||
try {
|
||||
// 检查URL格式
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
urlError.value = 'URL必须以http://或https://开头'
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用fetch跟随重定向
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
mode: 'cors',
|
||||
redirect: 'follow'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
urlError.value = '无法访问该URL'
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取最终URL(可能经过重定向)
|
||||
const finalUrl = response.url || url
|
||||
|
||||
// 检查Content-Type
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const isImage = contentType.startsWith('image/')
|
||||
|
||||
// 或者检查URL扩展名
|
||||
const urlLower = finalUrl.toLowerCase()
|
||||
const hasImageExtension = allowedImageFormats.some(format =>
|
||||
urlLower.includes(`.${format}`) || urlLower.includes(`/${format}`)
|
||||
)
|
||||
|
||||
if (!isImage && !hasImageExtension) {
|
||||
urlError.value = 'URL指向的不是图片文件'
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用Image对象进一步验证(支持重定向)
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
validatedUrl.value = finalUrl
|
||||
urlValidated.value = true
|
||||
urlError.value = ''
|
||||
urlValidating.value = false
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
// 即使Image加载失败,如果Content-Type正确,也允许使用
|
||||
if (isImage || hasImageExtension) {
|
||||
validatedUrl.value = finalUrl
|
||||
urlValidated.value = true
|
||||
urlError.value = ''
|
||||
urlValidating.value = false
|
||||
resolve(true)
|
||||
} else {
|
||||
urlError.value = '无法加载图片,请检查URL是否正确'
|
||||
urlValidated.value = false
|
||||
urlValidating.value = false
|
||||
resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
img.src = finalUrl
|
||||
})
|
||||
} catch (error) {
|
||||
urlError.value = '验证失败: ' + (error.message || '网络错误')
|
||||
urlValidated.value = false
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理URL输入(防抖)
|
||||
let urlValidationTimer = null
|
||||
const handleUrlInput = () => {
|
||||
urlValidated.value = false
|
||||
urlError.value = ''
|
||||
|
||||
if (urlValidationTimer) {
|
||||
clearTimeout(urlValidationTimer)
|
||||
}
|
||||
|
||||
urlValidationTimer = setTimeout(() => {
|
||||
if (iconUrl.value) {
|
||||
validateImageUrl(iconUrl.value)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const selectUrl = async () => {
|
||||
if (iconUrl.value && !urlError.value) {
|
||||
// 如果还未验证,先验证
|
||||
if (!urlValidated.value) {
|
||||
const isValid = await validateImageUrl(iconUrl.value)
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
currentIcon.value = validatedUrl.value || iconUrl.value
|
||||
emit('update:modelValue', validatedUrl.value || iconUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
if (iconMode.value === 'url') {
|
||||
urlError.value = '无法加载图片,请检查URL是否正确'
|
||||
urlValidated.value = false
|
||||
}
|
||||
console.warn('无法加载图标URL:', iconUrl.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化时根据当前值设置模式
|
||||
if (props.modelValue) {
|
||||
if (props.defaultIconPath && props.modelValue === props.defaultIconPath) {
|
||||
iconMode.value = 'default'
|
||||
} else if (
|
||||
props.modelValue.startsWith('http://') ||
|
||||
props.modelValue.startsWith('https://')
|
||||
) {
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = props.modelValue
|
||||
} else if (props.modelValue.startsWith('/uploads/')) {
|
||||
iconMode.value = 'upload'
|
||||
uploadedIconUrl.value = props.modelValue
|
||||
} else {
|
||||
// 如果值不为空但不是已知格式,默认使用URL模式
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = props.modelValue
|
||||
}
|
||||
} else if (!props.defaultIconPath) {
|
||||
// 如果没有默认图标路径,默认使用上传模式
|
||||
iconMode.value = 'upload'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-selector {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background: rgba(var(--background-color-rgb), 0.3);
|
||||
}
|
||||
|
||||
.icon-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-bottom-color: #007aff;
|
||||
color: #007aff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.default-icon-preview,
|
||||
.uploaded-preview,
|
||||
.url-preview {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: rgba(var(--background-color-rgb), 0.5);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
margin: 0 auto 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
padding: 40px;
|
||||
background: rgba(var(--background-color-rgb), 0.5);
|
||||
border-radius: 8px;
|
||||
border: 2px dashed var(--border-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.upload-placeholder p {
|
||||
margin: 5px 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.upload-placeholder .hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-btn,
|
||||
.select-btn {
|
||||
padding: 10px 20px;
|
||||
background: #007aff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.upload-btn:hover,
|
||||
.select-btn:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.select-btn:disabled {
|
||||
background: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
margin-top: 10px;
|
||||
color: #007aff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.url-input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.url-input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.url-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--background-color-rgb), 0.5);
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.url-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: #007aff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.url-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: #f44336;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.current-icon {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.current-label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-background">
|
||||
<div class="floating-shapes">
|
||||
<div class="shape shape-1"></div>
|
||||
<div class="shape shape-2"></div>
|
||||
<div class="shape shape-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-box">
|
||||
<div class="login-header">
|
||||
<div class="login-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<h2>管理员登录</h2>
|
||||
<p class="login-subtitle">欢迎回来,请登录您的账户</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-user input-icon"></i>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
class="login-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-lock input-icon"></i>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
class="login-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="login-btn">
|
||||
<span v-if="!loading">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
登录
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
登录中...
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="error" class="error-message">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { login } from '../api'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await login(username.value, password.value)
|
||||
localStorage.setItem('token', res.data.token)
|
||||
window.location.href = '/admin'
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--background-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(0, 122, 255, 0.1) 0%,
|
||||
rgba(255, 204, 0, 0.1) 50%,
|
||||
rgba(0, 122, 255, 0.1) 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-shapes {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.1;
|
||||
animation: float 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--hover-link-color);
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: #007aff;
|
||||
bottom: -50px;
|
||||
right: -50px;
|
||||
animation-delay: 5s;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: var(--hover-link-color);
|
||||
top: 50%;
|
||||
right: 10%;
|
||||
animation-delay: 10s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.login-box {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: rgba(var(--background-color-rgb), 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
padding: 48px 40px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 20px;
|
||||
background: linear-gradient(135deg, #007aff, var(--hover-link-color));
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 15px rgba(0, 122, 255, 0.3);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 4px 15px rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(0, 122, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.login-icon i {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--text-color);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
color: rgba(var(--text-color-rgb, 51, 51, 51), 0.6);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
color: rgba(var(--text-color-rgb, 51, 51, 51), 0.5);
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px 14px 48px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--background-color-rgb), 0.6);
|
||||
color: var(--text-color);
|
||||
font-size: 15px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-input::placeholder {
|
||||
color: rgba(var(--text-color-rgb, 51, 51, 51), 0.4);
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
border-color: #007aff;
|
||||
background: rgba(var(--background-color-rgb), 0.8);
|
||||
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.login-input:focus + .input-icon,
|
||||
.login-input:focus ~ .input-icon {
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #007aff, #0056b3);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 15px rgba(0, 122, 255, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.login-btn:hover::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 122, 255, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.login-btn span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(244, 67, 54, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
animation: shake 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-box {
|
||||
padding: 36px 24px;
|
||||
margin: 20px;
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.shape {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="visit-timer-container">
|
||||
<div class="visit-timer"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 50, x: '-50%', scale: 0.5 }"
|
||||
:enter="{ opacity: 1, y: 0, x: '-50%', scale: 1, transition: { duration: 300 } }"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="toggleCalendar">
|
||||
<div class="timer-content">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>停留时间 : </span>
|
||||
<div class="time">
|
||||
<template v-for="(value, unit) in timeUnits" :key="unit">
|
||||
<div class="time-wrapper">
|
||||
<Transition name="flip">
|
||||
<span :key="value" class="time-unit">{{ value }}</span>
|
||||
</Transition>
|
||||
</div>
|
||||
<span v-if="unit !== 'seconds'" class="separator">:</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="calendar">
|
||||
<div v-if="showCalendar"
|
||||
class="calendar-popup"
|
||||
@mouseleave="handleMouseLeave">
|
||||
<div class="calendar-header">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
{{ dateTime.dateOnly }}
|
||||
</div>
|
||||
<div class="calendar-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>{{ dateTime.weekday }}</span>
|
||||
<span>{{ dateTime.timeWithoutSeconds }}</span>
|
||||
<span v-if="isCalendarPinned" class="pin-indicator">
|
||||
<i class="fas fa-thumbtack"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
|
||||
// === 合并计时和日期时间逻辑 ===
|
||||
const useTimeManager = () => {
|
||||
const startTime = ref(Date.now());
|
||||
const currentTime = ref(Date.now());
|
||||
|
||||
onMounted(() => {
|
||||
const timer = setInterval(() => {
|
||||
currentTime.value = Date.now();
|
||||
}, 1000);
|
||||
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
});
|
||||
|
||||
// 计算属性
|
||||
const timeUnits = computed(() => {
|
||||
const totalSeconds = Math.floor((currentTime.value - startTime.value) / 1000);
|
||||
return {
|
||||
hours: Math.floor(totalSeconds / 3600).toString().padStart(2, '0'),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60).toString().padStart(2, '0'),
|
||||
seconds: (totalSeconds % 60).toString().padStart(2, '0')
|
||||
};
|
||||
});
|
||||
|
||||
const dateTime = computed(() => {
|
||||
const now = new Date(currentTime.value);
|
||||
return {
|
||||
dateOnly: now.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}),
|
||||
weekday: now.toLocaleDateString('zh-CN', { weekday: 'long' }),
|
||||
timeWithoutSeconds: now.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
timeUnits,
|
||||
dateTime
|
||||
};
|
||||
};
|
||||
|
||||
// === 简化组件状态管理 ===
|
||||
const showCalendar = ref(false);
|
||||
const isCalendarPinned = ref(false);
|
||||
|
||||
const { timeUnits, dateTime } = useTimeManager();
|
||||
|
||||
// 简化事件处理
|
||||
const handleMouseEnter = () => showCalendar.value = true;
|
||||
const handleMouseLeave = () => !isCalendarPinned.value && (showCalendar.value = false);
|
||||
const toggleCalendar = () => {
|
||||
isCalendarPinned.value = !isCalendarPinned.value;
|
||||
showCalendar.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 基础组件样式 */
|
||||
.visit-timer-container {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
|
||||
/* 在移动端隐藏组件 */
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.visit-timer, .calendar-popup {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(10px);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.visit-timer {
|
||||
bottom: 50px;
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) translateY(-5px);
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
}
|
||||
|
||||
.timer-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
font-size: 0.9em;
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间单位样式 */
|
||||
.time-wrapper {
|
||||
position: relative;
|
||||
width: 1.6em;
|
||||
height: 1.2em;
|
||||
overflow: visible;
|
||||
|
||||
.time-unit {
|
||||
display: inline-block;
|
||||
width: 1.6em;
|
||||
text-align: center;
|
||||
height: 1.2em;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* 日历弹窗样式 */
|
||||
.calendar-popup {
|
||||
bottom: calc(50px + 50px);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px var(--shadow-color);
|
||||
min-width: 188px;
|
||||
|
||||
.calendar-header {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.calendar-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* 固定图钉样式 */
|
||||
.pin-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: -10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--background-color);
|
||||
box-shadow: 0 2px 12px var(--shadow-color);
|
||||
color: red;
|
||||
transform: rotate(45deg);
|
||||
animation: pin-in 0.6s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* 添加图钉动画关键帧 */
|
||||
@keyframes pin-in {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(0.5) translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: rotate(0deg) scale(1.2) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(45deg) scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画样式 */
|
||||
.calendar {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px) scale(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.flip {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&-enter-from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-leave-to {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="swiper-container">
|
||||
<div class="swiper-wrapper">
|
||||
<div v-for="(siteChunk, index) in chunkedSites" :key="index" class="swiper-slide">
|
||||
<div class="site-grid">
|
||||
<div v-for="(site, i) in siteChunk" :key="i" class="site-box" @click="openLink(site.url)">
|
||||
<div class="site-content">
|
||||
<i :class="site.icon" aria-hidden="true"></i>
|
||||
<span class="site-name">{{ site.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swiper-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import Swiper from 'swiper/bundle';
|
||||
import 'swiper/swiper-bundle.css';
|
||||
import { getSites } from '../api';
|
||||
|
||||
const sites = ref([]);
|
||||
const chunkedSites = ref([]);
|
||||
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
const res = await getSites();
|
||||
sites.value = res.data;
|
||||
// 将站点数据分块(每页6个)
|
||||
chunkedSites.value = sites.value.reduce((acc, site, index) => {
|
||||
const chunkIndex = Math.floor(index / 6);
|
||||
if (!acc[chunkIndex]) acc[chunkIndex] = [];
|
||||
acc[chunkIndex].push(site);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// 如果数据加载后需要重新初始化Swiper
|
||||
if (chunkedSites.value.length > 0) {
|
||||
setTimeout(() => {
|
||||
initSwiper();
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载站点数据失败:', error);
|
||||
// 如果API失败,使用空数组
|
||||
chunkedSites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
let swiperInstance = null;
|
||||
|
||||
const initSwiper = () => {
|
||||
if (swiperInstance) {
|
||||
swiperInstance.destroy();
|
||||
}
|
||||
swiperInstance = new Swiper('.swiper-container', {
|
||||
slidesPerView: 1,
|
||||
spaceBetween: 20,
|
||||
pagination: { el: '.swiper-pagination', clickable: true },
|
||||
mousewheel: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openLink = (url) => {
|
||||
if (url) window.open(url, '_blank');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadSites();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin: 30px 0 20px;
|
||||
}
|
||||
|
||||
.swiper-container {
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.swiper-pagination {
|
||||
bottom: inherit;
|
||||
}
|
||||
|
||||
.site-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.site-box {
|
||||
padding: 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(var(--background-color-rgb), 0.2);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 1px 8px var(--shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
.site-content {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
font-size: var(--icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.site-name {
|
||||
margin: 0;
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.site-content {
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site-box {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.site-content i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.swiper-pagination-bullet-active) {
|
||||
background: #8c8c8c94;
|
||||
width: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"type": "Email",
|
||||
"icon": "fas fa-envelope",
|
||||
"url": "mailto:i@bsgun.cn",
|
||||
"hoverColor": "#e78b0a"
|
||||
},
|
||||
{
|
||||
"type": "Github",
|
||||
"icon": "fab fa-github",
|
||||
"url": "https://github.com/JLinmr",
|
||||
"hoverColor": "#6500fc"
|
||||
},
|
||||
{
|
||||
"type": "支付宝",
|
||||
"icon": "fab fa-alipay",
|
||||
"qrCode": "https://lib.bsgun.cn/Hexo-static/img/zfbzf.avif",
|
||||
"hoverColor": "#007aff"
|
||||
},
|
||||
{
|
||||
"type": "微信",
|
||||
"icon": "fab fa-weixin",
|
||||
"qrCode": "https://lib.bsgun.cn/Hexo-static/img/wxzf.avif",
|
||||
"hoverColor": "#247700"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"name": "博客",
|
||||
"url": "https://blog.bsgun.cn",
|
||||
"icon": "fa fa-blog"
|
||||
},
|
||||
{
|
||||
"name": "雨云",
|
||||
"url": "https://www.rainyun.com/Lin_",
|
||||
"icon": "fa fa-cloud "
|
||||
},
|
||||
{
|
||||
"name": "图床",
|
||||
"url": "https://dev.bsgun.cn",
|
||||
"icon": "fa fa-image"
|
||||
},
|
||||
{
|
||||
"name": "封面",
|
||||
"url": "https://cover.bsgun.cn",
|
||||
"icon": "fa fa-panorama"
|
||||
},
|
||||
{
|
||||
"name": "监测",
|
||||
"url": "https://status.bsgun.cn",
|
||||
"icon": "fa fa-chart-line"
|
||||
},
|
||||
{
|
||||
"name": "图标",
|
||||
"url": "https://icon.bsgun.cn/",
|
||||
"icon": "fa fa-icons"
|
||||
}
|
||||
]
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.less';
|
||||
import { MotionPlugin } from '@vueuse/motion';
|
||||
import router from './router';
|
||||
import { loadAndApplyFrontendConfig } from './utils/frontendConfig';
|
||||
|
||||
// 加载前端配置
|
||||
loadAndApplyFrontendConfig();
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(MotionPlugin);
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '../components/Home.vue'
|
||||
import Admin from '../components/Admin.vue'
|
||||
import Login from '../components/Login.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: Admin,
|
||||
beforeEnter: (to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: Login,
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
+155
File diff suppressed because one or more lines are too long
@@ -0,0 +1,95 @@
|
||||
import { getFrontendConfig } from '../api'
|
||||
|
||||
/**
|
||||
* 从API获取前端配置并更新页面
|
||||
*/
|
||||
export async function loadAndApplyFrontendConfig() {
|
||||
try {
|
||||
const res = await getFrontendConfig()
|
||||
const config = res.data
|
||||
|
||||
// 更新页面标题
|
||||
if (config.title) {
|
||||
document.title = config.title
|
||||
}
|
||||
|
||||
// 更新meta标签
|
||||
if (config.keywords) {
|
||||
updateMetaTag('keywords', config.keywords)
|
||||
}
|
||||
if (config.description) {
|
||||
updateMetaTag('description', config.description)
|
||||
}
|
||||
|
||||
// 更新favicon
|
||||
if (config.favicon) {
|
||||
updateFavicon(config.favicon)
|
||||
}
|
||||
|
||||
// 动态加载图标库
|
||||
if (config.iconLibrary) {
|
||||
loadStylesheet(config.iconLibrary, 'icon-library')
|
||||
}
|
||||
|
||||
// 动态加载字体库
|
||||
if (config.fontLibrary) {
|
||||
loadStylesheet(config.fontLibrary, 'font-library')
|
||||
}
|
||||
|
||||
// 动态加载Umami统计脚本
|
||||
if (config.umamiScript && config.umamiWebsiteId) {
|
||||
loadUmamiScript(config.umamiScript, config.umamiWebsiteId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载前端配置失败:', error)
|
||||
// 如果API失败,使用默认值(从环境变量或index.html中的占位符)
|
||||
}
|
||||
}
|
||||
|
||||
function updateMetaTag(name, content) {
|
||||
if (!content) return
|
||||
|
||||
let meta = document.querySelector(`meta[name="${name}"]`)
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.setAttribute('name', name)
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function updateFavicon(href) {
|
||||
let link = document.querySelector("link[rel*='icon']")
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = href
|
||||
}
|
||||
|
||||
function loadStylesheet(href, id) {
|
||||
// 检查是否已加载
|
||||
if (document.getElementById(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.id = id
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href.startsWith('//') ? `https:${href}` : href
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
function loadUmamiScript(src, websiteId) {
|
||||
// 检查是否已加载
|
||||
if (document.querySelector(`script[data-website-id="${websiteId}"]`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.defer = true
|
||||
script.src = src
|
||||
script.setAttribute('data-website-id', websiteId)
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
Reference in New Issue
Block a user