整体优化
This commit is contained in:
+14
-23
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 会产生 Infinity,Array.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
@@ -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
@@ -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
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
<script setup lang="ts">
|
||||
|
||||
definePage({
|
||||
name: 'widget',
|
||||
|
||||
+490
-147
@@ -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
@@ -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
Reference in New Issue
Block a user