507 lines
16 KiB
Vue
507 lines
16 KiB
Vue
<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>
|