Initial project

This commit is contained in:
Timi
2025-07-08 16:41:57 +08:00
parent 34c88de543
commit 01baba4c8b
44 changed files with 13913 additions and 129 deletions

View File

@ -0,0 +1,90 @@
<template>
<div class="login-menu">
<t-dropdown
v-if="userStore.loginUser.user"
class="logged"
trigger="click"
>
<t-button class="button" variant="text">
<template #icon>
<t-avatar
class="icon"
:class="(<any>ImageType)[userStore.loginUser.user.profile.avatarType]"
:image="UserAPI.getAvatarURL(userStore.loginUser.user.profile)"
size="small"
shape="round"
/>
</template>
<span v-text="userStore.loginUser.user.name"></span>
<template #suffix>
<icon name="ARROW_1_S" />
</template>
</t-button>
<t-dropdown-menu>
<t-dropdown-item @click="toDeveloperConfig">
<template #prefixIcon>
<icon name="TOOL" />
</template>
<span>开发配置</span>
</t-dropdown-item>
<t-dropdown-item @click="userStore.logout()">
<template #prefixIcon>
<icon />
</template>
<span>退出登录</span>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
<t-button
v-if="!userStore.isLogged()"
variant="text"
@click="visitableAuth = true"
>
<template #icon>
<icon class="icon" name="USER" />
</template>
<span>登录</span>
</t-button>
</div>
<t-dialog
v-model:visible="visitableAuth"
attach="body"
:close-btn="false"
:header="false"
:footer="false"
>
<authorize-form
v-if="visitableAuth"
@login-success="visitableAuth = false"
@register-success="visitableAuth = false"
/>
</t-dialog>
</template>
<script lang="ts" setup>
import { Icon, ImageType, SettingKey, SettingMapper, UserAPI, userStore } from "timi-web";
import { AuthorizeForm } from "timi-tdesign-pc";
const visitableAuth = ref<boolean>(false);
const toDeveloperConfig = () => {
const loginUserId = userStore.loginUser.token?.id;
if (loginUserId) {
window.open(`https://${SettingMapper.getValue(SettingKey.DOMAIN_SPACE)}/${loginUserId}/developer`, "_blank");
}
};
</script>
<style lang="less" scoped>
.login-menu {
.icon {
margin-right: .5rem;
}
.logged {
&.button {
padding: 0;
}
}
}
</style>

View File

@ -0,0 +1,124 @@
<template>
<t-timeline class="push-log-timeline" mode="alternate">
<t-timeline-item
class="action"
v-for="(action, actionIndex) in items"
:key="actionIndex"
dot-color="primary"
>
<template #label>
<div class="time" v-text="Time.toPassedDate(action.operatedAt)" v-popup="Time.toDateTime(action.operatedAt)"></div>
</template>
<t-card class="item">
<div>
<div class="user">
<icon class="avatar" name="USER"/>
<span v-text="action.operator.name"></span>
</div>
<div v-if="showRepo" class="repository">
<span>仓库</span>
<router-link class="link selectable" :to="`/${action.repoName}/TODO_BRANCH`">
{{ action.repoName }}
</router-link>
</div>
<div v-if="showBranch" class="branch">
<span>分支</span>
<span class="selectable" v-text="action.refName"></span>
</div>
<template v-if="action.commitList">
<h4 v-if="showRepo || showBranch">提交记录</h4>
<div class="commits">
<div
class="commit"
v-for="(commit, commitIndex) in action.commitList"
:key="commitIndex"
>
<t-button
class="sha1"
size="small"
theme="default"
:content="`#${commit.sha.substring(0, 8)}`"
@click="MessagePlugin.warning('文件比对功能暂不可用')"
></t-button>
<div class="msg selectable" v-text="commit.message"></div>
<div
class="light-gray"
v-text="Time.toPassedDate(commit.committedAt)"
v-popup="Time.toDateTime(commit.committedAt)"
></div>
</div>
</div>
</template>
<div v-else>初始化分支</div>
</div>
</t-card>
</t-timeline-item>
</t-timeline>
</template>
<script lang="ts" setup>
import { Icon, Time } from "timi-web";
import { MessagePlugin } from "tdesign-vue-next";
import { ActionLogView } from "@/types/Common.ts";
defineOptions({
name: "PushLogTimeline"
});
const props = withDefaults(defineProps<{
items?: ActionLogView[];
showRepo?: boolean;
showBranch?: boolean;
}>(), {
items: () => [],
showRepo: true,
showBranch: true
});
const { items } = toRefs(props);
</script>
<style lang="less" scoped>
.push-log-timeline {
:deep(.action) {
.t-timeline-item__label {
width: 90px;
}
}
.action {
.user {
display: flex;
.avatar {
margin-right: .5rem;
}
}
.repository {
margin-top: .5rem;
}
.commits {
.commit {
display: flex;
&:nth-last-child(n + 2) {
margin-bottom: .5rem;
}
.sha1 {
margin-right: .5rem;
}
.msg {
flex: 1;
}
}
}
}
}
</style>

View File

@ -0,0 +1,568 @@
<template>
<div class="comment-time-line">
<t-timeline
class="comments"
layout="vertical"
mode="alternate"
>
<t-timeline-item
class="comment"
:class="{'system': comment.systemComment}"
v-for="(comment, index) in comments"
:key="index"
dot-color="primary"
>
<template #label>
<div class="time">
<t-popup
:delay="0"
:content="Time.toDateTime(comment.systemComment ? comment.systemComment.time : comment.createdAt)"
placement="left"
:show-arrow="true"
:destroy-on-close="true"
>
<span
v-text="Time.toPassedDateTime(comment.systemComment ? comment.systemComment.time : comment.createdAt)"></span>
</t-popup>
</div>
</template>
<t-card class="shadow">
<div
v-if="comment.systemComment"
class="system-comment-line"
:class="comment.systemComment.colorClass"
></div>
<template #title>
<div class="comment-user">
<template v-if="comment.user">
<t-avatar
class="icon"
:class="(<any>ImageType)[comment.user.profile.avatarType]"
:image="UserAPI.getAvatarURL(comment.user.profile)"
size="small"
shape="round"
/>
<h4 class="name" v-text="comment.user.name"></h4>
</template>
<template v-else>
<icon v-if="!comment.systemComment" class="icon" name="USER"/>
<h4 v-if="comment.systemComment" class="name">系统消息</h4>
<h4 v-else class="name" v-text="comment.nick"></h4>
</template>
</div>
</template>
<div v-if="comment.systemComment" v-text="comment.systemComment.msg"></div>
<markdown-view v-else :content="comment.content"/>
<div v-if="!comment.systemComment" class="reply-btn">
<t-button
v-if="props.canCreate"
size="small"
variant="text"
:content="`回复(${comment.repliesLength}`"
@click="doCommentReply(comment)"
></t-button>
<span v-else v-text="`回复(${comment.repliesLength}`"></span>
</div>
<div class="reply-pane"
v-if="!comment.systemComment && (replyTo === comment || 0 < comment.repliesLength)">
<t-list
class="replies"
:split="true"
v-if="comment.replies"
>
<t-list-item
class="reply"
v-for="reply in comment.replies"
:key="reply.id"
>
<div class="content">
<div class="header">
<div class="user">
<template v-if="reply.sender">
<t-avatar
class="user-icon"
:class="(<any>ImageType)[reply.sender.profile.avatarType]"
:image="UserAPI.getAvatarURL(reply.sender.profile)"
size="small"
shape="round"
/>
<div class="sender" v-text="reply.sender.name"></div>
</template>
<template v-else>
<icon name="USER"/>
<div class="sender" v-text="reply.senderNick"></div>
</template>
<template
v-if="reply.receiverId !== comment.userId || reply.receiverNick !== comment.nick">
<div class="reply-to gray">回复</div>
<div v-if="reply.receiver" v-text="reply.receiver.name"></div>
<div v-else v-text="reply.receiverNick"></div>
</template>
</div>
<div class="time light-gray" v-text="Time.toPassedDateTime(reply.createdAt)"></div>
</div>
<markdown-view :content="reply.content"/>
<div v-if="props.canCreate" class="reply-ctrl">
<t-button
class="button"
size="small"
variant="text"
@click="doCommentReply(comment, reply)"
>回复
</t-button>
</div>
</div>
</t-list-item>
</t-list>
<t-pagination
class="reply-pagination"
v-if="6 < comment.repliesLength"
:total="comment.repliesLength"
v-model="comment.repliesCurrentPage"
:pageSize="6"
:showPageSize="false"
:onCurrentChange="(current: number) => doFetchRepliesEvent(comment, current)"
/>
<t-form
class="form"
v-if="replyTo === comment"
:data="replyRequest"
@submit="doCommentReplySubmit(comment)"
>
<t-form-item label="昵称" name="nick">
<t-input
v-if="userStore.isLogged()"
class="nick"
:value="userStore.loginUser.user?.name"
disabled
/>
<t-input v-else class="nick" v-model="replyRequest.senderNick" placeholder=""/>
<div
v-if="replyToName"
class="reply-to gray"
v-text="`回复 ${replyToName}`"
></div>
</t-form-item>
<t-form-item label="内容" name="content">
<markdown-editor
:minRows="4"
:maxRows="16"
v-model:data="replyRequest.content"
/>
</t-form-item>
<t-form-item label="验证码" name="captcha">
<t-input-adornment>
<t-input class="captcha" v-model="replyCaptcha" placeholder=""/>
<template #append>
<captcha :width="90" :height="28" :api="CommonAPI.getCaptchaAPI()"
:from="CaptchaFrom.COMMENT_REPLY"/>
</template>
</t-input-adornment>
</t-form-item>
<t-form-item>
<t-space>
<t-button type="submit">提交</t-button>
<t-button theme="default" variant="outline" @click="replyTo = undefined">取消
</t-button>
</t-space>
</t-form-item>
</t-form>
</div>
</t-card>
</t-timeline-item>
</t-timeline>
<div class="more">
<p v-if="isFinished" class="gray" @click="doFetchEvent">没有更多评论</p>
<t-button v-if="!isFetching && !isFinished" @click="doFetchEvent">加载更多</t-button>
</div>
<t-form v-if="props.canCreate" class="comment-form" :data="commentRequest" @submit="doCommentSubmit">
<t-form-item label="昵称" name="name">
<t-input
v-if="userStore.isLogged()"
class="nick"
:value="userStore.loginUser?.user?.name"
disabled
/>
<t-input v-else class="nick" v-model="commentRequest.nick" placeholder=""/>
</t-form-item>
<t-form-item label="内容" name="course">
<markdown-editor :minRows="8" v-model:data="commentRequest.content"/>
</t-form-item>
<t-form-item label="验证码" name="captcha">
<t-input-adornment>
<t-input class="captcha" v-model="commentCaptcha" placeholder=""/>
<template #append>
<captcha :width="90" :height="28" :api="CommonAPI.getCaptchaAPI()"
:from="CaptchaFrom.COMMENT"/>
</template>
</t-input-adornment>
</t-form-item>
<t-form-item>
<t-button type="submit">提交</t-button>
</t-form-item>
</t-form>
</div>
</template>
<script lang="ts" setup>
import { MessagePlugin } from "tdesign-vue-next";
import {
Captcha,
CaptchaFrom,
Comment,
CommentAPI,
CommentBizType,
CommentReply,
CommentReplyBizType,
CommentReplyView,
CommentView,
CommonAPI,
Icon,
ImageType,
MarkdownEditor,
MarkdownView,
OrderType,
Page,
Time,
UserAPI,
userStore
} from "timi-web";
export interface Props {
bizType: CommentBizType,
bizId: number;
canCreate?: boolean;
systemComments?: SystemComment[];
}
export type SystemComment = {
colorClass?: string;
time: number;
msg?: string;
inserted: boolean;
}
type CommentItem = {
systemComment?: SystemComment;
repliesCurrentPage: number;
} & CommentView;
const props = withDefaults(defineProps<Props>(), {
canCreate: true
});
const comments = reactive<CommentItem[]>([]);
const commentCaptcha = ref();
const commentRequest = reactive<Comment>({
bizType: props.bizType,
bizId: props.bizId,
nick: "",
content: ""
});
const replyCaptcha = ref();
const replyRequest = reactive<CommentReply>({
commentId: -1,
replyId: undefined,
senderNick: "",
content: ""
});
const replyTo = ref<CommentItem>();
const replyToName = ref();
const isFinished = ref(false);
const isFetching = ref(true);
const page = ref<Page>({
index: 0,
size: 12
});
watch(() => commentRequest.nick, () => {
if (!userStore.isLogged()) {
replyRequest.senderNick = commentRequest.nick;
}
});
watch(() => replyRequest.senderNick, () => {
if (!userStore.isLogged()) {
commentRequest.nick = replyRequest.senderNick;
}
});
watch(replyTo, () => {
if (!replyTo.value) {
replyRequest.content = "";
replyCaptcha.value = "";
}
});
const insertSystemComment = (item: SystemComment): boolean => {
let insertAt = null;
{
// 查找插入位置
for (let i = comments.length - 1; -1 < i; i--) {
const element = comments[i];
if (element.createdAt && item.time < element.createdAt) {
insertAt = i;
} else {
break;
}
}
if ((insertAt === null && isFinished.value) || comments.length === 0) {
insertAt = comments.length;
}
}
if (insertAt !== null) {
// 可插入
item.colorClass = item.colorClass ?? "bg-blue";
comments.splice(insertAt, 0, {
systemComment: item
} as CommentItem);
return true;
}
return false;
};
const doFetchEvent = async () => {
isFetching.value = true;
const fetchResult = await CommentAPI.page({
bizType: props.bizType,
bizId: props.bizId,
orderMap: {
"createdAt": OrderType.ASC
},
...page.value
});
const list = fetchResult.list;
for (let i = 0; i < list.length; i++) {
comments.push({
repliesCurrentPage: 1,
...list[i]
});
}
page.value.index++;
if (fetchResult.list.length < page.value.size) {
isFinished.value = true;
}
isFetching.value = false;
if (props.systemComments) {
for (let i = 0; i < props.systemComments.length; i++) {
const item = props.systemComments[i];
if (!item.inserted) {
item.inserted = insertSystemComment(item);
}
}
}
};
const doFetchRepliesEvent = async (comment: CommentItem, toPage: number) => {
if (comment.id) {
const result = await CommentAPI.pageReply({
bizType: CommentReplyBizType.COMMENT,
bizId: comment.id,
index: toPage - 1,
size: 6
});
comment.replies = result.list;
}
};
const doCommentReply = async (comment: CommentItem, reply?: CommentReplyView) => {
replyTo.value = comment;
replyRequest.commentId = comment.id!;
if (reply) {
replyRequest.replyId = reply.id;
replyToName.value = reply.sender ? reply.sender.name : reply.senderNick;
} else {
replyRequest.replyId = undefined;
replyToName.value = undefined;
}
};
const doCommentSubmit = () => {
CommentAPI.create({
from: CaptchaFrom.GIT_ISSUE,
captcha: commentCaptcha.value,
data: commentRequest
}).then(() => {
if (isFinished.value) {
isFinished.value = false;
page.value.index--;
const size = comments.length % page.value.size;
comments.splice(comments.length - size, size);
doFetchEvent();
}
commentRequest.content = "";
commentCaptcha.value = "";
MessagePlugin.success("提交成功");
}).catch(msg => {
MessagePlugin.error("提交失败:" + msg);
});
};
const doCommentReplySubmit = async (comment: CommentItem) => {
CommentAPI.createReply({
from: CaptchaFrom.COMMENT_REPLY,
captcha: replyCaptcha.value,
data: replyRequest
}).then(() => {
if (0 < comment.repliesLength) {
const size = comment.replies.length % 6;
comment.replies.splice(comment.replies.length - size, size);
doFetchRepliesEvent(comment, comment.repliesCurrentPage = Math.ceil(comment.repliesLength / 6));
} else {
doFetchRepliesEvent(comment, comment.repliesCurrentPage = 1);
}
comment.repliesLength++;
replyTo.value = undefined;
MessagePlugin.success("提交成功");
}).catch((msg: string) => {
MessagePlugin.error("提交失败:" + msg);
});
};
onMounted(doFetchEvent);
</script>
<style lang="less" scoped>
.comment-time-line {
padding: 1rem 2rem;
border-top: var(--tui-border);
.comments {
.comment {
.system-comment-line {
top: -1px;
left: -1px;
width: 4px;
height: calc(100% + 2px);
position: absolute;
}
.time {
width: 90px;
margin-top: 2px;
}
.comment-user {
display: flex;
align-items: center;
.icon {
margin-right: .5rem;
}
.name {
margin: 0;
}
}
.reply-btn {
display: flex;
justify-content: end;
}
.reply-pane {
display: flex;
margin-top: 1rem;
align-items: end;
flex-direction: column;
.replies {
width: 100%;
background: #F8F8F8;
padding-left: 2rem;
.reply {
padding: .5rem;
.content {
width: 100%;
&:hover .reply-ctrl .button {
visibility: visible;
}
.header {
display: flex;
justify-content: space-between;
.user {
display: flex;
font-weight: bold;
.sender {
margin-left: .5rem;
}
.reply-to {
margin: 0 .5rem;
}
}
.time {
text-align: right;
}
}
.reply-ctrl {
display: flex;
justify-content: end;
.button {
visibility: hidden;
transition: none;
}
}
}
}
}
.reply-pagination {
margin: .5rem 0;
}
.form {
width: 100%;
margin-top: 1rem;
.nick {
width: 50%;
}
.reply-to {
margin-left: 1rem;
}
.captcha {
width: 6rem;
margin-right: 1rem;
}
}
}
}
}
.more {
display: flex;
justify-content: center;
}
.comment-form {
margin-top: 1rem;
.nick {
width: 50%;
}
.captcha {
width: 6rem;
margin-right: 1rem;
}
}
}
</style>