整体优化

This commit is contained in:
2026-08-04 17:33:45 +08:00
parent cadc8885f3
commit 2c69b3f1c3
32 changed files with 2212 additions and 643 deletions
+14 -23
View File
@@ -7,7 +7,7 @@
<router-view />
</n-layout-content>
<n-layout-footer v-show="route.path == '/home'" class="flex justify-center">
<div class="beian" ref="footer">
<div class="beian">
<a class="text-primary" href="https://beian.miit.gov.cn" target="_blank">皖ICP备2021017362号-1</a>
<a class="swag text-primary" target="_blank" href="https://www.hxyouzi.com/swag">api文档</a>
</div>
@@ -36,8 +36,20 @@
</template>
<script setup lang="ts">
interface NmodalItem {
visible: boolean
title: string
content: string
contType?: string
placeholder?: string
cancelText?: string
submitText?: string
handdleCancel?: () => void
handdleSubmit?: () => void
handdleInputChange: (e: any) => void
}
const route = useRoute()
const footer = ref<HTMLElement | null>(null)
window.$msg = useMessage()
const modal: NmodalItem = reactive({
visible: false,
@@ -51,18 +63,6 @@ const modal: NmodalItem = reactive({
handdleSubmit: () => { },
handdleInputChange: (e: any) => { }
})
interface NmodalItem {
visible: boolean
title: string
content: string
contType?: string
placeholder?: string
cancelText?: string
submitText?: string
handdleCancel?: () => void
handdleSubmit?: () => void
handdleInputChange: (e: any) => void
}
//mark method
function comemodal(mv: NmodalItem) {
if (!mv) return
@@ -93,15 +93,6 @@ function comemodal(mv: NmodalItem) {
}
window.$modal = comemodal
onMounted(() => {
// 计算footer的高度
const footerHeight = footer.value?.clientHeight || 0;
console.log('footerHeight', footerHeight);
document.addEventListener('resize', () => {
})
})
</script>
<style scoped lang="less">
-9
View File
@@ -83,7 +83,6 @@ function resetWaterfall() {
columnHeights.value = Array(columnCount.value).fill(0);
// 重新分配图片
fileList.value.forEach(async (item:any) =>await addToWaterfall(item));
console.log('>>> --> resetWaterfall --> fileList:', fileList.value)
}
// 添加图片到瀑布流
@@ -94,8 +93,6 @@ async function addToWaterfall(item: any) {
// 找到高度最小的列
let minHeight = Math.min(...columnHeights.value);
let minIndex = columnHeights.value.indexOf(minHeight);
console.log('>>> --> addToWaterfall --> item:', item)
console.log('>>> --> addToWaterfall --> minIndex:', minIndex)
// 添加到该列
columns.value[minIndex].push(item);
// 估算列高 - 实际高度会在图片加载后更新
@@ -181,18 +178,13 @@ async function getFileList() {
// 监听滚动事件
const handleScroll: any = throttle((e: any) => {
console.log('>>> --> handleScroll --> loading:', e)
if (loading.value) return;
const scrollTop = e.target.scrollTop
console.log('>>> --> handleScroll --> scrollTop:', scrollTop)
const scrollHeight = e.target.scrollHeight
// console.log('>>> --> handleScroll --> scrollHeight:', scrollHeight)
const clientHeight = e.target.offsetHeight;
// 当滚动到距离底部20%时加载更多
// console.log('>>> --> clientHeight --> clientHeight:', clientHeight)
if (scrollTop + clientHeight >= scrollHeight * 0.8) {
console.log('>>> --> handleScroll --> 加载更多')
pn.value++;
getFileList();
}
@@ -203,7 +195,6 @@ function onSearch() {
}
function downloadFile(url: string) {
console.log('>>> --> downloadFile --> url:', url)
// 创建临时a标签
const link = document.createElement('a');
// 设置下载链接
+24 -16
View File
@@ -178,30 +178,38 @@ async function login() {
const isValid = await validateForm(formLogin.value)
if (!isValid) return
const res = await $http.user.login(loginData) as ApiResponse<UserInfo>
if (res?.code !== 200) {
$msg.error('登录失败')
return
}
try {
const res = await $http.user.login(loginData) as ApiResponse<UserInfo>
if (res?.code !== 200) {
$msg.error('登录失败')
return
}
persistLogin(res.data)
$msg.success(res.msg)
persistLogin(res.data)
$msg.success(res.msg)
} catch {
$msg.error('登录失败,请检查网络或账号密码')
}
}
async function register() {
const isValid = await validateForm(formReg.value)
if (!isValid) return
const res = await $http.user.register(regData) as ApiResponse
if (res?.code !== 200) {
$msg.error('注册失败')
return
}
try {
const res = await $http.user.register(regData) as ApiResponse
if (res?.code !== 200) {
$msg.error('注册失败')
return
}
$msg.success(res.msg)
tid.value = 'tab1'
loginData.username = regData.username
resetRegister()
$msg.success(res.msg)
tid.value = 'tab1'
loginData.username = regData.username
resetRegister()
} catch {
$msg.error('注册失败,请检查网络或稍后再试')
}
}
function validateUsername(_rule: unknown, value: string) {
+52 -57
View File
@@ -5,10 +5,8 @@
<script setup lang="ts">
// https://aplayer.js.org/#/zh-Hans/
// import Hls from 'hls.js/dist/hls.min.js';
import APlayer from "aplayer"; // 引入音乐插件
import "aplayer/dist/APlayer.min.css"; // 引入音乐插件的样式
import { onMounted, reactive } from "vue";
// window.Hls = Hls;
const data: any = reactive({
@@ -29,64 +27,61 @@ const data: any = reactive({
onMounted(async () => {
// 8834041785
// const res = await fetch("https://met.hxyouzi.com/meting-api/?server=tencent&type=playlist&id=7567066822")
// const res = await fetch("https://api.moeyao.cn/meting/?server=tencent&type=playlist&id=8834041785")
// const mlist = await res.json()
const metto = 'youzi'
const server = 'tencent'
const gdid = '7567066822'
let ck = 'RK=beuVmBI+x3; ptcz=08924c0c66c35b66361f002fb0b3005ff0f2144404f548f8728b167ffb3dc25d; pgv_pvid=2605897556; fqm_pvqid=c38eee7f-1f49-4867-8fb4-8dbb764cb93b; ts_uid=4584087650; pac_uid=0_xXEQBtbyPnKSj; omgid=0_SK8mZi6ZyjtN4; _qimei_q32=d611a6803057686110470945b0c17a21; _qimei_q36=fcef018f916225c22be032db30001e219617; _qimei_h38=e8d664cf2171af215382857302000000719801; eas_sid=I1j7m5e9o2O188P6Y4P7D745Z4; fqm_sessionid=f2c44c43-4d85-4dcb-99c0-cef6d6e2b0cb; pgv_info=ssid=s1311374400; ts_last=y.qq.com/; ts_refer=cn.bing.com/; _qpsvr_localtk=0.1390152059728047; login_type=1; psrf_access_token_expiresAt=1765271149; psrf_qqopenid=DE4C1520CB81DEEF7C0BE8E9B0FD2783; wxrefresh_token=; music_ignore_pskey=202306271436Hn@vBj; wxunionid=; euin=owv5oeCioiEAoz**; wxopenid=; tmeLoginType=2; psrf_qqrefresh_token=A9707E145813679F3654C6E051AA3E2F; psrf_qqaccess_token=C0591D34DF4A719D28815DBA71122DC5; psrf_musickey_createtime=1760087149; psrf_qqunionid=AD81CE40E407AFA9A2F984D05F7F5CD6; uin=2410633923; qm_keyst=Q_H_L_63k3NMFc5RtmTBDyGhskoc5AjaymIKYUZThRS2bJfh0AHztt8D10MRy1XN-MEAUtIkdF_oauym2Y51IbgjNvw1Wu8hZECDA; qqmusic_key=Q_H_L_63k3NMFc5RtmTBDyGhskoc5AjaymIKYUZThRS2bJfh0AHztt8D10MRy1XN-MEAUtIkdF_oauym2Y51IbgjNvw1Wu8hZECDA'
const res = await fetch(`https://met.hxyouzi.com/api?server=${server}&type=playlist&id=${gdid}`)
let mlist: any = await res.json()
data.audio = []
mlist.forEach((i: any) => {
data.audio.push({
title: i.title,
author: i.author,
url: i.url,
pic: i.pic,
lrc: i.lrc,
try {
const server = 'tencent'
const gdid = '7567066822'
const res = await fetch(`https://met.hxyouzi.com/api?server=${server}&type=playlist&id=${gdid}`)
const mlist: any = await res.json()
data.audio = []
mlist.forEach((i: any) => {
data.audio.push({
title: i.title,
author: i.author,
url: i.url,
pic: i.pic,
lrc: i.lrc,
});
});
// console.log('>>>>>',data.audio);
});
// 创建一个音乐播放器实例,并挂载到DOM上,同时进行相关配置
const ap = new APlayer({
container: document.getElementById("aplayer"),
audio: data.audio, // 音乐信息
...data.info, // 其他配置信息
});
const d: HTMLElement | null = document.querySelector("#aplayer > div.aplayer-body > div.aplayer-miniswitcher > button");
let flag = true;
const app: HTMLElement | null = document.querySelector("#aplayer > div.aplayer-lrc");
const apic: HTMLElement | null = document.querySelector("#aplayer div.aplayer-body");
if (!d || !app || !apic) return;
app.style.display = "block";
app.style.transform = "translateY(50px)";
apic.style.transform = "translateX(-66px)";
d.addEventListener("click", () => {
if (flag) {
app.style.transform = "translateY(0)";
ap.lrc.show();
apic.style.transform = "translateX(0)";
flag = false;
} else {
app.style.transform = "translateY(50px)";
ap.lrc.hide();
ap.list.hide();
apic.style.transform = "translateX(-66px)";
flag = true;
}
});
document.querySelector(".aplayer-list > ol")?.addEventListener("mousewheel", (e) => {
e.stopPropagation();
})
// 创建一个音乐播放器实例,并挂载到DOM上,同时进行相关配置
const ap = new APlayer({
container: document.getElementById("aplayer"),
audio: data.audio, // 音乐信息
...data.info, // 其他配置信息
});
const d: any = document.querySelector("#aplayer > div.aplayer-body > div.aplayer-miniswitcher > button");
let flag = true;
const app: any = document.querySelector("#aplayer > div.aplayer-lrc");
const apic: any = document.querySelector("#aplayer div.aplayer-body");
app.style.display = "block";
app.style.transform = "translateY(50px)";
apic.style.transform = "translateX(-66px)";
d.addEventListener("click", () => {
if (flag) {
app.style.transform = "translateY(0)";
ap.lrc.show();
apic.style.transform = "translateX(0)";
flag = false;
} else {
app.style.transform = "translateY(50px)";
ap.lrc.hide();
ap.list.hide();
apic.style.transform = "translateX(-66px)";
flag = true;
}
});
document.querySelector(".aplayer-list > ol")?.addEventListener("mousewheel", (e) => {
e.stopPropagation();
})
// 获取当前屏幕宽度
const width = document.documentElement.clientWidth; // 获取屏幕宽度
if (width < 767) d.click();
// 获取当前屏幕宽度
const width = document.documentElement.clientWidth; // 获取屏幕宽度
if (width < 767) d.click();
} catch (error) {
console.error('音乐列表加载失败:', error)
}
});
</script>
<style lang="less" scoped>
+11 -13
View File
@@ -10,37 +10,35 @@
<script setup lang="ts">
import { throttle } from 'es-toolkit/function';
const scrollTop: any = ref(null)
const scrollTop = ref(0)
const visiable = ref(false)
const scrollElement: any = document.querySelector('.n-scrollbar .n-scrollbar-container')
const scrollElement = document.querySelector<HTMLElement>('.n-scrollbar .n-scrollbar-container')
const handleScroll = throttle(() => {
scrollTop.value = scrollElement.scrollTop
scrollTop.value = scrollElement?.scrollTop || 0
visiable.value = scrollTop.value > 200;
},1000)
function handleScrollTop() {
let timer: any = null;
cancelAnimationFrame(timer);
timer = requestAnimationFrame(function fn() {
let timer = 0
const fn = () => {
if (scrollTop.value > 0) {
scrollTop.value -= 50;
scrollElement.scrollTop = document.documentElement.scrollTop = scrollTop.value;
if (scrollElement) scrollElement.scrollTop = scrollTop.value;
timer = requestAnimationFrame(fn);
} else {
cancelAnimationFrame(timer);
visiable.value = false;
}
});
};
timer = requestAnimationFrame(fn);
}
onMounted(() => {
scrollElement?.addEventListener('scroll', handleScroll);
})
onBeforeMount(() => {
window.removeEventListener('scroll', handleScroll);
// 修复:原来用 onBeforeMount 且移除的是 window 的监听,导致卸载时滚动监听泄漏
onUnmounted(() => {
scrollElement?.removeEventListener('scroll', handleScroll);
})
</script>
<style scoped lang="less">
+156 -38
View File
@@ -2,7 +2,8 @@
<div class="pl-6 pr-6 !bg-transparent">
<n-card
embedded
class="side-glass-card side-glass-card--tint mt-4 overflow-hidden rounded-[24px]"
class="side-glass-card side-glass-card--tint mt-2 overflow-hidden rounded-[24px]"
:style="{ '--side-i': 0 }"
>
<template #header>
<div class="flex items-center font-semibold text-slate-700">
@@ -11,20 +12,49 @@
</div>
</template>
<template #default>
<div class="w-full pr-20 text-center text-4xl font-[yj] font-[500] tracking-[0.04em] text-[#ec66ab] drop-shadow-[0_8px_20px_rgba(236,102,171,0.14)]">
{{ t }} </div>
<div class="mt-3 flex justify-between text-[15px] text-slate-500">
<span>今年已过 {{ jq.dayOfYear }} </span>
{{ d }}
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div
class="text-3xl font-[yj] font-[500] leading-[1.15] tracking-[0.04em] text-[#ec66ab] drop-shadow-[0_8px_20px_rgba(236,102,171,0.14)]"
>
{{ t }}
</div>
<div class="mt-1 text-[14px] text-slate-500">{{ d }}</div>
</div>
<img
v-if="jq.type != 0"
class="pointer-events-none w-[88px] shrink-0 drop-shadow-[0_10px_22px_rgba(236,160,102,0.18)]"
src="@/assets/images/offwork.png"
alt="offwork"
>
<img
v-else
class="pointer-events-none w-[88px] shrink-0 drop-shadow-[0_10px_22px_rgba(102,160,236,0.18)]"
src="@/assets/images/onwork.png"
alt="onwork"
>
</div>
<div class="mt-2">
<div class="flex items-center justify-between text-[13px] text-slate-500">
<span>今年已过 {{ jq.dayOfYear }} </span>
<span>{{ yearProgress }}%</span>
</div>
<div
class="mt-1 h-1 overflow-hidden rounded-full bg-white/70 shadow-[inset_0_1px_2px_rgba(97,121,146,0.14)]"
>
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#f2b077,#ec66ab)] shadow-[0_0_10px_rgba(236,102,171,0.4)] transition-[width] duration-700"
:style="{ width: yearProgress + '%' }"
></div>
</div>
</div>
<img v-if="jq.type != 0" class="absolute right-4 -top-15" width="120" src="@/assets/images/offwork.png" alt="offwork">
<img v-else class="absolute right-4 -top-15" width="120" src="@/assets/images/onwork.png" alt="onwork">
</template>
</n-card>
<n-card
embedded
class="side-glass-card mt-4 overflow-hidden rounded-[24px]"
class="side-glass-card mt-2 overflow-hidden rounded-[24px]"
:style="{ '--side-i': 1 }"
>
<template #header>
<div class="flex items-center font-semibold text-slate-700">
@@ -35,21 +65,27 @@
<template #default>
<div class="py-1" v-for="i in bdNews" :key="i.id">
<a
class="flex justify-between truncate rounded-xl border border-transparent px-2 text-[#526ecc] no-underline transition-colors duration-200 hover:border-white/80 hover:bg-white/65 hover:text-primary"
class="group flex items-center justify-between gap-2 rounded-xl border border-transparent px-2 py-0.5 text-[#526ecc] no-underline transition-[background-color,border-color,box-shadow,transform] duration-200 hover:-translate-y-px hover:border-white/80 hover:bg-white/65 hover:shadow-[0_2px_8px_rgba(97,121,146,0.08),inset_0_1px_0_rgba(255,255,255,0.6)]"
:href="i.url"
target="_blank"
>
<span class="flex items-center truncate">
{{ i.index }}. {{ i.title }}
<icon-hot v-show="i.index < 4" class="ml-2 inline-block w-4 text-[red]"></icon-hot>
<span class="flex min-w-0 items-center truncate">
<span
class="mr-2 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-[11px] font-bold"
:class="i.index <= 3
? 'bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white shadow-[0_3px_8px_rgba(236,102,171,0.28)]'
: 'bg-slate-700/6 text-slate-400'"
>{{ i.index }}</span>
<span class="truncate transition-colors duration-200 group-hover:text-primary">{{ i.title }}</span>
<icon-hot v-show="i.index < 4" class="ml-2 inline-block w-4 shrink-0 text-[red]"></icon-hot>
</span>
<span class="ml-3 shrink-0 text-primary">{{ i.hot }}</span>
<span class="shrink-0 text-[13px] font-medium text-primary/85">{{ i.hot }}</span>
</a>
</div>
<div class="mt-2 flex items-center justify-between">
<div class="mt-1.5 flex items-center justify-between">
<div class="h-px w-2/5 bg-gradient-to-r from-transparent via-[#ec66ab] to-[#ec66ab]/40"></div>
<a
class="flex items-center font-medium text-[#ec66ab] no-underline"
class="flex items-center font-medium text-[#ec66ab] no-underline transition-transform duration-200 hover:scale-105"
href="https://www.baidu.com/s?ie=utf-8&wd=百度新闻"
target="_blank"
>
@@ -63,26 +99,41 @@
<n-card
embedded
class="side-glass-card mt-4 overflow-hidden rounded-[24px]"
class="side-glass-card mt-2 overflow-hidden rounded-[24px]"
:style="{ '--side-i': 2 }"
>
<template #header>
<div class="flex items-center font-semibold text-slate-700">
<icon-date class="mr-2 h-[20px] w-5"></icon-date>
农历节气
<div class="ml-12 font-semibold text-[#ec66ab]">{{ jq.yearTips }} {{ jq.lunarCalendar }}</div>
<div class="ml-8 font-semibold text-[#ec66ab]">{{ jq.yearTips }} {{ jq.lunarCalendar }}</div>
</div>
</template>
<template #default>
<div class="mx-[5%] truncate text-sm text-slate-600">
{{ jq.suit }}
<div class="mx-1 flex items-center gap-1.5 text-sm text-slate-600">
<icon-star class="w-4 shrink-0 text-[#7fb69a]"></icon-star>
<span class="truncate">{{ jq.suit }}</span>
</div>
<div class="mx-[5%] truncate text-sm text-slate-600">
{{ jq.avoid }}
<div class="mx-1 mt-1.5 flex items-center gap-1.5 text-sm text-slate-600">
<icon-lock class="w-4 shrink-0 text-[#d99a9a]"></icon-lock>
<span class="truncate">{{ jq.avoid }}</span>
</div>
<div class="flex w-full justify-center">
<img class="mt-3 w-[90%] rounded-2xl border border-white/80 bg-white/70 shadow-[0_12px_26px_rgba(102,129,156,0.12)]" :src="jqImg" alt="节气图">
<img
class="mt-2 w-[82%] rounded-2xl border border-white/80 bg-white/70 shadow-[0_1px_3px_rgba(97,121,146,0.05),0_10px_22px_rgba(97,121,146,0.1),0_28px_54px_rgba(178,128,88,0.07),inset_0_1px_0_rgba(255,255,255,0.7)]"
:src="jqImg"
alt="节气图"
>
</div>
<div class="mt-1.5 text-center font-medium text-slate-600">{{ jq.solarTerms }}</div>
<div
v-if="holiName && holiDays !== null"
class="mx-auto mt-2 flex w-fit items-center gap-1.5 rounded-full border border-white/70 bg-[linear-gradient(135deg,rgba(255,240,246,0.85),rgba(255,233,223,0.8))] px-3.5 py-0.5 text-[13px] font-medium text-[#c2558e] shadow-[0_2px_8px_rgba(236,102,171,0.12),inset_0_1px_0_rgba(255,255,255,0.7)]"
>
<icon-date class="w-3.5"></icon-date>
<b class="font-bold">{{ holiName }}</b> 还有
<b class="font-bold">{{ holiDays }}</b>
</div>
<div class="mt-3 text-center font-medium text-slate-600">{{ jq.solarTerms }}</div>
</template>
</n-card>
</div>
@@ -97,6 +148,25 @@ let timer: any = null
const jq = ref<any>('')
const jqImg = ref<any>('')
const bdNews = ref<any>([])
const holi = ref<any>(null)
// 今年进度百分比
const yearProgress = computed(() => {
const now = new Date()
const start = new Date(now.getFullYear(), 0, 1)
const end = new Date(now.getFullYear() + 1, 0, 1)
const pct = ((now.getTime() - start.getTime()) / (end.getTime() - start.getTime())) * 100
return Math.min(100, Math.max(0, pct)).toFixed(1)
})
// 下一个节假日(防御式解析多种字段名)
const holiName = computed(() => holi.value?.name || holi.value?.holiday || '')
const holiDays = computed(() => {
const v = holi.value
if (!v) return null
const raw = v.days ?? v.countdown ?? v.day ?? null
return typeof raw === 'number' ? raw : null
})
function getTime() {
const date = new Date()
@@ -105,24 +175,43 @@ function getTime() {
}
async function getJq() {
const res = await $http.mix.getJq({
date: formatTime(new Date(), 'YYYYMMDD')
})
jq.value = res.data
const j = res.data.solarTerms.slice(0, 2)
const timestamp = new Date().getTime()
jqImg.value = `https://www.hxyouzi.com/img/jq/${j}.png?${timestamp}`
try {
const res = await $http.mix.getJq({
date: formatTime(new Date(), 'YYYYMMDD')
})
jq.value = res.data
const j = res.data.solarTerms.slice(0, 2)
const timestamp = new Date().getTime()
jqImg.value = `https://www.hxyouzi.com/img/jq/${j}.png?${timestamp}`
} catch {
// 接口失败时静默处理,保持空状态
}
}
async function getBdhot() {
const res = await $http.mix.getBdhot()
bdNews.value = res.data.slice(0, 5)
try {
const res = await $http.mix.getBdhot()
bdNews.value = res.data.slice(0, 5)
} catch {
bdNews.value = []
}
}
async function getHoli() {
try {
const res = await $http.mix.getHoli()
const data = Array.isArray(res.data) ? res.data[0] : res.data
holi.value = data || null
} catch {
holi.value = null
}
}
onMounted(() => {
getTime()
getJq()
getBdhot()
getHoli()
timer = setInterval(() => {
getTime()
@@ -147,11 +236,40 @@ onUnmounted(() => {
linear-gradient(135deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.16)),
linear-gradient(180deg, rgba(241, 248, 252, 0.2), rgba(224, 236, 245, 0.08)) !important;
box-shadow:
0 22px 40px rgba(86, 111, 137, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 0 rgba(255, 255, 255, 0.16) !important;
0 1px 3px rgba(97, 121, 146, 0.05),
0 14px 30px rgba(97, 121, 146, 0.09),
0 36px 72px rgba(178, 128, 88, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(255, 255, 255, 0.14) !important;
backdrop-filter: blur(24px) saturate(165%);
-webkit-backdrop-filter: blur(24px) saturate(165%);
transition:
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.3s ease;
animation: side-card-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--side-i, 0) * 110ms);
}
.side-glass-card:hover {
transform: translateY(-2px);
box-shadow:
0 2px 6px rgba(97, 121, 146, 0.06),
0 20px 44px rgba(97, 121, 146, 0.13),
0 52px 100px rgba(178, 128, 88, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.8),
inset 0 -1px 0 rgba(255, 255, 255, 0.18) !important;
}
@keyframes side-card-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.side-glass-card::before,
@@ -187,11 +305,11 @@ onUnmounted(() => {
:deep(.n-card > .n-card__content),
:deep(.n-card > .n-card__footer) {
padding: 12px 24px !important;
padding: 8px 20px !important;
}
:deep(.n-card > .n-card-header) {
padding: 16px 24px 8px !important;
padding: 10px 20px 4px !important;
}
:deep(.n-card) {
+19 -15
View File
@@ -126,7 +126,6 @@ import linkSvg from '@/icon/menu/link.svg';
import picSvg from '@/icon/menu/pic.svg';
import type { DropdownOption, UploadFileInfo } from 'naive-ui';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
interface UserInfo {
@@ -279,21 +278,26 @@ function handleUpload(f: any) {
}
async function autoLogin() {
const res = await $http.user.autologin()
if (res?.code !== 200) {
$msg.error('登录失败')
usrLog.setIsLogin(false)
$cookies.remove('token')
$cookies.remove('userinfo')
syncUserFromCookies()
return
}
try {
const res = await $http.user.autologin()
if (res?.code !== 200) {
$msg.error('登录失败')
usrLog.setIsLogin(false)
$cookies.remove('token')
$cookies.remove('userinfo')
syncUserFromCookies()
return
}
$cookies.set('token', res.data.token, '1d')
$cookies.set('userinfo', res.data.userinfo, '1d')
$msg.success(res.msg)
usrLog.setIsLogin(true)
syncUserFromCookies()
$cookies.set('token', res.data.token, '1d')
$cookies.set('userinfo', res.data.userinfo, '1d')
$msg.success(res.msg)
usrLog.setIsLogin(true)
syncUserFromCookies()
} catch {
usrLog.setIsLogin(false)
syncUserFromCookies()
}
}
function handdleItemCancel() {
+7 -4
View File
@@ -1,7 +1,10 @@
export function useConfig() {
const files = import.meta.glob("./*.ts", { eager: true }) as Record<string, any>;
Object.keys(files).forEach(key => {
const name = key.replace(/(\.\/|\.ts)/g, "");
(window as Record<string, any>)["$" + name] = files[key].default as any;
});
Object.keys(files)
// 排除自身 index.ts,避免挂载出无意义的 $index
.filter(key => !key.endsWith("/index.ts"))
.forEach(key => {
const name = key.replace(/(\.\/|\.ts)/g, "");
(window as Record<string, any>)["$" + name] = files[key].default as any;
});
}
+7 -3
View File
@@ -1,6 +1,6 @@
<template>
<div class="image-shell" :style="shellStyle">
<n-image ref="lazyRef" class="lazy__img" :src="url" @load="handleLoad" @error="handleError">
<n-image ref="lazyRef" class="lazy__img" :src="url" :preview-src="preUrl" @load="handleLoad" @error="handleError">
<template #placeholder>
<div class="lazy-placeholder">
<span class="lazy-placeholder__ring">
@@ -32,6 +32,10 @@ const props = defineProps({
type: String,
default: "",
},
preUrl: {
type: String,
default: "",
},
loading: {
type: String,
default: loadingImg,
@@ -93,7 +97,7 @@ nextTick(() => {
height: 100%;
overflow: hidden;
display: flex;
border-radius: 18px;
border-radius: 11px;
background:
radial-gradient(circle at top left, rgba(255, 198, 105, 0.18), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(244, 249, 255, 0.72));
@@ -118,7 +122,7 @@ nextTick(() => {
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 18px;
border-radius: 11px;
}
:deep(.n-image-placeholder),
+1 -1
View File
@@ -201,7 +201,7 @@ defineExpose({
width: 100%;
position: relative;
overflow: hidden;
--waterfall-radius: 22px;
--waterfall-radius: 12px;
--waterfall-border: rgba(255, 255, 255, 0.72);
--waterfall-surface: linear-gradient(
180deg,
+4
View File
@@ -2,6 +2,7 @@ import "@/assets/main.css";
import { useConfig } from "@/config";
import icon from "@/icon/index.ts";
import { createPinia } from "pinia";
import { RouterLink, RouterView } from 'vue-router';
import "virtual:uno.css";
import 'vue-devui/tag/style.css';
import App from "./App.vue";
@@ -20,6 +21,9 @@ app.use(createPinia());
app.use(router);
// app.use(VMdPreview)
useConfig();
// resolver 模式的实验性路由器不会自动注册 RouterLink / RouterView,需手动全局注册
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)
for (const key in icon) {
// console.log(key, icon[key]);
app.component("icon-" + key, icon[key] as any);
+21 -10
View File
@@ -1,24 +1,35 @@
import NProgress from 'nprogress';
import NProgress from "nprogress";
import { createRouter, createWebHistory } from "vue-router";
// 文件系统路由:vue-router/auto-routes 提供自动生成的 routes
import { routes } from "vue-router/auto-routes";
routes.unshift({
path: "/",
redirect: "/home",
});
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes as any,
routes: [
// 根路径重定向到 /home
{ path: "/", redirect: "/home" },
...routes,
],
});
// 开发时热更新路由,无需整页刷新
// if (import.meta.hot) {
// handleHotUpdate(router);
// }
// 前置守卫
router.beforeEach((to, from, next) => {
NProgress.start()
next();
router.beforeEach(to => {
NProgress.start();
});
// 后置守卫
router.afterEach(() => {
NProgress.done()
NProgress.done();
});
// 路由错误时也要停止进度条
router.onError(() => {
NProgress.done();
});
export default router;
+8 -8
View File
@@ -1,10 +1,10 @@
export const useLogStore = defineStore('log', () => {
export const useLogStore = defineStore("log", () => {
// 登录状态
const isLogin:Ref<boolean,boolean> = ref(false)
function setIsLogin(v:boolean) {
isLogin.value = v
const isLogin = ref(false);
function setIsLogin(v: boolean) {
isLogin.value = v;
}
return { isLogin, setIsLogin }
})
return { isLogin, setIsLogin };
});
+6 -7
View File
@@ -1,10 +1,9 @@
export const useNavStore = defineStore('nav', () => {
const navH:Ref<number,number> = ref(56)
export const useNavStore = defineStore("nav", () => {
const navH = ref(56);
function setNavH(v:number) {
navH.value = v
function setNavH(v: number) {
navH.value = v;
}
return { navH , setNavH}
})
return { navH, setNavH };
});
+22 -34
View File
@@ -1,14 +1,15 @@
// 将res的key对应的值复制给dzdata的同名key
/**
* 将源对象res的属性复制到目标对象dzdata
* 将源对象 res 中「目标对象 dzdata 已存在的 key」对应的值复制过去(只覆盖既有键)
* @param {Object} res - 源对象,其属性将被复制。
* @param {Object} dzdata - 目标对象,将接收源对象的属性
* @param {Object} dzdata - 目标对象,仅其已存在的键会被源对象值覆盖
* @example
* ObjectCopy({a: 1, b: 2}, {c: 3}); // dzdata 变为 {a: 1, b: 2, c: 3}
* ObjectCopy({a: 1, b: 2}, {a: 3}); // dzdata 变为 {a: 1}
*/
export function ObjectCopy<T extends Record<string, any>>(res: Partial<T>, dzdata: T): void {
Object.keys(dzdata).forEach(key => {
if (res.hasOwnProperty(key)) {
// 用 Object.prototype.hasOwnProperty 防御 res 为 null 原型对象的情况
if (Object.prototype.hasOwnProperty.call(res, key)) {
(dzdata as Record<string, any>)[key] = res[key];
}
});
@@ -64,30 +65,18 @@ export function formatTime(time: string | number | Date, f: string): string | un
return str;
}
/**
* @description: 补零函数 为时间服务 不足两位的自动在数字前面加上0
* @param {*} n 待补零数字
* @return {*} 补零后数字
*/
function timeAdd0(n: number): string {
return n.toString().padStart(2, "0");
}
export function deepclone<T>(obj: T): T {
if (obj === null || typeof obj !== "object") {
return obj;
}
let newobj: any = obj instanceof Array ? [] : {};
if (window.JSON) {
newobj = JSON.parse(JSON.stringify(obj));
} else {
for (const i in obj) {
newobj[i] = typeof obj[i] === "object" ? deepclone(obj[i]) : obj[i];
}
// 优先使用 structuredClone:支持循环引用、Date/Map/Set,且性能优于 JSON 序列化
if (typeof structuredClone === "function") {
return structuredClone(obj);
}
return newobj as T;
// 降级方案:JSON 序列化(会丢失 undefined/函数/符号,循环引用会抛错)
return JSON.parse(JSON.stringify(obj)) as T;
}
/**
@@ -98,8 +87,8 @@ export function deepclone<T>(obj: T): T {
* @example
* formatTimeBydate(new Date(), 'yyyy-MM-dd HH:mm:ss');
*/
export function formatTimeBydate(this: string, f: string): string | undefined {
return formatTime(this, f);
export function formatTimeBydate(date: string | number | Date, f: string): string | undefined {
return formatTime(date, f);
}
export function debounce<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
@@ -139,18 +128,19 @@ export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number
* @return {*} 返回值
*/
export function getDictValue<T extends Record<string, any>>(dict: T[], ckey: keyof T, cvalue: T[keyof T], rkey: keyof T): T[keyof T] | string {
let result = "";
dict.forEach(item => {
if (item[ckey] === cvalue) {
result = item[rkey];
}
});
return result;
export function getDictValue<T extends Record<string, any>>(dict: T[], ckey: keyof T, cvalue: T[keyof T], rkey: keyof T): T[keyof T] | undefined {
// find 命中即返回,避免 forEach 遍历完整字典;未命中返回 undefined
const item = dict.find(item => item[ckey] === cvalue);
return item ? item[rkey] : undefined;
}
// 分割数组
export function chunkArrayInGroups<T>(arr: T[], size: number): T[][] {
// 防御非法 size<= 0 时 Math.ceil 会产生 InfinityArray.from 将抛 RangeError
if (size < 1) {
console.warn("chunkArrayInGroups: size 必须大于 0,已返回空数组");
return [];
}
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size));
}
// 重置对象
@@ -159,5 +149,3 @@ export function resetObject<T extends Record<string, any>>(obj: T): void {
(obj as Record<string, any>)[key] = null;
});
}
+8 -20
View File
@@ -25,42 +25,30 @@ request.interceptors.request.use(
} else delete config.headers["Authorization"];
return config;
},
function (error: AxiosError): string {
(error: AxiosError) => {
// 对请求错误做些什么
return error.message || "Request error";
}
return Promise.reject(error);
},
);
// 添加响应拦截器
request.interceptors.response.use(
function (response: AxiosResponse): any {
(response: AxiosResponse): any => {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response.data;
},
async function (error: AxiosError): Promise<string> {
(error: AxiosError) => {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
console.log("Response error", error);
// 对响应错误做点什么(统一 reject,保证调用方 try/catch 能捕获到错误)
if (error.response?.status === 401) {
window.$msg.warning("无效的token");
cookies.remove("token");
cookies.remove("userinfo");
// window.$modal({
// title: "无效的token",
// content: "token已失效,需要登录,请登录 =>",
// handdleSubmit: () => {
// router.replace("/login");
// },
// });
// router.replace("/login");
return "Unauthorized";
}
return error.message || "Response error";
}
return Promise.reject(error);
},
);
export default request;
+14 -14
View File
@@ -1,15 +1,15 @@
/**
* js 文件下使用这个做类型提示
* @type import('naive-ui').GlobalThemeOverrides
*/
const themeOverrides = {
common: {
primaryColor: '#ec66ab',
primaryColorHover: '#ec66ab',
primaryColorPressed: '#ec66ab',
primaryColorSuppl: '#ec66ab',
},
// ...
}
/**
* js 文件下使用这个做类型提示
* @type import('naive-ui').GlobalThemeOverrides
*/
const themeOverrides = {
common: {
primaryColor: "#ec66ab",
primaryColorHover: "#ec66ab",
primaryColorPressed: "#ec66ab",
primaryColorSuppl: "#ec66ab",
},
// ...
};
export default themeOverrides;
export default themeOverrides;
+111 -28
View File
@@ -13,7 +13,7 @@
<n-scrollbar ref="virtualListInst" class="relative z-[1] py-6" :style="boxStyle" @scroll="handleScroll">
<section
class="relative mx-auto w-[82%] rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-6 shadow-[0_20px_50px_rgba(110,124,112,0.11)] backdrop-blur-[20px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-[linear-gradient(120deg,rgba(255,255,255,0.24),transparent_45%)] after:content-['']">
class="relative mx-auto w-[82%] rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-6 shadow-[0_1px_3px_rgba(97,121,146,0.05),0_16px_38px_rgba(97,121,146,0.09),0_44px_84px_rgba(178,128,88,0.07),inset_0_1px_0_rgba(255,255,255,0.7)] backdrop-blur-[20px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-[linear-gradient(120deg,rgba(255,255,255,0.24),transparent_45%)] after:content-['']">
<div class="max-w-[48rem]">
<h1 class="m-0 text-[clamp(1.8rem,2.5vw,2.8rem)] leading-[1.2] text-[#2c3a4a]">
把喜欢的画面收进一片更柔和的光里
@@ -24,11 +24,11 @@
</div>
<div
class="mt-6 rounded-full border border-white/62 bg-[linear-gradient(135deg,rgba(255,249,241,0.76),rgba(236,245,243,0.72)),radial-gradient(circle_at_top_right,rgba(255,255,255,0.28),transparent_34%)] shadow-[inset_0_1px_0_rgba(255,255,255,0.55)]">
<n-input class="gallery-search" round size="large" v-model:value="kw" @keyup.enter="onSearch"
placeholder="输入关键词,按回车开始搜索">
class="mt-6 rounded-full border border-white/62 bg-[linear-gradient(135deg,rgba(255,249,241,0.76),rgba(236,245,243,0.72)),radial-gradient(circle_at_top_right,rgba(255,255,255,0.28),transparent_34%)] shadow-[inset_0_1px_0_rgba(255,255,255,0.55)] transition-[box-shadow,border-color] duration-300">
<n-input class="gallery-search" round size="large" clearable v-model:value="kw" @keyup.enter="onSearch"
placeholder="输入关键词,按回车开始搜索">
<template #suffix>
<n-icon class="cursor-pointer text-slate-500" size="large">
<n-icon class="cursor-pointer text-slate-500 transition-colors duration-200 hover:text-primary" size="large" @click="onSearch">
<icon-search />
</n-icon>
</template>
@@ -37,43 +37,62 @@
</section>
<section
class="mx-auto mt-6 w-[82%] rounded-[30px] border border-white/62 bg-white/42 p-6 shadow-[0_18px_42px_rgba(109,126,117,0.1)] backdrop-blur-[18px]">
class="mx-auto mt-6 w-[82%] rounded-[30px] border border-white/62 bg-white/42 p-6 shadow-[0_1px_3px_rgba(97,121,146,0.05),0_14px_30px_rgba(97,121,146,0.09),0_36px_72px_rgba(178,128,88,0.07),inset_0_1px_0_rgba(255,255,255,0.7)] backdrop-blur-[18px]">
<div class="mb-5 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div class="text-[1.6rem] font-semibold text-[#2f4050]">图片列表</div>
<div class="mt-1 flex items-center gap-2 text-[13px] text-slate-500">
<span v-if="searchKeyword" class="inline-flex items-center rounded-full bg-[rgba(236,102,171,0.1)] px-2.5 py-0.5 font-semibold text-[rgb(212,88,154)]">
关键词{{ searchKeyword }}
</span>
<span>{{ galleryStats.status }}</span>
</div>
</div>
<div
class="inline-flex items-center whitespace-nowrap rounded-full border border-white/80 bg-white/68 px-4 py-[0.65rem] text-[#526376]">
<div class="inline-flex items-center gap-2 self-start rounded-full bg-white/68 px-5 py-2 text-[#526376] shadow-[0_2px_8px_rgba(97,121,146,0.08),inset_0_1px_0_rgba(255,255,255,0.7)]">
<span class="inline-block h-2 w-2 rounded-full" :class="loading ? 'animate-pulse bg-[#ec66ab]' : 'bg-[#7fb69a]'"></span>
{{ galleryStats.loaded }}
</div>
</div>
<Waterfall ref="waterfall" :list="fileList" :gutter="gutter" :columns="column" class="w-full" img-selector="url"
<Waterfall ref="waterfall" :list="fileList" :gutter="gutter" :columns="column" row-key="filepath" class="w-full" img-selector="url"
animation-effect="fadeIn" :animation-duration="1000" :animation-delay="300" backgroundColor="transparent">
<template #item="{ item }">
<div
class="group relative flex h-full items-center justify-center overflow-hidden rounded-[24px] border border-white/82 bg-[linear-gradient(180deg,rgba(255,252,248,0.92),rgba(239,244,241,0.78))] shadow-[0_16px_34px_rgba(116,132,124,0.14)] backdrop-blur-[14px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:border-white/196 hover:shadow-[0_22px_42px_rgba(102,123,112,0.18)]">
<LazyImg class="overflow-hidden rounded-[20px]" :Pwidth="item.width" :Pheight="item.height"
:url="item.filepath" />
class="gallery-card group relative flex h-full items-center justify-center overflow-hidden rounded-[12px] border border-white/82 bg-[linear-gradient(180deg,rgba(255,252,248,0.92),rgba(239,244,241,0.78))] backdrop-blur-[14px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1">
<LazyImg class="gallery-img overflow-hidden rounded-[12px]" :Pwidth="item.width" :Pheight="item.height"
:url="item.thumbnail" :preUrl="item.filepath" />
<div
class="absolute left-0 right-0 top-0 z-10 overflow-hidden text-ellipsis whitespace-nowrap rounded-[14px] border border-white/22 bg-[rgba(39,50,65,0.1)] px-3 py-1 items-center justify-center text-sm text-white hidden backdrop-blur-[12px] group-hover:flex">
class="pointer-events-none absolute left-0 right-0 top-0 z-10 truncate rounded-[12px] border-b border-white/22 bg-gradient-to-b from-[rgba(39,50,65,0.55)] to-transparent px-3 pb-3 pt-1.5 text-sm text-white opacity-0 backdrop-blur-[6px] transition-opacity duration-300 group-hover:opacity-100">
{{ item.filename }}
</div>
<div
class="absolute bottom-0 left-0 right-0 z-10 flex items-center justify-between gap-3 rounded-[14px] border border-white/22 bg-[rgba(39,50,65,0.5)] px-3 py-1 text-white backdrop-blur-[12px]">
class="absolute bottom-0 left-0 right-0 z-10 flex translate-y-[102%] items-center justify-between gap-3 rounded-[12px] border border-white/22 bg-[rgba(39,50,65,0.55)] px-3 py-1.5 text-white backdrop-blur-[12px] transition-transform duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] group-hover:translate-y-0">
<div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-[#eef4f8]">
<span class="font-semibold text-[#ffd8cb]">{{ item.nickname }}</span> 分享
</div>
<div
class="inline-flex flex-shrink-0 cursor-pointer items-center rounded-full bg-white/18 px-4 py-1 text-[0.86rem] font-semibold text-[#ffe3d3] transition-colors duration-200 hover:bg-white/24 hover:text-white"
@click="downloadFile(item.filepath)">
<icon-download class="mr-1 h-5 w-5" />
class="inline-flex flex-shrink-0 cursor-pointer items-center rounded-full bg-white/18 px-3.5 py-1 text-[0.86rem] font-semibold text-[#ffe3d3] transition-[background-color,transform] duration-200 hover:scale-105 hover:bg-white/28 hover:text-white active:scale-95"
@click.stop="downloadFile(item.filepath)">
<icon-download class="mr-1 h-4 w-4" />
下载
</div>
</div>
</div>
</template>
</Waterfall>
<div v-if="fileList.length === 0 && !loading" class="flex flex-col items-center justify-center py-16 text-slate-400">
<icon-search class="mb-3 h-10 w-10 opacity-50" />
<p class="text-[15px]">{{ searchKeyword ? '未找到相关图片,换个关键词试试' : '暂无图片' }}</p>
</div>
<div v-if="loading" class="flex items-center justify-center gap-2 py-6 text-sm text-slate-400">
<icon-loading class="zhuan h-5 w-5 text-primary" />
<span>{{ fileList.length ? '加载中...' : '首次加载中...' }}</span>
</div>
<div v-else-if="isLoadAll && fileList.length" class="py-5 text-center text-[13px] text-slate-400">
已加载全部 {{ fileList.length }} 张图片
</div>
</section>
</n-scrollbar>
</div>
@@ -120,6 +139,7 @@ const cwidth = ref(240)
const column = ref(5)
const gutter = ref(18)
const isLoadAll = ref(false)
let requestSeq = 0
const searchKeyword = computed(() => kw.value.trim())
@@ -180,6 +200,7 @@ function resetGalleryState() {
async function getFileList() {
if (loading.value) return
loading.value = true
const seq = ++requestSeq
try {
const res = await $http.file.getFileList({
@@ -189,6 +210,9 @@ async function getFileList() {
})
const list = res.data as GalleryItem[]
// 过期请求直接丢弃,避免搜索时旧结果覆盖新结果
if (seq !== requestSeq) return
if (list.length < ps.value) {
isLoadAll.value = true
}
@@ -197,8 +221,10 @@ async function getFileList() {
} catch (error) {
console.error('获取图片列表失败:', error)
} finally {
loading.value = false
renderWaterfall()
if (seq === requestSeq) {
loading.value = false
renderWaterfall()
}
}
}
@@ -216,12 +242,17 @@ const handleScroll = throttle((e: Event) => {
pn.value += 1
getFileList()
}
}, 1000)
}, 300)
function onSearch() {
pn.value = 1
kw.value = kw.value.trim()
isLoadAll.value = false
// 立即清空旧列表,避免搜索时看到上一批结果
fileList.value = []
// 若正在加载中,先解除守卫,让新搜索请求可以发起
loading.value = false
requestSeq += 1
getFileList()
}
@@ -240,16 +271,17 @@ onMounted(() => {
calculateColumns()
updateBoxStyle()
getFileList()
window.addEventListener('resize', calculateColumns)
window.addEventListener('resize', updateBoxStyle)
window.addEventListener('scroll', handleScroll)
window.addEventListener('resize', handleResize)
})
function handleResize() {
calculateColumns()
updateBoxStyle()
}
onUnmounted(() => {
resetGalleryState()
window.removeEventListener('resize', calculateColumns)
window.removeEventListener('resize', updateBoxStyle)
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
// 防抖函数
@@ -260,8 +292,14 @@ onUnmounted(() => {
:deep(.gallery-search .n-input) {
background: rgba(255, 255, 255, 0.74) !important;
border: 1px solid rgba(255, 255, 255, 0.7) !important;
box-shadow: 0 10px 28px rgba(109, 138, 132, 0.1) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.6),
0 2px 6px rgba(97, 121, 146, 0.05),
0 10px 24px rgba(97, 121, 146, 0.09) !important;
backdrop-filter: blur(18px);
transition:
border-color 0.25s ease,
box-shadow 0.25s ease;
}
:deep(.gallery-search .n-input__input-el) {
@@ -274,7 +312,52 @@ onUnmounted(() => {
:deep(.gallery-search .n-input.n-input--focus) {
border-color: rgba(122, 160, 148, 0.7) !important;
box-shadow: 0 16px 38px rgba(118, 149, 139, 0.16) !important;
box-shadow:
0 0 0 4px rgba(122, 160, 148, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.6),
0 2px 6px rgba(97, 121, 146, 0.06),
0 16px 36px rgba(97, 121, 146, 0.14) !important;
}
/* 图片卡片 hover 增强 */
.gallery-card {
box-shadow:
0 1px 3px rgba(97, 121, 146, 0.05),
0 12px 26px rgba(97, 121, 146, 0.09),
0 32px 60px rgba(178, 128, 88, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.72) !important;
}
.gallery-card:hover {
border-color: rgba(255, 255, 255, 0.88) !important;
box-shadow:
0 2px 6px rgba(97, 121, 146, 0.06),
0 18px 38px rgba(97, 121, 146, 0.14),
0 48px 90px rgba(178, 128, 88, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.8) !important;
}
.gallery-img {
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.gallery-card:hover .gallery-img {
transform: scale(1.045);
}
/* 加载图标旋转 */
.zhuan {
animation: gallery-spin 1.5s linear infinite;
}
@keyframes gallery-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
:deep(.n-image img) {
+20 -12
View File
@@ -356,10 +356,14 @@ function getTagColor(tag: string) {
}
async function getPlinkList() {
const res = await $http.plink.getPlinkList() as PlinkListResponse
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tagname))))
tagColorList.value = buildTagColorMap(uniqueTags)
pList.value = res.data
try {
const res = await $http.plink.getPlinkList() as PlinkListResponse
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tagname))))
tagColorList.value = buildTagColorMap(uniqueTags)
pList.value = res.data
} catch {
pList.value = []
}
}
function gotoWebsite(url: string) {
@@ -404,15 +408,19 @@ async function submitPut() {
return
}
const res = await $http.plink.addPlink(plinkData)
if (res?.code !== 200) {
$msg.error(res.msg)
return
}
try {
const res = await $http.plink.addPlink(plinkData)
if (res?.code !== 200) {
$msg.error(res.msg)
return
}
$msg.success(res.msg)
resetFormData()
await getPlinkList()
$msg.success(res.msg)
resetFormData()
await getPlinkList()
} catch {
$msg.error('提交失败,请稍后再试')
}
}
onMounted(() => {
+1 -1
View File
@@ -4,7 +4,7 @@
</div>
</template>
<script setup lang="js">
<script setup lang="ts">
definePage({
name: 'widget',
+490 -147
View File
@@ -12,12 +12,21 @@
class="pointer-events-none absolute inset-0 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]">
</div>
<div class="relative z-[1] mx-auto flex h-full w-[86%] gap-6 py-6">
<aside class="hidden w-[21rem] shrink-0 lg:block">
<!-- 阅读进度条 -->
<div class="pointer-events-none absolute inset-x-0 top-0 z-[60] h-[3px] bg-white/10">
<div
class="h-full rounded-r-full bg-gradient-to-r from-[#ec66ab] via-[#c98a5d] to-[#76b0d6] transition-[width] duration-150 ease-out"
:style="{ width: `${progress}%` }"></div>
</div>
<div class="relative z-[1] mx-auto flex h-full w-[92%] max-w-[1500px] gap-6 py-6 xl:w-[86%]">
<aside v-if="!loading && !loadError" class="hidden w-[21rem] shrink-0 lg:block">
<div class="sticky top-6">
<section class="blog-detail-card p-6">
<div class="relative z-[1]">
<h1 class="mt-3 text-[2rem] text-center leading-[1.2] text-[#2c3a4a]">阅读目录</h1>
<h1 class="mt-3 text-center text-[2rem] leading-[1.2] text-[#2c3a4a]">阅读目录</h1>
<div class="mx-auto mt-2 h-1 w-16 rounded-full bg-gradient-to-r from-[#ec66ab] via-[#c98a5d] to-[#76b0d6] opacity-70">
</div>
<div class="blog-detail-card-soft mt-6 p-4">
<div class="mb-3 flex items-center justify-between">
@@ -25,11 +34,11 @@
<span class="rounded-full cursor-pointer bg-primary px-3 py-1 text-xs text-white">正文导航</span>
</div>
<div class="text-[13px] leading-5 text-[#526376]">
<div class="toc-list text-[13px] leading-5 text-[#526376]">
<template v-for="item in tocItems" :key="item.id">
<details v-if="item.children.length" class="group" :open="item.open">
<summary
class="flex cursor-pointer list-none items-center gap-2 py-1 text-[#526376] marker:hidden select-none">
class="flex cursor-pointer list-none items-center gap-2 rounded-lg py-1 text-[#526376] marker:hidden select-none">
<span
class="text-[11px] text-[#8a98a1] transition-transform duration-200 group-open:rotate-90"></span>
<span class="truncate transition-colors duration-200"
@@ -39,9 +48,10 @@
</span>
</summary>
<div class="ml-4 border-l border-[#d7dfdf] pl-3">
<div v-for="child in item.children" :key="child.id" class="py-1">
<div class="cursor-pointer truncate transition-colors duration-200"
:class="activeHeadingId === child.id ? 'text-primary font-semibold' : 'text-[#687983] hover:text-[#2f4050]'"
<div v-for="child in item.children" :key="child.id" class="py-1"
:style="{ paddingLeft: `${Math.min(child.level - 2, 3) * 10}px` }">
<div class="toc-child cursor-pointer truncate rounded-md transition-colors duration-200"
:class="activeHeadingId === child.id ? 'toc-active text-primary font-semibold' : 'text-[#687983] hover:text-[#2f4050]'"
@click="scrollToHeading(child.id)">
{{ child.text }}
</div>
@@ -49,8 +59,8 @@
</div>
</details>
<div v-else class="cursor-pointer py-1 transition-colors duration-200"
:class="activeHeadingId === item.id ? 'text-primary font-semibold' : 'text-[#526376] hover:text-[#2f4050]'"
<div v-else class="toc-leaf cursor-pointer rounded-md py-1 transition-colors duration-200"
:class="activeHeadingId === item.id ? 'toc-active text-primary font-semibold' : 'text-[#526376] hover:text-[#2f4050]'"
@click="scrollToHeading(item.id)">
{{ item.text }}
</div>
@@ -67,79 +77,172 @@
</aside>
<section class="blog-list-panel min-w-0 flex-1 overflow-hidden p-4 sm:p-6">
<n-scrollbar ref="detailScrollbar" :style="contentStyle" trigger="none" class="pr-1 mb-4 rounded-[3em] overflow-hidden">
<div class="space-y-5 pr-1">
<section class="blog-detail-card p-6">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">文章详情</div>
<h1
class="my-4 !w-full ml-1/2 text-center text-[1.9rem] font-semibold leading-[1.3] text-[#2f4050] sm:text-[2.15rem]">
{{ blogData.title || '文章内容' }}
</h1>
</div>
<n-breadcrumb class="shrink-0 rounded-full border border-white/52 bg-white/16 px-4 text-[#526376]"
separator="|">
<n-breadcrumb-item href="/blog/blog">文章</n-breadcrumb-item>
<n-breadcrumb-item>{{ blogData.title }}</n-breadcrumb-item>
</n-breadcrumb>
</div>
<div class="flex flex-wrap items-center justify-right gap-x-5 gap-y-2 text-sm text-[#708091]">
<em class="flex items-center gap-1 not-italic text-primary">
<n-icon><icon-author /></n-icon>
{{ blogData.nickname }}
</em>
<em class="flex items-center gap-1 not-italic">
<n-icon><icon-date /></n-icon>
{{ formatTime(blogData.updated_at, 'YYYY年MM月DD日 hh时') }}
</em>
<!-- 加载骨架屏 -->
<div v-if="loading" class="flex h-full flex-col gap-5">
<section class="blog-detail-card p-6">
<div class="flex items-center justify-between">
<div class="blog-skeleton h-5 w-24 rounded-full"></div>
<div class="blog-skeleton h-7 w-40 rounded-full"></div>
</div>
<div class="mt-6 space-y-3">
<div class="blog-skeleton mx-auto h-9 w-2/3 rounded-xl"></div>
<div class="blog-skeleton mx-auto h-4 w-1/3 rounded-full"></div>
<div class="mx-auto mt-5 flex gap-3">
<div class="blog-skeleton h-6 w-16 rounded-full"></div>
<div class="blog-skeleton h-6 w-20 rounded-full"></div>
<div class="blog-skeleton h-6 w-14 rounded-full"></div>
</div>
</div>
</section>
<section class="blog-detail-card p-6">
<div class="blog-skeleton h-10 w-36 rounded-xl"></div>
<div class="mt-6 space-y-3">
<div class="blog-skeleton h-4 w-full rounded-full"></div>
<div class="blog-skeleton h-4 w-11/12 rounded-full"></div>
<div class="blog-skeleton h-4 w-4/5 rounded-full"></div>
<div class="blog-skeleton h-4 w-full rounded-full"></div>
<div class="blog-skeleton h-4 w-2/3 rounded-full"></div>
</div>
</section>
</div>
<div class="mt-4 flex flex-wrap justify-center gap-3">
<div v-for="(item, index) in tags" :key="index" :style="{ backgroundColor: getTagColor(item) }"
class="inline-flex cursor-pointer items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(88,108,125,0.16)]">
<n-icon><icon-tag /></n-icon>
{{ item }}
</div>
</div>
</section>
<section class="blog-detail-card p-4 sm:p-5">
<n-collapse @item-header-click="AISum" arrow-placement="right">
<n-collapse-item>
<template #header>
<div class="flex items-center text-primary">
<icon-star class="mx-2 w-6" />
<div class="text-[20px] font-bold">AI 摘要</div>
</div>
</template>
<template #header-extra>
<div class="flex text-primary">
<icon-star class="mx-2 w-6" />
</div>
</template>
<div class="whitespace-pre-line indent-[2em] text-[#66778b]">
{{ msg }}
</div>
</n-collapse-item>
</n-collapse>
</section>
<section class="blog-detail-card px-5 py-6 sm:px-8 sm:py-8">
<MdPreview class="relative article-preview !bg-transparent" :modelValue="markdown" />
</section>
<!-- 加载失败 -->
<div v-else-if="loadError" class="flex h-full flex-col items-center justify-center gap-4 text-center">
<div class="blog-detail-card px-10 py-12">
<div class="text-[1.2rem] font-semibold text-[#2f4050]">文章加载失败</div>
<p class="mt-2 text-sm text-[#8a98a1]">{{ loadError }}</p>
<button type="button" @click="reload"
class="mt-6 cursor-pointer rounded-full bg-primary px-6 py-2 text-sm text-white shadow-[0_10px_24px_rgba(88,108,125,0.24)] transition-transform hover:-translate-y-0.5">
重新加载
</button>
</div>
</n-scrollbar>
</div>
<button
v-show="showBackToTop"
type="button"
aria-label="回到顶部"
class="absolute bottom-6 right-6 z-[3] flex h-11 w-11 items-center justify-center rounded-full outline-none border-none bg-white/24 text-primary shadow-[0_16px_28px_rgba(109,126,117,0.14),inset_0_1px_0_rgba(255,255,255,0.24)] backdrop-blur-[16px] transition-all duration-300 hover:-translate-y-1 hover:bg-white/36"
@click="scrollToTop"
>
<icon-backtop class="h-5 w-5" />
</button>
<template v-else>
<n-scrollbar ref="detailScrollbar" :style="contentStyle" trigger="none"
class="pr-1 mb-4 rounded-[3em] overflow-hidden">
<div class="space-y-5 pr-1">
<section class="blog-detail-card p-6">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">文章详情</div>
<h1
class="my-4 text-center text-[1.9rem] font-semibold leading-[1.3] text-[#2f4050] sm:text-[2.15rem]">
{{ blogData.title || '文章内容' }}
</h1>
<div
class="mx-auto mb-1 h-1 w-24 rounded-full bg-gradient-to-r from-[#ec66ab] via-[#c98a5d] to-[#76b0d6] opacity-70">
</div>
</div>
<n-breadcrumb class="shrink-0 rounded-full border border-white/52 bg-white/16 px-4 text-[#526376]"
separator="|">
<n-breadcrumb-item href="/blog/blog">文章</n-breadcrumb-item>
<n-breadcrumb-item>{{ blogData.title }}</n-breadcrumb-item>
</n-breadcrumb>
</div>
<div
class="mt-2 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm text-[#708091]">
<em
class="flex items-center gap-1.5 rounded-full bg-white/46 px-3 py-1 not-italic text-primary shadow-[0_4px_12px_rgba(88,108,125,0.08)]">
<n-icon><icon-author /></n-icon>
{{ blogData.nickname }}
</em>
<em class="flex items-center gap-1.5 not-italic">
<n-icon><icon-date /></n-icon>
{{ formatTime(blogData.updated_at, 'YYYY年MM月DD日 hh时') }}
</em>
</div>
<div class="mt-5 flex flex-wrap justify-center gap-3">
<div v-for="(item, index) in tags" :key="index" :style="{ backgroundColor: getTagColor(item) }"
class="inline-flex cursor-pointer items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(88,108,125,0.16)] transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_12px_26px_rgba(88,108,125,0.28)]">
<n-icon><icon-tag /></n-icon>
{{ item }}
</div>
</div>
</section>
<section class="blog-detail-card p-4 sm:p-5">
<n-collapse :expanded-names="aiExpanded ? ['ai-summary'] : []" arrow-placement="right"
@update:expanded-names="onAiExpand">
<n-collapse-item name="ai-summary">
<template #header>
<div class="flex items-center gap-2.5">
<div
class="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-[#ec66ab] to-[#c98a5d] text-white shadow-[0_6px_14px_rgba(236,102,171,0.28)]">
<n-icon :class="aiState === 'loading' || aiState === 'streaming' ? 'animate-spin' : ''">
<icon-star class="h-4 w-4" />
</n-icon>
</div>
<div class="text-[20px] font-bold text-[#2f4050]">AI 摘要</div>
</div>
</template>
<template #header-extra>
<div class="flex items-center gap-2 text-[0.78rem] text-[#8a98a1]">
<span v-if="aiState === 'loading'">正在思考</span>
<span v-else-if="aiState === 'streaming'">生成中</span>
<span v-else-if="aiState === 'done'">已完成</span>
<span v-else-if="aiState === 'error'">生成失败</span>
<span v-else>点击展开生成</span>
</div>
</template>
<div class="whitespace-pre-line pt-1 text-[#66778b]">
<!-- 加载中 -->
<div v-if="aiState === 'loading'" class="flex items-center gap-3 py-2 text-[#8a98a1]">
<n-spin :size="18" />
<span class="text-sm">AI 正在阅读文章请稍候</span>
</div>
<!-- 流式输出 / 已完成 -->
<div v-else-if="aiState === 'streaming' || (aiState === 'done' && aiMsg)"
class="indent-[2em] leading-7">
{{ aiMsg }}<span v-if="aiState === 'streaming'" class="ai-cursor"></span>
</div>
<!-- 生成失败 -->
<div v-else-if="aiState === 'error'"
class="flex flex-col items-start gap-3 rounded-2xl bg-[#fff5f5] px-4 py-3 text-sm text-[#c96a6a]">
<span>摘要生成失败{{ aiError || '未知错误' }}</span>
<n-button @click="triggerAi"
class="cursor-pointer rounded-full bg-primary px-4 py-1 text-xs text-white transition-opacity hover:opacity-85">
重新生成
</n-button>
</div>
<!-- 初始提示 -->
<div v-else class="py-2 text-sm text-[#8a98a1]">
点击上方标题展开AI 将为你生成这篇文章的摘要
</div>
<!-- 完成后重新生成 -->
<div v-if="aiState === 'done' && aiMsg" class="mt-3 flex justify-end">
<n-button @click="triggerAi"
class="cursor-pointer rounded-full bg-white/60 px-4 py-1 text-xs text-[#7a8797] shadow-[0_8px_20px_rgba(88,108,125,0.1)] transition-all hover:text-primary">
重新生成
</n-button>
</div>
</div>
</n-collapse-item>
</n-collapse>
</section>
<section class="blog-detail-card px-5 py-6 sm:px-8 sm:py-8">
<div
class="pointer-events-none absolute inset-x-10 top-0 h-[3px] rounded-b-full bg-gradient-to-r from-[#ec66ab] via-[#c98a5d] to-[#76b0d6] opacity-60">
</div>
<MdPreview class="relative article-preview !bg-transparent" :modelValue="markdown"
:onHtmlChanged="scheduleSyncHeadings" />
</section>
</div>
</n-scrollbar>
<button v-show="showBackToTop" type="button" aria-label="回到顶部"
class="absolute bottom-6 right-6 z-[3] flex h-11 w-11 items-center justify-center rounded-full outline-none border-none bg-white/24 text-primary shadow-[0_16px_28px_rgba(109,126,117,0.14),inset_0_1px_0_rgba(255,255,255,0.24)] backdrop-blur-[16px] transition-all duration-300 hover:-translate-y-1 hover:bg-white/36 hover:shadow-[0_20px_36px_rgba(109,126,117,0.22),inset_0_1px_0_rgba(255,255,255,0.32)]"
@click="scrollToTop">
<icon-backtop class="h-5 w-5" />
</button>
</template>
</section>
</div>
</div>
@@ -229,6 +332,7 @@ const detailScrollbar = ref<DetailScrollbarInst | null>(null)
const scrollElement = ref<HTMLElement | undefined>(undefined)
const activeHeadingId = ref('')
const showBackToTop = ref(false)
const progress = ref(0)
const headingElementMap = shallowRef(new Map<string, HTMLElement>())
const route = useRoute()
const nav = $store.nav.useNavStore() as { navH: number }
@@ -236,8 +340,6 @@ const nav = $store.nav.useNavStore() as { navH: number }
const boxStyle = ref<Record<string, string>>({})
const contentStyle = ref<Record<string, string>>({})
const tags = ref<string[]>([])
const msg = ref('展开后可生成摘要...')
const aimask = ref(false)
const markdown = ref('')
const blogData = ref<BlogDetail>({
aid: 'preview-only',
@@ -248,6 +350,18 @@ const blogData = ref<BlogDetail>({
cont: '',
})
/** 页面加载状态 */
const loading = ref(true)
const loadError = ref('')
/** AI 摘要状态机 */
type AiState = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
const aiState = ref<AiState>('idle')
const aiExpanded = ref(false)
const aiMsg = ref('')
const aiError = ref('')
let aiAbortController: AbortController | null = null
function getTagColor(tag: string) {
const normalized = tag.trim()
if (!normalized) return SOFT_TAG_PALETTE[0]
@@ -260,16 +374,41 @@ function getTagColor(tag: string) {
return SOFT_TAG_PALETTE[Math.abs(hash) % SOFT_TAG_PALETTE.length]
}
/** 文章字数与预估阅读时间(按 300 字/分钟估算) */
const stats = computed(() => {
const text = markdown.value
.replace(/```[\s\S]*?```/g, ' ')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/[#>*`~\-_|]/g, ' ')
.trim()
const chars = text.replace(/\s+/g, '').length
const minutes = Math.max(1, Math.ceil(chars / 300))
return { chars, minutes }
})
const tocItems = computed<TocItem[]>(() => {
const lines = (blogData.value.cont || '').split(/\r?\n/)
const flatItems: TocChildItem[] = []
let inFence = false
lines.forEach((line) => {
const trimmed = line.trim()
// 跳过围栏代码块,避免其中的 # 行被误解析为标题
if (/^(`{3,}|~{3,})/.test(trimmed)) {
inFence = !inFence
return
}
if (inFence) return
// 跳过引用块内的伪标题
if (/^>/.test(trimmed)) return
const match = line.match(/^(#{1,6})\s+(.+?)\s*$/)
if (!match) return
const level = match[1].length
const text = match[2].replace(/[`*_~]/g, '').trim()
// 去除 Markdown 标记与转义反斜杠(如 1\. 引言 → 1. 引言)
const text = match[2].replace(/[`*_~\\]/g, '').trim()
if (!text) return
flatItems.push({
@@ -329,10 +468,37 @@ function getPreviewRoot() {
}
async function syncScrollElement() {
await nextTick()
// n-scrollbar 内部容器可能跨多个帧才挂载完成,带重试等待
for (let i = 0; i < 10; i++) {
await nextTick()
const container = getScrollbarContainer()
if (container) {
scrollElement.value = container
bindScrollListener()
scheduleSyncHeadings()
return
}
await new Promise(resolve => requestAnimationFrame(resolve))
}
scrollElement.value = getScrollbarContainer() || undefined
requestAnimationFrame(() => {
syncPreviewHeadings()
}
/** 绑定滚动监听(幂等,避免重复绑定) */
function bindScrollListener() {
const el = scrollElement.value
if (!el || el.dataset.blogScrollBound === '1') return
el.dataset.blogScrollBound = '1'
el.addEventListener('scroll', updateActiveHeading)
}
/** 等待渲染完成后再同步标题映射(md-editor 渲染可能跨多个帧) */
function scheduleSyncHeadings() {
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
syncPreviewHeadings()
})
})
})
}
@@ -343,14 +509,22 @@ function syncPreviewHeadings() {
const headings = previewRoot.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
const flatTocItems = tocItems.value.flatMap(item => [item, ...item.children])
const nextHeadingMap = new Map<string, HTMLElement>()
const used = new Set<number>()
headings.forEach((heading, index) => {
const item = flatTocItems[index]
if (item) {
heading.id = item.id
heading.style.scrollMarginTop = '24px'
nextHeadingMap.set(item.id, heading)
headings.forEach((heading) => {
// 优先按标题文本匹配(渲染结果),其次按顺序兜底,避免解析与渲染不一致导致错位
const headingText = (heading.textContent || '').trim()
let itemIndex = flatTocItems.findIndex((item, i) => !used.has(i) && item.text === headingText)
if (itemIndex === -1) {
itemIndex = flatTocItems.findIndex((item, i) => !used.has(i))
}
if (itemIndex === -1) return
used.add(itemIndex)
const item = flatTocItems[itemIndex]
heading.id = item.id
heading.style.scrollMarginTop = '24px'
nextHeadingMap.set(item.id, heading)
})
headingElementMap.value = nextHeadingMap
@@ -360,6 +534,10 @@ function syncPreviewHeadings() {
function updateActiveHeading() {
const container = scrollElement.value
showBackToTop.value = !!container && container.scrollTop > 240
if (container) {
const max = container.scrollHeight - container.clientHeight
progress.value = max > 0 ? Math.min(100, Math.round((container.scrollTop / max) * 100)) : 0
}
const previewRoot = getPreviewRoot()
if (!container || !previewRoot) return
@@ -385,33 +563,39 @@ function updateActiveHeading() {
function scrollToHeading(id: string) {
const container = scrollElement.value
const target = headingElementMap.value.get(id) || null
if (!container || !target) return
if (!container) return
// 优先取内存映射,兜底直接按 id 查找 DOM(防止映射未就绪)
let target = headingElementMap.value.get(id) || null
if (!target) {
target = document.getElementById(id)
}
if (!target) return
const containerRect = container.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const nextTop = container.scrollTop + (targetRect.top - containerRect.top) - 24
detailScrollbar.value?.scrollTo?.({
top: Math.max(0, nextTop),
behavior: 'smooth',
})
smoothScrollTo(container, Math.max(0, nextTop))
activeHeadingId.value = id
}
function scrollToTop() {
detailScrollbar.value?.scrollTo?.({
top: 0,
behavior: 'smooth',
})
const container = scrollElement.value
if (container) smoothScrollTo(container, 0)
activeHeadingId.value = tocItems.value[0]?.id || ''
}
/** 平滑滚动(原生实现,兼容不支持 options 的环境) */
function smoothScrollTo(container: HTMLElement, top: number) {
try {
container.scrollTo({ top, behavior: 'smooth' })
} catch {
container.scrollTop = top
}
}
watch([markdown, tocItems], async () => {
await nextTick()
requestAnimationFrame(() => {
syncPreviewHeadings()
})
scheduleSyncHeadings()
})
function parseTags(str: string) {
@@ -421,58 +605,130 @@ function parseTags(str: string) {
async function getBlogDetail() {
const bid = (route.params as RouteParams).bid || '0'
const res = await $http.blog.getBlogDetail(bid) as BlogDetailResponse
blogData.value = res.data
tags.value = parseTags(res.data.tags)
markdown.value = res.data.cont
await syncScrollElement()
try {
loading.value = true
loadError.value = ''
const res = await $http.blog.getBlogDetail(bid) as BlogDetailResponse
blogData.value = res.data
tags.value = parseTags(res.data.tags)
markdown.value = res.data.cont
document.title = res.data.title ? `${res.data.title} - 文章详情` : '文章详情'
// 先结束 loading,让 v-else 分支的 n-scrollbar 真正挂载,再同步滚动容器
loading.value = false
await syncScrollElement()
} catch (err) {
loadError.value = (err as Error)?.message || '网络异常,请稍后重试'
window.$msg?.error('文章加载失败')
loading.value = false
}
}
async function AISum() {
if (aimask.value || !blogData.value.cont) return
function reload() {
loadError.value = ''
getBlogDetail()
}
const response = await fetch('https://api.siliconflow.cn/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer sk-jwwmhmxsjtseyekknqmamlvzmrkmwfvuacnssbwfufogrkdg'
},
body: JSON.stringify({
model: 'Qwen/Qwen3-8B',
messages: [
{
role: 'system',
content: '你是一个摘要生成工具,请用中文总结我发送给你的文章内容,不要换行,不要超过300字,只介绍文章内容,语气需要俏皮一些,不需要提出建议。开头固定为“这篇文章介绍了”。'
},
{ role: 'user', content: blogData.value.cont }
],
stream: true
})
})
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
if (!reader) return
msg.value = ''
let done = false
while (!done) {
const { value, done: doneReading } = await reader.read()
done = doneReading
const chunk = decoder.decode(value ?? new Uint8Array())
const match = chunk.match(/"content":"(.*?)"/)
if (match?.[1]) {
msg.value += match[1]
aimask.value = true
async function copyLink() {
const url = window.location.href
try {
await navigator.clipboard.writeText(url)
window.$msg?.success('文章链接已复制')
} catch {
// 兼容非安全上下文(http
try {
const textarea = document.createElement('textarea')
textarea.value = url
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
window.$msg?.success('文章链接已复制')
} catch {
window.$msg?.warning('复制失败,请手动复制地址栏链接')
}
}
}
function onAiExpand(names: string | string[] | null) {
const expanded = Array.isArray(names) ? names.includes('ai-summary') : names === 'ai-summary'
aiExpanded.value = expanded
if (expanded) triggerAi()
}
async function triggerAi() {
if (!blogData.value.cont) {
aiState.value = 'error'
aiError.value = '文章内容为空,无法生成摘要'
return
}
if (aiState.value === 'loading' || aiState.value === 'streaming') return
aiState.value = 'loading'
aiMsg.value = ''
aiError.value = ''
aiAbortController?.abort()
aiAbortController = new AbortController()
try {
const bid = (route.params as RouteParams).bid || '0'
const baseURL = 'https://www.hxyouzi.com'
const response = await fetch(`${baseURL}/api/art/ai/${bid}`, {
method: 'POST',
signal: aiAbortController.signal,
})
if (!response.ok) throw new Error(`请求失败(HTTP ${response.status}`)
const reader = response.body?.getReader()
if (!reader) throw new Error('当前浏览器不支持流式读取')
const decoder = new TextDecoder('utf-8')
let buffer = ''
aiState.value = 'streaming'
while (true) {
const { value, done } = await reader.read()
if (done) break
// 累积解码,避免 SSE 事件被 chunk 边界截断
buffer += decoder.decode(value ?? new Uint8Array(), { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
lines.forEach(line => handleSseLine(line.trim()))
}
if (buffer.trim()) handleSseLine(buffer.trim())
if (!aiMsg.value) {
aiState.value = 'error'
aiError.value = '未获取到摘要内容,请重试'
} else {
aiState.value = 'done'
}
} catch (err) {
if ((err as Error)?.name === 'AbortError') return
aiState.value = 'error'
aiError.value = (err as Error)?.message || '生成失败,请稍后重试'
}
}
function handleSseLine(line: string) {
if (!line.startsWith('data: ')) return
const payload = line.slice(6).trim()
if (!payload || payload === '[DONE]') return
// 服务端错误事件,如 [ERROR]xxx
if (/^\[(error|ERROR)\]/.test(payload)) {
aiState.value = 'error'
aiError.value = payload.replace(/^\[(error|ERROR)\]/, '') || '服务端返回错误'
return
}
aiMsg.value += payload
}
onMounted(async () => {
updateLayout()
await getBlogDetail()
scrollElement.value?.addEventListener('scroll', updateActiveHeading)
window.addEventListener('resize', updateLayout)
// getBlogDetail 内部会在 n-scrollbar 挂载后调用 syncScrollElement 绑定滚动事件
await getBlogDetail()
})
onBeforeUnmount(() => {
@@ -482,6 +738,93 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="less">
/* 目录列表:激活指示条与悬停反馈 */
.toc-list {
max-height: 60vh;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: rgba(139, 158, 166, 0.35);
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
.toc-leaf,
.toc-child {
position: relative;
padding-left: 10px;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 3px;
height: 0;
border-radius: 9999px;
background: #ec66ab;
transform: translateY(-50%);
transition: height 0.25s ease;
}
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
.toc-active {
background: linear-gradient(90deg, rgba(236, 102, 171, 0.08), rgba(236, 102, 171, 0));
box-shadow: inset 3px 0 0 rgba(236, 102, 171, 0.85);
&::before {
height: 14px;
}
}
/* 加载骨架屏 */
.blog-skeleton {
background: linear-gradient(90deg, rgba(210, 220, 225, 0.35) 25%, rgba(255, 255, 255, 0.65) 50%, rgba(210, 220, 225, 0.35) 75%);
background-size: 200% 100%;
animation: blog-skeleton-wave 1.4s ease-in-out infinite;
}
@keyframes blog-skeleton-wave {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* AI 摘要流式输出光标 */
.ai-cursor {
display: inline-block;
width: 2px;
height: 1.1em;
margin-left: 2px;
vertical-align: -2px;
background: #ec66ab;
animation: ai-cursor-blink 0.9s steps(2, start) infinite;
}
@keyframes ai-cursor-blink {
to {
visibility: hidden;
}
}
:deep(.article-preview .md-editor-preview) {
background: transparent !important;
}
+19 -11
View File
@@ -341,21 +341,29 @@ function getRandomFromPalette(index = 0): string {
}
async function getBlogList() {
const res = await $http.blog.getBlogList({
category: category.value,
page_size: page_size.value,
page_num: page_num.value,
}) as BlogListResponse
try {
const res = await $http.blog.getBlogList({
category: category.value,
page_size: page_size.value,
page_num: page_num.value,
}) as BlogListResponse
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tags))))
tagColorList.value = buildTagColorMap(uniqueTags)
blogList.value = res.data
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tags))))
tagColorList.value = buildTagColorMap(uniqueTags)
blogList.value = res.data
} catch {
blogList.value = []
}
}
async function getCateList() {
const res = await $http.blog.getCateList() as CateListResponse
cateList.value = res.data
total.value = allTotal.value
try {
const res = await $http.blog.getCateList() as CateListResponse
cateList.value = res.data
total.value = allTotal.value
} catch {
cateList.value = []
}
}
onMounted(() => {
File diff suppressed because it is too large Load Diff