style: 更新UI样式和字体设置

This commit is contained in:
2026-03-20 21:13:55 +08:00
parent 1e198af3d3
commit 9586c67a46
21 changed files with 2859 additions and 3606 deletions

5
.gitignore vendored
View File

@ -30,3 +30,8 @@ coverage
*.sw?
*.tsbuildinfo
.agent
.agents
.agent/*
.agents/*

View File

@ -6,7 +6,7 @@
<n-layout-content class="dcontent-1">
<router-view />
</n-layout-content>
<n-layout-footer v-show="route.path == '/home'" class="flex justify-center dfooter-1">
<n-layout-footer v-show="route.path == '/home'" class="flex justify-center">
<div class="beian" ref="footer">
<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>
@ -106,8 +106,13 @@ onMounted(() => {
<style scoped lang="less">
.dcontent-1 {
background: transparent;
}
background-color: #fbfbfb;
:deep(.n-layout),
:deep(.n-layout-header),
:deep(.n-layout-content) {
background: transparent !important;
}
.beian {
@ -127,4 +132,4 @@ onMounted(() => {
.beian .swag {
margin-left: 20px;
}
</style>
</style>

View File

@ -7,6 +7,46 @@
font-weight: normal;
}
:root {
--font-sans-zh: "LXGW WenKai", "霞鹜文楷", "STKaiti", "KaiTi", serif;
--font-mono-code: "Fira Code", "Cascadia Code", Consolas, monospace;
}
html,
body,
#app {
font-family: var(--font-sans-zh);
}
body,
button,
input,
select,
textarea,
.n-config-provider,
.n-layout,
.n-card,
.n-button,
.n-input,
.n-base-selection,
.n-modal,
.n-dropdown,
.devui-tag-item,
.d-tag {
font-family: var(--font-sans-zh) !important;
}
code,
pre,
kbd,
samp,
.hljs,
.md-editor-code,
.md-editor-code-head,
.md-editor-code-content {
font-family: var(--font-sans-zh) !important;
}
.n-scrollbar-rail__scrollbar {
background-color: #f6cbe770 !important;
}
@ -19,4 +59,4 @@
/* 添加平滑过渡效果 */
#nprogress .bar {
transition: width 0.2s ease-in-out;
}
}

View File

@ -1,5 +1,6 @@
@import url("https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/style.css");
@import "qweather-icons/font/qweather-icons.css";
@import 'md-editor-v3/lib/preview.css';
@import "nprogress/nprogress.css";
@import "./base.less";
@import "./base.less";

File diff suppressed because it is too large Load Diff

View File

@ -1,65 +1,125 @@
<template>
<n-card class="mt-10 rounden-[10px] w-[500px]" shadow="never">
<n-tabs v-model:value="tid" justify-content="space-evenly" animated>
<template>
<div class="w-[500px] rounded-[28px] border border-white/80 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.14),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.84),rgba(242,247,251,0.78))] p-8 shadow-[0_28px_60px_rgba(75,96,120,0.18)] backdrop-blur-[20px]">
<n-tabs class="bg-transparent" v-model:value="tid" justify-content="space-evenly" animated>
<n-tab-pane name="tab1" tab="登录">
<n-form ref="formLogin" layout="vertical" :data="loginData" :rules="rules">
<n-form-item field="username">
<n-input v-model:value="loginData.username" placeholder="请输入用户名" />
<n-form ref="formLogin" layout="vertical" :model="loginData" :rules="rules">
<n-form-item path="username" class="auth-form-item">
<n-input class="auth-input" v-model:value="loginData.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item field="password">
<n-input v-model:value="loginData.password" type="password" show-password-on="click" placeholder="请输入密码" />
<n-form-item path="password" class="auth-form-item mt-4">
<n-input
class="auth-input"
v-model:value="loginData.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
/>
</n-form-item>
<n-form-item class="form-operation-wrap">
<n-button class="w-full" type="primary" @click="login"> </n-button>
<n-form-item class="mt-6 auth-form-item">
<div class="mx-auto flex w-[78%] gap-3">
<n-button
class="flex-1 !h-9 !rounded-full !border !border-white/70 !bg-white/55 !text-[14px] !font-semibold !tracking-[0.03em] !text-slate-600 backdrop-blur-[10px]"
@click="resetLogin"
>
重置
</n-button>
<n-button
class="flex-1 !h-9 !rounded-full !border !border-[#f4a9b8]/60 !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !text-[14px] !font-semibold !tracking-[0.03em]"
type="primary"
@click="login"
>
登录
</n-button>
</div>
</n-form-item>
</n-form>
</n-tab-pane>
<n-tab-pane name="tab2" tab="注册">
<n-form ref="formReg" layout="vertical" :data="regData" :rules="rrules">
<n-form-item field="username">
<n-input v-model:value="regData.username" placeholder="请输入用户名" />
<n-form ref="formReg" layout="vertical" :model="regData" :rules="rrules">
<n-form-item path="nickname" class="auth-form-item mt-4">
<n-input class="auth-input" v-model:value="regData.nickname" placeholder="请输入昵称" />
</n-form-item>
<n-form-item field="password">
<n-input v-model:value="regData.password" type="password" show-password-on="click" placeholder="请输入用密码" />
<n-form-item path="username" class="auth-form-item">
<n-input class="auth-input" v-model:value="regData.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item field="nickname">
<n-input v-model:value="regData.nickname" placeholder="请输入昵称" />
<n-form-item path="password" class="auth-form-item">
<n-input
class="auth-input"
v-model:value="regData.password"
type="password"
show-password-on="click"
placeholder="请输入用户密码"
/>
</n-form-item>
<n-form-item class="form-operation-wrap">
<n-button class="w-full" type="primary" @click="register">注册</n-button>
<n-form-item class="mt-6 auth-form-item">
<div class="mx-auto flex w-[78%] gap-3">
<n-button
class="flex-1 !h-9 !rounded-full !border !border-white/70 !bg-white/55 !text-[14px] !font-semibold !tracking-[0.03em] !text-slate-600 backdrop-blur-[10px]"
@click="resetRegister"
>
重置
</n-button>
<n-button
class="flex-1 !h-9 !rounded-full !border !border-[#f4a9b8]/60 !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !text-[14px] !font-semibold !tracking-[0.03em]"
type="primary"
@click="register"
>
注册
</n-button>
</div>
</n-form-item>
</n-form>
</n-tab-pane>
</n-tabs>
</n-card>
</div>
</template>
<script setup lang="ts">
const router = useRouter()
const props = defineProps({
setVisible: {
type: Function,
default: () => { },
}
})
// 登录注册逻辑
const tid = ref("tab1");
const formLogin: any = ref(null);
const loginData = reactive({
username: "",
password: ""
});
import type { FormInst, FormRules } from 'naive-ui'
const formReg: any = ref(null);
const regData = reactive({
username: "",
password: "",
nickname: ""
});
interface UserInfo {
token: string
userinfo: Record<string, unknown>
}
interface ApiResponse<T = Record<string, unknown>> {
code: number
msg: string
data: T
}
interface LoginFormData {
username: string
password: string
}
interface RegisterFormData extends LoginFormData {
nickname: string
}
const props = defineProps<{
setVisible?: (visible: boolean) => void
}>()
const tid = ref('tab1')
const formLogin = ref<FormInst | null>(null)
const loginData = reactive<LoginFormData>({
username: '',
password: ''
})
const formReg = ref<FormInst | null>(null)
const regData = reactive<RegisterFormData>({
username: '',
password: '',
nickname: ''
})
const usrLog = $store.log.useLogStore()
const rules: any = reactive({
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: validateUsername, trigger: 'blur' },
@ -69,79 +129,122 @@ const rules: any = reactive({
{ validator: validatePassword, trigger: 'blur' }
]
})
const rrules: any = reactive({
const rrules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: validateUsername, trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ validator: validatePassword, trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
]
})
function login() {
console.log('>>> --> login --> formLogin.value:', usrLog.isLogin)
formLogin.value?.validate(async (is: boolean) => {
if (is) {
$msg.error('信息填写不正确,请检查后再提交')
} else {
const res = await $http.user.login(loginData)
if (res?.code !== 200) {
$msg.error('登录失败')
return
}
$cookies.set('token', res.data.token, '1d')
$cookies.set('userinfo', res.data.userinfo, '1d')
$msg.success(res.msg)
usrLog.setIsLogin(true)
props.setVisible(false)
}
})
}
function register() {
formReg.value.validate(async (is: boolean) => {
if (is) {
$msg.error('信息填写不正确,请检查后再提交')
} else {
const res = await $http.user.register(regData)
if (res?.code !== 200) {
$msg.error('注册失败')
return
}
$msg.success(res.msg)
tid.value = 'tab1'
loginData.username = regData.username
regData.username = ''
regData.nickname = ''
regData.password = ''
}
})
}
async function validateForm(form: FormInst | null) {
if (!form) return false
function validateUsername(rule: any, value: string, callback: Function) {
if (/^[a-zA-Z][a-zA-Z0-9]{3,15}$/.test(value)) return callback()
else return callback(new Error('请输入4-16位字母或数字,且以字母开头'))
}
function validatePassword(rule: any, value: string, callback: Function) {
if (/^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$/.test(value)) return callback()
else return callback(new Error('密码长度为6-12位且必须包含数字和字母'))
}
</script>
<style scoped lang="less">
:deep(.devui-tabs__nav) {
display: flex;
justify-content: center;
li a span {
// width: 20%;
font-size: 18px !important;
font-weight: 500;
try {
await form.validate()
return true
} catch {
$msg.error('信息填写不正确,请检查后再提交')
return false
}
}
:deep(.devui-form__item--horizontal) {
margin-top: 30px;
function persistLogin(data: UserInfo) {
$cookies.set('token', data.token, '1d')
$cookies.set('userinfo', data.userinfo, '1d')
usrLog.setIsLogin(true)
props.setVisible?.(false)
}
</style>
function resetLogin() {
loginData.username = ''
loginData.password = ''
}
function resetRegister() {
regData.username = ''
regData.password = ''
regData.nickname = ''
}
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
}
persistLogin(res.data)
$msg.success(res.msg)
}
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
}
$msg.success(res.msg)
tid.value = 'tab1'
loginData.username = regData.username
resetRegister()
}
function validateUsername(_rule: unknown, value: string) {
if (/^[a-zA-Z][a-zA-Z0-9]{3,15}$/.test(value)) {
return true
}
return new Error('请输入 4-16 位字母或数字,且以字母开头')
}
function validatePassword(_rule: unknown, value: string) {
if (/^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$/.test(value)) {
return true
}
return new Error('密码长度为 6-12 位,且必须包含数字和字母')
}
</script>
<style scoped lang="less">
:deep(.n-tabs),
:deep(.n-tabs-nav),
:deep(.n-tabs-pane-wrapper),
:deep(.n-tab-pane) {
background: transparent !important;
}
:deep(.n-tabs-tab.n-tabs-tab--active) {
color: #ec66ab !important;
}
:deep(.n-tabs-bar) {
background: linear-gradient(135deg, #ec66ab, #f48a6f) !important;
}
:deep(.n-input) {
border-radius: 9999px !important;
background: rgba(255, 255, 255, 0.08) !important;
}
:deep(.n-form-item) {
grid-template-rows: unset;
margin-top: 1em;
}
</style>

View File

@ -61,17 +61,22 @@ onMounted(async () => {
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;
}
});

View File

@ -1,120 +1,126 @@
<template>
<div class="pr-8">
<n-card embedded class="mt-4 shadow">
<!-- <div class="dt-card mt-10 bg-white"> -->
<template>
<div class="pr-8 !bg-transparent">
<n-card
embedded
class="mt-4 overflow-hidden rounded-[24px] border border-primary bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.12),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.1),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,249,253,0.8))] backdrop-blur-[18px]"
>
<template #header>
<div class="flex items-center">
<icon-time class="w-5 mr-2 text-primary"></icon-time>
<div class="flex items-center font-semibold text-slate-700">
<icon-time class="mr-2 w-5 text-primary"></icon-time>
时间日期
</div>
</template>
<template #default>
<div class="w-full pr-20 text-center text-[#ec66ab] font-500 text-4xl font-[yj]">{{ t }}</div>
<div class="mt-3 flex justify-between">
<span>今年已过了{{ jq.dayOfYear }}</span>
<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>
<img v-if="jq.type != 0" class="absolute top-0 right-4" width="120" src="@/assets/images/offwork.png" alt="">
<img v-else class="absolute top-0 right-4" width="120" src="@/assets/images/onwork.png" alt="">
<img v-if="jq.type != 0" class="absolute right-4 top-0" width="120" src="@/assets/images/offwork.png" alt="offwork">
<img v-else class="absolute right-4 top-0" width="120" src="@/assets/images/onwork.png" alt="onwork">
</template>
<!-- </div> -->
</n-card>
<n-card embedded class="mt-4 shadow">
<n-card
embedded
class="mt-4 overflow-hidden rounded-[24px] border border-white/75 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.12),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.1),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,249,253,0.8))] backdrop-blur-[18px]"
>
<template #header>
<div class="flex items-center">
<icon-news class="w-5 mr-2 text-primary"></icon-news>
<div class="flex items-center font-semibold text-slate-700">
<icon-news class="mr-2 w-5 text-primary"></icon-news>
百度新闻
</div>
</template>
<template #default>
<div class="py-1" v-for="i in bdNews" :key="i.id">
<!-- 淡蓝色 -->
<a class="text-[#526ecc] no-underline hover:text-primary hover:underline flex justify-between truncate" :href="i.url" target="_blank">
<span class="flex items-center">{{ i.index }}. {{ i.title }}
<icon-hot v-show="i.index < 4" class="ml-2 w-4 text-[red] inline-block"></icon-hot>
<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"
: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>
<span class="text-primary">{{ i.hot }}</span>
<span class="ml-3 shrink-0 text-primary">{{ i.hot }}</span>
</a>
</div>
<div class="mt-2 justify-between flex items-center">
<div class="w-2/5 h-px bg-[#ec66ab]"></div>
<a class="text-[#526ecc] no-underline text-[#ec66ab] flex items-center" href="https://www.baidu.com/s?ie=utf-8&wd=百度新闻"
target="_blank">
<div class="mt-2 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"
href="https://www.baidu.com/s?ie=utf-8&wd=百度新闻"
target="_blank"
>
更多
<icon-right class="ml-1 w-4 text-primary inline-block"></icon-right>
<icon-right class="ml-1 inline-block w-4 text-primary"></icon-right>
</a>
<div class="w-2/5 h-px bg-[#ec66ab]"></div>
<div class="h-px w-2/5 bg-gradient-to-l from-transparent via-[#ec66ab] to-[#ec66ab]/40"></div>
</div>
</template>
</n-card>
<n-card embedded class="mt-4 shadow">
<n-card
embedded
class="mt-4 overflow-hidden rounded-[24px] border border-white/75 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.12),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.1),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,249,253,0.8))] backdrop-blur-[18px]"
>
<template #header>
<div class="flex items-center">
<icon-date class="w-5 mr-2 h-[20px]" ></icon-date>
<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 text-[#ec66ab] font-500">{{ jq.yearTips }} {{ jq.lunarCalendar }}</div>
<div class="ml-12 font-semibold text-[#ec66ab]">{{ jq.yearTips }} {{ jq.lunarCalendar }}</div>
</div>
</template>
<template #default>
<div class="truncate text-sm mx-[5%]">
<div class="mx-[5%] truncate text-sm text-slate-600">
{{ jq.suit }}
</div>
<div class="truncate text-sm mx-[5%]">
<div class="mx-[5%] truncate text-sm text-slate-600">
{{ jq.avoid }}
</div>
<div class="w-full flex justify-center">
<img class="mt-2 w-[90%] rounded" :src="jqImg" alt=""></img>
<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="节气图">
</div>
<div class="mt-2 text-center">{{ jq.solarTerms }}</div>
<div class="mt-3 text-center font-medium text-slate-600">{{ jq.solarTerms }}</div>
</template>
</n-card>
</div>
</template>
<script setup lang="ts">
//mark import
import { formatTime } from '@/util/index';
import { formatTime } from '@/util/index'
//mark data
const t = ref<any>('')
const d = ref<any>('')
let timer: any = null
const jq = ref<any>("")
const jqImg = ref<any>("")
const jq = ref<any>('')
const jqImg = ref<any>('')
const bdNews = ref<any>([])
//mark method
function getTime() {
const date = new Date();
const date = new Date()
t.value = formatTime(date, 'hh:mm:ss')
d.value = formatTime(date, 'YYYY 年 MM 月 DD 日')
}
async function getJq() {
const res = await $http.mix.getJq({
date: formatTime(new Date(), "YYYYMMDD")
date: formatTime(new Date(), 'YYYYMMDD')
})
console.log('>>> --> getJq --> res:', res.data)
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
jqImg.value = `https://www.hxyouzi.com/img/jq/${j}.png?${timestamp}`
}
async function getBdhot() {
const res = await $http.mix.getBdhot()
console.log('>>> --> getBdhot --> res:', res.data)
// 取前5条aa
bdNews.value = res.data.slice(0, 5)
}
//mark 周期、内置函数等
onMounted(() => {
getTime()
getJq()
getBdhot()
@ -122,11 +128,10 @@ onMounted(() => {
getTime()
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
<style scoped lang="less">
@ -135,14 +140,17 @@ onUnmounted(() => {
src: url('@/assets/font/LCDML.woff2');
}
:deep(.n-card > .n-card__content, .n-card > .n-card__footer){
padding: 8px 25px !important;
:deep(.n-card > .n-card__content),
:deep(.n-card > .n-card__footer) {
padding: 12px 24px !important;
}
// .dt-card {
// background-image: url('@/assets/images/中秋节中国风边框34.png');
// background-size: 100% 100%;
// padding: 20px 40px;
// }
</style>
:deep(.n-card > .n-card-header) {
padding: 16px 24px 8px !important;
}
:deep(.n-card) {
background: transparent !important;
box-shadow:unset;
}
</style>

View File

@ -2,8 +2,15 @@
<Teleport to="body">
<div v-if="visible"
class="fixed top-0 left-0 z-[1000] w-[100vw] h-[100vh] flex items-center justify-center bg-[rgba(0,0,0,0.5)]">
<slot></slot>
<div @click.prevent="handdleClose" class="absolute w-8 h-8 top-4 right-4 rounded-full bg-white flex items-center justify-center text-[#ec66ab] cursor-pointer">X</div>
<div class="relative inline-flex">
<slot></slot>
<div
@click.prevent="handdleClose"
class="absolute right-4 top-4 z-[1] flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-white/92 text-[#ec66ab] shadow-[0_8px_18px_rgba(89,108,130,0.14)] backdrop-blur-[8px]"
>
X
</div>
</div>
</div>
</Teleport>
</template>
@ -32,4 +39,4 @@ function handdleClose() {
</script>
<style scoped lang="less"></style>
<style scoped lang="less"></style>

View File

@ -1,180 +1,225 @@
<template>
<div ref="nav" class="main-nav hidden lg:flex justify-between bg-white">
<!-- 网站Logo -->
<div class="px-5 items-center flex cursor-pointer w-1/5" slot="brand" @click="goHome">
<img :src="logo" alt="柚子的网站" class="h-9 align-middle" />
<template>
<div
ref="nav"
class="relative hidden items-center justify-between bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.18),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.14),transparent_24%),linear-gradient(180deg,rgba(239,231,218,0.82)_0%,rgba(232,240,239,0.68)_46%,rgba(243,241,232,0.28)_100%)] px-4 py-1.5 shadow-[0_6px_16px_rgba(110,124,112,0.05)] backdrop-blur-[14px] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-[-28px] after:h-[34px] after:bg-[linear-gradient(180deg,rgba(240,236,226,0.58),rgba(232,240,239,0.22),transparent)] after:content-[''] lg:flex"
>
<div
class="flex ml-4 cursor-pointer items-center rounded-full border border-white/65 bg-white/42 px-4 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px] transition-colors duration-200 hover:bg-white/56"
@click="goHome"
>
<img :src="logo" alt="柚子的网站" class="h-9 align-middle drop-shadow-[0_6px_14px_rgba(236,102,171,0.15)]" />
</div>
<!-- 主导航菜单 -->
<div class="w-2/5">
<n-menu :icon-size="12" v-model:value="activeKey" class="" mode="horizontal" :options="menuOptions" />
<div class="flex h-[42px] items-center rounded-full border border-white/65 bg-white/42 px-3 py-0.5 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
<n-menu :icon-size="12" v-model:value="activeKey" mode="horizontal" :options="menuOptions" />
</div>
<!-- 用户区域 -->
<div class="!text-[#ec66ab] flex items-center cursor-pointer" @click="gotoHf">
<span class="flex items-center location-info truncate">
<n-icon class="mr-1">
<div
class="flex cursor-pointer items-center rounded-full border border-white/65 bg-white/42 px-4 py-1.5 text-[#ec66ab] shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px] transition-colors duration-200 hover:bg-white/56"
@click="gotoHf"
>
<span class="flex items-center truncate text-[15px] font-medium text-slate-600">
<n-icon class="mr-1 text-[#ec66ab]">
<icon-loc class="w-[26px] h-[26px]"></icon-loc>
</n-icon>
{{ locationInfo }}
</span>
<span class="mx-3 text-gray-300">|</span>
<span class="weather-info mr-2 truncate">{{ wea }}</span>
<i :class="'qiIcon qi-' + weaIcon + '-fill'"></i>
<span class="weather-info ml-4">{{ temp }}°C</span>
<span class="mx-3 text-slate-300">|</span>
<span class="mr-2 truncate text-[15px] text-slate-500">{{ wea }}</span>
<i :class="'qiIcon qi-' + weaIcon + '-fill'" class="text-[#ec66ab]"></i>
<span class="ml-4 text-[15px] font-semibold text-slate-700">{{ temp }}&deg;C</span>
</div>
<div class="flex items-center justify-end mr-8 w-1/20">
<n-dropdown class="cursor-pointer w-[100px]" v-if="userinfo" :options="oprOp" @select="handleSelect"
trigger="hover">
<div class="flex items-center">
<n-avatar round :src="userinfo.ava_url" class="cursor-pointer" alt="用户的头" />
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
<div class="mr-4 flex w-1/20 items-center justify-end">
<n-dropdown v-if="userinfo" class="w-[128px] cursor-pointer" :options="oprOp" @select="handleSelect" trigger="hover">
<div class="flex items-center rounded-full border border-white/65 bg-white/46 px-2 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
<n-avatar round :src="userinfo.ava_url" class="cursor-pointer ring-2 ring-white/80" alt="用户头像" />
<div class="ml-2 truncate text-sm font-medium text-slate-600">{{ userinfo.nickname }}</div>
</div>
</n-dropdown>
<div v-else class="flex items-center" @click="toLogin">
<n-button size="small" type="primary" @click="toLogin">登录</n-button>
<div v-else class="flex items-center">
<n-button
size="small"
type="primary"
class="!h-8 !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] px-5 !font-semibold shadow-[0_12px_24px_rgba(236,102,171,0.24)]"
@click="toLogin"
>
登录
</n-button>
</div>
</div>
<!-- 登录弹窗 -->
<masked :visible="visible" :setVisible="setVisible">
<loginModal :setVisible="setVisible" />
</masked>
</div>
<div class="flex justify-between bg-white lg:hidden">
<div class="pl-2 items-centerflex" slot="brand" @click="">
<img :src="logo" alt="柚子的网站" class="h-9 align-middle" />
<div class="relative flex items-center justify-between bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.18),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.14),transparent_24%),linear-gradient(180deg,rgba(239,231,218,0.84)_0%,rgba(232,240,239,0.68)_46%,rgba(243,241,232,0.32)_100%)] px-3 py-2 shadow-[0_6px_16px_rgba(110,124,112,0.05)] backdrop-blur-[14px] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-[-24px] after:h-[30px] after:bg-[linear-gradient(180deg,rgba(240,236,226,0.58),rgba(232,240,239,0.22),transparent)] after:content-[''] lg:hidden">
<div class="flex items-center rounded-full border border-white/65 bg-white/42 px-3 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
<img :src="logo" alt="柚子的网站" class="h-9 align-middle drop-shadow-[0_6px_14px_rgba(236,102,171,0.15)]" />
</div>
<div class="flex items-center mr-2">
<div v-if="userinfo" class="flex items-center">
<n-avatar :src="userinfo.ava_url" round class="cursor-pointer" alt="用户头" />
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
<div v-if="userinfo" class="flex items-center rounded-full border border-white/65 bg-white/46 px-2 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
<n-avatar :src="userinfo.ava_url" round class="cursor-pointer ring-2 ring-white/80" alt="用户头" />
<div class="ml-2 text-sm font-medium text-slate-600">{{ userinfo.nickname }}</div>
</div>
<div v-else class="flex items-center">
<!-- <n-avatar round class="cursor-pointer" @click="toLogin"></n-avatar>
<div class="cursor-pointer ml-2 text-gray text-sm" @click="toLogin">登录</div> -->
<n-button type="primary" size="small" @click="toLogin">登录</n-button>
<div v-else class="flex items-center">
<n-button
type="primary"
size="small"
class="!h-8 !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] px-5 !font-semibold shadow-[0_12px_24px_rgba(236,102,171,0.24)]"
@click="toLogin"
>
登录
</n-button>
</div>
</div>
</div>
<!-- 修改头像弹窗 -->
<masked v-if="usrLog.isLogin" :visible="editModal" :setVisible="handdleItemCancel">
<div class="w-[500px] bg-white p-8 rounded-md">
<div class="text-center text-lg mb-8">修改头像</div>
<n-upload class="flex justify-center items-center" action="https://www.hxyouzi.com/api/user/avaupload"
:headers="{ Authorization: `Bearer ${token}` }" @finish="handleUpload" @before-upload="beforeUpload"
:show-file-list="false" accept="image/*">
<div class="w-[500px] rounded-[28px] border border-white/80 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.14),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.84),rgba(242,247,251,0.78))] p-8 shadow-[0_28px_60px_rgba(75,96,120,0.18)] backdrop-blur-[20px]">
<div class="mb-8 text-center text-xl font-bold tracking-[0.02em] text-slate-800">修改头像</div>
<n-upload
class="flex justify-center items-center"
action="https://www.hxyouzi.com/api/user/avaupload"
:headers="{ Authorization: `Bearer ${token}` }"
@finish="handleUpload"
@before-upload="beforeUpload"
:show-file-list="false"
accept="image/*"
>
<div class="flex flex-col justify-center items-center">
<n-avatar :size="80" :src="userinfo.ava_url" round class="cursor-pointer" alt="用户头" />
<em class="cursor-pointer mt-4 text-gray text-sm">点击头像上传不超过3m的图片</em>
<n-avatar :size="80" :src="userinfo?.ava_url" round class="cursor-pointer ring-4 ring-white/80 shadow-[0_16px_30px_rgba(102,129,156,0.16)]" alt="用户头" />
<em class="mt-4 cursor-pointer text-sm text-slate-500">点击头像上传不超过 3M 的图片</em>
</div>
</n-upload>
<div class="mt-8 flex justify-between">
<n-button class="w-[98%]" secondary @click="handdleItemCancel">取消</n-button>
<n-button class="w-[98%] !h-11 !rounded-full !border-none !bg-white/90 !font-bold !tracking-[0.04em] !text-slate-600 shadow-[0_12px_24px_rgba(89,108,130,0.12)]" secondary @click="handdleItemCancel">取消</n-button>
</div>
</div>
</masked>
</template>
<script setup lang="ts">
// 从@/icon/menu引入所有的svg文件
import logo from '@/assets/images/logo.png';
import loginModal from '@/components/Login.vue';
import masked from '@/components/mask.vue';
import artiSvg from '@/icon/menu/arti.svg';
// import downSvg from '@/icon/menu/download.svg';
import homeSvg from '@/icon/menu/home.svg';
import linkSvg from '@/icon/menu/link.svg';
import picSvg from '@/icon/menu/pic.svg';
// import settingSvg from '@/icon/menu/setting.svg';
import loginModal from '@/components/Login.vue'
import masked from '@/components/mask.vue'
import type { UploadFileInfo } from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter, RouterLink } from 'vue-router';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
interface UserInfo {
ava_url?: string
nickname?: string
}
interface UploadResponse {
code: number
msg: string
data?: {
token?: string
userinfo?: UserInfo
}
}
const activeKey = ref('home')
const route = useRoute();
const visible = ref(false);
const router = useRouter();
const locationInfo = ref("获取位置中...");
const locationInfo = ref('获取位置中...');
const latitude = ref<number | null>(null);
const longitude = ref<number | null>(null);
const fxlink = ref<string>("#")
const wea = ref("")
const weaIcon = ref<string>("")
const fxlink = ref<string>('#')
const wea = ref('')
const weaIcon = ref<string>('')
const temp = ref<number>(0);
const userinfo: any = ref(null)
const userinfo = ref<UserInfo | null>(null)
const nav: any = useTemplateRef('nav')
const navx = $store.nav.useNavStore()
const usrLog = $store.log.useLogStore()
const menuOptions = ref([
const menuOptions = [
{
label: () => h(RouterLink, { to: '/home', class: 'flex items-center justify-center' }, { default: () => '首页' }),
key: "home",
key: 'home',
icon: () => h(homeSvg)
},
{
label: () => h(RouterLink, { to: '/gallery', class: 'flex items-center justify-center' }, { default: () => '画廊' }),
key: "gallery",
key: 'gallery',
icon: () => h(picSvg)
},
{
label: () => h(RouterLink, { to: '/blog', class: 'flex items-center justify-center' }, { default: () => '文章' }),
key: "blog",
key: 'blog',
icon: () => h(artiSvg)
},
// {
// label: () => h(RouterLink, { to: '/widget', class: 'flex items-center justify-center' }, { default: () => '工具' }),
// key: "widget",
// icon: () => h(downSvg)
// },
// {
// label: () => h(RouterLink, { to: '/apps', class: 'flex items-center justify-center' }, { default: () => '软件' }),
// key: "apps",
// icon: () => h(settingSvg)
// },
{
label: () => h(RouterLink, { to: '/plink', class: 'flex items-center justify-center' }, { default: () => '友链' }),
key: "plink",
key: 'plink',
icon: () => h(linkSvg)
},
]);
const oprOp = ref([
]
const oprOp = [
{
key: 'logout',
label: '退出登录',
key: 'avatar',
label: '更换头像',
},
{
key: 'console',
label: '控制台',
},
{
key: 'avatar',
label: '更换头像',
key: 'logout',
label: '退出登录',
}
])
]
const editModal = ref<boolean>(false)
const token = ref<string>("")
const editModal = ref(false)
const token = ref('')
function syncUserFromCookies() {
userinfo.value = $cookies.get('userinfo') || null
token.value = $cookies.get('token') || ''
}
function updateNavHeight() {
nextTick(() => {
const h: number = nav.value?.clientHeight || 0
if (h > 0) navx.setNavH(h)
})
}
function beforeUpload(data: { file: UploadFileInfo }) {
console.log('>>> --> beforeUpload --> data:', data.file.file?.size)
const size = data.file.file?.size || 4 * 1024 * 1024
if (size > 3 * 1024 * 1024) {
$msg.error('上传的图片大小不能超过3M')
$msg.error('上传的图片大小不能超过 3M')
return false
}
return true
}
function handleUpload(f: any) {
console.log('上传完成', JSON.parse(f.event.target.response));
const res = JSON.parse(f.event.target.response)
const responseText = f?.event?.target?.response
if (!responseText) {
$msg.error('上传失败')
editModal.value = false
return
}
const res: UploadResponse = JSON.parse(responseText)
if (res.code === 200) {
// $msg.success(res.msg);
autoLogin()
} else {
$msg.error(res.msg);
@ -189,44 +234,42 @@ async function autoLogin() {
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)
userinfo.value = res.data.userinfo
syncUserFromCookies()
}
function handdleItemCancel() {
editModal.value = false
}
function setVisible(v: any) {
function setVisible(v: boolean) {
visible.value = v
}
function handleSelect(key: string) {
console.log('>>> --> handleSelect --> key:', key)
if (key == 'logout') {
if (key === 'logout') {
logout()
return
}
if (key == 'console') {
if (key === 'console') {
gotoConsole()
return
}
if (key == 'avatar') {
if (key === 'avatar') {
editModal.value = true
return
}
}
// 获取地理位置
const getLocation = () => {
if (!navigator.geolocation) {
locationInfo.value = "您的浏览器不支持地理位置定位";
locationInfo.value = '您的浏览器不支持地理位置定位';
return;
}
@ -235,31 +278,26 @@ const getLocation = () => {
latitude.value = position.coords.latitude;
longitude.value = position.coords.longitude;
const jw = `${longitude.value.toFixed(2)},${latitude.value.toFixed(2)}`;
// 这里可以添加调用后端API获取具体位置名称的逻辑
handdleJw(jw);
await handdleJw(jw);
},
async (error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
locationInfo.value = "正在定位...";
break;
case error.POSITION_UNAVAILABLE:
locationInfo.value = "正在定位...";
break;
case error.TIMEOUT:
locationInfo.value = "正在定位...";
locationInfo.value = '正在定位...';
break;
}
const res = await $http.mix.getIp();
latitude.value = Number(res.data.data.lat);
longitude.value = Number(res.data.data.lng);
const jw = `${longitude.value?.toFixed(4)},${latitude.value?.toFixed(4)}`;
// 这里可以添加调用后端API获取具体位置名称的逻辑
handdleJw(jw);
try {
const res = await $http.mix.getIp();
latitude.value = Number(res.data.data.lat);
longitude.value = Number(res.data.data.lng);
const jw = `${longitude.value?.toFixed(4)},${latitude.value?.toFixed(4)}`;
await handdleJw(jw);
} catch (e) {
locationInfo.value = '定位失败';
}
},
{
enableHighAccuracy: false,
@ -270,101 +308,129 @@ const getLocation = () => {
};
async function handdleJw(jw: string) {
const zxs = ['北京', '重庆', '天津', '上海']
// 根据经纬度获取物理位置
const loc = await $http.mix.getLocation({ location: jw });
console.log('>>> --> handdleJw --> loc:', loc)
fxlink.value = loc.data.fxLink
if (loc.code == 200) {
const zxs = [
'北京',
'重庆',
'天津',
'上海'
]
try {
const loc = await $http.mix.getLocation({ location: jw });
if (loc.code !== 200) {
$msg.error(loc.msg);
return
}
const data = loc.data;
fxlink.value = data.fxLink || '#'
if (zxs.includes(data.adm2)) {
locationInfo.value = `${data.adm1}${data.name}`;
} else {
locationInfo.value = `${data.adm1}${data.adm2}${data.name}`;
}
} else {
$msg.console.error(loc.msg);
return
}
const res = await $http.mix.getWeather({ location: jw });
console.log('>>> --> handdleJw --> res:', res)
if (res.code == 200) {
wea.value = res.data.text;
weaIcon.value = res.data.icon;
temp.value = res.data.temp;
} else {
$msg.console.error(loc.msg);
const res = await $http.mix.getWeather({ location: jw });
if (res.code === 200) {
wea.value = res.data.text;
weaIcon.value = res.data.icon;
temp.value = res.data.temp;
} else {
$msg.error(res.msg);
}
} catch (e) {
locationInfo.value = '定位失败';
wea.value = ''
weaIcon.value = ''
temp.value = 0
}
}
watch(() => route.name, (newVal) => {
activeKey.value = newVal as string
updateNavHeight()
}, { immediate: true })
watch(() => usrLog.isLogin, () => {
syncUserFromCookies()
})
function goHome() {
if (route.name == 'home') return
if (route.name === 'home') return
router.push('/home');
}
function toLogin() {
console.log('>>> --> toLogin --> toLogin:', 'toLogin')
if ($cookies.get('token')) return
visible.value = true
}
function logout() {
console.log('>>> --> logout --> logout:', 'logout')
$cookies.remove('token');
$cookies.remove('userinfo');
usrLog.setIsLogin(false)
userinfo.value = null;
syncUserFromCookies()
}
function gotoConsole() {
window.open("https://www.hxyouzi.com/console/home", "_BLACK")
window.open('https://www.hxyouzi.com/console/home', '_blank')
}
function gotoHf() {
console.log('>>> --> gotoHf --> fxlink:', fxlink)
window.open(fxlink.value, "_BLACK")
if (!fxlink.value || fxlink.value === '#') return
window.open(fxlink.value, '_blank')
}
onMounted(() => {
userinfo.value = $cookies.get('userinfo');
console.log('>>>>>>>>>>', userinfo.value);
setTimeout(() => {
console.log('>>>>>>>>>>', route)
activeKey.value = route.name as string;
}, 20);
console.log('>>> --> route.name:', route.name)
getLocation(); // 组件挂载时获取位置
const h: number = nav.value.clientHeight
if (h > 0) navx.setNavH(h)
token.value = $cookies.get('token')
syncUserFromCookies()
getLocation()
updateNavHeight()
window.addEventListener('resize', updateNavHeight)
});
onBeforeUpdate(() => {
userinfo.value = $cookies.get('userinfo');
activeKey.value = route.name as string;
const h: number = nav.value.clientHeight
// console.log('******>>> --> nav.value:', nav.value)
// console.log('()()()()()>>> --> h:', h)
if (h > 0) navx.setNavH(h)
})
onUnmounted(() => {
window.removeEventListener('resize', updateNavHeight)
})
</script>
<style scoped lang="less">
.main-nav {
box-shadow: 0 1px 15px 0 @primary;
margin-bottom: 1px;
}
:deep(.n-menu-item-content__icon) {
width: 16px !important;
height: 16px !important;
}
</style>
:deep(.n-menu.n-menu--horizontal) {
background: transparent !important;
height: 100%;
align-items: center;
}
:deep(.n-menu-item) {
display: flex;
align-items: center;
margin-right: 30px !important;
}
:deep(.n-menu--horizontal .n-menu-item-content) {
border-radius: 9999px;
padding: 0 16px !important;
height: 34px !important;
display: flex;
align-items: center;
color: #526377 !important;
font-weight: 600;
}
:deep(.n-menu--horizontal .n-menu-item-content::before) {
border-radius: 9999px !important;
}
:deep(.n-menu--horizontal .n-menu-item-content:hover) {
background: rgba(255, 255, 255, 0.72) !important;
color: #ec66ab !important;
}
:deep(.n-menu--horizontal .n-menu-item-content.n-menu-item-content--selected) {
background: linear-gradient(135deg, rgba(236, 102, 171, 0.14), rgba(244, 138, 111, 0.12)) !important;
color: #ec66ab !important;
}
</style>

View File

@ -1,20 +1,28 @@
<template>
<div class="image-container flex w-full">
<n-image ref="lazyRef" class="lazy__img" :src="url" @load="handleLoad" @error="handleError">
<div class="image-shell" :style="shellStyle">
<n-image ref="lazyRef" class="lazy__img" :src="url" @load="handleLoad" @error="handleError">
<template #placeholder>
<icon-loading class="zhuan text-primary w-12 h-12" />
<div class="lazy-placeholder">
<span class="lazy-placeholder__ring">
<icon-loading class="zhuan h-10 w-10 text-primary" />
</span>
</div>
</template>
<template #error>
<img :src="errorImg" alt="error" />
<div class="lazy-error">
<img :src="errorImg" alt="error" />
</div>
</template>
</n-image>
</div>
</template>
<script setup lang="ts">
import { inject, onMounted, ref, toRefs } from "vue";
import { computed, inject, nextTick, ref, shallowRef, toRefs } from "vue";
import loadError from "../assets/loadError.png";
import loadingImg from "../assets/loading.gif";
import { waterfallImageLoadedKey } from "../utils/keys";
const props = defineProps({
previewIcon: {
type: String,
@ -31,40 +39,104 @@ const props = defineProps({
errorImg: {
type: String,
default: loadError,
}
},
Pwidth: {
type: Number,
default: 0,
},
Pheight: {
type: Number,
default: 0,
},
});
const { url, loading, errorImg } = toRefs(props);
const { url, errorImg } = toRefs(props);
const imgLoaded = inject("imgLoaded") as () => void;
const imgLoaded = inject(waterfallImageLoadedKey, () => {});
const lazyRef = ref<any>(null);
const hasNotified = shallowRef(false);
const handleLoad = () => {
const shellStyle = computed(() => {
if (!props.Pwidth || !props.Pheight) return undefined;
return {
aspectRatio: `${props.Pwidth} / ${props.Pheight}`,
};
});
const notifyLoaded = () => {
if (hasNotified.value) return;
hasNotified.value = true;
imgLoaded();
};
const handleError = () => {
// 可以在这里添加错误处理逻辑
const handleLoad = () => {
notifyLoaded();
};
onMounted(() => {
if (lazyRef.value) {
imgLoaded();
}
const handleError = () => {
notifyLoaded();
};
nextTick(() => {
const nativeImg = lazyRef.value?.$el?.querySelector?.("img") as
| HTMLImageElement
| undefined;
if (nativeImg?.complete && nativeImg.currentSrc) notifyLoaded();
});
</script>
<style scoped>
:deep(.n-image img) {
.image-shell {
width: 100%;
height: auto;
/* object-fit: cover; */
height: 100%;
overflow: hidden;
display: flex;
border-radius: 18px;
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));
}
:deep(.n-image),
:deep(.n-image img),
:deep(.n-image-placeholder),
:deep(.n-image-error) {
width: 100%;
}
.lazy__img,
:deep(.n-image),
:deep(.n-image-wrapper) {
height: 100%;
}
.lazy__img {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 18px;
}
:deep(.n-image-placeholder),
:deep(.n-image-error) {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.n-image img) {
display: block;
height: auto;
transition: transform 0.45s ease, filter 0.28s ease;
}
.image-shell:hover :deep(.n-image img) {
transform: scale(1.015);
filter: saturate(1.03);
}
.lazy__img img[alt="loading"],
@ -75,6 +147,39 @@ onMounted(() => {
margin: 0 auto;
}
.lazy-placeholder,
.lazy-error {
display: flex;
width: 100%;
height: 100%;
min-height: 8rem;
align-items: center;
justify-content: center;
padding: 1.25rem;
text-align: center;
background:
radial-gradient(circle at top, rgba(255, 214, 108, 0.18), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(240, 247, 255, 0.72));
}
.lazy-placeholder__ring {
display: inline-flex;
height: 4rem;
width: 4rem;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.74);
border-radius: 999px;
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 12px 24px rgba(118, 144, 169, 0.16);
backdrop-filter: blur(10px);
}
.lazy-error img {
max-width: min(11rem, 100%);
opacity: 0.82;
}
@keyframes spin {
0% {
transform: rotate(0deg);
@ -83,11 +188,8 @@ onMounted(() => {
100% {
transform: rotate(360deg);
}
}
.zhuan {
animation: spin 1.5s linear infinite;
}

View File

@ -1,6 +1,11 @@
<template>
<div ref="waterfallWrapper" class="waterfall-list" :style="{ height: `${wrapperHeight}px` }">
<div v-for="(item, index) in list" :key="getKey(item, index)" :style="{height:`${colWidth * item.height / item.width}px`}" class="waterfall-item">
<div ref="waterfallWrapper" class="waterfall-list" :style="wrapperStyle">
<div
v-for="(item, index) in list"
:key="getKey(item, index)"
:style="getItemStyle(index)"
class="waterfall-item"
>
<div class="waterfall-card h-full">
<slot name="item" :item="item" :index="index" :url="getRenderURL(item)" />
</div>
@ -11,10 +16,10 @@
<script setup lang="ts">
import { useDebounceFn } from "@vueuse/core";
import type { PropType } from "vue";
import { provide, ref, watch } from "vue";
import { computed, provide, useTemplateRef, watch } from "vue";
import type { ViewCard } from "../types/waterfall";
import { useCalculateCols, useLayout } from "../use";
import Lazy from "../utils/Lazy";
import { waterfallImageLoadedKey } from "../utils/keys";
import { getValue } from "../utils/util";
const props = defineProps({
@ -34,6 +39,14 @@ const props = defineProps({
type: Number,
default: 200,
},
widthSelector: {
type: String,
default: "width",
},
heightSelector: {
type: String,
default: "height",
},
columns: {
type: Number,
default: 3,
@ -72,7 +85,7 @@ const props = defineProps({
},
loadProps: {
type: Object,
default: () => { },
default: () => {},
},
crossOrigin: {
type: Boolean,
@ -84,62 +97,91 @@ const props = defineProps({
},
});
const lazy = new Lazy(props.lazyload, props.loadProps, props.crossOrigin);
provide("lazy", lazy);
const waterfallWrapper = useTemplateRef<HTMLElement>("waterfallWrapper");
// 容器块信息
const { waterfallWrapper, wrapperWidth, colWidth, cols, offsetX } =
useCalculateCols(props);
// 瀹瑰櫒鍧椾俊鎭?
const { wrapperWidth, colWidth, cols, offsetX } = useCalculateCols(
props,
waterfallWrapper
);
// 容器高度,块定位
const getNumericValue = (item: ViewCard, selector: string): number | null => {
const value = getValue(item, selector)[0];
const resolved = Number(value);
if (!Number.isFinite(resolved) || resolved <= 0) return null;
return resolved;
};
const getItemHeightByIndex = (index: number): number | null => {
const item = props.list[index];
if (!item || colWidth.value <= 0) return null;
const width = getNumericValue(item, props.widthSelector);
const height = getNumericValue(item, props.heightSelector);
if (!width || !height) return null;
return (colWidth.value * height) / width;
};
const itemHeights = computed(() =>
props.list.map((_, index) => getItemHeightByIndex(index))
);
// 瀹瑰櫒楂樺害锛屽潡瀹氫綅
const { wrapperHeight, layoutHandle } = useLayout(
props,
colWidth,
cols,
offsetX,
waterfallWrapper
waterfallWrapper,
(index, item) => getItemHeightByIndex(index) ?? item.offsetHeight
);
// 1s内最多执行一次排版减少性能开销
const wrapperStyle = computed(() => ({
height: `${wrapperHeight.value}px`,
backgroundColor: props.backgroundColor,
}));
const getItemStyle = (index: number) => {
const height = itemHeights.value[index];
if (!height) return undefined;
return {
height: `${height}px`,
};
};
// 1s鍐呮渶澶氭墽琛屼竴娆℃帓鐗堬紝鍑忓皯鎬ц兘寮€閿€
const renderer = useDebounceFn(() => {
layoutHandle();
// console.log("强制更新排版");
}, props.delay);
// 列表发生变化直接触发排版
watch(
() => [wrapperWidth, colWidth, props.list],
[wrapperWidth, cols, itemHeights, () => props.list],
() => {
renderer();
},
{ deep: true }
{ immediate: true }
);
// 尺寸宽度变化防抖触发
const sizeChangeTime = ref(0);
// 鍥剧墖鍔犺浇瀹屾垚
provide(waterfallImageLoadedKey, renderer);
provide("sizeChangeTime", sizeChangeTime);
// 图片加载完成
provide("imgLoaded", renderer);
// 根据选择器获取图片地址
// 鏍规嵁閫夋嫨鍣ㄨ幏鍙栧浘鐗囧湴鍧€
const getRenderURL = (item: ViewCard): string => {
return getValue(item, props.imgSelector)[0];
return String(getValue(item, props.imgSelector)[0] ?? "");
};
// 获取唯一值
const getKey = (item: ViewCard, index: number): string => {
return item[props.rowKey] || index;
// 鑾峰彇鍞竴鍊?
const getKey = (item: ViewCard, index: number): string | number => {
return item[props.rowKey] ?? index;
};
const clearAndReload = () => {
const originalList = [...props.list];
props.list.length = 0;
setTimeout(() => {
props.list.push(...originalList);
renderer();
}, 0);
layoutHandle();
};
defineExpose({
@ -159,42 +201,53 @@ defineExpose({
width: 100%;
position: relative;
overflow: hidden;
background-color: v-bind(backgroundColor);
--waterfall-radius: 22px;
--waterfall-border: rgba(255, 255, 255, 0.72);
--waterfall-surface: linear-gradient(
180deg,
rgba(255, 255, 255, 0.9),
rgba(244, 250, 255, 0.72)
);
--waterfall-shadow: 0 16px 32px rgba(118, 144, 169, 0.12);
}
.waterfall-item {
position: absolute;
left: 0;
top: 0;
/* transition: .3s; */
/* 初始位置设置到屏幕以外,避免懒加载失败 */
transform: translate3d(0, 3000px, 0);
visibility: hidden;
will-change: transform, opacity;
}
/* 初始的入场效果 */
@-webkit-keyframes fadeIn {
0% {
opacity: 0;
}
.waterfall-card {
height: 100%;
overflow: hidden;
border: 1px solid var(--waterfall-border);
border-radius: var(--waterfall-radius);
background: var(--waterfall-surface);
box-shadow: var(--waterfall-shadow);
backdrop-filter: blur(14px);
transform-origin: center top;
}
100% {
opacity: 1;
}
.waterfall-card :deep(img) {
display: block;
}
@keyframes fadeIn {
0% {
opacity: 0;
opacity: 0.01;
transform: translate3d(0, 18px, 0) scale(0.985);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
.fadeIn {
-webkit-animation-name: fadeIn;
animation-name: fadeIn;
}
</style>

View File

@ -10,6 +10,8 @@ export interface ViewCard {
export interface WaterfallProps {
columns: number;
width: number;
widthSelector: string;
heightSelector: string;
animationDuration: number;
animationDelay: number;
animationEffect: string;

View File

@ -1,46 +1,47 @@
import { computed, ref } from "vue";
import type { Ref } from "vue";
import { useResizeObserver } from "@vueuse/core";
import { getItemWidth } from "../utils/itemWidth";
import type { WaterfallProps } from "../types/waterfall";
import type { Nullable } from "../types/util";
export function useCalculateCols(props: WaterfallProps) {
const wrapperWidth = ref<number>(0);
const waterfallWrapper = ref<Nullable<HTMLElement>>(null);
export function useCalculateCols(
props: WaterfallProps,
waterfallWrapper: Ref<Nullable<HTMLElement>>
) {
const wrapperWidth = ref<number>(0);
useResizeObserver(waterfallWrapper, (entries) => {
const entry = entries[0];
const { width } = entry.contentRect;
if (width === 0) return;
wrapperWidth.value = width;
useResizeObserver(waterfallWrapper, (entries) => {
const entry = entries[0];
const { width } = entry.contentRect;
if (width === 0) return;
wrapperWidth.value = width;
});
// 鍒楀疄闄呭搴?
const colWidth = computed(() => {
return getItemWidth({
wrapperWidth: wrapperWidth.value,
gutter: props.gutter,
hasAroundGutter: props.hasAroundGutter,
columns: props.columns,
});
});
// 列实际宽度
const colWidth = computed(() => {
return getItemWidth({
wrapperWidth: wrapperWidth.value,
gutter: props.gutter,
hasAroundGutter: props.hasAroundGutter,
columns: props.columns,
});
});
// 鍒?
const cols = computed(() => props.columns);
//
const cols = computed(() => props.columns);
// 鍋忕Щ
const offsetX = computed(() => {
const offset = props.hasAroundGutter ? props.gutter : -props.gutter;
const contextWidth = cols.value * (colWidth.value + props.gutter) + offset;
return (wrapperWidth.value - contextWidth) / 2;
});
// 偏移
const offsetX = computed(() => {
const offset = props.hasAroundGutter ? props.gutter : -props.gutter;
const contextWidth =
cols.value * (colWidth.value + props.gutter) + offset;
return (wrapperWidth.value - contextWidth) / 2;
});
return {
waterfallWrapper,
wrapperWidth,
colWidth,
cols,
offsetX,
};
return {
wrapperWidth,
colWidth,
cols,
offsetX,
};
}

View File

@ -1,5 +1,5 @@
import type { Ref } from "vue";
import { ref } from "vue";
import { nextTick, onBeforeUnmount, ref } from "vue";
import { addClass, hasClass, prefixStyle } from "../utils/dom";
import type { WaterfallProps } from "../types/waterfall";
import type { CssStyleObject, Nullable } from "../types/util";
@ -15,104 +15,121 @@ export function useLayout(
colWidth: Ref<number>,
cols: Ref<number>,
offsetX: Ref<number>,
waterfallWrapper: Ref<Nullable<HTMLElement>>
waterfallWrapper: Ref<Nullable<HTMLElement>>,
getItemHeight: (index: number, item: HTMLElement) => number | null
) {
const posY = ref<number[]>([]);
const wrapperHeight = ref(0);
let frameId = 0;
let layoutTaskId = 0;
// 获取对应y下标的x的值
// 鑾峰彇瀵瑰簲y涓嬫爣鐨剎鐨勫€?
const getX = (index: number): number => {
const count = props.hasAroundGutter ? index + 1 : index;
return props.gutter * count + colWidth.value * index + offsetX.value;
};
// 初始y
// 鍒濆y
const initY = (): void => {
posY.value = new Array(cols.value).fill(
props.hasAroundGutter ? props.gutter : 0
);
};
// 添加入场动画
// 娣诲姞鍏ュ満鍔ㄧ敾
const animation = addAnimation(props);
// 排版
const layoutHandle = async () => {
// 初始化y集合
const doLayout = () => {
initY();
// 构造列表
const items: HTMLElement[] = [];
if (waterfallWrapper && waterfallWrapper.value) {
waterfallWrapper.value.childNodes.forEach((el: any) => {
if (el!.className === "waterfall-item") items.push(el);
});
const items =
waterfallWrapper.value?.querySelectorAll<HTMLElement>(".waterfall-item") ??
[];
if (items.length === 0) {
wrapperHeight.value = 0;
return;
}
// 获取节点
if (items.length === 0) return false;
// 遍历节点
for (let i = 0; i < items.length; i++) {
const curItem = items[i] as HTMLElement;
// 最小的y值
const minY = Math.min.apply(null, posY.value);
// 最小y的下标
const curItem = items[i];
const minY = Math.min(...posY.value);
const minYIndex = posY.value.indexOf(minY);
// 当前下标对应的x
const curX = getX(minYIndex);
// 设置x,y,width
const style = curItem.style as CssStyleObject;
// 设置偏移
if (transform) style[transform] = `translate3d(${curX}px,${minY}px, 0)`;
if (transform) style[transform] = `translate3d(${curX}px, ${minY}px, 0)`;
style.width = `${colWidth.value}px`;
// 更新当前index的y值
const { height } = curItem.getBoundingClientRect();
const height = getItemHeight(i, curItem) ?? curItem.offsetHeight;
posY.value[minYIndex] += height + props.gutter;
// 添加入场动画
animation(curItem, () => {
// 添加动画时间
if (transition) style[transition] = "transform .3s";
if (transition) {
style[transition] =
"transform .32s cubic-bezier(0.22, 1, 0.36, 1), opacity .24s ease";
}
});
}
wrapperHeight.value = Math.max.apply(null, posY.value);
const maxY = Math.max(...posY.value);
const bottomGap = props.hasAroundGutter ? props.gutter : 0;
wrapperHeight.value = Math.max(maxY - props.gutter + bottomGap, 0);
};
// 鎺掔増
const layoutHandle = async () => {
layoutTaskId += 1;
const currentTaskId = layoutTaskId;
await nextTick();
if (currentTaskId !== layoutTaskId) return;
if (frameId) cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
frameId = 0;
doLayout();
});
};
onBeforeUnmount(() => {
if (frameId) cancelAnimationFrame(frameId);
});
return {
wrapperHeight,
layoutHandle,
};
}
// 动画
// 鍔ㄧ敾
function addAnimation(props: WaterfallProps) {
return (item: HTMLElement, callback?: () => void) => {
const content = item!.firstChild as HTMLElement;
const itemStyle = item.style as CssStyleObject;
itemStyle.visibility = "visible";
const content = item.firstElementChild as HTMLElement | null;
if (content && !hasClass(content, props.animationPrefix)) {
const durationSec = `${props.animationDuration / 1000}s`;
const delaySec = `${props.animationDelay / 1000}s`;
const style = content.style as CssStyleObject;
style.visibility = "visible";
if (duration) style[duration] = durationSec;
if (delay) style[delay] = delaySec;
if (fillMode) style[fillMode] = "both";
addClass(content, props.animationPrefix);
addClass(content, props.animationEffect);
// 确保动画完成后item可见
setTimeout(() => {
const itemStyle = item.style as CssStyleObject;
itemStyle.visibility = "visible";
if (callback) callback();
window.setTimeout(() => {
callback?.();
}, props.animationDuration + props.animationDelay);
return;
}
callback?.();
};
}

5
src/lib/utils/keys.ts Normal file
View File

@ -0,0 +1,5 @@
import type { InjectionKey } from "vue";
export const waterfallImageLoadedKey: InjectionKey<() => void> = Symbol(
"waterfall:image-loaded"
);

View File

@ -1,180 +1,280 @@
<template>
<n-scrollbar ref="virtualListInst" class="py-5" :style="boxStyle" @scroll="handleScroll">
<n-input class="my-4 !w-[72%] ml-[14%]" round size="large" v-model:value="kw" @keyup.enter="onSearch"
placeholder="请输入关键字">
<template #suffix>
<n-icon size="large">
<icon-search />
</n-icon>
</template>
</n-input>
<div
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]">
<div
class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]">
</div>
<div
class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]">
</div>
<div
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>
<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-['']">
<div class="max-w-[48rem]">
<h1 class="m-0 text-[clamp(1.8rem,2.5vw,2.8rem)] leading-[1.2] text-[#2c3a4a]">
把喜欢的画面收进一片更柔和的光里
</h1>
<p class="mt-[0.9rem] max-w-[42rem] leading-[1.75] text-[#66778b]">
输入关键词后按回车即可筛选图片
</p>
</div>
<Waterfall class="ml-[10%] !w-[80%]" ref="waterfall" :list="fileList" :gutter="gutter" :columns="column"
img-selector="url" animation-effect="fadeIn" :animation-duration="1000" :animation-delay="300"
backgroundColor="transparent"> >
<template #item="{ item }">
<div
class="card h-full flex items-center justify-center rounded-md shadow-lg overflow-hidden group transition-transform duration-300 box-border hover:-translate-y-1.5">
<!-- <div class="image-wrapper"> -->
<LazyImg class="rounded-md overflow-hidden" :Pwidth="item.width" :Pheight="item.height" :url="item.filepath" />
<!-- </div> -->
<div
class="hidden truncate group-hover:block absolute rounded-md z-10 truncate top-0 text-center w-full bg-[#00000070] text-white">
{{ item.filename }}
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%)] px-[1.1rem] py-4 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="请输入关键词,按回车开始搜索">
<template #suffix>
<n-icon class="cursor-pointer text-slate-500" size="large">
<icon-search />
</n-icon>
</template>
</n-input>
</div>
</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]">
<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>
<div
class="absolute rounded-md flex px-2 z-10 truncate bottom-0 w-full bg-[#00000070] text-white justify-between items-center">
<div> <span class="text-[#f1d9db] font-600">{{ item.nickname }}</span> 分享</div>
<div class="text-sm cursor-pointer text-[#f6cbe7] flex items-center" @click="downloadFile(item.filepath)">
<icon-download class="w-5 h-5" />
下载
</div>
class="inline-flex items-center whitespace-nowrap rounded-full border border-white/80 bg-white/68 px-4 py-[0.65rem] text-[#526376]">
{{ galleryStats.loaded }}
</div>
</div>
</template>
</Waterfall>
</n-scrollbar>
<Waterfall ref="waterfall" :list="fileList" :gutter="gutter" :columns="column" 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" />
<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">
{{ 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]">
<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" />
下载
</div>
</div>
</div>
</template>
</Waterfall>
</section>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { throttle } from 'es-toolkit';
import { LazyImg, Waterfall } from '../lib';
import { throttle } from 'es-toolkit'
import { LazyImg, Waterfall } from '../lib'
definePage({
name: 'gallery',
meta: {
title: '画廊',
}
})
const waterfall = ref<any>(null);
interface GalleryItem {
filepath: string
filename: string
nickname: string
width: number
height: number
}
interface ScrollContainer extends EventTarget {
scrollTop: number
scrollHeight: number
offsetHeight: number
}
const GALLERY_WIDTH_RATIO = 0.82
const PAGE_SIZE = 20
const SCROLL_THRESHOLD = 100
const waterfall = ref<any>(null)
const nav: any = $store.nav.useNavStore()
const boxStyle: any = ref({})
// 画廊页逻辑
const fileList = ref<any[]>([]);
const pn = ref(1);
const ps = ref(20);
const loading = ref(false);
const kw = ref<string>('');
const cwidth = ref<number>(240);
const column = ref<number>(5);
const gutter = ref<number>(18);
const boxStyle = ref<Record<string, string>>({})
const fileList = ref<GalleryItem[]>([])
const pn = ref(1)
const ps = ref(PAGE_SIZE)
const loading = ref(false)
const kw = ref('')
const cwidth = ref(240)
const column = ref(5)
const gutter = ref(18)
const isLoadAll = ref(false)
const isLoadAll = ref<boolean>(false)
const searchKeyword = computed(() => kw.value.trim())
const galleryStats = computed(() => {
let status = '浏览全部'
if (loading.value) {
status = '加载中'
} else if (isLoadAll.value && fileList.value.length > 0) {
status = '已全部加载'
} else if (searchKeyword.value) {
status = '搜索结果'
}
return {
loaded: fileList.value.length,
columns: column.value,
status,
keyword: searchKeyword.value ? `关键词:${searchKeyword.value}` : '关键词:全部图片',
}
})
// 计算列数
function calculateColumns() {
const totalWidth = window.innerWidth * 0.8; // 画廊宽度为视口宽度的80%
const col = Math.floor((totalWidth + gutter.value) / (cwidth.value + gutter.value));
column.value = col > 0 ? col : 1;
function renderWaterfall() {
waterfall.value?.renderer()
}
// 获取文件列表
function calculateColumns() {
const totalWidth = window.innerWidth * GALLERY_WIDTH_RATIO
const col = Math.floor((totalWidth + gutter.value) / (cwidth.value + gutter.value))
column.value = col > 0 ? col : 1
renderWaterfall()
}
function updateBoxStyle() {
boxStyle.value = {
maxHeight: `calc(100vh - 5px - ${nav.navH}px)`,
}
}
function appendFileList(list: GalleryItem[]) {
if (pn.value === 1) {
fileList.value = list
return
}
list.forEach((item) => {
fileList.value.push(item)
})
}
function resetGalleryState() {
pn.value = 1
kw.value = ''
fileList.value = []
isLoadAll.value = false
}
async function getFileList() {
if (loading.value) return;
loading.value = true;
if (loading.value) return
loading.value = true
try {
const res = await $http.file.getFileList({
keyword: kw.value,
page_num: pn.value,
page_size: ps.value,
});
})
const list = res.data as GalleryItem[]
// console.log('>>> --> getFileList --> res:', res);
if (res.data.length < ps.value) {
isLoadAll.value = true;
}
if (pn.value === 1) {
fileList.value = res.data;
} else {
// 追加新数据
res.data.forEach((item: any) => {
fileList.value.push(item);
});
if (list.length < ps.value) {
isLoadAll.value = true
}
appendFileList(list)
} catch (error) {
console.error('获取文件列表失败:', error);
console.error('获取图片列表失败:', error)
} finally {
loading.value = false;
waterfall.value?.renderer();
loading.value = false
renderWaterfall()
}
}
const handleScroll = throttle((e: Event) => {
if (loading.value || isLoadAll.value) return
const target = e.target as ScrollContainer | null
if (!target) return
const scrollTop = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.offsetHeight
// 监听滚动事件
const handleScroll: any = throttle((e: any) => {
// console.log('>>> --> handleScroll --> loading:', e)
if (loading.value) return;
if (isLoadAll.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 - 100) {
console.log('>>> --> handleScroll --> 加载更多')
pn.value++;
getFileList();
if (scrollTop + clientHeight >= scrollHeight - SCROLL_THRESHOLD) {
pn.value += 1
getFileList()
}
}, 1000)
function onSearch() {
pn.value = 1;
pn.value = 1
kw.value = kw.value.trim()
isLoadAll.value = false
getFileList();
getFileList()
}
function downloadFile(url: string) {
console.log('>>> --> downloadFile --> url:', url)
// 创建临时a标签
const link = document.createElement('a');
// 设置下载链接
link.href = url;
// 提取文件名
const fileName = url.split('/').pop() || 'downloaded-file';
// 设置下载属性和文件名
link.download = fileName;
// 设置为隐藏元素
link.style.display = 'none';
// 添加到文档
document.body.appendChild(link);
// 触发点击事件
link.click();
// 移除临时元素
document.body.removeChild(link);
const link = document.createElement('a')
link.href = url
const fileName = url.split('/').pop() || 'downloaded-file'
link.download = fileName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
onMounted(() => {
calculateColumns()
getFileList();
console.log('>>> --> nav.NavH:', nav.navH)
boxStyle.value = {
maxHeight: `calc(100vh - 5px - ${nav.navH}px)`,
}
// 添加滚动监听
window.addEventListener('resize', calculateColumns);
window.addEventListener('scroll', handleScroll);
});
updateBoxStyle()
getFileList()
window.addEventListener('resize', calculateColumns)
window.addEventListener('resize', updateBoxStyle)
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
pn.value = 1;
kw.value = '';
fileList.value = [];
isLoadAll.value = false
// 移除监听
window.removeEventListener('scroll', handleScroll);
});
resetGalleryState()
window.removeEventListener('resize', calculateColumns)
window.removeEventListener('resize', updateBoxStyle)
window.removeEventListener('scroll', handleScroll)
})
</script>
<style scoped lang="less">
:deep(.n-image img) {
border-radius: 0.375rem !important;
: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;
backdrop-filter: blur(18px);
}
</style>
:deep(.gallery-search .n-input__input-el) {
color: #314155 !important;
}
:deep(.gallery-search .n-input__placeholder) {
color: #93a2b3 !important;
}
: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;
}
:deep(.n-image img) {
border-radius: 1rem !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,117 +1,168 @@
<template>
<div class="plink-page pt-4 flex justify-between" :style="boxStyle">
<div class="left ml-4 ">
<n-card class="card w-100 shadow">
<template #header>
<div class="flex items-center gap-4 text-primary text-xl font-bold">
<icon-loading class="zhuan text-primary w-6 h-6" />
申请友链
</div>
</template>
<div>
<n-form :model="plinkData" ref="formRef" :rules="rules" label-width="80" :inline="false"
feedback-style="font-size: 12px">
<n-form-item path="name">
<template #label>
<div class="flex items-center gap-1 text-sm text-gray-500">
<user-icon></user-icon>
昵称
</div>
</template>
<n-input v-model:value="plinkData.name" placeholder="请输入你的昵称" />
</n-form-item>
<n-form-item path="title">
<template #label>
<div class="flex items-center gap-1 text-sm text-gray-500">
<site-icon></site-icon>
网站名称
</div>
</template>
<n-input v-model:value="plinkData.title" placeholder="请输入你的网站名称" />
</n-form-item>
<n-form-item path="url">
<template #label>
<div class="flex items-center gap-1 text-sm text-gray-500">
<url-icon></url-icon>
站点地址
</div>
</template>
<n-input v-model:value="plinkData.url" placeholder="请输入你的网站地址" />
</n-form-item>
<n-form-item path="avater">
<template #label>
<div class="flex items-center gap-1 text-sm text-gray-500">
<ava-icon></ava-icon>
头像链接
</div>
</template>
<n-input v-model:value="plinkData.avater" placeholder="请输入你的头像链接" />
</n-form-item>
<n-form-item path="desc">
<template #label>
<div class="flex items-center gap-1 text-sm text-gray-500">
<desc-icon></desc-icon>
站点介绍
</div>
</template>
<n-input type="textarea" :autosize="{ minRows: 2, maxRows: 3 }" maxlength="50"
v-model:value="plinkData.desc" placeholder="请用一句话简短的描述你的站点" />
</n-form-item>
<n-form-item path="tagname">
<template #label>
<div class="flex items-center gap-1 text-sm text-gray-500">
<tag-icon></tag-icon>
标签
</div>
</template>
<n-input type="textarea" :autosize="{ minRows: 2, maxRows: 3 }" maxlength="50"
v-model:value="plinkData.tagname" placeholder="输入你的标签用逗号隔开注意每个标签最多3个字符且只显示前3个标签哦~" />
</n-form-item>
<n-form-item>
<n-button class="w-full" type="primary" @click="submitPut">申请友链</n-button>
</n-form-item>
</n-form>
</div>
</n-card>
<div class="mt-4 text-right text-primary text-sm font-italic">申请完不显示是因为需要审核奥~~</div>
<div
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]"
:style="boxStyle">
<div
class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]">
</div>
<div
class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]">
</div>
<div
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>
<n-scrollbar class="h-full">
<div class="right h-full grid grid-cols-3 gap-5 flex-1 px-12">
<div
class="rounded-lg bg-white shadow flex flex-col items-center justify-center p-4 cursor-pointer transition-transform duration-300 box-border hover:-translate-y-1.5"
v-for="p in pList" :key="p.pid" @click="gotoWebsite(p.url)">
<div class="ava">
<n-avatar round size="large" :src="p.avater"></n-avatar>
</div>
<em class="bg-[#fbf2e3] px-4 rounded-full text-sm text-primary mb-4">{{ p.name }}</em>
<div class="font-bold text-xl text-gray-700 mb-1">
{{ p.title }}
</div>
<div class="flex gap-1 truncate mb-1 text-sm">
<div v-for="i in getTags(p.tagname)" :key="i.tid"
:style="{ backgroundColor: getDictValue(tagColorList, 'tag', i, 'color') }"
class="flex items-center gap-1 cursor-pointer text-sm text-white rounded-full px-3">
<icon-tag2 class="w-3 h-3 " />{{ i }}
<div class="relative z-[1] mx-auto flex h-full w-[86%] gap-6 py-5">
<aside class="hidden w-[25rem] shrink-0 xl:block">
<div class="sticky top-4">
<section
class="relative rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-5 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-['']">
<div class="relative z-[1]">
<h1 class="mt-2 text-[1.65rem] leading-[1.2] text-[#2c3a4a]">留下你的站点名片</h1>
<section
class="mt-4 rounded-[24px] border border-white/72 bg-white/58 p-4 shadow-[0_14px_32px_rgba(112,128,118,0.1)]">
<div class="mb-3 flex items-center justify-between gap-3 text-primary">
<div class="flex items-center gap-2">
<icon-loading class="zhuan h-4 w-4" />
<span class="text-sm font-semibold">申请友链</span>
</div>
<span
class="rounded-full bg-[rgba(250,239,220,0.92)] px-2.5 py-1 text-[11px] cursor-pointer text-primary"
@click="fillForm">一键填入</span>
</div>
<n-form ref="formRef" :model="plinkData" :rules="rules" class="compact-side-form" label-placement="top"
require-mark-placement="right-hanging" size="small">
<n-form-item v-for="field in formFields" :key="field.key" :path="field.key">
<template #label>
<div class="flex items-center gap-2 text-sm font-medium text-[#526376]">
<component :is="field.icon" class="h-4 w-4" />
{{ field.label }}
</div>
</template>
<n-input :value="plinkData[field.key]" :type="field.inputType"
:maxlength="field.maxlength" :placeholder="field.placeholder" :rows="field.rows"
@update:value="updateField(field.key, $event)" />
</n-form-item>
<div class="!mb-0 btn">
<n-button
class="!h-10 !w-full !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !text-sm !font-semibold shadow-[0_14px_28px_rgba(236,102,171,0.24)]"
type="primary" @click="submitPut">
提交友链申请
</n-button>
</div>
</n-form>
</section>
<p class="mt-3 text-right text-[12px] leading-5 text-[#7a8797]">
如果暂未显示通常是因为还在审核中...
</p>
</div>
</div>
<div class="truncate w-full text-gray-400 text-sm text-center">
{{ p.desc }}
</div>
</section>
</div>
</div>
</n-scrollbar>
</aside>
<section
class="min-w-0 flex-1 overflow-hidden rounded-[30px] border border-white/70 bg-transparent p-4 ring-1 ring-inset ring-[rgba(255,255,255,0.38)] shadow-[0_20px_46px_rgba(109,126,117,0.1)] sm:p-6">
<n-scrollbar :style="contentStyle" trigger="none" class="pr-1">
<div class="space-y-5 pr-1">
<section class="rounded-[26px] bg-transparent p-5 sm:p-6">
<div class="mb-5 flex items-center justify-between">
<div>
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">已通过友链</div>
<h3 class="mt-2 text-[1.4rem] font-semibold text-[#2f4050]">一起逛逛这些站点</h3>
</div>
<div
class="rounded-full border border-white/70 bg-white/42 px-4 py-2 text-sm text-[#526376] shadow-[0_8px_18px_rgba(112,128,118,0.08)]">
{{ pList.length }} 个站点
</div>
</div>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2 2xl:grid-cols-3">
<article v-for="p in pList" :key="p.pid"
class="cursor-pointer rounded-[26px] border border-white/72 bg-[linear-gradient(145deg,rgba(255,251,246,0.78),rgba(237,246,244,0.62))] p-6 shadow-[0_16px_34px_rgba(116,132,124,0.12)] backdrop-blur-[18px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1 hover:border-white/90 hover:shadow-[0_22px_42px_rgba(102,123,112,0.16)]"
@click="gotoWebsite(p.url)">
<div class="flex items-center gap-8">
<n-avatar round :size="56" :src="p.avater"
class="shrink-0 ring-2 ring-white/80 shadow-[0_10px_22px_rgba(88,108,125,0.14)]" />
<div class="min-w-0 flex-1">
<div
class="inline-flex items-center rounded-full bg-[rgba(250,239,220,0.92)] px-3 py-1 text-sm text-primary">
{{ p.name }}
</div>
<h4 class="mt-3 truncate text-[1.3rem] font-semibold text-[#2f4050]">
{{ p.title }}
</h4>
<p class="mt-2 line-clamp-2 min-h-10 text-sm text-[#6a7a88]">
{{ p.desc }}
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-3 justify-center">
<div v-for="tag in getTags(p.tagname)" :key="tag" :style="{ backgroundColor: getTagColor(tag) }"
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(88,108,125,0.16)]">
<icon-tag2 class="h-4 w-4" />
{{ tag }}
</div>
</div>
</article>
</div>
<div v-if="!pList.length" class="py-14 text-center text-[#7a8797]">
暂无友链数据等你来留下第一张站点名片
</div>
</section>
<section
class="rounded-[26px] border border-white/66 bg-transparent p-5 ring-1 ring-inset ring-[rgba(255,255,255,0.32)] shadow-[0_16px_34px_rgba(116,132,124,0.09)] xl:hidden">
<div class="mb-4 flex items-center gap-3 text-primary">
<icon-loading class="zhuan h-5 w-5" />
<span class="text-base font-semibold">申请友链</span>
</div>
<n-form ref="mobileFormRef" :model="plinkData" :rules="rules" label-placement="top"
require-mark-placement="right-hanging">
<n-form-item v-for="field in formFields" :key="field.key" :path="field.key">
<template #label>
<div class="flex items-center gap-2 text-sm font-medium text-[#526376]">
<component :is="field.icon" class="h-4 w-4" />
{{ field.label }}
</div>
</template>
<n-input :value="plinkData[field.key]" :type="field.inputType" :rows="field.rows"
:maxlength="field.maxlength" :placeholder="field.placeholder"
@update:value="updateField(field.key, $event)" />
</n-form-item>
<n-form-item class="btn">
<n-button
class="!h-11 !w-full !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !font-semibold shadow-[0_14px_28px_rgba(236,102,171,0.24)]"
type="primary" @click="submitPut">
提交友链申请
</n-button>
</n-form-item>
</n-form>
</section>
</div>
</n-scrollbar>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import avaIcon from '@/icon/plink/ava.svg';
import descIcon from '@/icon/plink/desc.svg';
import siteIcon from '@/icon/plink/site.svg';
import tagIcon from '@/icon/plink/tag.svg';
import urlIcon from '@/icon/plink/uri.svg';
import userIcon from '@/icon/plink/user.svg';
import { getDictValue } from '@/util';
import avaIcon from '@/icon/plink/ava.svg'
import descIcon from '@/icon/plink/desc.svg'
import siteIcon from '@/icon/plink/site.svg'
import tagIcon from '@/icon/plink/tag.svg'
import urlIcon from '@/icon/plink/uri.svg'
import userIcon from '@/icon/plink/user.svg'
import type { FormInst, FormRules } from 'naive-ui'
import type { Component } from 'vue'
definePage({
name: 'plink',
@ -119,109 +170,263 @@ definePage({
title: '友链',
}
})
// 友链页逻辑
const boxStyle: any = ref({})
const nav: any = $store.nav.useNavStore()
const plinkData = reactive<any>({
interface PlinkFormData {
name: string
title: string
avater: string
url: string
desc: string
tagname: string
}
interface PlinkItem {
pid: string | number
name: string
title: string
avater: string
url: string
desc: string
tagname: string
}
interface PlinkListResponse {
data: PlinkItem[]
}
interface TagColorItem {
tag: string
color: string
}
interface UserCookieInfo {
nickname?: string
ava_url?: string
}
type PlinkFieldKey = keyof PlinkFormData
interface PlinkFormField {
key: PlinkFieldKey
label: string
placeholder: string
icon: Component | string
inputType?: 'text' | 'textarea'
maxlength?: number
rows?: number
}
const SOFT_TAG_PALETTE = [
'#d46a7c',
'#c97956',
'#b9894d',
'#6f9d86',
'#5d92a8',
'#7d88b8',
'#9a78b5',
'#8f7d6b',
'#c86b8f',
'#cf7f6a',
'#a98f55',
'#7aa37b',
'#5d9f95',
'#6d8fc7',
'#8a80c4',
'#a16fa1',
'#b67b67',
'#927f72',
'#6f8b5f',
'#4f8aa8',
'#7a96a1',
'#b38754',
'#a66767',
'#7b6aa8',
] as const
const TEXTAREA_AUTOSIZE = {
minRows: 1,
maxRows: 2,
} as const
const formFields: PlinkFormField[] = [
{
key: 'name',
label: '昵称',
placeholder: '请输入你的昵称',
icon: userIcon,
inputType: 'text',
},
{
key: 'title',
label: '网站名称',
placeholder: '请输入你的网站名称',
icon: siteIcon,
inputType: 'text',
},
{
key: 'url',
label: '站点地址',
placeholder: '请输入你的网站地址',
icon: urlIcon,
inputType: 'text',
},
{
key: 'avater',
label: '头像链接',
placeholder: '请输入你的头像链接',
icon: avaIcon,
inputType: 'text',
},
{
key: 'desc',
label: '站点介绍',
placeholder: '请用一句话简短描述你的站点',
icon: descIcon,
inputType: 'textarea',
maxlength: 50,
rows:2
},
{
key: 'tagname',
label: '标签',
placeholder: '用逗号隔开,每个标签最多 4 个字,只显示前 3 个',
icon: tagIcon,
inputType: 'textarea',
maxlength: 50,
rows:2
},
]
const boxStyle = ref<Record<string, string>>({})
const contentStyle = ref<Record<string, string>>({})
const screenWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth)
const nav = $store.nav.useNavStore() as { navH: number }
const formRef = ref<FormInst | null>(null)
const mobileFormRef = ref<FormInst | null>(null)
const pList = ref<PlinkItem[]>([])
const tagColorList = ref<TagColorItem[]>([])
const plinkData = reactive<PlinkFormData>({
name: '',
title: '',
avater: '',
url: '',
desc: '',
tagname: '',
});
const rules: any = {
name: [{ required: true, message: '昵称不能为空~~', trigger: ['blur'] }],
title: [{ required: true, message: '网站名称不能为空~~', trigger: ['blur'] }],
url: [{ required: true, message: '站点地址不能为空~~', trigger: ['blur'] }],
avater: [{ required: true, message: '头像链接不能为空~~', trigger: ['blur'] }],
desc: [{ required: true, message: '站点介绍不能为空~~', trigger: ['blur'] }],
tagname: [{ required: true, message: '标签不能为空~~', trigger: ['blur'] }],
}
const pList = ref<any>([])
const formRef = ref<any>(null)
const tagColorList: any = ref([])
async function getPlinkList() {
const res = await $http.plink.getPlinkList()
const list: Array<string> = []
// 对res.data.tags进行去重
res.data.forEach((i: any) => {
const t = getTags(i.tagname)
t.forEach((ii: string) => {
if (!list.includes(ii)) list.push(ii)
})
});
})
tagColorList.value = list.map((i: string) => {
return {
tag: i,
color: getRandomFromPalette()
}
})
const rules: FormRules = {
name: [{ required: true, message: '昵称不能为空', trigger: ['blur'] }],
title: [{ required: true, message: '网站名称不能为空', trigger: ['blur'] }],
url: [{ required: true, message: '站点地址不能为空', trigger: ['blur'] }],
avater: [{ required: true, message: '头像链接不能为空', trigger: ['blur'] }],
desc: [{ required: true, message: '站点介绍不能为空', trigger: ['blur'] }],
tagname: [{ required: true, message: '标签不能为空', trigger: ['blur'] }],
}
function updateLayout() {
const viewportHeight = window.innerHeight - nav.navH - 1
screenWidth.value = window.innerWidth
boxStyle.value = { height: `${viewportHeight}px` }
contentStyle.value = { height: `${Math.max(viewportHeight - 48, 280)}px` }
}
function updateField(key: PlinkFieldKey, value: string) {
plinkData[key] = value
}
function normalizeTag(tag: string) {
return tag.trim().slice(0, 4)
}
function getTags(str: string) {
const normalized = (str || '').replace(//g, ',')
return normalized.split(',').slice(0, 3).map(normalizeTag).filter(Boolean)
}
function buildTagColorMap(tags: string[]): TagColorItem[] {
return tags.map((tag, index) => ({
tag,
color: SOFT_TAG_PALETTE[index % SOFT_TAG_PALETTE.length],
}))
}
function getTagColor(tag: string) {
return tagColorList.value.find((item) => item.tag === tag)?.color || SOFT_TAG_PALETTE[0]
}
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
}
function getTags(str: any) {
str = str.replaceAll('', ',')
let tags = str.split(',').slice(0, 3)
tags.forEach((tag: any, idx: number) => {
tags[idx] = tag.trim().slice(0, 4)
});
return tags
}
function gotoWebsite(url: string) {
if (!url) return
window.open(url, '_blank')
}
function submitPut() {
formRef.value?.validate(async (valid: boolean) => {
if (valid) {
$msg.error('请完善表单~~')
} else {
const res = await $http.plink.addPlink(plinkData)
if (res?.code !== 200) {
$msg.error(res.msg)
return
}
$msg.success(res.msg)
plinkData.name = ''
plinkData.title = ''
plinkData.avater = ''
plinkData.url = ''
plinkData.desc = ''
plinkData.tagname = ''
getPlinkList()
}
})
function resetFormData() {
plinkData.name = ''
plinkData.title = ''
plinkData.avater = ''
plinkData.url = ''
plinkData.desc = ''
plinkData.tagname = ''
}
// 生成一个随机的鲜艳颜色,和白色能对比
function getRandomFromPalette(): string {
const hue = Math.floor(Math.random() * 360);
return hslToHex(hue, 80, 50);
}
function hslToHex(h: number, s: number, l: number): any {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
function fillForm() {
const token = $cookies.get('token')
if (!token) {
$msg.warning('请先登录后再一键填入')
return
}
return `#${f(0)}${f(8)}${f(4)}`;
const userinfo = ($cookies.get('userinfo') || {}) as UserCookieInfo
plinkData.name = userinfo.nickname || ''
plinkData.avater = userinfo.ava_url || ''
}
function getActiveForm() {
if (screenWidth.value >= 1280) return formRef.value || mobileFormRef.value
return mobileFormRef.value || formRef.value
}
async function submitPut() {
const activeForm = getActiveForm()
if (!activeForm) return
try {
await activeForm.validate()
} catch {
$msg.error('请先完善表单内容')
return
}
const res = await $http.plink.addPlink(plinkData)
if (res?.code !== 200) {
$msg.error(res.msg)
return
}
$msg.success(res.msg)
resetFormData()
await getPlinkList()
}
onMounted(() => {
boxStyle.value = {
height: window.innerHeight - nav.navH - 1 + 'px',
}
getPlinkList()
updateLayout()
void getPlinkList()
window.addEventListener('resize', updateLayout)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
})
</script>
<style scoped>
/* 无限旋转动画 */
<style scoped lang="less">
@keyframes spin {
0% {
transform: rotate(0deg);
@ -236,8 +441,58 @@ onMounted(() => {
animation: spin 1.5s linear infinite;
}
:deep(.n-form-item) {
grid-template-rows: unset !important;
}
:deep(.btn.n-form-item) {
.n-form-item-feedback-wrapper {
display: none !important;
}
}
:deep(.compact-side-form .n-input) {
--n-height: 38px !important;
}
:deep(.n-form-item-feedback__line) {
text-align: right;
font-size: 12px !important;
color: #8b96a3;
}
</style>
:deep(.n-input),
:deep(.n-input .n-input-wrapper),
:deep(.n-input .n-input__textarea) {
background: rgba(255, 255, 255, 0.72) !important;
border-radius: 18px !important;
}
:deep(.n-input) {
--n-border: 1px solid rgba(214, 224, 226, 0.92) !important;
--n-border-hover: 1px solid rgba(190, 206, 214, 0.98) !important;
--n-border-focus: 1px solid rgba(236, 102, 171, 0.52) !important;
--n-box-shadow-focus: 0 0 0 4px rgba(236, 102, 171, 0.1) !important;
}
:deep(.n-input__input-el),
:deep(.n-input__textarea-el) {
color: #314155 !important;
}
:deep(.n-input__placeholder) {
color: #95a3b2 !important;
}
:deep(.n-input__placeholder) {
font-size: 13px;
span {
font-size: 13px;
}
}
:deep(.n-form-item-feedback__line) {
color: red;
}
</style>

View File

@ -1,69 +1,151 @@
<template>
<div class="flex justify-between">
<div class="left w-[20%] shadow">
<div class="fixed top-20 left-8">
<div class="text-center text-3xl text-bold mb-2">目录</div>
<MdCatalog :editorId="blogData.aid" class="my-cata" :scrollElement="scrollElement" />
</div>
</div>
<div class="flex-1">
<n-breadcrumb class="m-2" separator="|">
<n-breadcrumb-item href="/blog/blog">
文章
</n-breadcrumb-item>
<n-breadcrumb-item>
{{ blogData.title }}
</n-breadcrumb-item>
</n-breadcrumb>
<div class="w-full px-1/20">
<!-- <div class="text-center text-[40px] font-bold text-black` mt-10">{{ blogData.title }}</div> -->
<div class="my-4 bg-white shadow-lg p-4 rounded-md">
<n-collapse @item-header-click="AISum" arrow-placement="right">
<n-collapse-item>
<template #arrow>
<div class=" text-primary ml-2">
<!-- <icon-star class="w-6" /> -->
<template>
<div
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]"
:style="boxStyle"
>
<div class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]"></div>
<div class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]"></div>
<div 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="sticky top-6">
<section class="relative 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-['']">
<div class="relative z-[1]">
<h1 class="mt-3 text-[2rem] text-center leading-[1.2] text-[#2c3a4a]">阅读目录</h1>
<div class="mt-6 rounded-[22px] border border-white/70 bg-white/58 p-4 shadow-[0_12px_28px_rgba(112,128,118,0.1)]">
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold text-[#526376]">目录</span>
<span class="rounded-full cursor-pointer bg-primary px-3 py-1 text-xs text-white">正文导航</span>
</div>
</template>
<template #header>
<div class="text-primary flex">
<icon-star class="w-6 mx-2" />
<div class="text-center text-[20px] font-bold">AI 摘要</div>
<div class="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">
<span class="text-[11px] text-[#8a98a1] transition-transform duration-200 group-open:rotate-90"></span>
<span
class="truncate transition-colors duration-200"
:class="isNodeActive(item) ? 'text-primary font-semibold' : 'hover:text-[#2f4050]'"
@click.stop="scrollToHeading(item.id)"
>
{{ item.text }}
</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]'"
@click="scrollToHeading(child.id)"
>
{{ child.text }}
</div>
</div>
</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]'"
@click="scrollToHeading(item.id)"
>
{{ item.text }}
</div>
</template>
<div v-if="tocItems.length === 0" class="px-3 py-2 text-sm text-[#7a8797]">
暂无可生成的目录
</div>
</div>
</template>
<template #header-extra>
<div class="text-primary flex">
<icon-star class="w-6 mx-2" />
</div>
</template>
<div class="indent-lg text-gray-500">
{{ msg.replaceAll('\\n', '<br>') }}
</div>
</n-collapse-item>
</n-collapse>
</div>
</section>
</div>
</aside>
<div class="flex gap-2 justify-center my-4">
<em class="text-primary">
{{ blogData.nickname }}</em>
<em class="time">{{ formatTime(blogData.updated_at, "YYYY年MM月DD日hh时") }}</em>
</div>
<section class="min-w-0 flex-1 overflow-hidden rounded-[30px] border border-white/70 bg-transparent p-4 ring-1 ring-inset ring-[rgba(255,255,255,0.38)] shadow-[0_20px_46px_rgba(109,126,117,0.1)] sm:p-6">
<n-scrollbar ref="detailScrollbar" :style="contentStyle" trigger="none" class="pr-1 mb-4 overflow-hidden">
<div class="space-y-5 pr-1">
<section class="rounded-[28px] border border-white/68 bg-transparent p-6 ring-1 ring-inset ring-[rgba(255,255,255,0.34)] shadow-[0_18px_38px_rgba(116,132,124,0.09)]">
<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/65 bg-transparent 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>
<div class="flex flex-wrap gap-4 justify-center my-4">
<div v-for="item in tags" :key="item.tid" class="flex items-center gap-1 text-primary cursor-pointer">
<n-icon><icon-tag /></n-icon>
{{ item }}
<div class="mt-4 flex flex-wrap justify-center gap-3">
<div
v-for="item in tags"
:key="item"
: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="rounded-[26px] border border-white/66 bg-transparent p-4 ring-1 ring-inset ring-[rgba(255,255,255,0.32)] shadow-[0_16px_34px_rgba(116,132,124,0.09)] 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="rounded-[26px] border border-white/66 bg-transparent px-5 py-6 ring-1 ring-inset ring-[rgba(255,255,255,0.32)] shadow-[0_18px_36px_rgba(116,132,124,0.09)] sm:px-8 sm:py-8">
<MdPreview
class="relative article-preview !bg-transparent"
:modelValue="markdown"
/>
</section>
</div>
</div>
<MdPreview ref="mdp" theme="light" class="relative " :editorId="blogData.aid" previewTheme="my"
:modelValue="markdown" />
</div>
</n-scrollbar>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { formatTime } from '@/util';
import type { ExposeParam } from 'md-editor-v3';
import { MdPreview } from 'md-editor-v3';
import type { ScrollbarInst as NScrollbarInst } from 'naive-ui';
definePage({
name: 'blog/:bid',
path: '/blog/:bid',
@ -72,153 +154,403 @@ definePage({
}
})
import { formatTime } from '@/util';
import type { ExposeParam } from 'md-editor-v3';
import { MdCatalog, MdPreview } from 'md-editor-v3';
const editorRef = ref<ExposeParam>();
const scrollElement: HTMLElement = document.querySelector('.n-scrollbar .n-scrollbar-container') as HTMLElement;
const tags = ref<any[]>([])
const route: any = useRoute()
const msg = ref<any>('...')
const aimask = ref(false)
const markdown = ref('');
const blogData = ref<any>({
aid: 'preview-only',
updated_at: Date.now()
})
async function getBlogDetail() {
const bid: any = route.params?.bid || 0
// console.log('>>> --> getBlogDetail --> bid:', route, bid)
const res = await $http.blog.getBlogDetail(bid)
console.log('>>> --> getBlogDetail --> res:', res.data)
blogData.value = res.data
let ts = res.data.tags
ts = ts.replaceAll('', ',')
tags.value = ts.split(',')
editorRef.value?.toggleCatalog(true)
markdown.value = res.data.cont
// AISum()
interface BlogDetail {
aid: string
updated_at: string | number
title: string
nickname: string
tags: string
cont: string
}
// ai总结
interface BlogDetailResponse {
data: BlogDetail
}
interface RouteParams {
bid?: string
}
interface TocItem {
id: string
text: string
level: number
children: TocChildItem[]
open: boolean
}
interface TocChildItem {
id: string
text: string
level: number
}
interface DetailScrollbarInst extends NScrollbarInst {
scrollbarInstRef?: {
containerRef: HTMLElement | null
contentRef: HTMLElement | null
} | null
}
const SOFT_TAG_PALETTE = [
'#d46a7c',
'#c97956',
'#b9894d',
'#6f9d86',
'#5d92a8',
'#7d88b8',
'#9a78b5',
'#8f7d6b',
'#c86b8f',
'#cf7f6a',
'#a98f55',
'#7aa37b',
'#5d9f95',
'#6d8fc7',
'#8a80c4',
'#a16fa1',
'#b67b67',
'#927f72',
'#6f8b5f',
'#4f8aa8',
'#7a96a1',
'#b38754',
'#a66767',
'#7b6aa8',
] as const
const editorRef = ref<ExposeParam>()
const detailScrollbar = ref<DetailScrollbarInst | null>(null)
const scrollElement = ref<HTMLElement | undefined>(undefined)
const activeHeadingId = ref('')
const headingElementMap = shallowRef(new Map<string, HTMLElement>())
const route = useRoute()
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',
updated_at: Date.now(),
title: '',
nickname: '',
tags: '',
cont: '',
})
function getTagColor(tag: string) {
const normalized = tag.trim()
if (!normalized) return SOFT_TAG_PALETTE[0]
let hash = 0
for (const char of normalized) {
hash = (hash * 31 + char.charCodeAt(0)) % SOFT_TAG_PALETTE.length
}
return SOFT_TAG_PALETTE[Math.abs(hash) % SOFT_TAG_PALETTE.length]
}
const tocItems = computed<TocItem[]>(() => {
const lines = (blogData.value.cont || '').split(/\r?\n/)
const flatItems: TocChildItem[] = []
lines.forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+?)\s*$/)
if (!match) return
const level = match[1].length
const text = match[2].replace(/[`*_~]/g, '').trim()
if (!text) return
flatItems.push({
id: createHeadingId(text, flatItems.length),
text,
level,
})
})
const tree: TocItem[] = []
let currentParent: TocItem | null = null
flatItems.forEach((item) => {
if (item.level <= 2 || !currentParent) {
currentParent = {
...item,
children: [],
open: true,
}
tree.push(currentParent)
return
}
currentParent.children.push(item)
})
return tree
})
function updateLayout() {
const viewportHeight = window.innerHeight - nav.navH - 1
boxStyle.value = { height: `${viewportHeight}px` }
contentStyle.value = { height: `${Math.max(viewportHeight - 40, 320)}px` }
}
function createHeadingId(text: string, index: number) {
return `blog-heading-${index}-${text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '')}`
}
function isNodeActive(item: TocItem) {
return activeHeadingId.value === item.id || item.children.some(child => child.id === activeHeadingId.value)
}
function getScrollbarContainer() {
return detailScrollbar.value?.scrollbarInstRef?.containerRef || null
}
function getScrollbarContent() {
return detailScrollbar.value?.scrollbarInstRef?.contentRef || null
}
function getPreviewRoot() {
return getScrollbarContent()?.querySelector('.article-preview') as HTMLElement | null
}
async function syncScrollElement() {
await nextTick()
scrollElement.value = getScrollbarContainer() || undefined
requestAnimationFrame(() => {
syncPreviewHeadings()
})
}
function syncPreviewHeadings() {
const previewRoot = getPreviewRoot()
if (!previewRoot) return
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>()
headings.forEach((heading, index) => {
const item = flatTocItems[index]
if (item) {
heading.id = item.id
heading.style.scrollMarginTop = '24px'
nextHeadingMap.set(item.id, heading)
}
})
headingElementMap.value = nextHeadingMap
updateActiveHeading()
}
function updateActiveHeading() {
const container = scrollElement.value
const previewRoot = getPreviewRoot()
if (!container || !previewRoot) return
const headings = previewRoot.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
if (!headings.length) {
activeHeadingId.value = ''
return
}
const containerRect = container.getBoundingClientRect()
const flatTocItems = tocItems.value.flatMap(item => [item, ...item.children])
let currentId = flatTocItems[0]?.id || ''
headings.forEach((heading) => {
const rect = heading.getBoundingClientRect()
if (rect.top - containerRect.top <= 80 && heading.id) {
currentId = heading.id
}
})
activeHeadingId.value = currentId
}
function scrollToHeading(id: string) {
const container = scrollElement.value
const target = headingElementMap.value.get(id) || null
if (!container || !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',
})
activeHeadingId.value = id
}
watch([markdown, tocItems], async () => {
await nextTick()
requestAnimationFrame(() => {
syncPreviewHeadings()
})
})
function parseTags(str: string) {
const normalized = (str || '').replace(//g, ',')
return normalized.split(',').map(tag => tag.trim().slice(0, 4)).filter(Boolean)
}
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()
}
async function AISum() {
console.log('>>> --> AISum --> AISum:', AISum)
if (aimask.value) return
const response = await fetch('https://api.siliconflow.cn/v1/chat/completions', {
if (aimask.value || !blogData.value.cont) return
const response = await fetch('http://38.12.26.19:8317/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer sk-jwwmhmxsjtseyekknqmamlvzmrkmwfvuacnssbwfufogrkdg'
Authorization: 'Bearer sk-7wys75B2TsQYceMbZ'
},
body: JSON.stringify({
model: "Qwen/Qwen3-8B",
model: 'gpt-5.4',
messages: [
{ role: 'system', content: "你是一个摘要生成工具你需要解释我发送给你的内容不要换行不要超过400字只需要介绍文章的内容不需要提出建议和缺少的东西。请用中文回答内容添加表情包输出的内容开头为“这篇文章介绍了”所有句号试用“喵~~ 。”代替" },
{
role: 'system',
content: '你是一个摘要生成工具请用中文总结我发送给你的文章内容不要换行不要超过300字只介绍文章内容不需要提出建议。开头固定为“这篇文章介绍了”语气自然一些。'
},
{ role: 'user', content: blogData.value.cont }
],
stream: true
})
})
console.log('>>> --> AISum --> response:', response)
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)
const chunk = decoder.decode(value ?? new Uint8Array())
const match = chunk.match(/"content":"(.*?)"/)
if (match && match[1]) {
if (match?.[1]) {
msg.value += match[1]
aimask.value = true
}
}
// const data = await response.json()
// console.log('>>> --> AISum --> data:', data)
}
onMounted(() => {
getBlogDetail()
onMounted(async () => {
updateLayout()
await getBlogDetail()
scrollElement.value?.addEventListener('scroll', updateActiveHeading)
window.addEventListener('resize', updateLayout)
})
onBeforeUnmount(() => {
scrollElement.value?.removeEventListener('scroll', updateActiveHeading)
window.removeEventListener('resize', updateLayout)
})
</script>
<style lang="less">
@import "@/assets/myblog.less";
<style scoped lang="less">
:deep(.article-preview .md-editor-preview) {
background: transparent !important;
}
.my-cata {
* {
cursor: pointer;
}
:deep(.article-preview .md-editor-preview-wrapper) {
padding: 0 !important;
background: transparent !important;
}
.md-editor-catalog-indicator {
display: none;
}
:deep(.article-preview .md-editor) {
background: transparent !important;
}
.md-editor-catalog-active>span {
padding: 5px 15px !important;
border-radius: 30px;
color: white;
background-color: @primary;
position: relative;
:deep(.article-preview .md-editor-content),
:deep(.article-preview .md-editor-preview-theme),
:deep(.article-preview .md-editor-previewOnly),
:deep(.article-preview .md-editor-previewOnly .md-editor-preview) {
background: transparent !important;
}
&::after {
content: "✦";
position: static !important;
transform: none !important;
margin-left: 12px !important;
flex-shrink: 0;
display: inline-block !important;
line-height: 1;
font-size: 14px;
// font-family: Arial, sans-serif;
color: rgba(255, 255, 255, 0.9);
z-index: 10;
animation: outline-twinkle 1.5s infinite alternate;
}
}
:deep(.article-preview .md-editor-toolbar),
:deep(.article-preview .md-editor-footer),
:deep(.article-preview .md-editor-catalog) {
background: transparent !important;
}
.md-editor-catalog-link {
padding-block: 1px !important;
}
:deep(.n-collapse),
:deep(.n-collapse-item),
:deep(.n-collapse-item__header),
:deep(.n-collapse-item__content-wrapper),
:deep(.n-collapse-item__content-inner) {
background: transparent !important;
}
.md-editor-catalog-wrapper span {
padding: 5px 15px !important;
transition: all 0.6s ease-in-out;
:deep(.article-preview .md-editor-previewOnly) {
font-size: 16px;
color: #425466;
line-height: 1.95;
}
:deep(.article-preview h1),
:deep(.article-preview h2),
:deep(.article-preview h3),
:deep(.article-preview h4),
:deep(.article-preview h5),
:deep(.article-preview h6) {
color: #2f4050;
font-weight: 700;
line-height: 1.35;
margin-top: 1.6em;
margin-bottom: 0.7em;
}
&:hover {
padding: 5px 15px !important;
border-radius: 30px;
color: white;
background-color: @primary;
}
}
:deep(.article-preview p),
:deep(.article-preview li),
:deep(.article-preview blockquote) {
color: #5f6f7f;
line-height: 1.95;
}
:deep(.article-preview p) {
margin: 0.95em 0;
}
:deep(.article-preview blockquote) {
border-left: 4px solid rgba(236, 102, 171, 0.32);
background: transparent;
border-radius: 0 16px 16px 0;
padding: 0.9rem 1rem;
}
:deep(.article-preview pre) {
border-radius: 18px;
box-shadow: 0 14px 30px rgba(88, 108, 125, 0.12);
}
:deep(.article-preview img) {
border-radius: 18px;
box-shadow: 0 16px 30px rgba(88, 108, 125, 0.12);
}
:deep(.article-preview table) {
overflow: hidden;
border-radius: 16px;
background: transparent;
}
</style>
<style scoped lang="less">
/* 文章页样式 */
// :deep(.md img) {
// width: auto !important;
// height: 200px !important;
// }
// :deep(.md-editor-catalog-active>span) {
// color: @primary;
// }
// :deep(.md-editor-catalog-indicator) {
// background-color: @primary;
// }
// :deep(.md-editor-catalog-link span):hover {
// color: @primary;
// }</style>

View File

@ -1,59 +1,148 @@
<template>
<div class="article-page w-full flex" :style="boxStyle">
<div class="w-1/4 shadow relative">
<div class="card w-2/3 absolute top-16 left-1/6">
<div @click="clickCate(i, idx)" v-for="(i, idx) in cateList" :key="i.cid"
:class="{ '!bg-primary text-white': idx == currentCateIdx }"
class="relative cate flex my-6 py-6 px-8 justify-between text-xl font-bold bg-white shadow rounded cursor-pointer">
<div class="flex items-center">
<icon-arti class="w-5 h-5 text-primary mr-3" :class="{ 'text-white': idx == currentCateIdx }"></icon-arti>
<span>{{ i.name }}</span>
</div>
<div class="bg-primary px-4 rounded-full text-white ext-sm flex items-center gap-2">
{{ i.total || 0 }}
</div>
</div>
</div>
</div>
<div class="blog-list w-3/4 mt-10">
<div v-show="blogList.length == 0" class="ml-50 text-gray-500 mt-20">
什么都没有呢~ ~
</div>
<template>
<div
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]"
:style="boxStyle"
>
<div class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]"></div>
<div class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]"></div>
<div 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 v-for="item in blogList" :key="item.aid" class="rounded-lg p-4 w-[80%] ml-1/10 my-8 shadow cursor-pointer"
@click="$router.push(`/blog/${item.aid}`)">
<div class="flex justify-right gap-4 mb-2 text-gray-600">
<em class="flex items-center gap-1 text-sm">
<n-icon><icon-date /></n-icon>
{{ formatTime(item.updated_at, 'YYYY年MM月DD日') }}
</em>
<em class="flex items-center gap-1 text-sm">
<n-icon><icon-pen /></n-icon>
{{ getSize(item.cont) }}
</em>
<em class="flex items-center gap-1 text-sm ">
<n-icon><icon-author /></n-icon>
{{ item.nickname }}</em>
<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="sticky top-6">
<section class="relative 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-['']">
<div class="relative z-[1]">
<h1 class="mt-3 text-[2rem] text-center leading-[1.2] text-[#2c3a4a]">文章分类</h1>
<div class="mt-6 grid gap-4">
<button
type="button"
@click="clearCate"
class="flex items-center justify-between rounded-[22px] border px-4 py-4 text-left transition-[transform,box-shadow,border-color,background-color,color] duration-300 [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]"
:class="currentCateIdx === -1
? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white shadow-[0_16px_34px_rgba(236,102,171,0.24)]'
: 'border-white/70 bg-white/66 text-[#435468] shadow-[0_12px_28px_rgba(112,128,118,0.1)] hover:-translate-y-0.5 hover:border-white/90 hover:bg-white/76'"
>
<span class="flex items-center gap-3">
<icon-arti class="h-5 w-5" :class="currentCateIdx === -1 ? 'text-white' : 'text-primary'" />
<span class="text-base font-semibold">全部文章</span>
</span>
<span class="rounded-full px-3 py-1 text-sm font-semibold" :class="currentCateIdx === -1 ? 'bg-white/20 text-white' : 'bg-primary text-white'">
{{ allTotal }}
</span>
</button>
<button
v-for="(item, idx) in cateList"
:key="item.cid"
type="button"
@click="clickCate(item, idx)"
class="flex items-center justify-between rounded-[22px] border px-4 py-4 text-left transition-[transform,box-shadow,border-color,background-color,color] duration-300 [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]"
:class="currentCateIdx === idx
? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white shadow-[0_16px_34px_rgba(236,102,171,0.24)]'
: 'border-white/70 bg-white/66 text-[#435468] shadow-[0_12px_28px_rgba(112,128,118,0.1)] hover:-translate-y-0.5 hover:border-white/90 hover:bg-white/76'"
>
<span class="flex min-w-0 items-center gap-3">
<icon-arti class="h-5 w-5 shrink-0" :class="currentCateIdx === idx ? 'text-white' : 'text-primary'" />
<span class="truncate text-base font-semibold">{{ item.name }}</span>
</span>
<span class="shrink-0 rounded-full px-3 py-1 text-sm font-semibold" :class="currentCateIdx === idx ? 'bg-white/20 text-white' : 'bg-primary text-white'">
{{ item.total || 0 }}
</span>
</button>
</div>
</div>
</section>
</div>
<div class="text-xl font-bold text-primary mb-2">{{ item.title }}</div>
<div class="text-sm text-gray-500 mb-4 line-clamp-3">{{ item.pro }}</div>
<div class="flex gap-4">
<div v-for="i in getTags(item.tags)" :key="i.tid"
:style="{ backgroundColor: getDictValue(tagColorList, 'tag', i, 'color') }"
class="flex items-center gap-1 cursor-pointer text-white rounded-full px-3">
<icon-tag2 class="w-4 h-4" />{{ i }}
</aside>
<section class="min-w-0 flex-1 rounded-[30px] border border-white/62 bg-white/42 p-4 shadow-[0_18px_42px_rgba(109,126,117,0.1)] backdrop-blur-[18px] sm:p-6">
<div class="mb-5 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">文章列表</div>
<div class="mt-2 text-[1.6rem] font-semibold text-[#2f4050]">{{ currentCateLabel }}</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]">
当前 {{ blogList.length }}
</div>
</div>
</div>
<div v-show="blogList.length > page_size" class="mt-20 w-[80%] ml-1/10 cursor-pointer flex justify-center">
<n-pagination v-model:page="page_num" :page-count="total" :page-slot="5" @update:page="getBlogList" />
</div>
<div class="mb-4 flex flex-wrap gap-2 lg:hidden">
<button
type="button"
@click="clearCate"
class="rounded-full border px-4 py-2 text-sm font-semibold transition-colors duration-200"
:class="currentCateIdx === -1 ? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white' : 'border-white/80 bg-white/70 text-[#526376]'"
>
全部
</button>
<button
v-for="(item, idx) in cateList"
:key="item.cid"
type="button"
@click="clickCate(item, idx)"
class="rounded-full border px-4 py-2 text-sm font-semibold transition-colors duration-200"
:class="currentCateIdx === idx ? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white' : 'border-white/80 bg-white/70 text-[#526376]'"
>
{{ item.name }} · {{ item.total || 0 }}
</button>
</div>
<n-scrollbar :style="listStyle" trigger="none" class="pr-1">
<div class="space-y-5">
<div v-if="blogList.length === 0" class="rounded-[24px] border border-dashed border-white/75 bg-white/48 px-6 py-16 text-center text-[#7a8797]">
暂时还没有相关文章
</div>
<article
v-for="item in blogList"
:key="item.aid"
class="cursor-pointer rounded-[26px] border border-white/72 bg-[linear-gradient(145deg,rgba(255,251,246,0.78),rgba(237,246,244,0.62))] p-6 shadow-[0_16px_34px_rgba(116,132,124,0.12)] backdrop-blur-[18px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1 hover:border-white/90 hover:shadow-[0_22px_42px_rgba(102,123,112,0.16)]"
@click="$router.push(`/blog/${item.aid}`)"
>
<div class="flex flex-wrap justify-end gap-x-4 gap-y-2 text-sm text-[#708091]">
<em class="flex items-center gap-1 not-italic">
<n-icon><icon-date /></n-icon>
{{ formatTime(item.updated_at, 'YYYY年MM月DD日') }}
</em>
<em class="flex items-center gap-1 not-italic">
<n-icon><icon-pen /></n-icon>
{{ getSize(item.cont) }}
</em>
<em class="flex items-center gap-1 not-italic">
<n-icon><icon-author /></n-icon>
{{ item.nickname }}
</em>
</div>
<h2 class="mt-4 text-[1.45rem] font-semibold leading-[1.35] text-primary">{{ item.title }}</h2>
<p class="mt-3 line-clamp-3 leading-[1.85] text-[#66778b]">{{ item.pro }}</p>
<div class="mt-5 flex flex-wrap gap-3">
<div
v-for="tag in getTags(item.tags)"
:key="tag"
:style="{ backgroundColor: getTagColor(tag) }"
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(88,108,125,0.16)]"
>
<icon-tag2 class="h-4 w-4" />
{{ tag }}
</div>
</div>
</article>
</div>
<div v-if="blogList.length > page_size" class="mt-8 flex justify-center rounded-[22px] border border-white/70 bg-white/52 px-4 py-5 shadow-[0_10px_24px_rgba(116,132,124,0.08)]">
<n-pagination v-model:page="page_num" :page-count="total" :page-slot="5" @update:page="getBlogList" />
</div>
</n-scrollbar>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { formatTime, getDictValue } from '@/util'
import { formatTime } from '@/util'
definePage({
name: 'blog',
meta: {
@ -61,124 +150,168 @@ definePage({
}
})
const blogList = ref<any[]>([])
const boxStyle = ref({})
const nav: any = $store.nav.useNavStore()
const cateList = ref<any[]>([])
interface CateItem {
cid: string
name: string
total: number
}
interface BlogItem {
aid: number | string
title: string
pro: string
cont: string
tags: string
nickname: string
updated_at: string | number
}
interface BlogListResponse {
data: BlogItem[]
}
interface CateListResponse {
data: CateItem[]
}
interface TagColorItem {
tag: string
color: string
}
const SOFT_TAG_PALETTE = [
'#d46a7c',
'#c97956',
'#b9894d',
'#6f9d86',
'#5d92a8',
'#7d88b8',
'#9a78b5',
'#8f7d6b',
'#c86b8f',
'#cf7f6a',
'#a98f55',
'#7aa37b',
'#5d9f95',
'#6d8fc7',
'#8a80c4',
'#a16fa1',
'#b67b67',
'#927f72',
'#6f8b5f',
'#4f8aa8',
'#7a96a1',
'#b38754',
'#a66767',
'#7b6aa8',
] as const
const blogList = ref<BlogItem[]>([])
const boxStyle = ref<Record<string, string>>({})
const listStyle = ref<Record<string, string>>({})
const nav = $store.nav.useNavStore() as { navH: number }
const cateList = ref<CateItem[]>([])
const currentCateIdx = ref(-1)
const page_size = ref(5)
const page_num = ref(1)
const total = ref(0)
const category = ref('')
const tagColorList = ref<TagColorItem[]>([])
const tagColorList: any = ref([])
const allTotal = computed(() => cateList.value.reduce((acc, cur) => acc + (cur.total || 0), 0))
function clickCate(item: any, idx: number) {
if (currentCateIdx.value == idx) {
currentCateIdx.value = -1
category.value = ''
getBlogList()
total.value = cateList.value.reduce((acc: any, cur: any) => acc + cur.total, 0)
const currentCateLabel = computed(() => {
if (currentCateIdx.value === -1) return '全部文章'
return cateList.value[currentCateIdx.value]?.name || '文章列表'
})
function updateLayout() {
const viewportHeight = window.innerHeight - nav.navH - 1
boxStyle.value = { height: `${viewportHeight}px` }
listStyle.value = { height: `${Math.max(viewportHeight - 124, 280)}px` }
}
function resetPaging() {
page_num.value = 1
}
function clearCate() {
currentCateIdx.value = -1
category.value = ''
resetPaging()
total.value = allTotal.value
void getBlogList()
}
function clickCate(item: CateItem, idx: number) {
if (currentCateIdx.value === idx) {
clearCate()
return
}
currentCateIdx.value = idx
category.value = item.cid
getBlogList()
resetPaging()
total.value = item.total
console.log('>>> --> clickCate --> total.value:', total.value)
void getBlogList()
}
function normalizeTag(tag: string) {
return tag.trim().slice(0, 4)
}
function getTags(str: string) {
const normalized = (str || '').replace(//g, ',')
return normalized.split(',').slice(0, 5).map(normalizeTag).filter(Boolean)
}
function buildTagColorMap(tags: string[]): TagColorItem[] {
return tags.map((tag, index) => ({
tag,
color: getRandomFromPalette(index),
}))
}
function getTagColor(tag: string) {
return tagColorList.value.find((item) => item.tag === tag)?.color || SOFT_TAG_PALETTE[0]
}
function getSize(str: string) {
const reg = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u00AD\u0600-\u0604\u070F\u17B4\u17B5\u200B-\u200D\u3000]/g
return (str || '').replace(reg, '').length
}
function getRandomFromPalette(index = 0): string {
return SOFT_TAG_PALETTE[index % SOFT_TAG_PALETTE.length]
}
async function getBlogList() {
const res = await $http.blog.getBlogList({
category: category.value, page_size: page_size.value, page_num: page_num.value
})
const list: Array<string> = []
// 对res.data.tags进行去重
res.data.forEach((i: any) => {
const t = getTags(i.tags)
t.forEach((ii: string) => {
if (!list.includes(ii)) list.push(ii)
})
});
tagColorList.value = list.map((i: string) => {
return {
tag: i,
color: getRandomFromPalette()
}
})
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
}
// 计算markdown的文字数量
function getSize(str: string) {
const reg = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u00AD\u0600-\u0604\u070F\u17B4\u17B5\u200B-\u200D\u3000]/g;
return str.replace(reg, '').length;
async function getCateList() {
const res = await $http.blog.getCateList() as CateListResponse
cateList.value = res.data
total.value = allTotal.value
}
// 生成一个随机的鲜艳颜色,和白色能对比
function getRandomFromPalette(): string {
const hue = Math.floor(Math.random() * 360);
return hslToHex(hue, 80, 50);
}
function hslToHex(h: number, s: number, l: number): any {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
}
return `#${f(0)}${f(8)}${f(4)}`;
}
function getTags(str: any) {
str = str.replaceAll('', ',')
let tags = str.split(',').slice(0, 5)
tags.forEach((tag: any, idx: number) => {
tags[idx] = tag.trim().slice(0, 4)
});
return tags
}
async function getCateList() {
const res = await $http.blog.getCateList()
console.log('>>> --> getCateList --> res:', res.data)
cateList.value = res.data
total.value = cateList.value.reduce((acc: any, cur: any) => acc + cur.total, 0)
}
onMounted(() => {
boxStyle.value = {
height: `calc(100vh - ${nav.navH + 1}px)`,
}
getBlogList()
getCateList()
})
onMounted(() => {
updateLayout()
void getBlogList()
void getCateList()
window.addEventListener('resize', updateLayout)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
})
</script>
<style scoped>
/* 文章页样式 */
@keyframes fangda {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.cate:hover {
animation: fangda 01s ease-in-out 1;
}
</style>