Initial project

This commit is contained in:
Timi
2025-07-08 16:28:40 +08:00
parent 03cf3638d7
commit fb1438c393
44 changed files with 13002 additions and 129 deletions

View File

@ -0,0 +1,228 @@
/* TDesign 腾讯云样式附加修改 */
@popupShadowColor: rgba(0, 0, 0, .1);
@popupShadow: 3px 3px 0 @popupShadowColor;
.t-button {
cursor: var(--tui-cur-pointer);
}
.t-card {
box-shadow: var(--tui-shadow);
.t-card__title {
display: block;
}
.t-card__body,
.t-card__header {
margin: 14px 0;
padding: 0 16px;
}
}
.t-link {
cursor: var(--tui-cur-pointer) !important;
font-size: 100%;
}
.t-input .t-input__inner {
cursor: var(--tui-cur-text) !important;
}
.t-select .t-input__inner {
cursor: var(--tui-cur-default) !important;
}
.t-textarea__inner {
cursor: var(--tui-cur-text) !important;
word-wrap: normal;
}
.t-checkbox {
cursor: var(--tui-cur-pointer) !important;
}
.t-tabs {
.t-tabs__nav-item {
cursor: var(--tui-cur-pointer);
}
}
.t-pagination {
.t-pagination__number {
cursor: var(--tui-cur-pointer);
}
}
.t-breadcrumb {
.t-breadcrumb__item {
cursor: var(--tui--pointer);
.t-breadcrumb__inner-text {
cursor: var(--tui--pointer);
}
a {
color: inherit;
text-decoration: none;
}
&:last-child {
.t-breadcrumb__inner {
&:hover,
&:active {
color: var(--td-text-color-primary);
}
&:active {
animation: none;
}
}
.t-breadcrumb__inner-text {
cursor: var(--tui--default);
}
}
}
}
.t-list {
.t-list-item {
background: transparent;
&:last-child::after {
background: transparent;
}
}
}
.t-tree {
.t-tree__item,
.t-tree__icon,
.t-tree__label {
cursor: var(--tui--pointer) !important;
font-size: 100%;
}
.t-tree__label {
padding: 3px var(--td-comp-paddingLR-xs);
}
}
.t-timeline-item__wrapper {
.t-timeline-item__dot {
border: none;
width: 8px;
height: 8px;
margin: 2px 0 0 1px;
background: var(--td-brand-color);
border-radius: 0;
}
.t-timeline-item__tail {
border-left: 2px solid var(--td-component-border);
}
}
.t-layout {
background: transparent;
.t-layout__sider,
.t-layout__header,
.t-layout__content,
.t-layout__footer {
background: transparent;
}
}
.narrow-scrollbar {
&::-webkit-scrollbar {
cursor: var(--tui--default) !important;
}
&::-webkit-scrollbar-thumb {
border: none;
cursor: var(--tui--default);
background: #525870;
border-radius: 0;
}
}
.t-popup {
.t-popup__content {
outline: 1px solid var(--td-component-border);
box-shadow: @popupShadow;
}
.t-select-option {
cursor: var(--tui--pointer);
}
}
.t-popup[data-popper-placement^="left"] {
.t-popup__arrow {
right: calc(-9px / 2);
&::before {
box-shadow: inset -1px 0 0 var(--td-component-border), inset 0 1px 0 var(--td-component-border), 2px -2px 0 rgba(0, 0, 0, .08);
}
}
}
.t-popup[data-popper-placement^="right"] {
.t-popup__arrow {
left: calc(-10px / 2);
&::before {
box-shadow: inset 0 -1px 0 var(--td-component-border), inset 1px 0 0 var(--td-component-border);
}
}
}
.t-popup[data-popper-placement^="bottom"] {
&.t-select__dropdown .t-popup__content {
margin-top: 2px;
}
}
.t-dialog__ctx {
--td-mask-active: rgba(0, 0, 0, .2) !important;
.t-dialog {
background: rgba(255, 255, 255, .8);
box-shadow: 2px 2px 0 rgba(0, 0, 0, .3);
backdrop-filter: blur(10px);
animation-duration: .3s !important;
-webkit-backdrop-filter: blur(10px);
animation-timing-function: cubic-bezier(.23, 1, .32, 1) !important;
}
}
.t-message {
outline: 1px solid var(--td-component-border);
box-shadow: @popupShadow;
}
.t-dropdown {
border: none;
}
.t-avatar {
box-sizing: border-box;
}
.t-image-viewer {
.t-swiper-item {
overflow: hidden;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
/**
* emits
*/
export interface Emits {
(event: "loginSuccess"): Promise<void>;
(event: "registerSuccess"): Promise<void>;
}
export const useHandler = (emit: Emits) => {
const onLoginSuccess = async () => await emit("loginSuccess");
const onRegisterSuccess = async () => await emit("registerSuccess");
return {
onLoginSuccess,
onRegisterSuccess
};
};

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const AuthorizeForm = Toolkit.withInstall(view);
export default AuthorizeForm;

View File

@ -0,0 +1,64 @@
<template>
<div class="tui-authorize-form">
<t-tabs class="tab" v-model="tabValue">
<t-tab-panel :value="Tab.LOGIN" label="登录" />
<t-tab-panel :value="Tab.REGISTER" label="注册" />
</t-tabs>
<div class="content">
<login v-if="tabValue == Tab.LOGIN" @login-success="onLoginSuccess" />
<register v-if="tabValue == Tab.REGISTER" @register-success="onRegisterSuccess" />
</div>
</div>
</template>
<script lang="ts" setup>
import Login from "./login.vue";
import Register from "./register.vue";
import { Emits, useHandler } from "~/components/authorize-form/emits";
import { ref } from "vue";
defineOptions({
name: "AuthorizeForm"
});
enum Tab {
LOGIN = "LOGIN",
REGISTER = "REGISTER"
}
const emits = defineEmits<Emits>();
const { onLoginSuccess, onRegisterSuccess } = useHandler(emits);
const tabValue = ref(Tab.LOGIN);
</script>
<style lang="less" scoped>
.tui-authorize-form {
display: flex;
align-items: center;
flex-direction: column;
.tab {
width: 16rem;
background: transparent;
margin-bottom: 1rem;
:deep(.t-tabs__nav-wrap) {
width: 100%;
.t-tabs__nav-item {
width: 50%;
.t-tabs__nav-item-wrapper {
width: 100%;
justify-content: center;
}
}
}
}
.content {
width: 20rem;
}
}
</style>

View File

@ -0,0 +1,106 @@
<template>
<t-form class="tui-login-form" :data="loginRequest" :rules="rules" @submit="doSubmit">
<t-form-item label="用户" name="user">
<t-input class="user" v-model="loginRequest.data.user" placeholder="UID、邮箱或用户名" />
</t-form-item>
<t-form-item label="密码" name="password">
<t-input type="password" v-model="loginRequest.data.password" placeholder="" />
</t-form-item>
<t-form-item label="验证码" name="captcha">
<div class="captcha-box">
<t-input class="captcha" v-model="loginRequest.captcha" placeholder="" />
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" :from="CaptchaFrom.LOGIN" />
</div>
</t-form-item>
<div class="exec">
<t-button
class="button"
type="submit"
:loading="doSubmitting"
:disabled="doSubmitting"
>登录</t-button>
</div>
</t-form>
</template>
<script lang="ts" setup>
import { Captcha, CaptchaData, CaptchaFrom, CommonAPI, LoginRequest, UserAPI, userStore } from "timi-web";
import { Emits, useHandler } from "~/components/authorize-form/emits";
import { MessagePlugin } from "tdesign-vue-next";
const rules = {
user: [
{
required: true,
message: "请输入用户名"
}
],
password: [
{
required: true,
message: "请输入密码"
}
],
captcha: [
{
required: true,
message: "请输入验证码"
}
]
}
const emits = defineEmits<Emits>();
const { onLoginSuccess } = useHandler(emits);
const doSubmitting = ref<boolean>(false);
const loginRequest = reactive<CaptchaData<LoginRequest>>({
from: CaptchaFrom.LOGIN,
captcha: "",
data: {
user: "",
password: ""
}
});
const doSubmit = async () => {
doSubmitting.value = true;
UserAPI.login(loginRequest).then(async (response) => {
await userStore.updateToken(response);
await onLoginSuccess();
doSubmitting.value = false;
}).catch(msg => {
MessagePlugin.error(msg, 5E3);
doSubmitting.value = false;
});
};
</script>
<style lang="less" scoped>
.tui-login-form {
padding: 1rem;
margin-left: -32px;
.captcha-box {
width: 100%;
display: flex;
justify-content: space-between;
.captcha {
width: 7rem;
margin-right: 1rem;
}
}
.exec {
display: flex;
margin-top: 2rem;
justify-content: center;
.button {
width: 8rem;
margin-left: 32px;
}
}
}
</style>

View File

@ -0,0 +1,142 @@
<template>
<t-form class="tui-register-form" :data="formData.data" :rules="rules" @submit="doSubmit">
<t-form-item label="昵称" name="name">
<t-input class="name" v-model="formData.data.name" placeholder="" />
</t-form-item>
<t-form-item label="密码" name="password">
<t-input type="password" v-model="formData.data.password" placeholder="" />
</t-form-item>
<t-form-item label="确认密码" name="rePassword">
<t-input type="password" v-model="formData.data.rePassword" placeholder="" />
</t-form-item>
<t-form-item label="邮箱" name="email">
<t-input type="text" v-model="formData.data.email" placeholder="选填" />
</t-form-item>
<t-form-item label="验证码" name="captcha">
<div class="captcha-box">
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" :from="CaptchaFrom.REGISTER" />
</div>
</t-form-item>
<div class="exec">
<t-button
class="button"
type="submit"
:loading="doSubmitting"
:disabled="doSubmitting"
>注册</t-button>
</div>
</t-form>
</template>
<script lang="ts" setup>
import { Captcha, CaptchaData, CaptchaFrom, CommonAPI, RegisterRequest, UserAPI, userStore } from "timi-web";
import { FormRules, MessagePlugin, SubmitContext } from "tdesign-vue-next";
import { Emits, useHandler } from "~/components/authorize-form/emits";
type RegisterForm = {
rePassword: string;
} & RegisterRequest
const emits = defineEmits<Emits>();
const { onRegisterSuccess } = useHandler(emits);
const doSubmitting = ref<boolean>(false);
const formData = reactive<CaptchaData<RegisterForm>>({
from: CaptchaFrom.LOGIN,
captcha: "",
data: {
name: "",
password: "",
rePassword: "",
email: undefined
}
});
const rules = {
name: [
{
required: true,
message: "请输入昵称",
type: "error"
}
],
password: [
{
required: true,
message: "请输入密码",
type: "error"
}
],
rePassword: [
{
required: true,
message: "请输入确认密码",
type: "error"
},
{
validator: (val: string) => new Promise((resolve) => {
const timer = setTimeout(() => {
resolve(formData.data.password === val);
clearTimeout(timer);
});
}),
message: "两次密码不一致"
}
],
captcha: [
{
required: true,
message: "请输入验证码",
type: "error"
}
]
} as FormRules;
const doSubmit = ({validateResult, e}: SubmitContext) => {
if (e) {
e.preventDefault();
}
if (validateResult === true) {
doSubmitting.value = true;
UserAPI.register({...formData}).then(async (response) => {
await userStore.updateToken(response);
await onRegisterSuccess();
doSubmitting.value = false;
}).catch(msg => {
MessagePlugin.error(msg, 5E3);
doSubmitting.value = false;
});
}
};
</script>
<style lang="less" scoped>
.tui-register-form {
padding: 1rem;
margin-left: -32px;
.captcha-box {
width: 100%;
display: flex;
justify-content: space-between;
.captcha {
width: 7rem;
margin-right: 1rem;
}
}
.exec {
display: flex;
margin-top: 2rem;
justify-content: center;
.button {
width: 8rem;
margin-left: 32px;
}
}
}
</style>

View File

@ -0,0 +1,17 @@
import { Comment } from "timi-web";
/**
* emits
*/
export interface Emits {
(event: "submit"): Promise<void>;
}
export const useHandler = (emit: Emits) => {
const onSubmit = async (comment: Comment) => await emit("submit");
return {
onSubmit,
};
};

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentForm = Toolkit.withInstall(view);
export default CommentForm;

View File

@ -0,0 +1,114 @@
<template>
<t-form class="tui-comment-form">
<t-form-item label="昵称" name="nick">
<t-input
class="nick"
v-model="formData.data.nick"
:disabled="userStore.isLogged()"
placeholder="昵称"
/>
</t-form-item>
<t-form-item label="评论" name="content">
<markdown-editor v-model:data="formData.data.content" />
</t-form-item>
<t-form-item label="验证码" name="captcha">
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
<captcha
ref="captchaRef"
class="captcha-img"
:api="CommonAPI.getCaptchaAPI()"
:width="90"
:height="28"
:from="CaptchaFrom.COMMENT"
/>
<t-button
type="submit"
:loading="doSubmitting"
:disabled="doSubmitting"
@click="doSubmit"
>提交</t-button>
</t-form-item>
</t-form>
</template>
<script lang="ts" setup>
import {
Captcha,
CaptchaData,
CaptchaFrom,
Comment,
CommentAPI,
CommentBizType,
CommonAPI,
MarkdownEditor,
userStore
} from "timi-web";
import { Emits, useHandler } from "~/components/comment/form/emits";
defineOptions({
name: "CommentForm"
});
const emits = defineEmits<Emits>();
const { onSubmit } = useHandler(emits);
const props = withDefaults(defineProps<{
bizType: CommentBizType,
bizId: number,
}>(), {});
const { bizType, bizId } = toRefs(props);
const captchaRef = ref<{ update: () => void}>();
const formData = reactive<CaptchaData<Comment>>({
from: CaptchaFrom.COMMENT,
captcha: "",
data: {
bizType: bizType.value,
bizId: bizId.value,
nick: "",
content: ""
}
});
const doSubmitting = ref(false);
async function doSubmit() {
doSubmitting.value = true;
const loginUser = userStore.loginUser;
if (userStore.isLogged() && loginUser.user) {
formData.data.nick = loginUser.user.name;
formData.data.userId = loginUser.user.id;
}
await CommentAPI.create(formData);
formData.captcha = "";
formData.data.content = "";
captchaRef.value?.update();
doSubmitting.value = false;
await onSubmit(formData.data);
}
// 登录用户
const updateNick = () => formData.data.nick = userStore.loginUser?.user?.name || "";
watch(userStore.loginUser, updateNick);
onMounted(updateNick);
</script>
<style lang="less" scoped>
.tui-comment-form {
padding: 1rem;
.nick {
width: 12rem;
}
.captcha {
width: 6rem;
}
.captcha-img {
margin: 0 1rem;
}
}
</style>

View File

@ -0,0 +1,20 @@
import { CommentReply } from "timi-web";
/**
* emits
*/
export interface Emits {
(event: "cancel"): Promise<void>;
(event: "submit", reply: CommentReply): Promise<void>;
}
export const useHandler = (emit: Emits) => {
const onCancel = async () => await emit("cancel");
const onSubmit = async (reply: CommentReply) => await emit("submit", reply);
return {
onCancel,
onSubmit
};
};

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentReplyForm = Toolkit.withInstall(view);
export default CommentReplyForm;

View File

@ -0,0 +1,168 @@
<template>
<t-form class="tui-comment-reply-form">
<t-form-item label="昵称" name="nick">
<t-input
ref="nickRef"
class="nick"
v-model="formData.data.senderNick"
placeholder="昵称"
:disabled="userStore.isLogged()"
/>
<span v-if="replyToNick" class="gray selectable clip-text" v-text="`&nbsp;回复 ${replyToNick}`"></span>
</t-form-item>
<t-form-item label="评论" name="content">
<markdown-editor ref="contentRef" v-model:data="formData.data.content" />
</t-form-item>
<t-form-item label="验证码" name="captcha">
<div class="captcha-box">
<div>
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
<captcha
ref="captchaRef"
class="captcha-img"
:api="CommonAPI.getCaptchaAPI()"
:width="90"
:height="28"
:from="CaptchaFrom.COMMENT_REPLY"
/>
</div>
<div>
<t-button
class="submit"
type="submit"
:loading="doSubmitting"
:disabled="doSubmitting"
@click="doSubmit"
>提交</t-button>
<t-button theme="default" @click="onCancel">取消</t-button>
</div>
</div>
</t-form-item>
</t-form>
</template>
<script lang="ts" setup>
import {
Captcha,
CaptchaData,
CaptchaFrom,
CommentAPI,
CommentReply,
CommonAPI,
MarkdownEditor,
Scroller,
userStore
} from "timi-web";
import { Emits, useHandler } from "~/components/comment/form/reply/emits";
defineOptions({
name: "CommentReplyForm"
});
const emits = defineEmits<Emits>();
const { onCancel, onSubmit } = useHandler(emits);
const props = withDefaults(defineProps<{
commentId: number,
replyId?: number,
replyToNick?: string,
}>(), {});
const { commentId, replyId, replyToNick } = toRefs(props);
const doSubmitting = ref(false);
const nickRef = ref();
const contentRef = ref();
const formData = reactive<CaptchaData<CommentReply>>({
from: CaptchaFrom.COMMENT_REPLY,
captcha: "",
data: {
commentId: commentId.value,
replyId: replyId.value,
senderNick: "",
content: ""
}
});
/** 提交回复 */
async function doSubmit() {
doSubmitting.value = true;
formData.data.commentId = commentId.value;
formData.data.replyId = replyId.value;
const loginUser = userStore.loginUser;
if (userStore.isLogged() && loginUser.user) {
formData.data.senderNick = loginUser.user.name;
formData.data.senderId = loginUser.user.id;
}
await CommentAPI.createReply(formData);
formData.captcha = "";
await onSubmit(formData.data);
doSubmitting.value = false;
}
async function focus() {
await nextTick();
let focusEl: HTMLElement;
if (userStore.isLogged()) {
focusEl = nickRef.value[0].textArea;
} else {
focusEl = contentRef.value[0].inputRef;
}
focusEl.focus();
Scroller.toElement(focusEl);
}
// 登录用户
const updateNick = () => formData.data.senderNick = userStore.loginUser?.user?.name || "";
watch(userStore.loginUser, updateNick);
onMounted(updateNick);
defineExpose({
focus
});
</script>
<style lang="less" scoped>
.tui-comment-reply-form {
padding: 1rem 1rem 1rem 0;
.nick {
width: 12rem;
}
.captcha-box {
display: flex;
flex-wrap: wrap;
div {
display: flex;
}
.captcha {
width: 6rem;
}
.captcha-img {
margin: 0 1rem;
}
.submit {
margin-right: 1rem;
}
}
}
@media screen and (max-width: 650px) {
.tui-comment-reply-form {
.captcha-box {
> div:first-child {
margin-bottom: 1rem;
}
}
}
}
</style>

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const Comment = Toolkit.withInstall(view);
export default Comment;

View File

@ -0,0 +1,153 @@
<template>
<div
v-if="canComment || (pageResult && 0 < pageResult.total)"
class="tui-comment"
:class="{ 'can-comment': canComment }"
>
<span ref="titleAnchorRef"></span>
<h4
class="title cur-pointer"
@click="Scroller.toElement(titleAnchorRef!)"
>评论</h4>
<comment-form
ref="commentFormRef"
v-if="canComment"
:bizType="bizType" :bizId="bizId"
@submit="onSubmit"
/>
<t-pagination
v-if="pageResult && 16 < pageResult.total"
class="pages top"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
v-model:current="pageCurrent"
:onCurrentChange="doFetch"
>
<template #total-content>
<div class="total" v-text="`共 ${pageResult.total} 条评论`"></div>
</template>
</t-pagination>
<comment-list class="list" v-if="pageResult" :items="pageResult.list" />
<t-pagination
v-if="pageResult && 16 < pageResult.total"
class="pages bottom"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
v-model:current="pageCurrent"
:onCurrentChange="doFetch"
>
<template #total-content>
<div class="total" v-text="`共 ${pageResult.total} 条评论`"></div>
</template>
</t-pagination>
</div>
</template>
<script lang="ts" setup>
import {
CommentAPI,
CommentBizType,
CommentPage,
CommentReplyBizType,
CommentView,
PageResult,
Scroller
} from "timi-web";
import { CommentForm, CommentList } from "~/components";
// 评论总成
defineOptions({
name: "Comment"
});
const props = withDefaults(defineProps<{
bizType: CommentBizType,
bizId: number,
canComment?: boolean,
titleStickyOffset?: number,
}>(), {
canComment: true,
titleStickyOffset: 0
});
const { bizType, bizId, canComment } = toRefs(props);
// 数据列表
const page = reactive<CommentPage>({
bizType: bizType.value,
bizId: bizId.value,
index: 0,
size: 16
});
// 当前页
const pageCurrent = ref(1);
const pageResult = ref<PageResult<CommentView>>();
async function doFetch() {
page.index = pageCurrent.value - 1;
pageResult.value = await CommentAPI.page(page);
for (let i = 0; i < pageResult.value.list.length; i++) {
const item = pageResult.value.list[i];
item.repliesCurrent = 1;
item.repliesPage = {
bizId: item.id!,
bizType: CommentReplyBizType.COMMENT,
index: 0,
size: 6
}
}
}
onMounted(doFetch);
const titleAnchorRef = ref<HTMLSpanElement>();
/** 提交评论 */
async function onSubmit() {
page.index = 0;
await doFetch();
}
</script>
<style lang="less" scoped>
.tui-comment {
.title {
top: v-bind("titleStickyOffset + 'px'");
margin: 0;
padding: .5rem 1rem;
z-index: 2;
position: sticky;
background: #E4EFFA;
box-shadow: 0 -1px 0 var(--tui-light-gray);
border-bottom: var(--tui-border);
}
&.can-comment {
.title {
margin: 1rem 0;
}
.list {
border-top: 2px solid #CDDEF0;
}
}
.pages {
padding: 4px 4px 4px 1rem;
&.top {
border-top: 1px solid #E4EFFA;
}
&.bottom {
border-bottom: 1px solid #E4EFFA;
}
.total {
flex: 1;
}
}
}
</style>

View File

@ -0,0 +1,11 @@
/**
* emits
*/
export interface Emits {
}
export const useHandler = (emit: Emits) => {
return {
};
};

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentList = Toolkit.withInstall(view);
export default CommentList;

View File

@ -0,0 +1,241 @@
<template>
<div v-if="items" class="tui-comment-list">
<div class="comment" v-for="comment in items" :key="comment.id">
<!-- 主评论 -->
<div class="user">
<!-- 主评论头像 -->
<template v-if="comment.user">
<a
:href="`/user/space/${comment.userId}`"
target="_blank"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = comment.user"
>
<img
class="avatar"
:class="(<any>ImageType)[comment.user.profile.avatarType]"
:src="UserAPI.getAvatarURL(comment.user.profile)"
alt="主评论头像"
/>
</a>
<a
class="name pink selectable"
:href="`/user/space/${comment.userId}`"
v-text="comment.user.name"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = comment.user"
></a>
</template>
<!-- 游客的 -->
<template v-else>
<img class="avatar ir-pixelated" :src="defAvatarURL" alt="头像" />
<div class="name gray selectable clip-text" v-text="comment.nick"></div>
</template>
</div>
<div class="content">
<!-- 主评论内容 -->
<markdown-view class="value" :content="comment.content" />
<div class="reply-to">
<a class="button" href="javascript:" @click="replyTo(comment)">回复</a>
<span
class="light-gray"
v-text="Time.toPassedDate(comment.createdAt)"
v-popup:text="Time.toDateTime(comment.createdAt)"
></span>
</div>
<!-- 子评论 -->
<comment-reply-list :comment="comment" @reply="replyTo" />
<t-pagination
class="pages"
v-if="6 < comment.repliesLength"
:total="comment.repliesLength"
:pageSize="6"
:showPageSize="false"
v-model:current="comment.repliesCurrent"
@change="() => doFetchReply(comment)"
>
<template #total-content>
<div class="total" v-text="`共 ${comment.repliesLength} 条回复`"></div>
</template>
</t-pagination>
<!-- 回复表单此表单跟随页面上激活的回复对象 -->
<comment-reply-form
v-if="canReply && comment.id && activeReply === comment.id"
:commentId="comment.id"
:replyId="replyId"
:replyToNick="replyToNick"
@submit="onSubmitReply"
@cancel="activeReply = -1"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
CommentAPI,
CommentReply,
CommentReplyView,
CommentView,
CommonAPI,
ImageType,
MarkdownView,
SettingKey,
SettingMapper,
Time,
UserAPI
} from "timi-web";
import { CommentReplyForm, CommentReplyList } from "~/components";
import { popupUserStore } from "timi-tdesign-pc";
defineOptions({
name: "CommentList"
});
const props = withDefaults(defineProps<{
items: CommentView[],
canReply?: boolean,
}>(), {
canReply: true
});
const { items, canReply } = toRefs(props);
// 默认头像
const defAvatarURL = ref();
onMounted(async () => {
const res = JSON.parse(SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES) as string);
defAvatarURL.value = CommonAPI.getAttachmentReadAPI(res.user.avatar);
});
// 激活的回复评论下标
const activeReply = ref();
const replyId = ref();
// 被回复昵称
const replyToNick = ref();
/**
* 获取回复
*
* @param comment 所属评论
*/
async function doFetchReply(comment: CommentView) {
// 组件下标以 1 开始,分页参数以 0 开始
comment.repliesPage.index = comment.repliesCurrent - 1;
const result = await CommentAPI.pageReply(comment.repliesPage);
comment.replies.length = 0;
comment.replies.push(...result.list);
}
/**
* 触发回复表单(表单会跟随评论)
*
* @param comment 跟随的评论
* @param replyTo 被回复对象
*/
async function replyTo(comment: CommentView, replyTo?: CommentReplyView) {
activeReply.value = comment.id;
if (replyTo) {
// 被回复对象
replyToNick.value = replyTo.sender?.name || replyTo.senderNick;
replyId.value = replyTo.id;
} else {
replyToNick.value = comment.user?.name || comment.nick;
}
}
function onSubmitReply(reply: CommentReply) {
activeReply.value = -1;
const comment = items.value.find(comment => comment.id === reply.commentId)!;
comment.repliesLength++;
comment.repliesCurrent = Math.ceil(comment.repliesLength / 6); // 上取整最后一页
doFetchReply(comment);
}
</script>
<style lang="less" scoped>
.tui-comment-list {
font-size: 13px;
line-height: 1.5;
.comment {
display: flex;
line-height: 1.5;
border-bottom: 2px solid #CDDEF0;
.user {
width: 120px;
padding: 16px 0;
text-align: center;
background: #E4EFFA;
transition: .5s var(--tui-bezier);
.avatar {
width: 64px;
border: 1px solid #FBC7D4;
margin: 0 auto 10px auto;
padding: 3px;
background: #FFF;
transition: .5s var(--tui-bezier);
}
}
.content {
width: calc(100% - 120px);
transition: .5s var(--tui-bezier);
.value {
width: calc(100% - 2rem);
padding: .5rem;
min-height: 92px;
}
.pages {
padding: 4px 4px 4px 1rem;
border-top: 1px solid #E4EFFA;
.total {
flex: 1;
}
}
.reply-to {
display: flex;
padding-right: .5rem;
justify-content: end;
> a {
margin-right: .5rem;
}
}
}
}
}
@media screen and (max-width: 650px) {
.tui-comment-list {
.comment {
.user {
width: 60px;
padding: 8px 0;
transition: .5s var(--tui-bezier);
.avatar {
width: 32px;
margin: 0 auto 4px auto;
padding: 2px;
transition: .5s var(--tui-bezier);
}
}
.content {
width: calc(100% - 60px);
transition: .5s var(--tui-bezier);
}
}
}
}
</style>

View File

@ -0,0 +1,17 @@
import { CommentReplyView, CommentView } from "timi-web";
/**
* emits
*/
export interface Emits {
(event: "reply", comment: CommentView, replyTo?: CommentReplyView): Promise<void>;
}
export const useHandler = (emit: Emits) => {
const onReply = async (comment: CommentView, replyTo?: CommentReplyView) => await emit("reply", comment, replyTo);
return {
onReply,
};
};

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentReplyList = Toolkit.withInstall(view);
export default CommentReplyList;

View File

@ -0,0 +1,179 @@
<template>
<div class="tui-comment-reply-list">
<div v-if="comment && comment.replies" class="reply" v-for="reply in comment.replies" :key="reply.id">
<div class="header">
<!-- 发送者头像 -->
<div class="sender-avatar">
<a
v-if="reply.sender"
:href="`/user/space/${reply.senderId}`"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = reply.sender"
>
<img
:class="(<any>ImageType)[reply.sender.profile.avatarType]"
:src="UserAPI.getAvatarURL(reply.sender.profile)"
alt="回复发送者头像"
/>
</a>
<img v-else class="ir-pixelated" :src="defAvatarURL" alt="回复发送者头像" />
</div>
<!-- 被回复用户 -->
<a
v-if="reply.sender"
class="pink bold selectable"
:href="`/user/space/${reply.senderId}`"
target="_blank"
v-text="reply.sender.name"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = reply.sender"
></a>
<span
v-else
class="gray selectable"
v-text="reply.senderNick"
></span>
<!-- 非回复层主时才显示被回复用户 -->
<template v-if="!!reply.replyId">
<span>&nbsp;回复&nbsp;</span>
<!-- 被回复用户 -->
<a
v-if="reply.receiver"
class="pink bold selectable"
:href="`/user/space/${reply.receiverId}`"
target="_blank"
v-text="reply.receiver.name"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = reply.receiver"
></a>
<span
v-else
class="gray selectable"
v-text="reply.receiverNick"
></span>
</template>
</div>
<!-- 回复内容 -->
<markdown-view class="content" :content="reply.content" />
<!-- 其他回复信息 -->
<div class="reply-to">
<a href="javascript:" @click="onReply(comment, reply)">回复</a>
<span
class="light-gray"
v-text="Time.toPassedDate(reply.createdAt)"
v-popup:text="Time.toDateTime(reply.createdAt)"
></span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
CommentView,
CommonAPI,
ImageType,
MarkdownView,
PublicResources,
SettingKey,
SettingMapper,
Time,
UserAPI
} from "timi-web";
import { Emits, useHandler } from "~/components/comment/list/reply/emits";
import { popupUserStore } from "~/store/popupUser";
defineOptions({
name: "CommentReplyList"
});
const props = withDefaults(defineProps<{
comment: CommentView,
}>(), {});
const { comment } = toRefs(props);
const emits = defineEmits<Emits>();
const { onReply } = useHandler(emits);
// 默认头像
const defAvatarURL = ref();
onMounted(async () => {
const res = JSON.parse(SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES) as string) as PublicResources;
defAvatarURL.value = CommonAPI.getAttachmentReadAPI(res.user.avatar);
});
</script>
<style lang="less" scoped>
.tui-comment-reply-list {
.reply {
padding: 6px;
&:nth-child(n + 2) {
border-top: 1px #d7d7d7 dotted;
}
&:first-child {
border-top: 2px solid #E4EFFA;
}
.header {
display: flex;
align-items: center;
.sender-avatar {
border: 1px solid #FBC7D4;
padding: 1px;
margin-right: .5rem;
img {
width: 32px;
height: 32px;
transition: .5s var(--tui-bezier);
}
}
}
.content {
width: 100%;
margin-top: .5rem;
}
.reply-to {
display: flex;
padding-right: 0;
justify-content: end;
> a {
margin-right: .5rem;
}
}
}
}
@media screen and (max-width: 650px) {
.tui-comment-reply-list {
.reply {
padding: 3px;
transition: .5s var(--tui-bezier);
.header {
.sender-avatar {
img {
width: 16px;
height: 16px;
transition: .5s var(--tui-bezier);
}
}
}
}
}
}
</style>

34
src/components/index.ts Normal file
View File

@ -0,0 +1,34 @@
/** 导出所有组件 */
import AuthorizeForm from "~/components/authorize-form";
import RootLayout from "~/components/root-layout";
import Comment from "~/components/comment";
import CommentForm from "~/components/comment/form";
import CommentList from "~/components/comment/list";
import CommentReplyList from "~/components/comment/list/reply";
import CommentReplyForm from "~/components/comment/form/reply";
import UserProfile from "~/components/user-profile";
import UserProfilePopup from "~/components/user-profile/popup";
export default [
RootLayout,
AuthorizeForm,
Comment,
CommentForm,
CommentReplyForm,
CommentList,
CommentReplyList,
UserProfile,
UserProfilePopup
];
export {
RootLayout,
AuthorizeForm,
Comment,
CommentForm,
CommentReplyForm,
CommentList,
CommentReplyList,
UserProfile,
UserProfilePopup
};

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const RootLayout = Toolkit.withInstall(view);
export default RootLayout;

View File

@ -0,0 +1,59 @@
<template>
<div class="tui-root-layout">
<slot></slot>
<copyright :icp="icp" :domain="domain" :author="author" />
</div>
</template>
<script lang="ts" setup>
import { Copyright } from "timi-web";
defineOptions({
name: "RootLayout"
});
withDefaults(defineProps<{
icp?: string;
domain?: string;
author?: string;
}>(), {
});
</script>
<style lang="less" scoped>
.tui-root-layout {
border: var(--tui-border);
margin: 128px 80px 128px 0;
display: flex;
min-height: 520px;
background: WHITE;
transition: width 500ms var(--tui-bezier), margin 500ms var(--tui-bezier), min-height 500ms var(--tui-bezier);
box-shadow: var(--tui-shadow);
flex-direction: column;
justify-content: space-between;
}
/* 自适应 */
@media screen and (max-width: 2560px) {
.tui-root-layout {
width: 1200px;
}
}
@media screen and (max-width: 1920px) {
.tui-root-layout {
width: 900px;
}
}
@media screen and (max-width: 1200px) {
.tui-root-layout {
width: 100%;
margin: 0;
border: none;
box-shadow: none;
min-height: 100vh;
}
}
</style>

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const UserProfile = Toolkit.withInstall(view);
export default UserProfile;

View File

@ -0,0 +1,96 @@
<template>
<div class="tui-user-profile">
<template v-if="user">
<img
:class="(<any>ImageType)[user.profile.wrapperType]"
:src="UserAPI.getWrapperURL(user.profile)"
width="320"
alt="背景"
/>
<img
class="avatar"
:class="(<any>ImageType)[user.profile.avatarType]"
width="64"
height="64"
:src="UserAPI.getAvatarURL(user.profile)"
alt="头像"
/>
<div class="info">
<div class="name">
<h3 class="value pink" v-text="user.name"></h3>
<user-level :value="Toolkit.toUserLevel(user.profile.exp).value" />
</div>
<template v-if="user.profile.sex">
<icon
v-if="user.profile.sex === 1"
class="sex boy"
name="BOY"
fill="var(--tui-blue)"
v-popup="`男生`"
/>
<icon
v-else
class="sex girl"
name="GIRL"
fill="var(--tui-pink)"
v-popup="`女生`"
/>
</template>
<p class="description" v-text="user.profile.description"></p>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { Icon, ImageType, Toolkit, UserAPI, UserLevel, UserView } from "timi-web";
defineOptions({
name: "UserProfile"
});
const props = withDefaults(defineProps<{
user?: UserView,
}>(), {
});
const { user } = toRefs(props);
</script>
<style lang="less" scoped>
.tui-user-profile {
width: 320px;
position: relative;
.avatar {
margin: -32px 0 0 16px;
border: 1px solid #CDDEF0;
position: absolute;
}
.info {
margin-top: 4px;
.name {
margin: 0 6px;
display: flex;
align-items: center;
line-height: 22px;
padding-left: 84px;
.value {
margin: 0 .5rem 0 0;
}
}
.sex {
width: 22px;
height: 22px;
}
.description {
padding: 0 16px;
margin-top: 20px;
}
}
}
</style>

View File

@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const UserProfilePopup = Toolkit.withInstall(view);
export default UserProfilePopup;

View File

@ -0,0 +1,24 @@
<template>
<div ref="rootEl" class="tui-user-profile-popup">
<user-profile :user="popupUserStore.user.value" />
</div>
</template>
<script lang="ts" setup>
import UserProfile from "~/components/user-profile";
import { popupUserStore } from "~/store/popupUser";
const rootEl = ref<HTMLDivElement>();
onMounted(() => {
if (rootEl.value) {
popupUserStore.config.value = rootEl.value;
}
});
</script>
<style lang="less" scoped>
.tui-user-profile-popup {
display: none;
}
</style>

9
src/index.ts Normal file
View File

@ -0,0 +1,9 @@
import "./assets/style/tencent-cloud.less";
import "./assets/style/tencent-cloud-custom.less";
import { popupUserStore } from "./store/popupUser";
export * from "./components";
export {
popupUserStore
};

21
src/store/popupUser.ts Normal file
View File

@ -0,0 +1,21 @@
import { PopupConfig, PopupType, UserView } from "timi-web";
const user = ref<UserView>();
const config = reactive<PopupConfig>({
type: PopupType.EL,
value: undefined,
canShow: () => true,
afterHidden: async () => {
user.value = undefined;
}
});
const popupUserStore = {
user,
config
};
export {
popupUserStore
};