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

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
# 接口
VITE_API=https://api.imyeyu.dev

78
.eslintrc.cjs Normal file
View File

@ -0,0 +1,78 @@
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential",
"./.eslintrc-auto-import.json"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parserOptions": {
"ecmaVersion": "latest",
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"vue"
],
"rules": { // 注释是解释使用该设置的效果,而不是设置属性本身
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// 其他
"camelcase": 2, // 变量驼峰式命名
"quotes": ["error", "double"], // 强制双引字符串
"eqeqeq": ["error", "always"], // 强制全等比较
"semi": ["error", "always"], // 强制语句分号结束
"max-len": [
"error",
{
"code": 180
}
],
// 逗号
"comma-style": [2, "last"], // 逗号出现在行末 [first, last]
"comma-dangle": [2, "never"], // 数组或对象不可带最后一个逗号 [never, always, always-multiline]
// 空格
"no-trailing-spaces": "error", // 禁止行末存在空格
"comma-spacing": [2, { "before": false, "after": true }], // 逗号后需要空格
"semi-spacing": ["error", { "before": false, "after": true }], // 分号后需要空格
"computed-property-spacing": [2, "never"], // 以方括号取对象属性时,[ 后面和 ] 前面需要空格, [never, always]
"space-before-function-paren": ["error", { // 函数括号前空格
"anonymous": "always", // 针对匿名函数表达式,比如 function () {}
"named": "never", // 针对命名函数表达式,比如 function foo() {}
"asyncArrow": "always" // 针对异步的箭头函数表达式,比如 async () => {}
}],
// 缩进
"no-mixed-spaces-and-tabs": "off", // 允许混合缩进
"no-tabs": ["error", { allowIndentationTabs: true }], // 使用 Tab 缩进
"indent": ["error", "tab", { // Tab 缩进相关
SwitchCase: 1 // Switch Case 缩进一级
}],
// 框架
"@typescript-eslint/ban-types": "off", // TS 允许空对象
"@typescript-eslint/no-empty-function": "off", // TS 允许空函数
"@typescript-eslint/no-explicit-any": "off", // TS 允许 any 类型
"@typescript-eslint/explicit-module-boundary-types": "off", // TS 允许显式模块边界类型?
"vue/no-multiple-template-root": "off", // Vue3 支持多个根节点
"@typescript-eslint/no-this-alias": "off", // 允许 this 变量本地化
"vue/no-v-model-argument": "off", // 允许 v-model 支持参数
"vue/multi-word-component-names": "off" // 允许单个词语的组件名
}
};

146
.gitignore vendored
View File

@ -1,138 +1,30 @@
# ---> Node
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) node_modules
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist dist
dist-ssr
*.local
# Gatsby files # Editor directories and files
.cache/ .vscode/*
# Comment in the public line in if your project uses Gatsby and not Next.js !.vscode/extensions.json
# https://nextjs.org/blog/next-9-1#public-directory-support .idea
# public .DS_Store
*.suo
# vuepress build output *.ntvs*
.vuepress/dist *.njsproj
*.sln
# vuepress v2.x temp and cache directory *.sw?
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
/.env.production
/.eslintrc-auto-import.json
/components.d.ts
/src/auto-imports.d.ts
/vite.config.ts.timestamp*

View File

@ -1,3 +1,18 @@
# git # Vue 3 + TypeScript + Vite
Git 前端 This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>开源项目 - 夜雨</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "git",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"less": "^4.3.0",
"pinia": "^2.1.7",
"tdesign-vue-next": "^1.14.2",
"timi-tdesign-pc": "link:..\\timi-tdesign-pc",
"timi-web": "link:..\\timi-web",
"vue-clipboard3": "^2.0.0",
"vue-router": "4"
},
"devDependencies": {
"@types/node": "^22.13.5",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vitejs/plugin-basic-ssl": "^2.0.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/compiler-sfc": "^3.5.13",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.4.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.5.2",
"less": "^4.3.0",
"typescript": "^5.8.3",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.7.0",
"vite": "6.3.4",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vue": "^3.5.17",
"vue-tsc": "^2.2.10"
}
}

9970
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

25
src/Root.vue Normal file
View File

@ -0,0 +1,25 @@
<template>
<b-e-flower-fall class="background" />
<root-layout v-if="ready" class="diselect" icp="粤ICP备2025368555号-1" domain="imyeyu.com" author="夜雨">
<router-view />
</root-layout>
<popup />
<user-profile-popup />
</template>
<script lang="ts" setup>
import { BEFlowerFall, Popup } from "timi-web";
import { RootLayout, UserProfilePopup } from "timi-tdesign-pc";
const ready = ref(false);
onMounted(async () => {
ready.value = true;
});
</script>
<style lang="less">
.background {
background: url("@/assets/img/main.png") fixed right bottom;
}
</style>

27
src/api/IssueAPI.ts Normal file
View File

@ -0,0 +1,27 @@
import { axios, CaptchaData, PageResult } from "timi-web";
import { Issue, IssueView, Page } from "@/types/Issue";
const BASE_URI = "/git/issue";
async function view(id: number): Promise<IssueView> {
return axios.get(`${BASE_URI}/${id}`);
}
async function page(page: Page): Promise<PageResult<Issue>> {
return axios.post(`${BASE_URI}/list`, page);
}
async function create(captchaData: CaptchaData<Issue>): Promise<void> {
return axios.post(`${BASE_URI}/create`, captchaData);
}
async function update(issue: Issue): Promise<void> {
return axios.post(`${BASE_URI}/update`, issue);
}
export default {
view,
page,
create,
update
};

27
src/api/MergeAPI.ts Normal file
View File

@ -0,0 +1,27 @@
import { axios, CaptchaData, PageResult } from "timi-web";
import { Merge, MergeView, Page } from "@/types/Merge";
const BASE_URI = "/git/merge";
async function view(id: number): Promise<MergeView> {
return axios.get(`${BASE_URI}/${id}`);
}
async function page(page: Page): Promise<PageResult<Merge>> {
return axios.post(`${BASE_URI}/list`, page);
}
async function create(captchaData: CaptchaData<Merge>): Promise<void> {
return axios.post(`${BASE_URI}/create`, captchaData);
}
async function update(merge: Merge): Promise<void> {
return axios.post(`${BASE_URI}/update`, merge);
}
export default {
view,
page,
create,
update
};

12
src/api/ReleaseAPI.ts Normal file
View File

@ -0,0 +1,12 @@
import { axios, PageResult } from "timi-web";
import { Page, Release } from "@/types/Release";
const BASE_URI = "/git/release";
async function page(page: Page): Promise<PageResult<Release>> {
return axios.post(`${BASE_URI}/list`, page);
}
export default {
page
};

48
src/api/RepositoryAPI.ts Normal file
View File

@ -0,0 +1,48 @@
import { axios, Page, PageResult } from "timi-web";
import { File, Repository, RepositoryView } from "@/types/Repository";
import { ActionLogView } from "@/types/Common.ts";
const BASE_URI = "/git/repository";
const API_HOST = import.meta.env.VITE_API;
async function view(name: string): Promise<RepositoryView> {
return axios.get(`${BASE_URI}/${name}`);
}
async function page(page: Page): Promise<PageResult<Repository>> {
return axios.post(`${BASE_URI}/list`, page);
}
async function pagePush(name: string, branch: string, page: Page): Promise<PageResult<ActionLogView>> {
return axios.post(`${BASE_URI}/${name}:${branch}/log/push`, page);
}
async function listFile(name: string, branch: string, path: string): Promise<File[]> {
return axios.get(`${BASE_URI}/${name}:${branch}/file/list${path}`);
}
async function fileRaw(name: string, branch: string, path: string): Promise<ArrayBuffer> {
return axios({
url: `${BASE_URI}/${name}:${branch}/file/raw${path}`,
method: "GET",
responseType: "arraybuffer"
});
}
function downloadArchive(name: string, branch: string) {
window.open(`${API_HOST}${BASE_URI}/${name}:${branch}/archive`);
}
async function pageLog(page: Page): Promise<PageResult<ActionLogView>> {
return axios.post(`${BASE_URI}/log`, page);
}
export default {
view,
page,
pagePush,
listFile,
fileRaw,
downloadArchive,
pageLog
};

BIN
src/assets/img/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

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>

102
src/layout/IndexLayout.vue Normal file
View File

@ -0,0 +1,102 @@
<template>
<div class="index-layout">
<div class="header">
<t-head-menu class="menu" theme="light" v-model="menuValue">
<template #logo>
<div
v-if="owner"
class="owner"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = owner"
>
<t-avatar
class="icon ir-pixelated"
:class="(<any>ImageType)[owner.profile.avatarType]"
:image="UserAPI.getAvatarURL(owner.profile)"
size="large"
shape="round"
/>
<div class="name">夜雨</div>
</div>
</template>
<t-menu-item to="/" :value="Menu.LIST">仓库列表</t-menu-item>
<t-menu-item to="/log" :value="Menu.LOG">最近推送</t-menu-item>
<template #operations>
<login-menu />
</template>
</t-head-menu>
</div>
<router-view />
</div>
</template>
<script lang="ts" setup>
import { ImageType, UserAPI, UserView } from "timi-web";
import LoginMenu from "@/components/LoginMenu.vue";
import { popupUserStore } from "timi-tdesign-pc";
// ---------- 个人信息 ----------
const owner = ref<UserView>();
onMounted(async () => owner.value = await UserAPI.view(1));
// ---------- 导航 ----------
enum Menu {
LIST = "LIST",
LOG = "LOG"
}
const regexMenu: {
[key: string]: Menu
} = {
"RepositoryList": Menu.LIST,
"RepositoryLog": Menu.LOG
};
const route = useRoute();
const menuValue = ref();
const syncMenuRouter = (routerName?: string) => {
if (routerName) {
for (const item in regexMenu) {
if (new RegExp(item).test(routerName as string)) {
menuValue.value = regexMenu[item];
break;
}
}
}
};
watch(() => route.name, (routerName) => syncMenuRouter(routerName as string));
onMounted(() => syncMenuRouter(route.name as string));
</script>
<style lang="less" scoped>
.index-layout {
.header {
top: 0;
z-index: 1;
position: sticky;
background: rgba(231, 234, 239, .8);
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.menu {
background: transparent;
.owner {
margin: 0 3rem 0 .25rem;
display: flex;
align-items: center;
.name {
font-size: 1rem;
margin-left: 1rem;
}
}
}
}
}
</style>

View File

@ -0,0 +1,166 @@
<template>
<div class="repository-layout" v-if="repositoryStore.value">
<t-layout class="header">
<t-content>
<t-space class="title" align="center">
<h3 class="title" v-text="repositoryStore.value.name"></h3>
<p class="light-gray" v-text="repositoryStore.value.description"></p>
</t-space>
</t-content>
<t-aside class="aside" width="auto">
<t-space align="center">
<t-button class="repository-list" theme="default" variant="text" @click="backRepositoryList">
<template #icon>
<icon class="icon" name="LIST" />
</template>
<span>仓库列表</span>
</t-button>
<login-menu />
</t-space>
</t-aside>
</t-layout>
<t-head-menu class="menu" v-model="menuValue">
<t-menu-item
:to="`/${repositoryStore.value.name}/${repositoryStore.value.defaultBranch}`"
:value="Menu.FILE"
>
项目文件
</t-menu-item>
<t-menu-item
:to="`/${repositoryStore.value.name}/commits`"
:value="Menu.COMMIT_LOG"
>
更新记录
</t-menu-item>
<t-menu-item
:to="`/${repositoryStore.value.name}/issues`"
:value="Menu.ISSUE"
>
问题反馈
</t-menu-item>
<t-menu-item
:to="`/${repositoryStore.value.name}/merges`"
:value="Menu.MERGE"
>
合并请求
</t-menu-item>
<t-menu-item
:to="`/${repositoryStore.value.name}/releases`"
:value="Menu.RELEASE"
>
版本发布
</t-menu-item>
</t-head-menu>
<div class="content">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" v-if="route.meta?.keepAlive" />
</keep-alive>
<component :is="Component" v-if="!route.meta?.keepAlive" />
</router-view>
</div>
</div>
</template>
<script lang="ts" setup>
import RepositoryAPI from "@/api/RepositoryAPI";
import { Icon } from "timi-web";
import { useRepositoryStore } from "@/store/repository.ts";
import LoginMenu from "@/components/LoginMenu.vue";
const route = useRoute();
const router = useRouter();
const repositoryStore = useRepositoryStore();
// ---------- 导航 ----------
enum Menu {
FILE = "FILE",
COMMIT_LOG = "COMMIT_LOG",
ISSUE = "ISSUE",
MERGE = "MERGE",
RELEASE = "RELEASE"
}
const regexMenu: {
[key: string]: Menu
} = {
"FileDetail": Menu.FILE,
"CommitLog": Menu.COMMIT_LOG,
"Issue.*": Menu.ISSUE,
"Merge.*": Menu.MERGE,
"Release.*": Menu.RELEASE
};
const menuValue = ref();
const syncMenuRouter = (routerName?: string) => {
if (routerName) {
for (const item in regexMenu) {
if (new RegExp(item).test(routerName as string)) {
menuValue.value = regexMenu[item];
break;
}
}
}
};
watch(() => route.name, (routerName) => syncMenuRouter(routerName as string));
onMounted(async () => syncMenuRouter(route.name as string));
// ---------- 返回主页 ----------
const backRepositoryList = () => {
router.push("/");
repositoryStore.value = undefined;
};
// ---------- 页面加载 ----------
onMounted(async () => repositoryStore.value = await RepositoryAPI.view(route.params.repository as string));
</script>
<style lang="less" scoped>
.repository-layout {
border-bottom: var(--tui-border);
.header {
top: 0;
height: 50px;
z-index: 3;
position: sticky;
background: rgba(231, 234, 239, .8);
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.title {
padding-left: .5rem;
}
.aside {
display: flex;
align-items: center;
padding-right: 1rem;
.repository-list {
.icon {
margin-right: .5rem;
}
}
}
}
.menu {
top: 50px;
z-index: 3;
position: sticky;
background: rgba(255, 255, 255, .8);
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
:deep(.t-menu) {
margin: 0;
}
}
}
</style>

62
src/main.ts Normal file
View File

@ -0,0 +1,62 @@
import { createApp } from "vue";
import router from "@/router";
import { NavigationGuardNext, RouteLocationNormalized } from "vue-router";
import { axios, Scroller, userStore, VDraggable, VPopup } from "timi-web";
import { DialogPlugin } from "tdesign-vue-next";
import Root from "@/Root.vue";
import "tdesign-vue-next/dist/tdesign.css";
import "timi-web/style.css";
import "timi-tdesign-pc/style.css";
console.log(`
______ __ _ _
/ __\\ \\ \\ \\ \\
/ . . \\ ' \\ \\ \\ \\
( ) imyeyu.com ) ) ) )
'\\ ___ /' / / / /
====='===='=====================/_/_/_/
`);
// ---------- 网络 ----------
axios.defaults.baseURL = import.meta.env.VITE_API;
// ---------- 路由 ----------
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext): Promise<void> => {
await userStore.login4Storage();
if (to.matched.length !== 0) {
next();
return;
}
if (from.name) {
next({name: from.name});
return;
}
// 找不到页面
const alert = DialogPlugin.alert({
header: "错误404",
body: "找不到该页面",
confirmBtn: {
content: "关闭",
variant: "base",
theme: "default"
},
onConfirm: () => {
alert.destroy();
next("/");
}
});
});
router.afterEach(() => {
if (!window.location.hash) {
Scroller.toTop();
}
});
// ---------- Vue ----------
const app = createApp(Root);
app.use(router);
app.use(createPinia());
app.directive("draggable", VDraggable as any);
app.directive("popup", VPopup as any);
app.mount("#root");

30
src/router/index.ts Normal file
View File

@ -0,0 +1,30 @@
import IndexLayout from "@/layout/IndexLayout.vue";
import RepositoryList from "@/views/index/RepositoryList.vue";
import RepositoryLog from "@/views/index/RepositoryLog.vue";
import {createRouter, createWebHistory} from "vue-router";
import repository from "./repository";
export default createRouter({
history: createWebHistory("/"),
routes: [
{
// 主路由
path: "/",
name: "IndexLayout",
component: IndexLayout,
children: [
{
path: "/",
name: "RepositoryList",
component: RepositoryList
},
{
path: "/log",
name: "RepositoryLog",
component: RepositoryLog
}
]
},
repository
]
});

71
src/router/repository.ts Normal file
View File

@ -0,0 +1,71 @@
import RepositoryLayout from "@/layout/RepositoryLayout.vue";
import CommitLog from "@/views/repository/CommitLog.vue";
import FileDetail from "@/views/repository/FileDetail.vue";
import IssueDetail from "@/views/repository/IssueDetail.vue";
import IssueEdit from "@/views/repository/IssueEdit.vue";
import IssueList from "@/views/repository/IssueList.vue";
import MergeDetail from "@/views/repository/MergeDetail.vue";
import MergeEdit from "@/views/repository/MergeEdit.vue";
import MergeList from "@/views/repository/MergeList.vue";
import ReleaseList from "@/views/repository/ReleaseList.vue";
export default {
path: "/:repository",
name: "RepositoryLayout",
component: RepositoryLayout,
children: [
{
path: ":branch",
name: "FileDetail",
component: FileDetail,
meta: {
keepAlive: true
}
},
{
path: "commits",
name: "CommitLog",
component: CommitLog
},
{
path: "issues",
name: "IssueList",
component: IssueList
},
{
path: "issues",
name: "IssueList",
component: IssueList
},
{
path: "issues/edit/:id?",
name: "IssueEdit",
component: IssueEdit
},
{
path: "issues/:id",
name: "IssueDetail",
component: IssueDetail
},
{
path: "merges",
name: "MergeList",
component: MergeList
},
{
path: "merges/edit/:id?",
name: "MergeEdit",
component: MergeEdit
},
{
path: "merges/:id",
name: "MergeDetail",
component: MergeDetail
},
{
path: "releases",
name: "ReleaseList",
component: ReleaseList
}
]
};

15
src/store/repository.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineStore } from "pinia";
import { RepositoryView } from "@/types/Repository.ts";
const store = defineStore("user", () => {
const value = ref<RepositoryView>();
return {
value
};
});
export function useRepositoryStore() {
return store();
}

21
src/types/Common.ts Normal file
View File

@ -0,0 +1,21 @@
import { UserView } from "../../../timi-web";
export type ActionLogView = {
repoId: number;
repoName: string;
refName: string;
operatedAt: number;
operator: UserView;
commitList?: ActionLogCommit[];
}
export type ActionLogCommit = {
sha: string;
message: string;
committedAt: number;
}

49
src/types/Issue.ts Normal file
View File

@ -0,0 +1,49 @@
import { Attachment, Model, Page as BasePage, UserView } from "timi-web";
export type Issue = {
repositoryId?: number;
publisherId?: number;
publisherNick?: string;
type?: Type;
version?: string;
title: string;
description: string;
status?: Status;
confirmedAt?: number;
developAt?: number;
closedAt?: number;
} & Model;
export type IssueView = {
publisher?: UserView;
attachmentList: Attachment[]
} & Issue;
export enum Type {
BUG = "异常",
FEATURE = "功能",
SECURITY = "安全",
QUESTION = "问题",
DISCUSS = "讨论",
}
export enum Status {
BEFORE_CONFIRM = "待确认",
CONFIRMED = "已确认",
DEVELOPING = "开发中",
FINISHED = "已完成",
CLOSED = "已关闭",
}
export type Page = {
repositoryId?: number;
keyword?: string;
type?: Type;
status?: Status;
} & BasePage;
export type CommentPage = {
issueId?: number;
} & BasePage;

52
src/types/Merge.ts Normal file
View File

@ -0,0 +1,52 @@
import { IssueView } from "./Issue";
import { Attachment, Model, Page as BasePage, UserView } from "timi-web";
export type Merge = {
repositoryId?: number;
requesterId?: number;
issueId?: number;
type?: Type;
fromBranch?: string;
toBranch: string;
title: string;
description: string;
checkedAt?: number;
mergedAt?: number;
rejectedAt?: number;
rejectReason?: string;
closedAt?: number;
status?: Status;
} & Model;
export type MergeView = {
requester?: UserView;
issue?: IssueView;
attachmentList: Attachment[]
} & Merge;
export enum Type {
BUG = "异常",
FEATURE = "功能",
SECURITY = "安全",
DOCUMENT = "文档",
REFACTOR = "重构",
}
export enum Status {
BEFORE_CHECK = "待审查",
WAITING = "待合并",
MERGED = "已合并",
REJECTED = "已拒绝",
CLOSED = "已关闭",
}
export type Page = {
repositoryId?: number;
keyword?: string;
type?: Type;
status?: Status;
} & BasePage;
export type CommentPage = {
mergeId?: number;
} & BasePage;

15
src/types/Release.ts Normal file
View File

@ -0,0 +1,15 @@
import { AttachmentView, Model, Page as BasePage } from "timi-web";
export type Release = {
repositoryId: number;
version: string;
description: string;
sha1: string;
commits: number;
attachmentList: AttachmentView[];
} & Model;
export type Page = {
repositoryId?: number;
} & BasePage;

63
src/types/Repository.ts Normal file
View File

@ -0,0 +1,63 @@
import { Model, Page, UserView } from "timi-web";
export type Repository = {
id: number;
name: string;
description: string;
size: number;
language: string;
sshUrl: string;
cloneUrl: string;
defaultBranch: string;
archived: boolean;
createdAt: number;
updatedAt: number;
archiveAt: string;
license?: string[];
}
export type RepositoryView = {
branchList: Branch[];
} & Repository;
export type Branch = {
name: string;
protected: boolean;
}
export type Commit = {
id: string;
msg: string;
time: number;
committer: UserView;
}
export type CommitLog = {
pushId: number;
sha1: string;
message: string;
} & Model;
export type CommitPage = {
repositoryId?: number;
branch?: string;
} & Page;
export enum FileType {
FILE = "file",
DIRECTORY = "dir"
}
export type File = {
name: string;
path: string;
type: FileType
sha: string;
size: number;
lastCommitSha: string;
lastCommitterDate: number;
children: boolean;
}

View File

@ -0,0 +1,117 @@
<template>
<div class="repository-list">
<t-list class="items" v-if="page" :split="true">
<t-list-item class="item" v-for="item in pageResult.list" :key="item.id">
<t-layout>
<t-aside class="icon" width="auto">
<icon name="DUPLICATE" :scale="2" />
</t-aside>
<t-content class="content">
<t-layout>
<t-header class="header" height="auto">
<div class="name">
<router-link class="link black" :to="`/${item.name}/${item.defaultBranch}`">
{{ item.name }}
</router-link>
</div>
<div class="time light-gray"
v-text="`最近推送 ${Time.toPassedDate(item.updatedAt)}`"
v-popup="Time.toDateTime(item.updatedAt)"
></div>
</t-header>
<t-content>
<span class="gray" v-text="item.description"></span>
</t-content>
</t-layout>
</t-content>
</t-layout>
</t-list-item>
</t-list>
<footer class="footer">
<t-pagination
class="pagination"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
>
<template #totalContent>
<div class="total" v-text="`共 ${pageResult.total} 个项目`"></div>
</template>
</t-pagination>
</footer>
</div>
</template>
<script lang="ts" setup>
import RepositoryAPI from "@/api/RepositoryAPI";
import { Repository } from "@/types/Repository";
import { Icon, Page, PageResult, Time } from "timi-web";
const page = ref<Page>({
index: 0,
size: 12
});
const pageResult = reactive<PageResult<Repository>>({
total: 0,
list: []
});
onMounted(async () => {
const result = await RepositoryAPI.page(page.value);
pageResult.total = result.total;
pageResult.list = result.list;
});
</script>
<style lang="less" scoped>
.repository-list {
width: 100%;
.items {
height: 100%;
min-height: 520px;
.item {
.icon {
padding: .5rem;
}
.content {
padding-left: .5rem;
.header {
display: flex;
.name {
flex: 1;
display: block;
.link {
font-size: 16px;
font-weight: bold;
}
}
}
}
}
}
.footer {
bottom: -1px;
padding: 8px 16px;
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.pagination {
.total {
flex: 1;
}
}
}
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<div class="repository-log">
<push-log-timeline :items="list"/>
<div class="bottom">
<loading :showOn="isLoading"/>
<empty-tips :showOn="isFinished && !isLoading"/>
<t-button v-show="!isFinished && !isLoading" @click="doFetchEvent">加载更多</t-button>
</div>
</div>
</template>
<script lang="ts" setup>
import RepositoryAPI from "@/api/RepositoryAPI";
import { EmptyTips, Loading, Page } from "timi-web";
import { ActionLogView } from "@/types/Common.ts";
import PushLogTimeline from "@/components/PushLogTimeline.vue";
const page = ref<Page>({
index: 0,
size: 12
});
const list = reactive<ActionLogView[]>([]);
const isFinished = ref(false);
const isLoading = ref(true);
const doFetchEvent = async () => {
isLoading.value = true;
const result = await RepositoryAPI.pageLog(page.value);
list.push(...result.list);
page.value.index++;
if (result.list.length < page.value.size) {
isFinished.value = true;
}
isLoading.value = false;
};
onMounted(doFetchEvent);
</script>
<style lang="less" scoped>
.repository-log {
width: 100%;
padding: 1rem 2rem;
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<div v-if="repositoryStore.value" class="commit-log">
<t-layout>
<t-header class="header">
<div>
<t-select class="branch" v-model="branch" label="分支:" placeholder="全部" clearable>
<t-option
v-for="item in repositoryStore.value.branchList"
:key="item.name"
:value="item.name"
:label="item.name"
/>
</t-select>
</div>
</t-header>
<t-content class="content">
<push-log-timeline :items="commits" :show-repo="false" :show-branch="!branch" />
<div class="more">
<empty-tips :showOn="isFinished" />
<t-button v-if="!isFinished" @click="doFetchEvent">加载更多</t-button>
</div>
</t-content>
</t-layout>
</div>
</template>
<script lang="ts" setup>
import { EmptyTips, Page } from "timi-web";
import RepositoryAPI from "@/api/RepositoryAPI";
import { useRepositoryStore } from "@/store/repository.ts";
import { ActionLogView } from "@/types/Common.ts";
import PushLogTimeline from "@/components/PushLogTimeline.vue";
const repositoryStore = useRepositoryStore();
const branch = ref();
const page = ref<Page>({
index: 0,
size: 12
});
const commits = reactive<ActionLogView[]>([]);
const isFinished = ref(false);
const doFetchEvent = async () => {
if (repositoryStore.value) {
const result = await RepositoryAPI.pagePush(repositoryStore.value.name, branch.value ?? "all", page.value);
const list = result.list;
commits.push(...list);
page.value.index++;
if (result.list.length < page.value.size) {
isFinished.value = true;
}
}
};
watch(branch, () => {
if (repositoryStore.value) {
page.value.index = 0;
commits.length = 0;
isFinished.value = false;
doFetchEvent();
}
});
onMounted(doFetchEvent);
</script>
<style lang="less" scoped>
.commit-log {
.header {
top: calc(50px + 49px);
padding: 0 20px;
display: flex;
z-index: 3;
position: sticky;
background: rgba(231, 234, 239, .8);
align-items: center;
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.content {
padding: 1rem 2rem 1rem 1rem;
.more {
display: flex;
justify-content: center;
}
}
}
</style>

View File

@ -0,0 +1,468 @@
<template>
<div class="file-detail" v-if="repositoryStore.value && repositoryStore.value.branchList">
<div class="left">
<t-select class="branch" v-model="branch" label="分支:" :borderless="true">
<t-option
v-for="item in repositoryStore.value.branchList"
:key="item.name"
:value="item.name"
:label="item.name"
></t-option>
</t-select>
<div class="tree-container">
<t-tree
ref="tree"
class="tree"
:data="treeItems"
:keys="treeMap"
:line="true"
:activable="true"
:transition="false"
:active-multiple="false"
:expand-on-click-node="true"
:expand-parent="true"
value-mode="all"
expand-all
:load="treeLoad"
:onActive="onTreeActivated"
>
<template #icon="{ node }">
<icon name="FILE" fill="GRAY" v-if="node.data.type === FileType.FILE" />
<icon name="FOLDER" v-if="node.data.type === FileType.DIRECTORY" />
</template>
</t-tree>
</div>
</div>
<div class="right">
<div class="header">
<div class="clone-label">克隆地址</div>
<t-select class="clone-type" v-model="cloneType" borderless>
<t-option
v-for="item in CloneType"
:key="item"
:value="item"
:label="item"
></t-option>
</t-select>
<t-input
v-if="branch"
class="address"
:value="cloneAddr"
placeholder=""
readonly
/>
<t-button theme="success" @click="doCopyCloneAddr">复制</t-button>
<t-button @click="RepositoryAPI.downloadArchive(repositoryStore.value.name, branch)">下载源码</t-button>
</div>
<t-tabs
class="tabs"
theme="card"
v-model="tabActivated"
:onRemove="onTabRemove"
>
<t-tab-panel
v-for="file in tabFiles"
:key="file.path"
:value="file.path"
:label="file.name"
:removable="file.removable"
@click="onClickOpenFile(file)"
>
<template #label>
<span
v-text="file.name"
@click="onClickOpenFile(file)"
></span>
</template>
</t-tab-panel>
</t-tabs>
<div v-if="tabActivatedFileViewer"
:class="`viewer ${viewerClass}`"
@click="onClickViewer"
>
<markdown-view
v-if="tabActivatedFile && tabActivatedFileViewer !== PrismjsViewer.TEXT"
class="content selectable"
:show-code-border="false"
max-height="auto"
:content="tabActivatedFile.data"
@click="onClickViewer"
/>
<textarea
v-if="tabActivatedFile && tabActivatedFileViewer === PrismjsViewer.TEXT"
class="content selectable"
:value="tabActivatedFile.data"
readonly
></textarea>
</div>
<div v-else class="viewer not-support">
<div class="content">不支持预览</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Icon, MarkdownView, Prismjs, PrismjsType, PrismjsViewer, Toolkit } from "timi-web";
import type { TreeInstanceFunctions, TreeProps } from "tdesign-vue-next";
import RepositoryAPI from "@/api/RepositoryAPI";
import { File, FileType } from "@/types/Repository";
import { useRepositoryStore } from "@/store/repository.ts";
import useClipboard from "vue-clipboard3";
const router = useRouter();
const { toClipboard } = useClipboard();
const repositoryStore = useRepositoryStore();
// ---------- 分支 ----------
const branch = ref();
onMounted(async () => {
if (repositoryStore.value && repositoryStore.value.defaultBranch) {
branch.value = repositoryStore.value.defaultBranch;
}
});
// ---------- 文件树 ----------
const tree = ref<TreeInstanceFunctions>();
const treeItems = ref<File[]>([]);
const treeMap = {
value: "path",
label: "name"
};
watch(branch, async () => {
if (!repositoryStore.value || !branch.value) {
return;
}
treeItems.value = await RepositoryAPI.listFile(repositoryStore.value!.name, branch.value, "/");
treeItems.value.forEach((item) => item.children = item.type === FileType.DIRECTORY);
await nextTick();
// 文件树
if (tree.value) {
const items = treeItems.value;
for (const item of items) {
if (item.path === "README.md") {
await doOpenFile(tree.value.getItem(item.path).data as File);
break;
}
}
}
});
const treeLoad: TreeProps["load"] = node => {
return new Promise((resolve) => {
Toolkit.async(async () => {
const file = node.data;
const result = await RepositoryAPI.listFile(
repositoryStore.value!.name,
branch.value,
`/${file.path}`
);
result.forEach((item) => item.children = item.type === FileType.DIRECTORY);
resolve(result);
});
});
};
// ---------- 克隆地址 ----------
enum CloneType {
SSH = "SSH",
HTTP = "HTTP"
}
const cloneType = ref(CloneType.HTTP);
const cloneAddr = computed(() => cloneType.value === CloneType.HTTP ? repositoryStore.value!.cloneUrl : repositoryStore.value!.sshUrl);
async function doCopyCloneAddr() {
try {
await toClipboard(cloneAddr.value);
await MessagePlugin.success("复制成功");
} catch (e) {
await MessagePlugin.success("复制失败");
console.error(e);
}
}
// ---------- 打开文件 ----------
type OpenFile = {
path: string;
name: string;
data?: string;
removable: boolean;
isPreview: boolean;
activatedAt?: number;
prismjsType?: PrismjsType;
}
const tabFiles = ref<OpenFile[]>([]);
const tabActivated = ref();
const tabActivatedFile = ref<OpenFile>();
const tabActivatedFileViewer = ref<PrismjsViewer>();
const viewerClass = computed(() => {
if (tabActivatedFileViewer.value) {
return Toolkit.className(tabActivatedFileViewer.value.toString().toLowerCase());
}
return "";
});
watch(branch, async () => {
if (!branch.value) {
return;
}
await router.replace(`/${repositoryStore.value!.name}/${branch.value}`);
tabFiles.value = [];
tabActivated.value = null;
});
watch(tabActivated, value => {
if (tabActivated.value) {
const targetFile = tabFiles.value.find(item => item.path === value);
if (targetFile) {
targetFile.activatedAt = new Date().getTime();
tabActivatedFile.value = targetFile;
return;
}
}
tabActivatedFile.value = undefined;
});
watch(tabActivatedFile, () => {
if (tabActivatedFile.value && tabActivatedFile.value.prismjsType) {
const properties = Prismjs.getFileProperties(tabActivatedFile.value.prismjsType);
if (properties) {
tabActivatedFileViewer.value = properties.viewer;
return;
}
}
tabActivatedFileViewer.value = undefined;
});
const doOpenFile = async (file: File) => {
if (file.type === FileType.FILE) {
let opened = tabFiles.value.find(item => item.path === file.path);
const previewIndex = tabFiles.value.findIndex(item => item.isPreview);
const isReadme = file.path === "README.md";
if (!opened) {
opened = {
path: file.path,
name: file.name,
isPreview: !isReadme,
removable: !isReadme,
prismjsType: PrismjsType.PlainText
};
if (previewIndex === -1) {
tabFiles.value.push(opened);
} else {
// 如果存在已打开预览,则替换该预览项
tabFiles.value.splice(previewIndex, 1, opened);
}
const raw = await RepositoryAPI.fileRaw(repositoryStore.value!.name, branch.value, `/${opened.path}`);
const decoder = new TextDecoder("utf-8");
opened.prismjsType = Prismjs.typeFromFileName(opened.name);
if (opened.prismjsType) {
const properties = Prismjs.getFileProperties(opened.prismjsType);
if (properties && properties.viewer === PrismjsViewer.CODE) {
opened.data = "```" + properties.prismjs + "\n" + decoder.decode(raw) + "\n```";
} else {
opened.data = decoder.decode(raw);
}
} else {
opened.data = "该文件暂不支持预览";
}
}
opened.activatedAt = new Date().getTime();
tabActivated.value = file.path;
tabActivatedFile.value = opened;
}
};
// TODO 明确类型会构建失败
const onTreeActivated = (_value: any, context: { node: any }) => {
doOpenFile(context.node.data);
};
const onTabRemove = (options: { index: number }) => {
tabFiles.value.splice(options.index, 1);
if (0 < tabFiles.value.length) {
tabActivated.value = tabFiles.value[tabFiles.value.length - 1].path;
} else {
tabActivated.value = null;
}
};
const onClickOpenFile = (openFile: OpenFile) => {
openFile.isPreview = false;
};
const onClickViewer = async () => {
const activated = tabFiles.value.find(item => item.path === tabActivated.value);
if (activated) {
activated.isPreview = false;
}
};
</script>
<style lang="less" scoped>
.file-detail {
// 100% 可视高度 - 上下外边距 - 边框 - 顶部 - 菜单 - 底部
height: calc(100vh - 128px * 2 - 2px - 50px - 50px - 94px);
display: flex;
.left {
width: 16rem;
z-index: 4;
display: flex;
flex-direction: column;
.branch {
border-bottom: var(--tui-border);
:deep(.t-select-input--borderless) {
.t-input {
border: 1px solid transparent;
box-shadow: none;
background: #FFF;
&:hover:not(.t-input--focused) {
background: var(--td-bg-color-container-hover);
}
}
.t-input--focused {
box-shadow: 0 0 0 2px var(--td-brand-color-focus);
background: var(--td-bg-color-specialcomponent);
border-color: var(--td-brand-color);
}
}
}
.tree-container {
height: 100%;
overflow: auto;
.tree {
&:deep(.t-tree__line) {
--color: #333;
margin-left: .5rem;
}
&:deep(.t-tree__icon) {
margin-left: .5rem;
}
&:deep(.t-tree__item.t-is-active) {
background: var(--td-brand-color-light);
}
}
}
}
.right {
width: calc(100% - 16rem);
display: flex;
border-left: var(--tui-border);
flex-direction: column;
.header {
top: calc(50px + 49px);
z-index: 4;
display: flex;
position: sticky;
.clone-label {
display: flex;
padding: 0 10px;
background: var(--td-bg-color-secondarycontainer);
line-height: 30px;
border-right: var(--tui-border);
}
.clone-type {
width: 6rem;
border-right: var(--tui-border);
}
.address {
flex: 1;
:deep(.t-input) {
border-color: transparent;
}
:deep(.t-input:hover),
:deep(.t-input--focused) {
border-color: var(--td-brand-color);
}
}
}
.tabs {
border-top: var(--tui-border);
:deep(.t-tabs__nav-container.t-tabs__nav--card::after) {
background: var(--tui-light-gray);
}
}
.viewer {
height: 100%;
overflow: auto;
&.text {
.content {
width: calc(100% - 8px);
height: calc(100% - 4px);
border: none;
padding: 2px 4px;
font-size: 14px;
word-wrap: normal;
}
}
&.code {
:deep(.tui-markdown-view) {
height: 100%;
position: relative;
&::before {
content: "";
top: 0;
left: 0;
width: 3em;
height: 100%;
position: absolute;
background: rgba(242, 242, 242, .9);
border-right: 1px solid #999;
}
pre[class^="language-"] {
height: 100%;
}
}
}
&.markdown {
height: 100%;
padding: .5rem 1rem;
}
&.not-support {
text-align: center;
}
:deep(pre[class*="language-"]) {
margin: 0;
border: none;
}
}
}
}
@media screen and (max-width: 1200px) {
.file-detail {
height: calc(100vh - 50px - 50px - 94px);
}
}
</style>

View File

@ -0,0 +1,166 @@
<template>
<div class="issue-detail" v-if="issue && repositoryStore.value">
<div class="header">
<t-breadcrumb max-item-width="9999">
<t-breadcrumb-item>
<router-link :to="`/${repositoryStore.value.name}/issues`">反馈列表</router-link>
</t-breadcrumb-item>
<t-breadcrumb-item>
<h3 class="title" v-text="issue.title"></h3>
</t-breadcrumb-item>
</t-breadcrumb>
</div>
<div class="content">
<div class="description">
<markdown-view :content="issue.description" />
<div class="edit" v-if="canEdit">
<t-button @click="router.push(`/${repositoryStore.value.name}/issues/edit/${issue.id}`)">
修改问题反馈
</t-button>
</div>
</div>
<t-list>
<t-list-item>
<t-list-item-meta title="编号">
<template #description>
<span>#</span>
<span v-text="issue.id"></span>
</template>
</t-list-item-meta>
</t-list-item>
<t-list-item>
<t-list-item-meta title="用户" :description="issue.publisherNick">
<template #description>
<t-space size="6px">
<template v-if="issue.publisher">
<t-avatar
:class="(<any>ImageType)[issue.publisher.profile.avatarType]"
:image="UserAPI.getAvatarURL(issue.publisher.profile)"
size="small"
shape="round"
/>
<span v-text="issue.publisher.name"></span>
</template>
<span v-else v-text="issue.publisherNick"></span>
</t-space>
</template>
</t-list-item-meta>
</t-list-item>
<t-list-item>
<t-list-item-meta title="版本" :description="issue.version" />
</t-list-item>
<t-list-item v-if="issue.type">
<t-list-item-meta title="类型" :description="(<any>Type)[issue.type]" />
</t-list-item>
<t-list-item v-if="issue.status">
<t-list-item-meta title="状态" :description="(<any>Status)[issue.status]" />
</t-list-item>
<t-list-item v-if="issue.closedAt">
<t-list-item-meta title="关闭时间" :description="Time.toPassedDateTime(issue.closedAt)" />
</t-list-item>
</t-list>
</div>
<timeline-comment
v-if="!!issue.id"
:biz-type="CommentBizType.GIT_ISSUE"
:bizId="issue.id"
:canCreate="issue.status !== <Status>Toolkit.keyFromValue(Status, Status.CLOSED)"
:systemComments="commentTimelineSystems"
/>
</div>
</template>
<script lang="ts" setup>
import IssueAPI from "@/api/IssueAPI";
import { MarkdownView, Time, Toolkit, userStore } from "timi-web";
import { CommentBizType, ImageType, UserAPI } from "timi-web";
import TimelineComment, { SystemComment } from "@/components/TimelineComment.vue";
import { IssueView, Status, Type } from "@/types/Issue";
import { useRepositoryStore } from "@/store/repository.ts";
// 引用数据
const route = useRoute();
const router = useRouter();
const repositoryStore = useRepositoryStore();
// 基本数据
const issue = ref<IssueView>();
const commentTimelineSystems = ref<SystemComment[]>([]);
const canEdit = computed(() => {
const _issue = issue.value;
if (repositoryStore.value && _issue && _issue.id && userStore.loginUser) {
const profile = userStore.loginUser.user?.profile;
if (profile && profile.userId === _issue.publisherId) {
const typeCondition = _issue.type !== <Type>Toolkit.keyFromValue(Type, Type.DISCUSS);
const statusCondition = _issue.status !== <Status>Toolkit.keyFromValue(Status, Status.FINISHED);
return typeCondition && statusCondition && !_issue.closedAt;
}
}
return false;
});
onMounted(async () => {
const issueId = route.params.id;
if (issueId) {
issue.value = await IssueAPI.view(issueId as any as number);
if (issue.value.confirmedAt) {
commentTimelineSystems.value.push({
inserted: false,
time: issue.value.confirmedAt,
msg: "开发者已确认,等待开发排期"
});
}
if (issue.value.developAt) {
commentTimelineSystems.value.push({
colorClass: "bg-pink",
inserted: false,
time: issue.value.developAt,
msg: "正在开发.."
});
}
}
});
</script>
<style lang="less" scoped>
.issue-detail {
.header {
top: calc(50px + 49px);
margin: 0;
z-index: 3;
height: 32px;
display: flex;
position: sticky;
background: rgba(231, 234, 239, .8);
align-items: center;
padding-left: 1rem;
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.title {
margin: 0;
}
}
.content {
display: grid;
grid-template-columns: 80% 20%;
.description {
padding: .5rem 1rem;
display: flex;
align-self: stretch;
border-right: var(--tui-border);
flex-direction: column;
.edit {
display: flex;
margin-top: 2rem;
justify-content: center;
}
}
}
}
</style>

View File

@ -0,0 +1,192 @@
<template>
<t-layout v-if="repositoryStore.value" class="issue-edit">
<t-form class="form" :data="issue" @submit="doSubmit">
<t-header class="header shadow-down">
<t-breadcrumb>
<t-breadcrumb-item>
<router-link :to="`/${repositoryStore.value.name}/issues`">问题反馈列表</router-link>
</t-breadcrumb-item>
<t-breadcrumb-item v-if="issue.id">
<span v-text="issue.title"></span>
</t-breadcrumb-item>
<t-breadcrumb-item>
<span v-if="issue.id">编辑问题反馈</span>
<span v-else>新建反馈</span>
</t-breadcrumb-item>
</t-breadcrumb>
</t-header>
<t-content class="content">
<t-alert v-if="!userStore.isLogged()" class="login-tips" theme="info">
<span>建议登录账号以便修改已提交的反馈和接收邮件通知</span>
<span class="pink">未登录账号提价后不可修改</span>
</t-alert>
<t-form-item label="昵称" name="name">
<t-input
v-if="userStore.isLogged()"
class="name"
:value="userStore.loginUser?.user?.name"
disabled
/>
<t-input v-else class="name" v-model="issue.publisherNick" placeholder="" />
</t-form-item>
<t-form-item label="标题" name="title">
<t-input v-model="issue.title" placeholder="" />
</t-form-item>
<t-form-item label="类型" name="type">
<t-select
class="type"
v-model="issue.type"
:defaultValue="Type.BUG"
>
<t-option v-for="(value, key) in Type" :key="key" :value="key" :label="value" />
</t-select>
</t-form-item>
<t-form-item label="版本" name="version">
<t-input class="version" v-model="issue.version" placeholder="" />
</t-form-item>
<t-form-item label="描述" name="description">
<markdown-editor v-model:data="issue.description" />
</t-form-item>
<t-form-item></t-form-item><!-- 用于保持边距 -->
</t-content>
<t-footer class="footer shadow-up">
<t-form-item v-if="!issue.id" label="验证码" name="captcha">
<t-input-adornment>
<t-input class="captcha" v-model="captchaValue" placeholder="" />
<template #append>
<captcha
:api="CommonAPI.getCaptchaAPI()"
:width="90"
:height="28"
:from="CaptchaFrom.GIT_ISSUE"
/>
</template>
</t-input-adornment>
</t-form-item>
<t-form-item>
<t-space>
<t-button type="submit">提交</t-button>
<t-button
v-if="issue.id"
@click="router.back()"
theme="default"
>
取消
</t-button>
</t-space>
</t-form-item>
</t-footer>
</t-form>
</t-layout>
</template>
<script lang="ts" setup>
import { CaptchaFrom, CommonAPI, userStore } from "timi-web";
import { Captcha, MarkdownEditor } from "timi-web";
import { MessagePlugin } from "tdesign-vue-next";
import IssueAPI from "@/api/IssueAPI";
import { Issue, Type } from "@/types/Issue";
import { useRepositoryStore } from "@/store/repository.ts";
const route = useRoute();
const router = useRouter();
const repositoryStore = useRepositoryStore();
const issue = reactive<Issue>({
publisherNick: "",
type: undefined,
version: "",
title: "",
description: ""
});
const captchaValue = ref();
const doSubmit = () => {
if (issue.id) {
IssueAPI.update(issue).then(() => {
if (repositoryStore.value) {
router.push(`/${repositoryStore.value.name}/issues/${issue.id}`);
}
MessagePlugin.success("提交成功");
}).catch(msg => {
MessagePlugin.error("提交失败:" + msg);
});
} else {
IssueAPI.create({
from: CaptchaFrom.GIT_ISSUE,
captcha: captchaValue.value,
data: issue
}).then(() => {
router.push({name: "IssueList"});
MessagePlugin.success("提交成功");
}).catch(msg => {
MessagePlugin.error("提交失败:" + msg);
});
}
};
onMounted(async () => {
if (repositoryStore.value) {
issue.repositoryId = repositoryStore.value.id;
const issueId = route.params.id;
if (issueId) {
const result = await IssueAPI.view(issueId as any as number);
issue.id = result.id;
issue.title = result.title;
issue.type = result.type;
issue.version = result.version;
issue.description = result.description;
}
}
});
</script>
<style lang="less" scoped>
.form {
.header {
top: calc(50px + 49px);
margin: 0;
height: 32px;
z-index: 3;
display: flex;
position: sticky;
background: rgba(255, 255, 255, .8);
align-items: center;
padding-left: 1rem;
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.content {
padding: 20px;
.login-tips {
margin-bottom: 2rem;
}
.name,
.type,
.version {
width: 10rem;
}
}
.footer {
padding: 20px;
bottom: -1px;
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.captcha {
width: 6rem;
margin-right: 1rem;
}
}
}
</style>

View File

@ -0,0 +1,185 @@
<template>
<t-layout class="issue-list" v-if="repositoryStore.value">
<t-header class="header">
<t-row justify="space-between">
<t-col flex="auto">
<t-input-adornment prepend="搜索">
<t-input v-model="page.keyword" placeholder="请输入关键字" />
</t-input-adornment>
</t-col>
<t-col class="right">
<t-space>
<t-select v-model="page.type" label="类型" placeholder="全部" autoWidth clearable>
<t-option v-for="(value, key) in Type" :key="key" :value="key" :label="value" />
</t-select>
<t-select v-model="page.status" label="状态" placeholder="全部" autoWidth clearable>
<t-option v-for="(value, key) in Status" :key="key" :value="key" :label="value" />
</t-select>
<router-link :to="`/${repositoryStore.value.name}/issues/edit`">
<t-button theme="success">新建反馈</t-button>
</router-link>
</t-space>
</t-col>
</t-row>
</t-header>
<t-content class="list">
<t-list v-if="pageResult" :split="true">
<t-list-item class="action" v-for="item in pageResult.list" :key="item.id">
<t-layout>
<t-aside width="auto">
<t-space class="tags" :size="4">
<div
v-if="item.type"
class="tag type"
:class="colorType[(<any>Type)[item.type]]"
v-text="(<any>Type)[item.type]"
></div>
<div
v-if="item.status"
class="tag status"
:class="colorStatus[(<any>Status)[item.status]]"
v-text="(<any>Status)[item.status]"
></div>
</t-space>
</t-aside>
<t-content class="content">
<router-link :to="`/${repositoryStore.value.name}/issues/${item.id}`">
<t-link theme="default" size="large" hover="color" :content="item.title" />
</router-link>
<p class="time gray">
<span v-if="item.closedAt" v-text="`关闭于 ${Time.toPassedDateTime(item.closedAt)}`"></span>
<span v-else v-text="`创建于 ${Time.toPassedDateTime(item.createdAt)}`"></span>
</p>
</t-content>
</t-layout>
</t-list-item>
</t-list>
<empty-tips :showOn="pageResult?.total === 0" />
</t-content>
<t-footer class="footer" height="auto">
<t-pagination
v-if="pageResult"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
:onCurrentChange="(current: number) => page.index = current - 1"
/>
</t-footer>
</t-layout>
</template>
<script lang="ts" setup>
import { EmptyTips, PageResult, Time, Toolkit } from "timi-web";
import IssueAPI from "@/api/IssueAPI";
import { Issue, Page, Status, Type } from "@/types/Issue";
import { useRepositoryStore } from "@/store/repository.ts";
const colorType = {
[Type.BUG.toString()]: "bg-red",
[Type.FEATURE.toString()]: "bg-blue",
[Type.SECURITY.toString()]: "bg-orange",
[Type.QUESTION.toString()]: "bg-pink",
[Type.DISCUSS.toString()]: "bg-gray"
};
const colorStatus = {
[Status.BEFORE_CONFIRM.toString()]: "orange",
[Status.CONFIRMED.toString()]: "blue",
[Status.DEVELOPING.toString()]: "pink",
[Status.FINISHED.toString()]: "green",
[Status.CLOSED.toString()]: "gray"
};
const repositoryStore = useRepositoryStore();
const page = reactive<Page>({
repositoryId: undefined,
index: 0,
size: 12
});
const pageResult = ref<PageResult<Issue>>();
const fetchList = Toolkit.debounce(async () => pageResult.value = await IssueAPI.page(page));
watch(page, fetchList);
onMounted(async () => {
if (repositoryStore.value) {
page.repositoryId = repositoryStore.value.id;
await fetchList();
}
});
</script>
<style lang="less" scoped>
.issue-list {
min-height: 480px;
.header {
top: calc(50px + 49px);
z-index: 3;
padding: 0 20px;
display: flex;
position: sticky;
background: rgba(231, 234, 239, .8);
align-items: center;
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.t-row {
width: 100%;
}
.right {
margin-left: 1rem;
}
.filter {
padding: 0 1rem;
}
}
.list {
.tags {
display: flex;
padding-top: 1px;
.tag {
padding: 1px 5px;
&.type {
color: #FFF;
}
}
}
.content {
padding-left: .5rem;
.title {
margin: 0;
}
.time {
margin: 0;
}
}
}
.footer {
padding: 8px 16px;
bottom: -1px;
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
@media screen and (max-width: 992px) {
.issue-list {
min-height: calc(100vh - 100px);
}
}
</style>

View File

@ -0,0 +1,181 @@
<template>
<div v-if="merge && repositoryStore.value" class="merge-detail">
<div class="header shadow-down">
<t-breadcrumb max-item-width="9999">
<t-breadcrumb-item>
<router-link :to="`/${repositoryStore.value.name}/merges`">合并请求列表</router-link>
</t-breadcrumb-item>
<t-breadcrumb-item>
<h3 class="title" v-text="merge.title"></h3>
</t-breadcrumb-item>
</t-breadcrumb>
</div>
<div class="content">
<div class="description">
<markdown-view :content="merge.description" />
<div class="edit" v-if="canEdit">
<t-button @click="router.push(`/${repositoryStore.value.name}/merges/edit/${merge.id}`)">
修改合并请求
</t-button>
</div>
</div>
<t-list>
<t-list-item>
<t-list-item-meta title="用户" :description="merge.requester?.name" />
</t-list-item>
<t-list-item v-if="merge.issue">
<t-list-item-meta title="相关反馈">
<template #description>
<t-link
v-popup="merge.issue.title"
theme="primary"
:href="`/${repositoryStore.value.name}/issues/${merge.issue.id}`"
target="_blank"
:content="`#${merge.issue.id}`"
/>
</template>
</t-list-item-meta>
</t-list-item>
<t-list-item>
<t-list-item-meta title="来源分支" :description="merge.fromBranch" />
</t-list-item>
<t-list-item>
<t-list-item-meta title="去向分支" :description="merge.toBranch" />
</t-list-item>
<t-list-item v-if="merge.type">
<t-list-item-meta title="类型" :description="(<any>Type)[merge.type]" />
</t-list-item>
<t-list-item v-if="merge.status">
<t-list-item-meta title="状态" :description="(<any>Status)[merge.status]" />
</t-list-item>
<t-list-item v-if="merge.mergedAt">
<t-list-item-meta title="合并时间" :description="Time.toPassedDateTime(merge.mergedAt)" />
</t-list-item>
<t-list-item v-if="merge.rejectedAt">
<t-list-item-meta title="拒绝时间"
:description="Time.toPassedDateTime(merge.rejectedAt)" />
</t-list-item>
<t-list-item v-if="merge.closedAt">
<t-list-item-meta title="关闭时间" :description="Time.toPassedDateTime(merge.closedAt)" />
</t-list-item>
</t-list>
</div>
<timeline-comment
v-if="!!merge.id"
:biz-type="CommentBizType.GIT_MERGE"
:bizId="merge.id"
:canCreate="merge.status !== <Status>Toolkit.keyFromValue(Status, Status.CLOSED)"
:systemComments="commentTimelineSystems"
/>
</div>
</template>
<script lang="ts" setup>
import { CommentBizType, MarkdownView, Time, Toolkit, userStore } from "timi-web";
import IssueAPI from "@/api/IssueAPI";
import MergeAPI from "@/api/MergeAPI";
import TimelineComment, { SystemComment } from "@/components/TimelineComment.vue";
import { MergeView, Status, Type } from "@/types/Merge";
import { useRepositoryStore } from "@/store/repository.ts";
const router = useRouter();
const repositoryStore = useRepositoryStore();
const merge = ref<MergeView>();
const commentTimelineSystems = ref<SystemComment[]>([]);
const canEdit = computed(() => {
const _merge = merge.value;
if (repositoryStore.value && _merge && _merge.id) {
const profile = userStore.loginUser?.user?.profile;
if (profile && profile.userId === _merge.requesterId) {
return !_merge.closedAt && _merge.status === <Status>Toolkit.keyFromValue(Status, Status.BEFORE_CHECK);
}
}
return false;
});
onMounted(async () => {
const mergeId = router.currentRoute.value.params.id;
if (mergeId) {
merge.value = await MergeAPI.view(mergeId as any as number);
if (merge.value.checkedAt) {
commentTimelineSystems.value.push({
colorClass: "bg-pink",
inserted: false,
time: merge.value.checkedAt,
msg: "管理员已确认,等待合并"
});
}
if (merge.value.mergedAt) {
commentTimelineSystems.value.push({
colorClass: "bg-green",
inserted: false,
time: merge.value.mergedAt,
msg: `已完成合并至分支 ${merge.value.toBranch}`
});
}
if (merge.value.rejectedAt) {
commentTimelineSystems.value.push({
colorClass: "bg-red",
inserted: false,
time: merge.value.rejectedAt,
msg: `管理员拒绝此合并请求:${merge.value.rejectReason}`
});
}
if (merge.value.closedAt) {
commentTimelineSystems.value.push({
colorClass: "bg-gray",
inserted: false,
time: merge.value.closedAt,
msg: "已关闭此合并请求"
});
}
if (merge.value.issueId) {
merge.value.issue = await IssueAPI.view(merge.value.issueId);
}
}
});
</script>
<style lang="less" scoped>
.merge-detail {
.header {
top: calc(50px + 49px);
margin: 0;
z-index: 3;
height: 32px;
display: flex;
position: sticky;
background: rgba(231, 234, 239, .8);
align-items: center;
padding-left: 1rem;
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.title {
margin: 0;
}
}
.content {
display: grid;
grid-template-columns: 80% 20%;
.description {
padding: .5rem 1rem;
display: flex;
align-self: stretch;
border-right: var(--tui-border);
flex-direction: column;
.edit {
display: flex;
margin-top: 2rem;
justify-content: center;
}
}
}
}
</style>

View File

@ -0,0 +1,264 @@
<template>
<t-layout v-if="repositoryStore.value" class="merge-edit">
<t-form class="form" :data="merge" @submit="doSubmit" :disabled="!userStore.isLogged()">
<t-header class="header">
<t-breadcrumb>
<t-breadcrumb-item>
<router-link :to="`/${repositoryStore.value.name}/merges`">合并请求列表</router-link>
</t-breadcrumb-item>
<t-breadcrumb-item v-if="merge.id">
<router-link :to="`/${repositoryStore.value.name}/merges/${merge.id}`">
<span v-text="merge.title"></span>
</router-link>
</t-breadcrumb-item>
<t-breadcrumb-item>
<span v-if="merge.id">编辑合并请求</span>
<span v-else>新建合并请求</span>
</t-breadcrumb-item>
</t-breadcrumb>
</t-header>
<t-content class="content">
<t-alert v-if="!userStore.isLogged()" class="login-tips" theme="error">合并请求需要登录账号</t-alert>
<t-form-item label="标题" name="title">
<t-input v-model="merge.title" placeholder="" />
</t-form-item>
<t-form-item label="类型" name="type">
<t-select
v-model="merge.type"
:defaultValue="Type.BUG"
autoWidth
>
<t-option v-for="(value, key) in Type" :key="key" :value="key" :label="value" />
</t-select>
</t-form-item>
<t-form-item label="相关反馈" name="title">
<t-select
class="issue"
v-model="merge.issueId"
:loading="issueSearching"
clearable
filterable
placeholder="请输入关键字搜索并选择"
:on-search="issueSearch"
:popup-props="{ overlayClassName: ['merge-edit', 'issue-options'] }"
>
<t-option
v-for="item in issueOptions"
:key="item.value"
:value="item.value"
:label="item.label"
>
<div class="option">
<div class="id" v-text="`#${item.value}`"></div>
<div v-text="item.label"></div>
</div>
</t-option>
</t-select>
</t-form-item>
<t-form-item label="分支" name="branch">
<t-space>
<t-select class="branch" v-model="merge.fromBranch" label="来源:">
<t-option
v-for="item in fromBranches"
:key="item.name"
:value="item.name"
:label="item.name"
/>
</t-select>
<t-select class="branch" v-model="merge.toBranch" label="去向:">
<t-option
v-for="item in toBranches"
:key="item.name"
:value="item.name"
:label="item.name"
/>
</t-select>
</t-space>
</t-form-item>
<t-form-item label="描述" name="description">
<markdown-editor v-model:data="merge.description" />
</t-form-item>
<t-form-item></t-form-item><!-- 用于保持边距 -->
</t-content>
<t-footer class="footer">
<t-form-item v-if="!merge.id" label="验证码" name="captcha">
<t-input-adornment>
<t-input class="captcha" v-model="captchaValue" placeholder="" />
<template #append>
<captcha
:api="CommonAPI.getCaptchaAPI()"
:from="CaptchaFrom.GIT_MERGE"
:width="90"
:height="28"
/>
</template>
</t-input-adornment>
</t-form-item>
<t-form-item>
<t-space>
<t-button type="submit">提交</t-button>
<t-button
v-if="merge.id"
@click="router.back()"
theme="default"
>
取消
</t-button>
</t-space>
</t-form-item>
</t-footer>
</t-form>
</t-layout>
</template>
<script lang="ts" setup>
import { CaptchaFrom, CommonAPI, LabelValue, userStore } from "timi-web";
import { Captcha, MarkdownEditor } from "timi-web";
import { MessagePlugin } from "tdesign-vue-next";
import IssueAPI from "@/api/IssueAPI";
import MergeAPI from "@/api/MergeAPI";
import { Merge, Type } from "@/types/Merge";
import { Branch } from "@/types/Repository";
import { useRepositoryStore } from "@/store/repository.ts";
const route = useRoute();
const router = useRouter();
const repositoryStore = useRepositoryStore();
const fromBranches = reactive<Branch[]>([]);
const toBranches = reactive<Branch[]>([]);
const issueOptions = reactive<LabelValue<number>[]>([]);
const issueSearching = ref<boolean>(false);
const merge = reactive<Merge>({
repositoryId: undefined,
title: "",
type: undefined,
description: "",
fromBranch: "",
toBranch: ""
});
const captchaValue = ref();
const issueSearch = async (keyword?: string) => {
issueSearching.value = true;
const result = await IssueAPI.page({
repositoryId: merge.repositoryId,
index: 0,
size: 10,
keyword
});
issueOptions.length = 0;
issueOptions.push(...result.list.map(item => {
return {
label: item.title,
value: item.id
} as LabelValue<number>;
}));
issueSearching.value = false;
};
const doSubmit = () => {
if (merge.id) {
MergeAPI.update(merge).then(() => {
if (repositoryStore.value) {
router.push(`/${repositoryStore.value.name}/merges/${merge.id}`);
}
MessagePlugin.success("提交成功");
}).catch(msg => {
MessagePlugin.error("提交失败:" + msg);
});
} else {
MergeAPI.create({
from: CaptchaFrom.GIT_MERGE,
captcha: captchaValue.value,
data: merge
}).then(() => {
router.push({name: "MergeList"});
MessagePlugin.success("提交成功");
}).catch(msg => {
MessagePlugin.error("提交失败:" + msg);
});
}
};
onMounted(async () => {
if (repositoryStore.value) {
merge.repositoryId = repositoryStore.value.id;
fromBranches.push(...repositoryStore.value.branchList);
toBranches.push(...repositoryStore.value.branchList);
await issueSearch();
const mergeId = route.params.id;
if (mergeId) {
const result = await MergeAPI.view(mergeId as any as number);
merge.id = result.id;
merge.title = result.title;
merge.type = result.type;
merge.issueId = result.issueId;
merge.fromBranch = result.fromBranch;
merge.toBranch = result.toBranch;
merge.description = result.description;
}
}
});
</script>
<style lang="less" scoped>
.merge-edit {
.header {
top: calc(50px + 49px);
margin: 0;
height: 32px;
z-index: 3;
display: flex;
position: sticky;
background: rgba(255, 255, 255, .8);
align-items: center;
padding-left: 1rem;
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.content {
padding: 20px;
.login-tips {
margin-bottom: 2rem;
}
.name {
width: 10rem;
}
.issue {
width: 50%;
}
}
.footer {
bottom: -1px;
padding: 20px;
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.captcha {
width: 6rem;
margin-right: 1rem;
}
}
}
:global(.merge-edit.issue-options .option) {
display: flex;
}
:global(.merge-edit.issue-options .option .id) {
color: #777;
margin-right: .5rem;
}
</style>

View File

@ -0,0 +1,199 @@
<template>
<t-layout v-if="repositoryStore.value" class="merge-list">
<t-header class="header">
<t-row justify="space-between">
<t-col flex="auto">
<t-input-adornment prepend="搜索">
<t-input v-model="page.keyword" placeholder="请输入关键字" />
</t-input-adornment>
</t-col>
<t-col class="right">
<t-space>
<t-select
v-model="page.type"
label="类型"
placeholder="全部"
autoWidth
clearable
>
<t-option v-for="(value, key) in Type" :key="key" :value="key" :label="value" />
</t-select>
<t-select
v-model="page.status"
label="状态"
placeholder="全部"
autoWidth
clearable
>
<t-option v-for="(value, key) in Status" :key="key" :value="key" :label="value" />
</t-select>
<router-link :to="`/${repositoryStore.value.name}/merges/edit`">
<t-button theme="success">申请合并</t-button>
</router-link>
</t-space>
</t-col>
</t-row>
</t-header>
<t-content class="list">
<t-list v-if="pageResult" :split="true">
<t-list-item class="action" v-for="item in pageResult.list" :key="item.id">
<t-layout>
<t-aside width="auto">
<t-space class="tags" :size="4">
<div
v-if="item.type"
class="tag type"
:class="colorType[(<any>Type)[item.type]]"
v-text="(<any>Type)[item.type]"
></div>
<div
v-if="item.status"
class="tag status"
:class="colorStatus[(<any>Status)[item.status]]"
v-text="(<any>Status)[item.status]"
></div>
</t-space>
</t-aside>
<t-content class="content">
<router-link :to="`/${repositoryStore.value.name}/merges/${item.id}`">
<t-link theme="default" size="large" hover="color" :content="item.title" />
</router-link>
<p class="time gray">
<span
v-if="item.rejectedAt"
v-text="`拒绝于 ${Time.toPassedDateTime(item.rejectedAt)}`"
></span>
<span v-else v-text="`申请于 ${Time.toPassedDateTime(item.createdAt)}`"></span>
</p>
</t-content>
</t-layout>
</t-list-item>
</t-list>
<empty-tips :showOn="pageResult?.total === 0" />
</t-content>
<t-footer class="footer" height="auto">
<t-pagination
v-if="pageResult"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
:onCurrentChange="(current: number) => page.index = current - 1"
/>
</t-footer>
</t-layout>
</template>
<script lang="ts" setup>
import { EmptyTips, PageResult, Time, Toolkit } from "timi-web";
import MergeAPI from "@/api/MergeAPI";
import { Merge, Page, Status, Type } from "@/types/Merge";
import { useRepositoryStore } from "@/store/repository.ts";
const colorType = {
[Type.BUG.toString()]: "bg-red",
[Type.FEATURE.toString()]: "bg-blue",
[Type.SECURITY.toString()]: "bg-orange",
[Type.DOCUMENT.toString()]: "bg-green",
[Type.REFACTOR.toString()]: "bg-purple"
};
const colorStatus = {
[Status.BEFORE_CHECK.toString()]: "orange",
[Status.WAITING.toString()]: "blue",
[Status.MERGED.toString()]: "green",
[Status.REJECTED.toString()]: "red",
[Status.CLOSED.toString()]: "gray"
};
const repositoryStore = useRepositoryStore();
const page = reactive<Page>({
repositoryId: undefined,
index: 0,
size: 12
});
const pageResult = ref<PageResult<Merge>>();
const fetchList = Toolkit.debounce(async () => pageResult.value = await MergeAPI.page(page));
watch(page, fetchList);
onMounted(async () => {
if (repositoryStore.value) {
page.repositoryId = repositoryStore.value.id;
await fetchList();
}
});
</script>
<style lang="less" scoped>
.merge-list {
min-height: 480px;
.header {
top: calc(50px + 49px);
z-index: 3;
padding: 0 20px;
display: flex;
position: sticky;
background: rgba(231, 234, 239, .8);
align-items: center;
border-bottom: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.t-row {
width: 100%;
}
.right {
margin-left: 1rem;
}
.filter {
padding: 0 1rem;
}
}
.list {
.tags {
display: flex;
padding-top: 1px;
.tag {
padding: 1px 5px;
&.type {
color: #FFF;
}
}
}
.content {
padding-left: .5rem;
.title {
margin: 0;
}
.time {
margin: 0;
}
}
}
.footer {
padding: 8px 16px;
bottom: -1px;
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
border-top: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
@media screen and (max-width: 992px) {
.merge-list {
min-height: calc(100vh - 100px);
}
}
</style>

View File

@ -0,0 +1,130 @@
<template>
<div class="release-list">
<t-timeline class="releases" mode="alternate" v-if="pageResult">
<t-timeline-item
class="release"
v-for="(release, index) in pageResult.list"
:key="index"
dot-color="primary"
>
<template #label>
<div class="time" v-text="Time.toPassedDateTime(release.createdAt)"></div>
</template>
<div class="content">
<h3 class="version" v-text="release.version"></h3>
<div class="commits gray" v-text="`在该版本发布之后已有 ${release.commits} 次代码提交更新`"></div>
<markdown-view class="description" :content="release.description" />
<div v-if="release.attachmentList && 0 < release.attachmentList.length" class="attachments">
<h4 class="title">下载附件</h4>
<t-list :split="true" size="small">
<t-list-item
class="attachment"
v-for="attachment in release.attachmentList"
:key="attachment.id"
>
<t-link
:href="CommonAPI.getAttachmentReadAPI(attachment.mongoId)"
:content="`${attachment.title}(${attachment.name})`"
target="_blank"
>
<template #prefix-icon>
<icon name="FILE" />
</template>
</t-link>
<div class="size" v-text="IOSize.format(attachment.size)"></div>
</t-list-item>
</t-list>
</div>
</div>
</t-timeline-item>
</t-timeline>
<empty-tips :showOn="Toolkit.isEmpty(pageResult?.list)" />
</div>
</template>
<script lang="ts" setup>
import ReleaseAPI from "@/api/ReleaseAPI";
import { Page, Release } from "@/types/Release";
import { CommonAPI, EmptyTips, Icon, IOSize, MarkdownView, PageResult, Time, Toolkit } from "timi-web";
import { useRepositoryStore } from "@/store/repository.ts";
const repositoryStore = useRepositoryStore();
const page = reactive<Page>({
repositoryId: undefined,
index: 0,
size: 12
});
const pageResult = ref<PageResult<Release>>();
onMounted(async () => {
if (repositoryStore.value) {
page.repositoryId = repositoryStore.value.id;
pageResult.value = await ReleaseAPI.page(page);
}
});
</script>
<style lang="less" scoped>
.release-list {
padding: 1rem 2rem;
.releases {
.release {
.time {
width: 70px;
margin-top: 2px;
}
.content {
margin-bottom: 2rem;
.version {
margin: 0 0 .5rem 0;
}
.commits {
font-size: 13px;
}
.description {
margin-top: 1rem;
}
.attachments {
border-top: 1px solid var(--td-component-border);
margin-top: 1.5rem;
.title {
margin: .5rem 0;
}
.attachment {
.file {
display: flex;
}
}
}
}
}
:deep(.release) {
.t-timeline-item__wrapper {
margin-left: 80px;
}
&.t-timeline-item--last .t-timeline-item__tail {
display: block;
}
}
}
}
@media screen and (max-width: 992px) {
.release-list {
min-height: calc(100vh - 98px);
}
}
</style>

15
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module "prismjs";
interface ImportMetaEnv {
readonly VITE_API: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

42
tsconfig.json Normal file
View File

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue",
"node_modules/tdesign-vue-next/global.d.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

12
tsconfig.node.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

91
vite.config.ts Normal file
View File

@ -0,0 +1,91 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import dts from "vite-plugin-dts";
import { resolve } from "path";
import VueSetupExtend from "vite-plugin-vue-setup-extend";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { TDesignResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({
base: "/",
plugins: [
vue(),
dts({
include: "./src"
}),
VueSetupExtend(),
AutoImport({
imports: [
"vue",
"vue-router",
"pinia",
{
"axios": [
["default", "axios"]
]
}
],
dts: "src/auto-imports.d.ts",
eslintrc: {
enabled: true,
globalsPropValue: true
},
resolvers: [
TDesignResolver({
library: "vue-next"
})
]
}),
Components({
dirs: [
"src/components",
"src/layout",
"src/views/components"
],
resolvers: [
TDesignResolver({
library: "vue-next"
})
]
})
],
css: {
preprocessorOptions: {
less: {
math: "always",
lessOptions: {
modifyVars: {
"@btn-border-radius": "0"
},
javascriptEnabled: true
}
}
}
},
define: {
"process.env": {}
},
resolve: {
alias: {
"@": resolve(__dirname, "src")
},
extensions: [".js", ".json", ".ts"]
},
server: {
host: "git.imyeyu.dev",
port: 81
},
build: {
sourcemap: false,
minify: "terser",
terserOptions: {
compress: {
// eslint-disable-next-line camelcase
drop_console: true,
// eslint-disable-next-line camelcase
drop_debugger: true
}
}
}
});