Files
blog/src/views/Plink.vue
T

507 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(219,95,81,0.10),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(249,154,159,0.08),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(222,217,178,0.06),transparent_28%),linear-gradient(180deg,#faf6ee_0%,#f9f1ef_44%,#fdf7f6_100%)]"
:style="boxStyle">
<div
class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(219,95,81,0.20)] opacity-48 blur-[88px]">
</div>
<div
class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(222,217,178,0.15)] 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-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(128,118,126,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(128,118,126,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-[#72515a]">
<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,#D71A44,#DB5F51)] !text-sm !font-semibold shadow-[0_14px_28px_rgba(215,26,68,0.24)]"
type="primary" @click="submitPut">
提交友链申请
</n-button>
</div>
</n-form>
</section>
<p class="mt-3 text-right text-[12px] leading-5 text-[#8a6267]">
如果暂未显示通常是因为还在审核中...
</p>
</div>
</section>
</div>
</aside>
<section
class="min-w-0 flex-1 overflow-hidden rounded-[30px] border border-white/70 bg-transparent p-4 ring-1 ring-inset ring-[rgba(255,255,255,0.38)] shadow-[0_20px_46px_rgba(128,118,126,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-[#5c3d46]">一起逛逛这些站点</h3>
</div>
<div
class="rounded-full border border-white/70 bg-white/42 px-4 py-2 text-sm text-[#72515a] shadow-[0_8px_18px_rgba(128,118,126,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(128,118,126,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(128,118,126,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(128,118,126,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-[#5c3d46]">
{{ 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(128,118,126,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-[#8a6267]">
暂无友链数据等你来留下第一张站点名片
</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(128,118,126,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-[#72515a]">
<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,#D71A44,#DB5F51)] !font-semibold shadow-[0_14px_28px_rgba(215,26,68,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 type { FormInst, FormRules } from 'naive-ui'
import type { Component } from 'vue'
definePage({
name: 'plink',
meta: {
title: '友链',
}
})
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: 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() {
try {
const res = await $http.plink.getPlinkList() as PlinkListResponse
const uniqueTags = Array.from(new Set(res.data.flatMap((item) => getTags(item.tagname))))
tagColorList.value = buildTagColorMap(uniqueTags)
pList.value = res.data
} catch {
pList.value = []
}
}
function gotoWebsite(url: string) {
if (!url) return
window.open(url, '_blank')
}
function resetFormData() {
plinkData.name = ''
plinkData.title = ''
plinkData.avater = ''
plinkData.url = ''
plinkData.desc = ''
plinkData.tagname = ''
}
function fillForm() {
const token = $cookies.get('token')
if (!token) {
$msg.warning('请先登录后再一键填入')
return
}
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
}
try {
const res = await $http.plink.addPlink(plinkData)
if (res?.code !== 200) {
$msg.error(res.msg)
return
}
$msg.success(res.msg)
resetFormData()
await getPlinkList()
} catch {
$msg.error('提交失败,请稍后再试')
}
}
onMounted(() => {
updateLayout()
void getPlinkList()
window.addEventListener('resize', updateLayout)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
})
</script>
<style scoped lang="less">
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.zhuan {
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;
}
: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>