chore: format code

pull/36/head
CXM 3 years ago
parent 33631a9806
commit b2a66c3788

@ -1,38 +1,29 @@
<template> <template>
<n-config-provider :theme="theme"> <n-config-provider :theme="theme">
<n-message-provider> <n-message-provider>
<div <div class="app-container" :class="{ dark: theme?.name === 'dark' }">
class="app-container" <div has-sider class="main-wrap" position="static">
:class="{ dark: theme?.name === 'dark' }" <!-- -->
> <sidebar />
<div has-sider class="main-wrap" position="static">
<!-- -->
<sidebar />
<div class="content-wrap"> <div class="content-wrap">
<router-view class="app-wrap" v-slot="{ Component }"> <router-view v-slot="{ Component }" class="app-wrap">
<keep-alive> <keep-alive>
<component <component :is="Component" v-if="$route.meta.keepAlive" />
v-if="$route.meta.keepAlive" </keep-alive>
:is="Component" <component :is="Component" v-if="!$route.meta.keepAlive" />
/> </router-view>
</keep-alive> </div>
<component
v-if="!$route.meta.keepAlive"
:is="Component"
/>
</router-view>
</div>
<!-- --> <!-- -->
<rightbar /> <rightbar />
</div> </div>
<!-- / --> <!-- / -->
<auth /> <auth />
</div> </div>
</n-message-provider> </n-message-provider>
<n-global-style /> <n-global-style />
</n-config-provider> </n-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

@ -1,100 +1,99 @@
:root { :root {
// 如果要变更中间栏的大小,修改此处即可 // 如果要变更中间栏的大小,修改此处即可
--content-main: 500px; --content-main: 500px;
} }
.app-container { .app-container {
margin: 0; margin: 0;
.app-wrap { .app-wrap {
width: 100%; width: 100%;
// max-width: 1000px; // max-width: 1000px;
margin: 0 auto; margin: 0 auto;
} }
} }
.main-wrap { .main-wrap {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
.content-wrap { .content-wrap {
width: 100%; width: 100%;
max-width: var(--content-main); max-width: var(--content-main);
position: relative; position: relative;
} }
.main-content-wrap { .main-content-wrap {
margin: 0; margin: 0;
border-top: none; border-top: none;
border-radius: 0; border-radius: 0;
.n-list-item { .n-list-item {
padding: 0; padding: 0;
}
} }
}
} }
.empty-wrap { .empty-wrap {
min-height: 300px; min-height: 300px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.hash-link, .hash-link,
.user-link { .user-link {
color: #18a058; color: #18a058;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }
} }
.beian-link { .beian-link {
color: #333; color: #333;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
opacity: 0.75; opacity: 0.75;
} }
} }
.username-link { .username-link {
color: #000; color: #000;
color: none; color: none;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
.dark { .dark {
.hash-link,
.hash-link, .user-link {
.user-link { color: #63e2b7;
color: #63e2b7; }
}
.username-link {
.username-link { color: #eee;
color: #eee; }
}
.beian-link {
.beian-link { color: #ddd;
color: #ddd; }
}
} }
@media screen and (max-width: 821px) { @media screen and (max-width: 821px) {
.content-wrap { .content-wrap {
top: 0; top: 0;
left: 60px; left: 60px;
position: absolute !important; position: absolute !important;
width: calc(100% - 60px) !important; width: calc(100% - 60px) !important;
} }
} }

@ -1,111 +1,111 @@
<template> <template>
<n-modal <n-modal
v-model:show="store.state.authModalShow" v-model:show="store.state.authModalShow"
class="auth-card" class="auth-card"
preset="card" preset="card"
size="small" size="small"
:mask-closable="false" :mask-closable="false"
:bordered="false" :bordered="false"
:style="{ :style="{
width: '360px', width: '360px',
}" }"
> >
<div class="auth-wrap"> <div class="auth-wrap">
<n-card :bordered="false"> <n-card :bordered="false">
<n-tabs <n-tabs
:default-value="store.state.authModelTab" :default-value="store.state.authModelTab"
size="large" size="large"
justify-content="space-evenly" justify-content="space-evenly"
> >
<n-tab-pane name="signin" tab="登录"> <n-tab-pane name="signin" tab="登录">
<n-form <n-form
ref="loginRef" ref="loginRef"
:model="loginForm" :model="loginForm"
:rules="{ :rules="{
username: { username: {
required: true, required: true,
message: '', message: '',
}, },
password: { password: {
required: true, required: true,
message: '', message: '',
}, },
}" }"
> >
<n-form-item-row label="账户" path="username"> <n-form-item-row label="账户" path="username">
<n-input <n-input
v-model:value="loginForm.username" v-model:value="loginForm.username"
placeholder="请输入用户名" placeholder="请输入用户名"
@keyup.enter.prevent="handleLogin" @keyup.enter.prevent="handleLogin"
/> />
</n-form-item-row> </n-form-item-row>
<n-form-item-row label="密码" path="password"> <n-form-item-row label="密码" path="password">
<n-input <n-input
type="password" v-model:value="loginForm.password"
show-password-on="mousedown" type="password"
v-model:value="loginForm.password" show-password-on="mousedown"
placeholder="请输入账户密码" placeholder="请输入账户密码"
@keyup.enter.prevent="handleLogin" @keyup.enter.prevent="handleLogin"
/> />
</n-form-item-row> </n-form-item-row>
</n-form> </n-form>
<n-button <n-button
type="primary" type="primary"
block block
secondary secondary
strong strong
:loading="loading" :loading="loading"
@click="handleLogin" @click="handleLogin"
> >
</n-button> </n-button>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="signup" tab="注册"> <n-tab-pane name="signup" tab="注册">
<n-form <n-form
ref="registerRef" ref="registerRef"
:model="registerForm" :model="registerForm"
:rules="registerRule" :rules="registerRule"
> >
<n-form-item-row label="用户名" path="username"> <n-form-item-row label="用户名" path="username">
<n-input <n-input
v-model:value="registerForm.username" v-model:value="registerForm.username"
placeholder="用户名注册后无法修改" placeholder="用户名注册后无法修改"
/> />
</n-form-item-row> </n-form-item-row>
<n-form-item-row label="密码" path="password"> <n-form-item-row label="密码" path="password">
<n-input <n-input
type="password" v-model:value="registerForm.password"
show-password-on="mousedown" type="password"
placeholder="密码不少于6位" show-password-on="mousedown"
v-model:value="registerForm.password" placeholder="密码不少于6位"
@keyup.enter.prevent="handleRegister" @keyup.enter.prevent="handleRegister"
/> />
</n-form-item-row> </n-form-item-row>
<n-form-item-row label="重复密码" path="repassword"> <n-form-item-row label="重复密码" path="repassword">
<n-input <n-input
type="password" v-model:value="registerForm.repassword"
show-password-on="mousedown" type="password"
placeholder="请再次输入密码" show-password-on="mousedown"
v-model:value="registerForm.repassword" placeholder="请再次输入密码"
@keyup.enter.prevent="handleRegister" @keyup.enter.prevent="handleRegister"
/> />
</n-form-item-row> </n-form-item-row>
</n-form> </n-form>
<n-button <n-button
type="primary" type="primary"
block block
secondary secondary
strong strong
:loading="loading" :loading="loading"
@click="handleRegister" @click="handleRegister"
> >
</n-button> </n-button>
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
</n-card> </n-card>
</div> </div>
</n-modal> </n-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -119,138 +119,138 @@ const store = useStore();
const loading = ref(false); const loading = ref(false);
const loginRef = ref<FormInst>(); const loginRef = ref<FormInst>();
const loginForm = reactive({ const loginForm = reactive({
username: '', username: '',
password: '', password: '',
}); });
const registerRef = ref<FormInst>(); const registerRef = ref<FormInst>();
const registerForm = reactive({ const registerForm = reactive({
username: '', username: '',
password: '', password: '',
repassword: '', repassword: '',
}); });
const registerRule = { const registerRule = {
username: { username: {
required: true, required: true,
message: '', message: '',
},
password: {
required: true,
message: '',
},
repassword: [
{
required: true,
message: '',
}, },
password: { {
required: true, validator: (rule: FormItemRule, value: any) => {
message: '', return (
!!registerForm.password &&
registerForm.password.startsWith(value) &&
registerForm.password.length >= value.length
);
},
message: '',
trigger: 'input',
}, },
repassword: [ ],
{
required: true,
message: '',
},
{
validator: (rule: FormItemRule, value: any) => {
return (
!!registerForm.password &&
registerForm.password.startsWith(value) &&
registerForm.password.length >= value.length
);
},
message: '',
trigger: 'input',
},
],
}; };
const handleLogin = (e: Event) => { const handleLogin = (e: Event) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
loginRef.value?.validate((errors) => { loginRef.value?.validate((errors) => {
if (!errors) { if (!errors) {
loading.value = true; loading.value = true;
userLogin({ userLogin({
username: loginForm.username, username: loginForm.username,
password: loginForm.password, password: loginForm.password,
}) })
.then((res) => { .then((res) => {
const token = res?.token || ''; const token = res?.token || '';
// 写入用户信息 // 写入用户信息
localStorage.setItem('PAOPAO_TOKEN', token); localStorage.setItem('PAOPAO_TOKEN', token);
return userInfo(token); return userInfo(token);
}) })
.then((res) => { .then((res) => {
window.$message.success(''); window.$message.success('');
loading.value = false; loading.value = false;
store.commit('updateUserinfo', res); store.commit('updateUserinfo', res);
store.commit('triggerAuth', false); store.commit('triggerAuth', false);
loginForm.username = ''; loginForm.username = '';
loginForm.password = ''; loginForm.password = '';
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
} }
}); });
}; };
const handleRegister = (e: Event) => { const handleRegister = (e: Event) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
registerRef.value?.validate((errors) => { registerRef.value?.validate((errors) => {
if (!errors) { if (!errors) {
loading.value = true; loading.value = true;
userRegister({ userRegister({
username: registerForm.username, username: registerForm.username,
password: registerForm.password, password: registerForm.password,
}) })
.then((res) => { .then((res) => {
return userLogin({ return userLogin({
username: registerForm.username, username: registerForm.username,
password: registerForm.password, password: registerForm.password,
}); });
}) })
.then((res) => { .then((res) => {
const token = res?.token || ''; const token = res?.token || '';
// 写入用户信息 // 写入用户信息
localStorage.setItem('PAOPAO_TOKEN', token); localStorage.setItem('PAOPAO_TOKEN', token);
return userInfo(token); return userInfo(token);
}) })
.then((res) => { .then((res) => {
window.$message.success(''); window.$message.success('');
loading.value = false; loading.value = false;
store.commit('updateUserinfo', res); store.commit('updateUserinfo', res);
store.commit('triggerAuth', false); store.commit('triggerAuth', false);
registerForm.username = ''; registerForm.username = '';
registerForm.password = ''; registerForm.password = '';
registerForm.repassword = ''; registerForm.repassword = '';
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
} }
}); });
}; };
onMounted(() => { onMounted(() => {
const token = localStorage.getItem('PAOPAO_TOKEN') || ''; const token = localStorage.getItem('PAOPAO_TOKEN') || '';
if (token) { if (token) {
userInfo(token) userInfo(token)
.then((res) => { .then((res) => {
store.commit('updateUserinfo', res); store.commit('updateUserinfo', res);
store.commit('triggerAuth', false); store.commit('triggerAuth', false);
}) })
.catch((err) => { .catch((err) => {
store.commit('userLogout');
});
} else {
store.commit('userLogout'); store.commit('userLogout');
} });
} else {
store.commit('userLogout');
}
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.auth-wrap { .auth-wrap {
margin-top: -30px; margin-top: -30px;
} }
</style> </style>

@ -1,100 +1,89 @@
<template> <template>
<div class="comment-item"> <div class="comment-item">
<n-thing content-indented> <n-thing content-indented>
<template #avatar> <template #avatar>
<n-avatar round :size="30" :src="comment.user.avatar" /> <n-avatar round :size="30" :src="comment.user.avatar" />
</template>
<template #header>
<span class="nickname-wrap">
<router-link
class="username-link"
:to="{
name: 'user',
query: { username: comment.user.username },
}"
@click.stop
>
{{ comment.user.nickname }}
</router-link>
</span>
<span class="username-wrap"> @{{ comment.user.username }} </span>
</template>
<template #header-extra>
<div class="opt-wrap">
<span class="timestamp">
{{ comment.ip_loc ? comment.ip_loc + ' · ' : comment.ip_loc }}
{{ formatRelativeTime(comment.created_on) }}
</span>
<n-popconfirm
v-if="
store.state.userInfo.is_admin ||
store.state.userInfo.id === comment.user.id
"
negative-text="取消"
positive-text="确认"
@positive-click="execDelAction"
>
<template #trigger>
<n-button quaternary circle size="tiny" class="del-btn">
<template #icon>
<n-icon>
<trash />
</n-icon>
</template>
</n-button>
</template> </template>
<template #header>
<span class="nickname-wrap"> </n-popconfirm>
<router-link </div>
@click.stop </template>
class="username-link" <template v-if="comment.texts.length > 0" #description>
:to="{ <span
name: 'user', v-for="content in comment.texts"
query: { username: comment.user.username }, :key="content.id"
}" class="comment-text"
> @click.stop="doClickText($event, comment.id)"
{{ comment.user.nickname }} v-html="parsePostTag(content.content).content"
</router-link> >
</span> </span>
<span class="username-wrap"> </template>
@{{ comment.user.username }}
</span> <template #footer>
</template> <post-image :imgs="comment.imgs" />
<template #header-extra> <!-- -->
<div class="opt-wrap"> <div class="reply-wrap">
<span class="timestamp"> <reply-item
{{ v-for="reply in comment.replies"
comment.ip_loc :key="reply.id"
? comment.ip_loc + ' · ' :reply="reply"
: comment.ip_loc @focusReply="focusReply"
}} @reload="reload"
{{ formatRelativeTime(comment.created_on) }} />
</span> </div>
<!-- -->
<n-popconfirm <compose-reply
v-if=" v-if="store.state.userInfo.id > 0"
store.state.userInfo.is_admin || ref="replyComposeRef"
store.state.userInfo.id === comment.user.id :comment-id="comment.id"
" :at-userid="replyAtUserID"
negative-text="取消" :at-username="replyAtUsername"
positive-text="确认" @reload="reload"
@positive-click="execDelAction" @reset="resetReply"
> />
<template #trigger> </template>
<n-button </n-thing>
quaternary </div>
circle
size="tiny"
class="del-btn"
>
<template #icon>
<n-icon>
<trash />
</n-icon>
</template>
</n-button>
</template>
</n-popconfirm>
</div>
</template>
<template #description v-if="comment.texts.length > 0">
<span
v-for="content in comment.texts"
:key="content.id"
class="comment-text"
@click.stop="doClickText($event, comment.id)"
v-html="parsePostTag(content.content).content"
>
</span>
</template>
<template #footer>
<post-image :imgs="comment.imgs" />
<!-- -->
<div class="reply-wrap">
<reply-item
v-for="reply in comment.replies"
:key="reply.id"
:reply="reply"
@focusReply="focusReply"
@reload="reload"
/>
</div>
<!-- -->
<compose-reply
ref="replyComposeRef"
v-if="store.state.userInfo.id > 0"
:comment-id="comment.id"
:at-userid="replyAtUserID"
:at-username="replyAtUsername"
@reload="reload"
@reset="resetReply"
/>
</template>
</n-thing>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -113,137 +102,140 @@ const replyAtUsername = ref('');
const replyComposeRef = ref(); const replyComposeRef = ref();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "reload"): void (e: 'reload'): void;
}>(); }>();
const props = withDefaults(defineProps<{ const props = withDefaults(
comment: Item.CommentProps defineProps<{
}>(), {}) comment: Item.CommentProps;
}>(),
{},
);
const comment = computed(() => { const comment = computed(() => {
let comment: Item.CommentComponentProps = Object.assign( let comment: Item.CommentComponentProps = Object.assign(
{ {
texts: [], texts: [],
imgs: [], imgs: [],
}, },
props.comment props.comment,
); );
comment.contents.map((content :any) => { comment.contents.map((content: any) => {
if (+content.type === 1 || +content.type === 2) { if (+content.type === 1 || +content.type === 2) {
comment.texts.push(content); comment.texts.push(content);
} }
if (+content.type === 3) { if (+content.type === 3) {
comment.imgs.push(content); comment.imgs.push(content);
} }
}); });
return comment; return comment;
}); });
const doClickText = (e: MouseEvent, id: number | string) => { const doClickText = (e: MouseEvent, id: number | string) => {
let _target = e.target as any; let _target = e.target as any;
if (_target.dataset.detail) { if (_target.dataset.detail) {
const d = _target.dataset.detail.split(':'); const d = _target.dataset.detail.split(':');
if (d.length === 2) { if (d.length === 2) {
store.commit('refresh'); store.commit('refresh');
if (d[0] === 'tag') { if (d[0] === 'tag') {
window.$message.warning(''); window.$message.warning('');
} else { } else {
router.push({ router.push({
name: 'user', name: 'user',
query: { query: {
username: d[1], username: d[1],
}, },
}); });
} }
}
} }
}
}; };
const focusReply = (reply: Item.ReplyProps) => { const focusReply = (reply: Item.ReplyProps) => {
replyAtUserID.value = reply.user_id; replyAtUserID.value = reply.user_id;
replyAtUsername.value = reply.user?.username || ''; replyAtUsername.value = reply.user?.username || '';
replyComposeRef.value?.switchReply(true); replyComposeRef.value?.switchReply(true);
}; };
const reload = () => { const reload = () => {
emit('reload'); emit('reload');
}; };
const resetReply = () => { const resetReply = () => {
replyAtUserID.value = 0; replyAtUserID.value = 0;
replyAtUsername.value = ''; replyAtUsername.value = '';
}; };
const execDelAction = () => { const execDelAction = () => {
deleteComment({ deleteComment({
id: comment.value.id, id: comment.value.id,
})
.then((res) => {
window.$message.success('');
setTimeout(() => {
reload();
}, 50);
}) })
.then((res) => { .catch((err) => {});
window.$message.success('');
setTimeout(() => {
reload();
}, 50);
})
.catch((err) => {});
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.comment-item { .comment-item {
width: 100%; width: 100%;
padding: 16px; padding: 16px;
box-sizing: border-box; box-sizing: border-box;
.nickname-wrap { .nickname-wrap {
font-size: 14px; font-size: 14px;
}
.username-wrap {
font-size: 14px;
opacity: 0.75;
}
.opt-wrap {
display: flex;
align-items: center;
.timestamp {
opacity: 0.75;
font-size: 12px;
} }
.username-wrap { .del-btn {
font-size: 14px; margin-left: 4px;
opacity: 0.75;
} }
}
.opt-wrap {
display: flex; .comment-text {
align-items: center; display: block;
.timestamp { text-align: justify;
opacity: 0.75; overflow: hidden;
font-size: 12px; white-space: pre-wrap;
} word-break: break-all;
.del-btn { }
margin-left: 4px;
} .opt-item {
} display: flex;
align-items: center;
.comment-text { opacity: 0.7;
display: block; .opt-item-icon {
text-align: justify; margin-right: 10px;
overflow: hidden;
white-space: pre-wrap;
word-break: break-all;
}
.opt-item {
display: flex;
align-items: center;
opacity: 0.7;
.opt-item-icon {
margin-right: 10px;
}
} }
}
} }
.reply-wrap { .reply-wrap {
margin-top: 10px; margin-top: 10px;
border-radius: 5px; border-radius: 5px;
background: #fafafc; background: #fafafc;
.reply-item { .reply-item {
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
}
} }
}
} }
.dark { .dark {
.reply-wrap { .reply-wrap {
background: #18181c; background: #18181c;
} }
} }
</style> </style>

@ -1,168 +1,159 @@
<template> <template>
<div> <div>
<div class="compose-wrap" v-if="store.state.userInfo.id > 0"> <div v-if="store.state.userInfo.id > 0" class="compose-wrap">
<div class="compose-line"> <div class="compose-line">
<div class="compose-user"> <div class="compose-user">
<n-avatar <n-avatar round :size="30" :src="store.state.userInfo.avatar" />
round </div>
:size="30" <n-mention
:src="store.state.userInfo.avatar" type="textarea"
/> size="large"
</div> autosize
<n-mention :bordered="false"
type="textarea" :options="optionsRef"
size="large" :prefix="['@']"
autosize :loading="loading"
:bordered="false" :value="content"
:options="optionsRef" :disabled="props.lock === 1"
:prefix="['@']" :placeholder="
:loading="loading" props.lock === 1
:value="content" ? ''
:disabled="props.lock === 1" : '...'
@update:value="changeContent" "
@search="handleSearch" @update:value="changeContent"
@focus="focusComment" @search="handleSearch"
:placeholder=" @focus="focusComment"
props.lock === 1 />
? '' </div>
: '...'
"
/>
</div>
<n-upload
v-if="showBtn"
ref="uploadRef"
abstract
list-type="image"
:multiple="true"
:max="9"
:action="uploadGateway"
:headers="{
Authorization: uploadToken,
}"
:data="{
type: uploadType,
}"
@before-upload="beforeUpload"
@finish="finishUpload"
@error="failUpload"
@remove="removeUpload"
@update:file-list="updateUpload"
>
<div class="compose-line compose-options">
<div class="attachment">
<n-upload-trigger #="{ handleClick }" abstract>
<n-button
:disabled="
(fileQueue.length > 0 &&
uploadType === 'public/video') ||
fileQueue.length === 9
"
@click="
() => {
setUploadType('public/image');
handleClick();
}
"
quaternary
circle
type="primary"
>
<template #icon>
<n-icon
size="20"
color="var(--primary-color)"
>
<image-outline />
</n-icon>
</template>
</n-button>
</n-upload-trigger>
<n-tooltip trigger="hover" placement="bottom"> <n-upload
<template #trigger> v-if="showBtn"
<n-progress ref="uploadRef"
class="text-statistic" abstract
type="circle" list-type="image"
:show-indicator="false" :multiple="true"
status="success" :max="9"
:stroke-width="10" :action="uploadGateway"
:percentage="(content.length / 200) * 100" :headers="{
/> Authorization: uploadToken,
</template> }"
{{ content.length }} / 200 :data="{
</n-tooltip> type: uploadType,
</div> }"
@before-upload="beforeUpload"
@finish="finishUpload"
@error="failUpload"
@remove="removeUpload"
@update:file-list="updateUpload"
>
<div class="compose-line compose-options">
<div class="attachment">
<n-upload-trigger #="{ handleClick }" abstract>
<n-button
:disabled="
(fileQueue.length > 0 && uploadType === 'public/video') ||
fileQueue.length === 9
"
quaternary
circle
type="primary"
@click="
() => {
setUploadType('public/image');
handleClick();
}
"
>
<template #icon>
<n-icon size="20" color="var(--primary-color)">
<image-outline />
</n-icon>
</template>
</n-button>
</n-upload-trigger>
<div class="submit-wrap"> <n-tooltip trigger="hover" placement="bottom">
<n-button <template #trigger>
quaternary <n-progress
round class="text-statistic"
type="tertiary" type="circle"
class="cancel-btn" :show-indicator="false"
size="small" status="success"
@click="cancelComment" :stroke-width="10"
> :percentage="(content.length / 200) * 100"
/>
</n-button> </template>
<n-button {{ content.length }} / 200
:loading="submitting" </n-tooltip>
@click="submitPost" </div>
type="primary"
secondary
size="small"
round
>
</n-button>
</div>
</div>
<div class="attachment-list-wrap"> <div class="submit-wrap">
<n-upload-file-list /> <n-button
</div> quaternary
</n-upload> round
type="tertiary"
class="cancel-btn"
size="small"
@click="cancelComment"
>
</n-button>
<n-button
:loading="submitting"
type="primary"
secondary
size="small"
round
@click="submitPost"
>
</n-button>
</div>
</div> </div>
<div class="compose-wrap" v-else> <div class="attachment-list-wrap">
<div class="login-wrap"> <n-upload-file-list />
<span class="login-banner"> </span>
</div>
<div class="login-wrap">
<n-button
strong
secondary
round
type="primary"
@click="triggerAuth('signin')"
>
</n-button>
<n-button
strong
secondary
round
type="info"
@click="triggerAuth('signup')"
>
</n-button>
</div>
</div> </div>
</n-upload>
</div> </div>
</template>
<div v-else class="compose-wrap">
<div class="login-wrap">
<span class="login-banner"> </span>
</div>
<div class="login-wrap">
<n-button
strong
secondary
round
type="primary"
@click="triggerAuth('signin')"
>
</n-button>
<n-button
strong
secondary
round
type="info"
@click="triggerAuth('signup')"
>
</n-button>
</div>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { import {
ImageOutline, ImageOutline,
VideocamOutline, VideocamOutline,
AttachOutline, AttachOutline,
CompassOutline, CompassOutline,
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { createComment } from '@/api/post'; import { createComment } from '@/api/post';
import { getSuggestUsers } from '@/api/user'; import { getSuggestUsers } from '@/api/user';
@ -170,15 +161,18 @@ import { parsePostTag } from '@/utils/content';
import type { MentionOption, UploadFileInfo, UploadInst } from 'naive-ui'; import type { MentionOption, UploadFileInfo, UploadInst } from 'naive-ui';
const emit = defineEmits<{ const emit = defineEmits<{
(e: "post-success"): void (e: 'post-success'): void;
}>(); }>();
const props = withDefaults(defineProps<{ const props = withDefaults(
lock: number, defineProps<{
postId: number, lock: number;
}>(), { postId: number;
}>(),
{
lock: 0, lock: 0,
postId: 0 postId: 0,
}); },
);
const store = useStore(); const store = useStore();
@ -197,232 +191,235 @@ const uploadToken = ref();
// 加载at用户列表 // 加载at用户列表
const loadSuggestionUsers = debounce((k) => { const loadSuggestionUsers = debounce((k) => {
getSuggestUsers({ getSuggestUsers({
k, k,
}) })
.then((res) => { .then((res) => {
let options: MentionOption[] = []; let options: MentionOption[] = [];
res.map((i) => { res.map((i) => {
options.push({ options.push({
label: i, label: i,
value: i, value: i,
});
});
optionsRef.value = options;
loading.value = false;
})
.catch((err) => {
loading.value = false;
}); });
});
optionsRef.value = options;
loading.value = false;
})
.catch((err) => {
loading.value = false;
});
}, 200); }, 200);
const handleSearch = (k: string, prefix: string) => { const handleSearch = (k: string, prefix: string) => {
if (loading.value) { if (loading.value) {
return; return;
} }
loading.value = true; loading.value = true;
if (prefix === '@') { if (prefix === '@') {
loadSuggestionUsers(k); loadSuggestionUsers(k);
} }
}; };
const changeContent = (v: string) => { const changeContent = (v: string) => {
if (v.length > 200) { if (v.length > 200) {
return; return;
} }
content.value = v; content.value = v;
}; };
const setUploadType = (type: string) => { const setUploadType = (type: string) => {
uploadType.value = type; uploadType.value = type;
}; };
const updateUpload = (list: UploadFileInfo[]) => { const updateUpload = (list: UploadFileInfo[]) => {
fileQueue.value = list; fileQueue.value = list;
}; };
const beforeUpload = async (data: any) => { const beforeUpload = async (data: any) => {
// 图片类型校验 // 图片类型校验
if ( if (
uploadType.value === 'public/image' && uploadType.value === 'public/image' &&
!['image/png', 'image/jpg', 'image/jpeg', 'image/gif'].includes( !['image/png', 'image/jpg', 'image/jpeg', 'image/gif'].includes(
(data.file as any).file?.type (data.file as any).file?.type,
) )
) { ) {
window.$message.warning(' png/jpg/gif '); window.$message.warning(' png/jpg/gif ');
return false; return false;
} }
if (uploadType.value === 'image' && (data.file as any).file?.size > 10485760) { if (
window.$message.warning('10MB'); uploadType.value === 'image' &&
return false; (data.file as any).file?.size > 10485760
} ) {
window.$message.warning('10MB');
return false;
}
return true; return true;
}; };
const finishUpload = ({ file, event }: any): any => { const finishUpload = ({ file, event }: any): any => {
try { try {
let data = JSON.parse(event.target?.response); let data = JSON.parse(event.target?.response);
if (data.code === 0) { if (data.code === 0) {
if (uploadType.value === 'public/image') { if (uploadType.value === 'public/image') {
imageContents.value.push({ imageContents.value.push({
id: file.id, id: file.id,
content: data.data.content, content: data.data.content,
} as Item.CommentItemProps); } as Item.CommentItemProps);
} }
}
} catch (error) {
window.$message.error('');
} }
} catch (error) {
window.$message.error('');
}
}; };
const failUpload = ({ file, event }: any): any => { const failUpload = ({ file, event }: any): any => {
try { try {
let data = JSON.parse(event.target?.response); let data = JSON.parse(event.target?.response);
if (data.code !== 0) { if (data.code !== 0) {
let errMsg = data.msg || ''; let errMsg = data.msg || '';
if (data.details && data.details.length > 0) { if (data.details && data.details.length > 0) {
data.details.map((detail: string) => { data.details.map((detail: string) => {
errMsg += ':' + detail; errMsg += ':' + detail;
}); });
} }
window.$message.error(errMsg); window.$message.error(errMsg);
}
} catch (error) {
window.$message.error('');
} }
} catch (error) {
window.$message.error('');
}
}; };
const removeUpload = ({ file }: any) => { const removeUpload = ({ file }: any) => {
let idx = imageContents.value.findIndex((item) => item.id === file.id); let idx = imageContents.value.findIndex((item) => item.id === file.id);
if (idx > -1) { if (idx > -1) {
imageContents.value.splice(idx, 1); imageContents.value.splice(idx, 1);
} }
}; };
const focusComment = () => { const focusComment = () => {
showBtn.value = true; showBtn.value = true;
}; };
const cancelComment = () => { const cancelComment = () => {
showBtn.value = false; showBtn.value = false;
// 置空 // 置空
uploadRef.value?.clear(); uploadRef.value?.clear();
fileQueue.value = []; fileQueue.value = [];
content.value = ''; content.value = '';
imageContents.value = []; imageContents.value = [];
}; };
// 发布动态 // 发布动态
const submitPost = () => { const submitPost = () => {
if (content.value.trim().length === 0) { if (content.value.trim().length === 0) {
window.$message.warning(''); window.$message.warning('');
return; return;
} }
// 解析用户at // 解析用户at
let { users } = parsePostTag(content.value); let { users } = parsePostTag(content.value);
const contents = []; const contents = [];
let sort = 100; let sort = 100;
contents.push({
content: content.value,
type: 2, // 文字
sort,
});
imageContents.value.map((img) => {
sort++;
contents.push({ contents.push({
content: content.value, content: img.content,
type: 2, // 文字 type: 3, // 图片
sort, sort,
});
imageContents.value.map((img) => {
sort++;
contents.push({
content: img.content,
type: 3, // 图片
sort,
});
}); });
});
submitting.value = true; submitting.value = true;
createComment({ createComment({
contents, contents,
post_id: props.postId, post_id: props.postId,
users: Array.from(new Set(users)), users: Array.from(new Set(users)),
}) })
.then((res) => { .then((res) => {
window.$message.success(''); window.$message.success('');
submitting.value = false; submitting.value = false;
emit('post-success'); emit('post-success');
// 置空 // 置空
cancelComment(); cancelComment();
}) })
.catch((err) => { .catch((err) => {
submitting.value = false; submitting.value = false;
}); });
}; };
const triggerAuth = (key: string) => { const triggerAuth = (key: string) => {
store.commit('triggerAuth', true); store.commit('triggerAuth', true);
store.commit('triggerAuthKey', key); store.commit('triggerAuthKey', key);
}; };
onMounted(() => { onMounted(() => {
uploadToken.value = 'Bearer ' + localStorage.getItem('PAOPAO_TOKEN'); uploadToken.value = 'Bearer ' + localStorage.getItem('PAOPAO_TOKEN');
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.compose-wrap { .compose-wrap {
width: 100%; width: 100%;
padding: 16px; padding: 16px;
box-sizing: border-box; box-sizing: border-box;
.compose-line { .compose-line {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
.compose-user { .compose-user {
width: 42px; width: 42px;
height: 42px; height: 42px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
&.compose-options { &.compose-options {
margin-top: 6px; margin-top: 6px;
padding-left: 42px; padding-left: 42px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.submit-wrap { .submit-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
.cancel-btn { .cancel-btn {
margin-right: 8px; margin-right: 8px;
}
}
} }
}
} }
.login-wrap { }
display: flex; .login-wrap {
justify-content: center; display: flex;
width: 100%; justify-content: center;
.login-banner { width: 100%;
margin-bottom: 12px; .login-banner {
opacity: 0.8; margin-bottom: 12px;
} opacity: 0.8;
button { }
margin: 0 4px; button {
} margin: 0 4px;
} }
}
} }
.attachment { .attachment {
display: flex; display: flex;
align-items: center; align-items: center;
.text-statistic { .text-statistic {
margin-left: 8px; margin-left: 8px;
width: 18px; width: 18px;
height: 18px; height: 18px;
transform: rotate(180deg); transform: rotate(180deg);
} }
} }
.attachment-list-wrap { .attachment-list-wrap {
margin-top: 12px; margin-top: 12px;
margin-left: 42px; margin-left: 42px;
.n-upload-file-info__thumbnail { .n-upload-file-info__thumbnail {
overflow: hidden; overflow: hidden;
} }
} }
</style> </style>

@ -1,41 +1,39 @@
<template> <template>
<div class="reply-compose-wrap"> <div class="reply-compose-wrap">
<div class="reply-switch"> <div class="reply-switch">
<span class="show" v-if="!showReply" @click="switchReply(true)"> <span v-if="!showReply" class="show" @click="switchReply(true)">
</span> </span>
<span class="hide" v-if="showReply" @click="switchReply(false)"> <span v-if="showReply" class="hide" @click="switchReply(false)">
</span> </span>
</div> </div>
<div class="reply-input-wrap" v-if="showReply"> <div v-if="showReply" class="reply-input-wrap">
<n-input-group> <n-input-group>
<n-input <n-input
ref="inputInstRef" ref="inputInstRef"
size="small" v-model:value="replyContent"
:placeholder=" size="small"
props.atUsername :placeholder="
? '@' + props.atUsername props.atUsername ? '@' + props.atUsername : '..'
: '..' "
" maxlength="100"
maxlength="100" show-count
v-model:value="replyContent" clearable
show-count />
clearable <n-button
/> type="primary"
<n-button size="small"
type="primary" ghost
size="small" :loading="submitting"
ghost @click="submitReply"
:loading="submitting" >
@click="submitReply"
> </n-button>
</n-input-group>
</n-button>
</n-input-group>
</div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -43,70 +41,73 @@ import { ref } from 'vue';
import { createCommentReply } from '@/api/post'; import { createCommentReply } from '@/api/post';
import { InputInst } from 'naive-ui'; import { InputInst } from 'naive-ui';
const props = withDefaults(defineProps<{ const props = withDefaults(
commentId: number, defineProps<{
atUserid: number, commentId: number;
atUsername: string, atUserid: number;
}>(), { atUsername: string;
}>(),
{
commentId: 0, commentId: 0,
atUserid: 0, atUserid: 0,
atUsername: "" atUsername: '',
}); },
);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "reload"): void, (e: 'reload'): void;
(e: "reset"): void (e: 'reset'): void;
}>(); }>();
const inputInstRef = ref<InputInst>(); const inputInstRef = ref<InputInst>();
const showReply = ref(false); const showReply = ref(false);
const replyContent = ref(''); const replyContent = ref('');
const submitting = ref(false); const submitting = ref(false);
const switchReply = (status: boolean) => { const switchReply = (status: boolean) => {
showReply.value = status; showReply.value = status;
if (status) { if (status) {
setTimeout(() => { setTimeout(() => {
inputInstRef.value?.focus(); inputInstRef.value?.focus();
}, 10); }, 10);
} else { } else {
submitting.value = false; submitting.value = false;
replyContent.value = ''; replyContent.value = '';
emit('reset'); emit('reset');
} }
}; };
const submitReply = () => { const submitReply = () => {
submitting.value = true; submitting.value = true;
createCommentReply({ createCommentReply({
comment_id: props.commentId, comment_id: props.commentId,
at_user_id: props.atUserid, at_user_id: props.atUserid,
content: replyContent.value, content: replyContent.value,
})
.then((res) => {
switchReply(false);
window.$message.success('');
emit('reload');
}) })
.then((res) => { .catch((err) => {
switchReply(false); submitting.value = false;
window.$message.success(''); });
emit('reload');
})
.catch((err) => {
submitting.value = false;
});
}; };
defineExpose({ switchReply }); defineExpose({ switchReply });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.reply-compose-wrap { .reply-compose-wrap {
.reply-switch { .reply-switch {
text-align: right; text-align: right;
font-size: 12px; font-size: 12px;
margin: 10px 0; margin: 10px 0;
.show { .show {
color: #18a058; color: #18a058;
cursor: pointer; cursor: pointer;
} }
.hide { .hide {
opacity: 0.75; opacity: 0.75;
cursor: pointer; cursor: pointer;
}
} }
}
} }
</style> </style>

File diff suppressed because it is too large Load Diff

@ -1,35 +1,35 @@
<template> <template>
<n-card size="small" :bordered="true" class="nav-title-card"> <n-card size="small" :bordered="true" class="nav-title-card">
<template #header> <template #header>
<div class="navbar"> <div class="navbar">
<n-button <n-button
class="back-btn" v-if="back"
v-if="back" class="back-btn"
@click="goBack" quaternary
quaternary circle
circle size="small"
size="small" @click="goBack"
> >
<template #icon> <template #icon>
<n-icon><chevron-left-round /></n-icon> <n-icon><chevron-left-round /></n-icon>
</template> </template>
</n-button> </n-button>
{{ props.title }} {{ props.title }}
<n-switch <n-switch
:value="store.state.theme === 'dark'" :value="store.state.theme === 'dark'"
@update:value="switchTheme" size="small"
size="small" class="theme-switch-wrap"
class="theme-switch-wrap" @update:value="switchTheme"
> >
<template #icon> <template #icon>
<dark-mode-outlined /> <dark-mode-outlined />
</template> </template>
</n-switch> </n-switch>
</div> </div>
</template> </template>
</n-card> </n-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -43,72 +43,72 @@ const store = useStore();
const router = useRouter(); const router = useRouter();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
title: string; title: string;
back?: boolean; back?: boolean;
}>(), }>(),
{ {
title: '', title: '',
back: false, back: false,
} },
); );
const switchTheme = (theme: boolean) => { const switchTheme = (theme: boolean) => {
if (theme) { if (theme) {
localStorage.setItem('PAOPAO_THEME', 'dark'); localStorage.setItem('PAOPAO_THEME', 'dark');
store.commit('triggerTheme', 'dark'); store.commit('triggerTheme', 'dark');
} else { } else {
localStorage.setItem('PAOPAO_THEME', 'light'); localStorage.setItem('PAOPAO_THEME', 'light');
store.commit('triggerTheme', 'light'); store.commit('triggerTheme', 'light');
} }
}; };
const goBack = () => { const goBack = () => {
if (window.history.length <= 1) { if (window.history.length <= 1) {
router.push({ router.push({
path: '/', path: '/',
}); });
} else { } else {
router.go(-1); router.go(-1);
} }
}; };
onMounted(() => { onMounted(() => {
if (!localStorage.getItem('PAOPAO_THEME')) { if (!localStorage.getItem('PAOPAO_THEME')) {
switchTheme((useOsTheme() as unknown as string) === 'dark'); switchTheme((useOsTheme() as unknown as string) === 'dark');
} }
}); });
</script> </script>
<style lang="less"> <style lang="less">
.nav-title-card { .nav-title-card {
z-index: 99; z-index: 99;
width: 100%; width: 100%;
top: 0; top: 0;
position: sticky; position: sticky;
border-radius: 0; border-radius: 0;
border-bottom: 0; border-bottom: 0;
background-color: rgba(255, 255, 255, 0.75); background-color: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
.navbar { .navbar {
height: 30px; height: 30px;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
.back-btn { .back-btn {
margin-right: 8px; margin-right: 8px;
} }
.theme-switch-wrap { .theme-switch-wrap {
position: absolute; position: absolute;
right: 0; right: 0;
top: calc(50% - 9px); top: calc(50% - 9px);
}
} }
}
} }
.dark { .dark {
.nav-title-card { .nav-title-card {
background-color: rgba(16, 16, 20, 0.75); background-color: rgba(16, 16, 20, 0.75);
} }
} }
</style> </style>

@ -1,72 +1,70 @@
<template> <template>
<div <div
class="message-item" class="message-item"
:class="{ unread: message.is_read === 0 }" :class="{ unread: message.is_read === 0 }"
@click="handleReadMessage(message)" @click="handleReadMessage(message)"
> >
<n-thing content-indented> <n-thing content-indented>
<template #header> <template #header>
<div class="sender-wrap"> <div class="sender-wrap">
<n-avatar <n-avatar
:size="22" :size="22"
:src=" :src="
message.sender_user.id > 0 message.sender_user.id > 0
? message.sender_user.avatar ? message.sender_user.avatar
: defaultavatar : defaultavatar
" "
/> />
<span class="nickname" v-if="message.sender_user.id > 0"> <span v-if="message.sender_user.id > 0" class="nickname">
<router-link <router-link
@click.stop class="username-link"
class="username-link" :to="{
:to="{ name: 'user',
name: 'user', query: {
query: { username: message.sender_user.username,
username: message.sender_user.username, },
}, }"
}" @click.stop
> >
{{ message.sender_user.nickname }} {{ message.sender_user.nickname }}
</router-link> </router-link>
<span class="username"> <span class="username"> @{{ message.sender_user.username }} </span>
@{{ message.sender_user.username }} </span>
</span> <span v-else class="nickname"> </span>
</span> </div>
<span class="nickname" v-else> </span> </template>
</div> <template #header-extra>
</template> <span class="timestamp">
<template #header-extra> <n-badge v-if="message.is_read === 0" dot processing />
<span class="timestamp"> <span class="timestamp-txt">
<n-badge v-if="message.is_read === 0" dot processing /> {{ formatRelativeTime(message.created_on) }}
<span class="timestamp-txt"> </span>
{{ formatRelativeTime(message.created_on) }} </span>
</span> </template>
</span> <template #description>
</template> <n-alert
<template #description> :show-icon="false"
<n-alert class="breif-wrap"
:show-icon="false" :type="message.is_read > 0 ? 'default' : 'success'"
class="breif-wrap" >
:type="message.is_read > 0 ? 'default' : 'success'" <div class="breif-content">
> {{ message.breif }}
<div class="breif-content"> <span
{{ message.breif }} v-if="message.type !== 4"
<span class="hash-link view-link"
v-if="message.type !== 4" @click.stop="viewDetail(message)"
@click.stop="viewDetail(message)" >
class="hash-link view-link" <n-icon><share-outline /></n-icon>
> </span>
<n-icon><share-outline /></n-icon> </div>
</span>
</div>
<div v-if="message.type === 4" class="whisper-content-wrap"> <div v-if="message.type === 4" class="whisper-content-wrap">
{{ message.content }} {{ message.content }}
</div> </div>
</n-alert> </n-alert>
</template> </template>
</n-thing> </n-thing>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -76,99 +74,99 @@ import { readMessage } from '@/api/user';
import { formatRelativeTime } from '@/utils/formatTime'; import { formatRelativeTime } from '@/utils/formatTime';
const defaultavatar = const defaultavatar =
'https://assets.paopao.info/public/avatar/default/admin.png'; 'https://assets.paopao.info/public/avatar/default/admin.png';
const router = useRouter(); const router = useRouter();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
message: Item.MessageProps; message: Item.MessageProps;
}>(), }>(),
{} {},
); );
const viewDetail = (message: Item.MessageProps) => { const viewDetail = (message: Item.MessageProps) => {
handleReadMessage(message); handleReadMessage(message);
if (message.type === 1 || message.type === 2 || message.type === 3) { if (message.type === 1 || message.type === 2 || message.type === 3) {
if (message.post && message.post.id > 0) { if (message.post && message.post.id > 0) {
router.push({ router.push({
name: 'post', name: 'post',
query: { query: {
id: message.post_id, id: message.post_id,
}, },
}); });
} else { } else {
window.$message.error(''); window.$message.error('');
}
} }
}
}; };
const handleReadMessage = (message: Item.MessageProps) => { const handleReadMessage = (message: Item.MessageProps) => {
if (message.is_read === 0) { if (message.is_read === 0) {
readMessage({ readMessage({
id: message.id, id: message.id,
}) })
.then((res) => { .then((res) => {
message.is_read = 1; message.is_read = 1;
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
} }
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.message-item { .message-item {
padding: 16px; padding: 16px;
&.unread { &.unread {
background: #fcfffc; background: #fcfffc;
} }
.sender-wrap { .sender-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
.nickname { .nickname {
margin-left: 10px; margin-left: 10px;
}
.username {
opacity: 0.75;
font-size: 14px;
}
} }
.username {
.timestamp { opacity: 0.75;
opacity: 0.75; font-size: 14px;
font-size: 12px;
display: flex;
align-items: center;
.timestamp-txt {
margin-left: 6px;
}
} }
}
.breif-wrap { .timestamp {
margin-top: 10px; opacity: 0.75;
.breif-content { font-size: 12px;
display: flex; display: flex;
width: 100%; align-items: center;
} .timestamp-txt {
.whisper-content-wrap { margin-left: 6px;
margin-top: 12px;
text-decoration: underline;
}
} }
}
.view-link { .breif-wrap {
margin-left: 8px; margin-top: 10px;
display: flex; .breif-content {
align-items: center; display: flex;
width: 100%;
}
.whisper-content-wrap {
margin-top: 12px;
text-decoration: underline;
} }
}
.view-link {
margin-left: 8px;
display: flex;
align-items: center;
}
} }
.dark { .dark {
.message-item { .message-item {
&.unread { &.unread {
background: #0f180b; background: #0f180b;
}
} }
}
} }
</style> </style>

@ -1,27 +1,30 @@
<template> <template>
<div class="skeleton-item" v-for="i in new Array(num)" :key="i"> <div v-for="i in new Array(num)" :key="i" class="skeleton-item">
<div class="content"> <div class="content">
<n-skeleton text :repeat="2" /> <n-skeleton text :repeat="2" />
<n-skeleton text style="width: 60%" /> <n-skeleton text style="width: 60%" />
</div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = withDefaults(defineProps<{ const props = withDefaults(
num: number defineProps<{
}>(), { num: number;
num: 1 }>(),
}); {
num: 1,
},
);
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.skeleton-item { .skeleton-item {
padding: 12px; padding: 12px;
display: flex; display: flex;
.content { .content {
width: 100%; width: 100%;
} }
} }
</style> </style>

@ -1,38 +1,38 @@
<template> <template>
<div class="attachment-wrap"> <div class="attachment-wrap">
<div <div
class="attach-item" v-for="attachment in attachments"
v-for="attachment in attachments" :key="attachment.id"
:key="attachment.id" class="attach-item"
> >
<n-button <n-button
@click.stop="download(attachment)" type="primary"
type="primary" size="tiny"
size="tiny" dashed
dashed @click.stop="download(attachment)"
> >
<template #icon> <template #icon>
<n-icon> <n-icon>
<cloud-download-outline /> <cloud-download-outline />
</n-icon> </n-icon>
</template> </template>
{{ attachment.type === 8 ? '' : '' }} {{ attachment.type === 8 ? '' : '' }}
</n-button> </n-button>
</div>
<!-- -->
<n-modal
v-model:show="showDownloadModal"
:mask-closable="false"
preset="dialog"
title="下载提示"
:content="downloadTip"
positive-text="确认下载"
negative-text="取消"
icon-placement="top"
@positive-click="execDownloadAction"
/>
</div> </div>
<!-- -->
<n-modal
v-model:show="showDownloadModal"
:mask-closable="false"
preset="dialog"
title="下载提示"
:content="downloadTip"
positive-text="确认下载"
negative-text="取消"
icon-placement="top"
@positive-click="execDownloadAction"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -41,71 +41,67 @@ import { CloudDownloadOutline } from '@vicons/ionicons5';
import { precheckAttachment, getAttachment } from '@/api/user'; import { precheckAttachment, getAttachment } from '@/api/user';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
attachments: Item.PostItemProps[]; attachments: Item.PostItemProps[];
price?: number; price?: number;
}>(), }>(),
{ {
attachments: () => [], attachments: () => [],
price: 0, price: 0,
} },
); );
const showDownloadModal = ref(false); const showDownloadModal = ref(false);
const downloadTip = ref<any>(''); const downloadTip = ref<any>('');
const attachmentID = ref(0); const attachmentID = ref(0);
const download = (attachment: Item.PostItemProps) => { const download = (attachment: Item.PostItemProps) => {
showDownloadModal.value = true; showDownloadModal.value = true;
attachmentID.value = attachment.id; attachmentID.value = attachment.id;
downloadTip.value = '';
if (attachment.type === 8) {
downloadTip.value = () =>
h('div', {}, [
h(
'p',
{},
'' +
(props.price / 100).toFixed(2) +
'元',
),
]);
downloadTip.value = ''; precheckAttachment({
if (attachment.type === 8) { id: attachmentID.value,
downloadTip.value = () => })
.then((res) => {
if (res.paid) {
downloadTip.value = () =>
h('div', {}, [ h('div', {}, [
h( h('p', {}, ''),
'p',
{},
'' +
(props.price / 100).toFixed(2) +
'元'
),
]); ]);
}
precheckAttachment({ })
id: attachmentID.value, .catch((err) => {
}) showDownloadModal.value = false;
.then((res) => { });
if (res.paid) { }
downloadTip.value = () =>
h('div', {}, [
h(
'p',
{},
''
),
]);
}
})
.catch((err) => {
showDownloadModal.value = false;
});
}
}; };
const execDownloadAction = () => { const execDownloadAction = () => {
getAttachment({ getAttachment({
id: attachmentID.value, id: attachmentID.value,
})
.then((res) => {
window.open(res.replace('http://', 'https://'), '_blank');
}) })
.then((res) => { .catch((err) => {
window.open(res.replace('http://', 'https://'), '_blank'); console.log(err);
}) });
.catch((err) => {
console.log(err);
});
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.attach-item { .attach-item {
margin: 10px 0; margin: 10px 0;
} }
</style> </style>

@ -1,155 +1,145 @@
<template> <template>
<div class="detail-item" @click="goPostDetail(post.id)"> <div class="detail-item" @click="goPostDetail(post.id)">
<n-thing> <n-thing>
<template #avatar> <template #avatar>
<n-avatar round :size="30" :src="post.user.avatar" /> <n-avatar round :size="30" :src="post.user.avatar" />
</template> </template>
<template #header> <template #header>
<router-link <router-link
@click.stop class="username-link"
class="username-link" :to="{
:to="{ name: 'user',
name: 'user', query: { username: post.user.username },
query: { username: post.user.username }, }"
}" @click.stop
> >
{{ post.user.nickname }} {{ post.user.nickname }}
</router-link> </router-link>
<span class="username-wrap"> @{{ post.user.username }} </span> <span class="username-wrap"> @{{ post.user.username }} </span>
</template> </template>
<template #header-extra> <template #header-extra>
<div <div
class="options" v-if="
v-if=" store.state.userInfo.is_admin ||
store.state.userInfo.is_admin || store.state.userInfo.id === post.user.id
store.state.userInfo.id === post.user.id "
" class="options"
> >
<n-dropdown <n-dropdown
placement="bottom-end" placement="bottom-end"
trigger="click" trigger="click"
size="small" size="small"
:options="adminOptions" :options="adminOptions"
@select="handlePostAction" @select="handlePostAction"
> >
<n-button quaternary circle> <n-button quaternary circle>
<template #icon> <template #icon>
<n-icon> <n-icon>
<more-horiz-filled /> <more-horiz-filled />
</n-icon> </n-icon>
</template> </template>
</n-button> </n-button>
</n-dropdown> </n-dropdown>
</div> </div>
<!-- --> <!-- -->
<n-modal <n-modal
v-model:show="showDelModal" v-model:show="showDelModal"
:mask-closable="false" :mask-closable="false"
preset="dialog" preset="dialog"
title="提示" title="提示"
content="确定删除该泡泡动态吗?" content="确定删除该泡泡动态吗?"
positive-text="确认" positive-text="确认"
negative-text="取消" negative-text="取消"
@positive-click="execDelAction" @positive-click="execDelAction"
/> />
<!-- --> <!-- -->
<n-modal <n-modal
v-model:show="showLockModal" v-model:show="showLockModal"
:mask-closable="false" :mask-closable="false"
preset="dialog" preset="dialog"
title="提示" title="提示"
:content=" :content="
'' + '' + (post.is_lock ? '' : '') + ''
(post.is_lock ? '' : '') + "
'' positive-text="确认"
" negative-text="取消"
positive-text="确认" @positive-click="execLockAction"
negative-text="取消" />
@positive-click="execLockAction" <!-- -->
/> <n-modal
<!-- --> v-model:show="showStickModal"
<n-modal :mask-closable="false"
v-model:show="showStickModal" preset="dialog"
:mask-closable="false" title="提示"
preset="dialog" :content="
title="提示" '' + (post.is_top ? '' : '') + ''
:content=" "
'' + positive-text="确认"
(post.is_top ? '' : '') + negative-text="取消"
'' @positive-click="execStickAction"
" />
positive-text="确认" </template>
negative-text="取消" <div v-if="post.texts.length > 0">
@positive-click="execStickAction" <span
/> v-for="content in post.texts"
</template> :key="content.id"
<div v-if="post.texts.length > 0"> class="post-text"
<span @click.stop="doClickText($event, post.id)"
v-for="content in post.texts" v-html="parsePostTag(content.content).content"
:key="content.id" >
class="post-text" </span>
@click.stop="doClickText($event, post.id)" </div>
v-html="parsePostTag(content.content).content"
>
</span>
</div>
<template #footer> <template #footer>
<post-attachment :attachments="post.attachments" /> <post-attachment :attachments="post.attachments" />
<post-attachment <post-attachment
:attachments="post.charge_attachments" :attachments="post.charge_attachments"
:price="post.attachment_price" :price="post.attachment_price"
/> />
<post-image :imgs="post.imgs" /> <post-image :imgs="post.imgs" />
<post-video :videos="post.videos" :full="true" /> <post-video :videos="post.videos" :full="true" />
<post-link :links="post.links" /> <post-link :links="post.links" />
<div class="timestamp"> <div class="timestamp">
{{ formatRelativeTime(post.created_on) }} {{ formatRelativeTime(post.created_on) }}
<span v-if="post.ip_loc"> <span v-if="post.ip_loc">
<n-divider vertical /> <n-divider vertical />
{{ post.ip_loc }} {{ post.ip_loc }}
</span> </span>
<span v-if="post.created_on != post.latest_replied_on"> <span v-if="post.created_on != post.latest_replied_on">
<n-divider vertical /> <n-divider vertical />
{{ formatRelativeTime(post.latest_replied_on) }} {{ formatRelativeTime(post.latest_replied_on) }}
</span> </span>
</div> </div>
</template> </template>
<template #action> <template #action>
<div class="opts-wrap"> <div class="opts-wrap">
<n-space justify="space-between"> <n-space justify="space-between">
<div <div class="opt-item hover" @click.stop="handlePostStar">
class="opt-item hover" <n-icon size="20" class="opt-item-icon">
@click.stop="handlePostStar" <heart-outline v-if="!hasStarred" />
> <heart v-if="hasStarred" color="red" />
<n-icon size="20" class="opt-item-icon"> </n-icon>
<heart-outline v-if="!hasStarred" /> {{ post.upvote_count }}
<heart v-if="hasStarred" color="red" /> </div>
</n-icon> <div class="opt-item">
{{ post.upvote_count }} <n-icon size="20" class="opt-item-icon">
</div> <chatbox-outline />
<div class="opt-item"> </n-icon>
<n-icon size="20" class="opt-item-icon"> {{ post.comment_count }}
<chatbox-outline /> </div>
</n-icon> <div class="opt-item hover" @click.stop="handlePostCollection">
{{ post.comment_count }} <n-icon size="20" class="opt-item-icon">
</div> <bookmark-outline v-if="!hasCollected" />
<div <bookmark v-if="hasCollected" color="#ff7600" />
class="opt-item hover" </n-icon>
@click.stop="handlePostCollection" {{ post.collection_count }}
> </div>
<n-icon size="20" class="opt-item-icon"> </n-space>
<bookmark-outline v-if="!hasCollected" /> </div>
<bookmark v-if="hasCollected" color="#ff7600" /> </template>
</n-icon> </n-thing>
{{ post.collection_count }} </div>
</div>
</n-space>
</div>
</template>
</n-thing>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -159,21 +149,21 @@ import { useRouter } from 'vue-router';
import { formatRelativeTime } from '@/utils/formatTime'; import { formatRelativeTime } from '@/utils/formatTime';
import { parsePostTag } from '@/utils/content'; import { parsePostTag } from '@/utils/content';
import { import {
Heart, Heart,
HeartOutline, HeartOutline,
Bookmark, Bookmark,
BookmarkOutline, BookmarkOutline,
ChatboxOutline, ChatboxOutline,
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { MoreHorizFilled } from '@vicons/material'; import { MoreHorizFilled } from '@vicons/material';
import { import {
getPostStar, getPostStar,
postStar, postStar,
getPostCollection, getPostCollection,
postCollection, postCollection,
deletePost, deletePost,
lockPost, lockPost,
stickPost, stickPost,
} from '@/api/post'; } from '@/api/post';
const store = useStore(); const store = useStore();
@ -181,10 +171,10 @@ const router = useRouter();
const hasStarred = ref(false); const hasStarred = ref(false);
const hasCollected = ref(false); const hasCollected = ref(false);
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
post: Item.PostProps; post: Item.PostProps;
}>(), }>(),
{} {},
); );
const showDelModal = ref(false); const showDelModal = ref(false);
const showLockModal = ref(false); const showLockModal = ref(false);
@ -192,303 +182,303 @@ const showStickModal = ref(false);
const loading = ref(false); const loading = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'reload'): void; (e: 'reload'): void;
}>(); }>();
const post = computed({ const post = computed({
get: () => { get: () => {
let post: Item.PostComponentProps = Object.assign( let post: Item.PostComponentProps = Object.assign(
{ {
texts: [], texts: [],
imgs: [], imgs: [],
videos: [], videos: [],
links: [], links: [],
attachments: [], attachments: [],
charge_attachments: [], charge_attachments: [],
}, },
props.post props.post,
); );
post.contents.map((content) => { post.contents.map((content) => {
if (+content.type === 1 || +content.type === 2) { if (+content.type === 1 || +content.type === 2) {
post.texts.push(content); post.texts.push(content);
} }
if (+content.type === 3) { if (+content.type === 3) {
post.imgs.push(content); post.imgs.push(content);
} }
if (+content.type === 4) { if (+content.type === 4) {
post.videos.push(content); post.videos.push(content);
} }
if (+content.type === 6) { if (+content.type === 6) {
post.links.push(content); post.links.push(content);
} }
if (+content.type === 7) { if (+content.type === 7) {
post.attachments.push(content); post.attachments.push(content);
} }
if (+content.type === 8) { if (+content.type === 8) {
post.charge_attachments.push(content); post.charge_attachments.push(content);
} }
}); });
return post; return post;
}, },
set: (newVal) => { set: (newVal) => {
props.post.upvote_count = newVal.upvote_count; props.post.upvote_count = newVal.upvote_count;
props.post.comment_count = newVal.comment_count; props.post.comment_count = newVal.comment_count;
props.post.collection_count = newVal.collection_count; props.post.collection_count = newVal.collection_count;
}, },
}); });
const adminOptions = computed(() => { const adminOptions = computed(() => {
let options = [ let options = [
{ {
label: '', label: '',
key: 'delete', key: 'delete',
}, },
]; ];
if (post.value.is_lock === 0) { if (post.value.is_lock === 0) {
options.push({ options.push({
label: '', label: '',
key: 'lock', key: 'lock',
}); });
} else {
options.push({
label: '',
key: 'unlock',
});
}
if (store.state.userInfo.is_admin) {
if (post.value.is_top === 0) {
options.push({
label: '',
key: 'stick',
});
} else { } else {
options.push({ options.push({
label: '', label: '',
key: 'unlock', key: 'unstick',
}); });
}
if (store.state.userInfo.is_admin) {
if (post.value.is_top === 0) {
options.push({
label: '',
key: 'stick',
});
} else {
options.push({
label: '',
key: 'unstick',
});
}
} }
return options; }
return options;
}); });
const goPostDetail = (id: number) => { const goPostDetail = (id: number) => {
router.push({ router.push({
name: 'post', name: 'post',
query: { query: {
id, id,
}, },
}); });
}; };
const doClickText = (e: MouseEvent, id: number) => { const doClickText = (e: MouseEvent, id: number) => {
if ((e.target as any).dataset.detail) { if ((e.target as any).dataset.detail) {
const d = (e.target as any).dataset.detail.split(':'); const d = (e.target as any).dataset.detail.split(':');
if (d.length === 2) { if (d.length === 2) {
store.commit('refresh'); store.commit('refresh');
if (d[0] === 'tag') { if (d[0] === 'tag') {
router.push({ router.push({
name: 'home', name: 'home',
query: { query: {
q: d[1], q: d[1],
t: 'tag', t: 'tag',
}, },
}); });
} else { } else {
router.push({ router.push({
name: 'user', name: 'user',
query: { query: {
username: d[1], username: d[1],
}, },
}); });
} }
return; return;
}
} }
goPostDetail(id); }
goPostDetail(id);
}; };
const handlePostAction = ( const handlePostAction = (
item: 'delete' | 'lock' | 'unlock' | 'stick' | 'unstick' item: 'delete' | 'lock' | 'unlock' | 'stick' | 'unstick',
) => { ) => {
if (item === 'delete') { if (item === 'delete') {
showDelModal.value = true; showDelModal.value = true;
} }
if (item === 'lock' || item === 'unlock') { if (item === 'lock' || item === 'unlock') {
showLockModal.value = true; showLockModal.value = true;
} }
if (item === 'stick' || item === 'unstick') { if (item === 'stick' || item === 'unstick') {
showStickModal.value = true; showStickModal.value = true;
} }
}; };
const execDelAction = () => { const execDelAction = () => {
deletePost({ deletePost({
id: post.value.id, id: post.value.id,
}) })
.then((res) => { .then((res) => {
window.$message.success(''); window.$message.success('');
router.replace('/'); router.replace('/');
setTimeout(() => { setTimeout(() => {
store.commit('refresh'); store.commit('refresh');
}, 50); }, 50);
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
}; };
const execLockAction = () => { const execLockAction = () => {
lockPost({ lockPost({
id: post.value.id, id: post.value.id,
})
.then((res) => {
emit('reload');
if (res.lock_status === 1) {
window.$message.success('');
} else {
window.$message.success('');
}
}) })
.then((res) => { .catch((err) => {
emit('reload'); loading.value = false;
if (res.lock_status === 1) { });
window.$message.success('');
} else {
window.$message.success('');
}
})
.catch((err) => {
loading.value = false;
});
}; };
const execStickAction = () => { const execStickAction = () => {
stickPost({ stickPost({
id: post.value.id, id: post.value.id,
})
.then((res) => {
emit('reload');
if (res.top_status === 1) {
window.$message.success('');
} else {
window.$message.success('');
}
}) })
.then((res) => { .catch((err) => {
emit('reload'); loading.value = false;
if (res.top_status === 1) { });
window.$message.success('');
} else {
window.$message.success('');
}
})
.catch((err) => {
loading.value = false;
});
}; };
const handlePostStar = () => { const handlePostStar = () => {
postStar({ postStar({
id: post.value.id, id: post.value.id,
})
.then((res) => {
hasStarred.value = res.status;
if (res.status) {
post.value = {
...post.value,
upvote_count: post.value.upvote_count + 1,
};
} else {
post.value = {
...post.value,
upvote_count: post.value.upvote_count - 1,
};
}
}) })
.then((res) => { .catch((err) => {
hasStarred.value = res.status; console.log(err);
if (res.status) { });
post.value = {
...post.value,
upvote_count: post.value.upvote_count + 1,
};
} else {
post.value = {
...post.value,
upvote_count: post.value.upvote_count - 1,
};
}
})
.catch((err) => {
console.log(err);
});
}; };
const handlePostCollection = () => { const handlePostCollection = () => {
postCollection({ postCollection({
id: post.value.id, id: post.value.id,
})
.then((res) => {
hasCollected.value = res.status;
if (res.status) {
post.value = {
...post.value,
collection_count: post.value.collection_count + 1,
};
} else {
post.value = {
...post.value,
collection_count: post.value.collection_count - 1,
};
}
}) })
.then((res) => { .catch((err) => {
hasCollected.value = res.status; console.log(err);
if (res.status) { });
post.value = {
...post.value,
collection_count: post.value.collection_count + 1,
};
} else {
post.value = {
...post.value,
collection_count: post.value.collection_count - 1,
};
}
})
.catch((err) => {
console.log(err);
});
}; };
onMounted(() => { onMounted(() => {
if (store.state.userInfo.id > 0) { if (store.state.userInfo.id > 0) {
getPostStar({ getPostStar({
id: post.value.id, id: post.value.id,
}) })
.then((res) => { .then((res) => {
hasStarred.value = res.status; hasStarred.value = res.status;
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
getPostCollection({ getPostCollection({
id: post.value.id, id: post.value.id,
}) })
.then((res) => { .then((res) => {
hasCollected.value = res.status; hasCollected.value = res.status;
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
} }
}); });
</script> </script>
<style lang="less"> <style lang="less">
.detail-item { .detail-item {
width: 100%; width: 100%;
padding: 16px; padding: 16px;
box-sizing: border-box; box-sizing: border-box;
background: #f7f9f9; background: #f7f9f9;
.nickname-wrap { .nickname-wrap {
font-size: 14px; font-size: 14px;
} }
.username-wrap { .username-wrap {
font-size: 14px; font-size: 14px;
opacity: 0.75; opacity: 0.75;
} }
.options { .options {
opacity: 0.75; opacity: 0.75;
} }
.post-text { .post-text {
font-size: 16px; font-size: 16px;
text-align: justify; text-align: justify;
overflow: hidden; overflow: hidden;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
.opts-wrap { .opts-wrap {
margin-top: 20px; margin-top: 20px;
.opt-item { .opt-item {
display: flex; display: flex;
align-items: center; align-items: center;
opacity: 0.7; opacity: 0.7;
.opt-item-icon { .opt-item-icon {
margin-right: 10px; margin-right: 10px;
} }
&.hover { &.hover {
cursor: pointer; cursor: pointer;
} }
}
} }
.n-thing { }
.n-thing-avatar-header-wrapper { .n-thing {
align-items: center; .n-thing-avatar-header-wrapper {
} align-items: center;
}
.timestamp {
opacity: 0.75;
font-size: 12px;
margin-top: 10px;
} }
}
.timestamp {
opacity: 0.75;
font-size: 12px;
margin-top: 10px;
}
} }
.dark { .dark {
.detail-item { .detail-item {
background: #18181c; background: #18181c;
} }
} }
</style> </style>

@ -1,280 +1,283 @@
<template> <template>
<div class="images-wrap"> <div class="images-wrap">
<n-image-group v-if="[1].includes(props.imgs.length)"> <n-image-group v-if="[1].includes(props.imgs.length)">
<n-grid :x-gap="4" :y-gap="4" :cols="2"> <n-grid :x-gap="4" :y-gap="4" :cols="2">
<template v-for="img in props.imgs" :key="img.id"> <template v-for="img in props.imgs" :key="img.id">
<n-gi> <n-gi>
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x1"
@click.stop object-fit="cover"
class="post-img x1" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
<n-image-group v-if="[2, 3].includes(props.imgs.length)"> <n-image-group v-if="[2, 3].includes(props.imgs.length)">
<n-grid :x-gap="4" :y-gap="4" :cols="3"> <n-grid :x-gap="4" :y-gap="4" :cols="3">
<template v-for="img in props.imgs" :key="img.id"> <template v-for="img in props.imgs" :key="img.id">
<n-gi> <n-gi>
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
<n-image-group v-if="[4].includes(props.imgs.length)"> <n-image-group v-if="[4].includes(props.imgs.length)">
<n-grid :x-gap="4" :y-gap="4" :cols="4"> <n-grid :x-gap="4" :y-gap="4" :cols="4">
<template v-for="img in props.imgs" :key="img.id"> <template v-for="img in props.imgs" :key="img.id">
<n-gi> <n-gi>
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x3"
@click.stop object-fit="cover"
class="post-img x3" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
<n-image-group v-if="[5].includes(props.imgs.length)"> <n-image-group v-if="[5].includes(props.imgs.length)">
<n-grid :x-gap="4" :y-gap="4" :cols="3"> <n-grid :x-gap="4" :y-gap="4" :cols="3">
<template v-for="(img, idx) in props.imgs" :key="img.id"> <template v-for="(img, idx) in props.imgs" :key="img.id">
<n-gi v-if="idx < 3"> <n-gi v-if="idx < 3">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
<n-grid :x-gap="4" :y-gap="4" :cols="2" style="margin-top: 4px"> <n-grid :x-gap="4" :y-gap="4" :cols="2" style="margin-top: 4px">
<template v-for="(img, idx) in props.imgs" :key="img.id"> <template v-for="(img, idx) in props.imgs" :key="img.id">
<n-gi v-if="idx >= 3"> <n-gi v-if="idx >= 3">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x1"
@click.stop object-fit="cover"
class="post-img x1" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
<n-image-group v-if="[6].includes(props.imgs.length)"> <n-image-group v-if="[6].includes(props.imgs.length)">
<n-grid :x-gap="4" :y-gap="4" :cols="3"> <n-grid :x-gap="4" :y-gap="4" :cols="3">
<template v-for="(img, idx) in props.imgs" :key="img.id"> <template v-for="(img, idx) in props.imgs" :key="img.id">
<n-gi v-if="idx < 3"> <n-gi v-if="idx < 3">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
<n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px"> <n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px">
<template v-for="(img, idx) in props.imgs" :key="img.id"> <template v-for="(img, idx) in props.imgs" :key="img.id">
<n-gi v-if="idx >= 3"> <n-gi v-if="idx >= 3">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
<n-image-group v-if="props.imgs.length === 7"> <n-image-group v-if="props.imgs.length === 7">
<n-grid :x-gap="4" :y-gap="4" :cols="4"> <n-grid :x-gap="4" :y-gap="4" :cols="4">
<template v-for="(img, idx) in props.imgs"> <template v-for="(img, idx) in props.imgs">
<n-gi :key="img.id" v-if="idx < 4"> <n-gi v-if="idx < 4" :key="img.id">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x3"
@click.stop object-fit="cover"
class="post-img x3" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
<n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px"> <n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px">
<template v-for="(img, idx) in props.imgs"> <template v-for="(img, idx) in props.imgs">
<n-gi :key="img.id" v-if="idx >= 4"> <n-gi v-if="idx >= 4" :key="img.id">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
<n-image-group v-if="props.imgs.length === 8"> <n-image-group v-if="props.imgs.length === 8">
<n-grid :x-gap="4" :y-gap="4" :cols="4"> <n-grid :x-gap="4" :y-gap="4" :cols="4">
<template v-for="(img, idx) in props.imgs"> <template v-for="(img, idx) in props.imgs">
<n-gi :key="img.id" v-if="idx < 4"> <n-gi v-if="idx < 4" :key="img.id">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x3"
@click.stop object-fit="cover"
class="post-img x3" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
<n-grid :x-gap="4" :y-gap="4" :cols="4" style="margin-top: 4px"> <n-grid :x-gap="4" :y-gap="4" :cols="4" style="margin-top: 4px">
<template v-for="(img, idx) in props.imgs"> <template v-for="(img, idx) in props.imgs">
<n-gi :key="img.id" v-if="idx >= 4"> <n-gi v-if="idx >= 4" :key="img.id">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x3"
@click.stop object-fit="cover"
class="post-img x3" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
<n-image-group v-if="props.imgs.length === 9"> <n-image-group v-if="props.imgs.length === 9">
<n-grid :x-gap="4" :y-gap="4" :cols="3"> <n-grid :x-gap="4" :y-gap="4" :cols="3">
<template v-for="(img, idx) in props.imgs"> <template v-for="(img, idx) in props.imgs">
<n-gi :key="img.id" v-if="idx < 3"> <n-gi v-if="idx < 3" :key="img.id">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
<n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px"> <n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px">
<template v-for="(img, idx) in props.imgs"> <template v-for="(img, idx) in props.imgs">
<n-gi :key="img.id" v-if="idx >= 3 && idx < 6"> <n-gi v-if="idx >= 3 && idx < 6" :key="img.id">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
<n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px"> <n-grid :x-gap="4" :y-gap="4" :cols="3" style="margin-top: 4px">
<template v-for="(img, idx) in props.imgs"> <template v-for="(img, idx) in props.imgs">
<n-gi :key="img.id" v-if="idx >= 6"> <n-gi v-if="idx >= 6" :key="img.id">
<n-image <n-image
@error="() => (img.content = defaultImg)" class="post-img x2"
@click.stop object-fit="cover"
class="post-img x2" :src="img.content + thumbnail"
object-fit="cover" :preview-src="img.content"
:src="img.content + thumbnail" @error="() => (img.content = defaultImg)"
:preview-src="img.content" @click.stop
/> />
</n-gi> </n-gi>
</template> </template>
</n-grid> </n-grid>
</n-image-group> </n-image-group>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
const defaultImg = const defaultImg =
'https://paopao-assets.oss-cn-shanghai.aliyuncs.com/public/404.png'; 'https://paopao-assets.oss-cn-shanghai.aliyuncs.com/public/404.png';
const thumbnail = const thumbnail =
'?x-oss-process=image/resize,m_fill,w_300,h_300,limit_0/auto-orient,1/format,png'; '?x-oss-process=image/resize,m_fill,w_300,h_300,limit_0/auto-orient,1/format,png';
const props = withDefaults(defineProps<{ const props = withDefaults(
imgs: Item.PostItemProps[], defineProps<{
}>(), { imgs: Item.PostItemProps[];
imgs: () => [] }>(),
}); {
imgs: () => [],
},
);
</script> </script>
<style lang="less"> <style lang="less">
.images-wrap { .images-wrap {
margin-top: 10px; margin-top: 10px;
} }
.post-img { .post-img {
display: flex; display: flex;
margin: 0; margin: 0;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
border: 1px solid #eee; border: 1px solid #eee;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
.x1 { .x1 {
height: 140px; height: 140px;
} }
.x2 { .x2 {
height: 90px; height: 90px;
} }
.x3 { .x3 {
height: 80px; height: 80px;
} }
.dark { .dark {
.post-img { .post-img {
border: 1px solid #333; border: 1px solid #333;
} }
} }
@media screen and (max-width: 821px) { @media screen and (max-width: 821px) {
.x1 { .x1 {
height: 100px; height: 100px;
} }
.x2 { .x2 {
height: 70px; height: 70px;
} }
.x3 { .x3 {
height: 50px; height: 50px;
} }
} }
</style> </style>

@ -1,84 +1,84 @@
<template> <template>
<div class="post-item" @click="goPostDetail(post.id)"> <div class="post-item" @click="goPostDetail(post.id)">
<n-thing content-indented> <n-thing content-indented>
<template #avatar> <template #avatar>
<n-avatar round :size="30" :src="post.user.avatar" /> <n-avatar round :size="30" :src="post.user.avatar" />
</template> </template>
<template #header> <template #header>
<span class="nickname-wrap"> <span class="nickname-wrap">
<router-link <router-link
@click.stop class="username-link"
class="username-link" :to="{
:to="{ name: 'user',
name: 'user', query: { username: post.user.username },
query: { username: post.user.username }, }"
}" @click.stop
> >
{{ post.user.nickname }} {{ post.user.nickname }}
</router-link> </router-link>
</span> </span>
<span class="username-wrap"> @{{ post.user.username }} </span> <span class="username-wrap"> @{{ post.user.username }} </span>
<n-tag <n-tag
v-if="post.is_top" v-if="post.is_top"
class="top-tag" class="top-tag"
type="warning" type="warning"
size="small" size="small"
round round
> >
</n-tag> </n-tag>
</template> </template>
<template #header-extra> <template #header-extra>
<span class="timestamp"> <span class="timestamp">
{{ post.ip_loc ? post.ip_loc + ' · ' : post.ip_loc }} {{ post.ip_loc ? post.ip_loc + ' · ' : post.ip_loc }}
{{ formatRelativeTime(post.created_on) }} {{ formatRelativeTime(post.created_on) }}
</span> </span>
</template> </template>
<template #description v-if="post.texts.length > 0"> <template v-if="post.texts.length > 0" #description>
<span <span
v-for="content in post.texts" v-for="content in post.texts"
:key="content.id" :key="content.id"
class="post-text" class="post-text"
@click.stop="doClickText($event, post.id)" @click.stop="doClickText($event, post.id)"
v-html="parsePostTag(content.content).content" v-html="parsePostTag(content.content).content"
> >
</span> </span>
</template> </template>
<template #footer> <template #footer>
<post-attachment :attachments="post.attachments" /> <post-attachment :attachments="post.attachments" />
<post-attachment <post-attachment
:attachments="post.charge_attachments" :attachments="post.charge_attachments"
:price="post.attachment_price" :price="post.attachment_price"
/> />
<post-image :imgs="post.imgs" /> <post-image :imgs="post.imgs" />
<post-video :videos="post.videos" /> <post-video :videos="post.videos" />
<post-link :links="post.links" /> <post-link :links="post.links" />
</template> </template>
<template #action> <template #action>
<n-space justify="space-between"> <n-space justify="space-between">
<div class="opt-item"> <div class="opt-item">
<n-icon size="18" class="opt-item-icon"> <n-icon size="18" class="opt-item-icon">
<heart-outline /> <heart-outline />
</n-icon> </n-icon>
{{ post.upvote_count }} {{ post.upvote_count }}
</div> </div>
<div class="opt-item"> <div class="opt-item">
<n-icon size="18" class="opt-item-icon"> <n-icon size="18" class="opt-item-icon">
<chatbox-outline /> <chatbox-outline />
</n-icon> </n-icon>
{{ post.comment_count }} {{ post.comment_count }}
</div> </div>
<div class="opt-item"> <div class="opt-item">
<n-icon size="18" class="opt-item-icon"> <n-icon size="18" class="opt-item-icon">
<bookmark-outline /> <bookmark-outline />
</n-icon> </n-icon>
{{ post.collection_count }} {{ post.collection_count }}
</div> </div>
</n-space> </n-space>
</template> </template>
</n-thing> </n-thing>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -88,142 +88,145 @@ import { useRoute, useRouter } from 'vue-router';
import { formatRelativeTime } from '@/utils/formatTime'; import { formatRelativeTime } from '@/utils/formatTime';
import { parsePostTag } from '@/utils/content'; import { parsePostTag } from '@/utils/content';
import { import {
HeartOutline, HeartOutline,
BookmarkOutline, BookmarkOutline,
ChatboxOutline, ChatboxOutline,
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const props = withDefaults(defineProps<{ const props = withDefaults(
post: Item.PostProps, defineProps<{
}>(), {}); post: Item.PostProps;
}>(),
{},
);
const post = computed(() => { const post = computed(() => {
let post: Item.PostComponentProps = Object.assign( let post: Item.PostComponentProps = Object.assign(
{ {
texts: [], texts: [],
imgs: [], imgs: [],
videos: [], videos: [],
links: [], links: [],
attachments: [], attachments: [],
charge_attachments: [], charge_attachments: [],
}, },
props.post props.post,
); );
post.contents.map((content) => { post.contents.map((content) => {
if (+content.type === 1 || +content.type === 2) { if (+content.type === 1 || +content.type === 2) {
post.texts.push(content); post.texts.push(content);
} }
if (+content.type === 3) { if (+content.type === 3) {
post.imgs.push(content); post.imgs.push(content);
} }
if (+content.type === 4) { if (+content.type === 4) {
post.videos.push(content); post.videos.push(content);
} }
if (+content.type === 6) { if (+content.type === 6) {
post.links.push(content); post.links.push(content);
} }
if (+content.type === 7) { if (+content.type === 7) {
post.attachments.push(content); post.attachments.push(content);
} }
if (+content.type === 8) { if (+content.type === 8) {
post.charge_attachments.push(content); post.charge_attachments.push(content);
} }
}); });
return post; return post;
}); });
const goPostDetail = (id: number) => { const goPostDetail = (id: number) => {
router.push({ router.push({
name: 'post', name: 'post',
query: { query: {
id, id,
}, },
}); });
}; };
const doClickText = (e: MouseEvent, id: number) => { const doClickText = (e: MouseEvent, id: number) => {
if ((e.target as any).dataset.detail) { if ((e.target as any).dataset.detail) {
const d = (e.target as any).dataset.detail.split(':'); const d = (e.target as any).dataset.detail.split(':');
if (d.length === 2) { if (d.length === 2) {
store.commit('refresh'); store.commit('refresh');
if (d[0] === 'tag') { if (d[0] === 'tag') {
router.push({ router.push({
name: 'home', name: 'home',
query: { query: {
q: d[1], q: d[1],
t: 'tag', t: 'tag',
}, },
}); });
} else { } else {
router.push({ router.push({
name: 'user', name: 'user',
query: { query: {
username: d[1], username: d[1],
}, },
}); });
} }
return; return;
}
} }
goPostDetail(id); }
goPostDetail(id);
}; };
</script> </script>
<style lang="less"> <style lang="less">
.post-item { .post-item {
width: 100%; width: 100%;
padding: 16px; padding: 16px;
box-sizing: border-box; box-sizing: border-box;
.nickname-wrap { .nickname-wrap {
font-size: 14px; font-size: 14px;
} }
.username-wrap { .username-wrap {
font-size: 14px; font-size: 14px;
opacity: 0.75; opacity: 0.75;
} }
.top-tag { .top-tag {
transform: scale(0.75); transform: scale(0.75);
} }
.timestamp { .timestamp {
opacity: 0.75; opacity: 0.75;
font-size: 12px; font-size: 12px;
} }
.post-text { .post-text {
text-align: justify; text-align: justify;
overflow: hidden; overflow: hidden;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
.opt-item { .opt-item {
display: flex; display: flex;
align-items: center; align-items: center;
opacity: 0.7; opacity: 0.7;
.opt-item-icon { .opt-item-icon {
margin-right: 10px; margin-right: 10px;
}
}
&:hover {
background: #f7f9f9;
cursor: pointer;
} }
}
&:hover {
background: #f7f9f9;
cursor: pointer;
}
.n-thing-avatar { .n-thing-avatar {
margin-top: 0; margin-top: 0;
} }
.n-thing-header { .n-thing-header {
line-height: 16px; line-height: 16px;
margin-bottom: 8px !important; margin-bottom: 8px !important;
} }
} }
.dark { .dark {
.post-item { .post-item {
&:hover { &:hover {
background: #18181c; background: #18181c;
}
} }
}
} }
</style> </style>

@ -1,40 +1,38 @@
<template> <template>
<div class="link-wrap"> <div class="link-wrap">
<div class="link-item" v-for="link in props.links" :key="link.id"> <div v-for="link in props.links" :key="link.id" class="link-item">
<n-icon class="hash-link"><link-outline /></n-icon> <n-icon class="hash-link"><link-outline /></n-icon>
<a <a :href="link.content" class="hash-link" target="_blank" @click.stop>
:href="link.content" <span class="link-txt">{{ link.content }}</span>
class="hash-link" </a>
target="_blank"
@click.stop
>
<span class="link-txt">{{ link.content }}</span>
</a>
</div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { LinkOutline } from '@vicons/ionicons5'; import { LinkOutline } from '@vicons/ionicons5';
const props = withDefaults(defineProps<{ const props = withDefaults(
links: Item.PostItemProps[] defineProps<{
}>(), { links: Item.PostItemProps[];
links: () => [] }>(),
}); {
links: () => [],
},
);
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.link-wrap { .link-wrap {
margin-bottom: 10px; margin-bottom: 10px;
.link-item { .link-item {
display: flex; display: flex;
align-items: center; align-items: center;
.hash-link { .hash-link {
.link-txt { .link-txt {
margin-left: 4px; margin-left: 4px;
word-break: break-all; word-break: break-all;
} }
}
} }
}
} }
</style> </style>

@ -1,34 +1,37 @@
<template> <template>
<div class="skeleton-item" v-for="i in new Array(num)" :key="i"> <div v-for="i in new Array(num)" :key="i" class="skeleton-item">
<div class="user"> <div class="user">
<n-skeleton circle size="small" /> <n-skeleton circle size="small" />
</div>
<div class="content">
<n-skeleton text :repeat="3" />
<n-skeleton text style="width: 60%" />
</div>
</div> </div>
<div class="content">
<n-skeleton text :repeat="3" />
<n-skeleton text style="width: 60%" />
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = withDefaults(defineProps<{ const props = withDefaults(
num: number, defineProps<{
}>(), { num: number;
num: 1 }>(),
}); {
num: 1,
},
);
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.skeleton-item { .skeleton-item {
padding: 12px; padding: 12px;
display: flex; display: flex;
.user { .user {
width: 42px; width: 42px;
} }
.content { .content {
width: calc(100% - 42px); width: calc(100% - 42px);
} }
} }
</style> </style>

@ -1,19 +1,19 @@
<template> <template>
<div v-if="props.videos.length > 0"> <div v-if="props.videos.length > 0">
<n-grid :x-gap="4" :y-gap="4" :cols="full ? 1 : 5"> <n-grid :x-gap="4" :y-gap="4" :cols="full ? 1 : 5">
<n-gi :span="full ? 1 : 3"> <n-gi :span="full ? 1 : 3">
<n-video <n-video
@click.stop v-for="video in props.videos"
v-for="video in props.videos" :key="video.id"
:key="video.id" :src="video.content"
:src="video.content" :colors="['#18a058', '#2aca75']"
:colors="['#18a058', '#2aca75']" :hoverable="true"
:hoverable="true" theme="gradient"
theme="gradient" @click.stop
></n-video> ></n-video>
</n-gi> </n-gi>
</n-grid> </n-grid>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -21,13 +21,13 @@ import { reactive } from 'vue';
import NVideo from 'nonesir-video'; import NVideo from 'nonesir-video';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
videos: Item.PostItemProps[]; videos: Item.PostItemProps[];
full?: boolean; full?: boolean;
}>(), }>(),
{ {
videos: () => [], videos: () => [],
full: false, full: false,
} },
); );
</script> </script>

@ -1,68 +1,66 @@
<template> <template>
<div class="reply-item"> <div class="reply-item">
<div class="header-wrap"> <div class="header-wrap">
<div class="username"> <div class="username">
<router-link <router-link
class="user-link" class="user-link"
:to="{ :to="{
name: 'user', name: 'user',
query: { username: props.reply.user.username }, query: { username: props.reply.user.username },
}" }"
> >
{{ props.reply.user.username }} {{ props.reply.user.username }}
</router-link> </router-link>
<span class="reply-name"> <span class="reply-name">
{{ props.reply.at_user_id > 0 ? '' : ':' }} {{ props.reply.at_user_id > 0 ? '' : ':' }}
</span> </span>
<router-link <router-link
class="user-link" v-if="props.reply.at_user_id > 0"
:to="{ class="user-link"
name: 'user', :to="{
query: { username: props.reply.at_user.username }, name: 'user',
}" query: { username: props.reply.at_user.username },
v-if="props.reply.at_user_id > 0" }"
> >
{{ props.reply.at_user.username }} {{ props.reply.at_user.username }}
</router-link> </router-link>
</div> </div>
<div class="timestamp"> <div class="timestamp">
{{ {{
props.reply.ip_loc props.reply.ip_loc ? props.reply.ip_loc + ' · ' : props.reply.ip_loc
? props.reply.ip_loc + ' · ' }}
: props.reply.ip_loc {{ formatRelativeTime(props.reply.created_on) }}
}} <n-popconfirm
{{ formatRelativeTime(props.reply.created_on) }} v-if="
<n-popconfirm store.state.userInfo.is_admin ||
v-if=" store.state.userInfo.id === props.reply.user.id
store.state.userInfo.is_admin || "
store.state.userInfo.id === props.reply.user.id negative-text="取消"
" positive-text="确认"
negative-text="取消" @positive-click="execDelAction"
positive-text="确认" >
@positive-click="execDelAction" <template #trigger>
> <n-button quaternary circle size="tiny" class="del-btn">
<template #trigger> <template #icon>
<n-button quaternary circle size="tiny" class="del-btn"> <n-icon>
<template #icon> <trash />
<n-icon> </n-icon>
<trash /> </template>
</n-icon> </n-button>
</template> </template>
</n-button>
</template> </n-popconfirm>
</div>
</n-popconfirm> </div>
</div>
</div>
<div class="base-wrap"> <div class="base-wrap">
<div class="content">{{ props.reply.content }}</div> <div class="content">{{ props.reply.content }}</div>
<div class="reply-switch" v-if="store.state.userInfo.id > 0"> <div v-if="store.state.userInfo.id > 0" class="reply-switch">
<span class="show" @click="focusReply"> </span> <span class="show" @click="focusReply"> </span>
</div> </div>
</div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -71,98 +69,100 @@ import { Trash } from '@vicons/tabler';
import { formatRelativeTime } from '@/utils/formatTime'; import { formatRelativeTime } from '@/utils/formatTime';
import { deleteCommentReply } from '@/api/post'; import { deleteCommentReply } from '@/api/post';
const props = withDefaults(defineProps<{ const props = withDefaults(
reply: Item.ReplyProps, defineProps<{
}>(), {}); reply: Item.ReplyProps;
}>(),
{},
);
const store = useStore(); const store = useStore();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "focusReply", reply: Item.ReplyProps): void, (e: 'focusReply', reply: Item.ReplyProps): void;
(e: "reload"): void (e: 'reload'): void;
}>(); }>();
const focusReply = () => { const focusReply = () => {
emit('focusReply', props.reply); emit('focusReply', props.reply);
}; };
const execDelAction = () => { const execDelAction = () => {
deleteCommentReply({ deleteCommentReply({
id: props.reply.id, id: props.reply.id,
}) })
.then((res) => { .then((res) => {
window.$message.success(''); window.$message.success('');
setTimeout(() => { setTimeout(() => {
emit('reload'); emit('reload');
}, 50); }, 50);
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.reply-item { .reply-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 12px; font-size: 12px;
padding: 8px; padding: 8px;
border-bottom: 1px solid #f3f3f3; border-bottom: 1px solid #f3f3f3;
.header-wrap { .header-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.username { .username {
max-width: 50%; max-width: 50%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
.reply-name { .reply-name {
margin: 0 3px; margin: 0 3px;
opacity: 0.75; opacity: 0.75;
} }
} }
.timestamp { .timestamp {
opacity: 0.75; opacity: 0.75;
text-align: right; text-align: right;
display: flex; display: flex;
align-items: center; align-items: center;
max-width: 50%; max-width: 50%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
}
} }
.base-wrap { }
display: flex; .base-wrap {
.content { display: flex;
width: calc(100% - 40px); .content {
margin-top: 4px; width: calc(100% - 40px);
font-size: 12px; margin-top: 4px;
text-align: justify; font-size: 12px;
line-height: 2; text-align: justify;
} line-height: 2;
.reply-switch { }
width: 40px; .reply-switch {
text-align: right; width: 40px;
font-size: 12px; text-align: right;
margin: 10px 0 0; font-size: 12px;
margin: 10px 0 0;
.show { .show {
color: #18a058; color: #18a058;
cursor: pointer; cursor: pointer;
} }
.hide { .hide {
opacity: 0.75; opacity: 0.75;
cursor: pointer; cursor: pointer;
} }
}
} }
}
} }
.dark { .dark {
.reply-item { .reply-item {
border-bottom: 1px solid #262628; border-bottom: 1px solid #262628;
} }
} }
</style> </style>

@ -1,71 +1,64 @@
<template> <template>
<div class="rightbar-wrap" v-if="!store.state.collapsedRight"> <div v-if="!store.state.collapsedRight" class="rightbar-wrap">
<div class="search-wrap"> <div class="search-wrap">
<n-input <n-input
round v-model:value="keyword"
clearable round
placeholder="搜一搜..." clearable
v-model:value="keyword" placeholder="搜一搜..."
@keyup.enter.prevent="handleSearch" @keyup.enter.prevent="handleSearch"
> >
<template #prefix> <template #prefix>
<n-icon :component="Search" /> <n-icon :component="Search" />
</template> </template>
</n-input> </n-input>
</div>
<n-card title="热门话题" embedded :bordered="false" size="small">
<n-spin :show="loading">
<div class="hot-tag-item" v-for="tag in tags" :key="tag.id">
<router-link
class="hash-link"
:to="{
name: 'home',
query: {
q: tag.tag,
t: 'tag',
},
}"
>
#{{ tag.tag }}
</router-link>
<div class="post-num">
{{ formatQuoteNum(tag.quote_num) }}
</div>
</div>
</n-spin>
</n-card>
<n-card class="copyright-wrap" embedded :bordered="false" size="small">
<div class="copyright">&copy; 2022 PaoPao.Info</div>
<div class="copyright">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
class="beian-link"
>
ICP2020036525-5
</a>
</div>
<div>
<n-space>
<a
href="https://www.rocs.me"
target="_blank"
class="hash-link"
>Roc's Me</a
>
<a
href="https://www.rocboss.com"
target="_blank"
class="hash-link"
>
ROCBOSS
</a>
</n-space>
</div>
</n-card>
</div> </div>
<n-card title="热门话题" embedded :bordered="false" size="small">
<n-spin :show="loading">
<div v-for="tag in tags" :key="tag.id" class="hot-tag-item">
<router-link
class="hash-link"
:to="{
name: 'home',
query: {
q: tag.tag,
t: 'tag',
},
}"
>
#{{ tag.tag }}
</router-link>
<div class="post-num">
{{ formatQuoteNum(tag.quote_num) }}
</div>
</div>
</n-spin>
</n-card>
<n-card class="copyright-wrap" embedded :bordered="false" size="small">
<div class="copyright">&copy; 2022 PaoPao.Info</div>
<div class="copyright">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
class="beian-link"
>
ICP2020036525-5
</a>
</div>
<div>
<n-space>
<a href="https://www.rocs.me" target="_blank" class="hash-link"
>Roc's Me</a
>
<a href="https://www.rocboss.com" target="_blank" class="hash-link">
ROCBOSS
</a>
</n-space>
</div>
</n-card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -82,82 +75,82 @@ const store = useStore();
const router = useRouter(); const router = useRouter();
const loadTags = () => { const loadTags = () => {
loading.value = true; loading.value = true;
getTags({ getTags({
type: 'hot', type: 'hot',
num: 12, num: 12,
})
.then((res) => {
tags.value = res;
loading.value = false;
}) })
.then((res) => { .catch((err) => {
tags.value = res; loading.value = false;
loading.value = false; });
})
.catch((err) => {
loading.value = false;
});
}; };
const formatQuoteNum = (num: number) => { const formatQuoteNum = (num: number) => {
if (num >= 1000) { if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'; return (num / 1000).toFixed(1) + 'k';
} }
return num; return num;
}; };
const handleSearch = () => { const handleSearch = () => {
router.push({ router.push({
name: 'home', name: 'home',
query: { query: {
q: keyword.value, q: keyword.value,
}, },
}); });
}; };
onMounted(() => { onMounted(() => {
loadTags(); loadTags();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.rightbar-wrap { .rightbar-wrap {
width: 240px; width: 240px;
position: fixed; position: fixed;
left: calc(50% + var(--content-main) / 2 + 10px); left: calc(50% + var(--content-main) / 2 + 10px);
.search-wrap { .search-wrap {
margin: 12px 0; margin: 12px 0;
} }
.hot-tag-item { .hot-tag-item {
line-height: 2; line-height: 2;
position: relative; position: relative;
.hash-link { .hash-link {
width: calc(100% - 60px); width: calc(100% - 60px);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
display: block; display: block;
} }
.post-num { .post-num {
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
width: 60px; width: 60px;
text-align: right; text-align: right;
line-height: 2; line-height: 2;
opacity: 0.5; opacity: 0.5;
}
} }
}
.copyright-wrap { .copyright-wrap {
margin-top: 10px; margin-top: 10px;
.copyright { .copyright {
font-size: 12px; font-size: 12px;
opacity: 0.75; opacity: 0.75;
} }
.hash-link { .hash-link {
font-size: 12px; font-size: 12px;
}
} }
}
} }
</style> </style>

@ -1,90 +1,85 @@
<template> <template>
<div class="sidebar-wrap"> <div class="sidebar-wrap">
<div class="logo-wrap"> <div class="logo-wrap">
<n-image <n-image
class="logo-img" class="logo-img"
width="36" width="36"
:src="LOGO" :src="LOGO"
:preview-disabled="true" :preview-disabled="true"
@click="goHome" @click="goHome"
/> />
</div> </div>
<n-menu <n-menu
:accordion="true" :accordion="true"
:collapsed="store.state.collapsedLeft" :collapsed="store.state.collapsedLeft"
:collapsed-width="64" :collapsed-width="64"
:icon-size="24" :icon-size="24"
:options="menuOptions" :options="menuOptions"
:render-label="renderMenuLabel" :render-label="renderMenuLabel"
:render-icon="renderMenuIcon" :render-icon="renderMenuIcon"
:value="selectedPath" :value="selectedPath"
@update:value="goRouter" @update:value="goRouter"
/> />
<div class="user-wrap" v-if="store.state.userInfo.id > 0">
<n-avatar
class="user-avatar"
round
:size="34"
:src="store.state.userInfo.avatar"
/>
<div class="user-info"> <div v-if="store.state.userInfo.id > 0" class="user-wrap">
<div class="nickname"> <n-avatar
<span class="nickname-txt"> class="user-avatar"
{{ store.state.userInfo.nickname }} round
</span> :size="34"
<n-button :src="store.state.userInfo.avatar"
class="logout" />
quaternary
circle
size="tiny"
@click="handleLogout"
>
<template #icon>
<n-icon><log-out-outline /></n-icon>
</template>
</n-button>
</div>
<div class="username">@{{ store.state.userInfo.username }}</div>
</div>
<div class="user-mini-wrap"> <div class="user-info">
<n-button <div class="nickname">
class="logout" <span class="nickname-txt">
quaternary {{ store.state.userInfo.nickname }}
circle </span>
@click="handleLogout" <n-button
> class="logout"
<template #icon> quaternary
<n-icon :size="24"><log-out-outline /></n-icon> circle
</template> size="tiny"
</n-button> @click="handleLogout"
</div> >
</div> <template #icon>
<div class="user-wrap" v-else> <n-icon><log-out-outline /></n-icon>
<div class="login-wrap"> </template>
<n-button </n-button>
strong
secondary
round
type="primary"
@click="triggerAuth('signin')"
>
</n-button>
<n-button
strong
secondary
round
type="info"
@click="triggerAuth('signup')"
>
</n-button>
</div>
</div> </div>
<div class="username">@{{ store.state.userInfo.username }}</div>
</div>
<div class="user-mini-wrap">
<n-button class="logout" quaternary circle @click="handleLogout">
<template #icon>
<n-icon :size="24"><log-out-outline /></n-icon>
</template>
</n-button>
</div>
</div>
<div v-else class="user-wrap">
<div class="login-wrap">
<n-button
strong
secondary
round
type="primary"
@click="triggerAuth('signin')"
>
</n-button>
<n-button
strong
secondary
round
type="info"
@click="triggerAuth('signup')"
>
</n-button>
</div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -92,16 +87,16 @@ import { h, ref, watch, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { NIcon, NBadge, useMessage } from 'naive-ui'; import { NIcon, NBadge, useMessage } from 'naive-ui';
import type { RouteRecordName } from "vue-router"; import type { RouteRecordName } from 'vue-router';
import { import {
HomeOutline, HomeOutline,
BookmarkOutline, BookmarkOutline,
NotificationsOutline, NotificationsOutline,
HeartOutline, HeartOutline,
LeafOutline, LeafOutline,
WalletOutline, WalletOutline,
SettingsOutline, SettingsOutline,
LogOutOutline, LogOutOutline,
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { Hash } from '@vicons/tabler'; import { Hash } from '@vicons/tabler';
import { getUnreadMsgCount } from '@/api/user'; import { getUnreadMsgCount } from '@/api/user';
@ -115,159 +110,159 @@ const selectedPath = ref<any>(route.name || '');
const msgLoop = ref(); const msgLoop = ref();
watch(route, () => { watch(route, () => {
selectedPath.value = route.name; selectedPath.value = route.name;
}); });
watch(store.state, () => { watch(store.state, () => {
if (store.state.userInfo.id > 0) { if (store.state.userInfo.id > 0) {
if (!msgLoop.value) { if (!msgLoop.value) {
getUnreadMsgCount() getUnreadMsgCount()
.then((res) => { .then((res) => {
hasUnreadMsg.value = res.count > 0; hasUnreadMsg.value = res.count > 0;
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
msgLoop.value = setInterval(() => { msgLoop.value = setInterval(() => {
getUnreadMsgCount() getUnreadMsgCount()
.then((res) => { .then((res) => {
hasUnreadMsg.value = res.count > 0; hasUnreadMsg.value = res.count > 0;
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
}, 5000); }, 5000);
} }
} else { } else {
if (msgLoop.value) { if (msgLoop.value) {
clearInterval(msgLoop.value); clearInterval(msgLoop.value);
}
} }
}
}); });
onMounted(() => { onMounted(() => {
window.onresize = () => { window.onresize = () => {
store.commit('triggerCollapsedLeft', document.body.clientWidth <= 821); store.commit('triggerCollapsedLeft', document.body.clientWidth <= 821);
store.commit('triggerCollapsedRight', document.body.clientWidth <= 821); store.commit('triggerCollapsedRight', document.body.clientWidth <= 821);
}; };
}); });
const menuOptions = computed(() => { const menuOptions = computed(() => {
return store.state.userInfo.id > 0 return store.state.userInfo.id > 0
? [ ? [
{ {
label: '广', label: '广',
key: 'home', key: 'home',
icon: () => h(HomeOutline), icon: () => h(HomeOutline),
href: '/', href: '/',
}, },
{ {
label: '', label: '',
key: 'topic', key: 'topic',
icon: () => h(Hash), icon: () => h(Hash),
href: '/topic', href: '/topic',
}, },
{ {
label: '', label: '',
key: 'profile', key: 'profile',
icon: () => h(LeafOutline), icon: () => h(LeafOutline),
href: '/profile', href: '/profile',
}, },
{ {
label: '', label: '',
key: 'notification', key: 'notification',
icon: () => h(NotificationsOutline), icon: () => h(NotificationsOutline),
href: '/notification', href: '/notification',
}, },
{ {
label: '', label: '',
key: 'collection', key: 'collection',
icon: () => h(BookmarkOutline), icon: () => h(BookmarkOutline),
href: '/collection', href: '/collection',
}, },
{ {
label: '', label: '',
key: 'star', key: 'star',
icon: () => h(HeartOutline), icon: () => h(HeartOutline),
href: '/star', href: '/star',
}, },
{ {
label: '', label: '',
key: 'wallet', key: 'wallet',
icon: () => h(WalletOutline), icon: () => h(WalletOutline),
href: '/wallet', href: '/wallet',
}, },
{ {
label: '', label: '',
key: 'setting', key: 'setting',
icon: () => h(SettingsOutline), icon: () => h(SettingsOutline),
href: '/setting', href: '/setting',
}, },
] ]
: [ : [
{ {
label: '广', label: '广',
key: 'home', key: 'home',
icon: () => h(HomeOutline), icon: () => h(HomeOutline),
href: '/', href: '/',
}, },
{ {
label: '', label: '',
key: 'topic', key: 'topic',
icon: () => h(Hash), icon: () => h(Hash),
href: '/topic', href: '/topic',
}, },
]; ];
}); });
const renderMenuLabel = (option: AnyObject) => { const renderMenuLabel = (option: AnyObject) => {
if ('href' in option) { if ('href' in option) {
return h('div', {}, option.label); return h('div', {}, option.label);
} }
return option.label; return option.label;
}; };
const renderMenuIcon = (option: AnyObject) => { const renderMenuIcon = (option: AnyObject) => {
if (option.key === 'notification') { if (option.key === 'notification') {
return h( return h(
NBadge, NBadge,
{
dot: true,
show: hasUnreadMsg.value,
processing: true,
},
{
default: () =>
h(
NIcon,
{ {
dot: true, color:
show: hasUnreadMsg.value, option.key === selectedPath.value
processing: true, ? 'var(--n-item-icon-color-active)'
: 'var(--n-item-icon-color)',
}, },
{ { default: option.icon },
default: () => ),
h( },
NIcon, );
{ }
color: return h(NIcon, null, { default: option.icon });
option.key === selectedPath.value
? 'var(--n-item-icon-color-active)'
: 'var(--n-item-icon-color)',
},
{ default: option.icon }
),
}
);
}
return h(NIcon, null, { default: option.icon });
}; };
const goRouter = (name: string, item: any = {}) => { const goRouter = (name: string, item: any = {}) => {
selectedPath.value = name; selectedPath.value = name;
router.push({ name }); router.push({ name });
}; };
const goHome = () => { const goHome = () => {
if (route.path === '/') { if (route.path === '/') {
store.commit('refresh'); store.commit('refresh');
} }
goRouter('home'); goRouter('home');
}; };
const triggerAuth = (key: string) => { const triggerAuth = (key: string) => {
store.commit('triggerAuth', true); store.commit('triggerAuth', true);
store.commit('triggerAuthKey', key); store.commit('triggerAuthKey', key);
}; };
const handleLogout = () => { const handleLogout = () => {
store.commit('userLogout'); store.commit('userLogout');
}; };
window.$store = store; window.$store = store;
window.$message = useMessage(); window.$message = useMessage();
@ -275,112 +270,112 @@ window.$message = useMessage();
<style lang="less"> <style lang="less">
.sidebar-wrap { .sidebar-wrap {
z-index: 99; z-index: 99;
width: 200px; width: 200px;
height: 100vh; height: 100vh;
position: fixed; position: fixed;
right: calc(50% + var(--content-main) / 2 + 10px); right: calc(50% + var(--content-main) / 2 + 10px);
padding: 12px 0; padding: 12px 0;
box-sizing: border-box; box-sizing: border-box;
.n-menu .n-menu-item-content::before { .n-menu .n-menu-item-content::before {
border-radius: 21px; border-radius: 21px;
} }
} }
.logo-wrap { .logo-wrap {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 12px; margin-bottom: 12px;
.logo-img { .logo-img {
margin-left: 24px; margin-left: 24px;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
}
} }
}
} }
.user-wrap { .user-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
left: 12px; left: 12px;
right: 12px; right: 12px;
.user-mini-wrap { .user-mini-wrap {
display: none; display: none;
} }
.user-avatar { .user-avatar {
margin-right: 8px; margin-right: 8px;
} }
.user-info { .user-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.nickname { .nickname {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
line-height: 16px; line-height: 16px;
height: 16px; height: 16px;
margin-bottom: 2px; margin-bottom: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
.nickname-txt { .nickname-txt {
max-width: 90px; max-width: 90px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
.logout { .logout {
margin-left: 6px; margin-left: 6px;
} }
} }
.username { .username {
font-size: 14px; font-size: 14px;
line-height: 16px; line-height: 16px;
height: 16px; height: 16px;
width: 120px; width: 120px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
opacity: 0.75; opacity: 0.75;
}
} }
.login-wrap { }
display: flex; .login-wrap {
justify-content: center; display: flex;
width: 100%; justify-content: center;
button { width: 100%;
margin: 0 4px; button {
} margin: 0 4px;
} }
}
} }
.auth-card { .auth-card {
.n-card-header { .n-card-header {
z-index: 999; z-index: 999;
} }
} }
@media screen and (max-width: 821px) { @media screen and (max-width: 821px) {
.sidebar-wrap { .sidebar-wrap {
width: 65px; width: 65px;
right: calc(100% - 60px); right: calc(100% - 60px);
}
.logo-wrap {
.logo-img {
margin-left: 12px !important;
} }
.logo-wrap { }
.logo-img { .user-wrap {
margin-left: 12px !important; .user-avatar,
} .user-info,
.login-wrap {
display: none;
} }
.user-wrap {
.user-avatar,
.user-info,
.login-wrap {
display: none;
}
.user-mini-wrap { .user-mini-wrap {
display: block !important; display: block !important;
}
} }
}
} }
</style> </style>

@ -1,52 +1,52 @@
<template> <template>
<n-modal <n-modal
:show="show" :show="show"
@update:show="closeModal" class="whisper-card"
class="whisper-card" preset="card"
preset="card" size="small"
size="small" title="私信"
title="私信" :mask-closable="false"
:mask-closable="false" :bordered="false"
:bordered="false" :style="{
:style="{ width: '360px',
width: '360px', }"
}" @update:show="closeModal"
> >
<div class="whisper-wrap"> <div class="whisper-wrap">
<n-alert :show-icon="false"> <n-alert :show-icon="false">
: :
<n-ellipsis style="max-width: 100%"> <n-ellipsis style="max-width: 100%">
<n-gradient-text type="success"> <n-gradient-text type="success">
{{ user.nickname }}@{{ user.username }} {{ user.nickname }}@{{ user.username }}
</n-gradient-text> </n-gradient-text>
</n-ellipsis> </n-ellipsis>
</n-alert> </n-alert>
<div class="whisper-line"> <div class="whisper-line">
<n-input <n-input
type="textarea" v-model:value="content"
placeholder="请输入私信内容(请勿发送不和谐内容,否则将会被封号)" type="textarea"
:autosize="{ placeholder="请输入私信内容(请勿发送不和谐内容,否则将会被封号)"
minRows: 5, :autosize="{
maxRows: 10, minRows: 5,
}" maxRows: 10,
v-model:value="content" }"
maxlength="200" maxlength="200"
show-count show-count
/> />
</div> </div>
<div class="whisper-line send-wrap"> <div class="whisper-line send-wrap">
<n-button <n-button
strong strong
secondary secondary
type="primary" type="primary"
:loading="loading" :loading="loading"
@click="sendWhisper" @click="sendWhisper"
> >
</n-button> </n-button>
</div> </div>
</div> </div>
</n-modal> </n-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -54,52 +54,52 @@ import { ref } from 'vue';
import { sendUserWhisper } from '@/api/user'; import { sendUserWhisper } from '@/api/user';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
show: boolean; show: boolean;
user: Item.UserInfo; user: Item.UserInfo;
}>(), }>(),
{ {
show: false, show: false,
} },
); );
const content = ref(''); const content = ref('');
const loading = ref(false); const loading = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'success'): void; (e: 'success'): void;
}>(); }>();
const closeModal = () => { const closeModal = () => {
emit('success'); emit('success');
}; };
const sendWhisper = () => { const sendWhisper = () => {
loading.value = true; loading.value = true;
sendUserWhisper({ sendUserWhisper({
user_id: props.user.id, user_id: props.user.id,
content: content.value, content: content.value,
}) })
.then((res: any) => { .then((res: any) => {
window.$message.success(''); window.$message.success('');
loading.value = false; loading.value = false;
content.value = ''; content.value = '';
closeModal(); closeModal();
}) })
.catch((err: any) => { .catch((err: any) => {
loading.value = false; loading.value = false;
}); });
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.whisper-wrap { .whisper-wrap {
.whisper-line { .whisper-line {
margin-top: 10px; margin-top: 10px;
&.send-wrap { &.send-wrap {
.n-button { .n-button {
width: 100%; width: 100%;
} }
}
} }
}
} }
</style> </style>

@ -1,188 +1,182 @@
declare module NetParams { declare namespace NetParams {
interface AuthUserLogin {
interface AuthUserLogin { /** 用户名 */
/** 用户名 */ username: string;
username: string, /** 密码 */
/** 密码 */ password: string;
password: string }
}
interface AuthUserRegister {
interface AuthUserRegister { /** 用户名 */
/** 用户名 */ username: string;
username: string, /** 密码 */
/** 密码 */ password: string;
password: string }
}
type AuthUserInfo = string;
type AuthUserInfo = string
interface AuthUpdateUserPassword {
interface AuthUpdateUserPassword { /** 新密码 */
/** 新密码 */ password: string;
password: string, /** 旧密码 */
/** 旧密码 */ old_password: string;
old_password: string }
}
interface UserGetCollections {
interface UserGetCollections { page: number;
page: number, page_size: number;
page_size: number }
}
interface UserPrecheckAttachment {
interface UserPrecheckAttachment { id: number;
id: number }
}
interface UserGetAttachment {
interface UserGetAttachment { id: number;
id: number }
}
interface UserGetUnreadMsgCount {}
interface UserGetUnreadMsgCount {
interface UserGetMessages {
} page: number;
page_size: number;
interface UserGetMessages { }
page: number,
page_size: number interface UserGetUserPosts {
} /** 用户名 */
username: string;
interface UserGetUserPosts { page: number;
/** 用户名 */ page_size: number;
username: string, }
page: number,
page_size: number interface UserGetStars {
} page: number;
page_size: number;
interface UserGetStars { }
page: number,
page_size: number interface UserGetUserProfile {
} username: string;
}
interface UserGetUserProfile {
username: string interface UserGetBills {
} page: number;
page_size: number;
interface UserGetBills { }
page: number,
page_size: number interface UserReqRecharge {
} amount: number;
}
interface UserReqRecharge {
amount: number interface UserGetRecharge {
} id: number;
}
interface UserGetRecharge {
id: number interface UserBindUserPhone {
} phone: string;
captcha: string;
interface UserBindUserPhone { }
phone: string,
captcha: string interface UserGetCaptcha {}
}
interface UserWhisper {
interface UserGetCaptcha { user_id: number;
content: string;
} }
interface UserWhisper { interface UserChangePassword {
user_id: number, /** 新密码 */
content: string password: string;
} /** 旧密码 */
old_password: string;
interface UserChangePassword { }
/** 新密码 */
password: string, interface UserChangeNickname {
/** 旧密码 */ /** 昵称 */
old_password: string nickname: string;
} }
interface UserChangeNickname { interface PostGetPost {
/** 昵称 */ id: number;
nickname: string }
}
interface PostGetPosts {
interface PostGetPost { query: string | null;
id: number type: string;
} page: number;
page_size: number;
interface PostGetPosts { }
query: string | null,
type: string, interface PostLockPost {
page: number, id: number;
page_size: number }
}
interface PostStickPost {
interface PostLockPost { id: number;
id: number }
}
interface PostGetPostStar {
interface PostStickPost { id: number;
id: number }
}
interface PostPostStar {
interface PostGetPostStar { id: number;
id: number }
}
interface PostGetPostCollection {
interface PostPostStar { id: number;
id: number }
}
interface PostPostCollection {
interface PostGetPostCollection { id: number;
id: number }
}
interface PostGetTags {
interface PostPostCollection { type: 'hot' | string;
id: number num: number;
} }
interface PostGetTags { interface PostGetPostComments {
type: "hot" | string, id: number;
num: number }
}
interface PostCreatePost {
interface PostGetPostComments { /** 帖子内容列表 */
id: number contents: Partial<Item.PostItemProps>[];
} /** 标签列表 */
tags: string[];
interface PostCreatePost { /** 艾特用户列表 */
/** 帖子内容列表 */ users: string[];
contents: Partial<Item.PostItemProps>[], /** 附件价格 */
/** 标签列表 */ attachment_price: number;
tags: string[], }
/** 艾特用户列表 */
users: string[], interface PostDeletePost {
/** 附件价格 */ id: number;
attachment_price: number }
}
interface PostCreateComment {
interface PostDeletePost { /** 内容ID */
id: number post_id: number;
} /** 帖子内容列表 */
contents: Partial<Item.CommentItemProps>[];
interface PostCreateComment { /** 艾特用户列表 */
/** 内容ID */ users: string[];
post_id: number, }
/** 帖子内容列表 */
contents: Partial<Item.CommentItemProps>[], interface PostDeleteComment {
/** 艾特用户列表 */ id: number;
users: string[] }
}
interface PostCreateCommentReply {
interface PostDeleteComment { /** 艾特的用户UID */
id: number at_user_id: number;
} /** 回复的评论ID */
comment_id: number;
interface PostCreateCommentReply { /** 回复内容 */
/** 艾特的用户UID */ content: string;
at_user_id: number, }
/** 回复的评论ID */
comment_id: number, interface PostDeleteCommentReply {
/** 回复内容 */ id: number;
content: string }
}
interface PostDeleteCommentReply{
id: number
}
} }

@ -1,158 +1,142 @@
declare module NetReq { declare namespace NetReq {
interface AuthUserLogin {
token: string;
}
interface AuthUserRegister {
/** 用户UID */
id: number;
/** 用户名 */
username: string;
}
type AuthUserInfo = Item.UserInfo;
interface AuthUpdateUserPassword {}
interface UserGetCollections {
/** 帖子列表 */
list: Item.PostProps[];
/** 页码信息 */
pager: Item.PagerProps;
}
type UserGetSuggestUsers = string[];
type UserGetSuggestTags = string[];
interface UserPrecheckAttachment {
paid: number;
}
type UserGetAttachment = string;
interface UserGetUnreadMsgCount {
count: number;
}
interface UserGetMessages {
/** 消息列表 */
list: Item.MessageProps[];
/** 页码信息 */
pager: Item.PagerProps;
}
interface AuthUserLogin { interface UserGetUserPosts {
token: string /** 帖子列表 */
} list: Item.PostProps[];
/** 页码信息 */
pager: Item.PagerProps;
}
interface AuthUserRegister { interface UserGetStars {
/** 用户UID */ /** 帖子列表 */
id: number, list: Item.PostProps[];
/** 用户名 */ /** 页码信息 */
username: string pager: Item.PagerProps;
} }
type AuthUserInfo = Item.UserInfo type UserGetUserProfile = Item.UserInfo;
interface AuthUpdateUserPassword { interface UserGetBills {
list: Item.BillProps[];
/** 页码信息 */
pager: Item.PagerProps;
}
} interface UserReqRecharge {
id: number;
pay: string;
}
interface UserGetCollections { interface UserGetRecharge {
/** 帖子列表 */ status: string;
list: Item.PostProps[], }
/** 页码信息 */
pager: Item.PagerProps
}
type UserGetSuggestUsers = string[] interface UserBindUserPhone {}
type UserGetSuggestTags = string[]
interface UserPrecheckAttachment { interface UserGetCaptcha {
paid: number id: string;
} /** 头像图片 base64 */
b64s: string;
}
type UserGetAttachment = string interface UserChangeNickname {}
interface UserGetUnreadMsgCount { interface UserChangePassword {}
count: number
}
interface UserGetMessages { type PostGetPost = Item.PostProps;
/** 消息列表 */
list: Item.MessageProps[],
/** 页码信息 */
pager: Item.PagerProps
}
interface UserGetUserPosts { interface PostGetPosts {
/** 帖子列表 */ /** 帖子列表 */
list: Item.PostProps[], list: Item.PostProps[];
/** 页码信息 */ /** 页码信息 */
pager: Item.PagerProps pager: Item.PagerProps;
} }
interface UserGetStars { interface PostLockPost {
/** 帖子列表 */ /** 锁定状态0为未锁定1为锁定 */
list: Item.PostProps[], lock_status: 0 | 1;
/** 页码信息 */ }
pager: Item.PagerProps
}
type UserGetUserProfile = Item.UserInfo interface PostStickPost {
/** 置顶状态0为未置顶1为置顶 */
top_status: 0 | 1;
}
interface UserGetBills { interface PostGetPostStar {
list: Item.BillProps[], status: boolean;
/** 页码信息 */ }
pager: Item.PagerProps
}
interface UserReqRecharge { interface PostPostStar {
id: number, status: boolean;
pay: string }
}
interface UserGetRecharge { interface PostGetPostCollection {
status: string status: boolean;
} }
interface UserBindUserPhone { interface PostPostCollection {
status: boolean;
}
} type PostGetTags = Item.TagProps[];
interface UserGetCaptcha { interface PostGetPostComments {
id: string, /** 评论列表 */
/** 头像图片 base64 */ list: Item.CommentProps[];
b64s: string /** 页码信息 */
} pager: Item.PagerProps;
}
interface UserChangeNickname { type PostCreatePost = Item.PostProps;
} interface PostDeletePost {}
interface UserChangePassword { type PostCreateComment = Item.CommentProps;
} interface PostDeleteComment {}
type PostGetPost = Item.PostProps type PostCreateCommentReply = Item.ReplyProps;
interface PostGetPosts {
/** 帖子列表 */
list: Item.PostProps[],
/** 页码信息 */
pager: Item.PagerProps
}
interface PostLockPost {
/** 锁定状态0为未锁定1为锁定 */
lock_status: 0 | 1
}
interface PostStickPost {
/** 置顶状态0为未置顶1为置顶 */
top_status: 0 | 1
}
interface PostGetPostStar {
status: boolean
}
interface PostPostStar {
status: boolean
}
interface PostGetPostCollection {
status: boolean
}
interface PostPostCollection {
status: boolean
}
type PostGetTags = Item.TagProps[]
interface PostGetPostComments {
/** 评论列表 */
list: Item.CommentProps[],
/** 页码信息 */
pager: Item.PagerProps
}
type PostCreatePost = Item.PostProps
interface PostDeletePost {
}
type PostCreateComment = Item.CommentProps
interface PostDeleteComment {
}
type PostCreateCommentReply = Item.ReplyProps
interface PostDeleteCommentReply{
}
interface PostDeleteCommentReply {}
} }

@ -1,290 +1,288 @@
declare module Item { declare namespace Item {
interface UserInfo {
/** 用户UID */
id: number;
/** 用户名 */
username: string;
/** 用户昵称 */
nickname: string;
/** 用户头像 */
avatar: string;
/** 用户手机号 */
phone?: string;
/** 是否为管理员 */
is_admin: boolean;
/** 用户余额(分) */
balance?: number;
/** 用户状态 */
status?: 0 | 1;
}
interface UserInfo { /** 评论内容 */
/** 用户UID */ interface CommentItemProps {
id: number, /** 内容ID */
/** 用户名 */ id: number;
username: string, /** 评论ID */
/** 用户昵称 */ comment_id: number;
nickname: string, /** 评论者UID */
/** 用户头像 */ user_id: number;
avatar: string, /** 类别1为标题2为文字段落3为图片地址4为视频地址5为语音地址6为链接地址 */
/** 用户手机号 */ type: number;
phone?: string, /** 内容 */
/** 是否为管理员 */ content: string;
is_admin: boolean, /** 排序,越小越靠前 */
/** 用户余额(分) */ sort: number;
balance?: number, /** 创建时间 */
/** 用户状态 */ created_on: number;
status?: 0 | 1 /** 修改时间 */
} modified_on?: number;
/** 删除时间 */
deleted_on?: number;
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1;
}
/** 评论数据 */
interface CommentProps {
id: number;
post_id: number;
/** 评论者UID */
user_id: number;
/** 评论者用户信息 */
user: UserInfo;
/** 评论内容 */ /** 评论内容 */
interface CommentItemProps { contents: CommentItemProps[];
/** 内容ID */ /** 回复列表 */
id: number, replies: ReplyProps[];
/** 评论ID */ /** 评论者IP地址 */
comment_id: number, ip?: string;
/** 评论者UID */ /** 评论者城市地址 */
user_id: number, ip_loc: string;
/** 类别1为标题2为文字段落3为图片地址4为视频地址5为语音地址6为链接地址 */ /** 创建时间 */
type: number, created_on: number;
/** 内容 */ /** 修改时间 */
content: string, modified_on?: number;
/** 排序,越小越靠前 */ /** 删除时间 */
sort: number, deleted_on?: number;
/** 创建时间 */ /** 是否删除0为未删除1为已删除 */
created_on: number, is_del?: 0 | 1;
/** 修改时间 */ }
modified_on?: number,
/** 删除时间 */
deleted_on?: number,
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1
}
/** 评论数据 */ interface CommentComponentProps extends CommentProps {
interface CommentProps { /** 文字评论列表 */
id: number, texts: CommentItemProps[];
post_id: number, /** 图片评论列表 */
/** 评论者UID */ imgs: CommentItemProps[];
user_id: number, }
/** 评论者用户信息 */
user: UserInfo,
/** 评论内容 */
contents: CommentItemProps[],
/** 回复列表 */
replies: ReplyProps[],
/** 评论者IP地址 */
ip?: string,
/** 评论者城市地址 */
ip_loc: string,
/** 创建时间 */
created_on: number,
/** 修改时间 */
modified_on?: number,
/** 删除时间 */
deleted_on?: number,
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1
}
interface CommentComponentProps extends CommentProps { /** 回复内容 */
/** 文字评论列表 */ interface ReplyProps {
texts: CommentItemProps[], /** 内容ID */
/** 图片评论列表 */ id: number;
imgs: CommentItemProps[] /** 评论ID */
} comment_id: number;
/** 回复人ID */
user_id: number;
/** 回复人用户数据 */
user: UserInfo;
/** 艾特人ID */
at_user_id: number;
/** 艾特人用户数据 */
at_user: UserInfo;
/** 内容 */
content: string;
/** 回复人IP地址 */
ip?: string;
/** 回复人城市地址 */
ip_loc: string;
/** 创建时间 */
created_on: number;
/** 修改时间 */
modified_on?: number;
/** 删除时间 */
deleted_on?: number;
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1;
}
/** 回复内容 */ /** 帖子内容 */
interface ReplyProps { interface PostItemProps {
/** 内容ID */ /** 内容ID */
id: number, id: number;
/** 评论ID */ /** 类型1为标题2为文字段落3为图片地址4为视频地址5为语音地址6为链接地址7为附件资源8为收费资源 */
comment_id: number, type: number;
/** 回复人ID */ /** POST ID */
user_id: number, post_id: number;
/** 回复人用户数据 */ /** 内容 */
user: UserInfo, content: string;
/** 艾特人ID */ /** 排序,越小越靠前 */
at_user_id: number, sort: number;
/** 艾特人用户数据 */ /** 用户UID */
at_user: UserInfo, user_id?: number;
/** 内容 */ /** 创建时间 */
content: string, created_on: number;
/** 回复人IP地址 */ /** 修改时间 */
ip?: string, modified_on?: number;
/** 回复人城市地址 */ /** 删除时间 */
ip_loc: string, deleted_on?: number;
/** 创建时间 */ /** 是否删除0为未删除1为已删除 */
created_on: number, is_del?: 0 | 1;
/** 修改时间 */ }
modified_on?: number,
/** 删除时间 */
deleted_on?: number,
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1
}
/** 帖子内容 */
interface PostItemProps {
/** 内容ID */
id: number,
/** 类型1为标题2为文字段落3为图片地址4为视频地址5为语音地址6为链接地址7为附件资源8为收费资源 */
type: number,
/** POST ID */
post_id: number,
/** 内容 */
content: string,
/** 排序,越小越靠前 */
sort: number,
/** 用户UID */
user_id?: number,
/** 创建时间 */
created_on: number,
/** 修改时间 */
modified_on?: number,
/** 删除时间 */
deleted_on?: number,
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1,
}
/** 帖子 */
interface PostProps {
id: number,
/** 发帖人UID */
user_id: number,
/** 发帖人用户数据 */
user: UserInfo,
/** 附件价格(分) */
attachment_price: number,
/** 发帖时IP地址 */
ip?: string,
/** 发帖时城市地址 */
ip_loc: string,
/** 最新回复时间 */
latest_replied_on: number,
/** 创建时间 */
created_on: number,
/** 修改时间 */
modified_on?: number,
/** 删除时间 */
deleted_on?: number,
/** 点赞数 */
upvote_count: number,
/** 评论数 */
comment_count: number,
/** 收藏数 */
collection_count: number,
/** 内容列表 */
contents: PostItemProps[],
/** 标签列表 */
tags: {[key: string]: number} | string,
/** 是否锁定 */
is_lock: number,
/** 是否置顶 */
is_top: number,
/** 是否精华 */
is_essence: number,
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1
}
/** 组件用帖子 */ /** 帖子 */
interface PostComponentProps extends PostProps { interface PostProps {
/** 文字段落列表 */ id: number;
texts: PostItemProps[], /** 发帖人UID */
/** 图片列表 */ user_id: number;
imgs: PostItemProps[], /** 发帖人用户数据 */
/** 视频列表 */ user: UserInfo;
videos: PostItemProps[], /** 附件价格(分) */
/** 链接列表 */ attachment_price: number;
links: PostItemProps[], /** 发帖时IP地址 */
/** 附件列表 */ ip?: string;
attachments: PostItemProps[], /** 发帖时城市地址 */
/** 收费附件列表 */ ip_loc: string;
charge_attachments: PostItemProps[] /** 最新回复时间 */
} latest_replied_on: number;
/** 创建时间 */
created_on: number;
/** 修改时间 */
modified_on?: number;
/** 删除时间 */
deleted_on?: number;
/** 点赞数 */
upvote_count: number;
/** 评论数 */
comment_count: number;
/** 收藏数 */
collection_count: number;
/** 内容列表 */
contents: PostItemProps[];
/** 标签列表 */
tags: { [key: string]: number } | string;
/** 是否锁定 */
is_lock: number;
/** 是否置顶 */
is_top: number;
/** 是否精华 */
is_essence: number;
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1;
}
interface MessageProps { /** 组件用帖子 */
id: number, interface PostComponentProps extends PostProps {
/** 类型1为动态2为评论3为回复4为私信99为系统通知 */ /** 文字段落列表 */
type: 1 | 2 | 3 | 4 | 99, texts: PostItemProps[];
/** 摘要说明 */ /** 图片列表 */
breif: string, imgs: PostItemProps[];
/** 详细内容 */ /** 视频列表 */
content: string, videos: PostItemProps[];
/** 是否已读0为未读1为已读 */ /** 链接列表 */
is_read: 0 | 1, links: PostItemProps[];
/** 发送人UID */ /** 附件列表 */
sender_user_id: number, attachments: PostItemProps[];
/** 发送人用户数据 */ /** 收费附件列表 */
sender_user: UserInfo, charge_attachments: PostItemProps[];
/** 接收方UID */ }
receiver_user_id: number,
/** 帖子ID */
post_id: number,
/** 帖子内容 */
post: PostProps,
/** 评论ID */
comment_id: number,
/** 评论内容 */
comment: CommentProps,
/** 回复ID */
reply_id: number,
/** 回复内容 */
replay: ReplyProps,
/** 创建时间 */
created_on: number,
/** 修改时间 */
modified_on?: number,
/** 删除时间 */
deleted_on?: number,
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1
}
interface AttachmentProps { interface MessageProps {
id: number, id: number;
/** 类别1为图片2为视频3为其他附件 */ /** 类型1为动态2为评论3为回复4为私信99为系统通知 */
type: 1 | 2 | 3, type: 1 | 2 | 3 | 4 | 99;
/** 发布者用户UID */ /** 摘要说明 */
user_id: number, breif: string;
/** 发布者用户数据 */ /** 详细内容 */
user: UserInfo, content: string;
/** 文件大小 */ /** 是否已读0为未读1为已读 */
file_size: number, is_read: 0 | 1;
/** 图片宽度 */ /** 发送人UID */
img_width?: number, sender_user_id: number;
/** 图片高度 */ /** 发送人用户数据 */
img_height?: number, sender_user: UserInfo;
/** 内容 */ /** 接收方UID */
content: string, receiver_user_id: number;
/** 创建时间 */ /** 帖子ID */
created_on: number, post_id: number;
/** 修改时间 */ /** 帖子内容 */
modified_on?: number, post: PostProps;
/** 删除时间 */ /** 评论ID */
deleted_on?: number, comment_id: number;
/** 是否删除0为未删除1为已删除 */ /** 评论内容 */
is_del?: 0 | 1 comment: CommentProps;
} /** 回复ID */
reply_id: number;
/** 回复内容 */
replay: ReplyProps;
/** 创建时间 */
created_on: number;
/** 修改时间 */
modified_on?: number;
/** 删除时间 */
deleted_on?: number;
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1;
}
interface TagProps { interface AttachmentProps {
id: number, id: number;
/** 创建者UID */ /** 类别1为图片2为视频3为其他附件 */
user_id: number, type: 1 | 2 | 3;
/** 创建者用户数据 */ /** 发布者用户UID */
user: UserInfo, user_id: number;
/** 标签名 */ /** 发布者用户数据 */
tag: string, user: UserInfo;
/** 引用数 */ /** 文件大小 */
quote_num: number, file_size: number;
/** 创建时间 */ /** 图片宽度 */
created_on: number, img_width?: number;
/** 修改时间 */ /** 图片高度 */
modified_on?: number, img_height?: number;
/** 删除时间 */ /** 内容 */
deleted_on?: number, content: string;
/** 是否删除0为未删除1为已删除 */ /** 创建时间 */
is_del?: 0 | 1 created_on: number;
} /** 修改时间 */
modified_on?: number;
/** 删除时间 */
deleted_on?: number;
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1;
}
interface PagerProps { interface TagProps {
/** 当前页码 */ id: number;
page: number, /** 创建者UID */
/** 每页条数 */ user_id: number;
page_size: number, /** 创建者用户数据 */
/** 总条数 */ user: UserInfo;
total_rows: number /** 标签名 */
} tag: string;
/** 引用数 */
quote_num: number;
/** 创建时间 */
created_on: number;
/** 修改时间 */
modified_on?: number;
/** 删除时间 */
deleted_on?: number;
/** 是否删除0为未删除1为已删除 */
is_del?: 0 | 1;
}
interface BillProps { interface PagerProps {
id: number, /** 当前页码 */
reason: string, page: number;
change_amount: number, /** 每页条数 */
created_on: number page_size: number;
} /** 总条数 */
total_rows: number;
}
interface BillProps {
id: number;
reason: string;
change_amount: number;
created_on: number;
}
} }

@ -2,8 +2,8 @@
export const parsePostTag = (content: string) => { export const parsePostTag = (content: string) => {
const tags: string[] = [] const tags: string[] = []
const users: string[] = [] const users: string[] = []
var tagExp = /(#|)([^#@])+?\s+?/g // 这⾥中⽂#和英⽂#都会识别 const tagExp = /(#|)([^#@])+?\s+?/g // 这⾥中⽂#和英⽂#都会识别
var atExp = /@([a-zA-Z0-9])+?\s+?/g // 这⾥中⽂#和英⽂#都会识别 const atExp = /@([a-zA-Z0-9])+?\s+?/g // 这⾥中⽂#和英⽂#都会识别
content = content content = content
.replace(/<[^>]*?>/gi, '') .replace(/<[^>]*?>/gi, '')
.replace(/(.*?)<\/[^>]*?>/gi, '') .replace(/(.*?)<\/[^>]*?>/gi, '')

@ -1,8 +1,8 @@
// 滚动到顶部 // 滚动到顶部
export const scrollToTop = (scrollDuration: number) => { export const scrollToTop = (scrollDuration: number) => {
var cosParameter = window.scrollY / 2; const cosParameter = window.scrollY / 2;
var scrollCount = 0; let scrollCount = 0;
var oldTimestamp = performance.now(); let oldTimestamp = performance.now();
function step(newTimestamp: number) { function step(newTimestamp: number) {
scrollCount += scrollCount +=
Math.PI / (scrollDuration / (newTimestamp - oldTimestamp)); Math.PI / (scrollDuration / (newTimestamp - oldTimestamp));

@ -1,19 +1,19 @@
<template> <template>
<div> <div>
<main-nav title="404" /> <main-nav title="404" />
<n-list class="main-content-wrap wrap404" bordered> <n-list class="main-content-wrap wrap404" bordered>
<n-result <n-result
status="404" status="404"
title="404 资源不存在" title="404 资源不存在"
description="再看看其他的吧" description="再看看其他的吧"
> >
<template #footer> <template #footer>
<n-button @click="goHome"></n-button> <n-button @click="goHome"></n-button>
</template> </template>
</n-result> </n-result>
</n-list> </n-list>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -21,17 +21,17 @@ import { useRouter } from 'vue-router';
const router = useRouter(); const router = useRouter();
const goHome = () => { const goHome = () => {
router.push({ router.push({
path: '/', path: '/',
}); });
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.wrap404 { .wrap404 {
min-height: 500px; min-height: 500px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
</style> </style>

@ -1,33 +1,33 @@
<template> <template>
<div> <div>
<main-nav title="收藏" /> <main-nav title="收藏" />
<n-list class="main-content-wrap" bordered> <n-list class="main-content-wrap" bordered>
<template #footer> <template #footer>
<div class="pagination-wrap" v-if="totalPage > 0"> <div v-if="totalPage > 0" class="pagination-wrap">
<n-pagination <n-pagination
:page="page" :page="page"
@update:page="updatePage" :page-slot="!store.state.collapsedRight ? 8 : 5"
:page-slot="!store.state.collapsedRight ? 8 : 5" :page-count="totalPage"
:page-count="totalPage" @update:page="updatePage"
/> />
</div> </div>
</template> </template>
<div v-if="loading" class="skeleton-wrap"> <div v-if="loading" class="skeleton-wrap">
<post-skeleton :num="pageSize" /> <post-skeleton :num="pageSize" />
</div> </div>
<div v-else> <div v-else>
<div class="empty-wrap" v-if="list.length === 0"> <div v-if="list.length === 0" class="empty-wrap">
<n-empty size="large" description="暂无数据" /> <n-empty size="large" description="暂无数据" />
</div> </div>
<n-list-item v-for="post in list" :key="post.id"> <n-list-item v-for="post in list" :key="post.id">
<post-item :post="post" /> <post-item :post="post" />
</n-list-item> </n-list-item>
</div> </div>
</n-list> </n-list>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -47,27 +47,27 @@ const pageSize = ref(20);
const totalPage = ref(0); const totalPage = ref(0);
const loadPosts = () => { const loadPosts = () => {
loading.value = true; loading.value = true;
getCollections({ getCollections({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
}) })
.then((rsp) => { .then((rsp) => {
loading.value = false; loading.value = false;
list.value = rsp.list; list.value = rsp.list;
totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value); totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value);
window.scrollTo(0, 0); window.scrollTo(0, 0);
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
}; };
const updatePage = (p: number) => { const updatePage = (p: number) => {
page.value = p; page.value = p;
loadPosts(); loadPosts();
}; };
onMounted(() => { onMounted(() => {
loadPosts(); loadPosts();
}); });
</script> </script>

@ -1,37 +1,37 @@
<template> <template>
<div> <div>
<main-nav :title="title" /> <main-nav :title="title" />
<n-list class="main-content-wrap" bordered> <n-list class="main-content-wrap" bordered>
<template #footer> <template #footer>
<div class="pagination-wrap" v-if="totalPage > 0"> <div v-if="totalPage > 0" class="pagination-wrap">
<n-pagination <n-pagination
:page="page" :page="page"
@update:page="updatePage" :page-slot="!store.state.collapsedRight ? 8 : 5"
:page-slot="!store.state.collapsedRight ? 8 : 5" :page-count="totalPage"
:page-count="totalPage" @update:page="updatePage"
/> />
</div> </div>
</template> </template>
<n-list-item> <n-list-item>
<!-- --> <!-- -->
<compose @post-success="onPostSuccess" /> <compose @post-success="onPostSuccess" />
</n-list-item> </n-list-item>
<div v-if="loading" class="skeleton-wrap"> <div v-if="loading" class="skeleton-wrap">
<post-skeleton :num="pageSize" /> <post-skeleton :num="pageSize" />
</div> </div>
<div v-else> <div v-else>
<div class="empty-wrap" v-if="list.length === 0"> <div v-if="list.length === 0" class="empty-wrap">
<n-empty size="large" description="暂无数据" /> <n-empty size="large" description="暂无数据" />
</div> </div>
<n-list-item v-for="post in list" :key="post.id"> <n-list-item v-for="post in list" :key="post.id">
<post-item :post="post" /> <post-item :post="post" />
</n-list-item> </n-list-item>
</div> </div>
</n-list> </n-list>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -50,91 +50,91 @@ const page = ref(+(route.query.p as string) || 1);
const pageSize = ref(20); const pageSize = ref(20);
const totalPage = ref(0); const totalPage = ref(0);
const title = computed(() => { const title = computed(() => {
let t = '广'; let t = '广';
if (route.query && route.query.q) { if (route.query && route.query.q) {
if (route.query.t && route.query.t === 'tag') { if (route.query.t && route.query.t === 'tag') {
t = '#' + decodeURIComponent(route.query.q as string); t = '#' + decodeURIComponent(route.query.q as string);
} else { } else {
t = ': ' + decodeURIComponent(route.query.q as string); t = ': ' + decodeURIComponent(route.query.q as string);
}
} }
}
return t; return t;
}); });
const loadPosts = () => { const loadPosts = () => {
loading.value = true; loading.value = true;
getPosts({ getPosts({
query: route.query.q ? decodeURIComponent(route.query.q as string) : null, query: route.query.q ? decodeURIComponent(route.query.q as string) : null,
type: route.query.t as string, type: route.query.t as string,
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
}) })
.then((rsp) => { .then((rsp) => {
loading.value = false; loading.value = false;
list.value = rsp.list; list.value = rsp.list;
totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value); totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value);
window.scrollTo(0, 0); window.scrollTo(0, 0);
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
}; };
const onPostSuccess = (post: Item.PostProps) => { const onPostSuccess = (post: Item.PostProps) => {
router.push({ router.push({
name: 'post', name: 'post',
query: { query: {
id: post.id, id: post.id,
}, },
}); });
}; };
const updatePage = (p: number) => { const updatePage = (p: number) => {
router.push({ router.push({
name: 'home', name: 'home',
query: { query: {
...route.query, ...route.query,
p, p,
}, },
}); });
}; };
onMounted(() => { onMounted(() => {
loadPosts(); loadPosts();
}); });
watch( watch(
() => ({ () => ({
path: route.path, path: route.path,
query: route.query, query: route.query,
refresh: store.state.refresh, refresh: store.state.refresh,
}), }),
(to, from) => { (to, from) => {
if (to.refresh !== from.refresh) { if (to.refresh !== from.refresh) {
page.value = +(route.query.p as string) || 1; page.value = +(route.query.p as string) || 1;
setTimeout(() => { setTimeout(() => {
loadPosts(); loadPosts();
}, 0); }, 0);
return; return;
} }
if (from.path !== '/post' && to.path === '/') { if (from.path !== '/post' && to.path === '/') {
page.value = +(route.query.p as string) || 1; page.value = +(route.query.p as string) || 1;
setTimeout(() => { setTimeout(() => {
loadPosts(); loadPosts();
}, 0); }, 0);
}
} }
},
); );
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.pagination-wrap { .pagination-wrap {
padding: 10px; padding: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
} }
</style> </style>

@ -1,34 +1,34 @@
<template> <template>
<div> <div>
<main-nav title="提醒" /> <main-nav title="提醒" />
<n-list class="main-content-wrap messages-wrap" bordered> <n-list class="main-content-wrap messages-wrap" bordered>
<div v-if="loading" class="skeleton-wrap"> <div v-if="loading" class="skeleton-wrap">
<message-skeleton :num="pageSize" /> <message-skeleton :num="pageSize" />
</div> </div>
<div v-else> <div v-else>
<div class="empty-wrap" v-if="list.length === 0"> <div v-if="list.length === 0" class="empty-wrap">
<n-empty size="large" description="暂无数据" /> <n-empty size="large" description="暂无数据" />
</div> </div>
<n-list-item v-for="m in list" :key="m.id"> <n-list-item v-for="m in list" :key="m.id">
<message-item :message="m" /> <message-item :message="m" />
</n-list-item> </n-list-item>
</div> </div>
<template #footer> <template #footer>
<div class="pagination-wrap" v-if="totalPage > 0"> <div v-if="totalPage > 0" class="pagination-wrap">
<n-pagination <n-pagination
:page="page" :page="page"
@update:page="updatePage" :page-slot="!store.state.collapsedRight ? 8 : 5"
:page-slot="!store.state.collapsedRight ? 8 : 5" :page-count="totalPage"
:page-count="totalPage" @update:page="updatePage"
/> />
</div> </div>
</template> </template>
</n-list> </n-list>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -45,34 +45,34 @@ const totalPage = ref(0);
const list = ref<Item.MessageProps[]>([]); const list = ref<Item.MessageProps[]>([]);
const loadMessages = () => { const loadMessages = () => {
loading.value = true; loading.value = true;
getMessages({ getMessages({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
})
.then((res) => {
loading.value = false;
list.value = res.list;
totalPage.value = Math.ceil(res.pager.total_rows / pageSize.value);
}) })
.then((res) => { .catch((err) => {
loading.value = false; loading.value = false;
list.value = res.list; });
totalPage.value = Math.ceil(res.pager.total_rows / pageSize.value);
})
.catch((err) => {
loading.value = false;
});
}; };
const updatePage = (p: number) => { const updatePage = (p: number) => {
page.value = p; page.value = p;
loadMessages(); loadMessages();
}; };
onMounted(() => { onMounted(() => {
loadMessages(); loadMessages();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.pagination-wrap { .pagination-wrap {
padding: 10px; padding: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
} }
</style> </style>

@ -1,48 +1,42 @@
<template> <template>
<div> <div>
<main-nav title="泡泡详情" :back="true" /> <main-nav title="泡泡详情" :back="true" />
<n-list class="main-content-wrap" bordered> <n-list class="main-content-wrap" bordered>
<n-list-item> <n-list-item>
<n-spin :show="loading"> <n-spin :show="loading">
<div class="detail-wrap" v-if="post.id > 0"> <div v-if="post.id > 0" class="detail-wrap">
<post-detail :post="post" @reload="loadPost" /> <post-detail :post="post" @reload="loadPost" />
</div> </div>
<div class="empty-wrap" v-else> <div v-else class="empty-wrap">
<n-empty size="large" description="暂无数据" /> <n-empty size="large" description="暂无数据" />
</div> </div>
</n-spin> </n-spin>
</n-list-item> </n-list-item>
<n-list-item v-if="post.id > 0"> <n-list-item v-if="post.id > 0">
<compose-comment <compose-comment
:lock="post.is_lock" :lock="post.is_lock"
:post-id="post.id" :post-id="post.id"
@post-success="loadComments(true)" @post-success="loadComments(true)"
/> />
</n-list-item> </n-list-item>
<div v-if="post.id > 0"> <div v-if="post.id > 0">
<div v-if="commentLoading" class="skeleton-wrap"> <div v-if="commentLoading" class="skeleton-wrap">
<post-skeleton :num="5" /> <post-skeleton :num="5" />
</div> </div>
<div v-else> <div v-else>
<div class="empty-wrap" v-if="comments.length === 0"> <div v-if="comments.length === 0" class="empty-wrap">
<n-empty <n-empty size="large" description="暂无评论,快来抢沙发" />
size="large" </div>
description="暂无评论,快来抢沙发"
/>
</div>
<n-list-item v-for="comment in comments" :key="comment.id"> <n-list-item v-for="comment in comments" :key="comment.id">
<comment-item <comment-item :comment="comment" @reload="loadComments" />
:comment="comment" </n-list-item>
@reload="loadComments" </div>
/> </div>
</n-list-item> </n-list>
</div> </div>
</div>
</n-list>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -58,57 +52,57 @@ const comments = ref<Item.CommentProps[]>([]);
const postId = computed(() => +(route.query.id as string)); const postId = computed(() => +(route.query.id as string));
const loadPost = () => { const loadPost = () => {
post.value = { post.value = {
id: 0, id: 0,
} as Item.PostProps; } as Item.PostProps;
loading.value = true; loading.value = true;
getPost({ getPost({
id: postId.value, id: postId.value,
}) })
.then((res) => { .then((res) => {
loading.value = false; loading.value = false;
post.value = res; post.value = res;
// 加载评论 // 加载评论
loadComments(); loadComments();
})
.catch((err) => {
loading.value = false;
});
};
const loadComments = (scrollToBottom: boolean = false) => {
if (comments.value.length === 0) {
commentLoading.value = true;
}
getPostComments({
id: post.value.id as number,
}) })
.then((res) => { .catch((err) => {
comments.value = res.list; loading.value = false;
commentLoading.value = false; });
};
const loadComments = (scrollToBottom = false) => {
if (comments.value.length === 0) {
commentLoading.value = true;
}
getPostComments({
id: post.value.id as number,
})
.then((res) => {
comments.value = res.list;
commentLoading.value = false;
if (scrollToBottom) { if (scrollToBottom) {
setTimeout(() => { setTimeout(() => {
window.scrollTo(0, 99999); window.scrollTo(0, 99999);
}, 50); }, 50);
} }
}) })
.catch((err) => { .catch((err) => {
commentLoading.value = false; commentLoading.value = false;
}); });
}; };
onMounted(() => { onMounted(() => {
loadPost(); loadPost();
}); });
watch(postId, () => { watch(postId, () => {
if (postId.value > 0 && route.name === 'post') { if (postId.value > 0 && route.name === 'post') {
loadPost(); loadPost();
} }
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.detail-wrap { .detail-wrap {
min-height: 100px; min-height: 100px;
} }
</style> </style>

@ -1,53 +1,53 @@
<template> <template>
<div> <div>
<main-nav title="主页" /> <main-nav title="主页" />
<n-list <n-list
class="main-content-wrap profile-wrap" v-if="store.state.userInfo.id > 0"
bordered class="main-content-wrap profile-wrap"
v-if="store.state.userInfo.id > 0" bordered
> >
<!-- --> <!-- -->
<div class="profile-baseinfo"> <div class="profile-baseinfo">
<div class="avatar"> <div class="avatar">
<n-avatar size="large" :src="store.state.userInfo.avatar" /> <n-avatar size="large" :src="store.state.userInfo.avatar" />
</div> </div>
<div class="base-info"> <div class="base-info">
<div class="username"> <div class="username">
<strong>{{ store.state.userInfo.nickname }}</strong> <strong>{{ store.state.userInfo.nickname }}</strong>
<span> @{{ store.state.userInfo.username }} </span> <span> @{{ store.state.userInfo.username }} </span>
</div> </div>
<div class="uid">UID. {{ store.state.userInfo.id }}</div> <div class="uid">UID. {{ store.state.userInfo.id }}</div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="pagination-wrap" v-if="totalPage > 0"> <div v-if="totalPage > 0" class="pagination-wrap">
<n-pagination <n-pagination
:page="page" :page="page"
@update:page="updatePage" :page-slot="!store.state.collapsedRight ? 8 : 5"
:page-slot="!store.state.collapsedRight ? 8 : 5" :page-count="totalPage"
:page-count="totalPage" @update:page="updatePage"
/> />
</div> </div>
</template> </template>
<n-tabs class="profile-tabs-wrap" animated> <n-tabs class="profile-tabs-wrap" animated>
<n-tab-pane name="post" tab="泡泡"> </n-tab-pane> <n-tab-pane name="post" tab="泡泡"> </n-tab-pane>
<!-- <n-tab-pane name="comment" tab="评论"> </n-tab-pane> --> <!-- <n-tab-pane name="comment" tab="评论"> </n-tab-pane> -->
</n-tabs> </n-tabs>
<div v-if="loading" class="skeleton-wrap"> <div v-if="loading" class="skeleton-wrap">
<post-skeleton :num="pageSize" /> <post-skeleton :num="pageSize" />
</div> </div>
<div v-else> <div v-else>
<div class="empty-wrap" v-if="list.length === 0"> <div v-if="list.length === 0" class="empty-wrap">
<n-empty size="large" description="暂无数据" /> <n-empty size="large" description="暂无数据" />
</div> </div>
<n-list-item v-for="post in list" :key="post.id"> <n-list-item v-for="post in list" :key="post.id">
<post-item :post="post" /> <post-item :post="post" />
</n-list-item> </n-list-item>
</div> </div>
</n-list> </n-list>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -66,66 +66,66 @@ const pageSize = ref(20);
const totalPage = ref(0); const totalPage = ref(0);
const loadPosts = () => { const loadPosts = () => {
loading.value = true; loading.value = true;
getUserPosts({ getUserPosts({
username: store.state.userInfo.username, username: store.state.userInfo.username,
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
}) })
.then((rsp) => { .then((rsp) => {
loading.value = false; loading.value = false;
list.value = rsp.list; list.value = rsp.list;
totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value); totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value);
window.scrollTo(0, 0); window.scrollTo(0, 0);
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
}; };
const updatePage = (p: number) => { const updatePage = (p: number) => {
page.value = p; page.value = p;
loadPosts(); loadPosts();
}; };
onMounted(() => { onMounted(() => {
loadPosts(); loadPosts();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.profile-baseinfo { .profile-baseinfo {
display: flex; display: flex;
padding: 16px; padding: 16px;
.avatar { .avatar {
width: 55px; width: 55px;
} }
.base-info { .base-info {
position: relative; position: relative;
width: calc(100% - 55px); width: calc(100% - 55px);
.username { .username {
line-height: 16px; line-height: 16px;
font-size: 16px; font-size: 16px;
} }
.uid { .uid {
font-size: 14px; font-size: 14px;
line-height: 14px; line-height: 14px;
margin-top: 10px; margin-top: 10px;
opacity: 0.75; opacity: 0.75;
}
} }
}
} }
.profile-tabs-wrap { .profile-tabs-wrap {
padding: 0 16px; padding: 0 16px;
} }
.pagination-wrap { .pagination-wrap {
padding: 10px; padding: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

@ -1,33 +1,33 @@
<template> <template>
<div> <div>
<main-nav title="点赞" /> <main-nav title="点赞" />
<n-list class="main-content-wrap" bordered> <n-list class="main-content-wrap" bordered>
<template #footer> <template #footer>
<div class="pagination-wrap" v-if="totalPage > 0"> <div v-if="totalPage > 0" class="pagination-wrap">
<n-pagination <n-pagination
:page="page" :page="page"
@update:page="updatePage" :page-slot="!store.state.collapsedRight ? 8 : 5"
:page-slot="!store.state.collapsedRight ? 8 : 5" :page-count="totalPage"
:page-count="totalPage" @update:page="updatePage"
/> />
</div> </div>
</template> </template>
<div v-if="loading" class="skeleton-wrap"> <div v-if="loading" class="skeleton-wrap">
<post-skeleton :num="pageSize" /> <post-skeleton :num="pageSize" />
</div> </div>
<div v-else> <div v-else>
<div class="empty-wrap" v-if="list.length === 0"> <div v-if="list.length === 0" class="empty-wrap">
<n-empty size="large" description="暂无数据" /> <n-empty size="large" description="暂无数据" />
</div> </div>
<n-list-item v-for="post in list" :key="post.id"> <n-list-item v-for="post in list" :key="post.id">
<post-item :post="post" /> <post-item :post="post" />
</n-list-item> </n-list-item>
</div> </div>
</n-list> </n-list>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -47,36 +47,36 @@ const pageSize = ref(20);
const totalPage = ref(0); const totalPage = ref(0);
const loadPosts = () => { const loadPosts = () => {
loading.value = true; loading.value = true;
getStars({ getStars({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
}) })
.then((rsp) => { .then((rsp) => {
loading.value = false; loading.value = false;
list.value = rsp.list; list.value = rsp.list;
totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value); totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value);
window.scrollTo(0, 0); window.scrollTo(0, 0);
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
}; };
const updatePage = (p: number) => { const updatePage = (p: number) => {
page.value = p; page.value = p;
loadPosts(); loadPosts();
}; };
onMounted(() => { onMounted(() => {
loadPosts(); loadPosts();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.pagination-wrap { .pagination-wrap {
padding: 10px; padding: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
} }
</style> </style>

@ -1,42 +1,42 @@
<template> <template>
<div> <div>
<main-nav title="话题" /> <main-nav title="话题" />
<n-list class="main-content-wrap tags-wrap" bordered> <n-list class="main-content-wrap tags-wrap" bordered>
<n-tabs type="line" animated @update:value="changeTab"> <n-tabs type="line" animated @update:value="changeTab">
<n-tab-pane name="hot" tab="热门" /> <n-tab-pane name="hot" tab="热门" />
<n-tab-pane name="new" tab="最新" /> <n-tab-pane name="new" tab="最新" />
</n-tabs> </n-tabs>
<n-spin :show="loading"> <n-spin :show="loading">
<n-space> <n-space>
<n-tag <n-tag
class="tag-item" v-for="tag in tags"
type="success" :key="tag.id"
round class="tag-item"
v-for="tag in tags" type="success"
:key="tag.id" round
> >
<router-link <router-link
class="hash-link" class="hash-link"
:to="{ :to="{
name: 'home', name: 'home',
query: { query: {
q: tag.tag, q: tag.tag,
t: 'tag', t: 'tag',
}, },
}" }"
> >
#{{ tag.tag }} #{{ tag.tag }}
</router-link> </router-link>
<span class="tag-hot">({{ tag.quote_num }})</span> <span class="tag-hot">({{ tag.quote_num }})</span>
<template #avatar> <template #avatar>
<n-avatar :src="tag.user.avatar" /> <n-avatar :src="tag.user.avatar" />
</template> </template>
</n-tag> </n-tag>
</n-space> </n-space>
</n-spin> </n-spin>
</n-list> </n-list>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -48,37 +48,37 @@ const tagType = ref('hot');
const loading = ref(false); const loading = ref(false);
const loadTags = () => { const loadTags = () => {
loading.value = true; loading.value = true;
getTags({ getTags({
type: tagType.value, type: tagType.value,
num: 50, num: 50,
})
.then((res) => {
tags.value = res;
loading.value = false;
}) })
.then((res) => { .catch((err) => {
tags.value = res; loading.value = false;
loading.value = false; });
})
.catch((err) => {
loading.value = false;
});
}; };
const changeTab = (tab: string) => { const changeTab = (tab: string) => {
tagType.value = tab; tagType.value = tab;
loadTags(); loadTags();
}; };
onMounted(() => { onMounted(() => {
loadTags(); loadTags();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.tags-wrap { .tags-wrap {
padding: 20px; padding: 20px;
.tag-item { .tag-item {
.tag-hot { .tag-hot {
margin-left: 12px; margin-left: 12px;
font-size: 12px; font-size: 12px;
opacity: 0.75; opacity: 0.75;
}
} }
}
} }
</style> </style>

@ -1,71 +1,67 @@
<template> <template>
<div> <div>
<main-nav title="用户详情" /> <main-nav title="用户详情" />
<n-list class="main-content-wrap profile-wrap" bordered> <n-list class="main-content-wrap profile-wrap" bordered>
<!-- --> <!-- -->
<n-spin :show="userLoading"> <n-spin :show="userLoading">
<div class="profile-baseinfo" v-if="user.id > 0"> <div v-if="user.id > 0" class="profile-baseinfo">
<div class="avatar"> <div class="avatar">
<n-avatar size="large" :src="user.avatar" /> <n-avatar size="large" :src="user.avatar" />
</div> </div>
<div class="base-info"> <div class="base-info">
<div class="username"> <div class="username">
<strong>{{ user.nickname }}</strong> <strong>{{ user.nickname }}</strong>
<span> @{{ user.username }} </span> <span> @{{ user.username }} </span>
</div>
<div class="uid">UID. {{ user.id }}</div>
</div>
<div class="user-opts">
<n-space vertical>
<n-button
size="small"
secondary
type="primary"
@click="openWhisper"
>
</n-button>
</n-space>
</div>
</div>
<!-- -->
<whisper
:show="showWhisper"
:user="user"
@success="whisperSuccess"
/>
</n-spin>
<template #footer>
<div class="pagination-wrap" v-if="totalPage > 0">
<n-pagination
:page="page"
@update:page="updatePage"
:page-slot="!store.state.collapsedRight ? 8 : 5"
:page-count="totalPage"
/>
</div>
</template>
<n-tabs class="profile-tabs-wrap" animated>
<n-tab-pane name="post" tab="泡泡" />
<!-- <n-tab-pane name="comment" tab="评论"> </n-tab-pane> -->
</n-tabs>
<div v-if="loading" class="skeleton-wrap">
<post-skeleton :num="pageSize" />
</div> </div>
<div v-else> <div class="uid">UID. {{ user.id }}</div>
<div class="empty-wrap" v-if="list.length === 0"> </div>
<n-empty size="large" description="暂无数据" />
</div> <div class="user-opts">
<n-space vertical>
<n-list-item v-for="post in list" :key="post.id"> <n-button
<post-item :post="post" /> size="small"
</n-list-item> secondary
</div> type="primary"
</n-list> @click="openWhisper"
</div> >
</n-button>
</n-space>
</div>
</div>
<!-- -->
<whisper :show="showWhisper" :user="user" @success="whisperSuccess" />
</n-spin>
<template #footer>
<div v-if="totalPage > 0" class="pagination-wrap">
<n-pagination
:page="page"
:page-slot="!store.state.collapsedRight ? 8 : 5"
:page-count="totalPage"
@update:page="updatePage"
/>
</div>
</template>
<n-tabs class="profile-tabs-wrap" animated>
<n-tab-pane name="post" tab="泡泡" />
<!-- <n-tab-pane name="comment" tab="评论"> </n-tab-pane> -->
</n-tabs>
<div v-if="loading" class="skeleton-wrap">
<post-skeleton :num="pageSize" />
</div>
<div v-else>
<div v-if="list.length === 0" class="empty-wrap">
<n-empty size="large" description="暂无数据" />
</div>
<n-list-item v-for="post in list" :key="post.id">
<post-item :post="post" />
</n-list-item>
</div>
</n-list>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -79,10 +75,10 @@ const route = useRoute();
const loading = ref(false); const loading = ref(false);
const user = reactive({ const user = reactive({
id: 0, id: 0,
avatar: '', avatar: '',
username: '', username: '',
nickname: '', nickname: '',
}); });
const userLoading = ref(false); const userLoading = ref(false);
const showWhisper = ref(false); const showWhisper = ref(false);
@ -93,105 +89,105 @@ const pageSize = ref(20);
const totalPage = ref(0); const totalPage = ref(0);
const loadPosts = () => { const loadPosts = () => {
loading.value = true; loading.value = true;
getUserPosts({ getUserPosts({
username: username.value as string, username: username.value as string,
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
})
.then((rsp) => {
loading.value = false;
list.value = rsp.list;
totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value);
window.scrollTo(0, 0);
}) })
.then((rsp) => { .catch((err) => {
loading.value = false; loading.value = false;
list.value = rsp.list; });
totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value);
window.scrollTo(0, 0);
})
.catch((err) => {
loading.value = false;
});
}; };
const loadUser = () => { const loadUser = () => {
userLoading.value = true; userLoading.value = true;
getUserProfile({ getUserProfile({
username: username.value as string, username: username.value as string,
})
.then((res) => {
userLoading.value = false;
user.id = res.id;
user.avatar = res.avatar;
user.username = res.username;
user.nickname = res.nickname;
loadPosts();
}) })
.then((res) => { .catch((err) => {
userLoading.value = false; userLoading.value = false;
user.id = res.id; console.log(err);
user.avatar = res.avatar; });
user.username = res.username;
user.nickname = res.nickname;
loadPosts();
})
.catch((err) => {
userLoading.value = false;
console.log(err);
});
}; };
const updatePage = (p: number) => { const updatePage = (p: number) => {
page.value = p; page.value = p;
loadPosts(); loadPosts();
}; };
const openWhisper = () => { const openWhisper = () => {
// window.$message.warning('您尚未获得私信权限'); // window.$message.warning('您尚未获得私信权限');
showWhisper.value = true; showWhisper.value = true;
}; };
const whisperSuccess = () => { const whisperSuccess = () => {
showWhisper.value = false; showWhisper.value = false;
}; };
watch( watch(
() => ({ () => ({
path: route.path, path: route.path,
query: route.query, query: route.query,
}), }),
(to, from) => { (to, from) => {
if (from.path === '/user' && to.path === '/user') { if (from.path === '/user' && to.path === '/user') {
username.value = route.query.username || ''; username.value = route.query.username || '';
loadUser(); loadUser();
}
} }
},
); );
onMounted(() => { onMounted(() => {
loadUser(); loadUser();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.profile-tabs-wrap { .profile-tabs-wrap {
padding: 0 16px; padding: 0 16px;
} }
.profile-baseinfo { .profile-baseinfo {
display: flex; display: flex;
padding: 16px; padding: 16px;
.avatar {
width: 55px;
}
.base-info {
position: relative;
width: calc(100% - 55px);
.avatar { .username {
width: 55px; line-height: 16px;
font-size: 16px;
} }
.base-info { .uid {
position: relative; font-size: 14px;
width: calc(100% - 55px); line-height: 14px;
margin-top: 10px;
.username { opacity: 0.75;
line-height: 16px;
font-size: 16px;
}
.uid {
font-size: 14px;
line-height: 14px;
margin-top: 10px;
opacity: 0.75;
}
} }
}
} }
.pagination-wrap { .pagination-wrap {
padding: 10px; padding: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
} }
</style> </style>

@ -1,146 +1,137 @@
<template> <template>
<div> <div>
<main-nav title="钱包" /> <main-nav title="钱包" />
<n-list class="main-content-wrap" bordered> <n-list class="main-content-wrap" bordered>
<div class="balance-wrap"> <div class="balance-wrap">
<div class="balance-line"> <div class="balance-line">
<n-statistic label="账户余额 (元)" <n-statistic label="账户余额 (元)"
><n-number-animation ><n-number-animation
:from="0.0" :from="0.0"
:to="(store.state.userInfo.balance || 0) / 100" :to="(store.state.userInfo.balance || 0) / 100"
:duration="500" :duration="500"
:precision="2" :precision="2"
/> />
</n-statistic> </n-statistic>
<div class="balance-opts"> <div class="balance-opts">
<n-space vertical> <n-space vertical>
<n-button <n-button
size="small" size="small"
secondary secondary
type="primary" type="primary"
@click="doRecharge" @click="doRecharge"
> >
</n-button> </n-button>
<n-button <n-button
size="small" size="small"
secondary secondary
type="tertiary" type="tertiary"
@click="doWithdraw" @click="doWithdraw"
> >
</n-button> </n-button>
</n-space> </n-space>
</div> </div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="pagination-wrap" v-if="totalPage > 0"> <div v-if="totalPage > 0" class="pagination-wrap">
<n-pagination <n-pagination
:page="page" :page="page"
@update:page="updatePage" :page-slot="!store.state.collapsedRight ? 8 : 5"
:page-slot="!store.state.collapsedRight ? 8 : 5" :page-count="totalPage"
:page-count="totalPage" @update:page="updatePage"
/> />
</div> </div>
</template> </template>
<div v-if="loading" class="skeleton-wrap"> <div v-if="loading" class="skeleton-wrap">
<post-skeleton :num="pageSize" /> <post-skeleton :num="pageSize" />
</div> </div>
<div v-else> <div v-else>
<div class="empty-wrap" v-if="list.length === 0"> <div v-if="list.length === 0" class="empty-wrap">
<n-empty size="large" description="暂无数据" /> <n-empty size="large" description="暂无数据" />
</div> </div>
<n-list-item v-for="bill in list" :key="bill.id"> <n-list-item v-for="bill in list" :key="bill.id">
<div class="bill-line"> <div class="bill-line">
<div>NO.{{ bill.id }}</div> <div>NO.{{ bill.id }}</div>
<div>{{ bill.reason }}</div> <div>{{ bill.reason }}</div>
<div <div
:class="{ :class="{
income: bill.change_amount >= 0, income: bill.change_amount >= 0,
out: bill.change_amount < 0, out: bill.change_amount < 0,
}" }"
> >
{{ {{
(bill.change_amount > 0 ? '+' : '') + (bill.change_amount > 0 ? '+' : '') +
(bill.change_amount / 100).toFixed(2) (bill.change_amount / 100).toFixed(2)
}} }}
</div>
<div>
{{ formatRelativeTime(bill.created_on) }}
</div>
</div>
</n-list-item>
</div> </div>
</n-list> <div>
<n-modal v-model:show="showRecharge"> {{ formatRelativeTime(bill.created_on) }}
<n-card </div>
:bordered="false" </div>
title="请选择充值金额" </n-list-item>
role="dialog" </div>
aria-modal="true" </n-list>
style="width: 100%; max-width: 330px" <n-modal v-model:show="showRecharge">
<n-card
:bordered="false"
title="请选择充值金额"
role="dialog"
aria-modal="true"
style="width: 100%; max-width: 330px"
>
<div v-if="rechargeQrcode.length === 0" class="amount-options">
<n-space align="baseline">
<n-button
v-for="amount in openAmounts"
:key="amount"
size="small"
secondary
:type="selectedRechargeAmount === amount ? 'info' : 'default'"
@click.stop="selectedRechargeAmount = amount"
> >
<div class="amount-options" v-if="rechargeQrcode.length === 0"> {{ amount / 100 }}
<n-space align="baseline"> </n-button>
<n-button </n-space>
v-for="amount in openAmounts" </div>
:key="amount" <div
size="small" v-if="selectedRechargeAmount > 0 && rechargeQrcode.length === 0"
secondary style="margin-top: 10px"
:type=" >
selectedRechargeAmount === amount <n-button
? 'info' :loading="recharging"
: 'default' strong
" secondary
@click.stop="selectedRechargeAmount = amount" type="info"
> style="width: 100%"
{{ amount / 100 }} @click="handleRecharge"
</n-button> >
</n-space> <template #icon>
</div> <n-icon><logo-alipay /></n-icon>
<div </template>
v-if="
selectedRechargeAmount > 0 && </n-button>
rechargeQrcode.length === 0 </div>
" <div v-show="rechargeQrcode.length > 0" class="qrcode-wrap">
style="margin-top: 10px" <canvas id="qrcode-container"></canvas>
> <div class="pay-tips">
<n-button 使{{
:loading="recharging" (selectedRechargeAmount / 100).toFixed(2)
strong }}
secondary </div>
type="info" <div class="pay-sub-tips">
style="width: 100%" <n-badge :value="100" type="info" dot processing />
@click="handleRecharge" <span style="margin-left: 6px"> ... </span>
> </div>
<template #icon> </div>
<n-icon><logo-alipay /></n-icon> </n-card>
</template> </n-modal>
</div>
</n-button>
</div>
<div class="qrcode-wrap" v-show="rechargeQrcode.length > 0">
<canvas id="qrcode-container"></canvas>
<div class="pay-tips">
使{{
(selectedRechargeAmount / 100).toFixed(2)
}}
</div>
<div class="pay-sub-tips">
<n-badge :value="100" type="info" dot processing />
<span style="margin-left: 6px">
...
</span>
</div>
</div>
</n-card>
</n-modal>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -170,158 +161,154 @@ const totalPage = ref(0);
const openAmounts = ref([100, 200, 300, 500, 1000, 3000, 5000, 10000, 50000]); const openAmounts = ref([100, 200, 300, 500, 1000, 3000, 5000, 10000, 50000]);
const loadPosts = () => { const loadPosts = () => {
loading.value = true; loading.value = true;
getBills({ getBills({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
}) })
.then((rsp) => { .then((rsp) => {
loading.value = false; loading.value = false;
list.value = rsp.list; list.value = rsp.list;
totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value); totalPage.value = Math.ceil(rsp.pager.total_rows / pageSize.value);
window.scrollTo(0, 0); window.scrollTo(0, 0);
}) })
.catch((err) => { .catch((err) => {
loading.value = false; loading.value = false;
}); });
}; };
const updatePage = (p: number) => { const updatePage = (p: number) => {
page.value = p; page.value = p;
loadPosts(); loadPosts();
}; };
const loadWallet = () => { const loadWallet = () => {
// 获取最新 // 获取最新
const token = localStorage.getItem('PAOPAO_TOKEN') || ''; const token = localStorage.getItem('PAOPAO_TOKEN') || '';
if (token) { if (token) {
userInfo(token) userInfo(token)
.then((res) => { .then((res) => {
store.commit('updateUserinfo', res); store.commit('updateUserinfo', res);
store.commit('triggerAuth', false); store.commit('triggerAuth', false);
loadPosts(); loadPosts();
}) })
.catch((err) => { .catch((err) => {
store.commit('triggerAuth', true);
store.commit('userLogout');
});
} else {
store.commit('triggerAuth', true); store.commit('triggerAuth', true);
store.commit('userLogout'); store.commit('userLogout');
} });
} else {
store.commit('triggerAuth', true);
store.commit('userLogout');
}
}; };
const doRecharge = () => { const doRecharge = () => {
showRecharge.value = true; showRecharge.value = true;
}; };
const handleRecharge = (amount: any) => { const handleRecharge = (amount: any) => {
recharging.value = true; recharging.value = true;
reqRecharge({ reqRecharge({
amount: selectedRechargeAmount.value, amount: selectedRechargeAmount.value,
}) })
.then((res) => { .then((res) => {
recharging.value = false; recharging.value = false;
rechargeQrcode.value = res.pay; rechargeQrcode.value = res.pay;
// 生成二维码 // 生成二维码
QRCode.toCanvas( QRCode.toCanvas(document.querySelector('#qrcode-container'), res.pay, {
document.querySelector('#qrcode-container'), width: 150,
res.pay, margin: 2,
{ });
width: 150,
margin: 2,
}
);
const s = setInterval(() => { const s = setInterval(() => {
getRecharge({ getRecharge({
id: res.id, id: res.id,
}) })
.then((res) => { .then((res) => {
if (res.status === 'TRADE_SUCCESS') { if (res.status === 'TRADE_SUCCESS') {
clearInterval(s); clearInterval(s);
window.$message.success(''); window.$message.success('');
showRecharge.value = false; showRecharge.value = false;
rechargeQrcode.value = ''; rechargeQrcode.value = '';
loadWallet(); loadWallet();
} }
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
}, 2000); }, 2000);
}) })
.catch((err) => { .catch((err) => {
recharging.value = false; recharging.value = false;
}); });
}; };
const doWithdraw = () => { const doWithdraw = () => {
if (store.state.userInfo.balance == 0) { if (store.state.userInfo.balance == 0) {
window.$message.warning(''); window.$message.warning('');
} else { } else {
window.$message.warning(''); window.$message.warning('');
} }
}; };
onMounted(() => { onMounted(() => {
loadWallet(); loadWallet();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.balance-wrap { .balance-wrap {
padding: 16px; padding: 16px;
.balance-line { .balance-line {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.balance-opts { .balance-opts {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
} }
}
} }
.bill-line { .bill-line {
padding: 16px; padding: 16px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.income, .income,
.out { .out {
font-weight: bold; font-weight: bold;
} }
.income { .income {
color: #18a058; color: #18a058;
} }
} }
.pagination-wrap { .pagination-wrap {
padding: 10px; padding: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
} }
.qrcode-wrap { .qrcode-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.pay-tips {
margin-top: 16px;
}
.pay-sub-tips {
margin-top: 4px;
font-size: 12px;
opacity: 0.75;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; }
.pay-tips {
margin-top: 16px;
}
.pay-sub-tips {
margin-top: 4px;
font-size: 12px;
opacity: 0.75;
display: flex;
align-items: center;
}
} }
.dark { .dark {
.income { .income {
color: #63e2b7; color: #63e2b7;
} }
} }
</style> </style>
Loading…
Cancel
Save