style: 更新UI样式和字体设置
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@ -30,3 +30,8 @@ coverage
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
.agent
|
||||
.agents
|
||||
.agent/*
|
||||
.agents/*
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<n-layout-content class="dcontent-1">
|
||||
<router-view />
|
||||
</n-layout-content>
|
||||
<n-layout-footer v-show="route.path == '/home'" class="flex justify-center dfooter-1">
|
||||
<n-layout-footer v-show="route.path == '/home'" class="flex justify-center">
|
||||
<div class="beian" ref="footer">
|
||||
<a class="text-primary" href="https://beian.miit.gov.cn" target="_blank">皖ICP备2021017362号-1</a>
|
||||
<a class="swag text-primary" target="_blank" href="https://www.hxyouzi.com/swag">api文档</a>
|
||||
@ -106,8 +106,13 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="less">
|
||||
.dcontent-1 {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
background-color: #fbfbfb;
|
||||
:deep(.n-layout),
|
||||
:deep(.n-layout-header),
|
||||
:deep(.n-layout-content) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.beian {
|
||||
@ -127,4 +132,4 @@ onMounted(() => {
|
||||
.beian .swag {
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -7,6 +7,46 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans-zh: "LXGW WenKai", "霞鹜文楷", "STKaiti", "KaiTi", serif;
|
||||
--font-mono-code: "Fira Code", "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
font-family: var(--font-sans-zh);
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
.n-config-provider,
|
||||
.n-layout,
|
||||
.n-card,
|
||||
.n-button,
|
||||
.n-input,
|
||||
.n-base-selection,
|
||||
.n-modal,
|
||||
.n-dropdown,
|
||||
.devui-tag-item,
|
||||
.d-tag {
|
||||
font-family: var(--font-sans-zh) !important;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp,
|
||||
.hljs,
|
||||
.md-editor-code,
|
||||
.md-editor-code-head,
|
||||
.md-editor-code-content {
|
||||
font-family: var(--font-sans-zh) !important;
|
||||
}
|
||||
|
||||
.n-scrollbar-rail__scrollbar {
|
||||
background-color: #f6cbe770 !important;
|
||||
}
|
||||
@ -19,4 +59,4 @@
|
||||
/* 添加平滑过渡效果 */
|
||||
#nprogress .bar {
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
|
||||
@import url("https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/style.css");
|
||||
@import "qweather-icons/font/qweather-icons.css";
|
||||
@import 'md-editor-v3/lib/preview.css';
|
||||
@import "nprogress/nprogress.css";
|
||||
@import "./base.less";
|
||||
@import "./base.less";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,65 +1,125 @@
|
||||
<template>
|
||||
<n-card class="mt-10 rounden-[10px] w-[500px]" shadow="never">
|
||||
<n-tabs v-model:value="tid" justify-content="space-evenly" animated>
|
||||
<template>
|
||||
<div class="w-[500px] rounded-[28px] border border-white/80 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.14),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.84),rgba(242,247,251,0.78))] p-8 shadow-[0_28px_60px_rgba(75,96,120,0.18)] backdrop-blur-[20px]">
|
||||
<n-tabs class="bg-transparent" v-model:value="tid" justify-content="space-evenly" animated>
|
||||
<n-tab-pane name="tab1" tab="登录">
|
||||
<n-form ref="formLogin" layout="vertical" :data="loginData" :rules="rules">
|
||||
<n-form-item field="username">
|
||||
<n-input v-model:value="loginData.username" placeholder="请输入用户名" />
|
||||
<n-form ref="formLogin" layout="vertical" :model="loginData" :rules="rules">
|
||||
<n-form-item path="username" class="auth-form-item">
|
||||
<n-input class="auth-input" v-model:value="loginData.username" placeholder="请输入用户名" />
|
||||
</n-form-item>
|
||||
<n-form-item field="password">
|
||||
<n-input v-model:value="loginData.password" type="password" show-password-on="click" placeholder="请输入密码" />
|
||||
<n-form-item path="password" class="auth-form-item mt-4">
|
||||
<n-input
|
||||
class="auth-input"
|
||||
v-model:value="loginData.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item class="form-operation-wrap">
|
||||
<n-button class="w-full" type="primary" @click="login">登 录</n-button>
|
||||
<n-form-item class="mt-6 auth-form-item">
|
||||
<div class="mx-auto flex w-[78%] gap-3">
|
||||
<n-button
|
||||
class="flex-1 !h-9 !rounded-full !border !border-white/70 !bg-white/55 !text-[14px] !font-semibold !tracking-[0.03em] !text-slate-600 backdrop-blur-[10px]"
|
||||
@click="resetLogin"
|
||||
>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button
|
||||
class="flex-1 !h-9 !rounded-full !border !border-[#f4a9b8]/60 !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !text-[14px] !font-semibold !tracking-[0.03em]"
|
||||
type="primary"
|
||||
@click="login"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="tab2" tab="注册">
|
||||
<n-form ref="formReg" layout="vertical" :data="regData" :rules="rrules">
|
||||
<n-form-item field="username">
|
||||
<n-input v-model:value="regData.username" placeholder="请输入用户名" />
|
||||
<n-form ref="formReg" layout="vertical" :model="regData" :rules="rrules">
|
||||
<n-form-item path="nickname" class="auth-form-item mt-4">
|
||||
<n-input class="auth-input" v-model:value="regData.nickname" placeholder="请输入昵称" />
|
||||
</n-form-item>
|
||||
<n-form-item field="password">
|
||||
<n-input v-model:value="regData.password" type="password" show-password-on="click" placeholder="请输入用密码" />
|
||||
<n-form-item path="username" class="auth-form-item">
|
||||
<n-input class="auth-input" v-model:value="regData.username" placeholder="请输入用户名" />
|
||||
</n-form-item>
|
||||
<n-form-item field="nickname">
|
||||
<n-input v-model:value="regData.nickname" placeholder="请输入昵称" />
|
||||
<n-form-item path="password" class="auth-form-item">
|
||||
<n-input
|
||||
class="auth-input"
|
||||
v-model:value="regData.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请输入用户密码"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item class="form-operation-wrap">
|
||||
<n-button class="w-full" type="primary" @click="register">注册</n-button>
|
||||
|
||||
<n-form-item class="mt-6 auth-form-item">
|
||||
<div class="mx-auto flex w-[78%] gap-3">
|
||||
<n-button
|
||||
class="flex-1 !h-9 !rounded-full !border !border-white/70 !bg-white/55 !text-[14px] !font-semibold !tracking-[0.03em] !text-slate-600 backdrop-blur-[10px]"
|
||||
@click="resetRegister"
|
||||
>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button
|
||||
class="flex-1 !h-9 !rounded-full !border !border-[#f4a9b8]/60 !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !text-[14px] !font-semibold !tracking-[0.03em]"
|
||||
type="primary"
|
||||
@click="register"
|
||||
>
|
||||
注册
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
setVisible: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
}
|
||||
})
|
||||
// 登录注册逻辑
|
||||
const tid = ref("tab1");
|
||||
const formLogin: any = ref(null);
|
||||
const loginData = reactive({
|
||||
username: "",
|
||||
password: ""
|
||||
});
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
|
||||
const formReg: any = ref(null);
|
||||
const regData = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
nickname: ""
|
||||
});
|
||||
interface UserInfo {
|
||||
token: string
|
||||
userinfo: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ApiResponse<T = Record<string, unknown>> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface LoginFormData {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface RegisterFormData extends LoginFormData {
|
||||
nickname: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
setVisible?: (visible: boolean) => void
|
||||
}>()
|
||||
|
||||
const tid = ref('tab1')
|
||||
const formLogin = ref<FormInst | null>(null)
|
||||
const loginData = reactive<LoginFormData>({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const formReg = ref<FormInst | null>(null)
|
||||
const regData = reactive<RegisterFormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: ''
|
||||
})
|
||||
|
||||
const usrLog = $store.log.useLogStore()
|
||||
|
||||
const rules: any = reactive({
|
||||
const rules = reactive<FormRules>({
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ validator: validateUsername, trigger: 'blur' },
|
||||
@ -69,79 +129,122 @@ const rules: any = reactive({
|
||||
{ validator: validatePassword, trigger: 'blur' }
|
||||
]
|
||||
})
|
||||
const rrules: any = reactive({
|
||||
|
||||
const rrules = reactive<FormRules>({
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ validator: validateUsername, trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ validator: validatePassword, trigger: 'blur' }
|
||||
],
|
||||
nickname: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
||||
]
|
||||
})
|
||||
function login() {
|
||||
console.log('>>> --> login --> formLogin.value:', usrLog.isLogin)
|
||||
formLogin.value?.validate(async (is: boolean) => {
|
||||
if (is) {
|
||||
$msg.error('信息填写不正确,请检查后再提交')
|
||||
} else {
|
||||
const res = await $http.user.login(loginData)
|
||||
if (res?.code !== 200) {
|
||||
$msg.error('登录失败')
|
||||
return
|
||||
}
|
||||
$cookies.set('token', res.data.token, '1d')
|
||||
$cookies.set('userinfo', res.data.userinfo, '1d')
|
||||
$msg.success(res.msg)
|
||||
usrLog.setIsLogin(true)
|
||||
props.setVisible(false)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
function register() {
|
||||
formReg.value.validate(async (is: boolean) => {
|
||||
if (is) {
|
||||
$msg.error('信息填写不正确,请检查后再提交')
|
||||
} else {
|
||||
const res = await $http.user.register(regData)
|
||||
if (res?.code !== 200) {
|
||||
$msg.error('注册失败')
|
||||
return
|
||||
}
|
||||
$msg.success(res.msg)
|
||||
tid.value = 'tab1'
|
||||
loginData.username = regData.username
|
||||
regData.username = ''
|
||||
regData.nickname = ''
|
||||
regData.password = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
async function validateForm(form: FormInst | null) {
|
||||
if (!form) return false
|
||||
|
||||
function validateUsername(rule: any, value: string, callback: Function) {
|
||||
if (/^[a-zA-Z][a-zA-Z0-9]{3,15}$/.test(value)) return callback()
|
||||
else return callback(new Error('请输入4-16位字母或数字,且以字母开头'))
|
||||
}
|
||||
function validatePassword(rule: any, value: string, callback: Function) {
|
||||
if (/^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$/.test(value)) return callback()
|
||||
else return callback(new Error('密码长度为6-12位,且必须包含数字和字母'))
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.devui-tabs__nav) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
li a span {
|
||||
// width: 20%;
|
||||
font-size: 18px !important;
|
||||
font-weight: 500;
|
||||
try {
|
||||
await form.validate()
|
||||
return true
|
||||
} catch {
|
||||
$msg.error('信息填写不正确,请检查后再提交')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.devui-form__item--horizontal) {
|
||||
margin-top: 30px;
|
||||
function persistLogin(data: UserInfo) {
|
||||
$cookies.set('token', data.token, '1d')
|
||||
$cookies.set('userinfo', data.userinfo, '1d')
|
||||
usrLog.setIsLogin(true)
|
||||
props.setVisible?.(false)
|
||||
}
|
||||
</style>
|
||||
|
||||
function resetLogin() {
|
||||
loginData.username = ''
|
||||
loginData.password = ''
|
||||
}
|
||||
|
||||
function resetRegister() {
|
||||
regData.username = ''
|
||||
regData.password = ''
|
||||
regData.nickname = ''
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const isValid = await validateForm(formLogin.value)
|
||||
if (!isValid) return
|
||||
|
||||
const res = await $http.user.login(loginData) as ApiResponse<UserInfo>
|
||||
if (res?.code !== 200) {
|
||||
$msg.error('登录失败')
|
||||
return
|
||||
}
|
||||
|
||||
persistLogin(res.data)
|
||||
$msg.success(res.msg)
|
||||
}
|
||||
|
||||
async function register() {
|
||||
const isValid = await validateForm(formReg.value)
|
||||
if (!isValid) return
|
||||
|
||||
const res = await $http.user.register(regData) as ApiResponse
|
||||
if (res?.code !== 200) {
|
||||
$msg.error('注册失败')
|
||||
return
|
||||
}
|
||||
|
||||
$msg.success(res.msg)
|
||||
tid.value = 'tab1'
|
||||
loginData.username = regData.username
|
||||
resetRegister()
|
||||
}
|
||||
|
||||
function validateUsername(_rule: unknown, value: string) {
|
||||
if (/^[a-zA-Z][a-zA-Z0-9]{3,15}$/.test(value)) {
|
||||
return true
|
||||
}
|
||||
return new Error('请输入 4-16 位字母或数字,且以字母开头')
|
||||
}
|
||||
|
||||
function validatePassword(_rule: unknown, value: string) {
|
||||
if (/^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$/.test(value)) {
|
||||
return true
|
||||
}
|
||||
return new Error('密码长度为 6-12 位,且必须包含数字和字母')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.n-tabs),
|
||||
:deep(.n-tabs-nav),
|
||||
:deep(.n-tabs-pane-wrapper),
|
||||
:deep(.n-tab-pane) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
:deep(.n-tabs-tab.n-tabs-tab--active) {
|
||||
color: #ec66ab !important;
|
||||
}
|
||||
|
||||
:deep(.n-tabs-bar) {
|
||||
background: linear-gradient(135deg, #ec66ab, #f48a6f) !important;
|
||||
}
|
||||
|
||||
|
||||
:deep(.n-input) {
|
||||
border-radius: 9999px !important;
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.n-form-item) {
|
||||
grid-template-rows: unset;
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -61,17 +61,22 @@ onMounted(async () => {
|
||||
const d: any = document.querySelector("#aplayer > div.aplayer-body > div.aplayer-miniswitcher > button");
|
||||
let flag = true;
|
||||
const app: any = document.querySelector("#aplayer > div.aplayer-lrc");
|
||||
const apic: any = document.querySelector("#aplayer div.aplayer-body");
|
||||
app.style.display = "block";
|
||||
app.style.transform = "translateY(50px)";
|
||||
apic.style.transform = "translateX(-66px)";
|
||||
d.addEventListener("click", () => {
|
||||
if (flag) {
|
||||
app.style.transform = "translateY(0)";
|
||||
ap.lrc.show();
|
||||
apic.style.transform = "translateX(0)";
|
||||
flag = false;
|
||||
|
||||
} else {
|
||||
app.style.transform = "translateY(50px)";
|
||||
ap.lrc.hide();
|
||||
ap.list.hide();
|
||||
apic.style.transform = "translateX(-66px)";
|
||||
flag = true;
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,120 +1,126 @@
|
||||
<template>
|
||||
<div class="pr-8">
|
||||
<n-card embedded class="mt-4 shadow">
|
||||
<!-- <div class="dt-card mt-10 bg-white"> -->
|
||||
<template>
|
||||
<div class="pr-8 !bg-transparent">
|
||||
<n-card
|
||||
embedded
|
||||
class="mt-4 overflow-hidden rounded-[24px] border border-primary bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.12),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.1),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,249,253,0.8))] backdrop-blur-[18px]"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<icon-time class="w-5 mr-2 text-primary"></icon-time>
|
||||
<div class="flex items-center font-semibold text-slate-700">
|
||||
<icon-time class="mr-2 w-5 text-primary"></icon-time>
|
||||
时间日期
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="w-full pr-20 text-center text-[#ec66ab] font-500 text-4xl font-[yj]">{{ t }}</div>
|
||||
<div class="mt-3 flex justify-between">
|
||||
<span>今年已过了{{ jq.dayOfYear }}天</span>
|
||||
<div class="w-full pr-20 text-center text-4xl font-[yj] font-[500] tracking-[0.04em] text-[#ec66ab] drop-shadow-[0_8px_20px_rgba(236,102,171,0.14)]">
|
||||
{{ t }} </div>
|
||||
<div class="mt-3 flex justify-between text-[15px] text-slate-500">
|
||||
<span>今年已过 {{ jq.dayOfYear }} 天</span>
|
||||
{{ d }}
|
||||
</div>
|
||||
<img v-if="jq.type != 0" class="absolute top-0 right-4" width="120" src="@/assets/images/offwork.png" alt="">
|
||||
<img v-else class="absolute top-0 right-4" width="120" src="@/assets/images/onwork.png" alt="">
|
||||
<img v-if="jq.type != 0" class="absolute right-4 top-0" width="120" src="@/assets/images/offwork.png" alt="offwork">
|
||||
<img v-else class="absolute right-4 top-0" width="120" src="@/assets/images/onwork.png" alt="onwork">
|
||||
</template>
|
||||
<!-- </div> -->
|
||||
</n-card>
|
||||
|
||||
|
||||
<n-card embedded class="mt-4 shadow">
|
||||
<n-card
|
||||
embedded
|
||||
class="mt-4 overflow-hidden rounded-[24px] border border-white/75 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.12),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.1),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,249,253,0.8))] backdrop-blur-[18px]"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<icon-news class="w-5 mr-2 text-primary"></icon-news>
|
||||
<div class="flex items-center font-semibold text-slate-700">
|
||||
<icon-news class="mr-2 w-5 text-primary"></icon-news>
|
||||
百度新闻
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="py-1" v-for="i in bdNews" :key="i.id">
|
||||
<!-- 淡蓝色 -->
|
||||
<a class="text-[#526ecc] no-underline hover:text-primary hover:underline flex justify-between truncate" :href="i.url" target="_blank">
|
||||
<span class="flex items-center">{{ i.index }}. {{ i.title }}
|
||||
<icon-hot v-show="i.index < 4" class="ml-2 w-4 text-[red] inline-block"></icon-hot>
|
||||
<a
|
||||
class="flex justify-between truncate rounded-xl border border-transparent px-2 text-[#526ecc] no-underline transition-colors duration-200 hover:border-white/80 hover:bg-white/65 hover:text-primary"
|
||||
:href="i.url"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="flex items-center truncate">
|
||||
{{ i.index }}. {{ i.title }}
|
||||
<icon-hot v-show="i.index < 4" class="ml-2 inline-block w-4 text-[red]"></icon-hot>
|
||||
</span>
|
||||
<span class="text-primary">{{ i.hot }}</span>
|
||||
<span class="ml-3 shrink-0 text-primary">{{ i.hot }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-2 justify-between flex items-center">
|
||||
<div class="w-2/5 h-px bg-[#ec66ab]"></div>
|
||||
<a class="text-[#526ecc] no-underline text-[#ec66ab] flex items-center" href="https://www.baidu.com/s?ie=utf-8&wd=百度新闻"
|
||||
target="_blank">
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<div class="h-px w-2/5 bg-gradient-to-r from-transparent via-[#ec66ab] to-[#ec66ab]/40"></div>
|
||||
<a
|
||||
class="flex items-center font-medium text-[#ec66ab] no-underline"
|
||||
href="https://www.baidu.com/s?ie=utf-8&wd=百度新闻"
|
||||
target="_blank"
|
||||
>
|
||||
更多
|
||||
<icon-right class="ml-1 w-4 text-primary inline-block"></icon-right>
|
||||
<icon-right class="ml-1 inline-block w-4 text-primary"></icon-right>
|
||||
</a>
|
||||
<div class="w-2/5 h-px bg-[#ec66ab]"></div>
|
||||
<div class="h-px w-2/5 bg-gradient-to-l from-transparent via-[#ec66ab] to-[#ec66ab]/40"></div>
|
||||
</div>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
|
||||
<n-card embedded class="mt-4 shadow">
|
||||
<n-card
|
||||
embedded
|
||||
class="mt-4 overflow-hidden rounded-[24px] border border-white/75 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.12),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.1),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,249,253,0.8))] backdrop-blur-[18px]"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<icon-date class="w-5 mr-2 h-[20px]" ></icon-date>
|
||||
<div class="flex items-center font-semibold text-slate-700">
|
||||
<icon-date class="mr-2 h-[20px] w-5"></icon-date>
|
||||
农历节气
|
||||
<div class="ml-12 text-[#ec66ab] font-500">{{ jq.yearTips }}年 {{ jq.lunarCalendar }}</div>
|
||||
<div class="ml-12 font-semibold text-[#ec66ab]">{{ jq.yearTips }}年 {{ jq.lunarCalendar }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="truncate text-sm mx-[5%]">
|
||||
<div class="mx-[5%] truncate text-sm text-slate-600">
|
||||
宜:{{ jq.suit }}
|
||||
</div>
|
||||
<div class="truncate text-sm mx-[5%]">
|
||||
<div class="mx-[5%] truncate text-sm text-slate-600">
|
||||
忌:{{ jq.avoid }}
|
||||
</div>
|
||||
<div class="w-full flex justify-center">
|
||||
<img class="mt-2 w-[90%] rounded" :src="jqImg" alt=""></img>
|
||||
<div class="flex w-full justify-center">
|
||||
<img class="mt-3 w-[90%] rounded-2xl border border-white/80 bg-white/70 shadow-[0_12px_26px_rgba(102,129,156,0.12)]" :src="jqImg" alt="节气图">
|
||||
</div>
|
||||
<div class="mt-2 text-center">{{ jq.solarTerms }}</div>
|
||||
<div class="mt-3 text-center font-medium text-slate-600">{{ jq.solarTerms }}</div>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//mark import
|
||||
import { formatTime } from '@/util/index';
|
||||
import { formatTime } from '@/util/index'
|
||||
|
||||
//mark data
|
||||
const t = ref<any>('')
|
||||
const d = ref<any>('')
|
||||
let timer: any = null
|
||||
const jq = ref<any>("")
|
||||
const jqImg = ref<any>("")
|
||||
const jq = ref<any>('')
|
||||
const jqImg = ref<any>('')
|
||||
const bdNews = ref<any>([])
|
||||
//mark method
|
||||
|
||||
function getTime() {
|
||||
const date = new Date();
|
||||
const date = new Date()
|
||||
t.value = formatTime(date, 'hh:mm:ss')
|
||||
d.value = formatTime(date, 'YYYY 年 MM 月 DD 日')
|
||||
}
|
||||
|
||||
async function getJq() {
|
||||
const res = await $http.mix.getJq({
|
||||
date: formatTime(new Date(), "YYYYMMDD")
|
||||
date: formatTime(new Date(), 'YYYYMMDD')
|
||||
})
|
||||
console.log('>>> --> getJq --> res:', res.data)
|
||||
jq.value = res.data
|
||||
const j = res.data.solarTerms.slice(0, 2)
|
||||
// 获取时间戳
|
||||
const timestamp = new Date().getTime()
|
||||
jqImg.value = 'https://www.hxyouzi.com/img/jq/' + j + '.png?' + timestamp
|
||||
jqImg.value = `https://www.hxyouzi.com/img/jq/${j}.png?${timestamp}`
|
||||
}
|
||||
|
||||
async function getBdhot() {
|
||||
const res = await $http.mix.getBdhot()
|
||||
console.log('>>> --> getBdhot --> res:', res.data)
|
||||
// 取前5条aa
|
||||
bdNews.value = res.data.slice(0, 5)
|
||||
}
|
||||
|
||||
//mark 周期、内置函数等
|
||||
onMounted(() => {
|
||||
getTime()
|
||||
getJq()
|
||||
getBdhot()
|
||||
|
||||
@ -122,11 +128,10 @@ onMounted(() => {
|
||||
getTime()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@ -135,14 +140,17 @@ onUnmounted(() => {
|
||||
src: url('@/assets/font/LCDML.woff2');
|
||||
}
|
||||
|
||||
|
||||
:deep(.n-card > .n-card__content, .n-card > .n-card__footer){
|
||||
padding: 8px 25px !important;
|
||||
:deep(.n-card > .n-card__content),
|
||||
:deep(.n-card > .n-card__footer) {
|
||||
padding: 12px 24px !important;
|
||||
}
|
||||
|
||||
// .dt-card {
|
||||
// background-image: url('@/assets/images/中秋节中国风边框34.png');
|
||||
// background-size: 100% 100%;
|
||||
// padding: 20px 40px;
|
||||
// }
|
||||
</style>
|
||||
:deep(.n-card > .n-card-header) {
|
||||
padding: 16px 24px 8px !important;
|
||||
}
|
||||
|
||||
:deep(.n-card) {
|
||||
background: transparent !important;
|
||||
box-shadow:unset;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,8 +2,15 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="visible"
|
||||
class="fixed top-0 left-0 z-[1000] w-[100vw] h-[100vh] flex items-center justify-center bg-[rgba(0,0,0,0.5)]">
|
||||
<slot></slot>
|
||||
<div @click.prevent="handdleClose" class="absolute w-8 h-8 top-4 right-4 rounded-full bg-white flex items-center justify-center text-[#ec66ab] cursor-pointer">X</div>
|
||||
<div class="relative inline-flex">
|
||||
<slot></slot>
|
||||
<div
|
||||
@click.prevent="handdleClose"
|
||||
class="absolute right-4 top-4 z-[1] flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-white/92 text-[#ec66ab] shadow-[0_8px_18px_rgba(89,108,130,0.14)] backdrop-blur-[8px]"
|
||||
>
|
||||
X
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@ -32,4 +39,4 @@ function handdleClose() {
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
<style scoped lang="less"></style>
|
||||
|
||||
@ -1,180 +1,225 @@
|
||||
<template>
|
||||
<div ref="nav" class="main-nav hidden lg:flex justify-between bg-white">
|
||||
<!-- 网站Logo -->
|
||||
<div class="px-5 items-center flex cursor-pointer w-1/5" slot="brand" @click="goHome">
|
||||
<img :src="logo" alt="柚子的网站" class="h-9 align-middle" />
|
||||
<template>
|
||||
<div
|
||||
ref="nav"
|
||||
class="relative hidden items-center justify-between bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.18),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.14),transparent_24%),linear-gradient(180deg,rgba(239,231,218,0.82)_0%,rgba(232,240,239,0.68)_46%,rgba(243,241,232,0.28)_100%)] px-4 py-1.5 shadow-[0_6px_16px_rgba(110,124,112,0.05)] backdrop-blur-[14px] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-[-28px] after:h-[34px] after:bg-[linear-gradient(180deg,rgba(240,236,226,0.58),rgba(232,240,239,0.22),transparent)] after:content-[''] lg:flex"
|
||||
>
|
||||
<div
|
||||
class="flex ml-4 cursor-pointer items-center rounded-full border border-white/65 bg-white/42 px-4 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px] transition-colors duration-200 hover:bg-white/56"
|
||||
@click="goHome"
|
||||
>
|
||||
<img :src="logo" alt="柚子的网站" class="h-9 align-middle drop-shadow-[0_6px_14px_rgba(236,102,171,0.15)]" />
|
||||
</div>
|
||||
<!-- 主导航菜单 -->
|
||||
<div class="w-2/5">
|
||||
<n-menu :icon-size="12" v-model:value="activeKey" class="" mode="horizontal" :options="menuOptions" />
|
||||
|
||||
<div class="flex h-[42px] items-center rounded-full border border-white/65 bg-white/42 px-3 py-0.5 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
|
||||
<n-menu :icon-size="12" v-model:value="activeKey" mode="horizontal" :options="menuOptions" />
|
||||
</div>
|
||||
<!-- 用户区域 -->
|
||||
<div class="!text-[#ec66ab] flex items-center cursor-pointer" @click="gotoHf">
|
||||
<span class="flex items-center location-info truncate">
|
||||
<n-icon class="mr-1">
|
||||
|
||||
<div
|
||||
class="flex cursor-pointer items-center rounded-full border border-white/65 bg-white/42 px-4 py-1.5 text-[#ec66ab] shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px] transition-colors duration-200 hover:bg-white/56"
|
||||
@click="gotoHf"
|
||||
>
|
||||
<span class="flex items-center truncate text-[15px] font-medium text-slate-600">
|
||||
<n-icon class="mr-1 text-[#ec66ab]">
|
||||
<icon-loc class="w-[26px] h-[26px]"></icon-loc>
|
||||
</n-icon>
|
||||
{{ locationInfo }}
|
||||
</span>
|
||||
<span class="mx-3 text-gray-300">|</span>
|
||||
<span class="weather-info mr-2 truncate">{{ wea }}</span>
|
||||
<i :class="'qiIcon qi-' + weaIcon + '-fill'"></i>
|
||||
<span class="weather-info ml-4">{{ temp }}°C</span>
|
||||
<span class="mx-3 text-slate-300">|</span>
|
||||
<span class="mr-2 truncate text-[15px] text-slate-500">{{ wea }}</span>
|
||||
<i :class="'qiIcon qi-' + weaIcon + '-fill'" class="text-[#ec66ab]"></i>
|
||||
<span class="ml-4 text-[15px] font-semibold text-slate-700">{{ temp }}°C</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mr-8 w-1/20">
|
||||
<n-dropdown class="cursor-pointer w-[100px]" v-if="userinfo" :options="oprOp" @select="handleSelect"
|
||||
trigger="hover">
|
||||
<div class="flex items-center">
|
||||
<n-avatar round :src="userinfo.ava_url" class="cursor-pointer" alt="用户的头" />
|
||||
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
|
||||
<div class="mr-4 flex w-1/20 items-center justify-end">
|
||||
<n-dropdown v-if="userinfo" class="w-[128px] cursor-pointer" :options="oprOp" @select="handleSelect" trigger="hover">
|
||||
<div class="flex items-center rounded-full border border-white/65 bg-white/46 px-2 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
|
||||
<n-avatar round :src="userinfo.ava_url" class="cursor-pointer ring-2 ring-white/80" alt="用户头像" />
|
||||
<div class="ml-2 truncate text-sm font-medium text-slate-600">{{ userinfo.nickname }}</div>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
<div v-else class="flex items-center" @click="toLogin">
|
||||
<n-button size="small" type="primary" @click="toLogin">登录</n-button>
|
||||
|
||||
<div v-else class="flex items-center">
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="!h-8 !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] px-5 !font-semibold shadow-[0_12px_24px_rgba(236,102,171,0.24)]"
|
||||
@click="toLogin"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 登录弹窗 -->
|
||||
|
||||
<masked :visible="visible" :setVisible="setVisible">
|
||||
<loginModal :setVisible="setVisible" />
|
||||
</masked>
|
||||
|
||||
</div>
|
||||
<div class="flex justify-between bg-white lg:hidden">
|
||||
<div class="pl-2 items-centerflex" slot="brand" @click="">
|
||||
<img :src="logo" alt="柚子的网站" class="h-9 align-middle" />
|
||||
|
||||
<div class="relative flex items-center justify-between bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.18),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.14),transparent_24%),linear-gradient(180deg,rgba(239,231,218,0.84)_0%,rgba(232,240,239,0.68)_46%,rgba(243,241,232,0.32)_100%)] px-3 py-2 shadow-[0_6px_16px_rgba(110,124,112,0.05)] backdrop-blur-[14px] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-[-24px] after:h-[30px] after:bg-[linear-gradient(180deg,rgba(240,236,226,0.58),rgba(232,240,239,0.22),transparent)] after:content-[''] lg:hidden">
|
||||
<div class="flex items-center rounded-full border border-white/65 bg-white/42 px-3 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
|
||||
<img :src="logo" alt="柚子的网站" class="h-9 align-middle drop-shadow-[0_6px_14px_rgba(236,102,171,0.15)]" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mr-2">
|
||||
<div v-if="userinfo" class="flex items-center">
|
||||
<n-avatar :src="userinfo.ava_url" round class="cursor-pointer" alt="用户的头" />
|
||||
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
|
||||
<div v-if="userinfo" class="flex items-center rounded-full border border-white/65 bg-white/46 px-2 py-1 shadow-[0_8px_20px_rgba(112,137,160,0.07)] backdrop-blur-[14px]">
|
||||
<n-avatar :src="userinfo.ava_url" round class="cursor-pointer ring-2 ring-white/80" alt="用户头像" />
|
||||
<div class="ml-2 text-sm font-medium text-slate-600">{{ userinfo.nickname }}</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<!-- <n-avatar round class="cursor-pointer" @click="toLogin"></n-avatar>
|
||||
<div class="cursor-pointer ml-2 text-gray text-sm" @click="toLogin">登录</div> -->
|
||||
<n-button type="primary" size="small" @click="toLogin">登录</n-button>
|
||||
|
||||
<div v-else class="flex items-center">
|
||||
<n-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="!h-8 !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] px-5 !font-semibold shadow-[0_12px_24px_rgba(236,102,171,0.24)]"
|
||||
@click="toLogin"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改头像弹窗 -->
|
||||
<masked v-if="usrLog.isLogin" :visible="editModal" :setVisible="handdleItemCancel">
|
||||
<div class="w-[500px] bg-white p-8 rounded-md">
|
||||
<div class="text-center text-lg mb-8">修改头像</div>
|
||||
<n-upload class="flex justify-center items-center" action="https://www.hxyouzi.com/api/user/avaupload"
|
||||
:headers="{ Authorization: `Bearer ${token}` }" @finish="handleUpload" @before-upload="beforeUpload"
|
||||
:show-file-list="false" accept="image/*">
|
||||
<div class="w-[500px] rounded-[28px] border border-white/80 bg-[radial-gradient(circle_at_top_left,rgba(255,199,132,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(136,203,255,0.14),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.84),rgba(242,247,251,0.78))] p-8 shadow-[0_28px_60px_rgba(75,96,120,0.18)] backdrop-blur-[20px]">
|
||||
<div class="mb-8 text-center text-xl font-bold tracking-[0.02em] text-slate-800">修改头像</div>
|
||||
|
||||
<n-upload
|
||||
class="flex justify-center items-center"
|
||||
action="https://www.hxyouzi.com/api/user/avaupload"
|
||||
:headers="{ Authorization: `Bearer ${token}` }"
|
||||
@finish="handleUpload"
|
||||
@before-upload="beforeUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<n-avatar :size="80" :src="userinfo.ava_url" round class="cursor-pointer" alt="用户的头" />
|
||||
<em class="cursor-pointer mt-4 text-gray text-sm">点击头像上传不超过3m的图片</em>
|
||||
<n-avatar :size="80" :src="userinfo?.ava_url" round class="cursor-pointer ring-4 ring-white/80 shadow-[0_16px_30px_rgba(102,129,156,0.16)]" alt="用户头像" />
|
||||
<em class="mt-4 cursor-pointer text-sm text-slate-500">点击头像上传不超过 3M 的图片</em>
|
||||
</div>
|
||||
</n-upload>
|
||||
|
||||
<div class="mt-8 flex justify-between">
|
||||
<n-button class="w-[98%]" secondary @click="handdleItemCancel">取消</n-button>
|
||||
<n-button class="w-[98%] !h-11 !rounded-full !border-none !bg-white/90 !font-bold !tracking-[0.04em] !text-slate-600 shadow-[0_12px_24px_rgba(89,108,130,0.12)]" secondary @click="handdleItemCancel">取消</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</masked>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 从@/icon/menu引入所有的svg文件
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import loginModal from '@/components/Login.vue';
|
||||
import masked from '@/components/mask.vue';
|
||||
import artiSvg from '@/icon/menu/arti.svg';
|
||||
// import downSvg from '@/icon/menu/download.svg';
|
||||
import homeSvg from '@/icon/menu/home.svg';
|
||||
import linkSvg from '@/icon/menu/link.svg';
|
||||
import picSvg from '@/icon/menu/pic.svg';
|
||||
// import settingSvg from '@/icon/menu/setting.svg';
|
||||
import loginModal from '@/components/Login.vue'
|
||||
import masked from '@/components/mask.vue'
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router';
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
|
||||
interface UserInfo {
|
||||
ava_url?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data?: {
|
||||
token?: string
|
||||
userinfo?: UserInfo
|
||||
}
|
||||
}
|
||||
|
||||
const activeKey = ref('home')
|
||||
const route = useRoute();
|
||||
const visible = ref(false);
|
||||
const router = useRouter();
|
||||
const locationInfo = ref("获取位置中...");
|
||||
const locationInfo = ref('获取位置中...');
|
||||
const latitude = ref<number | null>(null);
|
||||
const longitude = ref<number | null>(null);
|
||||
const fxlink = ref<string>("#")
|
||||
const wea = ref("")
|
||||
const weaIcon = ref<string>("")
|
||||
const fxlink = ref<string>('#')
|
||||
const wea = ref('')
|
||||
const weaIcon = ref<string>('')
|
||||
const temp = ref<number>(0);
|
||||
const userinfo: any = ref(null)
|
||||
const userinfo = ref<UserInfo | null>(null)
|
||||
const nav: any = useTemplateRef('nav')
|
||||
const navx = $store.nav.useNavStore()
|
||||
const usrLog = $store.log.useLogStore()
|
||||
const menuOptions = ref([
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
label: () => h(RouterLink, { to: '/home', class: 'flex items-center justify-center' }, { default: () => '首页' }),
|
||||
key: "home",
|
||||
key: 'home',
|
||||
icon: () => h(homeSvg)
|
||||
},
|
||||
{
|
||||
label: () => h(RouterLink, { to: '/gallery', class: 'flex items-center justify-center' }, { default: () => '画廊' }),
|
||||
key: "gallery",
|
||||
key: 'gallery',
|
||||
icon: () => h(picSvg)
|
||||
},
|
||||
{
|
||||
label: () => h(RouterLink, { to: '/blog', class: 'flex items-center justify-center' }, { default: () => '文章' }),
|
||||
key: "blog",
|
||||
key: 'blog',
|
||||
icon: () => h(artiSvg)
|
||||
},
|
||||
// {
|
||||
// label: () => h(RouterLink, { to: '/widget', class: 'flex items-center justify-center' }, { default: () => '工具' }),
|
||||
// key: "widget",
|
||||
// icon: () => h(downSvg)
|
||||
// },
|
||||
// {
|
||||
// label: () => h(RouterLink, { to: '/apps', class: 'flex items-center justify-center' }, { default: () => '软件' }),
|
||||
// key: "apps",
|
||||
// icon: () => h(settingSvg)
|
||||
// },
|
||||
{
|
||||
label: () => h(RouterLink, { to: '/plink', class: 'flex items-center justify-center' }, { default: () => '友链' }),
|
||||
key: "plink",
|
||||
key: 'plink',
|
||||
icon: () => h(linkSvg)
|
||||
},
|
||||
]);
|
||||
const oprOp = ref([
|
||||
]
|
||||
|
||||
const oprOp = [
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
key: 'avatar',
|
||||
label: '更换头像',
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
label: '控制台',
|
||||
},
|
||||
{
|
||||
key: 'avatar',
|
||||
label: '更换头像',
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
const editModal = ref<boolean>(false)
|
||||
const token = ref<string>("")
|
||||
const editModal = ref(false)
|
||||
const token = ref('')
|
||||
|
||||
function syncUserFromCookies() {
|
||||
userinfo.value = $cookies.get('userinfo') || null
|
||||
token.value = $cookies.get('token') || ''
|
||||
}
|
||||
|
||||
function updateNavHeight() {
|
||||
nextTick(() => {
|
||||
const h: number = nav.value?.clientHeight || 0
|
||||
if (h > 0) navx.setNavH(h)
|
||||
})
|
||||
}
|
||||
|
||||
function beforeUpload(data: { file: UploadFileInfo }) {
|
||||
console.log('>>> --> beforeUpload --> data:', data.file.file?.size)
|
||||
const size = data.file.file?.size || 4 * 1024 * 1024
|
||||
if (size > 3 * 1024 * 1024) {
|
||||
$msg.error('上传的图片大小不能超过3M')
|
||||
$msg.error('上传的图片大小不能超过 3M')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleUpload(f: any) {
|
||||
console.log('上传完成', JSON.parse(f.event.target.response));
|
||||
const res = JSON.parse(f.event.target.response)
|
||||
const responseText = f?.event?.target?.response
|
||||
if (!responseText) {
|
||||
$msg.error('上传失败')
|
||||
editModal.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const res: UploadResponse = JSON.parse(responseText)
|
||||
if (res.code === 200) {
|
||||
// $msg.success(res.msg);
|
||||
autoLogin()
|
||||
} else {
|
||||
$msg.error(res.msg);
|
||||
@ -189,44 +234,42 @@ async function autoLogin() {
|
||||
usrLog.setIsLogin(false)
|
||||
$cookies.remove('token')
|
||||
$cookies.remove('userinfo')
|
||||
syncUserFromCookies()
|
||||
return
|
||||
}
|
||||
|
||||
$cookies.set('token', res.data.token, '1d')
|
||||
$cookies.set('userinfo', res.data.userinfo, '1d')
|
||||
$msg.success(res.msg)
|
||||
usrLog.setIsLogin(true)
|
||||
userinfo.value = res.data.userinfo
|
||||
syncUserFromCookies()
|
||||
}
|
||||
|
||||
function handdleItemCancel() {
|
||||
editModal.value = false
|
||||
}
|
||||
|
||||
|
||||
function setVisible(v: any) {
|
||||
function setVisible(v: boolean) {
|
||||
visible.value = v
|
||||
}
|
||||
|
||||
function handleSelect(key: string) {
|
||||
console.log('>>> --> handleSelect --> key:', key)
|
||||
if (key == 'logout') {
|
||||
if (key === 'logout') {
|
||||
logout()
|
||||
return
|
||||
}
|
||||
if (key == 'console') {
|
||||
if (key === 'console') {
|
||||
gotoConsole()
|
||||
return
|
||||
}
|
||||
if (key == 'avatar') {
|
||||
if (key === 'avatar') {
|
||||
editModal.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取地理位置
|
||||
const getLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
locationInfo.value = "您的浏览器不支持地理位置定位";
|
||||
locationInfo.value = '您的浏览器不支持地理位置定位';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -235,31 +278,26 @@ const getLocation = () => {
|
||||
latitude.value = position.coords.latitude;
|
||||
longitude.value = position.coords.longitude;
|
||||
const jw = `${longitude.value.toFixed(2)},${latitude.value.toFixed(2)}`;
|
||||
|
||||
// 这里可以添加调用后端API获取具体位置名称的逻辑
|
||||
handdleJw(jw);
|
||||
|
||||
|
||||
await handdleJw(jw);
|
||||
},
|
||||
async (error) => {
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
locationInfo.value = "正在定位...";
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
locationInfo.value = "正在定位...";
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
locationInfo.value = "正在定位...";
|
||||
locationInfo.value = '正在定位...';
|
||||
break;
|
||||
}
|
||||
const res = await $http.mix.getIp();
|
||||
latitude.value = Number(res.data.data.lat);
|
||||
longitude.value = Number(res.data.data.lng);
|
||||
const jw = `${longitude.value?.toFixed(4)},${latitude.value?.toFixed(4)}`;
|
||||
|
||||
// 这里可以添加调用后端API获取具体位置名称的逻辑
|
||||
handdleJw(jw);
|
||||
try {
|
||||
const res = await $http.mix.getIp();
|
||||
latitude.value = Number(res.data.data.lat);
|
||||
longitude.value = Number(res.data.data.lng);
|
||||
const jw = `${longitude.value?.toFixed(4)},${latitude.value?.toFixed(4)}`;
|
||||
await handdleJw(jw);
|
||||
} catch (e) {
|
||||
locationInfo.value = '定位失败';
|
||||
}
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
@ -270,101 +308,129 @@ const getLocation = () => {
|
||||
};
|
||||
|
||||
async function handdleJw(jw: string) {
|
||||
const zxs = ['北京', '重庆', '天津', '上海']
|
||||
// 根据经纬度获取物理位置
|
||||
const loc = await $http.mix.getLocation({ location: jw });
|
||||
console.log('>>> --> handdleJw --> loc:', loc)
|
||||
fxlink.value = loc.data.fxLink
|
||||
if (loc.code == 200) {
|
||||
const zxs = [
|
||||
'北京',
|
||||
'重庆',
|
||||
'天津',
|
||||
'上海'
|
||||
]
|
||||
|
||||
try {
|
||||
const loc = await $http.mix.getLocation({ location: jw });
|
||||
if (loc.code !== 200) {
|
||||
$msg.error(loc.msg);
|
||||
return
|
||||
}
|
||||
|
||||
const data = loc.data;
|
||||
fxlink.value = data.fxLink || '#'
|
||||
if (zxs.includes(data.adm2)) {
|
||||
locationInfo.value = `${data.adm1}${data.name}`;
|
||||
} else {
|
||||
locationInfo.value = `${data.adm1}${data.adm2}${data.name}`;
|
||||
}
|
||||
} else {
|
||||
$msg.console.error(loc.msg);
|
||||
return
|
||||
}
|
||||
const res = await $http.mix.getWeather({ location: jw });
|
||||
console.log('>>> --> handdleJw --> res:', res)
|
||||
if (res.code == 200) {
|
||||
wea.value = res.data.text;
|
||||
weaIcon.value = res.data.icon;
|
||||
temp.value = res.data.temp;
|
||||
} else {
|
||||
$msg.console.error(loc.msg);
|
||||
|
||||
const res = await $http.mix.getWeather({ location: jw });
|
||||
if (res.code === 200) {
|
||||
wea.value = res.data.text;
|
||||
weaIcon.value = res.data.icon;
|
||||
temp.value = res.data.temp;
|
||||
} else {
|
||||
$msg.error(res.msg);
|
||||
}
|
||||
} catch (e) {
|
||||
locationInfo.value = '定位失败';
|
||||
wea.value = ''
|
||||
weaIcon.value = ''
|
||||
temp.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.name, (newVal) => {
|
||||
activeKey.value = newVal as string
|
||||
updateNavHeight()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => usrLog.isLogin, () => {
|
||||
syncUserFromCookies()
|
||||
})
|
||||
|
||||
function goHome() {
|
||||
if (route.name == 'home') return
|
||||
if (route.name === 'home') return
|
||||
router.push('/home');
|
||||
}
|
||||
|
||||
function toLogin() {
|
||||
console.log('>>> --> toLogin --> toLogin:', 'toLogin')
|
||||
|
||||
if ($cookies.get('token')) return
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function logout() {
|
||||
console.log('>>> --> logout --> logout:', 'logout')
|
||||
$cookies.remove('token');
|
||||
$cookies.remove('userinfo');
|
||||
usrLog.setIsLogin(false)
|
||||
userinfo.value = null;
|
||||
syncUserFromCookies()
|
||||
}
|
||||
|
||||
function gotoConsole() {
|
||||
window.open("https://www.hxyouzi.com/console/home", "_BLACK")
|
||||
window.open('https://www.hxyouzi.com/console/home', '_blank')
|
||||
}
|
||||
|
||||
function gotoHf() {
|
||||
console.log('>>> --> gotoHf --> fxlink:', fxlink)
|
||||
window.open(fxlink.value, "_BLACK")
|
||||
|
||||
if (!fxlink.value || fxlink.value === '#') return
|
||||
window.open(fxlink.value, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
userinfo.value = $cookies.get('userinfo');
|
||||
console.log('>>>>>>>>>>', userinfo.value);
|
||||
setTimeout(() => {
|
||||
console.log('>>>>>>>>>>', route)
|
||||
activeKey.value = route.name as string;
|
||||
}, 20);
|
||||
console.log('>>> --> route.name:', route.name)
|
||||
getLocation(); // 组件挂载时获取位置
|
||||
|
||||
const h: number = nav.value.clientHeight
|
||||
if (h > 0) navx.setNavH(h)
|
||||
token.value = $cookies.get('token')
|
||||
|
||||
|
||||
syncUserFromCookies()
|
||||
getLocation()
|
||||
updateNavHeight()
|
||||
window.addEventListener('resize', updateNavHeight)
|
||||
});
|
||||
onBeforeUpdate(() => {
|
||||
userinfo.value = $cookies.get('userinfo');
|
||||
activeKey.value = route.name as string;
|
||||
const h: number = nav.value.clientHeight
|
||||
// console.log('******>>> --> nav.value:', nav.value)
|
||||
// console.log('()()()()()>>> --> h:', h)
|
||||
if (h > 0) navx.setNavH(h)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateNavHeight)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.main-nav {
|
||||
box-shadow: 0 1px 15px 0 @primary;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content__icon) {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
:deep(.n-menu.n-menu--horizontal) {
|
||||
background: transparent !important;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
:deep(.n-menu-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 30px !important;
|
||||
}
|
||||
|
||||
:deep(.n-menu--horizontal .n-menu-item-content) {
|
||||
border-radius: 9999px;
|
||||
padding: 0 16px !important;
|
||||
height: 34px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #526377 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.n-menu--horizontal .n-menu-item-content::before) {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
|
||||
:deep(.n-menu--horizontal .n-menu-item-content:hover) {
|
||||
background: rgba(255, 255, 255, 0.72) !important;
|
||||
color: #ec66ab !important;
|
||||
}
|
||||
|
||||
:deep(.n-menu--horizontal .n-menu-item-content.n-menu-item-content--selected) {
|
||||
background: linear-gradient(135deg, rgba(236, 102, 171, 0.14), rgba(244, 138, 111, 0.12)) !important;
|
||||
color: #ec66ab !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,20 +1,28 @@
|
||||
<template>
|
||||
<div class="image-container flex w-full">
|
||||
<n-image ref="lazyRef" class="lazy__img" :src="url" @load="handleLoad" @error="handleError">
|
||||
<div class="image-shell" :style="shellStyle">
|
||||
<n-image ref="lazyRef" class="lazy__img" :src="url" @load="handleLoad" @error="handleError">
|
||||
<template #placeholder>
|
||||
<icon-loading class="zhuan text-primary w-12 h-12" />
|
||||
<div class="lazy-placeholder">
|
||||
<span class="lazy-placeholder__ring">
|
||||
<icon-loading class="zhuan h-10 w-10 text-primary" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #error>
|
||||
<img :src="errorImg" alt="error" />
|
||||
<div class="lazy-error">
|
||||
<img :src="errorImg" alt="error" />
|
||||
</div>
|
||||
</template>
|
||||
</n-image>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, ref, toRefs } from "vue";
|
||||
import { computed, inject, nextTick, ref, shallowRef, toRefs } from "vue";
|
||||
import loadError from "../assets/loadError.png";
|
||||
import loadingImg from "../assets/loading.gif";
|
||||
import { waterfallImageLoadedKey } from "../utils/keys";
|
||||
|
||||
const props = defineProps({
|
||||
previewIcon: {
|
||||
type: String,
|
||||
@ -31,40 +39,104 @@ const props = defineProps({
|
||||
errorImg: {
|
||||
type: String,
|
||||
default: loadError,
|
||||
}
|
||||
},
|
||||
Pwidth: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
Pheight: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const { url, loading, errorImg } = toRefs(props);
|
||||
const { url, errorImg } = toRefs(props);
|
||||
|
||||
const imgLoaded = inject("imgLoaded") as () => void;
|
||||
const imgLoaded = inject(waterfallImageLoadedKey, () => {});
|
||||
const lazyRef = ref<any>(null);
|
||||
const hasNotified = shallowRef(false);
|
||||
|
||||
const handleLoad = () => {
|
||||
const shellStyle = computed(() => {
|
||||
if (!props.Pwidth || !props.Pheight) return undefined;
|
||||
|
||||
return {
|
||||
aspectRatio: `${props.Pwidth} / ${props.Pheight}`,
|
||||
};
|
||||
});
|
||||
|
||||
const notifyLoaded = () => {
|
||||
if (hasNotified.value) return;
|
||||
|
||||
hasNotified.value = true;
|
||||
imgLoaded();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
// 可以在这里添加错误处理逻辑
|
||||
const handleLoad = () => {
|
||||
notifyLoaded();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (lazyRef.value) {
|
||||
imgLoaded();
|
||||
}
|
||||
const handleError = () => {
|
||||
notifyLoaded();
|
||||
};
|
||||
|
||||
nextTick(() => {
|
||||
const nativeImg = lazyRef.value?.$el?.querySelector?.("img") as
|
||||
| HTMLImageElement
|
||||
| undefined;
|
||||
|
||||
if (nativeImg?.complete && nativeImg.currentSrc) notifyLoaded();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-image img) {
|
||||
.image-shell {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
/* object-fit: cover; */
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 198, 105, 0.18), transparent 32%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(244, 249, 255, 0.72));
|
||||
}
|
||||
|
||||
:deep(.n-image),
|
||||
:deep(.n-image img),
|
||||
:deep(.n-image-placeholder),
|
||||
:deep(.n-image-error) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lazy__img,
|
||||
:deep(.n-image),
|
||||
:deep(.n-image-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lazy__img {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
:deep(.n-image-placeholder),
|
||||
:deep(.n-image-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.n-image img) {
|
||||
display: block;
|
||||
height: auto;
|
||||
transition: transform 0.45s ease, filter 0.28s ease;
|
||||
}
|
||||
|
||||
.image-shell:hover :deep(.n-image img) {
|
||||
transform: scale(1.015);
|
||||
filter: saturate(1.03);
|
||||
}
|
||||
|
||||
.lazy__img img[alt="loading"],
|
||||
@ -75,6 +147,39 @@ onMounted(() => {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.lazy-placeholder,
|
||||
.lazy-error {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 8rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 214, 108, 0.18), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(240, 247, 255, 0.72));
|
||||
}
|
||||
|
||||
.lazy-placeholder__ring {
|
||||
display: inline-flex;
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
box-shadow: 0 12px 24px rgba(118, 144, 169, 0.16);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.lazy-error img {
|
||||
max-width: min(11rem, 100%);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@ -83,11 +188,8 @@ onMounted(() => {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.zhuan {
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div ref="waterfallWrapper" class="waterfall-list" :style="{ height: `${wrapperHeight}px` }">
|
||||
<div v-for="(item, index) in list" :key="getKey(item, index)" :style="{height:`${colWidth * item.height / item.width}px`}" class="waterfall-item">
|
||||
<div ref="waterfallWrapper" class="waterfall-list" :style="wrapperStyle">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="getKey(item, index)"
|
||||
:style="getItemStyle(index)"
|
||||
class="waterfall-item"
|
||||
>
|
||||
<div class="waterfall-card h-full">
|
||||
<slot name="item" :item="item" :index="index" :url="getRenderURL(item)" />
|
||||
</div>
|
||||
@ -11,10 +16,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import type { PropType } from "vue";
|
||||
import { provide, ref, watch } from "vue";
|
||||
import { computed, provide, useTemplateRef, watch } from "vue";
|
||||
import type { ViewCard } from "../types/waterfall";
|
||||
import { useCalculateCols, useLayout } from "../use";
|
||||
import Lazy from "../utils/Lazy";
|
||||
import { waterfallImageLoadedKey } from "../utils/keys";
|
||||
import { getValue } from "../utils/util";
|
||||
|
||||
const props = defineProps({
|
||||
@ -34,6 +39,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
widthSelector: {
|
||||
type: String,
|
||||
default: "width",
|
||||
},
|
||||
heightSelector: {
|
||||
type: String,
|
||||
default: "height",
|
||||
},
|
||||
columns: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
@ -72,7 +85,7 @@ const props = defineProps({
|
||||
},
|
||||
loadProps: {
|
||||
type: Object,
|
||||
default: () => { },
|
||||
default: () => {},
|
||||
},
|
||||
crossOrigin: {
|
||||
type: Boolean,
|
||||
@ -84,62 +97,91 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const lazy = new Lazy(props.lazyload, props.loadProps, props.crossOrigin);
|
||||
provide("lazy", lazy);
|
||||
const waterfallWrapper = useTemplateRef<HTMLElement>("waterfallWrapper");
|
||||
|
||||
// 容器块信息
|
||||
const { waterfallWrapper, wrapperWidth, colWidth, cols, offsetX } =
|
||||
useCalculateCols(props);
|
||||
// 瀹瑰櫒鍧椾俊鎭?
|
||||
const { wrapperWidth, colWidth, cols, offsetX } = useCalculateCols(
|
||||
props,
|
||||
waterfallWrapper
|
||||
);
|
||||
|
||||
// 容器高度,块定位
|
||||
const getNumericValue = (item: ViewCard, selector: string): number | null => {
|
||||
const value = getValue(item, selector)[0];
|
||||
const resolved = Number(value);
|
||||
|
||||
if (!Number.isFinite(resolved) || resolved <= 0) return null;
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const getItemHeightByIndex = (index: number): number | null => {
|
||||
const item = props.list[index];
|
||||
if (!item || colWidth.value <= 0) return null;
|
||||
|
||||
const width = getNumericValue(item, props.widthSelector);
|
||||
const height = getNumericValue(item, props.heightSelector);
|
||||
|
||||
if (!width || !height) return null;
|
||||
|
||||
return (colWidth.value * height) / width;
|
||||
};
|
||||
|
||||
const itemHeights = computed(() =>
|
||||
props.list.map((_, index) => getItemHeightByIndex(index))
|
||||
);
|
||||
|
||||
// 瀹瑰櫒楂樺害锛屽潡瀹氫綅
|
||||
const { wrapperHeight, layoutHandle } = useLayout(
|
||||
props,
|
||||
colWidth,
|
||||
cols,
|
||||
offsetX,
|
||||
waterfallWrapper
|
||||
waterfallWrapper,
|
||||
(index, item) => getItemHeightByIndex(index) ?? item.offsetHeight
|
||||
);
|
||||
|
||||
// 1s内最多执行一次排版,减少性能开销
|
||||
const wrapperStyle = computed(() => ({
|
||||
height: `${wrapperHeight.value}px`,
|
||||
backgroundColor: props.backgroundColor,
|
||||
}));
|
||||
|
||||
const getItemStyle = (index: number) => {
|
||||
const height = itemHeights.value[index];
|
||||
if (!height) return undefined;
|
||||
|
||||
return {
|
||||
height: `${height}px`,
|
||||
};
|
||||
};
|
||||
|
||||
// 1s鍐呮渶澶氭墽琛屼竴娆℃帓鐗堬紝鍑忓皯鎬ц兘寮€閿€
|
||||
const renderer = useDebounceFn(() => {
|
||||
layoutHandle();
|
||||
// console.log("强制更新排版");
|
||||
}, props.delay);
|
||||
|
||||
// 列表发生变化直接触发排版
|
||||
watch(
|
||||
() => [wrapperWidth, colWidth, props.list],
|
||||
[wrapperWidth, cols, itemHeights, () => props.list],
|
||||
() => {
|
||||
renderer();
|
||||
},
|
||||
{ deep: true }
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 尺寸宽度变化防抖触发
|
||||
const sizeChangeTime = ref(0);
|
||||
// 鍥剧墖鍔犺浇瀹屾垚
|
||||
provide(waterfallImageLoadedKey, renderer);
|
||||
|
||||
provide("sizeChangeTime", sizeChangeTime);
|
||||
|
||||
// 图片加载完成
|
||||
provide("imgLoaded", renderer);
|
||||
|
||||
// 根据选择器获取图片地址
|
||||
// 鏍规嵁閫夋嫨鍣ㄨ幏鍙栧浘鐗囧湴鍧€
|
||||
const getRenderURL = (item: ViewCard): string => {
|
||||
return getValue(item, props.imgSelector)[0];
|
||||
return String(getValue(item, props.imgSelector)[0] ?? "");
|
||||
};
|
||||
|
||||
// 获取唯一值
|
||||
const getKey = (item: ViewCard, index: number): string => {
|
||||
return item[props.rowKey] || index;
|
||||
// 鑾峰彇鍞竴鍊?
|
||||
const getKey = (item: ViewCard, index: number): string | number => {
|
||||
return item[props.rowKey] ?? index;
|
||||
};
|
||||
|
||||
const clearAndReload = () => {
|
||||
const originalList = [...props.list];
|
||||
props.list.length = 0;
|
||||
setTimeout(() => {
|
||||
props.list.push(...originalList);
|
||||
renderer();
|
||||
}, 0);
|
||||
layoutHandle();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
@ -159,42 +201,53 @@ defineExpose({
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: v-bind(backgroundColor);
|
||||
--waterfall-radius: 22px;
|
||||
--waterfall-border: rgba(255, 255, 255, 0.72);
|
||||
--waterfall-surface: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.9),
|
||||
rgba(244, 250, 255, 0.72)
|
||||
);
|
||||
--waterfall-shadow: 0 16px 32px rgba(118, 144, 169, 0.12);
|
||||
}
|
||||
|
||||
.waterfall-item {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
/* transition: .3s; */
|
||||
/* 初始位置设置到屏幕以外,避免懒加载失败 */
|
||||
transform: translate3d(0, 3000px, 0);
|
||||
visibility: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* 初始的入场效果 */
|
||||
@-webkit-keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
.waterfall-card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--waterfall-border);
|
||||
border-radius: var(--waterfall-radius);
|
||||
background: var(--waterfall-surface);
|
||||
box-shadow: var(--waterfall-shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
.waterfall-card :deep(img) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
opacity: 0.01;
|
||||
transform: translate3d(0, 18px, 0) scale(0.985);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
-webkit-animation-name: fadeIn;
|
||||
animation-name: fadeIn;
|
||||
}
|
||||
</style>
|
||||
|
||||
2
src/lib/types/waterfall.d.ts
vendored
2
src/lib/types/waterfall.d.ts
vendored
@ -10,6 +10,8 @@ export interface ViewCard {
|
||||
export interface WaterfallProps {
|
||||
columns: number;
|
||||
width: number;
|
||||
widthSelector: string;
|
||||
heightSelector: string;
|
||||
animationDuration: number;
|
||||
animationDelay: number;
|
||||
animationEffect: string;
|
||||
|
||||
@ -1,46 +1,47 @@
|
||||
import { computed, ref } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
import { getItemWidth } from "../utils/itemWidth";
|
||||
import type { WaterfallProps } from "../types/waterfall";
|
||||
import type { Nullable } from "../types/util";
|
||||
|
||||
export function useCalculateCols(props: WaterfallProps) {
|
||||
const wrapperWidth = ref<number>(0);
|
||||
const waterfallWrapper = ref<Nullable<HTMLElement>>(null);
|
||||
export function useCalculateCols(
|
||||
props: WaterfallProps,
|
||||
waterfallWrapper: Ref<Nullable<HTMLElement>>
|
||||
) {
|
||||
const wrapperWidth = ref<number>(0);
|
||||
|
||||
useResizeObserver(waterfallWrapper, (entries) => {
|
||||
const entry = entries[0];
|
||||
const { width } = entry.contentRect;
|
||||
if (width === 0) return;
|
||||
wrapperWidth.value = width;
|
||||
useResizeObserver(waterfallWrapper, (entries) => {
|
||||
const entry = entries[0];
|
||||
const { width } = entry.contentRect;
|
||||
if (width === 0) return;
|
||||
wrapperWidth.value = width;
|
||||
});
|
||||
|
||||
// 鍒楀疄闄呭搴?
|
||||
const colWidth = computed(() => {
|
||||
return getItemWidth({
|
||||
wrapperWidth: wrapperWidth.value,
|
||||
gutter: props.gutter,
|
||||
hasAroundGutter: props.hasAroundGutter,
|
||||
columns: props.columns,
|
||||
});
|
||||
});
|
||||
|
||||
// 列实际宽度
|
||||
const colWidth = computed(() => {
|
||||
return getItemWidth({
|
||||
wrapperWidth: wrapperWidth.value,
|
||||
gutter: props.gutter,
|
||||
hasAroundGutter: props.hasAroundGutter,
|
||||
columns: props.columns,
|
||||
});
|
||||
});
|
||||
// 鍒?
|
||||
const cols = computed(() => props.columns);
|
||||
|
||||
// 列
|
||||
const cols = computed(() => props.columns);
|
||||
// 鍋忕Щ
|
||||
const offsetX = computed(() => {
|
||||
const offset = props.hasAroundGutter ? props.gutter : -props.gutter;
|
||||
const contextWidth = cols.value * (colWidth.value + props.gutter) + offset;
|
||||
return (wrapperWidth.value - contextWidth) / 2;
|
||||
});
|
||||
|
||||
// 偏移
|
||||
const offsetX = computed(() => {
|
||||
const offset = props.hasAroundGutter ? props.gutter : -props.gutter;
|
||||
const contextWidth =
|
||||
cols.value * (colWidth.value + props.gutter) + offset;
|
||||
return (wrapperWidth.value - contextWidth) / 2;
|
||||
});
|
||||
|
||||
return {
|
||||
waterfallWrapper,
|
||||
wrapperWidth,
|
||||
colWidth,
|
||||
cols,
|
||||
offsetX,
|
||||
};
|
||||
return {
|
||||
wrapperWidth,
|
||||
colWidth,
|
||||
cols,
|
||||
offsetX,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { nextTick, onBeforeUnmount, ref } from "vue";
|
||||
import { addClass, hasClass, prefixStyle } from "../utils/dom";
|
||||
import type { WaterfallProps } from "../types/waterfall";
|
||||
import type { CssStyleObject, Nullable } from "../types/util";
|
||||
@ -15,104 +15,121 @@ export function useLayout(
|
||||
colWidth: Ref<number>,
|
||||
cols: Ref<number>,
|
||||
offsetX: Ref<number>,
|
||||
waterfallWrapper: Ref<Nullable<HTMLElement>>
|
||||
waterfallWrapper: Ref<Nullable<HTMLElement>>,
|
||||
getItemHeight: (index: number, item: HTMLElement) => number | null
|
||||
) {
|
||||
const posY = ref<number[]>([]);
|
||||
const wrapperHeight = ref(0);
|
||||
let frameId = 0;
|
||||
let layoutTaskId = 0;
|
||||
|
||||
// 获取对应y下标的x的值
|
||||
// 鑾峰彇瀵瑰簲y涓嬫爣鐨剎鐨勫€?
|
||||
const getX = (index: number): number => {
|
||||
const count = props.hasAroundGutter ? index + 1 : index;
|
||||
return props.gutter * count + colWidth.value * index + offsetX.value;
|
||||
};
|
||||
|
||||
// 初始y
|
||||
// 鍒濆y
|
||||
const initY = (): void => {
|
||||
posY.value = new Array(cols.value).fill(
|
||||
props.hasAroundGutter ? props.gutter : 0
|
||||
);
|
||||
};
|
||||
|
||||
// 添加入场动画
|
||||
// 娣诲姞鍏ュ満鍔ㄧ敾
|
||||
const animation = addAnimation(props);
|
||||
|
||||
// 排版
|
||||
const layoutHandle = async () => {
|
||||
// 初始化y集合
|
||||
const doLayout = () => {
|
||||
initY();
|
||||
|
||||
// 构造列表
|
||||
const items: HTMLElement[] = [];
|
||||
if (waterfallWrapper && waterfallWrapper.value) {
|
||||
waterfallWrapper.value.childNodes.forEach((el: any) => {
|
||||
if (el!.className === "waterfall-item") items.push(el);
|
||||
});
|
||||
const items =
|
||||
waterfallWrapper.value?.querySelectorAll<HTMLElement>(".waterfall-item") ??
|
||||
[];
|
||||
|
||||
if (items.length === 0) {
|
||||
wrapperHeight.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取节点
|
||||
if (items.length === 0) return false;
|
||||
|
||||
// 遍历节点
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const curItem = items[i] as HTMLElement;
|
||||
// 最小的y值
|
||||
const minY = Math.min.apply(null, posY.value);
|
||||
// 最小y的下标
|
||||
const curItem = items[i];
|
||||
const minY = Math.min(...posY.value);
|
||||
const minYIndex = posY.value.indexOf(minY);
|
||||
// 当前下标对应的x
|
||||
const curX = getX(minYIndex);
|
||||
|
||||
// 设置x,y,width
|
||||
const style = curItem.style as CssStyleObject;
|
||||
|
||||
// 设置偏移
|
||||
if (transform) style[transform] = `translate3d(${curX}px,${minY}px, 0)`;
|
||||
if (transform) style[transform] = `translate3d(${curX}px, ${minY}px, 0)`;
|
||||
style.width = `${colWidth.value}px`;
|
||||
|
||||
// 更新当前index的y值
|
||||
const { height } = curItem.getBoundingClientRect();
|
||||
const height = getItemHeight(i, curItem) ?? curItem.offsetHeight;
|
||||
posY.value[minYIndex] += height + props.gutter;
|
||||
|
||||
// 添加入场动画
|
||||
animation(curItem, () => {
|
||||
// 添加动画时间
|
||||
if (transition) style[transition] = "transform .3s";
|
||||
if (transition) {
|
||||
style[transition] =
|
||||
"transform .32s cubic-bezier(0.22, 1, 0.36, 1), opacity .24s ease";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wrapperHeight.value = Math.max.apply(null, posY.value);
|
||||
const maxY = Math.max(...posY.value);
|
||||
const bottomGap = props.hasAroundGutter ? props.gutter : 0;
|
||||
wrapperHeight.value = Math.max(maxY - props.gutter + bottomGap, 0);
|
||||
};
|
||||
|
||||
// 鎺掔増
|
||||
const layoutHandle = async () => {
|
||||
layoutTaskId += 1;
|
||||
const currentTaskId = layoutTaskId;
|
||||
|
||||
await nextTick();
|
||||
if (currentTaskId !== layoutTaskId) return;
|
||||
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = 0;
|
||||
doLayout();
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
});
|
||||
|
||||
return {
|
||||
wrapperHeight,
|
||||
layoutHandle,
|
||||
};
|
||||
}
|
||||
|
||||
// 动画
|
||||
// 鍔ㄧ敾
|
||||
function addAnimation(props: WaterfallProps) {
|
||||
return (item: HTMLElement, callback?: () => void) => {
|
||||
const content = item!.firstChild as HTMLElement;
|
||||
const itemStyle = item.style as CssStyleObject;
|
||||
itemStyle.visibility = "visible";
|
||||
|
||||
const content = item.firstElementChild as HTMLElement | null;
|
||||
if (content && !hasClass(content, props.animationPrefix)) {
|
||||
const durationSec = `${props.animationDuration / 1000}s`;
|
||||
const delaySec = `${props.animationDelay / 1000}s`;
|
||||
const style = content.style as CssStyleObject;
|
||||
|
||||
style.visibility = "visible";
|
||||
if (duration) style[duration] = durationSec;
|
||||
|
||||
if (delay) style[delay] = delaySec;
|
||||
|
||||
if (fillMode) style[fillMode] = "both";
|
||||
|
||||
addClass(content, props.animationPrefix);
|
||||
addClass(content, props.animationEffect);
|
||||
|
||||
// 确保动画完成后item可见
|
||||
setTimeout(() => {
|
||||
const itemStyle = item.style as CssStyleObject;
|
||||
itemStyle.visibility = "visible";
|
||||
if (callback) callback();
|
||||
window.setTimeout(() => {
|
||||
callback?.();
|
||||
}, props.animationDuration + props.animationDelay);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
callback?.();
|
||||
};
|
||||
}
|
||||
|
||||
5
src/lib/utils/keys.ts
Normal file
5
src/lib/utils/keys.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { InjectionKey } from "vue";
|
||||
|
||||
export const waterfallImageLoadedKey: InjectionKey<() => void> = Symbol(
|
||||
"waterfall:image-loaded"
|
||||
);
|
||||
@ -1,180 +1,280 @@
|
||||
<template>
|
||||
<n-scrollbar ref="virtualListInst" class="py-5" :style="boxStyle" @scroll="handleScroll">
|
||||
<n-input class="my-4 !w-[72%] ml-[14%]" round size="large" v-model:value="kw" @keyup.enter="onSearch"
|
||||
placeholder="请输入关键字">
|
||||
<template #suffix>
|
||||
<n-icon size="large">
|
||||
<icon-search />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
<div
|
||||
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]">
|
||||
<div
|
||||
class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]">
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]">
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]">
|
||||
</div>
|
||||
|
||||
<n-scrollbar ref="virtualListInst" class="relative z-[1] py-6" :style="boxStyle" @scroll="handleScroll">
|
||||
<section
|
||||
class="relative mx-auto w-[82%] rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-6 shadow-[0_20px_50px_rgba(110,124,112,0.11)] backdrop-blur-[20px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-[linear-gradient(120deg,rgba(255,255,255,0.24),transparent_45%)] after:content-['']">
|
||||
<div class="max-w-[48rem]">
|
||||
<h1 class="m-0 text-[clamp(1.8rem,2.5vw,2.8rem)] leading-[1.2] text-[#2c3a4a]">
|
||||
把喜欢的画面收进一片更柔和的光里
|
||||
</h1>
|
||||
<p class="mt-[0.9rem] max-w-[42rem] leading-[1.75] text-[#66778b]">
|
||||
输入关键词后按回车即可筛选图片。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Waterfall class="ml-[10%] !w-[80%]" ref="waterfall" :list="fileList" :gutter="gutter" :columns="column"
|
||||
img-selector="url" animation-effect="fadeIn" :animation-duration="1000" :animation-delay="300"
|
||||
backgroundColor="transparent"> >
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
class="card h-full flex items-center justify-center rounded-md shadow-lg overflow-hidden group transition-transform duration-300 box-border hover:-translate-y-1.5">
|
||||
<!-- <div class="image-wrapper"> -->
|
||||
<LazyImg class="rounded-md overflow-hidden" :Pwidth="item.width" :Pheight="item.height" :url="item.filepath" />
|
||||
<!-- </div> -->
|
||||
<div
|
||||
class="hidden truncate group-hover:block absolute rounded-md z-10 truncate top-0 text-center w-full bg-[#00000070] text-white">
|
||||
{{ item.filename }}
|
||||
class="mt-6 rounded-full border border-white/62 bg-[linear-gradient(135deg,rgba(255,249,241,0.76),rgba(236,245,243,0.72)),radial-gradient(circle_at_top_right,rgba(255,255,255,0.28),transparent_34%)] px-[1.1rem] py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.55)]">
|
||||
<n-input class="gallery-search" round size="large" v-model:value="kw" @keyup.enter="onSearch"
|
||||
placeholder="请输入关键词,按回车开始搜索">
|
||||
<template #suffix>
|
||||
<n-icon class="cursor-pointer text-slate-500" size="large">
|
||||
<icon-search />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mx-auto mt-6 w-[82%] rounded-[30px] border border-white/62 bg-white/42 p-6 shadow-[0_18px_42px_rgba(109,126,117,0.1)] backdrop-blur-[18px]">
|
||||
<div class="mb-5 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div class="text-[1.6rem] font-semibold text-[#2f4050]">图片列表</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute rounded-md flex px-2 z-10 truncate bottom-0 w-full bg-[#00000070] text-white justify-between items-center">
|
||||
<div>由 <span class="text-[#f1d9db] font-600">{{ item.nickname }}</span> 分享</div>
|
||||
<div class="text-sm cursor-pointer text-[#f6cbe7] flex items-center" @click="downloadFile(item.filepath)">
|
||||
<icon-download class="w-5 h-5" />
|
||||
下载
|
||||
</div>
|
||||
class="inline-flex items-center whitespace-nowrap rounded-full border border-white/80 bg-white/68 px-4 py-[0.65rem] text-[#526376]">
|
||||
{{ galleryStats.loaded }} 张
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Waterfall>
|
||||
</n-scrollbar>
|
||||
|
||||
<Waterfall ref="waterfall" :list="fileList" :gutter="gutter" :columns="column" class="w-full" img-selector="url"
|
||||
animation-effect="fadeIn" :animation-duration="1000" :animation-delay="300" backgroundColor="transparent">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
class="group relative flex h-full items-center justify-center overflow-hidden rounded-[24px] border border-white/82 bg-[linear-gradient(180deg,rgba(255,252,248,0.92),rgba(239,244,241,0.78))] shadow-[0_16px_34px_rgba(116,132,124,0.14)] backdrop-blur-[14px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:border-white/196 hover:shadow-[0_22px_42px_rgba(102,123,112,0.18)]">
|
||||
<LazyImg class="overflow-hidden rounded-[20px]" :Pwidth="item.width" :Pheight="item.height"
|
||||
:url="item.filepath" />
|
||||
<div
|
||||
class="absolute left-0 right-0 top-0 z-10 overflow-hidden text-ellipsis whitespace-nowrap rounded-[14px] border border-white/22 bg-[rgba(39,50,65,0.1)] px-3 py-1 items-center justify-center text-sm text-white hidden backdrop-blur-[12px] group-hover:flex">
|
||||
{{ item.filename }}
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 z-10 flex items-center justify-between gap-3 rounded-[14px] border border-white/22 bg-[rgba(39,50,65,0.5)] px-3 py-1 text-white backdrop-blur-[12px]">
|
||||
<div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-[#eef4f8]">
|
||||
由 <span class="font-semibold text-[#ffd8cb]">{{ item.nickname }}</span> 分享
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex flex-shrink-0 cursor-pointer items-center rounded-full bg-white/18 px-4 py-1 text-[0.86rem] font-semibold text-[#ffe3d3] transition-colors duration-200 hover:bg-white/24 hover:text-white"
|
||||
@click="downloadFile(item.filepath)">
|
||||
<icon-download class="mr-1 h-5 w-5" />
|
||||
下载
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Waterfall>
|
||||
</section>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { throttle } from 'es-toolkit';
|
||||
import { LazyImg, Waterfall } from '../lib';
|
||||
import { throttle } from 'es-toolkit'
|
||||
import { LazyImg, Waterfall } from '../lib'
|
||||
|
||||
definePage({
|
||||
name: 'gallery',
|
||||
meta: {
|
||||
title: '画廊',
|
||||
}
|
||||
})
|
||||
const waterfall = ref<any>(null);
|
||||
|
||||
interface GalleryItem {
|
||||
filepath: string
|
||||
filename: string
|
||||
nickname: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface ScrollContainer extends EventTarget {
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
offsetHeight: number
|
||||
}
|
||||
|
||||
const GALLERY_WIDTH_RATIO = 0.82
|
||||
const PAGE_SIZE = 20
|
||||
const SCROLL_THRESHOLD = 100
|
||||
|
||||
const waterfall = ref<any>(null)
|
||||
const nav: any = $store.nav.useNavStore()
|
||||
const boxStyle: any = ref({})
|
||||
// 画廊页逻辑
|
||||
const fileList = ref<any[]>([]);
|
||||
const pn = ref(1);
|
||||
const ps = ref(20);
|
||||
const loading = ref(false);
|
||||
const kw = ref<string>('');
|
||||
const cwidth = ref<number>(240);
|
||||
const column = ref<number>(5);
|
||||
const gutter = ref<number>(18);
|
||||
const boxStyle = ref<Record<string, string>>({})
|
||||
const fileList = ref<GalleryItem[]>([])
|
||||
const pn = ref(1)
|
||||
const ps = ref(PAGE_SIZE)
|
||||
const loading = ref(false)
|
||||
const kw = ref('')
|
||||
const cwidth = ref(240)
|
||||
const column = ref(5)
|
||||
const gutter = ref(18)
|
||||
const isLoadAll = ref(false)
|
||||
|
||||
const isLoadAll = ref<boolean>(false)
|
||||
const searchKeyword = computed(() => kw.value.trim())
|
||||
|
||||
const galleryStats = computed(() => {
|
||||
let status = '浏览全部'
|
||||
|
||||
if (loading.value) {
|
||||
status = '加载中'
|
||||
} else if (isLoadAll.value && fileList.value.length > 0) {
|
||||
status = '已全部加载'
|
||||
} else if (searchKeyword.value) {
|
||||
status = '搜索结果'
|
||||
}
|
||||
|
||||
return {
|
||||
loaded: fileList.value.length,
|
||||
columns: column.value,
|
||||
status,
|
||||
keyword: searchKeyword.value ? `关键词:${searchKeyword.value}` : '关键词:全部图片',
|
||||
}
|
||||
})
|
||||
|
||||
// 计算列数
|
||||
function calculateColumns() {
|
||||
const totalWidth = window.innerWidth * 0.8; // 画廊宽度为视口宽度的80%
|
||||
const col = Math.floor((totalWidth + gutter.value) / (cwidth.value + gutter.value));
|
||||
column.value = col > 0 ? col : 1;
|
||||
function renderWaterfall() {
|
||||
waterfall.value?.renderer()
|
||||
}
|
||||
|
||||
// 获取文件列表
|
||||
function calculateColumns() {
|
||||
const totalWidth = window.innerWidth * GALLERY_WIDTH_RATIO
|
||||
const col = Math.floor((totalWidth + gutter.value) / (cwidth.value + gutter.value))
|
||||
column.value = col > 0 ? col : 1
|
||||
renderWaterfall()
|
||||
}
|
||||
|
||||
function updateBoxStyle() {
|
||||
boxStyle.value = {
|
||||
maxHeight: `calc(100vh - 5px - ${nav.navH}px)`,
|
||||
}
|
||||
}
|
||||
|
||||
function appendFileList(list: GalleryItem[]) {
|
||||
if (pn.value === 1) {
|
||||
fileList.value = list
|
||||
return
|
||||
}
|
||||
|
||||
list.forEach((item) => {
|
||||
fileList.value.push(item)
|
||||
})
|
||||
}
|
||||
|
||||
function resetGalleryState() {
|
||||
pn.value = 1
|
||||
kw.value = ''
|
||||
fileList.value = []
|
||||
isLoadAll.value = false
|
||||
}
|
||||
|
||||
async function getFileList() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const res = await $http.file.getFileList({
|
||||
keyword: kw.value,
|
||||
page_num: pn.value,
|
||||
page_size: ps.value,
|
||||
});
|
||||
})
|
||||
const list = res.data as GalleryItem[]
|
||||
|
||||
// console.log('>>> --> getFileList --> res:', res);
|
||||
if (res.data.length < ps.value) {
|
||||
isLoadAll.value = true;
|
||||
}
|
||||
if (pn.value === 1) {
|
||||
fileList.value = res.data;
|
||||
} else {
|
||||
// 追加新数据
|
||||
res.data.forEach((item: any) => {
|
||||
fileList.value.push(item);
|
||||
});
|
||||
if (list.length < ps.value) {
|
||||
isLoadAll.value = true
|
||||
}
|
||||
|
||||
appendFileList(list)
|
||||
} catch (error) {
|
||||
console.error('获取文件列表失败:', error);
|
||||
console.error('获取图片列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
waterfall.value?.renderer();
|
||||
loading.value = false
|
||||
renderWaterfall()
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = throttle((e: Event) => {
|
||||
if (loading.value || isLoadAll.value) return
|
||||
|
||||
const target = e.target as ScrollContainer | null
|
||||
if (!target) return
|
||||
|
||||
const scrollTop = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.offsetHeight
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll: any = throttle((e: any) => {
|
||||
// console.log('>>> --> handleScroll --> loading:', e)
|
||||
if (loading.value) return;
|
||||
if (isLoadAll.value) return;
|
||||
const scrollTop = e.target.scrollTop
|
||||
// console.log('>>> --> handleScroll --> scrollTop:', scrollTop)
|
||||
const scrollHeight = e.target.scrollHeight
|
||||
// console.log('>>> --> handleScroll --> scrollHeight:', scrollHeight)
|
||||
const clientHeight = e.target.offsetHeight;
|
||||
|
||||
// 当滚动到距离底部20%时加载更多
|
||||
// console.log('>>> --> clientHeight --> clientHeight:', clientHeight)
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
console.log('>>> --> handleScroll --> 加载更多')
|
||||
pn.value++;
|
||||
getFileList();
|
||||
if (scrollTop + clientHeight >= scrollHeight - SCROLL_THRESHOLD) {
|
||||
pn.value += 1
|
||||
getFileList()
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
function onSearch() {
|
||||
pn.value = 1;
|
||||
pn.value = 1
|
||||
kw.value = kw.value.trim()
|
||||
isLoadAll.value = false
|
||||
getFileList();
|
||||
getFileList()
|
||||
}
|
||||
|
||||
function downloadFile(url: string) {
|
||||
console.log('>>> --> downloadFile --> url:', url)
|
||||
// 创建临时a标签
|
||||
const link = document.createElement('a');
|
||||
// 设置下载链接
|
||||
link.href = url;
|
||||
// 提取文件名
|
||||
const fileName = url.split('/').pop() || 'downloaded-file';
|
||||
// 设置下载属性和文件名
|
||||
link.download = fileName;
|
||||
// 设置为隐藏元素
|
||||
link.style.display = 'none';
|
||||
// 添加到文档
|
||||
document.body.appendChild(link);
|
||||
// 触发点击事件
|
||||
link.click();
|
||||
// 移除临时元素
|
||||
document.body.removeChild(link);
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const fileName = url.split('/').pop() || 'downloaded-file'
|
||||
link.download = fileName
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
calculateColumns()
|
||||
getFileList();
|
||||
console.log('>>> --> nav.NavH:', nav.navH)
|
||||
boxStyle.value = {
|
||||
maxHeight: `calc(100vh - 5px - ${nav.navH}px)`,
|
||||
}
|
||||
// 添加滚动监听
|
||||
window.addEventListener('resize', calculateColumns);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
});
|
||||
updateBoxStyle()
|
||||
getFileList()
|
||||
window.addEventListener('resize', calculateColumns)
|
||||
window.addEventListener('resize', updateBoxStyle)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
pn.value = 1;
|
||||
kw.value = '';
|
||||
fileList.value = [];
|
||||
isLoadAll.value = false
|
||||
// 移除监听
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
});
|
||||
resetGalleryState()
|
||||
window.removeEventListener('resize', calculateColumns)
|
||||
window.removeEventListener('resize', updateBoxStyle)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.n-image img) {
|
||||
border-radius: 0.375rem !important;
|
||||
:deep(.gallery-search .n-input) {
|
||||
background: rgba(255, 255, 255, 0.74) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.7) !important;
|
||||
box-shadow: 0 10px 28px rgba(109, 138, 132, 0.1) !important;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
</style>
|
||||
|
||||
:deep(.gallery-search .n-input__input-el) {
|
||||
color: #314155 !important;
|
||||
}
|
||||
|
||||
:deep(.gallery-search .n-input__placeholder) {
|
||||
color: #93a2b3 !important;
|
||||
}
|
||||
|
||||
:deep(.gallery-search .n-input.n-input--focus) {
|
||||
border-color: rgba(122, 160, 148, 0.7) !important;
|
||||
box-shadow: 0 16px 38px rgba(118, 149, 139, 0.16) !important;
|
||||
}
|
||||
|
||||
:deep(.n-image img) {
|
||||
border-radius: 1rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,117 +1,168 @@
|
||||
<template>
|
||||
<div class="plink-page pt-4 flex justify-between" :style="boxStyle">
|
||||
<div class="left ml-4 ">
|
||||
<n-card class="card w-100 shadow">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-4 text-primary text-xl font-bold">
|
||||
<icon-loading class="zhuan text-primary w-6 h-6" />
|
||||
申请友链
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<n-form :model="plinkData" ref="formRef" :rules="rules" label-width="80" :inline="false"
|
||||
feedback-style="font-size: 12px">
|
||||
<n-form-item path="name">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<user-icon></user-icon>
|
||||
昵称
|
||||
</div>
|
||||
</template>
|
||||
<n-input v-model:value="plinkData.name" placeholder="请输入你的昵称" />
|
||||
</n-form-item>
|
||||
<n-form-item path="title">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<site-icon></site-icon>
|
||||
网站名称
|
||||
</div>
|
||||
</template>
|
||||
<n-input v-model:value="plinkData.title" placeholder="请输入你的网站名称" />
|
||||
</n-form-item>
|
||||
<n-form-item path="url">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<url-icon></url-icon>
|
||||
站点地址
|
||||
</div>
|
||||
</template>
|
||||
<n-input v-model:value="plinkData.url" placeholder="请输入你的网站地址" />
|
||||
</n-form-item>
|
||||
<n-form-item path="avater">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<ava-icon></ava-icon>
|
||||
头像链接
|
||||
</div>
|
||||
</template>
|
||||
<n-input v-model:value="plinkData.avater" placeholder="请输入你的头像链接" />
|
||||
</n-form-item>
|
||||
<n-form-item path="desc">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<desc-icon></desc-icon>
|
||||
站点介绍
|
||||
</div>
|
||||
</template>
|
||||
<n-input type="textarea" :autosize="{ minRows: 2, maxRows: 3 }" maxlength="50"
|
||||
v-model:value="plinkData.desc" placeholder="请用一句话简短的描述你的站点" />
|
||||
</n-form-item>
|
||||
<n-form-item path="tagname">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<tag-icon></tag-icon>
|
||||
标签
|
||||
</div>
|
||||
</template>
|
||||
<n-input type="textarea" :autosize="{ minRows: 2, maxRows: 3 }" maxlength="50"
|
||||
v-model:value="plinkData.tagname" placeholder="输入你的标签,用逗号隔开,注意每个标签最多3个字符,且只显示前3个标签哦~" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button class="w-full" type="primary" @click="submitPut">申请友链</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-card>
|
||||
<div class="mt-4 text-right text-primary text-sm font-italic">申请完不显示是因为需要审核奥~~</div>
|
||||
<div
|
||||
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]"
|
||||
:style="boxStyle">
|
||||
<div
|
||||
class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]">
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]">
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]">
|
||||
</div>
|
||||
<n-scrollbar class="h-full">
|
||||
<div class="right h-full grid grid-cols-3 gap-5 flex-1 px-12">
|
||||
<div
|
||||
class="rounded-lg bg-white shadow flex flex-col items-center justify-center p-4 cursor-pointer transition-transform duration-300 box-border hover:-translate-y-1.5"
|
||||
v-for="p in pList" :key="p.pid" @click="gotoWebsite(p.url)">
|
||||
<div class="ava">
|
||||
<n-avatar round size="large" :src="p.avater"></n-avatar>
|
||||
</div>
|
||||
<em class="bg-[#fbf2e3] px-4 rounded-full text-sm text-primary mb-4">{{ p.name }}</em>
|
||||
<div class="font-bold text-xl text-gray-700 mb-1">
|
||||
{{ p.title }}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 truncate mb-1 text-sm">
|
||||
<div v-for="i in getTags(p.tagname)" :key="i.tid"
|
||||
:style="{ backgroundColor: getDictValue(tagColorList, 'tag', i, 'color') }"
|
||||
class="flex items-center gap-1 cursor-pointer text-sm text-white rounded-full px-3">
|
||||
<icon-tag2 class="w-3 h-3 " />{{ i }}
|
||||
<div class="relative z-[1] mx-auto flex h-full w-[86%] gap-6 py-5">
|
||||
<aside class="hidden w-[25rem] shrink-0 xl:block">
|
||||
<div class="sticky top-4">
|
||||
<section
|
||||
class="relative rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-5 shadow-[0_20px_50px_rgba(110,124,112,0.11)] backdrop-blur-[20px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-[linear-gradient(120deg,rgba(255,255,255,0.24),transparent_45%)] after:content-['']">
|
||||
<div class="relative z-[1]">
|
||||
<h1 class="mt-2 text-[1.65rem] leading-[1.2] text-[#2c3a4a]">留下你的站点名片</h1>
|
||||
<section
|
||||
class="mt-4 rounded-[24px] border border-white/72 bg-white/58 p-4 shadow-[0_14px_32px_rgba(112,128,118,0.1)]">
|
||||
<div class="mb-3 flex items-center justify-between gap-3 text-primary">
|
||||
<div class="flex items-center gap-2">
|
||||
<icon-loading class="zhuan h-4 w-4" />
|
||||
<span class="text-sm font-semibold">申请友链</span>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full bg-[rgba(250,239,220,0.92)] px-2.5 py-1 text-[11px] cursor-pointer text-primary"
|
||||
@click="fillForm">一键填入</span>
|
||||
</div>
|
||||
|
||||
<n-form ref="formRef" :model="plinkData" :rules="rules" class="compact-side-form" label-placement="top"
|
||||
require-mark-placement="right-hanging" size="small">
|
||||
<n-form-item v-for="field in formFields" :key="field.key" :path="field.key">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-[#526376]">
|
||||
<component :is="field.icon" class="h-4 w-4" />
|
||||
{{ field.label }}
|
||||
</div>
|
||||
</template>
|
||||
<n-input :value="plinkData[field.key]" :type="field.inputType"
|
||||
:maxlength="field.maxlength" :placeholder="field.placeholder" :rows="field.rows"
|
||||
@update:value="updateField(field.key, $event)" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="!mb-0 btn">
|
||||
<n-button
|
||||
class="!h-10 !w-full !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !text-sm !font-semibold shadow-[0_14px_28px_rgba(236,102,171,0.24)]"
|
||||
type="primary" @click="submitPut">
|
||||
提交友链申请
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</section>
|
||||
|
||||
<p class="mt-3 text-right text-[12px] leading-5 text-[#7a8797]">
|
||||
如果暂未显示,通常是因为还在审核中...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="truncate w-full text-gray-400 text-sm text-center">
|
||||
{{ p.desc }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
class="min-w-0 flex-1 overflow-hidden rounded-[30px] border border-white/70 bg-transparent p-4 ring-1 ring-inset ring-[rgba(255,255,255,0.38)] shadow-[0_20px_46px_rgba(109,126,117,0.1)] sm:p-6">
|
||||
<n-scrollbar :style="contentStyle" trigger="none" class="pr-1">
|
||||
<div class="space-y-5 pr-1">
|
||||
|
||||
|
||||
<section class="rounded-[26px] bg-transparent p-5 sm:p-6">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">已通过友链</div>
|
||||
<h3 class="mt-2 text-[1.4rem] font-semibold text-[#2f4050]">一起逛逛这些站点</h3>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-full border border-white/70 bg-white/42 px-4 py-2 text-sm text-[#526376] shadow-[0_8px_18px_rgba(112,128,118,0.08)]">
|
||||
共 {{ pList.length }} 个站点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2 2xl:grid-cols-3">
|
||||
<article v-for="p in pList" :key="p.pid"
|
||||
class="cursor-pointer rounded-[26px] border border-white/72 bg-[linear-gradient(145deg,rgba(255,251,246,0.78),rgba(237,246,244,0.62))] p-6 shadow-[0_16px_34px_rgba(116,132,124,0.12)] backdrop-blur-[18px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1 hover:border-white/90 hover:shadow-[0_22px_42px_rgba(102,123,112,0.16)]"
|
||||
@click="gotoWebsite(p.url)">
|
||||
<div class="flex items-center gap-8">
|
||||
<n-avatar round :size="56" :src="p.avater"
|
||||
class="shrink-0 ring-2 ring-white/80 shadow-[0_10px_22px_rgba(88,108,125,0.14)]" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="inline-flex items-center rounded-full bg-[rgba(250,239,220,0.92)] px-3 py-1 text-sm text-primary">
|
||||
{{ p.name }}
|
||||
</div>
|
||||
<h4 class="mt-3 truncate text-[1.3rem] font-semibold text-[#2f4050]">
|
||||
{{ p.title }}
|
||||
</h4>
|
||||
<p class="mt-2 line-clamp-2 min-h-10 text-sm text-[#6a7a88]">
|
||||
{{ p.desc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-3 justify-center">
|
||||
<div v-for="tag in getTags(p.tagname)" :key="tag" :style="{ backgroundColor: getTagColor(tag) }"
|
||||
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(88,108,125,0.16)]">
|
||||
<icon-tag2 class="h-4 w-4" />
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="!pList.length" class="py-14 text-center text-[#7a8797]">
|
||||
暂无友链数据,等你来留下第一张站点名片。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-[26px] border border-white/66 bg-transparent p-5 ring-1 ring-inset ring-[rgba(255,255,255,0.32)] shadow-[0_16px_34px_rgba(116,132,124,0.09)] xl:hidden">
|
||||
<div class="mb-4 flex items-center gap-3 text-primary">
|
||||
<icon-loading class="zhuan h-5 w-5" />
|
||||
<span class="text-base font-semibold">申请友链</span>
|
||||
</div>
|
||||
|
||||
<n-form ref="mobileFormRef" :model="plinkData" :rules="rules" label-placement="top"
|
||||
require-mark-placement="right-hanging">
|
||||
<n-form-item v-for="field in formFields" :key="field.key" :path="field.key">
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-[#526376]">
|
||||
<component :is="field.icon" class="h-4 w-4" />
|
||||
{{ field.label }}
|
||||
</div>
|
||||
</template>
|
||||
<n-input :value="plinkData[field.key]" :type="field.inputType" :rows="field.rows"
|
||||
:maxlength="field.maxlength" :placeholder="field.placeholder"
|
||||
@update:value="updateField(field.key, $event)" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item class="btn">
|
||||
<n-button
|
||||
class="!h-11 !w-full !rounded-full !border-none !bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] !font-semibold shadow-[0_14px_28px_rgba(236,102,171,0.24)]"
|
||||
type="primary" @click="submitPut">
|
||||
提交友链申请
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</section>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import avaIcon from '@/icon/plink/ava.svg';
|
||||
import descIcon from '@/icon/plink/desc.svg';
|
||||
import siteIcon from '@/icon/plink/site.svg';
|
||||
import tagIcon from '@/icon/plink/tag.svg';
|
||||
import urlIcon from '@/icon/plink/uri.svg';
|
||||
import userIcon from '@/icon/plink/user.svg';
|
||||
import { getDictValue } from '@/util';
|
||||
import avaIcon from '@/icon/plink/ava.svg'
|
||||
import descIcon from '@/icon/plink/desc.svg'
|
||||
import siteIcon from '@/icon/plink/site.svg'
|
||||
import tagIcon from '@/icon/plink/tag.svg'
|
||||
import urlIcon from '@/icon/plink/uri.svg'
|
||||
import userIcon from '@/icon/plink/user.svg'
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
definePage({
|
||||
name: 'plink',
|
||||
@ -119,109 +170,263 @@ definePage({
|
||||
title: '友链',
|
||||
}
|
||||
})
|
||||
// 友链页逻辑
|
||||
const boxStyle: any = ref({})
|
||||
const nav: any = $store.nav.useNavStore()
|
||||
const plinkData = reactive<any>({
|
||||
|
||||
interface PlinkFormData {
|
||||
name: string
|
||||
title: string
|
||||
avater: string
|
||||
url: string
|
||||
desc: string
|
||||
tagname: string
|
||||
}
|
||||
|
||||
interface PlinkItem {
|
||||
pid: string | number
|
||||
name: string
|
||||
title: string
|
||||
avater: string
|
||||
url: string
|
||||
desc: string
|
||||
tagname: string
|
||||
}
|
||||
|
||||
interface PlinkListResponse {
|
||||
data: PlinkItem[]
|
||||
}
|
||||
|
||||
interface TagColorItem {
|
||||
tag: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface UserCookieInfo {
|
||||
nickname?: string
|
||||
ava_url?: string
|
||||
}
|
||||
|
||||
type PlinkFieldKey = keyof PlinkFormData
|
||||
|
||||
interface PlinkFormField {
|
||||
key: PlinkFieldKey
|
||||
label: string
|
||||
placeholder: string
|
||||
icon: Component | string
|
||||
inputType?: 'text' | 'textarea'
|
||||
maxlength?: number
|
||||
rows?: number
|
||||
}
|
||||
|
||||
const SOFT_TAG_PALETTE = [
|
||||
'#d46a7c',
|
||||
'#c97956',
|
||||
'#b9894d',
|
||||
'#6f9d86',
|
||||
'#5d92a8',
|
||||
'#7d88b8',
|
||||
'#9a78b5',
|
||||
'#8f7d6b',
|
||||
'#c86b8f',
|
||||
'#cf7f6a',
|
||||
'#a98f55',
|
||||
'#7aa37b',
|
||||
'#5d9f95',
|
||||
'#6d8fc7',
|
||||
'#8a80c4',
|
||||
'#a16fa1',
|
||||
'#b67b67',
|
||||
'#927f72',
|
||||
'#6f8b5f',
|
||||
'#4f8aa8',
|
||||
'#7a96a1',
|
||||
'#b38754',
|
||||
'#a66767',
|
||||
'#7b6aa8',
|
||||
] as const
|
||||
|
||||
const TEXTAREA_AUTOSIZE = {
|
||||
minRows: 1,
|
||||
maxRows: 2,
|
||||
} as const
|
||||
|
||||
const formFields: PlinkFormField[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: '昵称',
|
||||
placeholder: '请输入你的昵称',
|
||||
icon: userIcon,
|
||||
inputType: 'text',
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: '网站名称',
|
||||
placeholder: '请输入你的网站名称',
|
||||
icon: siteIcon,
|
||||
inputType: 'text',
|
||||
},
|
||||
{
|
||||
key: 'url',
|
||||
label: '站点地址',
|
||||
placeholder: '请输入你的网站地址',
|
||||
icon: urlIcon,
|
||||
inputType: 'text',
|
||||
},
|
||||
{
|
||||
key: 'avater',
|
||||
label: '头像链接',
|
||||
placeholder: '请输入你的头像链接',
|
||||
icon: avaIcon,
|
||||
inputType: 'text',
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: '站点介绍',
|
||||
placeholder: '请用一句话简短描述你的站点',
|
||||
icon: descIcon,
|
||||
inputType: 'textarea',
|
||||
maxlength: 50,
|
||||
rows:2
|
||||
},
|
||||
{
|
||||
key: 'tagname',
|
||||
label: '标签',
|
||||
placeholder: '用逗号隔开,每个标签最多 4 个字,只显示前 3 个',
|
||||
icon: tagIcon,
|
||||
inputType: 'textarea',
|
||||
maxlength: 50,
|
||||
rows:2
|
||||
},
|
||||
]
|
||||
|
||||
const boxStyle = ref<Record<string, string>>({})
|
||||
const contentStyle = ref<Record<string, string>>({})
|
||||
const screenWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth)
|
||||
const nav = $store.nav.useNavStore() as { navH: number }
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const mobileFormRef = ref<FormInst | null>(null)
|
||||
const pList = ref<PlinkItem[]>([])
|
||||
const tagColorList = ref<TagColorItem[]>([])
|
||||
|
||||
const plinkData = reactive<PlinkFormData>({
|
||||
name: '',
|
||||
title: '',
|
||||
avater: '',
|
||||
url: '',
|
||||
desc: '',
|
||||
tagname: '',
|
||||
});
|
||||
const rules: any = {
|
||||
name: [{ required: true, message: '昵称不能为空~~', trigger: ['blur'] }],
|
||||
title: [{ required: true, message: '网站名称不能为空~~', trigger: ['blur'] }],
|
||||
url: [{ required: true, message: '站点地址不能为空~~', trigger: ['blur'] }],
|
||||
avater: [{ required: true, message: '头像链接不能为空~~', trigger: ['blur'] }],
|
||||
desc: [{ required: true, message: '站点介绍不能为空~~', trigger: ['blur'] }],
|
||||
tagname: [{ required: true, message: '标签不能为空~~', trigger: ['blur'] }],
|
||||
}
|
||||
const pList = ref<any>([])
|
||||
const formRef = ref<any>(null)
|
||||
const tagColorList: any = ref([])
|
||||
async function getPlinkList() {
|
||||
const res = await $http.plink.getPlinkList()
|
||||
|
||||
const list: Array<string> = []
|
||||
// 对res.data.tags进行去重
|
||||
res.data.forEach((i: any) => {
|
||||
const t = getTags(i.tagname)
|
||||
t.forEach((ii: string) => {
|
||||
if (!list.includes(ii)) list.push(ii)
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
tagColorList.value = list.map((i: string) => {
|
||||
return {
|
||||
tag: i,
|
||||
color: getRandomFromPalette()
|
||||
}
|
||||
})
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '昵称不能为空', trigger: ['blur'] }],
|
||||
title: [{ required: true, message: '网站名称不能为空', trigger: ['blur'] }],
|
||||
url: [{ required: true, message: '站点地址不能为空', trigger: ['blur'] }],
|
||||
avater: [{ required: true, message: '头像链接不能为空', trigger: ['blur'] }],
|
||||
desc: [{ required: true, message: '站点介绍不能为空', trigger: ['blur'] }],
|
||||
tagname: [{ required: true, message: '标签不能为空', trigger: ['blur'] }],
|
||||
}
|
||||
|
||||
function updateLayout() {
|
||||
const viewportHeight = window.innerHeight - nav.navH - 1
|
||||
screenWidth.value = window.innerWidth
|
||||
boxStyle.value = { height: `${viewportHeight}px` }
|
||||
contentStyle.value = { height: `${Math.max(viewportHeight - 48, 280)}px` }
|
||||
}
|
||||
|
||||
function updateField(key: PlinkFieldKey, value: string) {
|
||||
plinkData[key] = value
|
||||
}
|
||||
|
||||
function normalizeTag(tag: string) {
|
||||
return tag.trim().slice(0, 4)
|
||||
}
|
||||
|
||||
function getTags(str: string) {
|
||||
const normalized = (str || '').replace(/,/g, ',')
|
||||
return normalized.split(',').slice(0, 3).map(normalizeTag).filter(Boolean)
|
||||
}
|
||||
|
||||
function buildTagColorMap(tags: string[]): TagColorItem[] {
|
||||
return tags.map((tag, index) => ({
|
||||
tag,
|
||||
color: SOFT_TAG_PALETTE[index % SOFT_TAG_PALETTE.length],
|
||||
}))
|
||||
}
|
||||
|
||||
function getTagColor(tag: string) {
|
||||
return tagColorList.value.find((item) => item.tag === tag)?.color || SOFT_TAG_PALETTE[0]
|
||||
}
|
||||
|
||||
async function getPlinkList() {
|
||||
const res = await $http.plink.getPlinkList() as PlinkListResponse
|
||||
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tagname))))
|
||||
tagColorList.value = buildTagColorMap(uniqueTags)
|
||||
pList.value = res.data
|
||||
}
|
||||
function getTags(str: any) {
|
||||
str = str.replaceAll(',', ',')
|
||||
let tags = str.split(',').slice(0, 3)
|
||||
tags.forEach((tag: any, idx: number) => {
|
||||
tags[idx] = tag.trim().slice(0, 4)
|
||||
});
|
||||
return tags
|
||||
}
|
||||
|
||||
function gotoWebsite(url: string) {
|
||||
if (!url) return
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function submitPut() {
|
||||
formRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
$msg.error('请完善表单~~')
|
||||
} else {
|
||||
const res = await $http.plink.addPlink(plinkData)
|
||||
if (res?.code !== 200) {
|
||||
$msg.error(res.msg)
|
||||
return
|
||||
}
|
||||
$msg.success(res.msg)
|
||||
plinkData.name = ''
|
||||
plinkData.title = ''
|
||||
plinkData.avater = ''
|
||||
plinkData.url = ''
|
||||
plinkData.desc = ''
|
||||
plinkData.tagname = ''
|
||||
getPlinkList()
|
||||
}
|
||||
})
|
||||
function resetFormData() {
|
||||
plinkData.name = ''
|
||||
plinkData.title = ''
|
||||
plinkData.avater = ''
|
||||
plinkData.url = ''
|
||||
plinkData.desc = ''
|
||||
plinkData.tagname = ''
|
||||
}
|
||||
|
||||
// 生成一个随机的鲜艳颜色,和白色能对比
|
||||
|
||||
function getRandomFromPalette(): string {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
return hslToHex(hue, 80, 50);
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): any {
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l) / 100;
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
function fillForm() {
|
||||
const token = $cookies.get('token')
|
||||
if (!token) {
|
||||
$msg.warning('请先登录后再一键填入')
|
||||
return
|
||||
}
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
|
||||
const userinfo = ($cookies.get('userinfo') || {}) as UserCookieInfo
|
||||
plinkData.name = userinfo.nickname || ''
|
||||
plinkData.avater = userinfo.ava_url || ''
|
||||
}
|
||||
|
||||
function getActiveForm() {
|
||||
if (screenWidth.value >= 1280) return formRef.value || mobileFormRef.value
|
||||
return mobileFormRef.value || formRef.value
|
||||
}
|
||||
|
||||
async function submitPut() {
|
||||
const activeForm = getActiveForm()
|
||||
if (!activeForm) return
|
||||
|
||||
try {
|
||||
await activeForm.validate()
|
||||
} catch {
|
||||
$msg.error('请先完善表单内容')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await $http.plink.addPlink(plinkData)
|
||||
if (res?.code !== 200) {
|
||||
$msg.error(res.msg)
|
||||
return
|
||||
}
|
||||
|
||||
$msg.success(res.msg)
|
||||
resetFormData()
|
||||
await getPlinkList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
boxStyle.value = {
|
||||
height: window.innerHeight - nav.navH - 1 + 'px',
|
||||
}
|
||||
getPlinkList()
|
||||
updateLayout()
|
||||
void getPlinkList()
|
||||
window.addEventListener('resize', updateLayout)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateLayout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 无限旋转动画 */
|
||||
<style scoped lang="less">
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@ -236,8 +441,58 @@ onMounted(() => {
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
:deep(.n-form-item) {
|
||||
grid-template-rows: unset !important;
|
||||
}
|
||||
|
||||
:deep(.btn.n-form-item) {
|
||||
.n-form-item-feedback-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.compact-side-form .n-input) {
|
||||
--n-height: 38px !important;
|
||||
}
|
||||
|
||||
:deep(.n-form-item-feedback__line) {
|
||||
text-align: right;
|
||||
font-size: 12px !important;
|
||||
color: #8b96a3;
|
||||
}
|
||||
</style>
|
||||
|
||||
:deep(.n-input),
|
||||
:deep(.n-input .n-input-wrapper),
|
||||
:deep(.n-input .n-input__textarea) {
|
||||
background: rgba(255, 255, 255, 0.72) !important;
|
||||
border-radius: 18px !important;
|
||||
}
|
||||
|
||||
:deep(.n-input) {
|
||||
--n-border: 1px solid rgba(214, 224, 226, 0.92) !important;
|
||||
--n-border-hover: 1px solid rgba(190, 206, 214, 0.98) !important;
|
||||
--n-border-focus: 1px solid rgba(236, 102, 171, 0.52) !important;
|
||||
--n-box-shadow-focus: 0 0 0 4px rgba(236, 102, 171, 0.1) !important;
|
||||
}
|
||||
|
||||
:deep(.n-input__input-el),
|
||||
:deep(.n-input__textarea-el) {
|
||||
color: #314155 !important;
|
||||
}
|
||||
|
||||
:deep(.n-input__placeholder) {
|
||||
color: #95a3b2 !important;
|
||||
}
|
||||
|
||||
:deep(.n-input__placeholder) {
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-form-item-feedback__line) {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,69 +1,151 @@
|
||||
<template>
|
||||
<div class="flex justify-between">
|
||||
<div class="left w-[20%] shadow">
|
||||
<div class="fixed top-20 left-8">
|
||||
<div class="text-center text-3xl text-bold mb-2">目录</div>
|
||||
<MdCatalog :editorId="blogData.aid" class="my-cata" :scrollElement="scrollElement" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<n-breadcrumb class="m-2" separator="|">
|
||||
<n-breadcrumb-item href="/blog/blog">
|
||||
文章
|
||||
</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>
|
||||
{{ blogData.title }}
|
||||
</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
<div class="w-full px-1/20">
|
||||
<!-- <div class="text-center text-[40px] font-bold text-black` mt-10">{{ blogData.title }}</div> -->
|
||||
<div class="my-4 bg-white shadow-lg p-4 rounded-md">
|
||||
<n-collapse @item-header-click="AISum" arrow-placement="right">
|
||||
<n-collapse-item>
|
||||
<template #arrow>
|
||||
<div class=" text-primary ml-2">
|
||||
<!-- <icon-star class="w-6" /> -->
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]"
|
||||
:style="boxStyle"
|
||||
>
|
||||
<div class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]"></div>
|
||||
<div class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]"></div>
|
||||
<div class="pointer-events-none absolute inset-0 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]"></div>
|
||||
|
||||
<div class="relative z-[1] mx-auto flex h-full w-[86%] gap-6 py-6">
|
||||
<aside class="hidden w-[21rem] shrink-0 lg:block">
|
||||
<div class="sticky top-6">
|
||||
<section class="relative rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-6 shadow-[0_20px_50px_rgba(110,124,112,0.11)] backdrop-blur-[20px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-[linear-gradient(120deg,rgba(255,255,255,0.24),transparent_45%)] after:content-['']">
|
||||
<div class="relative z-[1]">
|
||||
<h1 class="mt-3 text-[2rem] text-center leading-[1.2] text-[#2c3a4a]">阅读目录</h1>
|
||||
|
||||
<div class="mt-6 rounded-[22px] border border-white/70 bg-white/58 p-4 shadow-[0_12px_28px_rgba(112,128,118,0.1)]">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-[#526376]">目录</span>
|
||||
<span class="rounded-full cursor-pointer bg-primary px-3 py-1 text-xs text-white">正文导航</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="text-primary flex">
|
||||
<icon-star class="w-6 mx-2" />
|
||||
<div class="text-center text-[20px] font-bold">AI 摘要</div>
|
||||
|
||||
<div class="text-[13px] leading-5 text-[#526376]">
|
||||
<template v-for="item in tocItems" :key="item.id">
|
||||
<details v-if="item.children.length" class="group" :open="item.open">
|
||||
<summary class="flex cursor-pointer list-none items-center gap-2 py-1 text-[#526376] marker:hidden select-none">
|
||||
<span class="text-[11px] text-[#8a98a1] transition-transform duration-200 group-open:rotate-90">▶</span>
|
||||
<span
|
||||
class="truncate transition-colors duration-200"
|
||||
:class="isNodeActive(item) ? 'text-primary font-semibold' : 'hover:text-[#2f4050]'"
|
||||
@click.stop="scrollToHeading(item.id)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="ml-4 border-l border-[#d7dfdf] pl-3">
|
||||
<div v-for="child in item.children" :key="child.id" class="py-1">
|
||||
<div
|
||||
class="cursor-pointer truncate transition-colors duration-200"
|
||||
:class="activeHeadingId === child.id ? 'text-primary font-semibold' : 'text-[#687983] hover:text-[#2f4050]'"
|
||||
@click="scrollToHeading(child.id)"
|
||||
>
|
||||
{{ child.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="cursor-pointer py-1 transition-colors duration-200"
|
||||
:class="activeHeadingId === item.id ? 'text-primary font-semibold' : 'text-[#526376] hover:text-[#2f4050]'"
|
||||
@click="scrollToHeading(item.id)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="tocItems.length === 0" class="px-3 py-2 text-sm text-[#7a8797]">
|
||||
暂无可生成的目录
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="text-primary flex">
|
||||
<icon-star class="w-6 mx-2" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="indent-lg text-gray-500">
|
||||
{{ msg.replaceAll('\\n', '<br>') }}
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex gap-2 justify-center my-4">
|
||||
<em class="text-primary">
|
||||
{{ blogData.nickname }}</em>
|
||||
<em class="time">{{ formatTime(blogData.updated_at, "YYYY年MM月DD日hh时") }}</em>
|
||||
</div>
|
||||
<section class="min-w-0 flex-1 overflow-hidden rounded-[30px] border border-white/70 bg-transparent p-4 ring-1 ring-inset ring-[rgba(255,255,255,0.38)] shadow-[0_20px_46px_rgba(109,126,117,0.1)] sm:p-6">
|
||||
<n-scrollbar ref="detailScrollbar" :style="contentStyle" trigger="none" class="pr-1 mb-4 overflow-hidden">
|
||||
<div class="space-y-5 pr-1">
|
||||
<section class="rounded-[28px] border border-white/68 bg-transparent p-6 ring-1 ring-inset ring-[rgba(255,255,255,0.34)] shadow-[0_18px_38px_rgba(116,132,124,0.09)]">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">文章详情</div>
|
||||
<h1 class="my-4 !w-full ml-1/2 text-center text-[1.9rem] font-semibold leading-[1.3] text-[#2f4050] sm:text-[2.15rem]">
|
||||
{{ blogData.title || '文章内容' }}
|
||||
</h1>
|
||||
</div>
|
||||
<n-breadcrumb class="shrink-0 rounded-full border border-white/65 bg-transparent px-4 text-[#526376]" separator="|">
|
||||
<n-breadcrumb-item href="/blog/blog">文章</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ blogData.title }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-right gap-x-5 gap-y-2 text-sm text-[#708091]">
|
||||
<em class="flex items-center gap-1 not-italic text-primary">
|
||||
<n-icon><icon-author /></n-icon>
|
||||
{{ blogData.nickname }}
|
||||
</em>
|
||||
<em class="flex items-center gap-1 not-italic">
|
||||
<n-icon><icon-date /></n-icon>
|
||||
{{ formatTime(blogData.updated_at, 'YYYY年MM月DD日 hh时') }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4 justify-center my-4">
|
||||
<div v-for="item in tags" :key="item.tid" class="flex items-center gap-1 text-primary cursor-pointer">
|
||||
<n-icon><icon-tag /></n-icon>
|
||||
{{ item }}
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-3">
|
||||
<div
|
||||
v-for="item in tags"
|
||||
:key="item"
|
||||
:style="{ backgroundColor: getTagColor(item) }"
|
||||
class="inline-flex cursor-pointer items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(88,108,125,0.16)]"
|
||||
>
|
||||
<n-icon><icon-tag /></n-icon>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[26px] border border-white/66 bg-transparent p-4 ring-1 ring-inset ring-[rgba(255,255,255,0.32)] shadow-[0_16px_34px_rgba(116,132,124,0.09)] sm:p-5">
|
||||
<n-collapse @item-header-click="AISum" arrow-placement="right">
|
||||
<n-collapse-item>
|
||||
<template #header>
|
||||
<div class="flex items-center text-primary">
|
||||
<icon-star class="mx-2 w-6" />
|
||||
<div class="text-[20px] font-bold">AI 摘要</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="flex text-primary">
|
||||
<icon-star class="mx-2 w-6" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="whitespace-pre-line indent-[2em] text-[#66778b]">
|
||||
{{ msg }}
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[26px] border border-white/66 bg-transparent px-5 py-6 ring-1 ring-inset ring-[rgba(255,255,255,0.32)] shadow-[0_18px_36px_rgba(116,132,124,0.09)] sm:px-8 sm:py-8">
|
||||
<MdPreview
|
||||
class="relative article-preview !bg-transparent"
|
||||
:modelValue="markdown"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MdPreview ref="mdp" theme="light" class="relative " :editorId="blogData.aid" previewTheme="my"
|
||||
:modelValue="markdown" />
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatTime } from '@/util';
|
||||
import type { ExposeParam } from 'md-editor-v3';
|
||||
import { MdPreview } from 'md-editor-v3';
|
||||
import type { ScrollbarInst as NScrollbarInst } from 'naive-ui';
|
||||
|
||||
definePage({
|
||||
name: 'blog/:bid',
|
||||
path: '/blog/:bid',
|
||||
@ -72,153 +154,403 @@ definePage({
|
||||
}
|
||||
})
|
||||
|
||||
import { formatTime } from '@/util';
|
||||
import type { ExposeParam } from 'md-editor-v3';
|
||||
import { MdCatalog, MdPreview } from 'md-editor-v3';
|
||||
|
||||
const editorRef = ref<ExposeParam>();
|
||||
const scrollElement: HTMLElement = document.querySelector('.n-scrollbar .n-scrollbar-container') as HTMLElement;
|
||||
|
||||
const tags = ref<any[]>([])
|
||||
const route: any = useRoute()
|
||||
|
||||
const msg = ref<any>('...')
|
||||
const aimask = ref(false)
|
||||
|
||||
const markdown = ref('');
|
||||
const blogData = ref<any>({
|
||||
aid: 'preview-only',
|
||||
updated_at: Date.now()
|
||||
})
|
||||
async function getBlogDetail() {
|
||||
const bid: any = route.params?.bid || 0
|
||||
// console.log('>>> --> getBlogDetail --> bid:', route, bid)
|
||||
const res = await $http.blog.getBlogDetail(bid)
|
||||
console.log('>>> --> getBlogDetail --> res:', res.data)
|
||||
blogData.value = res.data
|
||||
let ts = res.data.tags
|
||||
ts = ts.replaceAll(',', ',')
|
||||
tags.value = ts.split(',')
|
||||
editorRef.value?.toggleCatalog(true)
|
||||
markdown.value = res.data.cont
|
||||
// AISum()
|
||||
interface BlogDetail {
|
||||
aid: string
|
||||
updated_at: string | number
|
||||
title: string
|
||||
nickname: string
|
||||
tags: string
|
||||
cont: string
|
||||
}
|
||||
|
||||
// ai总结
|
||||
interface BlogDetailResponse {
|
||||
data: BlogDetail
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
bid?: string
|
||||
}
|
||||
|
||||
interface TocItem {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
children: TocChildItem[]
|
||||
open: boolean
|
||||
}
|
||||
|
||||
interface TocChildItem {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
interface DetailScrollbarInst extends NScrollbarInst {
|
||||
scrollbarInstRef?: {
|
||||
containerRef: HTMLElement | null
|
||||
contentRef: HTMLElement | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const SOFT_TAG_PALETTE = [
|
||||
'#d46a7c',
|
||||
'#c97956',
|
||||
'#b9894d',
|
||||
'#6f9d86',
|
||||
'#5d92a8',
|
||||
'#7d88b8',
|
||||
'#9a78b5',
|
||||
'#8f7d6b',
|
||||
'#c86b8f',
|
||||
'#cf7f6a',
|
||||
'#a98f55',
|
||||
'#7aa37b',
|
||||
'#5d9f95',
|
||||
'#6d8fc7',
|
||||
'#8a80c4',
|
||||
'#a16fa1',
|
||||
'#b67b67',
|
||||
'#927f72',
|
||||
'#6f8b5f',
|
||||
'#4f8aa8',
|
||||
'#7a96a1',
|
||||
'#b38754',
|
||||
'#a66767',
|
||||
'#7b6aa8',
|
||||
] as const
|
||||
|
||||
const editorRef = ref<ExposeParam>()
|
||||
const detailScrollbar = ref<DetailScrollbarInst | null>(null)
|
||||
const scrollElement = ref<HTMLElement | undefined>(undefined)
|
||||
const activeHeadingId = ref('')
|
||||
const headingElementMap = shallowRef(new Map<string, HTMLElement>())
|
||||
const route = useRoute()
|
||||
const nav = $store.nav.useNavStore() as { navH: number }
|
||||
|
||||
const boxStyle = ref<Record<string, string>>({})
|
||||
const contentStyle = ref<Record<string, string>>({})
|
||||
const tags = ref<string[]>([])
|
||||
const msg = ref('展开后可生成摘要...')
|
||||
const aimask = ref(false)
|
||||
const markdown = ref('')
|
||||
const blogData = ref<BlogDetail>({
|
||||
aid: 'preview-only',
|
||||
updated_at: Date.now(),
|
||||
title: '',
|
||||
nickname: '',
|
||||
tags: '',
|
||||
cont: '',
|
||||
})
|
||||
|
||||
function getTagColor(tag: string) {
|
||||
const normalized = tag.trim()
|
||||
if (!normalized) return SOFT_TAG_PALETTE[0]
|
||||
|
||||
let hash = 0
|
||||
for (const char of normalized) {
|
||||
hash = (hash * 31 + char.charCodeAt(0)) % SOFT_TAG_PALETTE.length
|
||||
}
|
||||
|
||||
return SOFT_TAG_PALETTE[Math.abs(hash) % SOFT_TAG_PALETTE.length]
|
||||
}
|
||||
|
||||
const tocItems = computed<TocItem[]>(() => {
|
||||
const lines = (blogData.value.cont || '').split(/\r?\n/)
|
||||
const flatItems: TocChildItem[] = []
|
||||
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+?)\s*$/)
|
||||
if (!match) return
|
||||
|
||||
const level = match[1].length
|
||||
const text = match[2].replace(/[`*_~]/g, '').trim()
|
||||
if (!text) return
|
||||
|
||||
flatItems.push({
|
||||
id: createHeadingId(text, flatItems.length),
|
||||
text,
|
||||
level,
|
||||
})
|
||||
})
|
||||
|
||||
const tree: TocItem[] = []
|
||||
let currentParent: TocItem | null = null
|
||||
|
||||
flatItems.forEach((item) => {
|
||||
if (item.level <= 2 || !currentParent) {
|
||||
currentParent = {
|
||||
...item,
|
||||
children: [],
|
||||
open: true,
|
||||
}
|
||||
tree.push(currentParent)
|
||||
return
|
||||
}
|
||||
|
||||
currentParent.children.push(item)
|
||||
})
|
||||
|
||||
return tree
|
||||
})
|
||||
|
||||
function updateLayout() {
|
||||
const viewportHeight = window.innerHeight - nav.navH - 1
|
||||
boxStyle.value = { height: `${viewportHeight}px` }
|
||||
contentStyle.value = { height: `${Math.max(viewportHeight - 40, 320)}px` }
|
||||
}
|
||||
|
||||
function createHeadingId(text: string, index: number) {
|
||||
return `blog-heading-${index}-${text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')}`
|
||||
}
|
||||
|
||||
function isNodeActive(item: TocItem) {
|
||||
return activeHeadingId.value === item.id || item.children.some(child => child.id === activeHeadingId.value)
|
||||
}
|
||||
|
||||
function getScrollbarContainer() {
|
||||
return detailScrollbar.value?.scrollbarInstRef?.containerRef || null
|
||||
}
|
||||
|
||||
function getScrollbarContent() {
|
||||
return detailScrollbar.value?.scrollbarInstRef?.contentRef || null
|
||||
}
|
||||
|
||||
function getPreviewRoot() {
|
||||
return getScrollbarContent()?.querySelector('.article-preview') as HTMLElement | null
|
||||
}
|
||||
|
||||
async function syncScrollElement() {
|
||||
await nextTick()
|
||||
scrollElement.value = getScrollbarContainer() || undefined
|
||||
requestAnimationFrame(() => {
|
||||
syncPreviewHeadings()
|
||||
})
|
||||
}
|
||||
|
||||
function syncPreviewHeadings() {
|
||||
const previewRoot = getPreviewRoot()
|
||||
if (!previewRoot) return
|
||||
|
||||
const headings = previewRoot.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
|
||||
const flatTocItems = tocItems.value.flatMap(item => [item, ...item.children])
|
||||
const nextHeadingMap = new Map<string, HTMLElement>()
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
const item = flatTocItems[index]
|
||||
if (item) {
|
||||
heading.id = item.id
|
||||
heading.style.scrollMarginTop = '24px'
|
||||
nextHeadingMap.set(item.id, heading)
|
||||
}
|
||||
})
|
||||
|
||||
headingElementMap.value = nextHeadingMap
|
||||
updateActiveHeading()
|
||||
}
|
||||
|
||||
function updateActiveHeading() {
|
||||
const container = scrollElement.value
|
||||
const previewRoot = getPreviewRoot()
|
||||
if (!container || !previewRoot) return
|
||||
|
||||
const headings = previewRoot.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
|
||||
if (!headings.length) {
|
||||
activeHeadingId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const flatTocItems = tocItems.value.flatMap(item => [item, ...item.children])
|
||||
let currentId = flatTocItems[0]?.id || ''
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const rect = heading.getBoundingClientRect()
|
||||
if (rect.top - containerRect.top <= 80 && heading.id) {
|
||||
currentId = heading.id
|
||||
}
|
||||
})
|
||||
|
||||
activeHeadingId.value = currentId
|
||||
}
|
||||
|
||||
function scrollToHeading(id: string) {
|
||||
const container = scrollElement.value
|
||||
const target = headingElementMap.value.get(id) || null
|
||||
if (!container || !target) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
const nextTop = container.scrollTop + (targetRect.top - containerRect.top) - 24
|
||||
|
||||
detailScrollbar.value?.scrollTo?.({
|
||||
top: Math.max(0, nextTop),
|
||||
behavior: 'smooth',
|
||||
})
|
||||
activeHeadingId.value = id
|
||||
}
|
||||
|
||||
watch([markdown, tocItems], async () => {
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
syncPreviewHeadings()
|
||||
})
|
||||
})
|
||||
|
||||
function parseTags(str: string) {
|
||||
const normalized = (str || '').replace(/,/g, ',')
|
||||
return normalized.split(',').map(tag => tag.trim().slice(0, 4)).filter(Boolean)
|
||||
}
|
||||
|
||||
async function getBlogDetail() {
|
||||
const bid = (route.params as RouteParams).bid || '0'
|
||||
const res = await $http.blog.getBlogDetail(bid) as BlogDetailResponse
|
||||
blogData.value = res.data
|
||||
tags.value = parseTags(res.data.tags)
|
||||
markdown.value = res.data.cont
|
||||
await syncScrollElement()
|
||||
}
|
||||
|
||||
async function AISum() {
|
||||
console.log('>>> --> AISum --> AISum:', AISum)
|
||||
if (aimask.value) return
|
||||
const response = await fetch('https://api.siliconflow.cn/v1/chat/completions', {
|
||||
if (aimask.value || !blogData.value.cont) return
|
||||
|
||||
const response = await fetch('http://38.12.26.19:8317/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer sk-jwwmhmxsjtseyekknqmamlvzmrkmwfvuacnssbwfufogrkdg'
|
||||
Authorization: 'Bearer sk-7wys75B2TsQYceMbZ'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "Qwen/Qwen3-8B",
|
||||
model: 'gpt-5.4',
|
||||
messages: [
|
||||
{ role: 'system', content: "你是一个摘要生成工具,你需要解释我发送给你的内容,不要换行,不要超过400字,只需要介绍文章的内容,不需要提出建议和缺少的东西。请用中文回答,内容添加表情包,输出的内容开头为“这篇文章介绍了”,所有句号试用“喵~~ 。”代替" },
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个摘要生成工具,请用中文总结我发送给你的文章内容,不要换行,不要超过300字,只介绍文章内容,不需要提出建议。开头固定为“这篇文章介绍了”,语气自然一些。'
|
||||
},
|
||||
{ role: 'user', content: blogData.value.cont }
|
||||
],
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
console.log('>>> --> AISum --> response:', response)
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
if (!reader) return
|
||||
|
||||
msg.value = ''
|
||||
let done = false
|
||||
while (!done) {
|
||||
const { value, done: doneReading } = await reader.read()
|
||||
done = doneReading
|
||||
const chunk = decoder.decode(value)
|
||||
const chunk = decoder.decode(value ?? new Uint8Array())
|
||||
const match = chunk.match(/"content":"(.*?)"/)
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
msg.value += match[1]
|
||||
aimask.value = true
|
||||
}
|
||||
}
|
||||
// const data = await response.json()
|
||||
|
||||
// console.log('>>> --> AISum --> data:', data)
|
||||
}
|
||||
onMounted(() => {
|
||||
getBlogDetail()
|
||||
|
||||
onMounted(async () => {
|
||||
updateLayout()
|
||||
await getBlogDetail()
|
||||
scrollElement.value?.addEventListener('scroll', updateActiveHeading)
|
||||
window.addEventListener('resize', updateLayout)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
scrollElement.value?.removeEventListener('scroll', updateActiveHeading)
|
||||
window.removeEventListener('resize', updateLayout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import "@/assets/myblog.less";
|
||||
<style scoped lang="less">
|
||||
:deep(.article-preview .md-editor-preview) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.my-cata {
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.article-preview .md-editor-preview-wrapper) {
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.md-editor-catalog-indicator {
|
||||
display: none;
|
||||
}
|
||||
:deep(.article-preview .md-editor) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.md-editor-catalog-active>span {
|
||||
padding: 5px 15px !important;
|
||||
border-radius: 30px;
|
||||
color: white;
|
||||
background-color: @primary;
|
||||
position: relative;
|
||||
:deep(.article-preview .md-editor-content),
|
||||
:deep(.article-preview .md-editor-preview-theme),
|
||||
:deep(.article-preview .md-editor-previewOnly),
|
||||
:deep(.article-preview .md-editor-previewOnly .md-editor-preview) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "✦";
|
||||
position: static !important;
|
||||
transform: none !important;
|
||||
margin-left: 12px !important;
|
||||
flex-shrink: 0;
|
||||
display: inline-block !important;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
// font-family: Arial, sans-serif;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
z-index: 10;
|
||||
animation: outline-twinkle 1.5s infinite alternate;
|
||||
}
|
||||
}
|
||||
:deep(.article-preview .md-editor-toolbar),
|
||||
:deep(.article-preview .md-editor-footer),
|
||||
:deep(.article-preview .md-editor-catalog) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.md-editor-catalog-link {
|
||||
padding-block: 1px !important;
|
||||
}
|
||||
:deep(.n-collapse),
|
||||
:deep(.n-collapse-item),
|
||||
:deep(.n-collapse-item__header),
|
||||
:deep(.n-collapse-item__content-wrapper),
|
||||
:deep(.n-collapse-item__content-inner) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.md-editor-catalog-wrapper span {
|
||||
padding: 5px 15px !important;
|
||||
transition: all 0.6s ease-in-out;
|
||||
:deep(.article-preview .md-editor-previewOnly) {
|
||||
font-size: 16px;
|
||||
color: #425466;
|
||||
line-height: 1.95;
|
||||
}
|
||||
|
||||
:deep(.article-preview h1),
|
||||
:deep(.article-preview h2),
|
||||
:deep(.article-preview h3),
|
||||
:deep(.article-preview h4),
|
||||
:deep(.article-preview h5),
|
||||
:deep(.article-preview h6) {
|
||||
color: #2f4050;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.7em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
padding: 5px 15px !important;
|
||||
border-radius: 30px;
|
||||
color: white;
|
||||
background-color: @primary;
|
||||
}
|
||||
}
|
||||
:deep(.article-preview p),
|
||||
:deep(.article-preview li),
|
||||
:deep(.article-preview blockquote) {
|
||||
color: #5f6f7f;
|
||||
line-height: 1.95;
|
||||
}
|
||||
|
||||
:deep(.article-preview p) {
|
||||
margin: 0.95em 0;
|
||||
}
|
||||
|
||||
:deep(.article-preview blockquote) {
|
||||
border-left: 4px solid rgba(236, 102, 171, 0.32);
|
||||
background: transparent;
|
||||
border-radius: 0 16px 16px 0;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
:deep(.article-preview pre) {
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 14px 30px rgba(88, 108, 125, 0.12);
|
||||
}
|
||||
|
||||
:deep(.article-preview img) {
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 30px rgba(88, 108, 125, 0.12);
|
||||
}
|
||||
|
||||
:deep(.article-preview table) {
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 文章页样式 */
|
||||
// :deep(.md img) {
|
||||
// width: auto !important;
|
||||
// height: 200px !important;
|
||||
// }
|
||||
|
||||
// :deep(.md-editor-catalog-active>span) {
|
||||
// color: @primary;
|
||||
// }
|
||||
|
||||
// :deep(.md-editor-catalog-indicator) {
|
||||
// background-color: @primary;
|
||||
// }
|
||||
|
||||
// :deep(.md-editor-catalog-link span):hover {
|
||||
// color: @primary;
|
||||
// }</style>
|
||||
@ -1,59 +1,148 @@
|
||||
<template>
|
||||
<div class="article-page w-full flex" :style="boxStyle">
|
||||
<div class="w-1/4 shadow relative">
|
||||
<div class="card w-2/3 absolute top-16 left-1/6">
|
||||
<div @click="clickCate(i, idx)" v-for="(i, idx) in cateList" :key="i.cid"
|
||||
:class="{ '!bg-primary text-white': idx == currentCateIdx }"
|
||||
class="relative cate flex my-6 py-6 px-8 justify-between text-xl font-bold bg-white shadow rounded cursor-pointer">
|
||||
<div class="flex items-center">
|
||||
<icon-arti class="w-5 h-5 text-primary mr-3" :class="{ 'text-white': idx == currentCateIdx }"></icon-arti>
|
||||
<span>{{ i.name }}</span>
|
||||
</div>
|
||||
<div class="bg-primary px-4 rounded-full text-white ext-sm flex items-center gap-2">
|
||||
{{ i.total || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="blog-list w-3/4 mt-10">
|
||||
<div v-show="blogList.length == 0" class="ml-50 text-gray-500 mt-20">
|
||||
什么都没有呢~ ~
|
||||
</div>
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(215,177,123,0.24),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(120,162,182,0.18),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(176,198,172,0.14),transparent_28%),linear-gradient(180deg,#efe7da_0%,#e8f0ef_44%,#f3f1e8_100%)]"
|
||||
:style="boxStyle"
|
||||
>
|
||||
<div class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(234,176,107,0.45)] opacity-48 blur-[88px]"></div>
|
||||
<div class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(118,176,214,0.32)] opacity-48 blur-[88px]"></div>
|
||||
<div class="pointer-events-none absolute inset-0 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]"></div>
|
||||
|
||||
<div v-for="item in blogList" :key="item.aid" class="rounded-lg p-4 w-[80%] ml-1/10 my-8 shadow cursor-pointer"
|
||||
@click="$router.push(`/blog/${item.aid}`)">
|
||||
<div class="flex justify-right gap-4 mb-2 text-gray-600">
|
||||
<em class="flex items-center gap-1 text-sm">
|
||||
<n-icon><icon-date /></n-icon>
|
||||
{{ formatTime(item.updated_at, 'YYYY年MM月DD日') }}
|
||||
</em>
|
||||
<em class="flex items-center gap-1 text-sm">
|
||||
<n-icon><icon-pen /></n-icon>
|
||||
{{ getSize(item.cont) }} 字
|
||||
</em>
|
||||
<em class="flex items-center gap-1 text-sm ">
|
||||
<n-icon><icon-author /></n-icon>
|
||||
{{ item.nickname }}</em>
|
||||
<div class="relative z-[1] mx-auto flex h-full w-[86%] gap-6 py-6">
|
||||
<aside class="hidden w-[21rem] shrink-0 lg:block">
|
||||
<div class="sticky top-6">
|
||||
<section class="relative rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-6 shadow-[0_20px_50px_rgba(110,124,112,0.11)] backdrop-blur-[20px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-[linear-gradient(120deg,rgba(255,255,255,0.24),transparent_45%)] after:content-['']">
|
||||
<div class="relative z-[1]">
|
||||
<h1 class="mt-3 text-[2rem] text-center leading-[1.2] text-[#2c3a4a]">文章分类</h1>
|
||||
<div class="mt-6 grid gap-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="clearCate"
|
||||
class="flex items-center justify-between rounded-[22px] border px-4 py-4 text-left transition-[transform,box-shadow,border-color,background-color,color] duration-300 [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]"
|
||||
:class="currentCateIdx === -1
|
||||
? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white shadow-[0_16px_34px_rgba(236,102,171,0.24)]'
|
||||
: 'border-white/70 bg-white/66 text-[#435468] shadow-[0_12px_28px_rgba(112,128,118,0.1)] hover:-translate-y-0.5 hover:border-white/90 hover:bg-white/76'"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<icon-arti class="h-5 w-5" :class="currentCateIdx === -1 ? 'text-white' : 'text-primary'" />
|
||||
<span class="text-base font-semibold">全部文章</span>
|
||||
</span>
|
||||
<span class="rounded-full px-3 py-1 text-sm font-semibold" :class="currentCateIdx === -1 ? 'bg-white/20 text-white' : 'bg-primary text-white'">
|
||||
{{ allTotal }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="(item, idx) in cateList"
|
||||
:key="item.cid"
|
||||
type="button"
|
||||
@click="clickCate(item, idx)"
|
||||
class="flex items-center justify-between rounded-[22px] border px-4 py-4 text-left transition-[transform,box-shadow,border-color,background-color,color] duration-300 [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]"
|
||||
:class="currentCateIdx === idx
|
||||
? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white shadow-[0_16px_34px_rgba(236,102,171,0.24)]'
|
||||
: 'border-white/70 bg-white/66 text-[#435468] shadow-[0_12px_28px_rgba(112,128,118,0.1)] hover:-translate-y-0.5 hover:border-white/90 hover:bg-white/76'"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<icon-arti class="h-5 w-5 shrink-0" :class="currentCateIdx === idx ? 'text-white' : 'text-primary'" />
|
||||
<span class="truncate text-base font-semibold">{{ item.name }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 rounded-full px-3 py-1 text-sm font-semibold" :class="currentCateIdx === idx ? 'bg-white/20 text-white' : 'bg-primary text-white'">
|
||||
{{ item.total || 0 }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="text-xl font-bold text-primary mb-2">{{ item.title }}</div>
|
||||
<div class="text-sm text-gray-500 mb-4 line-clamp-3">{{ item.pro }}</div>
|
||||
<div class="flex gap-4">
|
||||
<div v-for="i in getTags(item.tags)" :key="i.tid"
|
||||
:style="{ backgroundColor: getDictValue(tagColorList, 'tag', i, 'color') }"
|
||||
class="flex items-center gap-1 cursor-pointer text-white rounded-full px-3">
|
||||
<icon-tag2 class="w-4 h-4" />{{ i }}
|
||||
</aside>
|
||||
|
||||
<section class="min-w-0 flex-1 rounded-[30px] border border-white/62 bg-white/42 p-4 shadow-[0_18px_42px_rgba(109,126,117,0.1)] backdrop-blur-[18px] sm:p-6">
|
||||
<div class="mb-5 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">文章列表</div>
|
||||
<div class="mt-2 text-[1.6rem] font-semibold text-[#2f4050]">{{ currentCateLabel }}</div>
|
||||
</div>
|
||||
<div class="inline-flex items-center whitespace-nowrap rounded-full border border-white/80 bg-white/68 px-4 py-[0.65rem] text-[#526376]">
|
||||
当前 {{ blogList.length }} 篇
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="blogList.length > page_size" class="mt-20 w-[80%] ml-1/10 cursor-pointer flex justify-center">
|
||||
<n-pagination v-model:page="page_num" :page-count="total" :page-slot="5" @update:page="getBlogList" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-wrap gap-2 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="clearCate"
|
||||
class="rounded-full border px-4 py-2 text-sm font-semibold transition-colors duration-200"
|
||||
:class="currentCateIdx === -1 ? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white' : 'border-white/80 bg-white/70 text-[#526376]'"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="(item, idx) in cateList"
|
||||
:key="item.cid"
|
||||
type="button"
|
||||
@click="clickCate(item, idx)"
|
||||
class="rounded-full border px-4 py-2 text-sm font-semibold transition-colors duration-200"
|
||||
:class="currentCateIdx === idx ? 'border-transparent bg-[linear-gradient(135deg,#ec66ab,#f48a6f)] text-white' : 'border-white/80 bg-white/70 text-[#526376]'"
|
||||
>
|
||||
{{ item.name }} · {{ item.total || 0 }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<n-scrollbar :style="listStyle" trigger="none" class="pr-1">
|
||||
<div class="space-y-5">
|
||||
<div v-if="blogList.length === 0" class="rounded-[24px] border border-dashed border-white/75 bg-white/48 px-6 py-16 text-center text-[#7a8797]">
|
||||
暂时还没有相关文章。
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-for="item in blogList"
|
||||
:key="item.aid"
|
||||
class="cursor-pointer rounded-[26px] border border-white/72 bg-[linear-gradient(145deg,rgba(255,251,246,0.78),rgba(237,246,244,0.62))] p-6 shadow-[0_16px_34px_rgba(116,132,124,0.12)] backdrop-blur-[18px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1 hover:border-white/90 hover:shadow-[0_22px_42px_rgba(102,123,112,0.16)]"
|
||||
@click="$router.push(`/blog/${item.aid}`)"
|
||||
>
|
||||
<div class="flex flex-wrap justify-end gap-x-4 gap-y-2 text-sm text-[#708091]">
|
||||
<em class="flex items-center gap-1 not-italic">
|
||||
<n-icon><icon-date /></n-icon>
|
||||
{{ formatTime(item.updated_at, 'YYYY年MM月DD日') }}
|
||||
</em>
|
||||
<em class="flex items-center gap-1 not-italic">
|
||||
<n-icon><icon-pen /></n-icon>
|
||||
{{ getSize(item.cont) }} 字
|
||||
</em>
|
||||
<em class="flex items-center gap-1 not-italic">
|
||||
<n-icon><icon-author /></n-icon>
|
||||
{{ item.nickname }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-4 text-[1.45rem] font-semibold leading-[1.35] text-primary">{{ item.title }}</h2>
|
||||
<p class="mt-3 line-clamp-3 leading-[1.85] text-[#66778b]">{{ item.pro }}</p>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="tag in getTags(item.tags)"
|
||||
:key="tag"
|
||||
:style="{ backgroundColor: getTagColor(tag) }"
|
||||
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(88,108,125,0.16)]"
|
||||
>
|
||||
<icon-tag2 class="h-4 w-4" />
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="blogList.length > page_size" class="mt-8 flex justify-center rounded-[22px] border border-white/70 bg-white/52 px-4 py-5 shadow-[0_10px_24px_rgba(116,132,124,0.08)]">
|
||||
<n-pagination v-model:page="page_num" :page-count="total" :page-slot="5" @update:page="getBlogList" />
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatTime, getDictValue } from '@/util'
|
||||
import { formatTime } from '@/util'
|
||||
|
||||
definePage({
|
||||
name: 'blog',
|
||||
meta: {
|
||||
@ -61,124 +150,168 @@ definePage({
|
||||
}
|
||||
})
|
||||
|
||||
const blogList = ref<any[]>([])
|
||||
const boxStyle = ref({})
|
||||
const nav: any = $store.nav.useNavStore()
|
||||
const cateList = ref<any[]>([])
|
||||
interface CateItem {
|
||||
cid: string
|
||||
name: string
|
||||
total: number
|
||||
}
|
||||
|
||||
interface BlogItem {
|
||||
aid: number | string
|
||||
title: string
|
||||
pro: string
|
||||
cont: string
|
||||
tags: string
|
||||
nickname: string
|
||||
updated_at: string | number
|
||||
}
|
||||
|
||||
interface BlogListResponse {
|
||||
data: BlogItem[]
|
||||
}
|
||||
|
||||
interface CateListResponse {
|
||||
data: CateItem[]
|
||||
}
|
||||
|
||||
interface TagColorItem {
|
||||
tag: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const SOFT_TAG_PALETTE = [
|
||||
'#d46a7c',
|
||||
'#c97956',
|
||||
'#b9894d',
|
||||
'#6f9d86',
|
||||
'#5d92a8',
|
||||
'#7d88b8',
|
||||
'#9a78b5',
|
||||
'#8f7d6b',
|
||||
'#c86b8f',
|
||||
'#cf7f6a',
|
||||
'#a98f55',
|
||||
'#7aa37b',
|
||||
'#5d9f95',
|
||||
'#6d8fc7',
|
||||
'#8a80c4',
|
||||
'#a16fa1',
|
||||
'#b67b67',
|
||||
'#927f72',
|
||||
'#6f8b5f',
|
||||
'#4f8aa8',
|
||||
'#7a96a1',
|
||||
'#b38754',
|
||||
'#a66767',
|
||||
'#7b6aa8',
|
||||
] as const
|
||||
|
||||
const blogList = ref<BlogItem[]>([])
|
||||
const boxStyle = ref<Record<string, string>>({})
|
||||
const listStyle = ref<Record<string, string>>({})
|
||||
const nav = $store.nav.useNavStore() as { navH: number }
|
||||
const cateList = ref<CateItem[]>([])
|
||||
const currentCateIdx = ref(-1)
|
||||
const page_size = ref(5)
|
||||
const page_num = ref(1)
|
||||
const total = ref(0)
|
||||
const category = ref('')
|
||||
const tagColorList = ref<TagColorItem[]>([])
|
||||
|
||||
const tagColorList: any = ref([])
|
||||
const allTotal = computed(() => cateList.value.reduce((acc, cur) => acc + (cur.total || 0), 0))
|
||||
|
||||
function clickCate(item: any, idx: number) {
|
||||
if (currentCateIdx.value == idx) {
|
||||
currentCateIdx.value = -1
|
||||
category.value = ''
|
||||
getBlogList()
|
||||
total.value = cateList.value.reduce((acc: any, cur: any) => acc + cur.total, 0)
|
||||
const currentCateLabel = computed(() => {
|
||||
if (currentCateIdx.value === -1) return '全部文章'
|
||||
return cateList.value[currentCateIdx.value]?.name || '文章列表'
|
||||
})
|
||||
|
||||
|
||||
function updateLayout() {
|
||||
const viewportHeight = window.innerHeight - nav.navH - 1
|
||||
boxStyle.value = { height: `${viewportHeight}px` }
|
||||
listStyle.value = { height: `${Math.max(viewportHeight - 124, 280)}px` }
|
||||
}
|
||||
|
||||
function resetPaging() {
|
||||
page_num.value = 1
|
||||
}
|
||||
|
||||
function clearCate() {
|
||||
currentCateIdx.value = -1
|
||||
category.value = ''
|
||||
resetPaging()
|
||||
total.value = allTotal.value
|
||||
void getBlogList()
|
||||
}
|
||||
|
||||
function clickCate(item: CateItem, idx: number) {
|
||||
if (currentCateIdx.value === idx) {
|
||||
clearCate()
|
||||
return
|
||||
}
|
||||
|
||||
currentCateIdx.value = idx
|
||||
category.value = item.cid
|
||||
getBlogList()
|
||||
resetPaging()
|
||||
total.value = item.total
|
||||
console.log('>>> --> clickCate --> total.value:', total.value)
|
||||
void getBlogList()
|
||||
}
|
||||
|
||||
function normalizeTag(tag: string) {
|
||||
return tag.trim().slice(0, 4)
|
||||
}
|
||||
|
||||
function getTags(str: string) {
|
||||
const normalized = (str || '').replace(/,/g, ',')
|
||||
return normalized.split(',').slice(0, 5).map(normalizeTag).filter(Boolean)
|
||||
}
|
||||
|
||||
function buildTagColorMap(tags: string[]): TagColorItem[] {
|
||||
return tags.map((tag, index) => ({
|
||||
tag,
|
||||
color: getRandomFromPalette(index),
|
||||
}))
|
||||
}
|
||||
|
||||
function getTagColor(tag: string) {
|
||||
return tagColorList.value.find((item) => item.tag === tag)?.color || SOFT_TAG_PALETTE[0]
|
||||
}
|
||||
|
||||
function getSize(str: string) {
|
||||
const reg = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u00AD\u0600-\u0604\u070F\u17B4\u17B5\u200B-\u200D\u3000]/g
|
||||
return (str || '').replace(reg, '').length
|
||||
}
|
||||
|
||||
function getRandomFromPalette(index = 0): string {
|
||||
return SOFT_TAG_PALETTE[index % SOFT_TAG_PALETTE.length]
|
||||
}
|
||||
|
||||
async function getBlogList() {
|
||||
const res = await $http.blog.getBlogList({
|
||||
category: category.value, page_size: page_size.value, page_num: page_num.value
|
||||
})
|
||||
|
||||
const list: Array<string> = []
|
||||
// 对res.data.tags进行去重
|
||||
res.data.forEach((i: any) => {
|
||||
const t = getTags(i.tags)
|
||||
t.forEach((ii: string) => {
|
||||
if (!list.includes(ii)) list.push(ii)
|
||||
})
|
||||
});
|
||||
|
||||
tagColorList.value = list.map((i: string) => {
|
||||
return {
|
||||
tag: i,
|
||||
color: getRandomFromPalette()
|
||||
}
|
||||
})
|
||||
category: category.value,
|
||||
page_size: page_size.value,
|
||||
page_num: page_num.value,
|
||||
}) as BlogListResponse
|
||||
|
||||
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tags))))
|
||||
tagColorList.value = buildTagColorMap(uniqueTags)
|
||||
blogList.value = res.data
|
||||
}
|
||||
// 计算markdown的文字数量
|
||||
function getSize(str: string) {
|
||||
const reg = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u00AD\u0600-\u0604\u070F\u17B4\u17B5\u200B-\u200D\u3000]/g;
|
||||
return str.replace(reg, '').length;
|
||||
|
||||
async function getCateList() {
|
||||
const res = await $http.blog.getCateList() as CateListResponse
|
||||
cateList.value = res.data
|
||||
total.value = allTotal.value
|
||||
}
|
||||
|
||||
// 生成一个随机的鲜艳颜色,和白色能对比
|
||||
|
||||
function getRandomFromPalette(): string {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
return hslToHex(hue, 80, 50);
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): any {
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l) / 100;
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
}
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
|
||||
function getTags(str: any) {
|
||||
str = str.replaceAll(',', ',')
|
||||
let tags = str.split(',').slice(0, 5)
|
||||
tags.forEach((tag: any, idx: number) => {
|
||||
tags[idx] = tag.trim().slice(0, 4)
|
||||
});
|
||||
return tags
|
||||
}
|
||||
async function getCateList() {
|
||||
const res = await $http.blog.getCateList()
|
||||
console.log('>>> --> getCateList --> res:', res.data)
|
||||
cateList.value = res.data
|
||||
total.value = cateList.value.reduce((acc: any, cur: any) => acc + cur.total, 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
boxStyle.value = {
|
||||
height: `calc(100vh - ${nav.navH + 1}px)`,
|
||||
}
|
||||
getBlogList()
|
||||
getCateList()
|
||||
})
|
||||
onMounted(() => {
|
||||
updateLayout()
|
||||
void getBlogList()
|
||||
void getCateList()
|
||||
window.addEventListener('resize', updateLayout)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateLayout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 文章页样式 */
|
||||
@keyframes fangda {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.cate:hover {
|
||||
|
||||
animation: fangda 01s ease-in-out 1;
|
||||
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user