Add files via upload

This commit is contained in:
QWQLwToo
2026-01-28 13:26:17 +08:00
committed by GitHub
parent 7030903eb6
commit 151c1c2387
29 changed files with 12656 additions and 2 deletions
+104
View File
@@ -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>
+100
View File
@@ -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
+207
View File
@@ -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
+528
View File
@@ -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
});
// truefalseDOM
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已更新')
// truefalseDOM
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()
}
}
}
}
// showVisitTimerDOM
watch(showVisitTimer, async (newValue, oldValue) => {
console.log('showVisitTimer变化:', { oldValue, newValue })
// DOM
await nextTick()
// falsevisit-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>
+442
View File
@@ -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>
+517
View File
@@ -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">支持 jpgpngjpegwebpavifsvgico 等格式</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 = () => {
// 使ImageContent-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>
+403
View File
@@ -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>
+270
View File
@@ -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>
+158
View File
@@ -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>
+26
View File
@@ -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"
}
]
+32
View File
@@ -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
View File
@@ -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');
+34
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+95
View File
@@ -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)
}