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? *.sw?
*.tsbuildinfo *.tsbuildinfo
.agent
.agents
.agent/*
.agents/*

View File

@ -6,7 +6,7 @@
<n-layout-content class="dcontent-1"> <n-layout-content class="dcontent-1">
<router-view /> <router-view />
</n-layout-content> </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"> <div class="beian" ref="footer">
<a class="text-primary" href="https://beian.miit.gov.cn" target="_blank">皖ICP备2021017362号-1</a> <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> <a class="swag text-primary" target="_blank" href="https://www.hxyouzi.com/swag">api文档</a>
@ -106,8 +106,13 @@ onMounted(() => {
<style scoped lang="less"> <style scoped lang="less">
.dcontent-1 { .dcontent-1 {
background: transparent;
}
background-color: #fbfbfb; :deep(.n-layout),
:deep(.n-layout-header),
:deep(.n-layout-content) {
background: transparent !important;
} }
.beian { .beian {

View File

@ -7,6 +7,46 @@
font-weight: normal; 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 { .n-scrollbar-rail__scrollbar {
background-color: #f6cbe770 !important; background-color: #f6cbe770 !important;
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,65 +1,125 @@
<template> <template>
<n-card class="mt-10 rounden-[10px] w-[500px]" shadow="never"> <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 v-model:value="tid" justify-content="space-evenly" animated> <n-tabs class="bg-transparent" v-model:value="tid" justify-content="space-evenly" animated>
<n-tab-pane name="tab1" tab="登录"> <n-tab-pane name="tab1" tab="登录">
<n-form ref="formLogin" layout="vertical" :data="loginData" :rules="rules"> <n-form ref="formLogin" layout="vertical" :model="loginData" :rules="rules">
<n-form-item field="username"> <n-form-item path="username" class="auth-form-item">
<n-input v-model:value="loginData.username" placeholder="请输入用户名" /> <n-input class="auth-input" v-model:value="loginData.username" placeholder="请输入用户名" />
</n-form-item> </n-form-item>
<n-form-item field="password"> <n-form-item path="password" class="auth-form-item mt-4">
<n-input v-model:value="loginData.password" type="password" show-password-on="click" placeholder="请输入密码" /> <n-input
class="auth-input"
v-model:value="loginData.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
/>
</n-form-item> </n-form-item>
<n-form-item class="form-operation-wrap"> <n-form-item class="mt-6 auth-form-item">
<n-button class="w-full" type="primary" @click="login"> </n-button> <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-item>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="tab2" tab="注册"> <n-tab-pane name="tab2" tab="注册">
<n-form ref="formReg" layout="vertical" :data="regData" :rules="rrules"> <n-form ref="formReg" layout="vertical" :model="regData" :rules="rrules">
<n-form-item field="username"> <n-form-item path="nickname" class="auth-form-item mt-4">
<n-input v-model:value="regData.username" placeholder="请输入用户名" /> <n-input class="auth-input" v-model:value="regData.nickname" placeholder="请输入昵称" />
</n-form-item> </n-form-item>
<n-form-item field="password"> <n-form-item path="username" class="auth-form-item">
<n-input v-model:value="regData.password" type="password" show-password-on="click" placeholder="请输入用密码" /> <n-input class="auth-input" v-model:value="regData.username" placeholder="请输入用户名" />
</n-form-item> </n-form-item>
<n-form-item field="nickname"> <n-form-item path="password" class="auth-form-item">
<n-input v-model:value="regData.nickname" placeholder="请输入昵称" /> <n-input
class="auth-input"
v-model:value="regData.password"
type="password"
show-password-on="click"
placeholder="请输入用户密码"
/>
</n-form-item> </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-item>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
</n-card> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const router = useRouter() import type { FormInst, FormRules } from 'naive-ui'
const props = defineProps({
setVisible: {
type: Function,
default: () => { },
}
})
// 登录注册逻辑
const tid = ref("tab1");
const formLogin: any = ref(null);
const loginData = reactive({
username: "",
password: ""
});
const formReg: any = ref(null); interface UserInfo {
const regData = reactive({ token: string
username: "", userinfo: Record<string, unknown>
password: "", }
nickname: ""
}); 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 usrLog = $store.log.useLogStore()
const rules: any = reactive({ const rules = reactive<FormRules>({
username: [ username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }, { required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: validateUsername, trigger: 'blur' }, { validator: validateUsername, trigger: 'blur' },
@ -69,79 +129,122 @@ const rules: any = reactive({
{ validator: validatePassword, trigger: 'blur' } { validator: validatePassword, trigger: 'blur' }
] ]
}) })
const rrules: any = reactive({
const rrules = reactive<FormRules>({
username: [ username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: validateUsername, trigger: 'blur' }, { validator: validateUsername, trigger: 'blur' },
], ],
password: [ password: [
{ required: true, message: '请输入密码', trigger: 'blur' }, { required: true, message: '请输入密码', trigger: 'blur' },
{ validator: validatePassword, trigger: 'blur' } { validator: validatePassword, trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
] ]
}) })
function login() {
console.log('>>> --> login --> formLogin.value:', usrLog.isLogin) async function validateForm(form: FormInst | null) {
formLogin.value?.validate(async (is: boolean) => { if (!form) return false
if (is) {
try {
await form.validate()
return true
} catch {
$msg.error('信息填写不正确,请检查后再提交') $msg.error('信息填写不正确,请检查后再提交')
} else { return false
const res = await $http.user.login(loginData) }
}
function persistLogin(data: UserInfo) {
$cookies.set('token', data.token, '1d')
$cookies.set('userinfo', data.userinfo, '1d')
usrLog.setIsLogin(true)
props.setVisible?.(false)
}
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) { if (res?.code !== 200) {
$msg.error('登录失败') $msg.error('登录失败')
return 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)
}
})
persistLogin(res.data)
$msg.success(res.msg)
} }
function register() {
formReg.value.validate(async (is: boolean) => { async function register() {
if (is) { const isValid = await validateForm(formReg.value)
$msg.error('信息填写不正确,请检查后再提交') if (!isValid) return
} else {
const res = await $http.user.register(regData) const res = await $http.user.register(regData) as ApiResponse
if (res?.code !== 200) { if (res?.code !== 200) {
$msg.error('注册失败') $msg.error('注册失败')
return return
} }
$msg.success(res.msg) $msg.success(res.msg)
tid.value = 'tab1' tid.value = 'tab1'
loginData.username = regData.username loginData.username = regData.username
regData.username = '' resetRegister()
regData.nickname = ''
regData.password = ''
}
})
} }
function validateUsername(rule: any, value: string, callback: Function) { function validateUsername(_rule: unknown, value: string) {
if (/^[a-zA-Z][a-zA-Z0-9]{3,15}$/.test(value)) return callback() if (/^[a-zA-Z][a-zA-Z0-9]{3,15}$/.test(value)) {
else return callback(new Error('请输入4-16位字母或数字,且以字母开头')) return true
} }
function validatePassword(rule: any, value: string, callback: Function) { return new Error('请输入 4-16 位字母或数字,且以字母开头')
if (/^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$/.test(value)) return callback()
else return callback(new Error('密码长度为6-12位且必须包含数字和字母'))
} }
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> </script>
<style scoped lang="less"> <style scoped lang="less">
:deep(.devui-tabs__nav) { :deep(.n-tabs),
display: flex; :deep(.n-tabs-nav),
justify-content: center; :deep(.n-tabs-pane-wrapper),
:deep(.n-tab-pane) {
li a span { background: transparent !important;
// width: 20%;
font-size: 18px !important;
font-weight: 500;
}
} }
:deep(.devui-form__item--horizontal) {
margin-top: 30px;
: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> </style>

View File

@ -61,17 +61,22 @@ onMounted(async () => {
const d: any = document.querySelector("#aplayer > div.aplayer-body > div.aplayer-miniswitcher > button"); const d: any = document.querySelector("#aplayer > div.aplayer-body > div.aplayer-miniswitcher > button");
let flag = true; let flag = true;
const app: any = document.querySelector("#aplayer > div.aplayer-lrc"); const app: any = document.querySelector("#aplayer > div.aplayer-lrc");
const apic: any = document.querySelector("#aplayer div.aplayer-body");
app.style.display = "block"; app.style.display = "block";
app.style.transform = "translateY(50px)"; app.style.transform = "translateY(50px)";
apic.style.transform = "translateX(-66px)";
d.addEventListener("click", () => { d.addEventListener("click", () => {
if (flag) { if (flag) {
app.style.transform = "translateY(0)"; app.style.transform = "translateY(0)";
ap.lrc.show(); ap.lrc.show();
apic.style.transform = "translateX(0)";
flag = false; flag = false;
} else { } else {
app.style.transform = "translateY(50px)"; app.style.transform = "translateY(50px)";
ap.lrc.hide(); ap.lrc.hide();
ap.list.hide(); ap.list.hide();
apic.style.transform = "translateX(-66px)";
flag = true; flag = true;
} }
}); });

View File

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

View File

@ -2,8 +2,15 @@
<Teleport to="body"> <Teleport to="body">
<div v-if="visible" <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)]"> class="fixed top-0 left-0 z-[1000] w-[100vw] h-[100vh] flex items-center justify-center bg-[rgba(0,0,0,0.5)]">
<div class="relative inline-flex">
<slot></slot> <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
@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> </div>
</Teleport> </Teleport>
</template> </template>

View File

@ -1,167 +1,207 @@
<template> <template>
<div ref="nav" class="main-nav hidden lg:flex justify-between bg-white"> <div
<!-- 网站Logo --> ref="nav"
<div class="px-5 items-center flex cursor-pointer w-1/5" slot="brand" @click="goHome"> 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"
<img :src="logo" alt="柚子的网站" class="h-9 align-middle" /> >
<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>
<!-- 主导航菜单 -->
<div class="w-2/5"> <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" class="" mode="horizontal" :options="menuOptions" /> <n-menu :icon-size="12" v-model:value="activeKey" mode="horizontal" :options="menuOptions" />
</div> </div>
<!-- 用户区域 -->
<div class="!text-[#ec66ab] flex items-center cursor-pointer" @click="gotoHf"> <div
<span class="flex items-center location-info truncate"> 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"
<n-icon class="mr-1"> @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> <icon-loc class="w-[26px] h-[26px]"></icon-loc>
</n-icon> </n-icon>
{{ locationInfo }} {{ locationInfo }}
</span> </span>
<span class="mx-3 text-gray-300">|</span> <span class="mx-3 text-slate-300">|</span>
<span class="weather-info mr-2 truncate">{{ wea }}</span> <span class="mr-2 truncate text-[15px] text-slate-500">{{ wea }}</span>
<i :class="'qiIcon qi-' + weaIcon + '-fill'"></i> <i :class="'qiIcon qi-' + weaIcon + '-fill'" class="text-[#ec66ab]"></i>
<span class="weather-info ml-4">{{ temp }}°C</span> <span class="ml-4 text-[15px] font-semibold text-slate-700">{{ temp }}&deg;C</span>
</div> </div>
<div class="flex items-center justify-end mr-8 w-1/20"> <div class="mr-4 flex w-1/20 items-center justify-end">
<n-dropdown class="cursor-pointer w-[100px]" v-if="userinfo" :options="oprOp" @select="handleSelect" <n-dropdown v-if="userinfo" class="w-[128px] cursor-pointer" :options="oprOp" @select="handleSelect" trigger="hover">
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]">
<div class="flex items-center"> <n-avatar round :src="userinfo.ava_url" class="cursor-pointer ring-2 ring-white/80" alt="用户头像" />
<n-avatar round :src="userinfo.ava_url" class="cursor-pointer" alt="用户的头" /> <div class="ml-2 truncate text-sm font-medium text-slate-600">{{ userinfo.nickname }}</div>
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
</div> </div>
</n-dropdown> </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>
</div> </div>
<!-- 登录弹窗 -->
<masked :visible="visible" :setVisible="setVisible"> <masked :visible="visible" :setVisible="setVisible">
<loginModal :setVisible="setVisible" /> <loginModal :setVisible="setVisible" />
</masked> </masked>
</div>
<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>
<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>
<div class="flex items-center mr-2"> <div class="flex items-center mr-2">
<div v-if="userinfo" class="flex items-center"> <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" alt="用户头" /> <n-avatar :src="userinfo.ava_url" round class="cursor-pointer ring-2 ring-white/80" alt="用户头" />
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div> <div class="ml-2 text-sm font-medium text-slate-600">{{ userinfo.nickname }}</div>
</div> </div>
<div v-else class="flex items-center"> <div v-else class="flex items-center">
<!-- <n-avatar round class="cursor-pointer" @click="toLogin"></n-avatar> <n-button
<div class="cursor-pointer ml-2 text-gray text-sm" @click="toLogin">登录</div> --> type="primary"
<n-button type="primary" size="small" @click="toLogin">登录</n-button> 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> </div>
</div> </div>
<!-- 修改头像弹窗 -->
<masked v-if="usrLog.isLogin" :visible="editModal" :setVisible="handdleItemCancel"> <masked v-if="usrLog.isLogin" :visible="editModal" :setVisible="handdleItemCancel">
<div class="w-[500px] bg-white p-8 rounded-md"> <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="text-center text-lg mb-8">修改头像</div> <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" <n-upload
:show-file-list="false" accept="image/*"> 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"> <div class="flex flex-col justify-center items-center">
<n-avatar :size="80" :src="userinfo.ava_url" round class="cursor-pointer" alt="用户头" /> <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="cursor-pointer mt-4 text-gray text-sm">点击头像上传不超过3m的图片</em> <em class="mt-4 cursor-pointer text-sm text-slate-500">点击头像上传不超过 3M 的图片</em>
</div> </div>
</n-upload> </n-upload>
<div class="mt-8 flex justify-between"> <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>
</div> </div>
</masked> </masked>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 从@/icon/menu引入所有的svg文件
import logo from '@/assets/images/logo.png'; 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 artiSvg from '@/icon/menu/arti.svg';
// import downSvg from '@/icon/menu/download.svg';
import homeSvg from '@/icon/menu/home.svg'; import homeSvg from '@/icon/menu/home.svg';
import linkSvg from '@/icon/menu/link.svg'; import linkSvg from '@/icon/menu/link.svg';
import picSvg from '@/icon/menu/pic.svg'; import picSvg from '@/icon/menu/pic.svg';
// import settingSvg from '@/icon/menu/setting.svg'; import type { UploadFileInfo } from 'naive-ui';
import loginModal from '@/components/Login.vue'
import masked from '@/components/mask.vue'
import type { UploadFileInfo } from 'naive-ui'
import { onMounted, ref, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter, RouterLink } from 'vue-router'; 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 activeKey = ref('home')
const route = useRoute(); const route = useRoute();
const visible = ref(false); const visible = ref(false);
const router = useRouter(); const router = useRouter();
const locationInfo = ref("获取位置中..."); const locationInfo = ref('获取位置中...');
const latitude = ref<number | null>(null); const latitude = ref<number | null>(null);
const longitude = ref<number | null>(null); const longitude = ref<number | null>(null);
const fxlink = ref<string>("#") const fxlink = ref<string>('#')
const wea = ref("") const wea = ref('')
const weaIcon = ref<string>("") const weaIcon = ref<string>('')
const temp = ref<number>(0); const temp = ref<number>(0);
const userinfo: any = ref(null) const userinfo = ref<UserInfo | null>(null)
const nav: any = useTemplateRef('nav') const nav: any = useTemplateRef('nav')
const navx = $store.nav.useNavStore() const navx = $store.nav.useNavStore()
const usrLog = $store.log.useLogStore() const usrLog = $store.log.useLogStore()
const menuOptions = ref([
const menuOptions = [
{ {
label: () => h(RouterLink, { to: '/home', class: 'flex items-center justify-center' }, { default: () => '首页' }), label: () => h(RouterLink, { to: '/home', class: 'flex items-center justify-center' }, { default: () => '首页' }),
key: "home", key: 'home',
icon: () => h(homeSvg) icon: () => h(homeSvg)
}, },
{ {
label: () => h(RouterLink, { to: '/gallery', class: 'flex items-center justify-center' }, { default: () => '画廊' }), label: () => h(RouterLink, { to: '/gallery', class: 'flex items-center justify-center' }, { default: () => '画廊' }),
key: "gallery", key: 'gallery',
icon: () => h(picSvg) icon: () => h(picSvg)
}, },
{ {
label: () => h(RouterLink, { to: '/blog', class: 'flex items-center justify-center' }, { default: () => '文章' }), label: () => h(RouterLink, { to: '/blog', class: 'flex items-center justify-center' }, { default: () => '文章' }),
key: "blog", key: 'blog',
icon: () => h(artiSvg) 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: () => '友链' }), label: () => h(RouterLink, { to: '/plink', class: 'flex items-center justify-center' }, { default: () => '友链' }),
key: "plink", key: 'plink',
icon: () => h(linkSvg) icon: () => h(linkSvg)
}, },
]); ]
const oprOp = ref([
const oprOp = [
{ {
key: 'logout', key: 'avatar',
label: '退出登录', label: '更换头像',
}, },
{ {
key: 'console', key: 'console',
label: '控制台', label: '控制台',
}, },
{ {
key: 'avatar', key: 'logout',
label: '更换头像', label: '退出登录',
} }
]) ]
const editModal = ref<boolean>(false) const editModal = ref(false)
const token = ref<string>("") 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 }) { function beforeUpload(data: { file: UploadFileInfo }) {
console.log('>>> --> beforeUpload --> data:', data.file.file?.size)
const size = data.file.file?.size || 4 * 1024 * 1024 const size = data.file.file?.size || 4 * 1024 * 1024
if (size > 3 * 1024 * 1024) { if (size > 3 * 1024 * 1024) {
$msg.error('上传的图片大小不能超过 3M') $msg.error('上传的图片大小不能超过 3M')
@ -171,10 +211,15 @@ function beforeUpload(data: { file: UploadFileInfo }) {
} }
function handleUpload(f: any) { function handleUpload(f: any) {
console.log('上传完成', JSON.parse(f.event.target.response)); const responseText = f?.event?.target?.response
const res = JSON.parse(f.event.target.response) if (!responseText) {
$msg.error('上传失败')
editModal.value = false
return
}
const res: UploadResponse = JSON.parse(responseText)
if (res.code === 200) { if (res.code === 200) {
// $msg.success(res.msg);
autoLogin() autoLogin()
} else { } else {
$msg.error(res.msg); $msg.error(res.msg);
@ -189,44 +234,42 @@ async function autoLogin() {
usrLog.setIsLogin(false) usrLog.setIsLogin(false)
$cookies.remove('token') $cookies.remove('token')
$cookies.remove('userinfo') $cookies.remove('userinfo')
syncUserFromCookies()
return return
} }
$cookies.set('token', res.data.token, '1d') $cookies.set('token', res.data.token, '1d')
$cookies.set('userinfo', res.data.userinfo, '1d') $cookies.set('userinfo', res.data.userinfo, '1d')
$msg.success(res.msg) $msg.success(res.msg)
usrLog.setIsLogin(true) usrLog.setIsLogin(true)
userinfo.value = res.data.userinfo syncUserFromCookies()
} }
function handdleItemCancel() { function handdleItemCancel() {
editModal.value = false editModal.value = false
} }
function setVisible(v: boolean) {
function setVisible(v: any) {
visible.value = v visible.value = v
} }
function handleSelect(key: string) { function handleSelect(key: string) {
console.log('>>> --> handleSelect --> key:', key) if (key === 'logout') {
if (key == 'logout') {
logout() logout()
return return
} }
if (key == 'console') { if (key === 'console') {
gotoConsole() gotoConsole()
return return
} }
if (key == 'avatar') { if (key === 'avatar') {
editModal.value = true editModal.value = true
return
} }
} }
// 获取地理位置
const getLocation = () => { const getLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
locationInfo.value = "您的浏览器不支持地理位置定位"; locationInfo.value = '您的浏览器不支持地理位置定位';
return; return;
} }
@ -235,31 +278,26 @@ const getLocation = () => {
latitude.value = position.coords.latitude; latitude.value = position.coords.latitude;
longitude.value = position.coords.longitude; longitude.value = position.coords.longitude;
const jw = `${longitude.value.toFixed(2)},${latitude.value.toFixed(2)}`; const jw = `${longitude.value.toFixed(2)},${latitude.value.toFixed(2)}`;
await handdleJw(jw);
// 这里可以添加调用后端API获取具体位置名称的逻辑
handdleJw(jw);
}, },
async (error) => { async (error) => {
switch (error.code) { switch (error.code) {
case error.PERMISSION_DENIED: case error.PERMISSION_DENIED:
locationInfo.value = "正在定位...";
break;
case error.POSITION_UNAVAILABLE: case error.POSITION_UNAVAILABLE:
locationInfo.value = "正在定位...";
break;
case error.TIMEOUT: case error.TIMEOUT:
locationInfo.value = "正在定位..."; locationInfo.value = '正在定位...';
break; break;
} }
try {
const res = await $http.mix.getIp(); const res = await $http.mix.getIp();
latitude.value = Number(res.data.data.lat); latitude.value = Number(res.data.data.lat);
longitude.value = Number(res.data.data.lng); longitude.value = Number(res.data.data.lng);
const jw = `${longitude.value?.toFixed(4)},${latitude.value?.toFixed(4)}`; const jw = `${longitude.value?.toFixed(4)},${latitude.value?.toFixed(4)}`;
await handdleJw(jw);
// 这里可以添加调用后端API获取具体位置名称的逻辑 } catch (e) {
handdleJw(jw); locationInfo.value = '定位失败';
}
}, },
{ {
enableHighAccuracy: false, enableHighAccuracy: false,
@ -270,101 +308,129 @@ const getLocation = () => {
}; };
async function handdleJw(jw: string) { async function handdleJw(jw: string) {
const zxs = ['北京', '重庆', '天津', '上海'] const zxs = [
// 根据经纬度获取物理位置 '北京',
'重庆',
'天津',
'上海'
]
try {
const loc = await $http.mix.getLocation({ location: jw }); const loc = await $http.mix.getLocation({ location: jw });
console.log('>>> --> handdleJw --> loc:', loc) if (loc.code !== 200) {
fxlink.value = loc.data.fxLink $msg.error(loc.msg);
if (loc.code == 200) { return
}
const data = loc.data; const data = loc.data;
fxlink.value = data.fxLink || '#'
if (zxs.includes(data.adm2)) { if (zxs.includes(data.adm2)) {
locationInfo.value = `${data.adm1}${data.name}`; locationInfo.value = `${data.adm1}${data.name}`;
} else { } else {
locationInfo.value = `${data.adm1}${data.adm2}${data.name}`; locationInfo.value = `${data.adm1}${data.adm2}${data.name}`;
} }
} else {
$msg.console.error(loc.msg);
return
}
const res = await $http.mix.getWeather({ location: jw }); const res = await $http.mix.getWeather({ location: jw });
console.log('>>> --> handdleJw --> res:', res) if (res.code === 200) {
if (res.code == 200) {
wea.value = res.data.text; wea.value = res.data.text;
weaIcon.value = res.data.icon; weaIcon.value = res.data.icon;
temp.value = res.data.temp; temp.value = res.data.temp;
} else { } else {
$msg.console.error(loc.msg); $msg.error(res.msg);
}
} catch (e) {
locationInfo.value = '定位失败';
wea.value = ''
weaIcon.value = ''
temp.value = 0
} }
} }
watch(() => route.name, (newVal) => { watch(() => route.name, (newVal) => {
activeKey.value = newVal as string activeKey.value = newVal as string
updateNavHeight()
}, { immediate: true })
watch(() => usrLog.isLogin, () => {
syncUserFromCookies()
}) })
function goHome() { function goHome() {
if (route.name == 'home') return if (route.name === 'home') return
router.push('/home'); router.push('/home');
} }
function toLogin() { function toLogin() {
console.log('>>> --> toLogin --> toLogin:', 'toLogin')
if ($cookies.get('token')) return if ($cookies.get('token')) return
visible.value = true visible.value = true
} }
function logout() { function logout() {
console.log('>>> --> logout --> logout:', 'logout')
$cookies.remove('token'); $cookies.remove('token');
$cookies.remove('userinfo'); $cookies.remove('userinfo');
usrLog.setIsLogin(false) usrLog.setIsLogin(false)
userinfo.value = null; syncUserFromCookies()
} }
function gotoConsole() { function gotoConsole() {
window.open("https://www.hxyouzi.com/console/home", "_BLACK") window.open('https://www.hxyouzi.com/console/home', '_blank')
} }
function gotoHf() { function gotoHf() {
console.log('>>> --> gotoHf --> fxlink:', fxlink) if (!fxlink.value || fxlink.value === '#') return
window.open(fxlink.value, "_BLACK") window.open(fxlink.value, '_blank')
} }
onMounted(() => { onMounted(() => {
syncUserFromCookies()
userinfo.value = $cookies.get('userinfo'); getLocation()
console.log('>>>>>>>>>>', userinfo.value); updateNavHeight()
setTimeout(() => { window.addEventListener('resize', updateNavHeight)
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')
}); });
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> </script>
<style scoped lang="less"> <style scoped lang="less">
.main-nav {
box-shadow: 0 1px 15px 0 @primary;
margin-bottom: 1px;
}
:deep(.n-menu-item-content__icon) { :deep(.n-menu-item-content__icon) {
width: 16px !important; width: 16px !important;
height: 16px !important; height: 16px !important;
} }
: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> </style>

View File

@ -1,20 +1,28 @@
<template> <template>
<div class="image-container flex w-full"> <div class="image-shell" :style="shellStyle">
<n-image ref="lazyRef" class="lazy__img" :src="url" @load="handleLoad" @error="handleError"> <n-image ref="lazyRef" class="lazy__img" :src="url" @load="handleLoad" @error="handleError">
<template #placeholder> <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>
<template #error> <template #error>
<div class="lazy-error">
<img :src="errorImg" alt="error" /> <img :src="errorImg" alt="error" />
</div>
</template> </template>
</n-image> </n-image>
</div> </div>
</template> </template>
<script setup lang="ts"> <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 loadError from "../assets/loadError.png";
import loadingImg from "../assets/loading.gif"; import loadingImg from "../assets/loading.gif";
import { waterfallImageLoadedKey } from "../utils/keys";
const props = defineProps({ const props = defineProps({
previewIcon: { previewIcon: {
type: String, type: String,
@ -31,40 +39,104 @@ const props = defineProps({
errorImg: { errorImg: {
type: String, type: String,
default: loadError, 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 lazyRef = ref<any>(null);
const hasNotified = shallowRef(false);
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 handleLoad = () => { const handleLoad = () => {
imgLoaded(); notifyLoaded();
}; };
const handleError = () => { const handleError = () => {
// 可以在这里添加错误处理逻辑 notifyLoaded();
}; };
onMounted(() => { nextTick(() => {
if (lazyRef.value) { const nativeImg = lazyRef.value?.$el?.querySelector?.("img") as
imgLoaded(); | HTMLImageElement
} | undefined;
if (nativeImg?.complete && nativeImg.currentSrc) notifyLoaded();
}); });
</script> </script>
<style scoped> <style scoped>
:deep(.n-image img) { .image-shell {
width: 100%; width: 100%;
height: auto; height: 100%;
/* object-fit: cover; */ 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 { .lazy__img {
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: center;
align-items: 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"], .lazy__img img[alt="loading"],
@ -75,6 +147,39 @@ onMounted(() => {
margin: 0 auto; 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 { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@ -83,11 +188,8 @@ onMounted(() => {
100% { 100% {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.zhuan { .zhuan {
animation: spin 1.5s linear infinite; animation: spin 1.5s linear infinite;
} }

View File

@ -1,6 +1,11 @@
<template> <template>
<div ref="waterfallWrapper" class="waterfall-list" :style="{ height: `${wrapperHeight}px` }"> <div ref="waterfallWrapper" class="waterfall-list" :style="wrapperStyle">
<div v-for="(item, index) in list" :key="getKey(item, index)" :style="{height:`${colWidth * item.height / item.width}px`}" class="waterfall-item"> <div
v-for="(item, index) in list"
:key="getKey(item, index)"
:style="getItemStyle(index)"
class="waterfall-item"
>
<div class="waterfall-card h-full"> <div class="waterfall-card h-full">
<slot name="item" :item="item" :index="index" :url="getRenderURL(item)" /> <slot name="item" :item="item" :index="index" :url="getRenderURL(item)" />
</div> </div>
@ -11,10 +16,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import type { PropType } from "vue"; 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 type { ViewCard } from "../types/waterfall";
import { useCalculateCols, useLayout } from "../use"; import { useCalculateCols, useLayout } from "../use";
import Lazy from "../utils/Lazy"; import { waterfallImageLoadedKey } from "../utils/keys";
import { getValue } from "../utils/util"; import { getValue } from "../utils/util";
const props = defineProps({ const props = defineProps({
@ -34,6 +39,14 @@ const props = defineProps({
type: Number, type: Number,
default: 200, default: 200,
}, },
widthSelector: {
type: String,
default: "width",
},
heightSelector: {
type: String,
default: "height",
},
columns: { columns: {
type: Number, type: Number,
default: 3, default: 3,
@ -84,62 +97,91 @@ const props = defineProps({
}, },
}); });
const lazy = new Lazy(props.lazyload, props.loadProps, props.crossOrigin); const waterfallWrapper = useTemplateRef<HTMLElement>("waterfallWrapper");
provide("lazy", lazy);
// 容器块信息 // 瀹瑰櫒鍧椾俊鎭?
const { waterfallWrapper, wrapperWidth, colWidth, cols, offsetX } = const { wrapperWidth, colWidth, cols, offsetX } = useCalculateCols(
useCalculateCols(props); 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( const { wrapperHeight, layoutHandle } = useLayout(
props, props,
colWidth, colWidth,
cols, cols,
offsetX, 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(() => { const renderer = useDebounceFn(() => {
layoutHandle(); layoutHandle();
// console.log("强制更新排版");
}, props.delay); }, props.delay);
// 列表发生变化直接触发排版
watch( watch(
() => [wrapperWidth, colWidth, props.list], [wrapperWidth, cols, itemHeights, () => props.list],
() => { () => {
renderer(); renderer();
}, },
{ deep: true } { immediate: true }
); );
// 尺寸宽度变化防抖触发 // 鍥剧墖鍔犺浇瀹屾垚
const sizeChangeTime = ref(0); provide(waterfallImageLoadedKey, renderer);
provide("sizeChangeTime", sizeChangeTime); // 鏍规嵁閫夋嫨鍣ㄨ幏鍙栧浘鐗囧湴鍧€
// 图片加载完成
provide("imgLoaded", renderer);
// 根据选择器获取图片地址
const getRenderURL = (item: ViewCard): string => { 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 => { const getKey = (item: ViewCard, index: number): string | number => {
return item[props.rowKey] || index; return item[props.rowKey] ?? index;
}; };
const clearAndReload = () => { const clearAndReload = () => {
const originalList = [...props.list]; layoutHandle();
props.list.length = 0;
setTimeout(() => {
props.list.push(...originalList);
renderer();
}, 0);
}; };
defineExpose({ defineExpose({
@ -159,42 +201,53 @@ defineExpose({
width: 100%; width: 100%;
position: relative; position: relative;
overflow: hidden; 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 { .waterfall-item {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
/* transition: .3s; */
/* 初始位置设置到屏幕以外,避免懒加载失败 */
transform: translate3d(0, 3000px, 0); transform: translate3d(0, 3000px, 0);
visibility: hidden; visibility: hidden;
will-change: transform, opacity;
} }
/* 初始的入场效果 */ .waterfall-card {
@-webkit-keyframes fadeIn { height: 100%;
0% { overflow: hidden;
opacity: 0; 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% { .waterfall-card :deep(img) {
opacity: 1; display: block;
}
} }
@keyframes fadeIn { @keyframes fadeIn {
0% { 0% {
opacity: 0; opacity: 0.01;
transform: translate3d(0, 18px, 0) scale(0.985);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
} }
.fadeIn { .fadeIn {
-webkit-animation-name: fadeIn;
animation-name: fadeIn; animation-name: fadeIn;
} }
</style> </style>

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { Ref } from "vue"; import type { Ref } from "vue";
import { ref } from "vue"; import { nextTick, onBeforeUnmount, ref } from "vue";
import { addClass, hasClass, prefixStyle } from "../utils/dom"; import { addClass, hasClass, prefixStyle } from "../utils/dom";
import type { WaterfallProps } from "../types/waterfall"; import type { WaterfallProps } from "../types/waterfall";
import type { CssStyleObject, Nullable } from "../types/util"; import type { CssStyleObject, Nullable } from "../types/util";
@ -15,104 +15,121 @@ export function useLayout(
colWidth: Ref<number>, colWidth: Ref<number>,
cols: Ref<number>, cols: Ref<number>,
offsetX: 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 posY = ref<number[]>([]);
const wrapperHeight = ref(0); const wrapperHeight = ref(0);
let frameId = 0;
let layoutTaskId = 0;
// 获取对应y下标的x的值 // 鑾峰彇瀵瑰簲y涓嬫爣鐨剎鐨勫€?
const getX = (index: number): number => { const getX = (index: number): number => {
const count = props.hasAroundGutter ? index + 1 : index; const count = props.hasAroundGutter ? index + 1 : index;
return props.gutter * count + colWidth.value * index + offsetX.value; return props.gutter * count + colWidth.value * index + offsetX.value;
}; };
// 初始y // 鍒濆y
const initY = (): void => { const initY = (): void => {
posY.value = new Array(cols.value).fill( posY.value = new Array(cols.value).fill(
props.hasAroundGutter ? props.gutter : 0 props.hasAroundGutter ? props.gutter : 0
); );
}; };
// 添加入场动画 // 娣诲姞鍏ュ満鍔ㄧ敾
const animation = addAnimation(props); const animation = addAnimation(props);
// 排版 const doLayout = () => {
const layoutHandle = async () => {
// 初始化y集合
initY(); initY();
// 构造列表 const items =
const items: HTMLElement[] = []; waterfallWrapper.value?.querySelectorAll<HTMLElement>(".waterfall-item") ??
if (waterfallWrapper && waterfallWrapper.value) { [];
waterfallWrapper.value.childNodes.forEach((el: any) => {
if (el!.className === "waterfall-item") items.push(el); if (items.length === 0) {
}); wrapperHeight.value = 0;
return;
} }
// 获取节点
if (items.length === 0) return false;
// 遍历节点
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const curItem = items[i] as HTMLElement; const curItem = items[i];
// 最小的y值 const minY = Math.min(...posY.value);
const minY = Math.min.apply(null, posY.value);
// 最小y的下标
const minYIndex = posY.value.indexOf(minY); const minYIndex = posY.value.indexOf(minY);
// 当前下标对应的x
const curX = getX(minYIndex); const curX = getX(minYIndex);
// 设置x,y,width
const style = curItem.style as CssStyleObject; 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`; style.width = `${colWidth.value}px`;
// 更新当前index的y值 const height = getItemHeight(i, curItem) ?? curItem.offsetHeight;
const { height } = curItem.getBoundingClientRect();
posY.value[minYIndex] += height + props.gutter; posY.value[minYIndex] += height + props.gutter;
// 添加入场动画
animation(curItem, () => { animation(curItem, () => {
// 添加动画时间 if (transition) {
if (transition) style[transition] = "transform .3s"; 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 { return {
wrapperHeight, wrapperHeight,
layoutHandle, layoutHandle,
}; };
} }
// 动画 // 鍔ㄧ敾
function addAnimation(props: WaterfallProps) { function addAnimation(props: WaterfallProps) {
return (item: HTMLElement, callback?: () => void) => { 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)) { if (content && !hasClass(content, props.animationPrefix)) {
const durationSec = `${props.animationDuration / 1000}s`; const durationSec = `${props.animationDuration / 1000}s`;
const delaySec = `${props.animationDelay / 1000}s`; const delaySec = `${props.animationDelay / 1000}s`;
const style = content.style as CssStyleObject; const style = content.style as CssStyleObject;
style.visibility = "visible"; style.visibility = "visible";
if (duration) style[duration] = durationSec; if (duration) style[duration] = durationSec;
if (delay) style[delay] = delaySec; if (delay) style[delay] = delaySec;
if (fillMode) style[fillMode] = "both"; if (fillMode) style[fillMode] = "both";
addClass(content, props.animationPrefix); addClass(content, props.animationPrefix);
addClass(content, props.animationEffect); addClass(content, props.animationEffect);
// 确保动画完成后item可见 window.setTimeout(() => {
setTimeout(() => { callback?.();
const itemStyle = item.style as CssStyleObject;
itemStyle.visibility = "visible";
if (callback) callback();
}, props.animationDuration + props.animationDelay); }, 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> <template>
<n-scrollbar ref="virtualListInst" class="py-5" :style="boxStyle" @scroll="handleScroll"> <div
<n-input class="my-4 !w-[72%] ml-[14%]" round size="large" v-model:value="kw" @keyup.enter="onSearch" 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%)]">
placeholder="请输入关键字"> <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>
<div
class="mt-6 rounded-full border border-white/62 bg-[linear-gradient(135deg,rgba(255,249,241,0.76),rgba(236,245,243,0.72)),radial-gradient(circle_at_top_right,rgba(255,255,255,0.28),transparent_34%)] 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> <template #suffix>
<n-icon size="large"> <n-icon class="cursor-pointer text-slate-500" size="large">
<icon-search /> <icon-search />
</n-icon> </n-icon>
</template> </template>
</n-input> </n-input>
</div>
</section>
<Waterfall class="ml-[10%] !w-[80%]" ref="waterfall" :list="fileList" :gutter="gutter" :columns="column" <section
img-selector="url" animation-effect="fadeIn" :animation-duration="1000" :animation-delay="300" 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]">
backgroundColor="transparent"> > <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="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>
<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 }"> <template #item="{ item }">
<div <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"> 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)]">
<!-- <div class="image-wrapper"> --> <LazyImg class="overflow-hidden rounded-[20px]" :Pwidth="item.width" :Pheight="item.height"
<LazyImg class="rounded-md overflow-hidden" :Pwidth="item.width" :Pheight="item.height" :url="item.filepath" /> :url="item.filepath" />
<!-- </div> -->
<div <div
class="hidden truncate group-hover:block absolute rounded-md z-10 truncate top-0 text-center w-full bg-[#00000070] text-white"> 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 }} {{ item.filename }}
</div> </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"> 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> <span class="text-[#f1d9db] font-600">{{ item.nickname }}</span> 分享</div> <div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-[#eef4f8]">
<div class="text-sm cursor-pointer text-[#f6cbe7] flex items-center" @click="downloadFile(item.filepath)"> <span class="font-semibold text-[#ffd8cb]">{{ item.nickname }}</span> 分享
<icon-download class="w-5 h-5" /> </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> </div>
</div> </div>
</template> </template>
</Waterfall> </Waterfall>
</section>
</n-scrollbar> </n-scrollbar>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { throttle } from 'es-toolkit'; import { throttle } from 'es-toolkit'
import { LazyImg, Waterfall } from '../lib'; import { LazyImg, Waterfall } from '../lib'
definePage({ definePage({
name: 'gallery', name: 'gallery',
meta: { meta: {
title: '画廊', 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 nav: any = $store.nav.useNavStore()
const boxStyle: any = ref({}) const boxStyle = ref<Record<string, string>>({})
// 画廊页逻辑 const fileList = ref<GalleryItem[]>([])
const fileList = ref<any[]>([]); const pn = ref(1)
const pn = ref(1); const ps = ref(PAGE_SIZE)
const ps = ref(20); const loading = ref(false)
const loading = ref(false); const kw = ref('')
const kw = ref<string>(''); const cwidth = ref(240)
const cwidth = ref<number>(240); const column = ref(5)
const column = ref<number>(5); const gutter = ref(18)
const gutter = ref<number>(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 renderWaterfall() {
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;
waterfall.value?.renderer() 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() { async function getFileList() {
if (loading.value) return; if (loading.value) return
loading.value = true; loading.value = true
try { try {
const res = await $http.file.getFileList({ const res = await $http.file.getFileList({
keyword: kw.value, keyword: kw.value,
page_num: pn.value, page_num: pn.value,
page_size: ps.value, page_size: ps.value,
}); })
const list = res.data as GalleryItem[]
// console.log('>>> --> getFileList --> res:', res); if (list.length < ps.value) {
if (res.data.length < ps.value) { isLoadAll.value = true
isLoadAll.value = true;
}
if (pn.value === 1) {
fileList.value = res.data;
} else {
// 追加新数据
res.data.forEach((item: any) => {
fileList.value.push(item);
});
} }
appendFileList(list)
} catch (error) { } catch (error) {
console.error('获取文件列表失败:', error); console.error('获取图片列表失败:', error)
} finally { } finally {
loading.value = false; loading.value = false
waterfall.value?.renderer(); 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
// 监听滚动事件 if (scrollTop + clientHeight >= scrollHeight - SCROLL_THRESHOLD) {
const handleScroll: any = throttle((e: any) => { pn.value += 1
// console.log('>>> --> handleScroll --> loading:', e) getFileList()
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();
} }
}, 1000) }, 1000)
function onSearch() { function onSearch() {
pn.value = 1; pn.value = 1
kw.value = kw.value.trim() kw.value = kw.value.trim()
isLoadAll.value = false isLoadAll.value = false
getFileList(); getFileList()
} }
function downloadFile(url: string) { function downloadFile(url: string) {
console.log('>>> --> downloadFile --> url:', url) const link = document.createElement('a')
// 创建临时a标签 link.href = url
const link = document.createElement('a'); const fileName = url.split('/').pop() || 'downloaded-file'
// 设置下载链接 link.download = fileName
link.href = url; link.style.display = 'none'
// 提取文件名 document.body.appendChild(link)
const fileName = url.split('/').pop() || 'downloaded-file'; link.click()
// 设置下载属性和文件名 document.body.removeChild(link)
link.download = fileName;
// 设置为隐藏元素
link.style.display = 'none';
// 添加到文档
document.body.appendChild(link);
// 触发点击事件
link.click();
// 移除临时元素
document.body.removeChild(link);
} }
onMounted(() => { onMounted(() => {
calculateColumns() calculateColumns()
getFileList(); updateBoxStyle()
console.log('>>> --> nav.NavH:', nav.navH) getFileList()
boxStyle.value = { window.addEventListener('resize', calculateColumns)
maxHeight: `calc(100vh - 5px - ${nav.navH}px)`, window.addEventListener('resize', updateBoxStyle)
} window.addEventListener('scroll', handleScroll)
// 添加滚动监听 })
window.addEventListener('resize', calculateColumns);
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => { onUnmounted(() => {
pn.value = 1; resetGalleryState()
kw.value = ''; window.removeEventListener('resize', calculateColumns)
fileList.value = []; window.removeEventListener('resize', updateBoxStyle)
isLoadAll.value = false window.removeEventListener('scroll', handleScroll)
// 移除监听 })
window.removeEventListener('scroll', handleScroll);
});
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
: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);
}
: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) { :deep(.n-image img) {
border-radius: 0.375rem !important; border-radius: 1rem !important;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -1,117 +1,168 @@
<template> <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>
<n-scrollbar class="h-full">
<div class="right h-full grid grid-cols-3 gap-5 flex-1 px-12">
<div <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" 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%)]"
v-for="p in pList" :key="p.pid" @click="gotoWebsite(p.url)"> :style="boxStyle">
<div class="ava"> <div
<n-avatar round size="large" :src="p.avater"></n-avatar> 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>
<em class="bg-[#fbf2e3] px-4 rounded-full text-sm text-primary mb-4">{{ p.name }}</em> <div
<div class="font-bold text-xl text-gray-700 mb-1"> 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]">
{{ p.title }} </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>
<div class="flex gap-1 truncate mb-1 text-sm"> <div class="relative z-[1] mx-auto flex h-full w-[86%] gap-6 py-5">
<div v-for="i in getTags(p.tagname)" :key="i.tid" <aside class="hidden w-[25rem] shrink-0 xl:block">
:style="{ backgroundColor: getDictValue(tagColorList, 'tag', i, 'color') }" <div class="sticky top-4">
class="flex items-center gap-1 cursor-pointer text-sm text-white rounded-full px-3"> <section
<icon-tag2 class="w-3 h-3 " />{{ i }} 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>
</section>
</div>
</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> </div>
<div class="truncate w-full text-gray-400 text-sm text-center">
<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.desc }}
</p>
</div> </div>
</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> </div>
</n-scrollbar> </n-scrollbar>
</section>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import avaIcon from '@/icon/plink/ava.svg'; import avaIcon from '@/icon/plink/ava.svg'
import descIcon from '@/icon/plink/desc.svg'; import descIcon from '@/icon/plink/desc.svg'
import siteIcon from '@/icon/plink/site.svg'; import siteIcon from '@/icon/plink/site.svg'
import tagIcon from '@/icon/plink/tag.svg'; import tagIcon from '@/icon/plink/tag.svg'
import urlIcon from '@/icon/plink/uri.svg'; import urlIcon from '@/icon/plink/uri.svg'
import userIcon from '@/icon/plink/user.svg'; import userIcon from '@/icon/plink/user.svg'
import { getDictValue } from '@/util'; import type { FormInst, FormRules } from 'naive-ui'
import type { Component } from 'vue'
definePage({ definePage({
name: 'plink', name: 'plink',
@ -119,109 +170,263 @@ definePage({
title: '友链', title: '友链',
} }
}) })
// 友链页逻辑
const boxStyle: any = ref({}) interface PlinkFormData {
const nav: any = $store.nav.useNavStore() name: string
const plinkData = reactive<any>({ 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: '', name: '',
title: '', title: '',
avater: '', avater: '',
url: '', url: '',
desc: '', desc: '',
tagname: '', tagname: '',
}); })
const rules: any = {
name: [{ required: true, message: '昵称不能为空~~', trigger: ['blur'] }], const rules: FormRules = {
title: [{ required: true, message: '网站名称不能为空~~', trigger: ['blur'] }], name: [{ required: true, message: '称不能为空', trigger: ['blur'] }],
url: [{ required: true, message: '站点地址不能为空~~', trigger: ['blur'] }], title: [{ required: true, message: '网站名称不能为空', trigger: ['blur'] }],
avater: [{ required: true, message: '头像链接不能为空~~', trigger: ['blur'] }], url: [{ required: true, message: '站点地址不能为空', trigger: ['blur'] }],
desc: [{ required: true, message: '站点介绍不能为空~~', trigger: ['blur'] }], avater: [{ required: true, message: '头像链接不能为空', trigger: ['blur'] }],
tagname: [{ 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) function updateLayout() {
const tagColorList: any = ref([]) 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() { async function getPlinkList() {
const res = await $http.plink.getPlinkList() const res = await $http.plink.getPlinkList() as PlinkListResponse
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tagname))))
const list: Array<string> = [] tagColorList.value = buildTagColorMap(uniqueTags)
// 对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()
}
})
pList.value = res.data 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) { function gotoWebsite(url: string) {
if (!url) return
window.open(url, '_blank') window.open(url, '_blank')
} }
function submitPut() { function resetFormData() {
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.name = ''
plinkData.title = '' plinkData.title = ''
plinkData.avater = '' plinkData.avater = ''
plinkData.url = '' plinkData.url = ''
plinkData.desc = '' plinkData.desc = ''
plinkData.tagname = '' plinkData.tagname = ''
getPlinkList()
}
})
} }
// 生成一个随机的鲜艳颜色,和白色能对比 function fillForm() {
const token = $cookies.get('token')
function getRandomFromPalette(): string { if (!token) {
const hue = Math.floor(Math.random() * 360); $msg.warning('请先登录后再一键填入')
return hslToHex(hue, 80, 50); return
} }
function hslToHex(h: number, s: number, l: number): any { const userinfo = ($cookies.get('userinfo') || {}) as UserCookieInfo
l /= 100; plinkData.name = userinfo.nickname || ''
const a = s * Math.min(l, 1 - l) / 100; plinkData.avater = userinfo.ava_url || ''
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 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(() => { onMounted(() => {
boxStyle.value = { updateLayout()
height: window.innerHeight - nav.navH - 1 + 'px', void getPlinkList()
} window.addEventListener('resize', updateLayout)
getPlinkList() })
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
}) })
</script> </script>
<style scoped> <style scoped lang="less">
/* 无限旋转动画 */
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@ -236,8 +441,58 @@ onMounted(() => {
animation: spin 1.5s linear infinite; 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) { :deep(.n-form-item-feedback__line) {
text-align: right; text-align: right;
font-size: 12px !important; font-size: 12px !important;
color: #8b96a3;
}
: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> </style>

View File

@ -1,69 +1,151 @@
<template> <template>
<div class="flex justify-between"> <div
<div class="left w-[20%] shadow"> 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="fixed top-20 left-8"> :style="boxStyle"
<div class="text-center text-3xl text-bold mb-2">目录</div> >
<MdCatalog :editorId="blogData.aid" class="my-cata" :scrollElement="scrollElement" /> <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>
<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> </div>
<div class="flex-1"> </div>
<n-breadcrumb class="m-2" separator="|"> </details>
<n-breadcrumb-item href="/blog/blog">
文章 <div
</n-breadcrumb-item> v-else
<n-breadcrumb-item> class="cursor-pointer py-1 transition-colors duration-200"
{{ blogData.title }} :class="activeHeadingId === item.id ? 'text-primary font-semibold' : 'text-[#526376] hover:text-[#2f4050]'"
</n-breadcrumb-item> @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>
</div>
</div>
</section>
</div>
</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 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> </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" /> -->
</div> </div>
</template> <div class="flex flex-wrap items-center justify-right gap-x-5 gap-y-2 text-sm text-[#708091]">
<template #header> <em class="flex items-center gap-1 not-italic text-primary">
<div class="text-primary flex"> <n-icon><icon-author /></n-icon>
<icon-star class="w-6 mx-2" /> {{ blogData.nickname }}
<div class="text-center text-[20px] font-bold">AI 摘要</div> </em>
</div> <em class="flex items-center gap-1 not-italic">
</template> <n-icon><icon-date /></n-icon>
<template #header-extra> {{ formatTime(blogData.updated_at, 'YYYY年MM月DD日 hh时') }}
<div class="text-primary flex"> </em>
<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> </div>
<div class="flex gap-2 justify-center my-4"> <div class="mt-4 flex flex-wrap justify-center gap-3">
<em class="text-primary"> <div
{{ blogData.nickname }}</em> v-for="item in tags"
<em class="time">{{ formatTime(blogData.updated_at, "YYYY年MM月DD日hh时") }}</em> :key="item"
</div> :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)]"
<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> <n-icon><icon-tag /></n-icon>
{{ item }} {{ item }}
</div> </div>
</div> </div>
</section>
<MdPreview ref="mdp" theme="light" class="relative " :editorId="blogData.aid" previewTheme="my" <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">
:modelValue="markdown" /> <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> </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>
</n-scrollbar>
</section>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <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({ definePage({
name: 'blog/:bid', name: 'blog/:bid',
path: '/blog/:bid', path: '/blog/:bid',
@ -72,153 +154,403 @@ definePage({
} }
}) })
import { formatTime } from '@/util'; interface BlogDetail {
import type { ExposeParam } from 'md-editor-v3'; aid: string
import { MdCatalog, MdPreview } from 'md-editor-v3'; updated_at: string | number
title: string
const editorRef = ref<ExposeParam>(); nickname: string
const scrollElement: HTMLElement = document.querySelector('.n-scrollbar .n-scrollbar-container') as HTMLElement; tags: string
cont: string
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()
} }
// 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() { async function AISum() {
console.log('>>> --> AISum --> AISum:', AISum) if (aimask.value || !blogData.value.cont) return
if (aimask.value) return
const response = await fetch('https://api.siliconflow.cn/v1/chat/completions', { const response = await fetch('http://38.12.26.19:8317/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: 'Bearer sk-jwwmhmxsjtseyekknqmamlvzmrkmwfvuacnssbwfufogrkdg' Authorization: 'Bearer sk-7wys75B2TsQYceMbZ'
}, },
body: JSON.stringify({ body: JSON.stringify({
model: "Qwen/Qwen3-8B", model: 'gpt-5.4',
messages: [ messages: [
{ role: 'system', content: "你是一个摘要生成工具你需要解释我发送给你的内容不要换行不要超过400字只需要介绍文章的内容不需要提出建议和缺少的东西。请用中文回答内容添加表情包输出的内容开头为“这篇文章介绍了”所有句号试用“喵~~ 。”代替" }, {
role: 'system',
content: '你是一个摘要生成工具请用中文总结我发送给你的文章内容不要换行不要超过300字只介绍文章内容不需要提出建议。开头固定为“这篇文章介绍了”语气自然一些。'
},
{ role: 'user', content: blogData.value.cont } { role: 'user', content: blogData.value.cont }
], ],
stream: true stream: true
}) })
}) })
console.log('>>> --> AISum --> response:', response)
const reader = response.body?.getReader() const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
if (!reader) return if (!reader) return
msg.value = '' msg.value = ''
let done = false let done = false
while (!done) { while (!done) {
const { value, done: doneReading } = await reader.read() const { value, done: doneReading } = await reader.read()
done = doneReading done = doneReading
const chunk = decoder.decode(value) const chunk = decoder.decode(value ?? new Uint8Array())
const match = chunk.match(/"content":"(.*?)"/) const match = chunk.match(/"content":"(.*?)"/)
if (match && match[1]) { if (match?.[1]) {
msg.value += match[1] msg.value += match[1]
aimask.value = true 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> </script>
<style lang="less"> <style scoped lang="less">
@import "@/assets/myblog.less"; :deep(.article-preview .md-editor-preview) {
background: transparent !important;
.my-cata {
* {
cursor: pointer;
} }
.md-editor-catalog-indicator { :deep(.article-preview .md-editor-preview-wrapper) {
display: none; padding: 0 !important;
background: transparent !important;
} }
.md-editor-catalog-active>span { :deep(.article-preview .md-editor) {
padding: 5px 15px !important; background: transparent !important;
border-radius: 30px;
color: white;
background-color: @primary;
position: relative;
&::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;
}
} }
.md-editor-catalog-link { :deep(.article-preview .md-editor-content),
padding-block: 1px !important; :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;
} }
.md-editor-catalog-wrapper span { :deep(.article-preview .md-editor-toolbar),
padding: 5px 15px !important; :deep(.article-preview .md-editor-footer),
transition: all 0.6s ease-in-out; :deep(.article-preview .md-editor-catalog) {
background: transparent !important;
&:hover {
padding: 5px 15px !important;
border-radius: 30px;
color: white;
background-color: @primary;
} }
: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;
} }
: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;
}
: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>
<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> <template>
<div class="article-page w-full flex" :style="boxStyle"> <div
<div class="w-1/4 shadow relative"> 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="card w-2/3 absolute top-16 left-1/6"> :style="boxStyle"
<div @click="clickCate(i, idx)" v-for="(i, idx) in cateList" :key="i.cid" >
:class="{ '!bg-primary text-white': idx == currentCateIdx }" <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>
class="relative cate flex my-6 py-6 px-8 justify-between text-xl font-bold bg-white shadow rounded cursor-pointer"> <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="flex items-center"> <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>
<icon-arti class="w-5 h-5 text-primary mr-3" :class="{ 'text-white': idx == currentCateIdx }"></icon-arti>
<span>{{ i.name }}</span> <div class="relative z-[1] mx-auto flex h-full w-[86%] gap-6 py-6">
</div> <aside class="hidden w-[21rem] shrink-0 lg:block">
<div class="bg-primary px-4 rounded-full text-white ext-sm flex items-center gap-2"> <div class="sticky top-6">
{{ i.total || 0 }} <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>
</div> </div>
</section>
</div> </div>
</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 class="blog-list w-3/4 mt-10">
<div v-show="blogList.length == 0" class="ml-50 text-gray-500 mt-20">
什么都没有呢~ ~
</div> </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" <div class="mb-4 flex flex-wrap gap-2 lg:hidden">
@click="$router.push(`/blog/${item.aid}`)"> <button
<div class="flex justify-right gap-4 mb-2 text-gray-600"> type="button"
<em class="flex items-center gap-1 text-sm"> @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> <n-icon><icon-date /></n-icon>
{{ formatTime(item.updated_at, 'YYYY年MM月DD日') }} {{ formatTime(item.updated_at, 'YYYY年MM月DD日') }}
</em> </em>
<em class="flex items-center gap-1 text-sm"> <em class="flex items-center gap-1 not-italic">
<n-icon><icon-pen /></n-icon> <n-icon><icon-pen /></n-icon>
{{ getSize(item.cont) }} {{ getSize(item.cont) }}
</em> </em>
<em class="flex items-center gap-1 text-sm "> <em class="flex items-center gap-1 not-italic">
<n-icon><icon-author /></n-icon> <n-icon><icon-author /></n-icon>
{{ item.nickname }}</em> {{ item.nickname }}
</em>
</div> </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> <h2 class="mt-4 text-[1.45rem] font-semibold leading-[1.35] text-primary">{{ item.title }}</h2>
<div class="flex gap-4"> <p class="mt-3 line-clamp-3 leading-[1.85] text-[#66778b]">{{ item.pro }}</p>
<div v-for="i in getTags(item.tags)" :key="i.tid"
:style="{ backgroundColor: getDictValue(tagColorList, 'tag', i, 'color') }" <div class="mt-5 flex flex-wrap gap-3">
class="flex items-center gap-1 cursor-pointer text-white rounded-full px-3"> <div
<icon-tag2 class="w-4 h-4" />{{ i }} 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>
</div> </div>
</article>
</div> </div>
<div v-show="blogList.length > page_size" class="mt-20 w-[80%] ml-1/10 cursor-pointer flex justify-center">
<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" /> <n-pagination v-model:page="page_num" :page-count="total" :page-slot="5" @update:page="getBlogList" />
</div> </div>
</n-scrollbar>
</section>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { formatTime, getDictValue } from '@/util' import { formatTime } from '@/util'
definePage({ definePage({
name: 'blog', name: 'blog',
meta: { meta: {
@ -61,124 +150,168 @@ definePage({
} }
}) })
const blogList = ref<any[]>([]) interface CateItem {
const boxStyle = ref({}) cid: string
const nav: any = $store.nav.useNavStore() name: string
const cateList = ref<any[]>([]) 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 currentCateIdx = ref(-1)
const page_size = ref(5) const page_size = ref(5)
const page_num = ref(1) const page_num = ref(1)
const total = ref(0) const total = ref(0)
const category = ref('') 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) { const currentCateLabel = computed(() => {
if (currentCateIdx.value == idx) { 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 currentCateIdx.value = -1
category.value = '' category.value = ''
getBlogList() resetPaging()
total.value = cateList.value.reduce((acc: any, cur: any) => acc + cur.total, 0) total.value = allTotal.value
void getBlogList()
}
function clickCate(item: CateItem, idx: number) {
if (currentCateIdx.value === idx) {
clearCate()
return return
} }
currentCateIdx.value = idx currentCateIdx.value = idx
category.value = item.cid category.value = item.cid
getBlogList() resetPaging()
total.value = item.total 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() { async function getBlogList() {
const res = await $http.blog.getBlogList({ const res = await $http.blog.getBlogList({
category: category.value, page_size: page_size.value, page_num: page_num.value category: category.value,
}) page_size: page_size.value,
page_num: page_num.value,
const list: Array<string> = [] }) as BlogListResponse
// 对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()
}
})
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tags))))
tagColorList.value = buildTagColorMap(uniqueTags)
blogList.value = res.data 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;
}
// 生成一个随机的鲜艳颜色,和白色能对比
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() { async function getCateList() {
const res = await $http.blog.getCateList() const res = await $http.blog.getCateList() as CateListResponse
console.log('>>> --> getCateList --> res:', res.data)
cateList.value = res.data cateList.value = res.data
total.value = cateList.value.reduce((acc: any, cur: any) => acc + cur.total, 0) total.value = allTotal.value
} }
onMounted(() => { onMounted(() => {
boxStyle.value = { updateLayout()
height: `calc(100vh - ${nav.navH + 1}px)`, void getBlogList()
} void getCateList()
getBlogList() window.addEventListener('resize', updateLayout)
getCateList()
}) })
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
})
</script> </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>