搜索结果列表页面开发
This commit is contained in:
29
src/components/Animation/config.ts
Normal file
29
src/components/Animation/config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import dataEmpty from './data/data-empty.json';
|
||||
import loading from './data/loading.json';
|
||||
import error403 from './data/error-403.json';
|
||||
import error404 from './data/error-404.json';
|
||||
import error503 from './data/error-503.json';
|
||||
|
||||
// lottie动画图片资源路径
|
||||
export const assetsPath = `${import.meta.env.VITE_STATIC_HOST}/static/images/`;
|
||||
|
||||
export const defaultRender = 'canvas';
|
||||
|
||||
// lottie动画定义
|
||||
export const animationMap = {
|
||||
dataEmpty: {
|
||||
data: dataEmpty
|
||||
},
|
||||
loading: {
|
||||
data: loading
|
||||
},
|
||||
error403: {
|
||||
data: error403
|
||||
},
|
||||
error404: {
|
||||
data: error404
|
||||
},
|
||||
error503: {
|
||||
data: error503
|
||||
},
|
||||
};
|
||||
1
src/components/Animation/data/data-empty.json
Normal file
1
src/components/Animation/data/data-empty.json
Normal file
File diff suppressed because one or more lines are too long
1
src/components/Animation/data/error-403.json
Normal file
1
src/components/Animation/data/error-403.json
Normal file
File diff suppressed because one or more lines are too long
1
src/components/Animation/data/error-404.json
Normal file
1
src/components/Animation/data/error-404.json
Normal file
File diff suppressed because one or more lines are too long
1
src/components/Animation/data/error-503.json
Normal file
1
src/components/Animation/data/error-503.json
Normal file
File diff suppressed because one or more lines are too long
1
src/components/Animation/data/loading.json
Normal file
1
src/components/Animation/data/loading.json
Normal file
File diff suppressed because one or more lines are too long
41
src/components/Animation/index.vue
Normal file
41
src/components/Animation/index.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div ref="lottieRef" class="g-animation" :style="{ width, height }"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
// import lottie from 'lottie-web';
|
||||
import { assetsPath, animationMap, defaultRender } from './config';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string, // 动画名称,在./config.js中配置
|
||||
loop?: boolean,
|
||||
autoplay?: boolean,
|
||||
renderer?: string,
|
||||
width?: string, // 需为有效的样式长度值,如 '100px'
|
||||
height?: string // 通width
|
||||
}>(), {
|
||||
loop: true,
|
||||
autoplay: true
|
||||
});
|
||||
|
||||
const lottieRef = ref(null);
|
||||
onMounted(() => {
|
||||
const config = animationMap[props.name];
|
||||
|
||||
if (config) {
|
||||
lottie.loadAnimation({
|
||||
container: lottieRef.value,
|
||||
animationData: config.data,
|
||||
assetsPath,
|
||||
renderer: props.renderer || config.renderer || defaultRender,
|
||||
loop: props.loop,
|
||||
autoplay: props.autoplay,
|
||||
width: props.width,
|
||||
height: props.height
|
||||
});
|
||||
} else {
|
||||
console.error(`Animation named ${props.name} doesnot exist`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
50
src/components/AssociatedEmail/README.md
Normal file
50
src/components/AssociatedEmail/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# AssociatedEmail
|
||||
|
||||
### 说明
|
||||
- AssociatedEmail组件为关联邮箱组件,demo如下
|
||||
``` js
|
||||
// tempalte
|
||||
<associated-email
|
||||
email="lixuanyang@gitcode.net"
|
||||
:is-mail="true"
|
||||
:is-verify="true"
|
||||
@handle-star="handleEmailStar"
|
||||
@handle-deleted="handleEmailDeleted"
|
||||
>
|
||||
</associated-email>
|
||||
// script
|
||||
const handleEmailStar = (props:object) => {
|
||||
|
||||
};
|
||||
const handleEmailDeleted = (props:object) => {
|
||||
|
||||
};
|
||||
```
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| id? | 卡片唯一id | string | | |
|
||||
| email | 邮箱 | string | | |
|
||||
| isMail? | 是否是主邮箱 | boolean | | false |
|
||||
| isVerify? | 是否校验 | boolean | | false |
|
||||
| hideDeleted? | 隐藏删除按钮 | boolean | | false |
|
||||
| hideStar? | 隐藏Star按钮 | boolean | | false |
|
||||
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
| handleStar | 点击Star按钮触发 | props(当前卡片对象信息) |
|
||||
| handleDeleted | 点击删除按钮触发 | props(当前卡片对象信息) |
|
||||
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
| header | 邮箱区域插槽 |
|
||||
| main | 主邮箱区域插槽 |
|
||||
| content | 描述内容区域插槽 |
|
||||
| right | 右侧按钮区域插槽 |
|
||||
123
src/components/AssociatedEmail/index.vue
Normal file
123
src/components/AssociatedEmail/index.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="g-email ">
|
||||
<div class="g-email-left">
|
||||
<div class="g-email-left-header flex items-center">
|
||||
<slot name="header">
|
||||
<div class="g-email-left-header-email">{{ email }}</div>
|
||||
<slot name="main">
|
||||
<span class="g-email-left-header-tip main" v-if="isMail">默认</span>
|
||||
<span class="g-email-left-header-tip no-verify" v-if="!isVerify">未验证</span>
|
||||
</slot>
|
||||
<div class="g-email-right-text" v-if="!isVerify" @click.stop="emits('handleStar', props)">
|
||||
<span class="g-email-right-text-p">重新发送邮件确认</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isMail" class="g-email-right">
|
||||
<slot name="right">
|
||||
<MoreList :moreOpts="moreOpts" :item="item"></MoreList>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MoreList from '@/components/MoreList/index.vue';
|
||||
const props = withDefaults(defineProps<{
|
||||
id?: string
|
||||
email: string
|
||||
isMail?: boolean
|
||||
isVerify?: boolean
|
||||
hideDeleted?: boolean
|
||||
hideStar?: boolean,
|
||||
moreOpts?: any,
|
||||
item?: any
|
||||
}>(), {
|
||||
id: '',
|
||||
email: '',
|
||||
isMail: false,
|
||||
isVerify: false,
|
||||
hideDeleted: false,
|
||||
hideStar: false,
|
||||
moreOpts: () => [],
|
||||
item: () => ({
|
||||
})
|
||||
});
|
||||
|
||||
const emits = defineEmits(['handleStar', 'handleDeleted']);
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-email-border-color: var(--color-G300);
|
||||
|
||||
.g-email {
|
||||
border: 1px solid $g-email-border-color;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
&:last-of-type{
|
||||
// border-bottom: none;
|
||||
}
|
||||
&-left {
|
||||
align-self: center;
|
||||
&-header {
|
||||
&-email {
|
||||
width: 300px;
|
||||
font-size: 14px;
|
||||
color: var(--color-G900);
|
||||
}
|
||||
|
||||
&-tip {
|
||||
padding-left: 16px;
|
||||
font-size: 14px;
|
||||
border-left: 1px solid var(--color-G400);
|
||||
&.main {
|
||||
color: var(--color-Y500);
|
||||
}
|
||||
|
||||
&.no-verify {
|
||||
color: var(--color-O500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
margin-top: 8px;
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
font-size: 16px;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
:deep(.devui-button) {
|
||||
border-color: $g-email-border-color;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
align-self: center;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
.devui-icon__container {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&-p {
|
||||
margin-left: 4px;
|
||||
color: var(--color-CG600);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
373
src/components/BranchAdd/index.vue
Normal file
373
src/components/BranchAdd/index.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<d-modal v-model="vModels" class="branch-add-modal" :before-close="handleCancel">
|
||||
<template #header>
|
||||
<gc-modal-header>
|
||||
<span class="text-G900 text-base font-bold leading-[24px]">新建分支</span>
|
||||
</gc-modal-header>
|
||||
<div class="line bg-G200"></div>
|
||||
</template>
|
||||
<div class="invi-modal-body pt-20 px-24">
|
||||
<d-form ref="branchFormRef" layout="vertical" :rules="rules" :data="branchForm">
|
||||
<d-form-item field="ref" label="基于">
|
||||
<div>
|
||||
<div class="g-branch-compare-content">
|
||||
<div ref="ele" class="g-branch-compare-repo">
|
||||
<div class="g-branch-compare-custom-icon">
|
||||
<slot name="repo-prefix">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<d-editable-select class="g-branch-compare-select" v-model="repoName" :options="repoList"
|
||||
:disabled="true">
|
||||
<template #item="{ option, index }">
|
||||
<div class="g-branch-compare-option">
|
||||
<slot name="repo-option" :data="option" :index="index">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
<div class="g-branch-compare-label">{{ option.label }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
<div class="g-branch-compare-branch">
|
||||
<div class="g-branch-compare-custom-icon">
|
||||
<slot name="branch-prefix">
|
||||
<Icon :name="branchIcon" :size="branchIconSize" />
|
||||
</slot>
|
||||
</div>
|
||||
<BranchSelector v-model="branchForm.ref" type="branch" :branch="branch" :repoId="repoId" :branchParams="{
|
||||
branch_type: ''
|
||||
}" :isCommits="isCommits"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</d-form-item>
|
||||
<d-form-item field="branch" label="分支名称" :rules="[{ required: true, message: '分支名称不能为空', trigger: 'blur' }]">
|
||||
<d-input v-model="branchForm.branch" placeholder="请输入分支名称" maxlength="200" />
|
||||
</d-form-item>
|
||||
<div v-if="prompt" class="mt-[-16px] text-[#c7000b]">{{ prompt }}</div>
|
||||
<d-form-item field="description" label="描述" class="mb-0">
|
||||
<d-textarea v-model="branchForm.description" :rows="5" placeholder="请补充分支的描述信息" show-count
|
||||
maxlength="2000"></d-textarea>
|
||||
</d-form-item>
|
||||
</d-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="g-modal-footer">
|
||||
<d-button @click="handleCancel">取消</d-button>
|
||||
<d-button :disabled="isConfirm" variant="solid" color="primary" :loading="loading"
|
||||
@click="handleConfirm">确认</d-button>
|
||||
</div>
|
||||
</template>
|
||||
</d-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed, provide } from 'vue';
|
||||
import { useModel } from '@/utils/hooks/useModel';
|
||||
import { getBranches, createBranches } from '@/api/branch';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import getRepoId from '@/utils/getRepoId';
|
||||
import { getRepoName } from '@/utils/index';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import BranchSelector from '@/components/BranchTagSelector/SingleSelector.vue';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
import { branchNameRegExp, tagNamePrefixHeadsRegExp, tagNamePrefixRemotesRegExp } from '@/utils/regex';
|
||||
|
||||
const repoId = ref(getRepoId());
|
||||
const branchFormRef = ref<any>(null);
|
||||
|
||||
const repoName = getRepoName();
|
||||
const { getRepoInfo } = repoInfoStore();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: boolean
|
||||
'onUpdate:modelValue'?: Function
|
||||
branches?: string[]
|
||||
options?: { name: string, value: string }[],
|
||||
branch?: string;
|
||||
repoIcon?: string;
|
||||
branchIcon?: string;
|
||||
repoIconSize?: string;
|
||||
branchIconSize?: string;
|
||||
repoReadonly?: boolean;
|
||||
maxHeight?: number;
|
||||
isCommits?: boolean
|
||||
}>(), {
|
||||
data: () => {
|
||||
return {
|
||||
repoId: null,
|
||||
sourceId: null,
|
||||
targetId: null
|
||||
};
|
||||
},
|
||||
branchList: () => [],
|
||||
repoIcon: 'version-history',
|
||||
branchIcon: 'gt-branches',
|
||||
repoIconSize: '16px',
|
||||
branchIconSize: '16px',
|
||||
repoReadonly: true,
|
||||
maxHeight: 250
|
||||
|
||||
});
|
||||
const emits = defineEmits(['confirm', 'update:modelValue']);
|
||||
|
||||
const branchForm = reactive({
|
||||
ref: props.branch || '',
|
||||
branch: '',
|
||||
description: ''
|
||||
});
|
||||
watch(() => props.branch, val => {
|
||||
branchForm.ref = val;
|
||||
});
|
||||
const checkRepoNameRule = (rule: object, value: string, callback: Function) => {
|
||||
if (!branchNameRegExp.test(value)) {
|
||||
return callback(new Error('分支名称由字母、数字-_和/组成;且/不能连续,分支名称必须以字母或数字开头,分支名称最多可以包含 200 个字符。'));
|
||||
} else if (!tagNamePrefixRemotesRegExp.test(value)) {
|
||||
return callback(new Error('格式错误,请勿以"refs/remotes/"开头'));
|
||||
} else if (!tagNamePrefixHeadsRegExp.test(value)) {
|
||||
return callback(new Error('格式错误,请勿以"refs/heads/"开头'));
|
||||
}
|
||||
return callback();
|
||||
};
|
||||
const rules = {
|
||||
ref: [{ required: true, message: '基于分支不能为空', trigger: 'blur' }],
|
||||
branch: [{ validator: checkRepoNameRule }, { required: true, message: '分支名称不能为空', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
const { vModels } = useModel(props, emits);
|
||||
|
||||
const repoList = ref([]);
|
||||
|
||||
const ele = ref(null);
|
||||
const selectWidth = ref(0);
|
||||
const { stop } = useResizeObserver(ele, (entries) => {
|
||||
const entry = entries[0];
|
||||
const { width } = entry.contentRect;
|
||||
selectWidth.value = width;
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
resetForm();
|
||||
vModels.value = false;
|
||||
};
|
||||
const resetForm = () => {
|
||||
branchForm.ref = '';
|
||||
branchForm.branch = '';
|
||||
branchForm.description = '';
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const handleConfirm = () => {
|
||||
branchFormRef.value.validate(async(isValid: any, invalidFields: any) => {
|
||||
if (!isValid) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const params = {
|
||||
repoId: repoId.value,
|
||||
ref: branchForm.ref,
|
||||
branch: branchForm.branch,
|
||||
description: branchForm.description
|
||||
};
|
||||
const { error } = await createBranches(params);
|
||||
if (!error) {
|
||||
emits('confirm', params.branch);
|
||||
resetForm();
|
||||
vModels.value = false;
|
||||
Message.success('分支创建成功');
|
||||
// 更新顶部tag信息
|
||||
getRepoInfo();
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
const isConfirm = computed(() => {
|
||||
if (branchForm.branch && branchForm.ref) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
watch(() => vModels.value, () => {
|
||||
branchForm.ref = props.branch;
|
||||
});
|
||||
const prompt = ref('');
|
||||
const bOptions = ref();
|
||||
provide('bOptions', bOptions);
|
||||
watch(() => branchForm.branch, val => {
|
||||
const bstr = val.split('/');
|
||||
const b = bOptions.value.find((item: any) => item.name === bstr[0]);
|
||||
if (b) {
|
||||
prompt.value = bstr[0] + '分支已存在';
|
||||
} else {
|
||||
prompt.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$g-branch-compare-border-color: var(--color-G300);
|
||||
$g-branch-compare-border-color-hover: var(--color-CG900);
|
||||
$g-branch-compare-border-color-focus: var(--color-link);
|
||||
$g-branch-compare-border-radius: 3px;
|
||||
$g-branch-compare-color: var(--color-CG900);
|
||||
$g-branch-compare-select-bg-color: linear-gradient(180deg, #FCFCFC 0%, #F8F9FB 100%);
|
||||
|
||||
.g-branch-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
&-tip {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
&-box {
|
||||
flex: 1;
|
||||
border: 1px solid $g-branch-compare-border-color;
|
||||
border-radius: $g-branch-compare-border-radius;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&-header {
|
||||
padding: 24px 32px 20px 32px;
|
||||
}
|
||||
|
||||
&-content {
|
||||
padding: 0px 0px 0 0px;
|
||||
}
|
||||
|
||||
&-repo {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&-branch {
|
||||
position: relative;
|
||||
|
||||
}
|
||||
|
||||
&-custom-icon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 38px;
|
||||
padding-left: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
|
||||
:deep(.icon) {
|
||||
align-self: center;
|
||||
color: $g-branch-compare-color !important;
|
||||
}
|
||||
|
||||
:deep(.devui-icon__container) {
|
||||
align-self: center;
|
||||
color: $g-branch-compare-color !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
color: $g-branch-compare-color;
|
||||
}
|
||||
|
||||
&-select {
|
||||
:deep(.devui-editable-select-input__wrapper) {
|
||||
background: $g-branch-compare-select-bg-color;
|
||||
}
|
||||
|
||||
:deep(.devui-editable-select-input__inner) {
|
||||
color: $g-branch-compare-color;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
:deep(.devui-editable-select__item) {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&-switch {
|
||||
margin: 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $g-branch-compare-border-radius;
|
||||
background: linear-gradient(180deg, #FCFCFC 0%, #F8F9FB 100%);
|
||||
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--color-CG400);
|
||||
display: flex;
|
||||
|
||||
:deep(.icon) {
|
||||
margin: 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.devui-form__label--required:before) {
|
||||
content: "*";
|
||||
color: var(--color-danger);
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-left: -12px
|
||||
}
|
||||
|
||||
:deep(.devui-select__input) {
|
||||
color: $g-branch-compare-color;
|
||||
padding-left: 36px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.invi-modal-body {
|
||||
}
|
||||
|
||||
.branch-add-modal {
|
||||
width: 410px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 0;
|
||||
|
||||
.devui-modal__body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tip-desc {
|
||||
// @apply text-G900 px-[22px] my-[18px] leading-[28px] break-all;
|
||||
}
|
||||
|
||||
.devui-modal__header {
|
||||
padding: 14px 24px;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
right: 24px;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
src/components/BranchCompare/README.md
Normal file
37
src/components/BranchCompare/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
TS接口文档可以查看同文件夹下的types.ts
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|---------------|---------------|-------------------------------|-----------|-------------------|
|
||||
| data | 源分支和目标分支的数据 | BranchCompareData | — | {} |
|
||||
| repoList | 仓库下拉数据项 | {label: string; value: string | number}[] | — | [] |
|
||||
| branchList | 对应仓库下分支的下拉数据项 | {label: string; value: string | number}[] | — | [] |
|
||||
| repoIcon? | 仓库对应图标 | string | — | 'version-history' |
|
||||
| branchIcon? | 分支对应图标 | string | — | 'branch-merge' |
|
||||
| repoIconSize? | 仓库图标尺寸 | string | — | '16px' |
|
||||
| branchIconSize? | 分支图标尺寸 | string | — | '16px' |
|
||||
|
||||
### Emits
|
||||
|
||||
注意:以下事件都是组件内部对应的按钮点击触发,如果用插槽自定义对应区域的内容的话,事件自行监听即可
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------|------|-----------------------|
|
||||
| repoChange | 仓库变化 | string | number |
|
||||
| branchChange | 分支变化 | { sourceId: string | number; targetId: string | number } |
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
| ------------- | -------------------------- |
|
||||
| source-header | 源分支头部插槽 |
|
||||
| repo-option | 仓库下拉option插槽 |
|
||||
| source-option | 源分支下拉option插槽 |
|
||||
| switch | 中间切换按钮插槽 |
|
||||
| target-header | 目标分支头部插槽 |
|
||||
| target-option | 源分支下拉option插槽 |
|
||||
| repo-prefix | 仓库输入框部分前缀图标插槽 |
|
||||
| branch-prefix | 分支输入框部分前缀图标插槽 |
|
||||
| source-tip | 源分支底部信息插槽 |
|
||||
| target-tip | 目标分支底部信息插槽 |
|
||||
309
src/components/BranchCompare/index.vue
Normal file
309
src/components/BranchCompare/index.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<section class="g-branch-compare">
|
||||
<div class="g-branch-compare-box abox bg-white">
|
||||
<div class="g-branch-compare-header">
|
||||
<slot name="source-header">
|
||||
<span class="g-branch-compare-title">当前</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-branch-compare-content">
|
||||
<div ref="ele" class="g-branch-compare-repo" v-if="showRepo">
|
||||
<div class="g-branch-compare-custom-icon">
|
||||
<slot name="repo-prefix">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<d-editable-select
|
||||
class="g-branch-compare-select"
|
||||
v-model="repoId"
|
||||
:options="repoList"
|
||||
:width="selectWidth"
|
||||
:disabled="repoReadonly"
|
||||
:max-height="maxHeight"
|
||||
>
|
||||
<template #item="{ option, index }">
|
||||
<div class="g-branch-compare-option">
|
||||
<slot name="repo-option" :data="option" :index="index">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
<div class="g-branch-compare-label">{{ option.label }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
<BranchTagSelector
|
||||
:repoId="repoId"
|
||||
v-model="sourceId"
|
||||
:hideFooter="true"
|
||||
:isCopy="false"
|
||||
overlayClass="g-branch-compare-dropdown"
|
||||
:disabled="disabled"
|
||||
@onClick="handleChange($event, 'fromTab')"
|
||||
/>
|
||||
<div class="text-xs text-CG600 g-branch-compare-tip">
|
||||
<slot name="source-tip"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="g-branch-compare-switch" @click="onSwitch">
|
||||
<slot name="switch">
|
||||
<Icon name="gt-exchange" size="16px" :operable="true"/>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-branch-compare-box abox bg-white">
|
||||
<div class="g-branch-compare-header">
|
||||
<slot name="target-header">
|
||||
<span class="g-branch-compare-title">目标</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-branch-compare-content">
|
||||
<div class="g-branch-compare-repo" v-if="showRepo">
|
||||
<div class="g-branch-compare-custom-icon">
|
||||
<slot name="repo-prefix">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<d-editable-select
|
||||
class="g-branch-compare-select"
|
||||
v-model="repoId"
|
||||
:options="repoList"
|
||||
:width="selectWidth"
|
||||
:disabled="repoReadonly"
|
||||
:max-height="maxHeight"
|
||||
>
|
||||
<template #item="{ option, index }">
|
||||
<div class="g-branch-compare-option">
|
||||
<slot name="repo-option" :data="option" :index="index">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
<div class="g-branch-compare-label">{{ option.label }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
<BranchTagSelector
|
||||
:repoId="repoId"
|
||||
v-model="targetId"
|
||||
:hideFooter="true"
|
||||
:isCopy="false"
|
||||
overlayClass="g-branch-compare-dropdown"
|
||||
:disabled="disabled"
|
||||
@onClick="handleChange($event, 'toTab')"
|
||||
/>
|
||||
<div class="text-xs text-CG600 g-branch-compare-tip">
|
||||
<slot name="target-tip"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'branch-compare'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onUnmounted, reactive } from 'vue';
|
||||
import type { BranchCompareData } from '@/components/BranchCompare/types';
|
||||
import BranchTagSelector from '@/components/BranchTagSelector/index.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: BranchCompareData;
|
||||
repoList: { label: string; value: number | string }[];
|
||||
repoIcon?: string;
|
||||
branchIcon?: string;
|
||||
repoIconSize?: string;
|
||||
branchIconSize?: string;
|
||||
repoReadonly: boolean;
|
||||
showRepo: boolean;
|
||||
tabType: object;
|
||||
maxHeight?: number;
|
||||
disabled: boolean;
|
||||
}>(),
|
||||
{
|
||||
data: () => {
|
||||
return {
|
||||
repoId: null,
|
||||
sourceId: null,
|
||||
targetId: null
|
||||
};
|
||||
},
|
||||
repoIcon: 'gt-compare',
|
||||
branchIcon: 'gt-branches',
|
||||
repoIconSize: '16px',
|
||||
branchIconSize: '16px',
|
||||
repoReadonly: false,
|
||||
showRepo: true,
|
||||
maxHeight: 250,
|
||||
disabled: false
|
||||
}
|
||||
);
|
||||
|
||||
const emits = defineEmits<{(e: 'repoChange', data: string | number): void;
|
||||
(e: 'branchChange', data: { sourceId: string | number; targetId: string | number }): void;
|
||||
}>();
|
||||
|
||||
const repoId = ref(props.data.repoId);
|
||||
const sourceId = ref(props.data.sourceId);
|
||||
const targetId = ref(props.data.targetId);
|
||||
|
||||
const tabType = props.tabType;
|
||||
const handleChange = (val, name) => {
|
||||
if (val.type) {
|
||||
tabType[name] = val.type;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
repoId.value = val.repoId || null;
|
||||
sourceId.value = val.sourceId || null;
|
||||
targetId.value = val.targetId || null;
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => repoId.value,
|
||||
(val, oldVal) => {
|
||||
if (val === oldVal) return false;
|
||||
emits('repoChange', val);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => `${sourceId.value}_${targetId.value}`,
|
||||
(val, oldVal) => {
|
||||
if (val === oldVal) return false;
|
||||
emits('branchChange', {
|
||||
sourceId: sourceId.value,
|
||||
targetId: targetId.value
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const ele = ref(null);
|
||||
const selectWidth = ref(488);
|
||||
|
||||
onUnmounted(() => {});
|
||||
|
||||
const onSwitch = () => {
|
||||
[tabType.fromTab, tabType.toTab] = [tabType.toTab, tabType.fromTab];
|
||||
[sourceId.value, targetId.value] = [targetId.value, sourceId.value];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-branch-compare-border-color: var(--color-G300);
|
||||
$g-branch-compare-border-color-hover: var(--color-CG900);
|
||||
$g-branch-compare-border-color-focus: var(--color-link);
|
||||
$g-branch-compare-border-radius: 3px;
|
||||
$g-branch-compare-color: var(--color-CG900);
|
||||
$g-branch-compare-select-bg-color: linear-gradient(180deg, #fcfcfc 0%, #f8f9fb 100%);
|
||||
.g-branch-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&-tip {
|
||||
min-height: 36px;
|
||||
}
|
||||
&-box {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
&-header {
|
||||
padding: 24px 32px 20px 32px;
|
||||
}
|
||||
&-content {
|
||||
padding: 0px 32px 0 32px;
|
||||
}
|
||||
&-repo {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&-branch {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&-custom-icon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 38px;
|
||||
padding-left: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
:deep(.icon) {
|
||||
align-self: center;
|
||||
color: $g-branch-compare-color !important;
|
||||
}
|
||||
:deep(.devui-icon__container) {
|
||||
align-self: center;
|
||||
color: $g-branch-compare-color !important;
|
||||
}
|
||||
}
|
||||
&-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
color: $g-branch-compare-color;
|
||||
}
|
||||
&-select {
|
||||
:deep(.devui-editable-select-input__wrapper) {
|
||||
background: $g-branch-compare-select-bg-color;
|
||||
}
|
||||
:deep(.devui-editable-select-input__inner) {
|
||||
color: $g-branch-compare-color;
|
||||
padding-left: 32px;
|
||||
}
|
||||
:deep(.devui-editable-select__item) {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
&-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
&-switch {
|
||||
margin: 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $g-branch-compare-border-radius;
|
||||
background: linear-gradient(180deg, #fcfcfc 0%, #f8f9fb 100%);
|
||||
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--color-CG400);
|
||||
display: flex;
|
||||
box-sizing: content-box;
|
||||
:deep(.icon) {
|
||||
margin: 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.abox {
|
||||
background-color: var(--devui-base-bg, #ffffff);
|
||||
box-shadow: var(--devui-shadow-length-base, 0 2px 6px 0) var(--devui-light-shadow, rgba(37, 43, 58, 0.12));
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
<style lang="scss">
|
||||
.g-branch-compare-dropdown {
|
||||
.g-branch-tag-selector-body {
|
||||
width: 488px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
6
src/components/BranchCompare/types.ts
Normal file
6
src/components/BranchCompare/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type BranchCompareData = {
|
||||
repoId: string | number;
|
||||
sourceId: string | number;
|
||||
targetId: string | number;
|
||||
[propName: string]: any;
|
||||
}
|
||||
37
src/components/BranchCompareFork/README.md
Normal file
37
src/components/BranchCompareFork/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
TS接口文档可以查看同文件夹下的types.ts
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|---------------|---------------|-------------------------------|-----------|-------------------|
|
||||
| data | 源分支和目标分支的数据 | BranchCompareData | — | {} |
|
||||
| repoList | 仓库下拉数据项 | {label: string; value: string | number}[] | — | [] |
|
||||
| branchList | 对应仓库下分支的下拉数据项 | {label: string; value: string | number}[] | — | [] |
|
||||
| repoIcon? | 仓库对应图标 | string | — | 'version-history' |
|
||||
| branchIcon? | 分支对应图标 | string | — | 'branch-merge' |
|
||||
| repoIconSize? | 仓库图标尺寸 | string | — | '16px' |
|
||||
| branchIconSize? | 分支图标尺寸 | string | — | '16px' |
|
||||
|
||||
### Emits
|
||||
|
||||
注意:以下事件都是组件内部对应的按钮点击触发,如果用插槽自定义对应区域的内容的话,事件自行监听即可
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------|------|-----------------------|
|
||||
| repoChange | 仓库变化 | string | number |
|
||||
| branchChange | 分支变化 | { sourceId: string | number; targetId: string | number } |
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
| ------------- | -------------------------- |
|
||||
| source-header | 源分支头部插槽 |
|
||||
| repo-option | 仓库下拉option插槽 |
|
||||
| source-option | 源分支下拉option插槽 |
|
||||
| switch | 中间切换按钮插槽 |
|
||||
| target-header | 目标分支头部插槽 |
|
||||
| target-option | 源分支下拉option插槽 |
|
||||
| repo-prefix | 仓库输入框部分前缀图标插槽 |
|
||||
| branch-prefix | 分支输入框部分前缀图标插槽 |
|
||||
| source-tip | 源分支底部信息插槽 |
|
||||
| target-tip | 目标分支底部信息插槽 |
|
||||
300
src/components/BranchCompareFork/index.vue
Normal file
300
src/components/BranchCompareFork/index.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<section ref="selectContainer" class="fordeep g-branch-compare">
|
||||
<div class="g-branch-compare-box g-card py-[30px]">
|
||||
<div class="g-branch-compare-header">
|
||||
<slot name="source-header">
|
||||
<span class="g-branch-compare-title">源分支</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-branch-compare-content">
|
||||
<div ref="ele" class="g-branch-compare-repo" v-if="showRepo">
|
||||
<div class="g-branch-compare-custom-icon">
|
||||
<slot name="repo-prefix">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<d-editable-select
|
||||
class="g-branch-compare-select"
|
||||
v-model="repoIdVal"
|
||||
:options="repoList"
|
||||
:width="selectWidth"
|
||||
:disabled="repoReadonly"
|
||||
:max-height="maxHeight"
|
||||
>
|
||||
<template #item="{ option, index }">
|
||||
<div class="g-branch-compare-option">
|
||||
<slot name="repo-option" :data="option" :index="index">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
<div class="g-branch-compare-label">{{ option.label }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
<div class="g-branch-compare-branch">
|
||||
<BranchSelect
|
||||
:repo="encodeURIComponent(repoIdVal)"
|
||||
sort="mr_source"
|
||||
:width="selectWidth"
|
||||
v-model:value="sourceIdVal"
|
||||
:max-height="maxHeight"
|
||||
/>
|
||||
</div>
|
||||
<div class="g-branch-compare-tip">
|
||||
<slot name="source-tip"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isForkCase" class="g-branch-compare-switch" @click="emits('onSwitch')">
|
||||
<slot name="switch">
|
||||
<Icon name="gt-exchange" size="14px" color="var(--color-G1000)" />
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="isForkCase" class="g-branch-compare-switch cursor-default">
|
||||
<slot name="switch">
|
||||
<Icon name="gt-exit" size="14px" color="var(--color-G1000)" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-branch-compare-box g-card py-[30px]">
|
||||
<div class="g-branch-compare-header">
|
||||
<slot name="target-header">
|
||||
<span class="g-branch-compare-title">目标分支</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-branch-compare-content">
|
||||
<div class="g-branch-compare-repo" v-if="showRepo">
|
||||
<div class="g-branch-compare-custom-icon">
|
||||
<slot name="repo-prefix">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<d-editable-select
|
||||
class="g-branch-compare-select"
|
||||
v-model="targetRepoIdVal"
|
||||
:width="selectWidth"
|
||||
:options="targetRepoList"
|
||||
:disabled="repoReadonly"
|
||||
:max-height="maxHeight"
|
||||
>
|
||||
<template #item="{ option, index }">
|
||||
<div class="g-branch-compare-option">
|
||||
<slot name="repo-option" :data="option" :index="index">
|
||||
<Icon :name="repoIcon" :size="repoIconSize"></Icon>
|
||||
<div class="g-branch-compare-label">{{ option.label }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
<div class="g-branch-compare-branch">
|
||||
<BranchSelect
|
||||
:repo="encodeURIComponent(targetRepoIdVal)"
|
||||
sort="mr_target"
|
||||
:width="selectWidth"
|
||||
v-model:value="targetIdVal"
|
||||
:max-height="maxHeight"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-CG600 g-branch-compare-tip">
|
||||
<slot name="target-tip"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onUnmounted } from 'vue';
|
||||
import type { BranchCompareData } from './types';
|
||||
import BranchSelect from '@/components/BranchSelect/index.vue';
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: BranchCompareData;
|
||||
repoId: string; // 源项目
|
||||
targetRepoId: string; // 目标项目
|
||||
sourceId: string; // 源分支
|
||||
targetId: string; // 目标分支
|
||||
repoList: { label: string; value: number | string }[];
|
||||
targetRepoList: { label: string; value: number | string }[];
|
||||
branchList: { label: string; value: number | string }[];
|
||||
targetBranchList: { label: string; value: number | string }[];
|
||||
repoIcon?: string;
|
||||
branchIcon?: string;
|
||||
repoIconSize?: string;
|
||||
branchIconSize?: string;
|
||||
repoReadonly: boolean;
|
||||
showRepo: boolean;
|
||||
maxHeight?: number;
|
||||
isFork?: boolean;
|
||||
}>(),
|
||||
{
|
||||
branchList: () => [],
|
||||
repoIcon: 'gt-compare',
|
||||
branchIcon: 'gt-branches',
|
||||
repoIconSize: '16px',
|
||||
branchIconSize: '16px',
|
||||
repoReadonly: false,
|
||||
showRepo: true,
|
||||
maxHeight: 250,
|
||||
isFork: false
|
||||
}
|
||||
);
|
||||
|
||||
const isForkCase = computed(() => {
|
||||
return props.isFork && repoIdVal.value !== targetRepoIdVal.value;
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:repoId', data: string | number | null): void;
|
||||
(e: 'update:targetRepoId', data: string | number | null): void;
|
||||
(e: 'update:sourceId', data: string | number | null): void;
|
||||
(e: 'update:targetId', data: string | number | null): void;
|
||||
(e: 'onSwitch', data): void;
|
||||
(e: 'sourceLoadMore', data): void;
|
||||
(e: 'targetLoadMore', data): void;
|
||||
}>();
|
||||
|
||||
const repoIdVal = computed({
|
||||
get() {
|
||||
return props.repoId;
|
||||
},
|
||||
set(value) {
|
||||
emits('update:repoId', value);
|
||||
}
|
||||
});
|
||||
const targetRepoIdVal = computed({
|
||||
get() {
|
||||
return props.targetRepoId;
|
||||
},
|
||||
set(value) {
|
||||
emits('update:targetRepoId', value);
|
||||
}
|
||||
});
|
||||
const sourceIdVal = computed({
|
||||
get() {
|
||||
return props.sourceId;
|
||||
},
|
||||
set(value) {
|
||||
emits('update:sourceId', value);
|
||||
}
|
||||
});
|
||||
const targetIdVal = computed({
|
||||
get() {
|
||||
return props.targetId;
|
||||
},
|
||||
set(value) {
|
||||
emits('update:targetId', value);
|
||||
}
|
||||
});
|
||||
|
||||
const ele = ref(null);
|
||||
const selectWidth = ref(488);
|
||||
const selectContainer = ref(null);
|
||||
useResizeObserver(selectContainer, (el)=> {
|
||||
selectWidth.value = (el[0].contentRect.width - 220) / 2;
|
||||
})
|
||||
|
||||
onUnmounted(() => {});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-branch-compare-border-color: var(--color-G300);
|
||||
$g-branch-compare-border-color-hover: var(--color-CG900);
|
||||
$g-branch-compare-border-color-focus: var(--color-link);
|
||||
$g-branch-compare-border-radius: 3px;
|
||||
$g-branch-compare-color: var(--color-CG900);
|
||||
$g-branch-compare-select-bg-color: linear-gradient(180deg, #fcfcfc 0%, #f8f9fb 100%);
|
||||
|
||||
.fordeep {
|
||||
:deep(.devui-editable-select-input__wrapper) {
|
||||
background: linear-gradient(180deg, #fcfcfc 0%, #f8f9fb 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.g-branch-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&-tip {
|
||||
}
|
||||
&-box {
|
||||
flex: 1;
|
||||
}
|
||||
&-header {
|
||||
padding: 0 32px 20px 32px;
|
||||
}
|
||||
&-content {
|
||||
padding: 0px 32px 0 32px;
|
||||
}
|
||||
&-repo {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&-branch {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&-custom-icon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 38px;
|
||||
padding-left: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
:deep(.icon) {
|
||||
align-self: center;
|
||||
color: $g-branch-compare-color !important;
|
||||
}
|
||||
:deep(.devui-icon__container) {
|
||||
align-self: center;
|
||||
color: $g-branch-compare-color !important;
|
||||
}
|
||||
}
|
||||
&-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
color: $g-branch-compare-color;
|
||||
}
|
||||
&-select {
|
||||
:deep(.devui-editable-select-input__wrapper) {
|
||||
background: $g-branch-compare-select-bg-color;
|
||||
}
|
||||
:deep(.devui-editable-select-input__inner) {
|
||||
color: $g-branch-compare-color;
|
||||
padding-left: 32px;
|
||||
}
|
||||
:deep(.devui-editable-select__item) {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
&-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
&-switch {
|
||||
margin: 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $g-branch-compare-border-radius;
|
||||
background: linear-gradient(180deg, #fcfcfc 0%, #f8f9fb 100%);
|
||||
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--color-CG400);
|
||||
display: flex;
|
||||
:deep(.icon) {
|
||||
margin: 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
src/components/BranchCompareFork/types.ts
Normal file
7
src/components/BranchCompareFork/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type BranchCompareData = {
|
||||
repoId: string | number;
|
||||
targetRepoId: string | number;
|
||||
sourceId: string | number;
|
||||
targetId: string | number;
|
||||
[propName: string]: any;
|
||||
}
|
||||
33
src/components/BranchContrastStatistics/README.md
Normal file
33
src/components/BranchContrastStatistics/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# BranchContrastStatistics
|
||||
|
||||
### 说明
|
||||
- 分支对比统计组件,demo如下
|
||||
``` js
|
||||
// tempalte
|
||||
<BranchContrastStatistics
|
||||
:commitNum="10"
|
||||
:changeNum="12"
|
||||
:userNum="10"
|
||||
/>
|
||||
```
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| commitNum? | 提交次数 | number | -- | 0 |
|
||||
| changeNum | 文件改动数 | number | -- | 0 |
|
||||
| userNum? | 贡献者数 | number | -- | 0 |
|
||||
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
|
||||
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
|
||||
50
src/components/BranchContrastStatistics/index.vue
Normal file
50
src/components/BranchContrastStatistics/index.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="g-branch-contrast-statistics">
|
||||
<Card simple class="box">
|
||||
<span class="item" v-if="commitNum !== undefined">
|
||||
<Icon name="gt-commit-c" ></Icon>
|
||||
<Number :number="commitNum"/>次提交
|
||||
</span>
|
||||
<span class="item" v-if="changeNum !== undefined">
|
||||
<Icon name="gt-file-code-c" ></Icon>
|
||||
<Number :number="changeNum"/>个文件改动
|
||||
</span>
|
||||
<span class="item" v-if="userNum !== undefined">
|
||||
<Icon name="gt-member-c" ></Icon>
|
||||
<Number :number="userNum"/>位贡献者
|
||||
</span>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
withDefaults(defineProps<{
|
||||
commitNum?: number
|
||||
changeNum?: number
|
||||
userNum?: number
|
||||
}>(), {
|
||||
commitNum: 0,
|
||||
changeNum: 0
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.g-branch-contrast-statistics {
|
||||
.box{
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
}
|
||||
.item {
|
||||
margin-right: 42px;
|
||||
color: var(--color-G900);
|
||||
display: flex;
|
||||
:deep(.icon){
|
||||
align-self: center;
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
61
src/components/BranchItem/README.md
Normal file
61
src/components/BranchItem/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# BranchItem
|
||||
|
||||
### 说明
|
||||
- 分支列表里面的分支卡片信息
|
||||
|
||||
``` js
|
||||
import { mock } from 'mockjs';
|
||||
|
||||
// 数据定义为 @/components/BranchItem/type.ts
|
||||
const branch = ref({
|
||||
name: mock('@word'),
|
||||
behindCount: mock('@natural(0, 500)'),
|
||||
aheadCount: mock('@natural(0, 500)'),
|
||||
|
||||
repo: {
|
||||
name: mock('@word'),
|
||||
namespace: mock('@first')
|
||||
},
|
||||
|
||||
lastCommit: {
|
||||
id: mock('@id'),
|
||||
message: mock('@paragraph'),
|
||||
username: mock('@first'),
|
||||
createTime: mock('@datetime')
|
||||
}
|
||||
});
|
||||
|
||||
// 此处使用v-bind统一prop,可以分开传入,如 <BranchItem :name="xxx" ...... />
|
||||
<BranchItem v-bind="branch" />
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| name | 分支名称 | string | - | - |
|
||||
| behindCount | 落后默认分支commit个数 | number | - | - |
|
||||
| aheadCount | 领先默认分支commit个数 | number | - | - |
|
||||
| repo | 分支所属的仓库信息 | Repo | - | - |
|
||||
| lastCommit | 分支下的最后一次commit | Commit | - | - |
|
||||
|
||||
#### Repo
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| name | 仓库名称 | string | - | - |
|
||||
| namespace | 仓库所属命名空间(用户或组织的namespace) | string | - | - |
|
||||
|
||||
#### Commit
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| id | commit的hash Id | string | - | - |
|
||||
| message | 提交描述 | string | - | - |
|
||||
| username | commit提交人 | string | - | - |
|
||||
| createTime | 提交时间 | string | - | - |
|
||||
|
||||
### Emits
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
| onView | 查看分支统计 | props整体数据 |
|
||||
| onEdit | 编辑分支事件 | props整体数据 |
|
||||
| onDelete | 删除分支事件 | props整体数据 |
|
||||
355
src/components/BranchItem/index.vue
Normal file
355
src/components/BranchItem/index.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="g-branch-item">
|
||||
<div class="g-branch-item-icon">
|
||||
<Icon v-if="isProtected" name="gt-shield-lock" size="14px" color="var(--color-light)" />
|
||||
</div>
|
||||
<div class="g-branch-item-meta">
|
||||
<Copy class="g-branch-item-meta-name font-bold" always size="14px">
|
||||
<GLink :to="branchLink">
|
||||
<d-tooltip position="top" :content="name">
|
||||
<span class="text-G900 name-text ellipsis">{{ name }}</span>
|
||||
</d-tooltip>
|
||||
</GLink>
|
||||
</Copy>
|
||||
|
||||
<span class="overflow-hidden whitespace-nowrap inline-block ml-2 align-middle text-ellipsis" v-if="description">
|
||||
<span class="overflow-hidden text-ellipsis inline-block max-w-[100px] align-middle">{{ description }}</span>
|
||||
<span
|
||||
class="inline-flex justify-center items-center align-middle rounded-sm ml-2 cursor-pointer bg-CG200 hover:bg-CG300 active:bg-CG300"
|
||||
>
|
||||
<Icon :name="isFold ? 'gt-chevron-up' : 'gt-chevron-down'" @click="isFold = !isFold"/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<p v-if="description" class="text-ellipsis break-all whitespace-pre-wrap overflow-hidden my-1"
|
||||
:style="{ transition: 'height linear 0.1s', height: isFold ? 'auto' : 0 }">{{ description }}</p>
|
||||
<div v-if="lastCommit" class="g-branch-item-meta-last-commit">
|
||||
<GLink :to="authorLink"><span class="text-light">{{ lastCommit?.author_name }}</span></GLink>
|
||||
<span class="text-light">提交于<Time :time="lastCommit?.created_at" /></span>
|
||||
<GLink :to="commitLink"><span class="text-light">{{ lastCommit.id?.slice(0,8) }}</span></GLink>
|
||||
<d-tooltip :position="['right']" :content="lastCommit.message">
|
||||
<span class="g-branch-item-meta-last-commit-message text-light">{{ lastCommit.message }}</span>
|
||||
<template #content>
|
||||
<div class="py-1 px-4 max-w-[400px] max-h-[200px] overflow-auto">
|
||||
{{ lastCommit.message }}
|
||||
</div>
|
||||
</template>
|
||||
</d-tooltip>
|
||||
<!-- 分支最近一次commit不用添加 -->
|
||||
<!-- <GpgTag :open_gpg_verified="lastCommit.open_gpg_verified" :gpg_primary_key_id="lastCommit.gpg_primary_key_id" :verification_status="lastCommit.verification_status" :name="lastCommit.name"></GpgTag> -->
|
||||
</div>
|
||||
</div>
|
||||
<d-tooltip v-if="!isDefault" :content="diffSummary">
|
||||
<div class="g-branch-item-git" >
|
||||
<div class="count left">
|
||||
<span :style="{ width: getWidth(behindCount) }"><i>{{ behindCount }}</i></span>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="count right">
|
||||
<span :style="{ width: getWidth(aheadCount) }"><i>{{ aheadCount }}</i></span>
|
||||
</div>
|
||||
</div>
|
||||
</d-tooltip>
|
||||
<div class="g-branch-item-default default-branch" v-else>
|
||||
<span>默认分支</span>
|
||||
</div>
|
||||
<div class="g-branch-item-operation">
|
||||
<div class="merge text-ellipsis" v-if="mergeRequestStatus">
|
||||
<div class="merge-type">
|
||||
<Icon :name="mergeRequestStatus.icon" size="12px" :color="mergeRequestStatus.color" />
|
||||
<span :style="{ color: mergeRequestStatus.color }">{{ mergeRequestStatus.text }}</span>
|
||||
</div>
|
||||
<div class="merge-id">
|
||||
<span>#{{ mergeRequestBasic.iid }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diff">
|
||||
<GLink :to="compareLink" v-if="!isDefault">
|
||||
<d-tooltip position="top" content="比较">
|
||||
<div class="diff-branch">
|
||||
<Icon :operable="true" name="gt-compare-branch" size="14px" color="var(--color-light)" />
|
||||
</div>
|
||||
</d-tooltip>
|
||||
</GLink>
|
||||
</div>
|
||||
<div v-if="canProtect" class="ml-5 min-w-[14px] cursor-pointer">
|
||||
<d-tooltip v-if="!isProtected" position="top" content="创建保护分支">
|
||||
<Icon
|
||||
name="gt-shield-lock-setting"
|
||||
size="14px"
|
||||
color="var(--color-light)"
|
||||
:operable="true"
|
||||
@click="handleProtect"
|
||||
/>
|
||||
</d-tooltip>
|
||||
</div>
|
||||
<div class="more ml-5">
|
||||
<MoreList v-if="canDelete && props.moreOpts?.length > 1" :moreOpts="props.moreOpts" :item="props" />
|
||||
<d-tooltip v-else-if="canDelete && props.moreOpts?.length" position="top" :content="props.moreOpts[0]?.text">
|
||||
<Icon :operable="true" :name="props.moreOpts[0].icon" @click="props.moreOpts[0]?.handle(props)"></Icon>
|
||||
</d-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { Branch } from './type';
|
||||
import MoreList from '@/components/MoreList/index.vue';
|
||||
const props = defineProps<Branch>();
|
||||
const authorLink = computed(() => ({
|
||||
name: 'homepage',
|
||||
params: {
|
||||
namespace: props?.lastCommit?.author_name || ''
|
||||
}
|
||||
}));
|
||||
const diffSummary = computed(() => {
|
||||
if (props.isDefault) {
|
||||
return null;
|
||||
}
|
||||
return `比${props.defaultBranch || '默认'}分支落后${props.behindCount}个提交、超前${props.aheadCount}个提交`;
|
||||
});
|
||||
|
||||
const mergeRequest = reactive({
|
||||
status: [{
|
||||
state: 'opened',
|
||||
text: '开启',
|
||||
icon: 'gt-merge-request',
|
||||
color: 'var(--color-Y500)'
|
||||
}, {
|
||||
state: 'closed',
|
||||
text: '关闭',
|
||||
icon: 'gt-skip-merge',
|
||||
color: 'var(--color-CG600)'
|
||||
}, {
|
||||
state: 'locked',
|
||||
text: '锁定',
|
||||
icon: 'gt-merge-request',
|
||||
color: 'var(--color-Y500)'
|
||||
}, {
|
||||
state: 'merged',
|
||||
text: '合入合并',
|
||||
icon: 'gt-closed-merge',
|
||||
color: 'var(--color-GN500)'
|
||||
}, {
|
||||
state: 'all',
|
||||
text: '全部',
|
||||
icon: 'gt-merge-request',
|
||||
color: 'var(--color-Y500)'
|
||||
}]
|
||||
});
|
||||
const mergeRequestStatus = computed(() => {
|
||||
const stateItem = mergeRequest.status.find((item: any) => item.state === props?.mergeRequestBasic?.state);
|
||||
return stateItem;
|
||||
});
|
||||
const commitLink = computed(() => ({
|
||||
name: 'repoCommitDetail',
|
||||
params: {
|
||||
namespace: props.repo.namespace,
|
||||
repoName: props.repo.name,
|
||||
commitId: props.lastCommit.id
|
||||
},
|
||||
query: {
|
||||
ref: props.name
|
||||
}
|
||||
}));
|
||||
const compareLink = computed(() => {
|
||||
try {
|
||||
return {
|
||||
name: 'repoCompare',
|
||||
params: {
|
||||
namespace: props.repo.namespace,
|
||||
repoName: props.repo.name,
|
||||
fromId: props.defaultBranch || 'main',
|
||||
toId: props.name
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const branchLink = computed(() => ({
|
||||
name: 'repoDir',
|
||||
params: {
|
||||
branchName: props.name
|
||||
}
|
||||
}));
|
||||
|
||||
const MAX_WIDTH = 30;
|
||||
const getWidth = (val: number): string => {
|
||||
const relativeWidth = val * MAX_WIDTH / (props.aheadCount + props.behindCount);
|
||||
const absoluteWidth = val * MAX_WIDTH / Math.max(props.aheadCount, props.behindCount);
|
||||
return `${(relativeWidth + absoluteWidth)}px`;
|
||||
};
|
||||
const isFold = ref(false);
|
||||
const emit = defineEmits(['onProtect']);
|
||||
const handleProtect = () => {
|
||||
emit('onProtect');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.g-branch-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
min-height: 72px;
|
||||
&+.g-branch-item {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
flex-basis: 45%;
|
||||
|
||||
&-name {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
.name-text {
|
||||
display: block;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&-last-commit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
color: #999;
|
||||
line-height: 20px;
|
||||
&-message {
|
||||
display: inline-block;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-check {
|
||||
flex-basis: 5%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-git {
|
||||
display: flex;
|
||||
flex-basis: 20%;
|
||||
align-items: center;
|
||||
|
||||
>.count {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
padding: 0 2px;
|
||||
color: var(--color-G700);
|
||||
transform: translateY(10px);
|
||||
|
||||
&.left {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-right: 4px;
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
transform: translate(-6px, -20px);
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
text-align: left;
|
||||
margin-left: 4px;
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
transform: translate(6px, -20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background-color: var(--color-G300);
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 2px 2px 2px 2px;
|
||||
border-bottom: 4px solid var(--color-G300);
|
||||
position: relative;
|
||||
|
||||
i {
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-default {
|
||||
flex-basis: 20%;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
color: var(--color-G700);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-operation {
|
||||
flex-basis: 30%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
.merge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 60px;
|
||||
gap: 8px;
|
||||
width: 150px;
|
||||
|
||||
.merge-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.merge-id {
|
||||
color: var(--color-CG600);
|
||||
}
|
||||
}
|
||||
|
||||
.diff-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
>span {
|
||||
margin-left: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--color-CG600);
|
||||
}
|
||||
}
|
||||
.more {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
i {
|
||||
cursor: pointer;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
src/components/BranchItem/type.ts
Normal file
44
src/components/BranchItem/type.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface Commit {
|
||||
id: string,
|
||||
message: string,
|
||||
author_name: string,
|
||||
created_at: string,
|
||||
open_gpg_verified: boolean; // 是否需要验证
|
||||
gpg_primary_key_id: string; // 密钥id
|
||||
verification_status: number; // 验证状态
|
||||
name: string; // 名称
|
||||
}
|
||||
|
||||
export interface MergerRequest {
|
||||
id: string,
|
||||
iid: string,
|
||||
merge_request_type: string,
|
||||
project_id: string,
|
||||
state: string,
|
||||
title: string,
|
||||
updated_at: string,
|
||||
web_url: string
|
||||
}
|
||||
|
||||
export interface Repo {
|
||||
name: string, // 仓库名
|
||||
namespace: string, // 用户名
|
||||
}
|
||||
|
||||
export interface Branch {
|
||||
name: string,
|
||||
description?: string;
|
||||
behindCount: number,
|
||||
aheadCount: number,
|
||||
defaultBranch: string,
|
||||
repo: Repo,
|
||||
lastCommit: Commit,
|
||||
mergeRequestBasic: MergerRequest,
|
||||
|
||||
isDefault: boolean,
|
||||
isProtected: boolean,
|
||||
canDelete: boolean,
|
||||
canProtect: boolean,
|
||||
|
||||
moreOpts: Array<object>,
|
||||
}
|
||||
118
src/components/BranchSelect/index.vue
Normal file
118
src/components/BranchSelect/index.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="fordeep relative" :class="{ forended: isEnded }">
|
||||
<Icon class="absolute z-10 top-2 left-4" :name="branchIcon" :size="branchIconSize" />
|
||||
<d-editable-select
|
||||
:max-height="maxHeight"
|
||||
placeholder=""
|
||||
v-model="value"
|
||||
:options="branchList"
|
||||
:enable-lazy-load="true"
|
||||
@load-more="loadMore"
|
||||
:loading="loading"
|
||||
:width="width"
|
||||
@input-change="inputChange"
|
||||
>
|
||||
<template #item="{ option, index }">
|
||||
<div class="flex gap-2 items-center overflow-hidden">
|
||||
<Icon :name="branchIcon" :size="branchIconSize" />
|
||||
<div class="flex-1 ellipsis">{{ option.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, reactive, computed } from 'vue';
|
||||
import { getBranches } from '@/api/branch';
|
||||
import repo from '@/router/config/repo';
|
||||
import { escapeResData } from '@/utils';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
const branchIcon = 'gt-branches';
|
||||
const branchIconSize = '16px';
|
||||
|
||||
const props = defineProps(['repo', 'value', 'sort', 'width', 'maxHeight']);
|
||||
const emit = defineEmits(['update:value']);
|
||||
|
||||
const value = useVModel(props, 'value', emit);
|
||||
|
||||
const page = reactive({
|
||||
per_page: 20,
|
||||
page: 1
|
||||
});
|
||||
const total = ref(0);
|
||||
const search = ref('');
|
||||
|
||||
const branchList = ref([]);
|
||||
|
||||
const loading = ref(false);
|
||||
const isEnded = computed(() => total.value && (page.page - 1) * page.per_page > total.value);
|
||||
const getList = debounce(
|
||||
(isInputChange) => {
|
||||
if (isEnded.value) return;
|
||||
loading.value = true;
|
||||
getBranches({
|
||||
repoId: props.repo,
|
||||
sort: props.sort,
|
||||
search: search.value,
|
||||
...page
|
||||
})
|
||||
.then((res) => {
|
||||
const data = escapeResData(res);
|
||||
total.value = data.total;
|
||||
const list = data.content.map((x) => ({ label: x.name, value: x.name }));
|
||||
if (isInputChange) branchList.value = list;
|
||||
else branchList.value = [...branchList.value, ...list];
|
||||
})
|
||||
.finally(() => (loading.value = false));
|
||||
},
|
||||
500,
|
||||
{
|
||||
leading: true
|
||||
}
|
||||
);
|
||||
|
||||
const loadMore = () => {
|
||||
page.page += 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const inputChange = (e) => {
|
||||
search.value = e;
|
||||
reset();
|
||||
getList(true);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
page.page = 1;
|
||||
branchList.value = [];
|
||||
};
|
||||
|
||||
watch(
|
||||
repo,
|
||||
() => {
|
||||
reset();
|
||||
getList();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fordeep {
|
||||
:deep(.devui-editable-select-input__inner) {
|
||||
padding-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.forended {
|
||||
:deep(.devui-editable-select__inner:after) {
|
||||
content: '--- 到底啦 ---';
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
src/components/BranchTagSelector/README.md
Normal file
49
src/components/BranchTagSelector/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# BranchTagSelector
|
||||
|
||||
### 说明
|
||||
- 组件有包含 分支/Tag 组合组件(./index.vue),以及单独的类型选择(./SingleSelector.vue)
|
||||
|
||||
``` html
|
||||
import BranchTagSelector from '@/components/BranchTagSelector/index.vue';
|
||||
import { useRepoId } from '@/utils/hools/useRepoId';
|
||||
|
||||
const currentBranch = computed(() => route.params.branchName);
|
||||
const repoId = useRepoId();
|
||||
|
||||
<BranchTagSelector
|
||||
:repoId="repoId"
|
||||
:modelValue="currentBranch"
|
||||
@onClick="handleBranchTagClick"
|
||||
@onFooterClick="handleBranchTagFooterClick"
|
||||
/>
|
||||
```
|
||||
|
||||
``` html
|
||||
import BranchSelector from '@/components/BranchTagSelector/SingleSelector.vue';
|
||||
|
||||
<BranchSelector type="branch" :repoId="repoId" v-model="branchId" />
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| repoId | 项目id(使用utls/hook中的repoId) | string | - | - |
|
||||
| modelValue | 输入框展示的值(分支、tag或commitId) | string | - | - |
|
||||
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
| onClick | 组件的分支或着tag的点击事件(type用于区别点击的是分支还是tag,value为对应的值) | { type, value } |
|
||||
| onTabChange | branch/tag切换 | type |
|
||||
| update:modelValue | v-model数据双向绑定 | 当前的branch、tag等 |
|
||||
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
| footer | 底部区域 |
|
||||
|
||||
154
src/components/BranchTagSelector/SingleSelector.vue
Normal file
154
src/components/BranchTagSelector/SingleSelector.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<d-select
|
||||
v-model="vModel"
|
||||
:placeholder="`输入关键词搜索${ type === 'branch' ? '分支' : ' Tag' }${showExtra ? '或创建一个通配符' : ''}`"
|
||||
:filter="loadData"
|
||||
remote
|
||||
:allow-clear="true"
|
||||
@toggleChange="handleToggleChange"
|
||||
>
|
||||
<d-skeleton :loading="isLoading" class="px-4 py-2 h-[300px]" :rows="5">
|
||||
<gc-option v-for="item in optionList" :title="item.name" :key="item.name" :value="item.value">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-1 gap-2 items-center overflow-hidden">
|
||||
<Icon :name="type === 'branch' ? 'gt-branches' : 'gt-tag'" class="shrink-0" />
|
||||
<div class="flex-1 ellipsis">{{item.name}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</gc-option>
|
||||
</d-skeleton>
|
||||
<template #empty>
|
||||
<span v-if="!isLoading" class="text-center text-gray-200">无数据</span>
|
||||
</template>
|
||||
</d-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, inject } from 'vue';
|
||||
import { reqCatch } from '@/utils/catch';
|
||||
import { getBranches } from '@/api/branch';
|
||||
import { getTags } from '@/api/tags';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
repoId: string,
|
||||
modelValue: string,
|
||||
type?: string,
|
||||
branch?: string;
|
||||
branchParams?: object // api接口参数透传,默认为null时查询所有的分支
|
||||
tagParams?: object // api接口参数透传,默认为null时查询所有的tag
|
||||
showExtra?: boolean // placeholder是否展示创建通配符提示
|
||||
isCommits?: boolean // 是否提交记录详情页面创建分支
|
||||
}>(), {
|
||||
type: 'branch'
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'input-change']);
|
||||
const vModel = useVModel(props, 'modelValue', emit);
|
||||
|
||||
const loading = ref({
|
||||
branch: false,
|
||||
tag: false
|
||||
});
|
||||
const isLoading = computed(() => props.type === 'branch' ? loading.value.branch : loading.value.tag);
|
||||
|
||||
// //// tag
|
||||
const tagList = ref(null);
|
||||
let tagFullList = null;
|
||||
const loadTagList = async(search = '') => {
|
||||
// 如果后台已返回全部数据,直接使用当前数据
|
||||
// if (tagFullList) {
|
||||
// search = search.trim().toLowerCase();
|
||||
// return tagFullList.filter(item => item.name.toLowerCase().includes(search));
|
||||
// }
|
||||
|
||||
// api查询分支
|
||||
const params = {
|
||||
repoId: props.repoId,
|
||||
search,
|
||||
view: 'simple',
|
||||
per_page: 1e2,
|
||||
...props.tagParams
|
||||
};
|
||||
const res = await reqCatch(getTags, params);
|
||||
const total = res?.data?.data?.total || 0;
|
||||
const newList = (res?.data?.data?.content || []).map(item => ({
|
||||
...item,
|
||||
value: item.name
|
||||
}));
|
||||
|
||||
// 更新前端缓存
|
||||
if (!search && total <= newList.length) {
|
||||
tagFullList = newList;
|
||||
}
|
||||
return newList;
|
||||
};
|
||||
const initTagList = async(filterStr) => {
|
||||
loading.value.tag = true;
|
||||
tagList.value = await loadTagList(filterStr);
|
||||
loading.value.tag = false;
|
||||
};
|
||||
|
||||
// /// branch
|
||||
const branchList = ref(null);
|
||||
let branchFullList = null;
|
||||
const loadBranchList = async(search = '') => {
|
||||
// if (branchFullList) {
|
||||
// search = search.trim().toLowerCase();
|
||||
// return branchFullList.filter(item => item.name.toLowerCase().includes(search));
|
||||
// }
|
||||
|
||||
// 搜索词不能超过一百
|
||||
const params = {
|
||||
repoId: props.repoId,
|
||||
search: search.slice(0, 100),
|
||||
view: 'simple',
|
||||
per_page: 1e2,
|
||||
...props.branchParams
|
||||
};
|
||||
const res = await reqCatch(getBranches, params);
|
||||
const newList = (res?.data?.data?.data?.content || []).map(item => ({
|
||||
...item,
|
||||
value: item.name
|
||||
}));
|
||||
const total = res?.data?.data?.data?.total || 0;
|
||||
if (props.branch && props.isCommits) {
|
||||
newList.push({ name: props.branch, value: props.branch });
|
||||
}
|
||||
if (!search && total <= newList.length) {
|
||||
branchFullList = newList;
|
||||
}
|
||||
|
||||
return newList;
|
||||
};
|
||||
|
||||
const bOptions = inject('bOptions', ref(null));
|
||||
const initBranchList = async(filterStr) => {
|
||||
loading.value.branch = true;
|
||||
branchList.value = await loadBranchList(filterStr);
|
||||
bOptions.value = branchList.value;
|
||||
loading.value.branch = false;
|
||||
};
|
||||
|
||||
const optionList = computed(() => props.type === 'branch' ? branchList.value : tagList.value);
|
||||
const loadData = debounce(async(query = '') => {
|
||||
emit('input-change', query);
|
||||
props.type === 'branch' ? await initBranchList(query) : await initTagList(query);
|
||||
}, 200);
|
||||
|
||||
// 点击select打开下拉框
|
||||
const handleToggleChange = isShow => {
|
||||
if (isShow) {
|
||||
cacelWatchAfterToggle && cacelWatchAfterToggle();
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 在用户打开下拉框之前,如果传入数据发生变化,则初始化。用户打开下拉框后不再自动初始化
|
||||
const cacelWatchAfterToggle = watch(() => props.modelValue, val => {
|
||||
loadData(props.modelValue);
|
||||
}, {
|
||||
immediate: true
|
||||
});
|
||||
|
||||
</script>
|
||||
377
src/components/BranchTagSelector/index.vue
Normal file
377
src/components/BranchTagSelector/index.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<d-dropdown
|
||||
:visible="visible"
|
||||
:position="['bottom-start', 'top-start']"
|
||||
trigger="manually"
|
||||
align="start"
|
||||
closeScope="blank"
|
||||
class="g-branch-tag-selector"
|
||||
:overlay-class="overlayClass"
|
||||
@toggle="handleDropdownToggle"
|
||||
>
|
||||
<!-- 触发 -->
|
||||
<div class="g-branch-tag-selector-trigger" :class="{'g-branch-tag-selector-disabled': disabled}" @click="handleDropdownOpen">
|
||||
<div class="g-branch-tag-selector-trigger-left">
|
||||
<template v-if="modelValue">
|
||||
<d-tooltip :mouse-enter-delay="800" :position="['top', 'right']">
|
||||
<div class="g-branch-tag-selector-trigger-left-value">{{ modelValue }}</div>
|
||||
<template #content>
|
||||
<div class="overflow-auto max-w-[200px] max-h-[100px] break-all p-4">{{ modelValue }}</div>
|
||||
</template>
|
||||
</d-tooltip>
|
||||
<d-tooltip v-if="isCopy" content="复制" :mouse-enter-delay="800">
|
||||
<Icon name="gt-copy" class="ml-10 w-4 flex-shrink-0" color="#9FA7B3" @click="handleCopy" />
|
||||
</d-tooltip>
|
||||
</template>
|
||||
<div v-else class="g-branch-tag-selector-trigger-left-placeholder text-lighter">请选择分支或 Tag</div>
|
||||
</div>
|
||||
<Icon name="gt-line-down" size="12px" color="" class="origin-center transition-transform flex-shrink-0" :class="{ 'arrow-down': !visible, 'arrow-up': visible }"/>
|
||||
</div>
|
||||
<!-- 下拉框数据 -->
|
||||
<template #menu>
|
||||
<div class="g-branch-tag-selector-body flex flex-col w-[350px]">
|
||||
<!-- 1.搜索 -->
|
||||
<div class="p-2">
|
||||
<d-input placeholder="输入关键词搜索过滤" v-model="filterStr" @input="handleFilterChange" />
|
||||
</div>
|
||||
<!-- 2.Tab切换 -->
|
||||
<div v-if="!hideTab" class="g-branch-tag-selector-body-tabs flex">
|
||||
<div
|
||||
class="flex-1 flex-center py-2 px-4 gap-1 cursor-pointer"
|
||||
:class="{
|
||||
'bg-CG300 font-bold': curTab === 'branch',
|
||||
'cursor-not-allowed opacity-50': type === 'tag'
|
||||
}"
|
||||
@click="handleTabChange('branch')"
|
||||
>
|
||||
<Icon name="gt-branches" /> 分支
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 flex-center py-2 px-4 gap-1 cursor-pointer"
|
||||
:class="{
|
||||
'bg-CG300 font-bold': curTab === 'tag',
|
||||
'cursor-not-allowed opacity-50': type === 'branch',
|
||||
}"
|
||||
@click="handleTabChange('tag')"
|
||||
>
|
||||
<Icon name="gt-tag" /> Tag
|
||||
</div>
|
||||
</div>
|
||||
<!-- 3.数据列表 -->
|
||||
<DataPanel :loading="isLoading" :empty="!optionList?.length" :card="false" class="h-[250px] overflow-auto">
|
||||
<div class="g-branch-tag-selector-body-list flex flex-col flex-1 px-2 my-2">
|
||||
<div
|
||||
v-for="item in optionList"
|
||||
:key="item.name"
|
||||
class="flex justify-between py-2 px-3 cursor-pointer hover:bg-CG200 gap-x-4"
|
||||
@click="handleDropDownClick(item)"
|
||||
>
|
||||
<div class="flex gap-2 items-center flex-1 overflow-hidden">
|
||||
<Icon :name="curTab === 'branch' ? 'gt-branches' : 'gt-tag'" class="shrink-0" />
|
||||
<div class="flex-1 flex min-w-0 ellipsis">
|
||||
<d-tooltip :position="'top'" :content="item.name">
|
||||
<span class="ellipsis" style="display: inline-block;">
|
||||
{{item.name}}
|
||||
</span>
|
||||
</d-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<template #loading>
|
||||
<div class="mx-3 my-2">
|
||||
<gc-skeleton-item class="w-10/12 m-3" />
|
||||
<gc-skeleton-item class="w-1/2 m-3" />
|
||||
<gc-skeleton-item class="w-10/12 m-3" />
|
||||
<gc-skeleton-item class="w-1/2 m-3" />
|
||||
<gc-skeleton-item class="w-10/12 m-3" />
|
||||
<gc-skeleton-item class="w-1/2 m-3" />
|
||||
<gc-skeleton-item class="w-10/12 m-3" />
|
||||
<gc-skeleton-item class="w-1/2 m-3" />
|
||||
</div>
|
||||
</template>
|
||||
</DataPanel>
|
||||
<!-- 4.底部操作 -->
|
||||
<div v-if="!hideFooter" class="g-branch-tag-selector-body-footer p-5 pt-2 flex">
|
||||
<template v-if="curTab === 'branch'">
|
||||
<d-button class="flex-1" @click="handleListView">查看分支列表</d-button>
|
||||
<d-button v-if="canAddBranch" class="flex-1 ml-2" @click="handleBranchCreate">新建分支</d-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<d-button class="flex-1" @click="handleListView">查看 Tag 列表</d-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</d-dropdown>
|
||||
<!-- 新建分支组件 -->
|
||||
<BranchAdd v-if="isShowBranchCreate" v-model="isShowBranchCreate" :branch="modelValue" @confirm="handleBranchCreateSuccess" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { reqCatch } from '@/utils/catch';
|
||||
import { getBranches } from '@/api/branch';
|
||||
import { getTags } from '@/api/tags';
|
||||
import BranchAdd from '@/components/BranchAdd/index.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
repoId: string, // 必填,项目/组织项目 id
|
||||
modelValue: string, // 触发下拉框的输入框展示内容
|
||||
type?: string, // 如果仅展示 分支/Tag,传入 branch/Tag。默认空可以切换分支/Tag
|
||||
canAddBranch?: boolean // 是否允许创建分支
|
||||
hideTab?: boolean // 隐藏 tab。此时若不传入 type,tab 将默认选择分支
|
||||
hideFooter?: boolean // 隐藏 footer
|
||||
branchParams?: object // api接口参数透传,默认为null时查询所有的分支
|
||||
tagParams?: object // api接口参数透传,默认为null时查询所有的tag
|
||||
isCopy: boolean
|
||||
overlayClass: string,
|
||||
disabled: boolean
|
||||
}>(), {
|
||||
isCopy: true,
|
||||
overlayClass: '',
|
||||
disabled: false
|
||||
});
|
||||
|
||||
const filterStr = ref(''); // 搜索字符串
|
||||
const loading = ref({
|
||||
branch: false,
|
||||
tag: false
|
||||
});
|
||||
const isLoading = computed(() => curTab.value === 'branch' ? loading.value.branch : loading.value.tag);
|
||||
|
||||
// //// tag
|
||||
const tagList = ref(null);
|
||||
let tagFullList = null;
|
||||
const loadTagList = async(search = '') => {
|
||||
// 如果后台已返回全部数据,直接使用当前数据
|
||||
if (tagFullList) {
|
||||
search = search.trim().toLowerCase();
|
||||
return tagFullList.filter(item => item.name.toLowerCase().includes(search));
|
||||
}
|
||||
|
||||
// api查询分支
|
||||
const params = {
|
||||
repoId: props.repoId,
|
||||
search,
|
||||
view: 'simple',
|
||||
per_page: 1e2,
|
||||
...props.tagParams
|
||||
};
|
||||
const res = await reqCatch(getTags, params);
|
||||
const total = res?.data?.data?.total || 0;
|
||||
const newList = (res?.data?.data?.content || []).map(item => ({
|
||||
...item,
|
||||
value: item.name
|
||||
}));
|
||||
|
||||
// 更新前端缓存
|
||||
if (!search && total <= newList.length) {
|
||||
tagFullList = newList;
|
||||
}
|
||||
|
||||
return newList;
|
||||
};
|
||||
const initTagList = async() => {
|
||||
loading.value.tag = true;
|
||||
tagList.value = await loadTagList(filterStr.value);
|
||||
loading.value.tag = false;
|
||||
};
|
||||
|
||||
// /// branch
|
||||
const branchList = ref(null);
|
||||
let branchFullList = null;
|
||||
const loadBranchList = async(search = '') => {
|
||||
if (branchFullList) {
|
||||
search = search.trim().toLowerCase();
|
||||
return branchFullList.filter(item => item.name.toLowerCase().includes(search));
|
||||
}
|
||||
|
||||
const params = {
|
||||
repoId: props.repoId,
|
||||
search,
|
||||
view: 'simple',
|
||||
per_page: 1e2,
|
||||
...props.branchParams
|
||||
};
|
||||
const res = await reqCatch(getBranches, params);
|
||||
const newList = (res?.data?.data?.data?.content || []).map(item => ({
|
||||
...item,
|
||||
value: item.name
|
||||
}));
|
||||
const total = res?.data?.data?.data?.total || 0;
|
||||
|
||||
if (!search && total <= newList.length) {
|
||||
branchFullList = newList;
|
||||
}
|
||||
|
||||
return newList;
|
||||
};
|
||||
const initBranchList = async() => {
|
||||
loading.value.branch = true;
|
||||
branchList.value = await loadBranchList(filterStr.value);
|
||||
loading.value.branch = false;
|
||||
};
|
||||
const init = async() => {
|
||||
curTab.value === 'branch' ? await initBranchList() : await initTagList();
|
||||
};
|
||||
const handleFilterChange = debounce(init, 300);
|
||||
|
||||
// 下拉窗口状态
|
||||
const visible = ref(false);
|
||||
const toggleDropdown = (isShow?: boolean): void => {
|
||||
if (typeof isShow === 'boolean') {
|
||||
visible.value = isShow;
|
||||
} else {
|
||||
visible.value = !visible.value;
|
||||
}
|
||||
};
|
||||
const handleDropdownOpen = (event) => {
|
||||
if (props.disabled) {
|
||||
return false;
|
||||
}
|
||||
toggleDropdown();
|
||||
event.target.blur();
|
||||
};
|
||||
|
||||
// /// 数据展示
|
||||
const curTab = ref(props.type || 'branch');
|
||||
const optionList = computed(() => curTab.value === 'branch' ? branchList.value : tagList.value);
|
||||
const handleDropdownToggle = (isClose: boolean): void => {
|
||||
toggleDropdown(isClose);
|
||||
|
||||
if (visible.value) {
|
||||
init();
|
||||
} else {
|
||||
// 重置数据
|
||||
filterStr.value = '';
|
||||
tagFullList = null;
|
||||
branchFullList = null;
|
||||
}
|
||||
};
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'onTabChange', 'onClick', 'onBranchCreate']);
|
||||
const handleDropDownClick = data => {
|
||||
toggleDropdown(false);
|
||||
|
||||
if (data) {
|
||||
emit('onClick', {
|
||||
type: curTab.value,
|
||||
value: data.name
|
||||
});
|
||||
|
||||
emit('update:modelValue', data.name);
|
||||
}
|
||||
};
|
||||
|
||||
// tab切换
|
||||
const handleTabChange = debounce(type => {
|
||||
// 置顶tab时不允许切换
|
||||
if (props.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
curTab.value = type;
|
||||
|
||||
init();
|
||||
|
||||
emit('onTabChange', type);
|
||||
});
|
||||
emit('onTabChange', curTab.value); // 初始化默认触发tab事件
|
||||
|
||||
// 查看列表
|
||||
const router = useRouter();
|
||||
const handleListView = () => {
|
||||
const path = decodeURIComponent(props.repoId);
|
||||
const targetUrl = `/${path}/${curTab.value === 'branch' ? 'branches' : 'tags'}`;
|
||||
router.push(targetUrl);
|
||||
};
|
||||
|
||||
// /// 创建分支
|
||||
const isShowBranchCreate = ref(false);
|
||||
const handleBranchCreate = () => {
|
||||
visible.value = false;
|
||||
isShowBranchCreate.value = true;
|
||||
};
|
||||
const handleBranchCreateSuccess = (branch: string) => {
|
||||
// 分支创建成功
|
||||
if (branch) {
|
||||
emit('onBranchCreate', branch);
|
||||
}
|
||||
};
|
||||
|
||||
// /// 复制分支名
|
||||
const { copy } = useClipboard();
|
||||
const handleCopy = event => {
|
||||
event.stopPropagation();
|
||||
|
||||
copy(props.modelValue);
|
||||
Message.success('复制成功');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.g-branch-tag-selector {
|
||||
min-width: 350px;
|
||||
&-trigger {
|
||||
height: 32px;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&-tip {
|
||||
opacity: .5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-disabled {
|
||||
color: var(--devui-disabled-text);
|
||||
background: var(--devui-disabled-bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&-body {
|
||||
&-tabs {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
28
src/components/Card/README.md
Normal file
28
src/components/Card/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Demo
|
||||
|
||||
### 说明
|
||||
- 全局卡片组件
|
||||
- 已全局声明
|
||||
|
||||
``` js
|
||||
<Card>
|
||||
<div></div>
|
||||
</Card>
|
||||
<Card simple>
|
||||
<div></div>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| simple | 简单模式-无padding | boolean | - | false |
|
||||
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
| default | 中间显示区域 |
|
||||
|
||||
16
src/components/Card/index.vue
Normal file
16
src/components/Card/index.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div :class="`g-content-card ${simple ? '' : 'p-20'}`">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
withDefaults(defineProps<{
|
||||
simple?: boolean
|
||||
}>(), {
|
||||
simple: false
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
314
src/components/CherryPick/index.vue
Normal file
314
src/components/CherryPick/index.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<GModal v-model="vModels" :showFooter="false" :title="addCherryPickItem.title">
|
||||
<d-form layout="vertical" :data="addCherryPickModel" ref="addCherryPickFormRef" :rules="addCherryPickRules"
|
||||
validate-on-rule-change="true">
|
||||
<d-form-item field="baseName" :label="addCherryPickItem.baseName.label">
|
||||
<div class="g-branch-compare-repo">
|
||||
<div class="release-custom-icon">
|
||||
<slot name="repo-prefix">
|
||||
<Icon name="gt-branches" size="16px"></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<d-editable-select class="release-branch-select" max-height="200" width="370" remote enable-lazy-load
|
||||
placeholder="请选择分支" :loading="searching" :remote-method="getBranchList" v-model="addCherryPickModel.baseName"
|
||||
:options="baseBranchOptions" :disabled="true">
|
||||
<template #item="{ option, index }">
|
||||
<div class="release-branch-option">
|
||||
<slot name="source-option" :data="option" :index="index">
|
||||
<Icon name="gt-branches" size="16px" />
|
||||
<div class="release-branch-label">{{ option.label }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
</d-form-item>
|
||||
<d-form-item field="branchName" :label="addCherryPickItem.tagName.label">
|
||||
<div class="g-branch-compare-repo">
|
||||
<div class="release-custom-icon">
|
||||
<slot name="repo-prefix">
|
||||
<Icon name="gt-branches" size="16px"></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<d-editable-select class="release-branch-select" max-height="200" width="370" remote enable-lazy-load
|
||||
placeholder="请选择分支" :loading="searching" :remote-method="getBranchList" v-model="addCherryPickModel.branchName"
|
||||
:options="targetBranchOptions">
|
||||
<template #item="{ option, index }">
|
||||
<div class="release-branch-option">
|
||||
<slot name="source-option" :data="option" :index="index">
|
||||
<Icon name="gt-branches" size="16px" />
|
||||
<div class="release-branch-label">{{ option.label }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</d-editable-select>
|
||||
</div>
|
||||
</d-form-item>
|
||||
<div>
|
||||
<d-checkbox v-model="addCherryPickModel.with_new_merge_request"
|
||||
>是否使用新的Pull Request进行Cherry-Pick</d-checkbox
|
||||
>
|
||||
</div>
|
||||
<gc-form-operation class="form-demo-form-operation mb-0">
|
||||
<d-button :disabled="createTagBtnLoading" @click="cancelModel">{{ addCherryPickItem.cancel }}</d-button>
|
||||
<d-button :disabled="isConfirm" :loading="createTagBtnLoading" color="primary" variant="solid"
|
||||
@click="handleCreateTag">{{ addCherryPickItem.submit }}</d-button>
|
||||
</gc-form-operation>
|
||||
</d-form>
|
||||
</GModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue';
|
||||
import { useModel } from '@/utils/hooks/useModel';
|
||||
import { useRepoId } from '@/utils/hooks/useRepoId';
|
||||
import { commitCherryPick } from '@/api/commit/index';
|
||||
import { reqCatch } from '@/utils/catch';
|
||||
import { getBranches } from '@/api/branch';
|
||||
import { GModal } from '@/components/Setting/index';
|
||||
import { useRouter } from 'vue-router';
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: boolean
|
||||
'onUpdate:modelValue'?: Function
|
||||
branches?: string[],
|
||||
mergeRequest: any[],
|
||||
options?: { name: string, value: string }[],
|
||||
branch?: string;
|
||||
repoIcon?: string;
|
||||
branchIcon?: string;
|
||||
repoIconSize?: string;
|
||||
branchIconSize?: string;
|
||||
repoReadonly?: boolean;
|
||||
maxHeight?: number
|
||||
}>(), {
|
||||
data: () => {
|
||||
return {
|
||||
repoId: null,
|
||||
sourceId: null,
|
||||
targetId: null
|
||||
};
|
||||
},
|
||||
branchList: () => [],
|
||||
repoIcon: 'version-history',
|
||||
branchIcon: 'gt-branches',
|
||||
repoIconSize: '16px',
|
||||
branchIconSize: '16px',
|
||||
repoReadonly: true,
|
||||
maxHeight: 250
|
||||
});
|
||||
const emits = defineEmits(['confirm', 'update:modelValue']);
|
||||
const { vModels } = useModel(props, emits);
|
||||
const searching = ref(false);
|
||||
const addCherryPickRules = {
|
||||
baseName: [{ required: true, message: '基于分支不能为空', trigger: 'blur' }],
|
||||
branchName: [{ required: true, message: '分支名称不能为空', trigger: 'blur' }]
|
||||
};
|
||||
const { repoId } = useRepoId();
|
||||
const baseBranchOptions = ref([]);
|
||||
const targetBranchOptions = ref([]);
|
||||
const createTagBtnLoading = ref(false);
|
||||
const addCherryPickFormRef = ref(null);
|
||||
const addCherryPickItem = {
|
||||
title: 'Cherry-Pick',
|
||||
baseName: {
|
||||
label: 'Cherry-Pick',
|
||||
helpTips: 'This is the plan name.',
|
||||
ruleMsg: '',
|
||||
placeholder: ''
|
||||
},
|
||||
tagName: {
|
||||
label: '目标分支',
|
||||
ruleMsg: '请选择目标分支',
|
||||
placeholder: '请选择目标分支'
|
||||
},
|
||||
submit: '创建',
|
||||
cancel: '取消'
|
||||
};
|
||||
const addCherryPickModel = reactive({
|
||||
baseName: '',
|
||||
tagName: '',
|
||||
branchName: '',
|
||||
description: '',
|
||||
with_new_merge_request: false
|
||||
});
|
||||
|
||||
const getBranchList = async(val?: string) => {
|
||||
const params = val ? {
|
||||
repoId: repoId.value,
|
||||
search: val,
|
||||
view: 'simple'
|
||||
} : {
|
||||
repoId: repoId.value,
|
||||
view: 'simple',
|
||||
page: 1,
|
||||
per_page: 20
|
||||
};
|
||||
searching.value = true;
|
||||
const res = await reqCatch(getBranches, params);
|
||||
if (!res.error) {
|
||||
baseBranchOptions.value = (res['data']['data']['data']['content'] || []).map((item: { name: any; default: boolean; }) => {
|
||||
if (item.default) {
|
||||
addCherryPickModel.baseName = item.name;
|
||||
}
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.name
|
||||
};
|
||||
});
|
||||
targetBranchOptions.value = (res['data']['data']['data']['content'] || []).map((item: { name: any; default: boolean; }) => {
|
||||
if (item.default) {
|
||||
addCherryPickModel.baseName = item.name;
|
||||
}
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.name
|
||||
};
|
||||
});
|
||||
if (props.branch) {
|
||||
baseBranchOptions.value.push({ label: props.branch, value: props.branch });
|
||||
addCherryPickModel.baseName = props.branch;
|
||||
}
|
||||
}
|
||||
searching.value = false;
|
||||
};
|
||||
getBranchList();
|
||||
const cancelModel = () => {
|
||||
vModels.value = false;
|
||||
addCherryPickModel.description = '';
|
||||
addCherryPickModel.tagName = '';
|
||||
addCherryPickModel.baseName = '';
|
||||
};
|
||||
const handleCreateTag = () => {
|
||||
createTagReq();
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const createTagReq = async() => {
|
||||
addCherryPickFormRef.value.validate(async(isValid: any, invalidFields: any) => {
|
||||
if (!isValid) return;
|
||||
createTagBtnLoading.value = true;
|
||||
const param = {
|
||||
branch: addCherryPickModel.branchName,
|
||||
with_new_merge_request: addCherryPickModel.with_new_merge_request
|
||||
};
|
||||
commitCherryPick(repoId.value, addCherryPickModel.baseName, param)
|
||||
.then((data: any) => {
|
||||
if (!addCherryPickModel.with_new_merge_request) {
|
||||
router.back();
|
||||
} else {
|
||||
emits('confirm', data.data.cherry_pick_branch_name, addCherryPickModel.branchName);
|
||||
}
|
||||
}).finally(() => {
|
||||
createTagBtnLoading.value = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isConfirm = computed(() => {
|
||||
if (addCherryPickModel.baseName && addCherryPickModel.branchName) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.release {
|
||||
margin: 20px 50px;
|
||||
flex-direction: column;
|
||||
|
||||
&-branch {
|
||||
&-select {
|
||||
:deep(.devui-editable-select-input__inner) {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
:deep(.devui-editable-select__inner) {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
:deep(.devui-editable-select__item) {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&-custom-icon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 38px;
|
||||
padding-left: 16px;
|
||||
padding-top: 4px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
|
||||
:deep(.icon) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
:deep(.devui-icon__container) {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&-left {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&-right {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.devui-form-operation.form-demo-form-operation) {
|
||||
text-align: right;
|
||||
padding-top: 20px;
|
||||
|
||||
button:first-child {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.devui-editable-select-input) {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:deep(.devui-input) {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:deep(.devui-form__control .devui-form__control-info .error-message) {
|
||||
margin: 8px auto;
|
||||
}
|
||||
|
||||
:deep(.g-rich-label) {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.g-rich-label-active) {
|
||||
background: white;
|
||||
box-shadow: 0px 2px 6px 0px rgba(0, 0, 0, 0.09);
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
73
src/components/CodeComparisonMenu/README.md
Normal file
73
src/components/CodeComparisonMenu/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# CodeComparisonMenu
|
||||
|
||||
### 说明
|
||||
- CodeComparisonMenu组件为代码对比功能菜单组件,demo如下
|
||||
``` js
|
||||
// tempalte
|
||||
<code-comparison-menu
|
||||
selected="1"
|
||||
:options= "[{name:'option_01',value:'1'}]"
|
||||
:total="12"
|
||||
:addNum="8"
|
||||
:reduceNum="4"
|
||||
searchWord=""
|
||||
@selected-change="selectedChangeCode"
|
||||
@handle-expand="handleExpandCode"
|
||||
@handle-setting="handleSettingCode"
|
||||
@handle-search="handleSearchCode"
|
||||
@change-search="changeSearchCode"
|
||||
>
|
||||
</code-comparison-menu>
|
||||
// script
|
||||
const selectedChangeCode = (value:string) => {
|
||||
|
||||
};
|
||||
const handleExpandCode = (props:object) => {
|
||||
|
||||
};
|
||||
const handleSettingCode = (props:object) => {
|
||||
|
||||
};
|
||||
const handleSearchCode = (props:object) => {
|
||||
|
||||
};
|
||||
const changeSearchCode = (props:object) => {
|
||||
|
||||
};
|
||||
```
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| id? | 卡片唯一id | string | -- | -- |
|
||||
| selected? | 下拉框选择值 | string | -- | -- |
|
||||
| options | 下拉框options,例:[{name:'option_01',value:'1'}] | array | -- | [] |
|
||||
| total? | 变更文件总数 | number | 0 | -- |
|
||||
| addNum? | 增加数 | number | -- | 0 |
|
||||
| reduceNum? | 减少数 | number | -- | 0 |
|
||||
| searchWord? | 搜索值 | string | -- | -- |
|
||||
| hideSelect? | 隐藏下拉框 | boolean | -- | false |
|
||||
| hideSearch? | 隐藏搜索 | boolean | -- | false |
|
||||
| hideExpand? | 隐藏展开全部 | boolean | -- | false |
|
||||
| hideSetting? | 隐藏设置 | boolean | -- | false |
|
||||
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
| selectedChange | 下拉框选择后触发 | value:下拉框选择值 |
|
||||
| handleExpand | 点击展开全部触发 | props(当前组件props对象) |
|
||||
| handleSetting | 点击设置触发 | props(当前组件props对象) |
|
||||
| handleSearch | 搜索框enter触发 | value:搜索框输入值 |
|
||||
| changeSearch | 搜索框实时输入触发 | value:搜索框输入值 |
|
||||
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
| left | 左侧布局区域插槽 |
|
||||
| info | 查看文件文案区域插槽 |
|
||||
| search | 搜索区域插槽 |
|
||||
| right | 右侧按钮区域插槽 |
|
||||
128
src/components/CodeComparisonMenu/index.vue
Normal file
128
src/components/CodeComparisonMenu/index.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="g-code-menu" >
|
||||
<div class="g-code-menu-left">
|
||||
<slot name="left">
|
||||
<div v-if="!hideSelect">
|
||||
<d-select class="g-code-menu-width" v-model="curVal" :options="options" @value-change="emits('selectedChange',curVal)"></d-select>
|
||||
</div>
|
||||
<div class="g-code-menu-left-desc" v-if="!hideSelect">
|
||||
<slot name="info">
|
||||
<template v-if="total">
|
||||
<p class="g-code-menu-left-desc-text">查看全部 {{ total }} 个文件变更</p>
|
||||
<p class="g-code-menu-left-desc-add">+{{ addNum }}</p>
|
||||
<p class="g-code-menu-left-desc-reduce">-{{ reduceNum }}</p>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-code-menu-line" v-if="!hideSelect && !hideSearch" ></div>
|
||||
<div>
|
||||
<slot name="search">
|
||||
<d-input v-model="search" v-if="!hideSearch" placeholder="搜索(Command + P)" clearable prefix="search" @input="emits('changeSearch',search)" @change="emits('handleSearch',search)" @clear="emits('handleSearch','')" />
|
||||
</slot>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-code-menu-right">
|
||||
<slot name="right">
|
||||
<span class="g-code-menu-pointer g-code-menu-center" v-if="!hideExpand" @click="emits('handleExpand',props)">
|
||||
<d-icon name="icon-expand"></d-icon> 展开全部
|
||||
</span>
|
||||
<span class="g-code-menu-line" v-if="!hideExpand && !hideSetting"></span>
|
||||
<span class="g-code-menu-pointer g-code-menu-center" v-if="!hideSetting" @click="emits('handleSetting',props)">
|
||||
<d-icon name="icon-local-parameter"></d-icon> 设置
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
id?: string
|
||||
selected?: string
|
||||
options: Array<object>
|
||||
total?: number
|
||||
addNum?: number
|
||||
reduceNum?: number
|
||||
searchWord?: string
|
||||
hideSelect?: boolean
|
||||
hideSearch?: boolean
|
||||
hideExpand?: boolean
|
||||
hideSetting?: boolean
|
||||
}>(), {
|
||||
id: '',
|
||||
selected: '',
|
||||
options: () => [],
|
||||
total: 0,
|
||||
addNum: 0,
|
||||
reduceNum: 0,
|
||||
searchWord: '',
|
||||
hideSelect: false,
|
||||
hideSearch: false,
|
||||
hideExpand: false,
|
||||
hideSetting: false
|
||||
});
|
||||
const curVal = ref(props.selected);
|
||||
const search = ref(props.searchWord);
|
||||
const emits = defineEmits(['selectedChange', 'handleExpand', 'handleSetting', 'handleSearch', 'changeSearch']);
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-code-menu-border-color: #D3D3D3;
|
||||
$g-code-menu-border-radius: 4px;
|
||||
$g-code-menu-text-color: #606060;
|
||||
$g-code-menu-text-h1: #000000;
|
||||
$g-code-menu-text-success: #0C974F;
|
||||
$g-code-menu-text-error: #DC0010;
|
||||
.g-code-menu {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
&-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
&-center {
|
||||
align-self: center;
|
||||
}
|
||||
&-line {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: $g-code-menu-border-color;
|
||||
margin: 4px 16px;
|
||||
}
|
||||
&-left {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
&-desc {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
margin-left: 16px;
|
||||
p {
|
||||
display: block;
|
||||
width: max-content;
|
||||
margin-left: 2px;
|
||||
}
|
||||
&-text {
|
||||
color: $g-code-menu-text-h1;
|
||||
}
|
||||
&-add {
|
||||
color: $g-code-menu-text-success;
|
||||
}
|
||||
&-reduce {
|
||||
color: $g-code-menu-text-error;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-right {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
color: $g-code-menu-text-color;
|
||||
:deep(.devui-icon__container) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
src/components/Commit/README.md
Normal file
33
src/components/Commit/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Commit组件
|
||||
|
||||
### 说明
|
||||
``` js
|
||||
// template
|
||||
<commit v-bind="commitObj" />
|
||||
|
||||
// script
|
||||
const commitObj = {
|
||||
avatar: 'https://devui.design/components/assets/image1.png',
|
||||
name: '曾恒瑶',
|
||||
repoName: 'test',
|
||||
title: 'Merge pull request #905 from HwangTaehyun/masterMerge pull request #905 from HwangTaehyun/masterMerge pull request #905 from HwangTaehyun/masterMerge pull request #905 from HwangTaehyun/masterMerge pull request #905 from HwangTaehyun/master',
|
||||
desc: 'Merge pull request #905 from HwangTaehyun/masterMerge pull request #905 from HwangTaehyun/masterMerge pull request #905 from HwangTaehyun/masterMerge pull request #905 from HwangTaehyun/masterMerge pull request #905 from Hwa',
|
||||
tip: 'Add Github Contributor Stats under Tools',
|
||||
id: '49bfc90a',
|
||||
createdTime: '2021-10-2',
|
||||
num: 84
|
||||
};
|
||||
```
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|----------------|-------------------------|-------------------------------|-----------|-------|
|
||||
| id | commit提交id | string | — | — |
|
||||
| avatar | 头像src | string | — | — |
|
||||
| name | 用户名 | string | — | — |
|
||||
| repoName | commit所属仓库名 | string | — | — |
|
||||
| title | 标题 | string | — | — |
|
||||
| createdTime | 创建时间 | string | — | — |
|
||||
| num? | 提交次数 | number | — | 1 |
|
||||
| desc? | 描述 | string | — | — |
|
||||
| tip? | 底部提示文案 | string | — | — |
|
||||
157
src/components/Commit/index.vue
Normal file
157
src/components/Commit/index.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="g-commit">
|
||||
<div class="g-commit-header flex justify-between">
|
||||
<div class="flex gap-2 items-center overflow-hidden">
|
||||
<GLink
|
||||
:to="toUserDetail"
|
||||
v-if="!author_user_name"
|
||||
:disabled="!author_user_name"
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<GAvatar class="g-commit-header-avatar" :width="20" :height="20" :src="avatar" :name="name" />
|
||||
<span class="g-commit-name">{{ name }}</span>
|
||||
</GLink>
|
||||
<GLink :to="toUserDetail" v-else class="flex items-center gap-2 flex-shrink-0">
|
||||
<GAvatar class="g-commit-header-avatar" :width="20" :height="20" :src="avatar" :name="name" />
|
||||
<span class="g-commit-name">{{ name }}</span>
|
||||
</GLink>
|
||||
<GLink :to="toDetail" class="g-commit-title ml-2">
|
||||
<span>{{ title || message }}</span>
|
||||
</GLink>
|
||||
<span
|
||||
v-show="foldable && (desc?.length || tip?.length)"
|
||||
class="fold-btn flex-center cursor-pointer hover:bg-CG200"
|
||||
@click="handleToggleFold"
|
||||
>
|
||||
<Icon :name="isFold ? 'gt-chevron-down' : 'gt-chevron-up'" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="g-commit-right flex-center flex-shrink-0 ml-20 text-G700 gap-4">
|
||||
<slot name="right">
|
||||
<span class="g-commit-text commit-link">
|
||||
<GLink :to="toDetail">{{ id?.slice(0, 8) }}</GLink>
|
||||
</span>
|
||||
<span class="g-commit-text"> 创建于 <Time :time="createdTime" /> </span>
|
||||
<span class="g-commit-text">
|
||||
<d-icon name="icon-time" :operable="true">
|
||||
<template #suffix>
|
||||
<GLink class="hover:text-black" :to="toCommitList">
|
||||
<template v-if="num">{{ num }}次提交</template>
|
||||
<template v-else>历史提交</template>
|
||||
</GLink>
|
||||
</template>
|
||||
</d-icon>
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="g-commit-content ml-7 mt-2" v-show="foldable ? !isFold : true">
|
||||
<slot name="content">
|
||||
<div class="g-commit-desc text-CG600 overflow-auto">{{ desc }}</div>
|
||||
<div class="g-commit-tip text-CG600">{{ tip }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { checkUsername } from '@/api/user';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface IProps {
|
||||
id: string;
|
||||
avatar: string;
|
||||
name: string;
|
||||
author_user_name: string;
|
||||
repoNamespace: string;
|
||||
repoName: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
desc?: string;
|
||||
tip?: string;
|
||||
createdTime: string;
|
||||
num?: number;
|
||||
branch?: string;
|
||||
foldable?: boolean; // 是否开启折叠,默认不开启
|
||||
}
|
||||
const props = defineProps<IProps>();
|
||||
|
||||
const toUserDetail = computed(() => ({
|
||||
name: 'homepage',
|
||||
params: {
|
||||
namespace: props.author_user_name
|
||||
}
|
||||
}));
|
||||
const router = useRouter();
|
||||
const toDetail = computed(() =>
|
||||
router
|
||||
.resolve({
|
||||
name: 'repoCommitDetail',
|
||||
params: {
|
||||
namespace: props.repoNamespace,
|
||||
repoName: props.repoName,
|
||||
commitId: props.id
|
||||
},
|
||||
query: {
|
||||
ref: props.branch
|
||||
}
|
||||
})
|
||||
.href.replace(/%2F/g, '/')
|
||||
);
|
||||
const toCommitList = computed(() => ({
|
||||
name: 'repoCommitByBranch',
|
||||
params: {
|
||||
branchName: props.branch
|
||||
}
|
||||
}));
|
||||
|
||||
const isFold = ref(true);
|
||||
const handleToggleFold = () => {
|
||||
isFold.value = !isFold.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
.g-commit {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.fold-btn {
|
||||
border-radius: var(--border-radius);
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
&-title {
|
||||
margin-right: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
&-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
& > div {
|
||||
word-break: break-all;
|
||||
white-space: wrap;
|
||||
}
|
||||
}
|
||||
&-tip {
|
||||
font-size: 12px;
|
||||
color: var(--color-light);
|
||||
}
|
||||
&-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
:deep(i) {
|
||||
color: black;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
102
src/components/CommitItem/GpgTag.vue
Normal file
102
src/components/CommitItem/GpgTag.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<d-tooltip :position="['top']" class="g-commit-item-header-info-tooltip">
|
||||
<!-- 如果没有开启GPG验证和没有使用GPG验证的数据不出 实际上也不会存在开启了没有使用的数据,但是需要符合测试 -->
|
||||
<div v-if="(open_gpg_verified && (isPass !== ''))" class="flex g-commit-item-text">
|
||||
<d-tag color="#10A35C" v-if="isPass">已验证</d-tag>
|
||||
<d-tag color="#9C9DB4" v-if="!isPass">未验证</d-tag>
|
||||
</div>
|
||||
<template #content>
|
||||
<div class="verified-tooltip-box">
|
||||
<div class="verified-tooltip-box-top">
|
||||
<div><span class="verified-tooltip-box-top-left">{{ statusObj[verification_status] }}</span>
|
||||
<Icon name="gt-plane-ok-green1" size="14px" v-if="isPass" />
|
||||
<Icon name="gt-plane-Fail" size="14px" v-if="!isPass" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="verified-tooltip-box-bottom">
|
||||
GPG Key lD: {{ gpg_primary_key_id }}
|
||||
</div>
|
||||
<div class="verified-tooltip-box-bottom-a">
|
||||
如何<a class="devui-link-light" target="_blank" href="https://docs.gitcode.com/docs/users/gpg">生成 GPG
|
||||
公钥并将其添加到您的帐户</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</d-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="gpgTag">
|
||||
import { computed, ref } from 'vue';
|
||||
export interface GPGtest {
|
||||
open_gpg_verified: boolean; // 是否需要验证
|
||||
gpg_primary_key_id: string; // 密钥id
|
||||
verification_status: number; // 验证状态
|
||||
name: string; // 名称
|
||||
}
|
||||
const props = withDefaults(defineProps<GPGtest>(), {
|
||||
open_gpg_verified: false,
|
||||
gpg_primary_key_id: '', // 密钥id
|
||||
verification_status: 5,
|
||||
name: ''
|
||||
});
|
||||
// 枚举对象
|
||||
const statusObj = ref<any>({
|
||||
0: '提交内容与签名不符,本次提交没有通过GPG签名校验',
|
||||
1: props.name !== 'gitcode.com' ? '由提交者的已验证签名签署本次提交' : '此提交是在gitcode.com上创建,并使用Gitcode 的已验证签名进行签名',
|
||||
4: '签名证书邮箱与GitCode内已绑定的邮箱不一致,本次提交没有通过GPG签名校验。',
|
||||
5: '签名证书未在GitCode平台注册,本次提交没有通过GPG签名校验。',
|
||||
6: '签名证书邮箱与GitCode提交邮箱不一致,本次提交没有通过GPG签名校验。'
|
||||
})
|
||||
// isPass true是现在已验证 false是未验证 空串是不出现
|
||||
const isPass = computed(() => {
|
||||
if (props?.verification_status === 6 || props?.verification_status === 0 || props?.verification_status === 4 || props?.verification_status === 5) {
|
||||
return false;
|
||||
}
|
||||
if (props?.verification_status === 1) {
|
||||
return true;
|
||||
}
|
||||
// 本身没值或者null 不展示
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.verified-tooltip-box {
|
||||
padding: 16px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.verified-tooltip-box-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
color: var(--devui-light-text, #ffffff);
|
||||
line-height: 22px;
|
||||
|
||||
.verified-tooltip-box-top-left {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.verified-tooltip-box-bottom {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.verified-tooltip-box-bottom,
|
||||
.verified-tooltip-box-bottom-a {
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--devui-placeholder, #9C9DB4);
|
||||
|
||||
a {
|
||||
color: #96adfa;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
src/components/CommitItem/README.md
Normal file
22
src/components/CommitItem/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# CommitItem
|
||||
|
||||
### 说明
|
||||
- Commit信息卡片,用于分支详情页-commit记录等
|
||||
|
||||
``` js
|
||||
import CommitItem from '@/components/CommitItem/index.vue';
|
||||
|
||||
<CommitItem id="xxx" message="xxx" .... />
|
||||
```
|
||||
|
||||
### Props
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| id | commit id | string | - | - |
|
||||
| message | commit描述信息 | string | - | - |
|
||||
| repoId | commit对应的仓库id | string | - | - |
|
||||
| namespace | commit对应的用户命名空间 | string | - | - |
|
||||
| statusText | commit状态描述 | string | - | - |
|
||||
| createTime | 创建时间 | string | - | - |
|
||||
| userAvatar | 用户头像src | string | - | - |
|
||||
| username | 用户名称 | string | - | - |
|
||||
234
src/components/CommitItem/index.vue
Normal file
234
src/components/CommitItem/index.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="g-commit-item">
|
||||
<div class="g-commit-item-header">
|
||||
<div class="g-commit-item-width">
|
||||
<GLink @click.stop class="g-commit-item-header-message flex-shrink-0" :to="commitLink">{{ title }}</GLink>
|
||||
<GIcon
|
||||
v-if="messageText && messageText !== title"
|
||||
name="gt-line-down"
|
||||
class="g-commit-item-header-down flex-auto hover:bg-CG200"
|
||||
:class="{ 'arrow-down': !showDesc, 'arrow-up': showDesc }"
|
||||
@click.stop="showMore"
|
||||
/>
|
||||
</div>
|
||||
<div class="g-commit-item-header-info">
|
||||
<GpgTag v-if="verification_status" :open_gpg_verified="open_gpg_verified" :gpg_primary_key_id="gpg_primary_key_id" :verification_status="verification_status" :name="name"></GpgTag>
|
||||
<d-tooltip :content="id">
|
||||
{{ shortHashId }}
|
||||
</d-tooltip>
|
||||
<Copy
|
||||
v-if="shortHashId"
|
||||
:showText="false"
|
||||
tooltip="复制CommitID"
|
||||
class="g-commit-item-header-info-copy"
|
||||
:content="id"
|
||||
always
|
||||
></Copy>
|
||||
<d-tooltip content="查看源码">
|
||||
<GLink :to="codeLink" target="_blank">
|
||||
<Icon name="gt-code2" color="#707A87" class="custom-icon-container"/>
|
||||
</GLink>
|
||||
</d-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="g-commit-item-panel">
|
||||
<div v-if="showDesc" v-html="messageText" class="text-CG600 whitespace-pre-line overflow-auto g-commit-item-description"></div>
|
||||
</Transition>
|
||||
<div class="g-commit-item-footer">
|
||||
<UserLink
|
||||
class="text-xs mr-2"
|
||||
:userImg="userAvatar"
|
||||
:imgSize="16"
|
||||
:userName="username"
|
||||
:goTo="userRouter"
|
||||
:disabled="!author_user_name"
|
||||
></UserLink>
|
||||
<span class="text-CG600 text-xs">提交于 <Time :time="createTime" /> </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Commit } from './type';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { xssPurify } from '@/utils';
|
||||
import { useOrgId } from '@/utils/hooks/useOrgId';
|
||||
import UserLink from '@/components/UserLInk/UserLink.vue';
|
||||
import GpgTag from '@/components/CommitItem/GpgTag.vue';
|
||||
const { orgId } = useOrgId('/');
|
||||
const props = defineProps<Commit>();
|
||||
const messageText = computed(() => xssPurify(props.message?.trim() || ''));
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const codeLink = computed(() =>
|
||||
router
|
||||
.resolve({
|
||||
name: 'repoDir',
|
||||
params: {
|
||||
...route.params,
|
||||
branchName: props.id,
|
||||
namespace: orgId.value
|
||||
}
|
||||
})
|
||||
.href.replace(/%2F/g, '/')
|
||||
);
|
||||
|
||||
const commitLink = computed(() =>
|
||||
router
|
||||
.resolve({
|
||||
name: 'repoCommitDetail',
|
||||
params: {
|
||||
commitId: props.id,
|
||||
repoName: props.repoId,
|
||||
namespace: orgId.value || props.namespace
|
||||
},
|
||||
query: {
|
||||
ref: props.branch
|
||||
}
|
||||
})
|
||||
.href.replace(/%2F/g, '/')
|
||||
);
|
||||
const shortHashId = computed(() => props.id?.slice(0, 8));
|
||||
const userRouter = computed(() => ({
|
||||
name: 'homepage',
|
||||
params: {
|
||||
namespace: props.author_user_name
|
||||
}
|
||||
}));
|
||||
const showDesc = ref(false);
|
||||
const showMore = () => {
|
||||
showDesc.value = !showDesc.value;
|
||||
};
|
||||
// const isCheckUser = ref(true);
|
||||
// const checkUser = async () => {
|
||||
// const res = await checkUsername(props.username || '');
|
||||
// if (!res.error) {
|
||||
// if (res.data.data.result === false) {
|
||||
// isCheckUser.value = false;
|
||||
// }
|
||||
// } else {
|
||||
// isCheckUser.value = false;
|
||||
// }
|
||||
// };
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.g-commit-item {
|
||||
position: relative;
|
||||
padding: 12px 20px 12px 56px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
|
||||
&-text {
|
||||
color: var(--color-CG500);
|
||||
|
||||
:deep(.icon) {
|
||||
align-self: center;
|
||||
margin-right: 4px;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
&-width {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&-message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-break: break-all;
|
||||
flex: auto;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-down {
|
||||
margin-left: 10px;
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
:deep(i) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
color: var(--color-light);
|
||||
|
||||
&-copy {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
:deep(.g-copy-trigger-border) {
|
||||
margin-left: 0;
|
||||
border: unset;
|
||||
}
|
||||
}
|
||||
|
||||
i.icon {
|
||||
cursor: pointer;
|
||||
color: var(--color-CG500);
|
||||
}
|
||||
}
|
||||
}
|
||||
&-panel-leave-active{
|
||||
transition: all 0.3s ease-in-out;
|
||||
animation: panelUpOut 0.3s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
&-panel-enter-active{
|
||||
transition: all 0.3s ease-in-out;
|
||||
animation: panelUpIn 0.3s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
&-description {
|
||||
margin-top: 4px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 20px;
|
||||
margin-top: 4px;
|
||||
|
||||
&-author {
|
||||
font-weight: 500;
|
||||
color: var(--color-CG600) !important;
|
||||
margin-right: 12px;
|
||||
|
||||
&[disabled='true'] {
|
||||
color: var(--color-CG600) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hover-style {
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
18
src/components/CommitItem/type.ts
Normal file
18
src/components/CommitItem/type.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface Commit {
|
||||
id: string;
|
||||
message?: string;
|
||||
repoId?: string;
|
||||
namespace: string;
|
||||
statusText?: string;
|
||||
createTime: string;
|
||||
userAvatar?: string;
|
||||
username?: string;
|
||||
author_user_name: string;
|
||||
title?: string;
|
||||
open_gpg_verified: boolean; // 是否需要验证
|
||||
gpg_primary_key_id: string; // 密钥id
|
||||
verification_status: number; // 验证状态
|
||||
branch?: string;
|
||||
[propName: string]: any;
|
||||
name: string; // 名称
|
||||
}
|
||||
23
src/components/CommitList/README.md
Normal file
23
src/components/CommitList/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# CommitList
|
||||
|
||||
### 说明
|
||||
- Commit列表组件,传入Commit实例对象
|
||||
- @TODO 目前时间列表组件在交互样式上,与原型有点差别。已与HW沟通,待高保真定稿再联合修改
|
||||
|
||||
``` js
|
||||
<CommitList :list="commitList" loadMore @onMore="loadMore" />
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| list | Commit对象数据数组 | Array<Commit> | - | - |
|
||||
| loadMore? | 是否显示加载更多按钮。开启loadMore才可监听onMore事件 | boolean | - | false |
|
||||
| loadMoreText? | 加载更多按钮上的文字,需开启loadMore才生效 | string | - | '点击加载更多' |
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
| onMore | 点击加载更多按钮 | |
|
||||
76
src/components/CommitList/index.vue
Normal file
76
src/components/CommitList/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="g-commit-list">
|
||||
<template v-for="(item, index) in commitList" :key="index">
|
||||
<Card simple class="g-commit-list-card overflow-hidden">
|
||||
<div class="g-commit-list-card-head flex items-center text-light px-20 gap-20">
|
||||
<Icon name="gt-commit" />{{ item.time }}
|
||||
</div>
|
||||
<CommitItem v-for="option in item.actions" :key="option.id" v-bind="option" :branch="props.branch" />
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Commit } from '@/components/CommitItem/type';
|
||||
|
||||
import CommitItem from '@/components/CommitItem/index.vue';
|
||||
|
||||
interface IProps {
|
||||
list: Array<Commit>;
|
||||
loadMore?: boolean;
|
||||
loadMoreText?: string;
|
||||
branch?: string;
|
||||
loading?:boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
loadMoreText: ''
|
||||
});
|
||||
|
||||
// @TODO
|
||||
// 1. 前端将传入的commit数组,自动按照日期进行整合。如果后端能按日期提供数据,则需要调整代码逻辑
|
||||
// 2. 此处暂未按照日期进行排序
|
||||
const commitList = computed(() => {
|
||||
const timeMap = props.list.reduce((res: { [key: string]: any }, cur: Commit) => {
|
||||
const time = dayjs(cur.createTime || cur.created_at).format('YYYY-MM-DD');
|
||||
res[time] = res[time] || { time, actions: [] };
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
message,
|
||||
id: repoId,
|
||||
authored_name: namespace,
|
||||
verification_status: statusText,
|
||||
committed_date: createTime,
|
||||
committer_avatar_url: userAvatar,
|
||||
author_name: username
|
||||
} = cur;
|
||||
const curItem = { ...cur, id, title, message, repoId, namespace, statusText, createTime, userAvatar, username };
|
||||
curItem.repoId = res[time]?.repoId;
|
||||
res[time].actions.push(curItem);
|
||||
return res;
|
||||
}, {});
|
||||
|
||||
return Object.entries(timeMap).map(([, value]) => value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.g-commit-list {
|
||||
margin-bottom: 12px;
|
||||
:deep(.devui-icon__container) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
&-card {
|
||||
margin-top: 12px;
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
&-head {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
30
src/components/ContentLayout/README.md
Normal file
30
src/components/ContentLayout/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# ContentLayout 布局组件
|
||||
|
||||
### 说明
|
||||
|
||||
1. 用于页面主体布局,在对应插槽位放置完成布局。横向分单板块或双板块
|
||||
|
||||
```js
|
||||
<ContentLayout defaultClassName='page-main' rightClassName='right'>
|
||||
<div style="background-color: burlywood;">default</div>
|
||||
<template #right>
|
||||
<div style="background-color: aqua;">aside-right</div>
|
||||
</template>
|
||||
</ContentLayout>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
| ---------------- | ----------------- | ------- | ------ | ------ |
|
||||
| defaultClassName | 主体 class 类名 | string | | - |
|
||||
| rightClassName | 右边 class 类名 | string | | - |
|
||||
| gap | flex间距 | string | | - |
|
||||
| rightConfig | 不同布局下右侧栏目的宽度 | object | | - |
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
| ------- | ----------------- |
|
||||
| default | 默认插槽 |
|
||||
| right | 右边 sidebar 插槽 |
|
||||
76
src/components/ContentLayout/index.vue
Normal file
76
src/components/ContentLayout/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div :class="['g-page-layout']" v-bind="$attrs">
|
||||
<div :class="['g-page-layout-default', defaultClassName]" v-if="$slots['default']">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div :class="['g-page-layout-right', rightClassName]" v-if="$slots['right']">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { usePageResize } from '@/utils/hooks/usePageResize';
|
||||
const { widthType } = usePageResize();
|
||||
|
||||
defineOptions({ name: 'ContentLayout' });
|
||||
withDefaults(defineProps<{
|
||||
rightClassName?: string
|
||||
rightConfig?: Object
|
||||
defaultClassName?: string
|
||||
gap?: string
|
||||
}>(), {
|
||||
rightConfig: () => {
|
||||
return {
|
||||
xxl: '400px',
|
||||
xl: '360px',
|
||||
md: '280px',
|
||||
lg: '0px',
|
||||
sm: '0px'
|
||||
};
|
||||
},
|
||||
gap: '64px'
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/*
|
||||
* 当前版心最大宽度 - 1536px
|
||||
*/
|
||||
.g-page-layout {
|
||||
max-width: 1536px;
|
||||
min-width: 280px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: v-bind(gap);
|
||||
.g-page-layout-default {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.g-page-layout-right {
|
||||
width: v-bind('rightConfig[widthType]');
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1536px) {
|
||||
.g-page-layout {
|
||||
padding: 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.g-page-layout {
|
||||
.g-page-layout-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.g-page-layout {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
src/components/ConversationItem/README.md
Normal file
49
src/components/ConversationItem/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# ConversationItem
|
||||
|
||||
### 说明
|
||||
- ConversationItem组件为会话组件,demo如下
|
||||
``` js
|
||||
// tempalte
|
||||
<conversation-item
|
||||
id="1"
|
||||
ip="42.48.83.94"
|
||||
tagText="当前"
|
||||
desc="这是你当前的会话"
|
||||
device="Safari 于 Mac"
|
||||
time="2023-07-16 10:24"
|
||||
@handle-deleted="handleDeletedConversation"
|
||||
>
|
||||
</conversation-item>
|
||||
// script
|
||||
const handleDeletedConversation = (id:string) => {
|
||||
|
||||
};
|
||||
```
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| id | 卡片唯一id | string | | |
|
||||
| ip | ip地址 | string | | |
|
||||
| desc? | 描述 | string | | |
|
||||
| tagText? | 标签文档(不传则不显示) | string | | |
|
||||
| device? | 设备信息 | string | | |
|
||||
| time | 登录时间 | string | | |
|
||||
| hideDeleted? | 隐藏删除按钮 | boolean | | false |
|
||||
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
| handleDeleted | 点击删除按钮触发 | id(当前卡片唯一标识) |
|
||||
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
| icon | 左侧icon区域插槽 |
|
||||
| header | ip信息显示区域插槽 |
|
||||
| content | 描述内容区域插槽 |
|
||||
| right | 右侧按钮区域插槽 |
|
||||
142
src/components/ConversationItem/index.vue
Normal file
142
src/components/ConversationItem/index.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="g-conversation-item">
|
||||
<div class="g-conversation-item-left">
|
||||
<div class="g-conversation-item-left-icon">
|
||||
<slot name="icon">
|
||||
<img :src="svg" v-if="svg" />
|
||||
<Icon name="gt-computer-c" size="32px" v-else></Icon>
|
||||
</slot>
|
||||
</div>
|
||||
<div>
|
||||
<div class="g-conversation-item-left-header">
|
||||
<slot name="header">
|
||||
<span class="g-conversation-item-left-header-title font-medium">{{ item.ip }}</span>
|
||||
<!-- <custom-tag v-if="item.tagText && item.is_current" border-color="#D3D3D3" :title="item.tagText" /> -->
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-conversation-item-left-content">
|
||||
<slot name="content">
|
||||
<p v-if="item.desc && item.is_current">{{ item.desc }}</p>
|
||||
<p class="g-conversation-item-size text-CG600">
|
||||
<span>登录时间: {{ formatTime(item.time, 'YYYY年MM月DD日 HH:mm') }}</span>
|
||||
<span class="g-secret-key-item-split"></span>
|
||||
<span>{{ item.os }}</span>
|
||||
<span class="g-secret-key-item-split"></span>
|
||||
<span>{{ item.browser }}</span>
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="g-conversation-item-right">
|
||||
<slot name="right">
|
||||
<!-- <d-button icon="icon-delete" v-if="!hideDeleted" @click.stop="emits('handleDeleted', object_id)"></d-button> -->
|
||||
<MoreList :moreOpts="moreOpts" :item="item"></MoreList>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTimeFormat } from '@/utils/hooks/useTimeFormat';
|
||||
import CustomTag from '@/components/CustomTag/index.vue';
|
||||
import Time from '@/components/Time/index.vue';
|
||||
import MoreList from '@/components/MoreList/index.vue';
|
||||
interface listItem {
|
||||
object_id: string;
|
||||
ip: string;
|
||||
desc?: string;
|
||||
tagText?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
time?: string;
|
||||
hideDeleted?: boolean;
|
||||
is_current?: boolean;
|
||||
}
|
||||
withDefaults(defineProps<{
|
||||
item?: listItem | object | any,
|
||||
moreOpts: any,
|
||||
svg?: any,
|
||||
}>(), {
|
||||
item: () => ({
|
||||
object_id: '',
|
||||
ip: '',
|
||||
desc: '这是你当前的会话',
|
||||
tagText: '当前',
|
||||
browser: '',
|
||||
os: '',
|
||||
time: '',
|
||||
hideDeleted: false,
|
||||
is_current: false
|
||||
}),
|
||||
moreOpts: () => [],
|
||||
svg: ''
|
||||
});
|
||||
|
||||
const { formatTime } = useTimeFormat();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-conversation-item-border-color: #D3D3D3;
|
||||
|
||||
.g-conversation-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&-size {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&-icon {
|
||||
margin-right: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&-header {
|
||||
&-title {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
margin-top: 0px;
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
font-size: 16px;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
:deep(.devui-button) {
|
||||
border-color: $g-conversation-item-border-color;
|
||||
margin-left: 24px;
|
||||
|
||||
// i {
|
||||
// font-size: 20px !important;
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.g-secret-key-item-split {
|
||||
margin: 0 8px;
|
||||
display: inline-block;
|
||||
font-weight: bolder;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background-color: var(--color-G400);
|
||||
}
|
||||
</style>
|
||||
15
src/components/Copy/README.md
Normal file
15
src/components/Copy/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|-----------------------------|---------|-----------|-------|
|
||||
| content | 需要被复制的内容,复制优先级比插槽内容要高 | string | — | '' |
|
||||
| always? | 是否一直显示复制按钮 | boolean | — | false |
|
||||
| showText? | 是否显示文案,插槽内容的显示优先级比content要高 | boolean | — | true |
|
||||
| size? | 图标的size | string | — | '20px' |
|
||||
|
||||
### Slots
|
||||
|
||||
| 参数 | 说明 |
|
||||
|----------|-------------|
|
||||
| default | 文本插槽 |
|
||||
| icon | 复制按钮位置对应的插槽 |
|
||||
107
src/components/Copy/index.vue
Normal file
107
src/components/Copy/index.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="g-copy">
|
||||
<div v-show="showText" ref="copyTextRef" class="g-copy-text">
|
||||
<slot>{{ content }}</slot>
|
||||
</div>
|
||||
<div v-show="!showText || always" @click="onCopy">
|
||||
<slot name="icon">
|
||||
<d-tooltip :content="tooltip" :disabled="disableTootip" :mouse-enter-delay="800">
|
||||
<Icon name="gt-copy" :operable="true" :size="size" />
|
||||
</d-tooltip>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'copy'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: string;
|
||||
always?: boolean;
|
||||
showText?: boolean;
|
||||
size?: string;
|
||||
tooltip?: string,
|
||||
disableTootip?: boolean
|
||||
}>(), {
|
||||
content: '',
|
||||
always: false,
|
||||
showText: true,
|
||||
size: '18px',
|
||||
tooltip: '复制'
|
||||
});
|
||||
|
||||
const copyTextRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const text = ref<string>(props.content);
|
||||
|
||||
const { copy, copied } = useClipboard({ source: text, legacy: true });
|
||||
|
||||
const onCopy = () => {
|
||||
text.value = props.content || (copyTextRef.value ? copyTextRef.value.innerText : '');
|
||||
copy(text.value);
|
||||
};
|
||||
|
||||
watch(() => copied.value, (val) => {
|
||||
if (val) {
|
||||
Message({
|
||||
type: 'success',
|
||||
message: '复制成功'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.g-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-light);
|
||||
&:hover {
|
||||
.g-copy-trigger {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
&-text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
&-trigger {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
&-border {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
:deep(.icon){
|
||||
color: var(--color-CG500)!important;
|
||||
margin: 0 auto ;
|
||||
}
|
||||
&.border-0 {
|
||||
.g-copy-trigger {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
&.m-0 {
|
||||
.g-copy-trigger {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
src/components/CreateFormBox/README.md
Normal file
9
src/components/CreateFormBox/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# CreateFormBox
|
||||
|
||||
### 说明
|
||||
- 新建页面外框布局组件
|
||||
|
||||
``` js
|
||||
<CreateFormBox></CreateFormBox>
|
||||
```
|
||||
|
||||
16
src/components/CreateFormBox/index.vue
Normal file
16
src/components/CreateFormBox/index.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="form-container">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.form-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
22
src/components/CustomHeader/README.md
Normal file
22
src/components/CustomHeader/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# CustomHeader
|
||||
|
||||
### 说明
|
||||
- 自定义header组件
|
||||
|
||||
``` js
|
||||
<CustomHeader title='' count='' />
|
||||
```
|
||||
|
||||
### Props
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|------------|--------|---------|----------|------|
|
||||
| title? | 标题 | string | - | '标题' |
|
||||
| count? | 计数 | number | - | 0 |
|
||||
| showCount? | 是否显示计数 | boolean | - | true |
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|--------------------|--------|
|
||||
| header-left | 组件左边插槽 |
|
||||
| actions | 组件右边插槽 |
|
||||
56
src/components/CustomHeader/index.vue
Normal file
56
src/components/CustomHeader/index.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="g-custom-header">
|
||||
<div class="g-custom-header-left">
|
||||
<slot name="header-left">
|
||||
<div class="g-custom-header-title" :style="{ fontSize }">{{ title }}</div>
|
||||
<div v-if="showCount" class="g-custom-header-count">
|
||||
<custom-tag border-color="transparent">{{ count }}</custom-tag>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="g-custom-header-actions">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'custom-header'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CustomTag from '@/components/CustomTag/index.vue';
|
||||
withDefaults(defineProps<{
|
||||
title?: string;
|
||||
count?: number;
|
||||
showCount?: boolean;
|
||||
fontSize?: string;
|
||||
}>(), {
|
||||
title: '标题',
|
||||
count: 0,
|
||||
showCount: true
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-custom-header-color: #000000;
|
||||
.g-custom-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
&-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
color: $g-custom-header-color;
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
src/components/CustomList/README.md
Normal file
25
src/components/CustomList/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# CustomHeader
|
||||
|
||||
### 说明
|
||||
- 自定义header组件
|
||||
|
||||
``` js
|
||||
<CustomList title='' data='' />
|
||||
```
|
||||
|
||||
### Props
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|--------|------------------------------------------------------------|----------|------|
|
||||
| title? | 标题 | string | - | '' |
|
||||
| data | 数据 | { name: string; extra: string; [propName: string]: any }[] | - | [] |
|
||||
| showCount? | 是否显示计数 | boolean | - | true |
|
||||
| showHeader? | 是否显示头部 | boolean | - | true |
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|------------|--------------|
|
||||
| actions | 组件头部操作区域插槽 |
|
||||
| item | 组件单元数据插槽 |
|
||||
| item-name | 组件单元数据名称区域插槽 |
|
||||
| item-extra | 组件单元数据尾部区域插槽 |
|
||||
105
src/components/CustomList/index.vue
Normal file
105
src/components/CustomList/index.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<section class="g-custom-list">
|
||||
<div v-if="showHeader" class="g-custom-list-header">
|
||||
<custom-header
|
||||
:title="title"
|
||||
:count="data.length"
|
||||
:showCount="showCount"
|
||||
>
|
||||
<template #actions>
|
||||
<slot name="actions">
|
||||
<d-button class="g-custom-list-button">新建Star列表</d-button>
|
||||
</slot>
|
||||
</template>
|
||||
</custom-header>
|
||||
</div>
|
||||
<div class="g-custom-list-content">
|
||||
<div
|
||||
v-for="(item, index) in data"
|
||||
:key="`${item.id}_${index}`"
|
||||
class="g-custom-list-item"
|
||||
:class="{'is-last': index === data.length - 1}"
|
||||
>
|
||||
<slot name="item" :data="item" :index="index">
|
||||
<div class="g-custom-list-item-name">
|
||||
<slot name="item-name" :data="item" :index="index">{{ item.name }}</slot>
|
||||
</div>
|
||||
<div class="g-custom-list-item-extra">
|
||||
<slot name="item-extra" :data="item" :index="index">
|
||||
{{ item.extra }}
|
||||
</slot>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'custom-list'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CustomHeader from '@/components/CustomHeader/index.vue';
|
||||
withDefaults(defineProps<{
|
||||
title?: string;
|
||||
data: {
|
||||
name: string;
|
||||
extra: string;
|
||||
[propName: string]: any;
|
||||
}[];
|
||||
showCount?: boolean;
|
||||
showHeader?: boolean;
|
||||
}>(), {
|
||||
title: '',
|
||||
data: () => [],
|
||||
showCount: true,
|
||||
showHeader: true
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-custom-list-color: #000000;
|
||||
$g-custom-list-color-second: #606060;
|
||||
$g-custom-list-color-white: #ffffff;
|
||||
$g-custom-list-bg-color: #333333;
|
||||
$g-custom-list-border-color: #D3D3D3;
|
||||
$g-custom-list-border-radius: 4px;
|
||||
.g-custom-list {
|
||||
&-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
&-button {
|
||||
padding: 8px 16px;
|
||||
line-height: 1;
|
||||
background-color: $g-custom-list-bg-color;
|
||||
color: $g-custom-list-color-white;
|
||||
}
|
||||
&-content {
|
||||
border: 1px solid $g-custom-list-border-color;
|
||||
border-radius: $g-custom-list-border-radius;
|
||||
}
|
||||
&-item {
|
||||
border-bottom: 1px solid $g-custom-list-border-color;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
line-height: 1;
|
||||
&.is-last {
|
||||
border-bottom: none;
|
||||
}
|
||||
&-name {
|
||||
font-size: 18px;
|
||||
color: $g-custom-list-bg-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
&-extra {
|
||||
color: $g-custom-list-color-second;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
src/components/CustomTab/README.md
Normal file
17
src/components/CustomTab/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
### 自定义 icon tabbar
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
| ---------------- | ---------------- | ------- | ------------------------------------------- | ------- |
|
||||
| defaultValue | 默认 tabkey | string | — | '' |
|
||||
| type | 选项卡组的类型 | string | 'tabs';'pills';'options';'wrapped';'slider' | 'pills' |
|
||||
| tabPosition | 选项卡所在的位置 | string | 'top' ;'right' ;'bottom' ;'left' | '' | |
|
||||
| hiddenDashedLine | 隐藏高亮的下划线 | boolean | — | - |
|
||||
| list | tabbar 数据 | array | — | [] |
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 类型 | 说明 | 返回值 |
|
||||
| ----------------- | ----------------------- | --------------------------------------------- | ------ |
|
||||
| active-tab-change | function(string/number) | 选项卡切换的回调函数,返回当前激活选项卡的 id | - |
|
||||
87
src/components/CustomTab/index.vue
Normal file
87
src/components/CustomTab/index.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<d-tabs :type="type" :tab-position="tabPosition" v-model="value"
|
||||
:class="['g-custom-tab', { 'hidden-dashed-line': hiddenDashedLine }, $attrs.class]"
|
||||
@active-tab-change="$emit('active-tab-change', $event)">
|
||||
<d-tab v-for="(item) in list" :key="item.id" :id="item.id">
|
||||
<template v-slot:title>
|
||||
<span class="g-custom-tab-item">
|
||||
<template v-if="item.icon || item.classIcon">
|
||||
<Icon :name="item.icon" :class="item.classIcon" />
|
||||
</template>
|
||||
<span class="g-custom-tab-title" :class="{ active: value === item.id }">{{ item.title }}</span>
|
||||
<template v-if="item.count">
|
||||
<span class="g-custom-tab-num">
|
||||
<Number :number="item.count" :bit="false"/>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</d-tab>
|
||||
</d-tabs>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'CustomTab' });
|
||||
import Number from '@/components/Number/index.vue';
|
||||
import { ref } from 'vue';
|
||||
defineEmits(['active-tab-change']);
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultValue?: string; // 默认 tabkey
|
||||
type?: 'tabs' | 'pills' | 'options' | 'wrapped' | 'slider';
|
||||
tabPosition?: 'top' | 'right' | 'bottom' | 'left';
|
||||
hiddenDashedLine?: boolean; // 影藏高亮的下划线
|
||||
list: {
|
||||
id: number | string; // tabkey
|
||||
title: string // 选项卡的标题
|
||||
icon?: string; // icon
|
||||
classIcon?: string; // 自定义图案
|
||||
disabled?: boolean // 选项卡是否不可用
|
||||
count?: number
|
||||
}[];
|
||||
}>(), {
|
||||
type: 'pills',
|
||||
list: () => []
|
||||
});
|
||||
|
||||
const value = ref(props.defaultValue);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hidden-dashed-line :deep(.devui-tabs__nav--pills li:after) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.g-custom-tab {
|
||||
:deep(.devui-tab__content) {
|
||||
display: none !important;
|
||||
}
|
||||
&-item {
|
||||
:deep(.devui-icon__container) {
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
}
|
||||
&-title {
|
||||
vertical-align: top;
|
||||
}
|
||||
&-num {
|
||||
width: 33px;
|
||||
height: 22px;
|
||||
padding: 1px 8px 1px 8px;
|
||||
border-radius: 20px;
|
||||
color: #606060;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.custom-filter-tab {
|
||||
.devui-tabs__nav--wrapped>li {
|
||||
color: var(--color-light);
|
||||
&:hover:not(.active):not(.disabled) a {
|
||||
color: var(--color-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
src/components/CustomTable/README.md
Normal file
58
src/components/CustomTable/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
### 自定义table
|
||||
|
||||
|
||||
``` vue
|
||||
<template>
|
||||
<DynamicTable ref="table" :data="tableData" :columns="tableColumns" selectable @selection-change="onselectionChange">
|
||||
<template #before>
|
||||
before
|
||||
</template>
|
||||
<template #header>
|
||||
1234567890
|
||||
</template>
|
||||
<template #nameColumn="slotProps">
|
||||
<span @click="console.log(slotProps)">
|
||||
<strong>{{ slotProps.row.name }}</strong> ({{ slotProps.row.age }}
|
||||
years old)
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #ageColumn="slotProps">
|
||||
<div>
|
||||
{{ slotProps.row.age >= 30 ? "Old" : "Young" }}
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345
|
||||
</template>
|
||||
</DynamicTable>
|
||||
<br>
|
||||
<d-button @click="handleClear">Clear All (自定义事件)</d-button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { ref } from 'vue';
|
||||
import DynamicTable from '@/components/CustomTable/index.vue';
|
||||
const table = ref(null);
|
||||
const tableData = ref([
|
||||
{ id: 1, name: 'Alice', age: 28 },
|
||||
{ id: 2, name: 'Bob', age: 35 },
|
||||
{ id: 3, name: 'Carol', age: 22 }
|
||||
]);
|
||||
|
||||
const tableColumns = ref([
|
||||
{ key: 'name', label: 'Name', field: 'name', slotName: 'nameColumn' },
|
||||
{ key: 'age', label: 'Age', field: 'age', slotName: 'ageColumn' }
|
||||
]);
|
||||
|
||||
function onselectionChange(data) {
|
||||
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
table.value.clearSelection();
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
223
src/components/CustomTable/index.vue
Normal file
223
src/components/CustomTable/index.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="g-custom-table-box">
|
||||
<table class="g-custom-table ">
|
||||
<thead v-if="!hiddenHeader">
|
||||
<tr class="g-custom-table-row" v-if="$slots['before']">
|
||||
<th class="g-custom-table-cell" :colspan="columns.length + (selectable ? 1 : 0)">
|
||||
<div class="cell">
|
||||
<slot name="before"></slot>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr class="g-custom-table-row">
|
||||
<th class="g-custom-table-cell g-custom-table-column-selection"
|
||||
v-if="selectable && columns[0] || (selectable && $slots['header'])">
|
||||
<div class="cell">
|
||||
<d-checkbox :disabled="data.length == 0" v-model="selectAll" :half-checked="halfChecked"
|
||||
@change="onAllChange" />
|
||||
</div>
|
||||
</th>
|
||||
<th class="g-custom-table-cell" v-if="$slots['header']" :colspan="columns.length + (selectable ? 1 : 0)">
|
||||
<div class="cell">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
</th>
|
||||
<th class="g-custom-table-cell" v-else v-for="(column, rowIndex) in columns" :key="rowIndex">
|
||||
<div class="cell">
|
||||
{{ column.label }}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-loading="loading">
|
||||
<tr class="g-custom-table-row g-custom-table-main" v-for="(item, rowIndex) in data"
|
||||
:key="item[rowKey] + '-' + rowIndex">
|
||||
<td class="g-custom-table-cell g-custom-table-column-selection" v-if="selectable && data.length !== 0">
|
||||
<div class="cell">
|
||||
<d-checkbox :name="rowIndex + ''" v-model="selectList[rowIndex]" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="g-custom-table-cell" :class="column.class" v-for="(column, colIndex) in columns"
|
||||
:key="rowIndex + '-' + colIndex" :data-name="column.slotName" :width="column.width">
|
||||
<div class="cell">
|
||||
<slot :name="column.slotName" :row="item" :column="column" :index="rowIndex">
|
||||
{{ item[column.field] }}
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="g-custom-table-row g-custom-table-footer" v-if="$slots['footer']">
|
||||
<td class="g-custom-table-cell" :colspan="columns.length + (selectable ? 1 : 0)">
|
||||
<div class="cell">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import debounce from 'lodash/debounce';
|
||||
interface IData {
|
||||
[propName: string]: any; // 数据 key 展示字段名 : value 数据
|
||||
}
|
||||
interface IColumn {
|
||||
label: string; // 表头展示字段
|
||||
field: string; // 表格 内容展示字段
|
||||
slotName: string; // 动态插槽 名
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
rowKey: string
|
||||
emptyText: string
|
||||
selectable: boolean
|
||||
data: IData[]
|
||||
columns: IColumn[]
|
||||
loading: boolean
|
||||
hiddenHeader?: boolean
|
||||
}>(),
|
||||
{
|
||||
selectable: false,
|
||||
data: () => [],
|
||||
rowKey: 'key',
|
||||
emptyText: '暂无数据',
|
||||
hiddenHeader: false
|
||||
}
|
||||
);
|
||||
|
||||
const selectAll = ref(false);
|
||||
const halfChecked = ref(false);
|
||||
const selectList = ref(new Array(100).fill(false) || []);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'selection-change': [item: object]
|
||||
}>();
|
||||
|
||||
watch(
|
||||
selectList,
|
||||
(_new) => {
|
||||
const selected = _new.slice(0, props.data.length);
|
||||
if (selected.every((blo) => blo)) {
|
||||
// 全选
|
||||
selectAll.value = true;
|
||||
halfChecked.value = false;
|
||||
} else if (selected.every((blo) => !blo)) {
|
||||
// 全不选
|
||||
selectAll.value = false;
|
||||
halfChecked.value = false;
|
||||
} else {
|
||||
// 半选状态
|
||||
selectAll.value = false;
|
||||
halfChecked.value = true;
|
||||
}
|
||||
const selectedData = props.data.filter((item, index) => selected[index]);
|
||||
updateNotice(selectedData);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(props.data, (_new) => {
|
||||
selectList.value = new Array(100).fill(false);
|
||||
});
|
||||
|
||||
// 通知上层选择列表数据
|
||||
const updateNotice = debounce((data: any) => emit('selection-change', data), 10);
|
||||
|
||||
const onAllChange = (e) => {
|
||||
selectList.value.fill(e);
|
||||
};
|
||||
|
||||
// 清空用户的选择
|
||||
const clearSelection = () => {
|
||||
selectList.value.fill(false);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
clearSelection
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.g-custom-table-box{
|
||||
// border: 1px solid var(--devui-line, #d7d8da);
|
||||
// box-shadow: 0px 2px 3px 0px rgba(148, 163, 184, 0.05);
|
||||
// border-radius: var(--devui-border-radius, 3px);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.g-custom-table {
|
||||
-webkit-border-horizontal-spacing: 0px;
|
||||
-webkit-border-vertical-spacing: 0px;
|
||||
border-top-width: 0px;
|
||||
border-bottom-width: 0px;
|
||||
border-left-width: 0px;
|
||||
border-right-width: 0px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
border-spacing: 0px;
|
||||
background: #FFFFFF;
|
||||
font-size: 14px;
|
||||
font-family: NotoSansSC-Regular, NotoSansSC;
|
||||
font-weight: 400;
|
||||
color: #707A87;
|
||||
line-height: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
&-column-selection {
|
||||
width: 40px;
|
||||
|
||||
.cell {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
&-cell {
|
||||
padding: 16px 0;
|
||||
box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-G200, #d7d8da);
|
||||
background-color: #fff;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
&-footer td:last-of-type {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-main {
|
||||
td:last-of-type {
|
||||
// text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.cell {
|
||||
box-sizing: border-box;
|
||||
word-break: break-all;
|
||||
line-height: 20px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th {
|
||||
.cell {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thead .g-custom-table-cell {
|
||||
background-color: linear-gradient(180deg, #FFFFFF 0%, #F9FAFB 100%);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
tbody .g-custom-table-row:last-of-type .g-custom-table-cell {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
8
src/components/CustomTag/README.md
Normal file
8
src/components/CustomTag/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|----------------|-------|--------|-----------|---|
|
||||
| title | tag内容 | string | — | '' |
|
||||
| color? | 文本颜色 | string | — | '' |
|
||||
| borderColor? | 边框颜色 | string | — | '' |
|
||||
| bgColor? | 背景颜色 | string | — | '' |
|
||||
60
src/components/CustomTag/index.vue
Normal file
60
src/components/CustomTag/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
class="g-custom-tag"
|
||||
:style="{
|
||||
color: color,
|
||||
border: borderColor ? `1px solid ${borderColor}`: undefined,
|
||||
'background-color': bgColor,
|
||||
'line-height': lineHeight
|
||||
}"
|
||||
>
|
||||
<slot><d-icon v-if="icon" class="g-custom-tag-icon" :color="iconColor" :name="icon"></d-icon>{{ title }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'custom-tag'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
title?: string;
|
||||
color?: string;
|
||||
borderColor?: string;
|
||||
bgColor?: string;
|
||||
lineHeight?: string;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
}>(), {
|
||||
title: ''
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$g-custom-tag-border-radius: 20px;
|
||||
$g-custom-tag-color: #333333;
|
||||
$g-custom-tag-bg-color: #F5F7F9;
|
||||
.g-custom-tag {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
border-radius: $g-custom-tag-border-radius;
|
||||
color: $g-custom-tag-color;
|
||||
background-color: $g-custom-tag-bg-color;
|
||||
margin-right: 16px;
|
||||
font-size: 12px;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
&-icon {
|
||||
margin-right: 4px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.devui-icon__container {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
src/components/DataPanel/README.md
Normal file
37
src/components/DataPanel/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# DataPanel
|
||||
|
||||
### 说明
|
||||
- 已全局声明
|
||||
- 在EmptyData上进行了二次封装,通过empty和loading字段自适应展示数据
|
||||
- loading使用d-skeleton方案,可以通过$slots.loading自定义。或者不传入loading,自己加v-loading="loading"
|
||||
- empty使用EmptyData方案,可以通过$slots.empty自定义
|
||||
|
||||
``` ts
|
||||
<DataPanel :empty="!dataList?length" :loading="loading">
|
||||
<d-table :data="dataList" />
|
||||
</DataPanel>
|
||||
|
||||
const dataList = ref([]);
|
||||
const loading = ref(false);
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| loading? | 是否加载中。默认启动v-loading效果,如果传入$slots.loading则v-loading失效 | boolean | - | false |
|
||||
| empty? | 是否数据为空,为空默认展示d-result | boolean | - | false |
|
||||
| emptyText? | 数据为空的标题 | string | - | '暂无数据' |
|
||||
| emptyDetail? | 数据为空标题下方的描述 | string | - | '暂无数据' |
|
||||
| skeleton? | loading状态下显示骨架屏,比animation优先级更高 | boolean | - | false |
|
||||
| animation? | loading状态下显示定制的loading | boolean | - | false |
|
||||
| card? | 是否使用g-card组件样式 | boolean | - | true |
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
| default | 数据自定义区域 |
|
||||
| empty? | 数据为空的定义 |
|
||||
| loading? | loading效果,如骨架屏。默认使用v-loading,传入loading slot则v-loading失效 |
|
||||
|
||||
38
src/components/DataPanel/index.vue
Normal file
38
src/components/DataPanel/index.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="g-data-panel" :class="{'g-card': card}">
|
||||
<slot v-if="loading" name="loading">
|
||||
<d-skeleton v-if="skeleton" class="p-20" :rows="skeletonRow"/>
|
||||
<Animation v-else-if="animation" class="w-full h-full flex-center" />
|
||||
</slot>
|
||||
<slot v-else-if="empty" name="empty">
|
||||
<EmptyData class="h-full flex-1" :hide-icon="hideIcon" :title="emptyText" :desc="emptyDetail" />
|
||||
</slot>
|
||||
<slot v-else></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Animation from '@/components/Animation/index.vue';
|
||||
|
||||
interface IProps {
|
||||
loading?: boolean,
|
||||
empty?: boolean,
|
||||
emptyText?: string,
|
||||
emptyDetail?: string,
|
||||
skeleton?: boolean,
|
||||
hideIcon?: boolean,
|
||||
skeletonRow?:number,
|
||||
card?: boolean,
|
||||
animation?: boolean
|
||||
}
|
||||
withDefaults(defineProps<IProps>(), {
|
||||
emptyText: '暂无数据',
|
||||
skeletonRow: 5,
|
||||
card: true,
|
||||
animation: false
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
</style>
|
||||
29
src/components/Demo/README.md
Normal file
29
src/components/Demo/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Demo
|
||||
|
||||
### 说明
|
||||
- xxxxxx(描述组件,并提供基础demo.建议demo使用最常见的用法即可)
|
||||
|
||||
``` html
|
||||
<Demo xxxx />
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|-----------|------------------------|----------------------------|----------|-----------|
|
||||
| id | 实体key | string | - | - |
|
||||
|
||||
|
||||
### Emits
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|-----------------|------------------------------------|---------------------|
|
||||
| onDelete | 删除 | id |
|
||||
|
||||
|
||||
### Slots
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---------------------|---------------------------------------------|
|
||||
| default | 中间显示区域 |
|
||||
|
||||
21
src/components/Demo/index.vue
Normal file
21
src/components/Demo/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="g-demo">
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface IProps {
|
||||
// 声明props内容
|
||||
}
|
||||
defineProps<IProps>();
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
|
||||
.g-demo {
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { discussionListItemType } from '@/api/discussion/types';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import Time from '@/components/Time/index.vue';
|
||||
import { baseURL } from '@/utils/request';
|
||||
|
||||
defineOptions({
|
||||
name: 'DashboardDiscussionListItem'
|
||||
});
|
||||
|
||||
interface additionType {}
|
||||
|
||||
type Iprops = discussionListItemType & additionType;
|
||||
|
||||
withDefaults(defineProps<Iprops>(), {});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="root">
|
||||
<div class="top">
|
||||
<GLink
|
||||
class="top-title"
|
||||
:href="`${baseURL}/api/v1/discuss/detail/${source_id}/${source_type}/${serial_number}`"
|
||||
>{{ title }}</GLink
|
||||
>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
<span class="bottom-icon">
|
||||
{{ category?.category_icon }}
|
||||
</span>
|
||||
<span v-if="created_date"
|
||||
><Time :time="created_date"></Time>创建的{{ category?.category_name }}</span
|
||||
>
|
||||
<span>{{ is_closed === 0 ? '' : ` · 已关闭` }}</span>
|
||||
<span
|
||||
v-if="category.category_type === DISCUSS_FORMAT.QANDA && is_answered === 1"
|
||||
class="bottom-answered"
|
||||
> · <Icon name="gt-closed-issue" color="#0EB07B" size="14px"></Icon
|
||||
><span class="ml-1">回答已采纳</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="category.category_type === DISCUSS_FORMAT.VOTE && is_closed === 1"
|
||||
class="info-content_voted"
|
||||
> · <Icon name="gt-skip-issue" color="#7E7E80" size="14px"></Icon
|
||||
><span class="ml-1">投票已结束</span></span
|
||||
>
|
||||
<span class="bottom-data">
|
||||
<Icon name="gt-comment" size="14px" class="mr-1" color="#7e7e80"></Icon>
|
||||
<span class="bottom-total">{{ comment_total || '0' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
|
||||
.root {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.top {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: var(--color-font);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
&-title {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.bottom {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #7e7e80;
|
||||
line-height: 20px;
|
||||
&-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
&-data {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
.top-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
444
src/components/Discussion/Module/Create/index.vue
Normal file
444
src/components/Discussion/Module/Create/index.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, reactive, watch, computed, nextTick } from 'vue';
|
||||
import { useRouter, useRoute, onBeforeRouteLeave } from 'vue-router';
|
||||
import DiscussionTypeItem from '../components/DiscussionTypeItem.vue';
|
||||
import PollForm from '../components/DiscussionPollForm.vue';
|
||||
import Sidebar from '../components/Sidebar/index.vue';
|
||||
import MdEditor from '@/components/MdEditor/index.vue';
|
||||
|
||||
import type { discussionTypeOrSection } from '@/api/discussion/types';
|
||||
import { discussSave, discussRepoMembers, discussOrgMembers, typeDetail } from '@/api/discussion';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionCreate'
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sourceType: 1 | 2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(),
|
||||
{
|
||||
sourceType: 1
|
||||
}
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const typeId = route.params ? (route.params.discussionTypeId as string) : '';
|
||||
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
|
||||
if (!isLogin) router.push('/404');
|
||||
|
||||
// 确认讨论是否开启
|
||||
const {
|
||||
id: source_id,
|
||||
discussOpen,
|
||||
getDiscussionStatus,
|
||||
project_id
|
||||
} = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level =
|
||||
props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
|
||||
|
||||
const currentType = ref<discussionTypeOrSection>({
|
||||
id: typeId,
|
||||
icon: '',
|
||||
title: '',
|
||||
categoryType: 0,
|
||||
isGroup: false
|
||||
});
|
||||
const fetchTypeDetail = async() => {
|
||||
const res = await typeDetail({ id: typeId });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
const { category_icon, category_name, category_type, category_desc } = resData;
|
||||
// 非管理员,禁止创建公告
|
||||
if (category_type === DISCUSS_FORMAT.ANNOUNCE) {
|
||||
if (access_level < 50) router.replace({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
|
||||
};
|
||||
currentType.value = {
|
||||
id: typeId,
|
||||
icon: category_icon,
|
||||
title: category_name,
|
||||
categoryType: category_type,
|
||||
isGroup: false,
|
||||
desc: category_desc,
|
||||
answerAcceptEnable: category_type === DISCUSS_FORMAT.QANDA
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const initialData = async() => {
|
||||
await getDiscussionStatus();
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
await fetchTypeDetail();
|
||||
// 提取暂存数据
|
||||
if (localStorage.getItem(localStoreKey.value)) {
|
||||
const { main, poll } = JSON.parse(localStorage.getItem(localStoreKey.value) as string);
|
||||
formData.value = main;
|
||||
pollData.value = poll;
|
||||
}
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
};
|
||||
initialData();
|
||||
|
||||
const fetchMemberList = () => {};
|
||||
|
||||
const goToTypeSelect = () => {
|
||||
onReset();
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
|
||||
};
|
||||
|
||||
interface formDataType {
|
||||
title: string;
|
||||
md_content: string;
|
||||
}
|
||||
const formData = ref({ title: '', md_content: '' } as formDataType);
|
||||
const formRef = ref(null);
|
||||
const formRules = {
|
||||
title: [
|
||||
{ required: true, message: '讨论标题不能为空', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '讨论标题长度限制为2-50', trigger: 'blur' }
|
||||
],
|
||||
md_content: [{ required: true, message: '讨论内容不能为空', trigger: 'change' }]
|
||||
};
|
||||
const mdRules = reactive({
|
||||
linkify: {
|
||||
fuzzyLink: false
|
||||
}
|
||||
});
|
||||
|
||||
interface pollOptionType {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
interface pollInfoType {
|
||||
title: string;
|
||||
options: pollOptionType[];
|
||||
}
|
||||
const pollData = ref<pollInfoType>({ title: '', options: [{ id: '', value: '' }] });
|
||||
const pollDataError = reactive({
|
||||
title: false,
|
||||
options: false
|
||||
});
|
||||
const onPollOptionsChange = (val: pollOptionType[]) => {
|
||||
pollData.value.options = val;
|
||||
};
|
||||
|
||||
// 校验投票内容
|
||||
const validatePollData = () => {
|
||||
pollDataError.title = !pollData.value.title.trim();
|
||||
pollDataError.options = pollData.value.options.reduce((p, c) => (p + (c.value ? 1 : 0)), 0) < 2;
|
||||
return !pollDataError.title && !pollDataError.options;
|
||||
};
|
||||
|
||||
interface submitDataType {
|
||||
source_id: string;
|
||||
source_type: number;
|
||||
category_id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
md_content: string;
|
||||
label?: string[];
|
||||
commentAts?: string[];
|
||||
question?: string;
|
||||
options?: string[];
|
||||
}
|
||||
const submitCreate = async(data: any) => {
|
||||
const res = await discussSave(data);
|
||||
if (!res.error) {
|
||||
Message.success('已新建');
|
||||
onReset();
|
||||
// 跳转到详情页
|
||||
router.push({
|
||||
name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionDetail`,
|
||||
params: {
|
||||
serialNumber: res?.data?.data
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleSubmit = async() => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
const submitData: submitDataType = {
|
||||
...formData.value,
|
||||
source_id: source_id.value,
|
||||
source_type: props.sourceType,
|
||||
category_id: typeId,
|
||||
label: relatedLabels.value
|
||||
};
|
||||
if (currentType.value.categoryType === DISCUSS_FORMAT.VOTE) {
|
||||
if (validatePollData()) {
|
||||
submitData.question = pollData.value.title;
|
||||
submitData.options = pollData.value.options.map((item) => item.value);
|
||||
await submitCreate(submitData);
|
||||
}
|
||||
} else {
|
||||
await submitCreate(submitData);
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
const onSubmit = () => {
|
||||
if (formRef.value) {
|
||||
formRef.value.validate((isValid: boolean) => {
|
||||
if (isValid) {
|
||||
handleSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 能否创建
|
||||
const canCreate = ref(false);
|
||||
watch(
|
||||
[() => formData.value, () => pollData.value],
|
||||
() => {
|
||||
if (formData.value.title) {
|
||||
formData.value.title = formData.value.title.trim();
|
||||
}
|
||||
nextTick(() => {
|
||||
if (currentType.value.categoryType === DISCUSS_FORMAT.VOTE) {
|
||||
// 投票
|
||||
formRef.value &&
|
||||
formRef.value.validate((isValid: boolean) => {
|
||||
const pollValidate = validatePollData();
|
||||
canCreate.value = isValid && pollValidate;
|
||||
});
|
||||
} else {
|
||||
formRef.value &&
|
||||
formRef.value.validate((isValid: boolean) => {
|
||||
canCreate.value = isValid;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 重置
|
||||
const onReset = () => {
|
||||
enableStore.value = false; // 不存储表单数据
|
||||
localStorage.removeItem(localStoreKey.value);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
onReset();
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
|
||||
};
|
||||
|
||||
// Label 关联
|
||||
const relatedLabels = ref<string[]>();
|
||||
const getLabels = (val: string[]) => {
|
||||
relatedLabels.value = val;
|
||||
};
|
||||
|
||||
// 表单信息暂存
|
||||
const enableStore = ref(true);
|
||||
const localStoreKey = computed(() => {
|
||||
return `discussion:${userInfo.id}/${source_id.value}/${typeId}`;
|
||||
});
|
||||
const storeFormData = () => {
|
||||
// 检测是否需要暂存
|
||||
if (
|
||||
enableStore.value &&
|
||||
(formData.value.title.trim() || formData.value.md_content.trim() || pollData.value.title.trim())
|
||||
) {
|
||||
const storeData = {
|
||||
main: { ...formData.value },
|
||||
poll: { ...pollData.value }
|
||||
};
|
||||
localStorage.setItem(localStoreKey.value, JSON.stringify(storeData));
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
storeFormData();
|
||||
next();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', storeFormData);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', storeFormData);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discussion-create mt-6" v-loading="loading">
|
||||
<div class="mb-6">
|
||||
<d-breadcrumb>
|
||||
<gc-breadcrumb-item
|
||||
:to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` }"
|
||||
><span class="title">讨论类型选择</span></gc-breadcrumb-item
|
||||
>
|
||||
<gc-breadcrumb-item><span class="cur-title">新建讨论</span></gc-breadcrumb-item>
|
||||
</d-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8 main-content">
|
||||
<Card class="discussion-create-container min-w-0 flex-1">
|
||||
<!-- 当前内容分类信息 -->
|
||||
<DiscussionTypeItem class="discussion-create-head" :info="currentType">
|
||||
<template #icon>
|
||||
<span class="emoji-icon">{{ currentType.icon }}</span>
|
||||
</template>
|
||||
<template #tools>
|
||||
<d-button @click="goToTypeSelect">重新选择讨论类型</d-button>
|
||||
</template>
|
||||
</DiscussionTypeItem>
|
||||
<div class="py-4 px-20 -mb-30">
|
||||
<d-form
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
:data="formData"
|
||||
:pop-postion="['right']"
|
||||
:rules="formRules"
|
||||
>
|
||||
<d-form-item field="title" label="" :show-feedback="false">
|
||||
<d-input
|
||||
style="width: 100%"
|
||||
v-model="formData.title"
|
||||
placeholder="请输入讨论标题"
|
||||
maxLength="50"
|
||||
minLength="2"
|
||||
/>
|
||||
</d-form-item>
|
||||
<d-form-item field="md_content" label="" :show-feedback="false">
|
||||
<MdEditor v-model="formData.md_content" :project-id="project_id"></MdEditor>
|
||||
</d-form-item>
|
||||
</d-form>
|
||||
</div>
|
||||
<!-- 投票表单 -->
|
||||
<div class="p-20" v-if="currentType?.categoryType === DISCUSS_FORMAT.VOTE">
|
||||
<PollForm
|
||||
v-model:title="pollData.title"
|
||||
:title-empty="pollDataError.title"
|
||||
:valid-options="pollDataError.options"
|
||||
@poll-options="onPollOptionsChange"
|
||||
/>
|
||||
</div>
|
||||
<!-- 按钮 -->
|
||||
<div class="flex justify-end gap-2 px-5 py-5 mt-3 discussion-create-footer">
|
||||
<d-button @click="onCancel">取消</d-button>
|
||||
<d-button variant="solid" color="primary" @click="onSubmit" :disabled="!canCreate" :loading="loading"
|
||||
>新建讨论</d-button
|
||||
>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div class="wrapper-right" v-if="access_level >= 30">
|
||||
<Sidebar
|
||||
v-if="source_id"
|
||||
:access_level="access_level"
|
||||
:source-id="source_id"
|
||||
:is-detail="false"
|
||||
:source-type="sourceType"
|
||||
@create-discuss="getLabels"
|
||||
></Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
|
||||
.discussion-create {
|
||||
&-container {
|
||||
padding: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9a9b9c;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.cur-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2d2d2e;
|
||||
line-height: 20px;
|
||||
}
|
||||
&-head {
|
||||
padding: 16px 20px !important;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
&-footer {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
:deep(.devui-form__control-info) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:deep(.devui-form__label--vertical) {
|
||||
display: none;
|
||||
}
|
||||
:deep(.devui-form__item--vertical) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
// 投票讨论
|
||||
// sidebar
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-select {
|
||||
margin-top: 7px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.setting {
|
||||
margin-top: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-right {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 576px){
|
||||
.discussion-create {
|
||||
padding:20px;
|
||||
}
|
||||
.main-content{
|
||||
flex-direction: column;
|
||||
.discussion-create-container{
|
||||
width:100%;
|
||||
}
|
||||
.wrapper-right{
|
||||
width:100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import type { userInfoType, commentType } from '@/api/discussion/types';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { getDiscussionOriginalData } from '@/api/discussion';
|
||||
|
||||
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
|
||||
import MdEditor from '@/components/MdEditor/index.vue';
|
||||
import MdRender from '@/components/MdRender/index.vue';
|
||||
import DiscussionContentToolbar from '../../ContentToolbar/index.vue';
|
||||
import { GModal } from '@/components/Setting/index';
|
||||
import { replyUpdate, replyDelete } from '@/api/discussion';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionReplyItem'
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
replyDetail: commentType;
|
||||
userInfo: userInfoType; // 当前用户信息
|
||||
access_level?: number; // 权限
|
||||
projectId?:string;
|
||||
memberList?: any[]; // 可@用户列表
|
||||
hintConfig?: any; // 提示配置
|
||||
}>();
|
||||
const emit = defineEmits(['updateReply', 'deleteReply', 'quoteReply']);
|
||||
|
||||
const loading = ref(false);
|
||||
const editing = ref(false);
|
||||
|
||||
const created_avatar = ref('');
|
||||
const renderReplyContent = computed(() => {
|
||||
return props.replyDetail.md_content as string;
|
||||
});
|
||||
|
||||
// 表单
|
||||
const formData = ref({ md_content: '' });
|
||||
const formRef = ref(null);
|
||||
const formRules = {
|
||||
md_content: [{ required: true, message: '回复内容不能为空', trigger: 'change' }]
|
||||
};
|
||||
const mdRules = ref({ linkify: { fuzzyLink: false }});
|
||||
|
||||
// 提交数据
|
||||
const onSubmit = async() => {
|
||||
if (formRef.value) {
|
||||
formRef.value.validate(async(isValid:boolean) => {
|
||||
if (isValid) {
|
||||
const submitData = {
|
||||
id: props.replyDetail.id,
|
||||
md_content: formData.value?.md_content
|
||||
};
|
||||
await onReplyUpdate(submitData);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface submitDataType {
|
||||
id: string;
|
||||
md_content: string;
|
||||
}
|
||||
|
||||
// 更新回复
|
||||
const onReplyUpdate = async(submitData:submitDataType) => {
|
||||
loading.value = true;
|
||||
const res = await replyUpdate(submitData);
|
||||
if (!res.error) {
|
||||
emit('updateReply', res.data.data);
|
||||
editing.value = false;
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// 删除回复
|
||||
const replyDeleteVisible = ref(false);
|
||||
const deleteLoading = ref(false);
|
||||
const handleDelete = () => {
|
||||
document.body.click();
|
||||
replyDeleteVisible.value = true;
|
||||
};
|
||||
const onReplyDelete = async() => {
|
||||
deleteLoading.value = true;
|
||||
const res = await replyDelete({ id: props.replyDetail.id });
|
||||
if (!res.error) {
|
||||
emit('deleteReply');
|
||||
}
|
||||
deleteLoading.value = false;
|
||||
replyDeleteVisible.value = false;
|
||||
};
|
||||
|
||||
const getOriginalComment = async(id: any) => {
|
||||
const res = await getDiscussionOriginalData(id);
|
||||
if (!res.error) {
|
||||
return res?.data?.data?.content;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
editing.value = true;
|
||||
const originalContent = await getOriginalComment(props.replyDetail.id );
|
||||
const mdContent = originalContent || (props.replyDetail?.md_content as string)
|
||||
formData.value.md_content = mdContent;
|
||||
}
|
||||
|
||||
|
||||
// 复制链接
|
||||
const { copy } = useClipboard({ source: 'text', legacy: true });
|
||||
const copyLink = () => {
|
||||
copy(`${location.origin}${location.pathname}#discussion-reply-${props?.replyDetail?.id}`);
|
||||
document.body.click();
|
||||
Message.success('已复制链接');
|
||||
};
|
||||
|
||||
// 引用回复
|
||||
const quoteReply = () => {
|
||||
document.body.click();
|
||||
emit('quoteReply', props.replyDetail.md_content);
|
||||
};
|
||||
|
||||
// 取消,清空表单
|
||||
const onCancel = () => {
|
||||
loading.value = false;
|
||||
editing.value = false;
|
||||
};
|
||||
// watch(editing, (newVal) => {
|
||||
// if (!newVal) {
|
||||
// // 表单值重置
|
||||
// formData.value.md_content = props.replyDetail?.md_content;
|
||||
// }
|
||||
// });
|
||||
|
||||
// 初始化值
|
||||
watch(
|
||||
() => props.replyDetail,
|
||||
() => {
|
||||
formData.value.md_content = props.replyDetail?.md_content;
|
||||
created_avatar.value = props.replyDetail?.created_by_user_photo || '';
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reply-container" :id="replyDetail?.id?`discussion-reply-${replyDetail.id}`:undefined">
|
||||
<!-- 展示区 -->
|
||||
<div class="content-show" v-show="!editing">
|
||||
<!-- bar -->
|
||||
<DiscussionContentToolbar
|
||||
:created_avatar="created_avatar"
|
||||
:created_by_user_name="replyDetail?.created_by_user_name"
|
||||
type="reply"
|
||||
:showDropDown="!!userInfo?.id"
|
||||
:created_date="replyDetail?.created_date"
|
||||
>
|
||||
<template #edit>
|
||||
<Icon
|
||||
name="gt-edit"
|
||||
size="16px"
|
||||
class="mr-1 cursor-pointer"
|
||||
color="inherit"
|
||||
@click="handleEdit"
|
||||
v-if="userInfo.id === replyDetail?.created_by"
|
||||
></Icon>
|
||||
</template>
|
||||
<template #option>
|
||||
<!-- <gc-option class="comment-menu-option" @click="copyLink">复制链接</gc-option> -->
|
||||
<gc-option @click="quoteReply">引用回复</gc-option>
|
||||
<gc-option
|
||||
@click="handleDelete"
|
||||
v-if="userInfo.id === replyDetail?.created_by || access_level === 50"
|
||||
>删除回复</gc-option
|
||||
>
|
||||
</template>
|
||||
</DiscussionContentToolbar>
|
||||
<!-- md-render -->
|
||||
<div class="content-md">
|
||||
<MdRender v-model="renderReplyContent"></MdRender>
|
||||
</div>
|
||||
<!-- 点赞区 -->
|
||||
<div class="content-like" v-if="replyDetail?.id">
|
||||
<LikeBtn
|
||||
:target-type="3"
|
||||
:likeTotal="replyDetail?.like_total"
|
||||
:target-id="replyDetail.id"
|
||||
:is-like="replyDetail.is_like"
|
||||
:is-login="!!userInfo?.id"
|
||||
></LikeBtn>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 编辑区 -->
|
||||
<div class="content-edit" v-if="editing" v-loading="loading">
|
||||
<d-form
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
:data="formData"
|
||||
:pop-postion="['right']"
|
||||
:rules="formRules"
|
||||
>
|
||||
<d-form-item field="md_content" label="" :show-feedback="false">
|
||||
<MdEditor v-model="formData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border></MdEditor>
|
||||
</d-form-item>
|
||||
</d-form>
|
||||
<div class="mt-4 flex justify-end gap-4">
|
||||
<d-button @click="onCancel">取消</d-button>
|
||||
<d-button color="primary" variant="solid" @click="onSubmit">更新回复</d-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GModal v-model="replyDeleteVisible" showWarnIcon @confirm="onReplyDelete" confirmColor="danger" title="删除回复">
|
||||
<p>确定要删除此回复?</p>
|
||||
</GModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reply-container {
|
||||
background: #fbfbfb;
|
||||
width: calc(100% + 40px);
|
||||
position: relative;
|
||||
left: -20px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #f5f5f5;
|
||||
:deep(.devui-form__label--vertical) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.reply-container:first-of-type {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__left,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__role {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e9f0;
|
||||
line-height: 16px;
|
||||
}
|
||||
&__creator {
|
||||
color: #2d2d2e;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-like {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,676 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick, reactive, watchEffect, onMounted, inject } from 'vue';
|
||||
import type { discussDetailType, userInfoType, commentType } from '@/api/discussion/types';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { Message } from 'vue-devui/message';
|
||||
|
||||
import MdEditor from '@/components/MdEditor/index.vue';
|
||||
import MdRender from '@/components/MdRender/index.vue';
|
||||
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
|
||||
import ReplyItem from './components/ReplyItem.vue';
|
||||
import DiscussionContentToolbar from '../ContentToolbar/index.vue';
|
||||
import { GModal } from '@/components/Setting/index';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import {
|
||||
commentUpdate,
|
||||
commentDelete,
|
||||
commentRemark,
|
||||
commentUnremark,
|
||||
replyList,
|
||||
replySave,
|
||||
getDiscussionOriginalData,
|
||||
} from '@/api/discussion';
|
||||
import { useReport } from '@/utils/hooks/useReport';
|
||||
import { useAccountStore } from '@/stores/user';
|
||||
import { formatQuoteReply } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionCommentItem'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
discussDetail?: discussDetailType | undefined;
|
||||
commentDetail?: commentType | undefined;
|
||||
userInfo: userInfoType; // 当前用户信息
|
||||
access_level?: number; // 权限
|
||||
projectId?:string;
|
||||
markedComment?: commentType; // 已采纳的答案
|
||||
memberList?: any[]; // 可@用户列表
|
||||
mode?: string; // 评论模式
|
||||
}>(), {
|
||||
access_level: 0
|
||||
});
|
||||
const emit = defineEmits(['updateComment', 'updateDiscuss', 'refreshDiscuss']);
|
||||
const isCover = computed(() => props.mode === 'cover');
|
||||
const showCover = ref(true);
|
||||
|
||||
const loading = ref(false);
|
||||
const editing = ref(false);
|
||||
const userStore = useAccountStore();
|
||||
const created_avatar = ref('');
|
||||
const hasMarkedCommentStyle = ref({});
|
||||
|
||||
const renderCommentContent = computed(() => {
|
||||
return props?.commentDetail?.md_content as string;
|
||||
});
|
||||
|
||||
// 表单
|
||||
const formData = ref({ md_content: '' });
|
||||
const formRef = ref(null);
|
||||
const formRules = {
|
||||
md_content: [{ required: true, message: '评论内容不能为空', trigger: 'change' }]
|
||||
};
|
||||
const mdRules = ref({ linkify: { fuzzyLink: false }});
|
||||
const commentDeleteVisible = ref(false);
|
||||
|
||||
const getGroupMembers = inject('getGroupMembers') as () => Promise<void>;
|
||||
const hintConfig = {
|
||||
'@': getGroupMembers,
|
||||
};
|
||||
|
||||
|
||||
// 提交更新评论
|
||||
const onSubmit = () => {
|
||||
if (formRef.value) {
|
||||
formRef.value.validate((isValid:boolean) => {
|
||||
if (isValid) {
|
||||
const submitData = {
|
||||
id: props.commentDetail?.id as string,
|
||||
md_content: formData.value?.md_content
|
||||
};
|
||||
onCommentUpdate(submitData);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface submitDataType {
|
||||
id:string;
|
||||
md_content:string;
|
||||
}
|
||||
// 更新评论
|
||||
const onCommentUpdate = async(submitData:submitDataType) => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
const res = await commentUpdate(submitData);
|
||||
if (!res.error) {
|
||||
emit('updateComment');
|
||||
editing.value = false;
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// 删除评论
|
||||
const deleteLoading = ref(false);
|
||||
const handleDelete = () => {
|
||||
document.body.click();
|
||||
commentDeleteVisible.value = true;
|
||||
};
|
||||
const onCommentDelete = async() => {
|
||||
deleteLoading.value = true;
|
||||
const res = await commentDelete({ id: props.commentDetail?.id as string });
|
||||
if (!res.error) {
|
||||
// 删除评论,需要更新讨论所有内容 - 包括侧边栏数据
|
||||
emit('refreshDiscuss');
|
||||
}
|
||||
deleteLoading.value = false;
|
||||
commentDeleteVisible.value = false;
|
||||
};
|
||||
|
||||
// 取消,清空表单
|
||||
const onCancel = () => {
|
||||
loading.value = false;
|
||||
editing.value = false;
|
||||
};
|
||||
|
||||
// 采纳回答
|
||||
const onRemarkAnswer = async() => {
|
||||
const res = await commentRemark({ id: props.commentDetail?.id as string });
|
||||
if (!res.error) {
|
||||
emit('updateDiscuss');
|
||||
}
|
||||
};
|
||||
|
||||
// 取消采纳回答
|
||||
const onUnremarkAnswer = async() => {
|
||||
const res = await commentUnremark({ id: props.commentDetail?.id as string });
|
||||
if (!res.error) {
|
||||
emit('updateDiscuss');
|
||||
}
|
||||
};
|
||||
|
||||
// 复制链接
|
||||
const { copy } = useClipboard({ source: 'text', legacy: true });
|
||||
const copyLink = () => {
|
||||
copy(`${location.origin}${location.pathname}#discussion-comment-${props?.commentDetail?.id}`);
|
||||
document.body.click();
|
||||
Message.success('已复制链接');
|
||||
};
|
||||
|
||||
// 评论-引用回复
|
||||
const refReplyMd = ref();
|
||||
const focusReplyMdEditor = async() => {
|
||||
await nextTick();
|
||||
const lastLineNumber = refReplyMd.value.instance.lastLine();
|
||||
refReplyMd.value.instance.setCursor(lastLineNumber, 0);
|
||||
refReplyMd.value.instance.scrollIntoView(null, refReplyMd.value.instance.getScrollInfo().clientHeight - 10);
|
||||
refReplyMd.value.instance.focus();
|
||||
};
|
||||
|
||||
const getOriginalComment = async(id: any) => {
|
||||
const res = await getDiscussionOriginalData(id);
|
||||
if (!res.error) {
|
||||
return res?.data?.data?.content;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
editing.value = true;
|
||||
const originalContent = await getOriginalComment(props.commentDetail?.id );
|
||||
const mdContent = originalContent || (props.commentDetail?.md_content as string)
|
||||
formData.value.md_content = mdContent;
|
||||
}
|
||||
|
||||
|
||||
const quoteReply = async () => {
|
||||
document.body.click();
|
||||
showReplyEditor.value = true;
|
||||
const originalContent = await getOriginalComment(props.commentDetail?.id );
|
||||
const mdContent = originalContent || (props.commentDetail?.md_content as string)
|
||||
replyFormData.value.md_content = formatQuoteReply(mdContent);
|
||||
focusReplyMdEditor();
|
||||
|
||||
};
|
||||
|
||||
// 回复-引用回复
|
||||
const replyQuoteReply = async (content = '', id) => {
|
||||
document.body.click();
|
||||
showReplyEditor.value = true;
|
||||
|
||||
const originalContent = await getOriginalComment(id)
|
||||
const mdContent = originalContent || (props.commentDetail?.md_content as string)
|
||||
replyFormData.value.md_content = formatQuoteReply(mdContent);
|
||||
focusReplyMdEditor();
|
||||
};
|
||||
|
||||
// 获取回复列表
|
||||
const replyListData = ref<commentType[]>([]);
|
||||
const addReplyList = ref<commentType[]>([]);// 暂存已添加的数据 (在分页获取同样数据后移除重复数据)
|
||||
const replyListLength = ref(0);
|
||||
const replyPager = reactive({
|
||||
page: 1,
|
||||
pages: 0,
|
||||
pageSize: 10,
|
||||
total: props.commentDetail.reply_total || 0,
|
||||
loading
|
||||
});
|
||||
const showReplyEditor = ref(false);
|
||||
|
||||
const fetchReplyList = async() => {
|
||||
// 锚点定位过来展示一条评论作为封面
|
||||
if(isCover.value && showCover.value) {
|
||||
replyListData.value = props.commentDetail?.replyCover ? [props.commentDetail?.replyCover] : [];
|
||||
replyListLength.value = 1;
|
||||
replyPager.total = props.commentDetail?.reply_total;
|
||||
return
|
||||
};
|
||||
|
||||
if (props.commentDetail.reply_total < 1 || replyPager.loading) return; // 一级评论已知有无二级评论,避免多余请求
|
||||
|
||||
replyPager.loading = true;
|
||||
const res = await replyList({ parent_id: props.commentDetail?.id as string, page: replyPager.page, size: replyPager.pageSize });
|
||||
replyPager.loading = false;
|
||||
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
replyListData.value = resData.records;
|
||||
replyListLength.value = resData.total;
|
||||
replyPager.total = resData.total;
|
||||
replyPager.pages = resData.pages;
|
||||
}
|
||||
};
|
||||
// 回复表单
|
||||
const replyLoading = ref(false);
|
||||
const replyFormRef = ref(null);
|
||||
const replyFormData = ref({ md_content: '' });
|
||||
const replyFormRules = {
|
||||
md_content: [{ required: true, message: '回复内容不能为空', trigger: 'change' }]
|
||||
};
|
||||
|
||||
// 确认提交回复
|
||||
const onReplySubmit = () => {
|
||||
if (replyFormRef.value) {
|
||||
replyFormRef.value.validate((isValid:boolean) => {
|
||||
if (isValid) {
|
||||
const submitData = {
|
||||
parent_id: props.commentDetail?.id as string,
|
||||
md_content: replyFormData.value?.md_content
|
||||
};
|
||||
onReplySave(submitData);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface replySubmitDataType {
|
||||
parent_id:string;
|
||||
md_content:string;
|
||||
}
|
||||
// 调用新增回复接口
|
||||
const onReplySave = async(submitData:replySubmitDataType) => {
|
||||
if (replyLoading.value) return;
|
||||
replyLoading.value = true;
|
||||
const res = await replySave(submitData);
|
||||
if (!res.error) {
|
||||
emit('refreshDiscuss', { total: addReplyList.value.length + replyPager.total + 1 }); // 同步总回复数量
|
||||
showReplyEditor.value = false;
|
||||
replyFormData.value.md_content = '';
|
||||
useReport('comment', {
|
||||
event_id: 'comment',
|
||||
source_type: 'disscussion',
|
||||
source_name: props.discussDetail?.title,
|
||||
source_id: props.discussDetail?.created_by,
|
||||
repo_author_id: props.discussDetail?.created_by_user_name,
|
||||
comment_type: 'replay'
|
||||
});
|
||||
const replyData = res.data.data;
|
||||
replyData.created_by_user_name = userStore.accountInfo.username;
|
||||
replyData.created_by_user_photo = userStore.accountInfo.avatar;
|
||||
addReplyList.value.push(replyData); // 添加暂存的回复数据
|
||||
}
|
||||
replyLoading.value = false;
|
||||
};
|
||||
|
||||
const updateReply = ($event, item) => {
|
||||
const { md_content } = $event;
|
||||
item.md_content = md_content;
|
||||
};
|
||||
|
||||
// 删除添加的回复
|
||||
const handleDeleteReply = (item, type) => {
|
||||
if (type === 'added') {
|
||||
// 后添加的
|
||||
const index = addReplyList.value.findIndex((e) => e.id === item.id);
|
||||
if (index > -1) {
|
||||
addReplyList.value.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
// 已添加的
|
||||
const index = replyListData.value.findIndex((e) => e.id === item.id);
|
||||
if (index > -1) {
|
||||
replyListData.value.splice(index, 1);
|
||||
replyPager.total--;
|
||||
}
|
||||
}
|
||||
emit('refreshDiscuss', { total: replyPager.total + addReplyList.value.length });
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
// 清除副作用 移除已添加的重复数据
|
||||
if (replyListData.value.length && addReplyList.value.length) {
|
||||
const list = replyListData.value.slice(0);
|
||||
list.forEach((item) => {
|
||||
const index = addReplyList.value.findIndex((e) => e.id === item.id);
|
||||
if (index > -1) {
|
||||
addReplyList.value.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
onMounted(() => {
|
||||
if (props.commentDetail?.serial_number === 1 && !isCover.value) { // 快速获取第一条,其他 二级评论 延迟在元素暴露时获取
|
||||
fetchReplyList();
|
||||
}
|
||||
});
|
||||
// 取消新增回复,清空表单
|
||||
const onReplyCancel = () => {
|
||||
replyLoading.value = false;
|
||||
showReplyEditor.value = false;
|
||||
};
|
||||
|
||||
const loadComment = async() => {
|
||||
showCover.value = false;
|
||||
fetchReplyList();
|
||||
};
|
||||
|
||||
const loadNextComment = async() => {
|
||||
replyPager.page++;
|
||||
if (replyPager.loading) return;
|
||||
replyPager.loading = true;
|
||||
const res = await replyList({ parent_id: props.commentDetail?.id as string, page: replyPager.page, size: replyPager.pageSize });
|
||||
replyPager.loading = false;
|
||||
if (res.error) {
|
||||
replyPager.page--;
|
||||
} else {
|
||||
const resData = res?.data?.data;
|
||||
replyListData.value = replyListData.value.slice(0).concat(resData.records);
|
||||
replyListLength.value = resData.total;
|
||||
replyPager.total = resData.total;
|
||||
}
|
||||
};
|
||||
// watch(showReplyEditor, (newVal) => {
|
||||
// if (!newVal) {
|
||||
// // 表单值重置
|
||||
// replyFormData.value.md_content = '';
|
||||
// }
|
||||
// });
|
||||
|
||||
// 初始化值
|
||||
watch(
|
||||
() => props.commentDetail,
|
||||
() => {
|
||||
formData.value.md_content = props.commentDetail?.md_content as string;
|
||||
created_avatar.value = props.commentDetail?.created_by_user_photo || '';
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
// watch(editing, (newVal) => {
|
||||
// if (!newVal) {
|
||||
// // 表单值重置
|
||||
// formData.value.md_content = props.commentDetail?.md_content as string;
|
||||
// }
|
||||
// });
|
||||
|
||||
watch(
|
||||
() => props.markedComment,
|
||||
() => {
|
||||
hasMarkedCommentStyle.value =
|
||||
(props.markedComment?.id === props.commentDetail?.id && props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA)
|
||||
? {
|
||||
borderWidth: '1px',
|
||||
borderColor: '#26b688'
|
||||
}
|
||||
: {
|
||||
borderWidth: '0px',
|
||||
borderColor: 'transparent'
|
||||
};
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
const answerStatus = computed(() => {
|
||||
const origin = { showAnswerBar: false, isAnswered: false, isRemarked: false };
|
||||
if (
|
||||
props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA &&
|
||||
(props.userInfo?.id === props.discussDetail.created_by || props.access_level >= 30)
|
||||
) {
|
||||
origin.showAnswerBar = true;
|
||||
if (props.discussDetail.is_answered === 1) {
|
||||
if (props.commentDetail?.is_remark === 0) {
|
||||
origin.showAnswerBar = false;
|
||||
}
|
||||
origin.isAnswered = true;
|
||||
}
|
||||
if (props.commentDetail?.is_remark === 1) {
|
||||
origin.isRemarked = true;
|
||||
}
|
||||
}
|
||||
return origin;
|
||||
});
|
||||
|
||||
const currentRole = computed(() => {
|
||||
switch (props.access_level) {
|
||||
case 10:
|
||||
return '浏览者';
|
||||
case 30:
|
||||
return '开发者';
|
||||
case 50:
|
||||
return '管理员';
|
||||
default:
|
||||
return '无权限';
|
||||
}
|
||||
});
|
||||
|
||||
// 回复 trigger input 是否启用
|
||||
const replyCommentEnable = computed(() => {
|
||||
if (!props.userInfo?.id || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) {
|
||||
return false;
|
||||
} else return true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card simple class="container" :id="commentDetail?.id?`discussion-comment-${commentDetail.id}`:undefined" :style="hasMarkedCommentStyle">
|
||||
<!-- 展示区 -->
|
||||
<div class="content-show" v-show="!editing" v-element-exposure="{trigger: fetchReplyList}">
|
||||
<!-- bar -->
|
||||
<DiscussionContentToolbar
|
||||
:created_avatar="created_avatar"
|
||||
:created_by_user_name="commentDetail?.created_by_user_name"
|
||||
type="comment"
|
||||
:showDropDown="!!userInfo?.id && !!commentDetail?.md_content"
|
||||
:created_date="commentDetail?.created_date"
|
||||
>
|
||||
<template #edit>
|
||||
<Icon
|
||||
name="gt-edit"
|
||||
size="16px"
|
||||
class="mr-1 cursor-pointer"
|
||||
color="inherit"
|
||||
@click="handleEdit"
|
||||
v-if="userInfo?.id === commentDetail?.created_by && commentDetail?.md_content"
|
||||
></Icon>
|
||||
</template>
|
||||
<template #option>
|
||||
<!-- <gc-option class="comment-menu-option" @click="copyLink">复制链接</gc-option> -->
|
||||
<gc-option class="comment-menu-option" @click="quoteReply">引用回复</gc-option>
|
||||
<gc-option
|
||||
class="comment-menu-option"
|
||||
@click="handleDelete"
|
||||
v-if="userInfo?.id === commentDetail?.created_by || access_level === 50"
|
||||
>删除评论</gc-option
|
||||
>
|
||||
</template>
|
||||
</DiscussionContentToolbar>
|
||||
<!-- md-render -->
|
||||
<div class="content-md" v-if="commentDetail?.md_content">
|
||||
<MdRender v-model="renderCommentContent"></MdRender>
|
||||
</div>
|
||||
<!-- 点赞区 -->
|
||||
<div class="content-like" v-if="commentDetail?.id">
|
||||
<LikeBtn
|
||||
:target-type="2"
|
||||
:likeTotal="commentDetail?.like_total"
|
||||
:target-id="commentDetail.id"
|
||||
:is-like="commentDetail.is_like"
|
||||
:is-login="!!userInfo?.id"
|
||||
></LikeBtn>
|
||||
<p v-if="commentDetail.reply_total && commentDetail.reply_total > 0">{{ commentDetail.reply_total }}条回复</p>
|
||||
</div>
|
||||
<!-- 回复展示区 -->
|
||||
<div v-if="replyListData.length > 0">
|
||||
<ReplyItem
|
||||
v-for="item in replyListData"
|
||||
:key="item.id"
|
||||
:access_level="access_level"
|
||||
:user-info="userInfo"
|
||||
:reply-detail="item"
|
||||
:projectId="props.projectId"
|
||||
@update-reply="updateReply($event, item)"
|
||||
@delete-reply="handleDeleteReply(item, undefined)"
|
||||
@quote-reply="(evt) =>{ replyQuoteReply(evt, item.id) }"
|
||||
:hint-config="hintConfig"
|
||||
></ReplyItem>
|
||||
</div>
|
||||
<div
|
||||
class="leading-[26px] py-2 px-[20px] mx-[-20px] bg-[#fbfbfb]"
|
||||
v-if="showCover && replyPager.total > replyListData.length"
|
||||
>
|
||||
<d-button variant="text" :loading="replyPager.loading" @click="loadComment">点击查看更多</d-button>
|
||||
</div>
|
||||
<div
|
||||
class="leading-[26px] py-2 px-[20px] mx-[-20px] bg-[#fbfbfb]"
|
||||
v-if="!showCover && replyPager.total > replyListData.length && replyListData.length && replyPager.page < replyPager.pages"
|
||||
>
|
||||
<d-button variant="text" :loading="replyPager.loading" @click="loadNextComment">点击查看更多</d-button>
|
||||
</div>
|
||||
<!-- 添加的回复列表 -->
|
||||
<div v-if="addReplyList.length > 0">
|
||||
<ReplyItem
|
||||
v-for="item in addReplyList"
|
||||
:key="item.id"
|
||||
:access_level="access_level"
|
||||
:user-info="userInfo"
|
||||
:reply-detail="item"
|
||||
:projectId="props.projectId"
|
||||
@update-reply="updateReply($event, item)"
|
||||
@delete-reply="handleDeleteReply(item, 'added')"
|
||||
@quote-reply="(evt) =>{ replyQuoteReply(evt, item.id) }"
|
||||
class="first-of-type:!mt-0"
|
||||
:hint-config="hintConfig"
|
||||
>
|
||||
</ReplyItem>
|
||||
</div>
|
||||
<!-- 采纳答案 & 回复评论区 -->
|
||||
<div class="content-answer" v-if="!showReplyEditor">
|
||||
<d-input
|
||||
placeholder="回复评论"
|
||||
class="flex-1 input-hover"
|
||||
@focus="replyCommentEnable && (showReplyEditor = true)"
|
||||
:disabled="!replyCommentEnable"
|
||||
:title="replyCommentEnable?undefined:'请先登录'"
|
||||
/>
|
||||
<span v-show="answerStatus.showAnswerBar">
|
||||
<d-button
|
||||
v-if="!answerStatus.isAnswered"
|
||||
@click="onRemarkAnswer"
|
||||
icon="right-o"
|
||||
class="remark-answer-btn"
|
||||
>采纳该回答</d-button
|
||||
>
|
||||
<d-button
|
||||
v-else
|
||||
icon="forbid"
|
||||
@click="onUnremarkAnswer"
|
||||
class="unremark-answer-btn"
|
||||
>取消采纳回答</d-button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="content-reply-editor"
|
||||
v-else
|
||||
v-loading="replyLoading"
|
||||
>
|
||||
<d-form
|
||||
ref="replyFormRef"
|
||||
layout="vertical"
|
||||
:data="replyFormData"
|
||||
:pop-postion="['right']"
|
||||
:rules="replyFormRules"
|
||||
>
|
||||
<d-form-item field="md_content" label="" :show-feedback="false">
|
||||
<MdEditor ref="refReplyMd" v-model="replyFormData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border-light></MdEditor>
|
||||
</d-form-item>
|
||||
</d-form>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<d-button @click="onReplyCancel">取消</d-button>
|
||||
<d-button variant="solid" @click="onReplySubmit" color="primary" :disabled="!replyFormData.md_content">回复</d-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑区 -->
|
||||
<div class="content-edit" v-if="editing" v-loading="loading">
|
||||
<d-form
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
:data="formData"
|
||||
:pop-postion="['right']"
|
||||
:rules="formRules"
|
||||
>
|
||||
<d-form-item field="md_content" label="" :show-feedback="false">
|
||||
<MdEditor v-model="formData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border-light />
|
||||
</d-form-item>
|
||||
</d-form>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<d-button @click="onCancel">取消</d-button>
|
||||
<d-button variant="solid" color="primary" @click="onSubmit" :disabled="!formData.md_content">更新评论</d-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GModal v-model="commentDeleteVisible" showWarnIcon @confirm="onCommentDelete" confirmColor="danger" title="删除评论">
|
||||
<p>确定要删除此评论?</p>
|
||||
</GModal>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
margin-top: 16px;
|
||||
:deep(.devui-form__label--vertical) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.container.g-content-card {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__left,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__role {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e9f0;
|
||||
line-height: 16px;
|
||||
}
|
||||
&__creator {
|
||||
color: #2d2d2e;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-like {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
& > p {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #9fa7b3;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-answer {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
.remark-answer-btn {
|
||||
background: rgba(14, 176, 123, 0.1);
|
||||
border: 1px solid #0eb07b;
|
||||
color: #0eb07b;
|
||||
:deep(.devui-button__icon-fix) {
|
||||
color: #0eb07b;
|
||||
}
|
||||
}
|
||||
.unremark-answer-btn {
|
||||
background: #fae5ec;
|
||||
border: 1px solid #e05e86;
|
||||
color: #e05e86;
|
||||
:deep(.devui-button__icon-fix) {
|
||||
color: #e05e86;
|
||||
}
|
||||
}
|
||||
}
|
||||
.content-reply-editor {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!-- 展示投票结果:投票标题,投票各选项票数-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import CustomTag from '@/components/CustomTag/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionContentToolbar'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
created_avatar: string; // 创建人头像
|
||||
created_by_user_name: string; // 创建人username
|
||||
type: 'discussion' | 'comment' | 'reply';
|
||||
showDropDown: boolean; // 是否登录
|
||||
created_date: string; // 创建日期
|
||||
isAnswerDisplay:boolean; // 是否为答案展示
|
||||
}>(), {
|
||||
created_avatar: '',
|
||||
created_by_user_name: '',
|
||||
type: 'comment',
|
||||
showDropDown: false,
|
||||
created_data: '',
|
||||
isAnswerDisplay: false
|
||||
});
|
||||
|
||||
const typeText = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'discussion':
|
||||
return '创建讨论:';
|
||||
case 'comment':
|
||||
return '评论:';
|
||||
case 'reply':
|
||||
return '回复:';
|
||||
default:
|
||||
return '评论:';
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-toolbar">
|
||||
<div class="content-toolbar__left space-x-2">
|
||||
<span><GAvatar :src="created_avatar" :name="created_by_user_name" :width="20" :height="20"></GAvatar></span>
|
||||
<GLink
|
||||
style="color: inherit"
|
||||
:to="{ name: 'homepage', params: { namespace: created_by_user_name } }"
|
||||
target="_blank"
|
||||
>{{ created_by_user_name }}</GLink
|
||||
>
|
||||
<!-- TODO: 接口缺失 先去除 role 展示 -->
|
||||
<!-- <span class="content-toolbar__role">开发者</span> -->
|
||||
<span class="content-toolbar__creator">{{ typeText }}</span>
|
||||
</div>
|
||||
<div class="content-toolbar__right space-x-5">
|
||||
<CustomTag v-if="isAnswerDisplay" title="回答已采纳" icon="right-o" bg-color="#e6f7f1" color="#0EB07B" icon-color="#0EB07B"></CustomTag>
|
||||
<slot name="edit">
|
||||
</slot>
|
||||
<d-dropdown style="width: 100px" align="start" v-if="showDropDown">
|
||||
<Icon name="gt-more-operate" size="16px" class="cursor-pointer"></Icon>
|
||||
<template #menu>
|
||||
<!-- slot 传入自定义 option -->
|
||||
<slot name="option"></slot>
|
||||
</template>
|
||||
</d-dropdown>
|
||||
<span v-if="created_date"><Time :time="created_date"></Time></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__left,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__role {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e9f0;
|
||||
line-height: 16px;
|
||||
}
|
||||
&__creator {
|
||||
color: #2d2d2e;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<!-- 已采纳答案展示(答案内容渲染),点击查看完整回答 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import type { commentType, userInfoType } from '@/api/discussion/types';
|
||||
import DiscussionContentToolbar from '../../../ContentToolbar/index.vue';
|
||||
import MdRender from '@/components/MdRender/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionAnswerDisplay'
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
markedComment: commentType;
|
||||
userInfo: userInfoType; // 当前用户信息
|
||||
}>();
|
||||
|
||||
const created_avatar = ref('');
|
||||
const renderCommentContent = computed(() => {
|
||||
return props.markedComment.md_content as string;
|
||||
});
|
||||
|
||||
const mdRules = ref({ linkify: { fuzzyLink: false }});
|
||||
|
||||
// 跳转到锚点
|
||||
const goHashId = async(hash:string) => {
|
||||
const aNode = document.createElement('a');
|
||||
aNode.href = hash;
|
||||
aNode.click();
|
||||
const height = document.querySelector('.g-header')?.clientHeight || 141;
|
||||
window.scrollBy(0, -height);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.markedComment,
|
||||
() => {
|
||||
created_avatar.value = props.markedComment.created_by_user_photo || '';
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<DiscussionContentToolbar
|
||||
:created_avatar="created_avatar"
|
||||
:created_by_user_name="markedComment?.created_by_user_name"
|
||||
type="comment"
|
||||
:showDropDown="false"
|
||||
:created_date="markedComment?.created_date"
|
||||
isAnswerDisplay
|
||||
>
|
||||
</DiscussionContentToolbar>
|
||||
<div class="content-markdown">
|
||||
<MdRender v-model="renderCommentContent"></MdRender>
|
||||
</div>
|
||||
<!-- link -->
|
||||
<div class="answer-link">
|
||||
<span @click="goHashId(`#discussion-comment-${markedComment.id}`)" class="cursor-pointer">点击查看完整回答</span><d-icon name="icon-run" color="#0EB07B" :rotate="90" size="12px"></d-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
padding:16px 20px 40px 20px;
|
||||
background: linear-gradient(180deg,#e6f7f1,#f0faf6);
|
||||
position:relative;
|
||||
width:calc(100% + 40px);
|
||||
left: -20px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__left,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__role {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e9f0;
|
||||
line-height: 16px;
|
||||
}
|
||||
&__creator {
|
||||
color: #2d2d2e;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-markdown {
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.answer-link{
|
||||
height:20px;
|
||||
position:absolute;
|
||||
margin-bottom: 10px;
|
||||
bottom:0;
|
||||
left:0;
|
||||
// background: #ffffff;
|
||||
width:100%;
|
||||
display:flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
a{
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #0EB07B;
|
||||
line-height: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,209 @@
|
||||
<!-- 投票结果展示 && 投票表单界面 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import type {
|
||||
discussDetailType,
|
||||
userInfoType,
|
||||
quesitonOptionType
|
||||
} from '@/api/discussion/types';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import PollResult from '../PollResult/index.vue';
|
||||
import { discussVote } from '@/api/discussion';
|
||||
import CustomTag from '@/components/CustomTag/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionPollDisplay'
|
||||
});
|
||||
|
||||
interface voteFormType {
|
||||
title: string;
|
||||
options: quesitonOptionType[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
discussDetail: discussDetailType | undefined; // 讨论详情
|
||||
userInfo: userInfoType; // 当前用户信息
|
||||
access_level: number; // 当前用户项目权限
|
||||
}>();
|
||||
const showResult = ref(true); // 默认展示投票结果
|
||||
const choosedOptionId = ref('');
|
||||
|
||||
const emit = defineEmits(['updatePollContent']);
|
||||
|
||||
const voteForm = ref<voteFormType>({ title: '', options: [] });
|
||||
|
||||
watch(
|
||||
() => props.discussDetail,
|
||||
() => {
|
||||
voteForm.value.title = props.discussDetail?.question?.question || '';
|
||||
voteForm.value.options = props.discussDetail?.options as quesitonOptionType[];
|
||||
// 初始化:显示表单还是显示结果
|
||||
// 登录 & 未投票 & 未锁定 || 锁定了,但是是项目成员 → 显示投票表单
|
||||
if (props.userInfo.id && !props.discussDetail?.is_vote && (props.discussDetail?.is_lock !== 1 || (props.discussDetail?.is_lock === 1 && props.access_level && props.access_level >= 30))) {
|
||||
showResult.value = false;
|
||||
} else showResult.value = true;
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
const voteClosed = computed(() => {
|
||||
if (props.discussDetail?.is_closed === 1 || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) {
|
||||
return true; // 投票关闭
|
||||
} else return false;
|
||||
});
|
||||
|
||||
// 投票按钮 disable 控制
|
||||
const voteBtnDisabled = computed(() => {
|
||||
// 讨论关闭后,普通讨论允许用户评论、投票讨论不允许用户投票、修改投票
|
||||
if (props.discussDetail?.is_closed === 1) {
|
||||
return true;
|
||||
} else if (props.discussDetail?.is_lock === 1) {
|
||||
// 锁定后,讨论不允许非成员用户发表评论、投票
|
||||
if (!props.access_level || props.access_level < 30) {
|
||||
return true;
|
||||
} else return showResult.value;
|
||||
} else return showResult.value;
|
||||
});
|
||||
|
||||
// 投票表单 - 投票结果切换
|
||||
const toggleResult = () => {
|
||||
showResult.value = !showResult.value;
|
||||
};
|
||||
|
||||
// 是否可以点击切换到投票表单
|
||||
const enableToggleResult = computed(() => {
|
||||
if (props.discussDetail?.is_closed === 1 || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) return false;
|
||||
else return true;
|
||||
});
|
||||
|
||||
const handleVote = async() => {
|
||||
if (choosedOptionId.value) {
|
||||
const res = await discussVote({
|
||||
discuss_id: props.discussDetail?.id as string,
|
||||
option_id: choosedOptionId.value
|
||||
});
|
||||
if (!res.error) {
|
||||
Message.success('已投票');
|
||||
emit('updatePollContent');
|
||||
}
|
||||
} else {
|
||||
Message.warning('请选择一个投票项');
|
||||
return;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="poll-form" v-if="!showResult">
|
||||
<p class="poll-title">{{ voteForm?.title }}</p>
|
||||
<div class="poll-container">
|
||||
<d-radio
|
||||
v-for="item in voteForm.options"
|
||||
v-model="choosedOptionId"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
class="mb-2"
|
||||
>
|
||||
{{ item.vote_option }}
|
||||
</d-radio>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="poll-result">
|
||||
<p class="poll-title">{{ voteForm?.title }}</p>
|
||||
<PollResult :vote-form="discussDetail?.options" :option_id="(discussDetail?.option_id as string)"></PollResult>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="poll-footer">
|
||||
<div class="poll-footer__left">
|
||||
<CustomTag
|
||||
v-if="voteClosed"
|
||||
title="投票关闭"
|
||||
color="#707A87"
|
||||
bg-color="rgba(159, 167, 179,0.1)"
|
||||
></CustomTag>
|
||||
<CustomTag
|
||||
v-else
|
||||
title="投票进行中"
|
||||
color="#0EB07B"
|
||||
bg-color="rgba(14, 176, 123,0.1)"
|
||||
></CustomTag>
|
||||
<span class="poll-footer__total"
|
||||
>{{ discussDetail?.question?.vote_total }}人已投票</span
|
||||
>
|
||||
<span v-if="userInfo.id"> ·
|
||||
<d-button variant="text" v-if="showResult" @click="toggleResult" class="toggle-result" :disabled="!enableToggleResult">隐藏投票结果</d-button>
|
||||
<d-button v-else @click="toggleResult" class="toggle-result underline" variant="text">查看投票结果</d-button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="poll-footer__right">
|
||||
<!-- <d-button :disable="voteBtnDisabled" @click="handleVote">{{
|
||||
discussDetail?.is_vote ? '已投' : '投票'
|
||||
}}</d-button> -->
|
||||
<d-button :disabled="voteBtnDisabled" @click="handleVote" variant="solid" color="primary" v-if="userInfo.id">投票</d-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poll-form {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(146deg, #e2eaff 0%, #fdf4f6 35%, #fef9fa 66%, #ebf2ff 100%);
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.devui-radio__wrapper) {
|
||||
background-color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
}
|
||||
:deep(.devui-radio--md) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.poll-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #252d3b;
|
||||
line-height: 26px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.line {
|
||||
margin-top: 14px;
|
||||
position: relative;
|
||||
left: -20px;
|
||||
width: calc(100% + 40px);
|
||||
height: 1px;
|
||||
background: #f0f1f2;
|
||||
}
|
||||
.poll-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #707a87;
|
||||
line-height: 16px;
|
||||
&__left {
|
||||
display:flex;
|
||||
align-items: center;
|
||||
.toggle-result{
|
||||
height:16px;
|
||||
font-size: 12px;
|
||||
color:#707a87;
|
||||
}
|
||||
}
|
||||
&__total{
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.poll-result {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(146deg, #e2eaff 0%, #fdf4f6 35%, #fef9fa 66%, #ebf2ff 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!-- 展示投票结果:投票标题,投票各选项票数-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { quesitonOptionType } from '@/api/discussion/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionPollResult'
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
voteForm: quesitonOptionType[];
|
||||
option_id: string;
|
||||
}>();
|
||||
|
||||
const dynamicStyle = (item: quesitonOptionType) => {
|
||||
return {
|
||||
width: `${item.vote_total_percent * 100}%`
|
||||
};
|
||||
};
|
||||
|
||||
const currentOptionId = computed(() => props.option_id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="item in props.voteForm" :key="item.id" class="poll-result-container">
|
||||
<div class="title">
|
||||
<d-radio disabled v-model="currentOptionId" :value="item.id">{{
|
||||
item.vote_option
|
||||
}}</d-radio>
|
||||
</div>
|
||||
<div class="item-container">
|
||||
<div class="item-chart">
|
||||
<div class="item-inner" :style="dynamicStyle(item)"></div>
|
||||
</div>
|
||||
<div class="item-data">
|
||||
<span>{{ item.vote_total }}票</span> ·
|
||||
<span>{{ `${item.vote_total_percent * 100}%` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poll-result-container {
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:deep(.devui-radio.disabled .devui-radio__label) {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #2d2d2e;
|
||||
line-height: 20px;
|
||||
}
|
||||
:deep(.devui-radio.active .devui-radio__material-inner.disabled) {
|
||||
fill: #adb0b8;
|
||||
}
|
||||
}
|
||||
.item-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.item-chart {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: #f0f1f2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.item-inner {
|
||||
background: #2865e0;
|
||||
border-radius: 2px;
|
||||
height: 4px;
|
||||
}
|
||||
.item-data {
|
||||
flex: 0 1 80px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #505a69;
|
||||
line-height: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,382 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, reactive, computed, inject } from 'vue';
|
||||
import type { discussDetailType, userInfoType, commentType } from '@/api/discussion/types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { Message } from 'vue-devui/message';
|
||||
|
||||
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
|
||||
import PollForm from '@/components/Discussion/Module/components/DiscussionPollForm.vue';
|
||||
import PollDisplay from './components/PollDisplay/index.vue';
|
||||
import AnswerDisplay from './components/AnswerDisplay/index.vue';
|
||||
import MdEditor from '@/components/MdEditor/index.vue';
|
||||
import MdRender from '@/components/MdRender/index.vue';
|
||||
import DiscussionContentToolbar from '../ContentToolbar/index.vue';
|
||||
import { GModal } from '@/components/Setting/index';
|
||||
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionDetailContent'
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
discussDetail: discussDetailType | undefined; // 讨论详情
|
||||
userInfo: userInfoType; // 当前用户信息
|
||||
access_level:number; // 当前用户权限
|
||||
projectId?:string;
|
||||
markedComment?: commentType; // 已采纳的答案
|
||||
memberList?: any[]; // 可@用户列表
|
||||
}>();
|
||||
const emit = defineEmits(['editContent', 'quoteReply']);
|
||||
|
||||
const loading = ref(false);
|
||||
const editing = ref(false);
|
||||
|
||||
const created_avatar = ref('');
|
||||
const hasMarkedCommentStyle = ref({});
|
||||
const renderDetailContent = computed(() => {
|
||||
return props.discussDetail?.md_content as string;
|
||||
});
|
||||
|
||||
const getGroupMembers = inject('getGroupMembers') as () => Promise<void>;
|
||||
const hintConfig = {
|
||||
'@': getGroupMembers,
|
||||
};
|
||||
|
||||
// 表单
|
||||
const formData = ref({ md_content: '' });
|
||||
const formRef = ref(null);
|
||||
const formRules = {
|
||||
md_content: [{ required: true, message: '讨论内容不能为空', trigger: 'change' }]
|
||||
};
|
||||
const mdRules = ref({ linkify: { fuzzyLink: false }});
|
||||
|
||||
// 投票表单
|
||||
interface pollOptionType {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
interface pollInfoType {
|
||||
title: string;
|
||||
options: pollOptionType[];
|
||||
}
|
||||
const pollData = reactive<pollInfoType>({ title: '', options: [{ id: '', value: '' }] });
|
||||
const pollDataError = reactive({
|
||||
title: false,
|
||||
options: false
|
||||
});
|
||||
const newPollOptions = ref(); // 记录修改后的投票选项
|
||||
const originPollData = ref({ title: '', options: [''] }); // 原始投票数据,用作是否修改投票比对
|
||||
// 监听投票内容更新
|
||||
const onPollOptionsChange = (val: pollOptionType[]) => {
|
||||
newPollOptions.value = val.map((item) => item.value);
|
||||
};
|
||||
|
||||
// 校验投票内容
|
||||
const validatePollData = () => {
|
||||
pollDataError.title = !pollData.title.trim();
|
||||
pollDataError.options = pollData.options.reduce((p, c) => (p + (c.value ? 1 : 0)), 0) < 2;
|
||||
return !pollDataError.title && !pollDataError.options;
|
||||
};
|
||||
// 弹窗确认
|
||||
const pollChangeModalVisible = ref(false);
|
||||
|
||||
// 能否更新
|
||||
const canUpdate = ref(false);
|
||||
watch([formData.value, pollData], () => {
|
||||
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE) {
|
||||
// 投票
|
||||
formRef.value && formRef.value.validate((isValid:boolean) => {
|
||||
const pollValidate = validatePollData();
|
||||
canUpdate.value = isValid && pollValidate;
|
||||
});
|
||||
} else {
|
||||
formRef.value && formRef.value.validate((isValid:boolean) => {
|
||||
canUpdate.value = isValid;
|
||||
});
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 提交数据
|
||||
const onSubmit = () => {
|
||||
if (formRef.value) {
|
||||
formRef.value.validate((isValid:boolean) => {
|
||||
if (isValid) {
|
||||
const submitData = {
|
||||
id: props.discussDetail?.id,
|
||||
title: props.discussDetail?.title,
|
||||
category_id: props.discussDetail?.category_id,
|
||||
is_edit: props.discussDetail?.is_edit,
|
||||
md_content: formData.value?.md_content
|
||||
};
|
||||
|
||||
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE) {
|
||||
// 标题 或者 投票选项有没有更新
|
||||
if (
|
||||
originPollData.value.title !== pollData.title ||
|
||||
!isEqual(originPollData.value.options, newPollOptions.value)
|
||||
) {
|
||||
// 弹窗确认
|
||||
pollChangeModalVisible.value = true;
|
||||
} else {
|
||||
emit('editContent', submitData);
|
||||
editing.value = false;
|
||||
}
|
||||
} else {
|
||||
emit('editContent', submitData);
|
||||
editing.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPollChangeSumbit = () => {
|
||||
// 确定更新投票
|
||||
const submitData = {
|
||||
id: props.discussDetail?.id,
|
||||
title: props.discussDetail?.title,
|
||||
category_id: props.discussDetail?.category_id,
|
||||
is_edit: true,
|
||||
md_content: formData.value?.md_content,
|
||||
question: pollData.title,
|
||||
options: newPollOptions.value
|
||||
};
|
||||
if (validatePollData()) {
|
||||
emit('editContent', submitData);
|
||||
pollChangeModalVisible.value = false;
|
||||
editing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 取消,清空表单
|
||||
const onCancel = () => {
|
||||
loading.value = false;
|
||||
editing.value = false;
|
||||
};
|
||||
|
||||
const onVoteUpdate = () => {
|
||||
emit('editContent');
|
||||
};
|
||||
|
||||
// 复制链接
|
||||
const { copy } = useClipboard({ source: 'text', legacy: true });
|
||||
const copyLink = () => {
|
||||
copy(`${location.origin}${location.pathname}#discussion-${props?.discussDetail?.id}`);
|
||||
document.body.click();
|
||||
Message.success('已复制链接');
|
||||
};
|
||||
|
||||
// 引用回复
|
||||
const quoteReply = () => {
|
||||
document.body.click();
|
||||
emit('quoteReply', props.discussDetail?.md_content);
|
||||
};
|
||||
|
||||
// 根据 props 初始化部分值
|
||||
watch(
|
||||
() => props.discussDetail,
|
||||
() => {
|
||||
created_avatar.value = props.discussDetail?.created_by_user_photo || '';
|
||||
formData.value.md_content = props.discussDetail?.md_content || '';
|
||||
pollData.title = props.discussDetail?.question?.question || '';
|
||||
pollData.options =
|
||||
props.discussDetail?.options && props.discussDetail.options.length > 0
|
||||
? props.discussDetail.options.map((item) => {
|
||||
return {
|
||||
id: item.id,
|
||||
value: item.vote_option
|
||||
};
|
||||
})
|
||||
: [];
|
||||
originPollData.value.title = props.discussDetail?.question?.question || '';
|
||||
originPollData.value.options =
|
||||
props.discussDetail?.options && props.discussDetail.options.length > 0
|
||||
? props.discussDetail.options.map((item) => item.vote_option)
|
||||
: [];
|
||||
newPollOptions.value = [...originPollData.value.options];
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.markedComment,
|
||||
() => {
|
||||
hasMarkedCommentStyle.value = (props.markedComment?.id && (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA))
|
||||
? {
|
||||
borderWidth: '1px',
|
||||
borderColor: '#26b688',
|
||||
paddingBottom: '0'
|
||||
}
|
||||
: {
|
||||
borderWidth: '0px',
|
||||
borderColor: 'transparent',
|
||||
paddingBottom: '16px'
|
||||
};
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card simple class="container" :style="hasMarkedCommentStyle" :id="discussDetail?.id?`discussion-${discussDetail.id}`:undefined">
|
||||
<!-- 展示区 -->
|
||||
<div class="content-show" v-show="!editing">
|
||||
<!-- bar -->
|
||||
<DiscussionContentToolbar
|
||||
:created_avatar="created_avatar"
|
||||
:created_by_user_name="discussDetail?.created_by_user_name"
|
||||
type="discussion"
|
||||
:showDropDown="!!userInfo?.id"
|
||||
:created_date="discussDetail?.created_date"
|
||||
>
|
||||
<template #edit>
|
||||
<Icon
|
||||
name="gt-edit"
|
||||
size="16px"
|
||||
class="mr-1 cursor-pointer"
|
||||
color="inherit"
|
||||
@click="editing = true"
|
||||
v-if="userInfo.id === discussDetail?.created_by || access_level > 30"
|
||||
></Icon>
|
||||
</template>
|
||||
<template #option>
|
||||
<!-- <gc-option class="comment-menu-option" @click="copyLink">复制链接</gc-option> -->
|
||||
<gc-option class="comment-menu-option" @click="quoteReply">引用回复</gc-option>
|
||||
</template>
|
||||
</DiscussionContentToolbar>
|
||||
<!-- md-render -->
|
||||
<div class="content-md">
|
||||
<MdRender v-model="renderDetailContent"></MdRender>
|
||||
</div>
|
||||
<!-- 点赞区 -->
|
||||
<div class="content-like" v-if="discussDetail?.id">
|
||||
<LikeBtn
|
||||
:target-type="1"
|
||||
:likeTotal="discussDetail?.like_total"
|
||||
:target-id="discussDetail?.id"
|
||||
:is-like="discussDetail?.is_like"
|
||||
:is-login="!!userInfo?.id"
|
||||
></LikeBtn>
|
||||
<p v-if="discussDetail.comment_total > 0">{{ discussDetail.comment_total }}条评论</p>
|
||||
</div>
|
||||
<!-- 投票 -->
|
||||
<div
|
||||
v-if="discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE"
|
||||
class="poll-container"
|
||||
>
|
||||
<PollDisplay
|
||||
:discuss-detail="discussDetail"
|
||||
:user-info="userInfo"
|
||||
:access_level="access_level"
|
||||
@update-poll-content="onVoteUpdate"
|
||||
></PollDisplay>
|
||||
</div>
|
||||
<!-- 采纳回答的展示 -->
|
||||
<div
|
||||
v-if="
|
||||
discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA &&
|
||||
discussDetail.is_answered === 1
|
||||
"
|
||||
>
|
||||
<AnswerDisplay
|
||||
v-if="markedComment?.id"
|
||||
:marked-comment="markedComment"
|
||||
:user-info="userInfo"
|
||||
></AnswerDisplay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑区 -->
|
||||
<div class="content-edit" v-if="editing" v-loading="loading">
|
||||
<d-form
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
:data="formData"
|
||||
:pop-postion="['right']"
|
||||
:rules="formRules"
|
||||
>
|
||||
<d-form-item field="md_content" label="" :show-feedback="false">
|
||||
<MdEditor v-model="formData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border-light></MdEditor>
|
||||
</d-form-item>
|
||||
</d-form>
|
||||
<!-- 投票表单 -->
|
||||
<div class="mt-6" v-if="discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE">
|
||||
<PollForm
|
||||
@poll-options="onPollOptionsChange"
|
||||
:default-value="pollData.options"
|
||||
v-model:title="pollData.title"
|
||||
:title-empty="pollDataError.title"
|
||||
:valid-options="pollDataError.options"
|
||||
></PollForm>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex justify-end gap-2"
|
||||
:style="{ marginBottom: markedComment?.id ? '16px' : '' }"
|
||||
>
|
||||
<d-button @click="onCancel">取消</d-button>
|
||||
<d-button color="primary" variant="solid" @click="onSubmit" :disabled="!canUpdate">更新讨论</d-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GModal v-model="pollChangeModalVisible" showWarnIcon @confirm="onPollChangeSumbit" title="更改投票内容">
|
||||
<p>更改投票内容会清空已有的投票数据,已投票的人也需要重新投票,确定要更改吗?</p>
|
||||
</GModal>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
&.g-content-card {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
:deep(.devui-form__label--vertical) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__left,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__role {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e9f0;
|
||||
line-height: 16px;
|
||||
}
|
||||
&__creator {
|
||||
color: #2d2d2e;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-like {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
& > p {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #9fa7b3;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.poll-container {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import Time from '@/components/Time/index.vue';
|
||||
import type { discussDetailType, userInfoType } from '@/api/discussion/types';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionDetailHead'
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
discussDetail: discussDetailType | undefined; // 讨论详情
|
||||
userInfo: userInfoType; // 当前用户信息
|
||||
}>();
|
||||
const emit = defineEmits(['editTitle']);
|
||||
|
||||
const titleInput = ref(null);
|
||||
const editing = ref(false);
|
||||
const editError = ref(false);
|
||||
const title = ref('');
|
||||
|
||||
const onChangeTitle = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleEscape();
|
||||
}
|
||||
};
|
||||
|
||||
const onBlurTitle = (e: FocusEvent) => {
|
||||
if (title.value !== props.discussDetail?.title) {
|
||||
handleEdit();
|
||||
} else {
|
||||
editing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (editing.value) {
|
||||
if (title.value.trim()) {
|
||||
if (title.value.trim().length > 50 || title.value.trim().length < 2) {
|
||||
Message.warning('标题长度范围为2-50');
|
||||
editError.value = true;
|
||||
return;
|
||||
}
|
||||
if (title.value !== props.discussDetail?.title) {
|
||||
emit('editTitle', {
|
||||
title: title.value.trim(),
|
||||
id: props.discussDetail?.id,
|
||||
md_content: props.discussDetail?.md_content,
|
||||
category_id: props.discussDetail?.category_id
|
||||
});
|
||||
}
|
||||
editing.value = false;
|
||||
editError.value = false;
|
||||
} else {
|
||||
Message.warning('标题不能为空');
|
||||
editError.value = true;
|
||||
}
|
||||
} else {
|
||||
editing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = () => {
|
||||
title.value = props.discussDetail?.title as string;
|
||||
editing.value = false;
|
||||
};
|
||||
|
||||
const handleKeydownEvent = (event:KeyboardEvent) => {
|
||||
if (event.code === 'Escape') {
|
||||
handleEscape();
|
||||
}
|
||||
};
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if ((props.userInfo.id === props.discussDetail?.created_by) && props.discussDetail?.is_closed === 0) return true;
|
||||
else return false;
|
||||
});
|
||||
|
||||
const onEdit = () => {
|
||||
if (canEdit.value) {
|
||||
editing.value = true;
|
||||
nextTick(() => {
|
||||
title.value && titleInput.value && titleInput.value.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.discussDetail,
|
||||
() => {
|
||||
title.value = props.discussDetail?.title as string;
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
// 判断当前
|
||||
const currentStatus = computed(() => {
|
||||
if (props.discussDetail?.is_closed === 0) {
|
||||
return {
|
||||
status: 'open',
|
||||
bgColor: '#FAE5EC',
|
||||
color: '#E05E86',
|
||||
icon: 'gt-issue',
|
||||
text: '已开启'
|
||||
};
|
||||
} else {
|
||||
// 是否为问答类型
|
||||
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA) {
|
||||
if (props.discussDetail.is_answered === 1) {
|
||||
// 已回答
|
||||
return {
|
||||
status: 'closed_answered',
|
||||
bgColor: 'rgba(14, 176, 123,0.1)',
|
||||
color: '#0EB07B',
|
||||
icon: 'gt-closed-issue',
|
||||
text: '完成关闭'
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'closed',
|
||||
bgColor: 'rgba(159, 167, 179,0.1)',
|
||||
color: '#9FA7B3',
|
||||
icon: 'gt-skip-issue',
|
||||
text: '已关闭'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydownEvent);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydownEvent);
|
||||
editing.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="left">
|
||||
<div class="status" :style="{ backgroundColor: currentStatus.bgColor }">
|
||||
<div class="left-top">
|
||||
<Icon :name="currentStatus.icon" :color="currentStatus.color" size="16px"></Icon>
|
||||
<span :style="{ color: currentStatus.color }">{{ currentStatus.text }}</span>
|
||||
</div>
|
||||
<div class="left-bottom">#{{ discussDetail?.serial_number }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="right-top">
|
||||
<span v-show="discussDetail?.is_lock === 1"><Icon name="gt-lock" size="16px"></Icon></span>
|
||||
<d-input
|
||||
ref="titleInput"
|
||||
v-if="editing"
|
||||
v-model="title"
|
||||
:error="editError"
|
||||
autofocus
|
||||
:style="{maxWidth: `calc(100% - ${314 - (discussDetail?.is_lock === 1 ? 0 : 24)}px)`}"
|
||||
@keydown="onChangeTitle"
|
||||
@blur="onBlurTitle"
|
||||
maxLength="50"
|
||||
minLength="2"
|
||||
/>
|
||||
<h4 v-else :class="['top-title',canEdit?'can-edit':'']" @click="onEdit">{{ discussDetail?.title }}</h4>
|
||||
</div>
|
||||
<div class="right-bottom bottom flex content-center mt-3">
|
||||
<span class="bottom-chat-icon">
|
||||
<Icon name="gt-comment" size="16px" class="mr-1" color="#707a87"></Icon>
|
||||
</span>
|
||||
<GLink style="color:inherit" :to="{ name: 'homepage', params: { namespace: discussDetail?.created_by_user_name }}" target="_blank">
|
||||
{{ discussDetail?.created_by_user_name }}
|
||||
</GLink>
|
||||
·
|
||||
<span v-if="discussDetail?.closed_date && discussDetail?.is_closed === 1">
|
||||
于 <Time :time="discussDetail?.closed_date"></Time> 关闭
|
||||
</span>
|
||||
<span v-if="discussDetail?.created_date && discussDetail?.is_closed === 0">
|
||||
创建于 <Time :time="discussDetail?.created_date"></Time>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.left {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #2d2d2e;
|
||||
line-height: 20px;
|
||||
.status {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
width: 100px;
|
||||
height:100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.left-top {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.right {
|
||||
flex: 1;
|
||||
.top-title {
|
||||
word-break: break-all;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: #2d2d2e;
|
||||
line-height: 32px;
|
||||
&.can-edit{
|
||||
cursor: pointer;
|
||||
position:relative;
|
||||
&:hover::after{
|
||||
content:url('/src/assets/imgs/icon/icon-pen.svg');
|
||||
position:absolute;
|
||||
right:-20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.bottom {
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 4px;
|
||||
color: #707a87;
|
||||
&-chat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
.right-top{
|
||||
display:flex;
|
||||
align-items: center;
|
||||
gap:8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts" name="NewComment">
|
||||
import { ref, computed, nextTick, inject } from 'vue';
|
||||
import type { userInfoType, discussDetailType } from '@/api/discussion/types';
|
||||
import MdEditor from '@/components/MdEditor/index.vue';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { emitEvent } from '@/utils/eventBus';
|
||||
import { discussClose, commentSave } from '@/api/discussion';
|
||||
import { useReport } from '@/utils/hooks/useReport';
|
||||
import { discussDetailRecentActiveUsers } from '@/api/discussion';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionNewComment'
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
discussDetail: discussDetailType | undefined;
|
||||
access_level: number;
|
||||
userInfo: userInfoType;
|
||||
projectId?:string;
|
||||
quotedReply?: string;
|
||||
clearQuotedReply?: Function;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['newComment']);
|
||||
|
||||
const formData = ref({
|
||||
md_content: ''
|
||||
});
|
||||
const replyStat = ref(false);
|
||||
const formRef = ref();
|
||||
const formRules = {
|
||||
md_content: [{ required: true, message: '讨论内容不能为空', trigger: 'change' }]
|
||||
};
|
||||
const mdRules = ref({ linkify: { fuzzyLink: false }});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const getGroupMembers = inject('getGroupMembers') as () => Promise<void>;
|
||||
const hintConfig = {
|
||||
'@': getGroupMembers,
|
||||
};
|
||||
|
||||
const checkLogin = () => {
|
||||
if (props.userInfo?.id) {
|
||||
return;
|
||||
} else {
|
||||
emitEvent('logout', { Authorization: true, triggerType: '评论' });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async(withState = false) => {
|
||||
loading.value = true;
|
||||
if (!props.userInfo.id) {
|
||||
Message.warning('请先登录');
|
||||
loading.value = false;
|
||||
return undefined;
|
||||
}
|
||||
if (formData.value.md_content.trim()) {
|
||||
try {
|
||||
const isValid = await formRef.value.validate();
|
||||
if (isValid) {
|
||||
const submitData = {
|
||||
discuss_id: props.discussDetail?.id as string,
|
||||
md_content: formData.value.md_content
|
||||
};
|
||||
const res = await onCommentSave(submitData, withState);
|
||||
loading.value = false;
|
||||
return res;
|
||||
}
|
||||
} catch (error) {
|
||||
loading.value = false;
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
Message.warning('评论内容不能为空');
|
||||
loading.value = false;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
interface submitDataType {
|
||||
discuss_id:string;
|
||||
md_content:string;
|
||||
}
|
||||
const onCommentSave = async(submitData: submitDataType, withState = false) => {
|
||||
const res = await commentSave(submitData);
|
||||
if (!res.error) {
|
||||
if (!withState) {
|
||||
Message.success({ type: 'success', message: '已创建评论' });
|
||||
emit('newComment');
|
||||
}
|
||||
formData.value.md_content = '';
|
||||
useReport('comment', {
|
||||
event_id: 'comment',
|
||||
source_type: 'disscussion',
|
||||
source_name: props.discussDetail?.title,
|
||||
source_id: props.discussDetail?.created_by,
|
||||
repo_author_id: props.discussDetail?.created_by_user_name,
|
||||
comment_type: replyStat.value ? 'replay' : 'comment'
|
||||
});
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// 关闭讨论 & 重新打开讨论
|
||||
const confirmCloseOrReopen = async() => {
|
||||
const res = await discussClose({
|
||||
id: props.discussDetail?.id as string,
|
||||
state: props.discussDetail?.is_closed === 0 ? 1 : 0
|
||||
});
|
||||
if (!res.error) {
|
||||
props.discussDetail?.is_closed === 0
|
||||
? Message.success('讨论已关闭')
|
||||
: Message.success('讨论已重新打开');
|
||||
emit('newComment');
|
||||
}
|
||||
};
|
||||
const handleCloseOrReopen = async() => {
|
||||
const { is_closed } = props.discussDetail;
|
||||
loading.value = true;
|
||||
|
||||
if (is_closed === 1) { // 关闭中
|
||||
await confirmCloseOrReopen();
|
||||
} else if (is_closed === 0) {
|
||||
// 开启中
|
||||
if (formData.value.md_content.trim()) {
|
||||
// 填写内容,先提交再变更状态
|
||||
const submitRes = await onSubmit(true);
|
||||
if (submitRes?.data?.data) {
|
||||
await confirmCloseOrReopen();
|
||||
}
|
||||
} else {
|
||||
await confirmCloseOrReopen();
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const closeEnabled = computed(() => {
|
||||
if ((props.userInfo.id && props.discussDetail?.created_by === props.userInfo.id) || props.access_level >= 30) {
|
||||
return true;
|
||||
} else return false;
|
||||
});
|
||||
|
||||
const newCommentDisabled = computed(() => {
|
||||
if (!props.userInfo?.id || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) {
|
||||
return true;
|
||||
} else return false;
|
||||
});
|
||||
|
||||
const refMd = ref();
|
||||
defineExpose({
|
||||
setMdContent(value: string) {
|
||||
formData.value.md_content = value;
|
||||
},
|
||||
async focusMdEditor() {
|
||||
await nextTick();
|
||||
const lastLineNumber = refMd.value.instance.lastLine();
|
||||
refMd.value.instance.setCursor(lastLineNumber, 0);
|
||||
refMd.value.instance.focus();
|
||||
},
|
||||
setReply(blo:boolean = true) {
|
||||
replyStat.value = blo;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="mt-4 new-comment" :title="newCommentDisabled?'请先登录':undefined" @click="checkLogin">
|
||||
<d-form
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
:data="formData"
|
||||
:pop-postion="['right']"
|
||||
:rules="formRules"
|
||||
v-loading="loading"
|
||||
:disabled="newCommentDisabled"
|
||||
>
|
||||
<d-form-item field="md_content" label="" :show-feedback="false" :class="{'newcomment-disabled':newCommentDisabled}">
|
||||
<MdEditor ref="refMd" v-model="formData.md_content" :hint-config='hintConfig' :project-id="projectId"></MdEditor>
|
||||
</d-form-item>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<d-button
|
||||
@click="handleCloseOrReopen"
|
||||
v-if="discussDetail?.is_closed === 1 && closeEnabled"
|
||||
:loading="loading"
|
||||
>重新打开讨论</d-button
|
||||
>
|
||||
<d-button v-if="discussDetail?.is_closed === 0 && closeEnabled" @click="handleCloseOrReopen" :loading="loading">关闭讨论</d-button>
|
||||
<d-button @click="onSubmit(false)" :loading="loading" variant="solid" color="primary" :disabled="newCommentDisabled || formData.md_content.length<1">发表评论</d-button>
|
||||
</div>
|
||||
</d-form>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.new-comment{
|
||||
:deep(.devui-form__label--vertical) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.newcomment-disabled {
|
||||
cursor:not-allowed;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
337
src/components/Discussion/Module/Detail/index.vue
Normal file
337
src/components/Discussion/Module/Detail/index.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, provide, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
discussDetail,
|
||||
discussUpdate,
|
||||
discussDetailRelatedUsers,
|
||||
commentList,
|
||||
getDiscussionDetail
|
||||
} from '@/api/discussion';
|
||||
import { getRepoMember } from '@/api/repo';
|
||||
import type {
|
||||
discussDetailType,
|
||||
commonPaginationType,
|
||||
commentType
|
||||
} from '@/api/discussion/types';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { formatQuoteReply } from '@/utils';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
import { useGlobalInfoStore } from '@/stores/Global';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { discussDetailRecentActiveUsers } from '@/api/discussion';
|
||||
|
||||
import DetailHead from './components/DetailHead/index.vue';
|
||||
import DetailContent from './components/DetailContent/index.vue';
|
||||
import CommentItem from './components/CommentItem/index.vue';
|
||||
import NewComment from './components/NewComment/index.vue';
|
||||
import Sidebar from '../components/Sidebar/index.vue';
|
||||
|
||||
import { useRepoId } from '@/utils/hooks/useRepoId';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionDetail'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sourceType: 1|2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(), {
|
||||
sourceType: 1
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const serialNumber = route.params.serialNumber as string;
|
||||
|
||||
const { repoId } = useRepoId();
|
||||
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { userInfo = {}} = useDiscussGetUserInfo();
|
||||
|
||||
// 确认讨论是否开启
|
||||
const { id: source_id, discussOpen, getDiscussionStatus, project_id } = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
const { setIsNotFound } = useGlobalInfoStore();
|
||||
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
|
||||
|
||||
// 获取讨论详情
|
||||
const loading = ref(false);
|
||||
const discussDetailData = ref<discussDetailType>();
|
||||
const fetchDetail = async() => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
const detailRes = await discussDetail({
|
||||
source_id: source_id.value,
|
||||
source_type: props.sourceType,
|
||||
serial_number: serialNumber
|
||||
});
|
||||
if (!detailRes.error) {
|
||||
const resData = detailRes?.data?.data;
|
||||
discussDetailData.value = resData;
|
||||
|
||||
// 获取评论列表
|
||||
await fetchCommentList();
|
||||
} else {
|
||||
// 审核未通过,内容区域 404
|
||||
setIsNotFound(true);
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const getGroupMembers = async () => {
|
||||
const recentRes = await discussDetailRecentActiveUsers({ source_id: discussDetailData.value?.id as string });
|
||||
const repoRes = await getRepoMember({ repoId: repoId.value, page: 1, per_page: 999 });
|
||||
const projectMembers = repoRes.data?.content || [];
|
||||
const recentMembers = recentRes.data?.data || [];
|
||||
const members = [...recentMembers, ...projectMembers].reduce((acc, cur) => {
|
||||
const target = acc.find((item) => item.username === cur.username);
|
||||
if (!target) {
|
||||
acc.push(cur);
|
||||
} else {
|
||||
('access_level' in cur) && (target.access_level = cur.access_level);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item)=>({
|
||||
...item,
|
||||
itemText: item.username,
|
||||
insertText: ` @${item.username}`,
|
||||
})) || [];
|
||||
|
||||
return members;
|
||||
};
|
||||
provide('getGroupMembers', getGroupMembers);
|
||||
|
||||
// 获取评论列表
|
||||
const commentsLoading = ref(false);
|
||||
const commentQuery = ref<commonPaginationType>({ page: 1, size: 100 });
|
||||
const commentTotal = ref(0);
|
||||
const comments = ref<commentType[]>([]);
|
||||
const markedComment = ref<commentType>({ id: '', md_content: '' });
|
||||
const fetchCommentList = async() => {
|
||||
if (commentsLoading.value) return;
|
||||
commentsLoading.value = true;
|
||||
const commentRes = await commentList({
|
||||
discuss_id: discussDetailData.value?.id as string,
|
||||
...commentQuery.value
|
||||
});
|
||||
if (!commentRes.error) {
|
||||
const resData = commentRes?.data?.data;
|
||||
commentTotal.value = resData.totol;
|
||||
comments.value = resData.records;
|
||||
// 查下有没有被标记为答案的
|
||||
if (resData.records && !isEmpty(resData.records)) {
|
||||
const $markedComment = resData.records.find((item: commentType) => item.is_remark === 1);
|
||||
if ($markedComment) {
|
||||
markedComment.value = $markedComment;
|
||||
} else {
|
||||
markedComment.value = { id: '', md_content: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
commentsLoading.value = false;
|
||||
};
|
||||
// 获取更多评论 - 每次多加载100条评论
|
||||
const fetchMoreComments = () => {
|
||||
commentQuery.value = {
|
||||
...commentQuery.value,
|
||||
size: commentQuery.value.size + 100
|
||||
};
|
||||
};
|
||||
const anchorComment = ref([])
|
||||
let anchorId = ref<string>('');
|
||||
const showAnchorItem = computed(()=>{
|
||||
if(!anchorId.value) return false;
|
||||
return !comments.value.some(item=> item.id === anchorId.value );
|
||||
})
|
||||
const getAnchorCommentInfo = async () => {
|
||||
const commentId = route.hash.split('?')?.find( i=> i.indexOf('#') > -1)?.slice(1);
|
||||
if(commentId) {
|
||||
const res = await getDiscussionDetail(commentId);
|
||||
const {parent, ...replyCover} = res?.data?.data || {};
|
||||
if(!parent) {
|
||||
anchorId.value = replyCover.id;
|
||||
anchorComment.value = [{...replyCover, replyCover: null}];
|
||||
} else {
|
||||
anchorId.value = parent.id;
|
||||
anchorComment.value = [{ ...parent, replyCover }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initialData = async() => {
|
||||
getAnchorCommentInfo();
|
||||
await getDiscussionStatus();
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
await fetchDetail();
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
};
|
||||
|
||||
initialData();
|
||||
|
||||
// 已登录用户获取可 @用户 列表
|
||||
const fetchMemberList = (id = '') => {};
|
||||
|
||||
// 回到讨论列表
|
||||
const goToList = () => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion` });
|
||||
};
|
||||
|
||||
// 引用回复
|
||||
const NewCommentEditor = ref();
|
||||
const onQuoteReply = (content: string) => {
|
||||
NewCommentEditor.value.setMdContent(formatQuoteReply(content));
|
||||
NewCommentEditor.value.setReply();
|
||||
const newComment = document && document.querySelector(`#newComment-${discussDetailData.value?.id}`);
|
||||
setTimeout(() => {
|
||||
newComment && newComment.scrollIntoView({ behavior: 'instant', block: 'end' });
|
||||
NewCommentEditor.value.focusMdEditor();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 讨论更新
|
||||
const onDetailUpdate = async(val?: discussDetailType) => {
|
||||
if (val) {
|
||||
const res = await discussUpdate({
|
||||
...val
|
||||
});
|
||||
if (!res.error) {
|
||||
// 刷新详情
|
||||
fetchDetail();
|
||||
}
|
||||
} else {
|
||||
fetchDetail();
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新讨论数据获取,包括参与者
|
||||
const detailSidebar = ref(null);
|
||||
const onRefresh = async($event, item, type = '') => {
|
||||
await fetchDetail();
|
||||
detailSidebar.value && detailSidebar.value.fetchRecentActiveUsers();
|
||||
if ($event) {
|
||||
item.reply_total = $event?.total || 0;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="root my-6" v-if="discussDetailData?.id">
|
||||
<DetailHead
|
||||
:discuss-detail="discussDetailData"
|
||||
:user-info="userInfo"
|
||||
@edit-title="onDetailUpdate"
|
||||
></DetailHead>
|
||||
<div class="wrapper">
|
||||
<div class="wrapper-left">
|
||||
<d-skeleton :loading="commentsLoading">
|
||||
<div>
|
||||
<DetailContent
|
||||
:discuss-detail="discussDetailData"
|
||||
:user-info="userInfo"
|
||||
:access_level="access_level"
|
||||
:projectId="sourceType===1?'':project_id"
|
||||
:marked-comment="markedComment"
|
||||
@edit-content="onDetailUpdate"
|
||||
@quote-reply="onQuoteReply"
|
||||
></DetailContent>
|
||||
</div>
|
||||
</d-skeleton>
|
||||
<div v-if="anchorComment.length > 0 && discussDetailData?.id && showAnchorItem">
|
||||
<CommentItem
|
||||
v-for="item in anchorComment"
|
||||
:key="item.id"
|
||||
:discuss-detail="discussDetailData"
|
||||
:access_level="access_level"
|
||||
:comment-detail="item"
|
||||
:user-info="userInfo"
|
||||
:projectId="sourceType===1?'':project_id"
|
||||
:marked-comment="markedComment"
|
||||
@update-comment="fetchCommentList"
|
||||
@update-discuss="fetchDetail"
|
||||
@refresh-discuss="onRefresh($event,item)"
|
||||
mode="cover"
|
||||
></CommentItem>
|
||||
</div>
|
||||
<div v-if="comments.length > 0 && discussDetailData?.id && !commentsLoading">
|
||||
<CommentItem
|
||||
v-for="item in comments"
|
||||
:key="item.id"
|
||||
:discuss-detail="discussDetailData"
|
||||
:access_level="access_level"
|
||||
:comment-detail="item"
|
||||
:user-info="userInfo"
|
||||
:projectId="sourceType===1?'':project_id"
|
||||
:marked-comment="markedComment"
|
||||
@update-comment="fetchCommentList"
|
||||
@update-discuss="fetchDetail"
|
||||
@refresh-discuss="onRefresh($event,item)"
|
||||
></CommentItem>
|
||||
</div>
|
||||
<div :id="`newComment-${discussDetailData?.id}`">
|
||||
<NewComment
|
||||
:discuss-detail="discussDetailData"
|
||||
:user-info="userInfo"
|
||||
:access_level="access_level"
|
||||
:projectId="sourceType===1?'':project_id"
|
||||
@new-comment="onRefresh(undefined, undefined, 'reload')"
|
||||
ref="NewCommentEditor"
|
||||
></NewComment>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-right">
|
||||
<d-skeleton :loading="loading">
|
||||
<Sidebar
|
||||
v-if="source_id && !loading"
|
||||
ref="detailSidebar"
|
||||
:discuss-detail="discussDetailData"
|
||||
:access_level="access_level"
|
||||
:user-info="userInfo"
|
||||
:source-id="source_id"
|
||||
:source-type="sourceType"
|
||||
:is-detail="true"
|
||||
@update-discuss="fetchDetail"
|
||||
></Sidebar>
|
||||
</d-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.wrapper {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: space-between;
|
||||
&-left {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
&-right {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 576px){
|
||||
.root{
|
||||
padding:20px;
|
||||
}
|
||||
.wrapper{
|
||||
flex-direction: column;
|
||||
&-left,&-right{
|
||||
width:100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
|
||||
import Time from '@/components/Time/index.vue';
|
||||
import type { discussionListItemType, commonDictType } from '@/api/discussion/types';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import LabelTag from '@/components/LabelTag/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionListItem'
|
||||
});
|
||||
|
||||
interface additionType {
|
||||
label_dict?: commonDictType[];
|
||||
isLogin?: boolean;
|
||||
categoryQuery?:boolean;
|
||||
sourceType:1|2;
|
||||
}
|
||||
|
||||
type Iprops = discussionListItemType & additionType;
|
||||
|
||||
const props = withDefaults(defineProps<Iprops>(), { sourceType: 1 });
|
||||
|
||||
// label 翻译
|
||||
const dictTranslate = (value: string, dict: commonDictType[]) => {
|
||||
return dict.find((item) => item.value === value);
|
||||
};
|
||||
|
||||
const labels = ref<string[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.label,
|
||||
() => {
|
||||
if (props.label) {
|
||||
labels.value = props.label;
|
||||
} else {
|
||||
labels.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 置顶图标是否展示
|
||||
const showPinStatus = computed(() => {
|
||||
if (props.categoryQuery) {
|
||||
return props.is_category_pin === 1;
|
||||
} else {
|
||||
return props.is_pin === 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="root">
|
||||
<div class="info">
|
||||
<div class="info-left">
|
||||
<LikeBtn
|
||||
:likeTotal="like_total"
|
||||
:isLogin="isLogin"
|
||||
:targetId="id"
|
||||
:targetType="1"
|
||||
:isLike="is_like"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-right">
|
||||
<div class="info-icon" v-if="category">
|
||||
{{ category.category_icon }}
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<div class="info-content__top">
|
||||
<GLink class="info-content__title ellipsis" :to="{ name: `${sourceType===1?'org':'repo'}DiscussionDetail`, params: { serialNumber: serial_number }}">
|
||||
{{ title }}
|
||||
</GLink>
|
||||
<div v-if="labels.length && label_dict.length" class="flex-center">
|
||||
<LabelTag
|
||||
v-for="item in labels"
|
||||
:key="item"
|
||||
:name="dictTranslate(item, label_dict).label"
|
||||
:color="dictTranslate(item, label_dict).color"
|
||||
></LabelTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-content__bottom">
|
||||
<GLink class="info-content__name" :to="{ name: 'homepage', params: { namespace: created_by_user_name }}" target="_blank">{{ created_by_user_name }}</GLink>
|
||||
<span v-if="created_date && category"><Time :time="created_date"></Time>创建的{{ category.category_name }}</span>
|
||||
<span v-if="is_closed === 1"><span class="px-1">·</span>已关闭</span>
|
||||
<span v-if="category?.category_type === DISCUSS_FORMAT.QANDA && is_answered === 1" class="info-content_answered">
|
||||
<span class="px-1">·</span>
|
||||
<Icon name="gt-closed-issue" color="#0EB07B" size="14px"></Icon><span class="ml-1">回答已采纳</span>
|
||||
</span>
|
||||
<span v-if="category?.category_type === DISCUSS_FORMAT.VOTE && is_closed === 1" class="info-content_voted">
|
||||
<span class="px-1">·</span>
|
||||
<Icon name="gt-skip-issue" color="#7E7E80" size="14px"></Icon><span class="ml-1">投票已结束</span>
|
||||
</span>
|
||||
<span class="statistics-total ml-4">
|
||||
<Icon name="gt-comment" class="mr-1" color="inherit" size="14px" />
|
||||
<span>{{ comment_total }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex items-center" v-if="showPinStatus">
|
||||
<Icon name="gt-to-top"></Icon>
|
||||
</div> -->
|
||||
<div class="flex items-center"></div>
|
||||
<div class="bg-CG300 flex items-end px-5 absolute pt-[32px] top-[-22px] left-[-30px] -rotate-45" v-if="showPinStatus"><span class="text-CG500 text-xs">置顶</span></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
.root {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap:16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.root:first-of-type {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
.root:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
.info {
|
||||
flex:1;
|
||||
}
|
||||
.info,
|
||||
.info-right {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.info-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 8px 6px 8px 8px;
|
||||
border-radius: 4px;
|
||||
display:flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.info-content{
|
||||
min-width: 0;
|
||||
}
|
||||
.info-content__top {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
.info-content__title {
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.g-label-tag {
|
||||
margin-right:8px;
|
||||
}
|
||||
}
|
||||
.info-content__bottom {
|
||||
color: var(--color-light);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
display:flex;
|
||||
align-items: center;
|
||||
.info-content__name {
|
||||
margin-right: 16px;
|
||||
}
|
||||
.info-content_answered{
|
||||
color:var(--color-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.info-content_voted{
|
||||
display:flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.statistics-total {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-light);
|
||||
line-height: 16px;
|
||||
display:flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
954
src/components/Discussion/Module/List/index.vue
Normal file
954
src/components/Discussion/Module/List/index.vue
Normal file
@@ -0,0 +1,954 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import ListItem from './components/DiscussListItem.vue';
|
||||
import { discussList, getAllSectionAndTypes, repoLabelList, getAnswerRank } from '@/api/discussion';
|
||||
import type { sectionItemType, commonDictType } from '@/api/discussion/types';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { filterEmptyObj } from '@/utils';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
import { emitEvent } from '@/utils/eventBus';
|
||||
defineOptions({
|
||||
name: 'DiscussionList'
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sourceType: 1 | 2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(),
|
||||
{
|
||||
sourceType: 1
|
||||
}
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(true);
|
||||
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
|
||||
|
||||
// 确认讨论是否开启
|
||||
const {
|
||||
id: source_id,
|
||||
discussOpen,
|
||||
getDiscussionStatus
|
||||
} = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level =
|
||||
props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
|
||||
|
||||
// 获取所有内容分类&组别
|
||||
interface discussionTypeMenuItem {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
isGroup: boolean;
|
||||
category_list?: {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
isGroup: boolean;
|
||||
};
|
||||
}
|
||||
const typeAndSectionList = ref<discussionTypeMenuItem[]>([]);
|
||||
const getTypeAndSection = async() => {
|
||||
// 获取所有内容分类&组别
|
||||
const resData = await getAllSectionAndTypes({
|
||||
source_id: source_id.value,
|
||||
source_type: props.sourceType
|
||||
});
|
||||
const $typeAndSectionList = [{ key: 'allType', label: '全部分类', icon: '📁', isGroup: false }];
|
||||
if (!resData.error) {
|
||||
const data: sectionItemType = resData?.data?.data;
|
||||
const { section, unSection } = data;
|
||||
if (unSection && unSection.length > 0) {
|
||||
for (const each of unSection) {
|
||||
$typeAndSectionList.push({
|
||||
key: each.id,
|
||||
icon: each.category_icon,
|
||||
label: each.category_name,
|
||||
isGroup: false
|
||||
});
|
||||
}
|
||||
}
|
||||
if (section && section.length > 0) {
|
||||
for (const each of section) {
|
||||
if (isArray(each.category_list) && !isEmpty(each.category_list)) {
|
||||
const sectionInfo = {
|
||||
key: each.id,
|
||||
icon: each.section_icon,
|
||||
label: each.section_name,
|
||||
isGroup: true,
|
||||
category_list: each.category_list.map((item) => ({
|
||||
key: item.id,
|
||||
icon: item.category_icon,
|
||||
label: item.category_name,
|
||||
isGroup: false
|
||||
}))
|
||||
};
|
||||
$typeAndSectionList.push(sectionInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
typeAndSectionList.value = $typeAndSectionList;
|
||||
};
|
||||
|
||||
// 获取社区热心榜
|
||||
interface answerRankType {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_photo: string;
|
||||
total: string;
|
||||
}
|
||||
const answerRankList = ref<answerRankType[]>([]);
|
||||
const getAnswerRankList = async() => {
|
||||
// 讨论-社区热心榜
|
||||
const res = await getAnswerRank({ source_id: source_id.value, source_type: props.sourceType });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
if (!isEmpty(resData)) {
|
||||
answerRankList.value = resData;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取Labels
|
||||
const labelOptions = ref<commonDictType[]>([]);
|
||||
const getLabels = async() => {
|
||||
if (props.sourceType === 1) {
|
||||
// const res = await orgLabelList({ project_id: source_id.value });
|
||||
// if (!res.error) {
|
||||
// const resData = res?.data?.data;
|
||||
// if (!isEmpty(resData.content)) {
|
||||
// labelOptions.value = resData.content.map((item: any) => ({
|
||||
// label: item.name,
|
||||
// value: item.id.toString(),
|
||||
// color: item.color
|
||||
// }));
|
||||
// }
|
||||
// }
|
||||
} else {
|
||||
const res = await repoLabelList({ project_id: source_id.value });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
if (!isEmpty(resData.content)) {
|
||||
labelOptions.value = resData.content.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id.toString(),
|
||||
color: item.color
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取讨论列表 query
|
||||
interface queryType {
|
||||
page: number;
|
||||
size: number;
|
||||
source_id: string;
|
||||
source_type: number;
|
||||
enable_query?: boolean;
|
||||
initial_query?: boolean; // 初次获取数据
|
||||
title?: string;
|
||||
category_id?: string;
|
||||
is_lock?: string; // 是否锁定:0:否;1:是
|
||||
is_closed?: string; // 是否关闭:0:否;1:是
|
||||
is_answered?: string; // 否已采纳答案:0:否;1:是
|
||||
label?: string;
|
||||
sort?: string; // 1:按创建时间倒序(默认);2:按创建时间正序;3:按评论数量倒序;4:按评论数量正序
|
||||
}
|
||||
const query = ref<queryType>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
source_id: '',
|
||||
source_type: props.sourceType,
|
||||
enable_query: false,
|
||||
initial_query: true,
|
||||
category_id: 'allType'
|
||||
});
|
||||
// 分类菜单点击
|
||||
const handleTypeChange = (e: { type: 'select'; key: string; el: HTMLElement; e: PointerEvent }) => {
|
||||
query.value.page = 1;
|
||||
if (!searchStore.value.title) {
|
||||
query.value.title = '';
|
||||
}
|
||||
handleQuery('category_id', e.key);
|
||||
};
|
||||
// selector 值
|
||||
const searchStore = ref({ title: '', label_id: '', sort: '', filter: '', initial_query: true });
|
||||
|
||||
// 监听 title 变更
|
||||
const handleTitleChange = (e: KeyboardEvent) => {
|
||||
// 回车触发
|
||||
if (e.key === 'Enter') {
|
||||
query.value.page = 1;
|
||||
handleQuery('title', searchStore.value.title);
|
||||
}
|
||||
};
|
||||
// 监听 label,sort,filter 变更
|
||||
watch(
|
||||
() => searchStore.value.label_id,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
query.value.page = 1;
|
||||
!searchStore.value.initial_query && handleQuery('label_id', newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => searchStore.value.sort,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
query.value.page = 1;
|
||||
!searchStore.value.initial_query && handleQuery('sort', newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => searchStore.value.filter,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
delete query.value.is_lock;
|
||||
delete query.value.is_answered;
|
||||
delete query.value.is_closed;
|
||||
let transferFilter = { label: '', value: '' };
|
||||
switch (newVal) {
|
||||
case '1': {
|
||||
transferFilter = {
|
||||
label: 'is_lock',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '2': {
|
||||
transferFilter = {
|
||||
label: 'is_lock',
|
||||
value: '0'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '3': {
|
||||
transferFilter = {
|
||||
label: 'is_closed',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '4': {
|
||||
transferFilter = {
|
||||
label: 'is_closed',
|
||||
value: '0'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '5': {
|
||||
transferFilter = {
|
||||
label: 'is_answered',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '6': {
|
||||
transferFilter = {
|
||||
label: 'is_answered',
|
||||
value: '0'
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
transferFilter = {
|
||||
label: 'clear_select',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
query.value.page = 1;
|
||||
!searchStore.value.initial_query && handleQuery(transferFilter.label, transferFilter.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 字典
|
||||
const sortOptions = [
|
||||
{ value: '1', label: '创建时间倒序' },
|
||||
{ value: '2', label: '创建时间正序' },
|
||||
{ value: '3', label: '评论数量倒序' },
|
||||
{ value: '4', label: '评论数量正序' }
|
||||
];
|
||||
const filterOptions = [
|
||||
{ value: '1', label: '已锁定' },
|
||||
{ value: '2', label: '未锁定' },
|
||||
{ value: '3', label: '已关闭' },
|
||||
{ value: '4', label: '未关闭' },
|
||||
{ value: '5', label: '已采纳回答' },
|
||||
{ value: '6', label: '未采纳回答' }
|
||||
];
|
||||
|
||||
const total = ref(0); // 讨论总条数
|
||||
const discussionList = ref(); // 讨论列表数据
|
||||
|
||||
// 路由进入时,获取初始 query 参数
|
||||
interface initialSeachParamType {
|
||||
category_id: string;
|
||||
label_id: string;
|
||||
sort: string;
|
||||
title: string;
|
||||
is_lock: string;
|
||||
is_closed: string;
|
||||
is_answered: string;
|
||||
}
|
||||
const fetchInitial = async() => {
|
||||
const initialQuery = route.query;
|
||||
const paramDict = [
|
||||
'category_id',
|
||||
'label_id',
|
||||
'sort',
|
||||
'title',
|
||||
'is_lock',
|
||||
'is_closed',
|
||||
'is_answered'
|
||||
];
|
||||
const filterdSearchParams = {} as initialSeachParamType;
|
||||
if (Object.keys(initialQuery).length) {
|
||||
for (const key of Object.keys(initialQuery)) {
|
||||
if (paramDict.includes(key)) {
|
||||
filterdSearchParams[key] = initialQuery[key];
|
||||
}
|
||||
}
|
||||
query.value = {
|
||||
...query.value,
|
||||
...filterEmptyObj(filterdSearchParams),
|
||||
enable_query: true
|
||||
};
|
||||
// searchStore 更新
|
||||
// 筛选方式确定后,后端修改下逻辑,不在前端做对应
|
||||
if (filterdSearchParams.title) searchStore.value.title = filterdSearchParams.title;
|
||||
if (filterdSearchParams.sort) searchStore.value.sort = filterdSearchParams.sort;
|
||||
if (filterdSearchParams.label_id) searchStore.value.label_id = filterdSearchParams.label_id;
|
||||
if (filterdSearchParams.is_lock) {
|
||||
if (filterdSearchParams.is_lock === '0') {
|
||||
searchStore.value.filter = '2';
|
||||
} else {
|
||||
searchStore.value.filter = '1';
|
||||
}
|
||||
}
|
||||
if (filterdSearchParams.is_closed) {
|
||||
if (filterdSearchParams.is_closed === '0') {
|
||||
searchStore.value.filter = '4';
|
||||
} else {
|
||||
searchStore.value.filter = '3';
|
||||
}
|
||||
}
|
||||
if (filterdSearchParams.is_answered) {
|
||||
if (filterdSearchParams.is_answered === '0') {
|
||||
searchStore.value.filter = '6';
|
||||
} else {
|
||||
searchStore.value.filter = '5';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
query.value = {
|
||||
...query.value,
|
||||
enable_query: true
|
||||
};
|
||||
}
|
||||
await fetchData(query.value);
|
||||
query.value.initial_query = false;
|
||||
searchStore.value.initial_query = false;
|
||||
};
|
||||
// 更新地址栏
|
||||
const updateURI = (key = '', val = '', resetFilter = false) => {
|
||||
const currentQuery = { ...route.query };
|
||||
if (resetFilter) {
|
||||
delete currentQuery.is_answered;
|
||||
delete currentQuery.is_lock;
|
||||
delete currentQuery.is_closed;
|
||||
}
|
||||
if (key) {
|
||||
currentQuery[key] = val;
|
||||
}
|
||||
const queryParams = filterEmptyObj({ ...currentQuery });
|
||||
router.push({ query: queryParams });
|
||||
};
|
||||
const clearTitle = () => {
|
||||
searchStore.value.title = '';
|
||||
query.value.page = 1;
|
||||
handleQuery('title', '');
|
||||
};
|
||||
// 更新 query
|
||||
const handleQuery = (mode = '', value = '') => {
|
||||
switch (mode) {
|
||||
case 'title': {
|
||||
query.value = filterEmptyObj({
|
||||
...query.value,
|
||||
title: value,
|
||||
enable_query: true
|
||||
}) as queryType;
|
||||
// updateURI('title', value, false);
|
||||
break;
|
||||
}
|
||||
case 'category_id': {
|
||||
if (value === 'allType') {
|
||||
const temp = { ...query.value, category_id: 'allType', enable_query: true } as queryType;
|
||||
// delete temp.title;
|
||||
query.value = filterEmptyObj({ ...temp }) as queryType;
|
||||
// updateURI('category_id', '', false);
|
||||
} else {
|
||||
query.value = { ...query.value, [mode]: value, enable_query: true };
|
||||
// updateURI(mode, value, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'is_lock': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = { ...temp, [mode]: value, enable_query: true };
|
||||
// updateURI(mode, value, true);
|
||||
break;
|
||||
}
|
||||
case 'is_closed': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = { ...temp, [mode]: value, enable_query: true };
|
||||
// updateURI(mode, value, true);
|
||||
break;
|
||||
}
|
||||
case 'is_answered': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = { ...temp, [mode]: value, enable_query: true };
|
||||
// updateURI(mode, value, true);
|
||||
break;
|
||||
}
|
||||
case 'clear_select': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = filterEmptyObj({ ...temp, enable_query: true }) as queryType;
|
||||
// updateURI('', '', true);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
query.value = filterEmptyObj({
|
||||
...query.value,
|
||||
[mode]: value,
|
||||
enable_query: true
|
||||
}) as queryType;
|
||||
// updateURI(mode, value, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 清空
|
||||
const resetQuery = () => {
|
||||
query.value = {
|
||||
page: 1,
|
||||
size: 10,
|
||||
source_id: '',
|
||||
source_type: props.sourceType,
|
||||
enable_query: true,
|
||||
initial_query: false,
|
||||
category_id: 'allType'
|
||||
};
|
||||
searchStore.value = {
|
||||
title: '',
|
||||
label_id: '',
|
||||
sort: '',
|
||||
filter: '',
|
||||
initial_query: false
|
||||
};
|
||||
router.push({ query: {}});
|
||||
};
|
||||
// 获取讨论列表
|
||||
const fetchData = async(currentQuery: queryType) => {
|
||||
loading.value = true;
|
||||
const submitQuery = { ...currentQuery };
|
||||
if (submitQuery.category_id === 'allType' || !submitQuery.category_id) {
|
||||
delete submitQuery.category_id;
|
||||
}
|
||||
delete submitQuery.enable_query;
|
||||
delete submitQuery.initial_query;
|
||||
const res = await discussList({
|
||||
...submitQuery,
|
||||
source_id: source_id.value
|
||||
});
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
total.value = resData.total;
|
||||
discussionList.value = resData.records;
|
||||
}
|
||||
loading.value = false;
|
||||
// enable_query 置回 false
|
||||
query.value = { ...query.value, enable_query: false };
|
||||
};
|
||||
// 监听 query 变化, 获取讨论列表
|
||||
watch(
|
||||
query,
|
||||
(newVal) => {
|
||||
if (newVal.enable_query && !newVal.initial_query) {
|
||||
//
|
||||
document.body.click(); // TODO: 华为pagination bug ,待修复
|
||||
fetchData(newVal);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 跳转新建
|
||||
const goCreate = () => {
|
||||
if (isLogin) {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
|
||||
} else {
|
||||
emitEvent('logout', true);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转讨论分类管理
|
||||
const goSet = () => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
await getDiscussionStatus();
|
||||
// 获取讨论数据
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
await getTypeAndSection();
|
||||
await fetchInitial();
|
||||
getLabels();
|
||||
getAnswerRankList();
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
};
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="disscussion-layout-wrapper flex g-page-layout mt-[24px]" v-if="discussOpen === '1'">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="left-menu">
|
||||
<div class="flex justify-between items-center content-center">
|
||||
<p class="secondary-title">分类</p>
|
||||
<Icon v-if="access_level >= 50" :operable="true" name="gt-setting" size="16px" class="cursor-pointer" @click="goSet" />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<d-menu mode="vertical" :default-select-keys="[query.category_id]" class="type-menu"
|
||||
@select="handleTypeChange($event)" width="100%">
|
||||
<template v-for="item in typeAndSectionList" :key="item.key">
|
||||
<d-menu-item class="disscuttion-menu-item" v-if="!item.isGroup" :key="item.key">
|
||||
<span>{{ `${item.icon} ${item.label}` }}</span>
|
||||
</d-menu-item>
|
||||
<d-sub-menu v-else :title="`${item.icon} ${item.label}`">
|
||||
<d-menu-item v-for="each in item.category_list" :key="each.key">
|
||||
{{ `${each.icon} ${each.label}` }}
|
||||
</d-menu-item>
|
||||
</d-sub-menu>
|
||||
</template>
|
||||
</d-menu>
|
||||
</div>
|
||||
<!-- 社区热心榜 -->
|
||||
<div class="answer-rank">
|
||||
<div class="answer-rank-title">
|
||||
<p class="secondary-title">社区热心榜<span class="tertiary-title">近30天</span></p>
|
||||
</div>
|
||||
<div class="answer-rank-list" v-if="!isEmpty(answerRankList)">
|
||||
<template v-for="item in answerRankList" :key="item.user_id">
|
||||
<div class="answer-rank-item">
|
||||
<div class="answer-rank-item__left">
|
||||
<GAvatar :src="item.user_photo" :width="32" :height="32" :name="item.user_name">
|
||||
</GAvatar>
|
||||
<GLink class="answer-rank-name ellipsis" :to="{ name: 'homepage', params: { namespace: item.user_name } }"
|
||||
target="_blank">
|
||||
{{ item.user_name }}
|
||||
</GLink>
|
||||
</div>
|
||||
<div class="answer-rank-item__right">
|
||||
<Icon name="gt-closed-issue" class="mr-2" color="var(--color-success)" />
|
||||
{{ item.total }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="answer-rank-empty" v-else>
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 讨论列表展示 -->
|
||||
<div class="right-list mb-[20px]">
|
||||
<!-- query -->
|
||||
<div class="search">
|
||||
<div class="search-query">
|
||||
<div class="search-query__title">
|
||||
<d-input v-model="searchStore.title" placeholder="搜索讨论" prefix="search" clearable
|
||||
@keydown="handleTitleChange" @clear="clearTitle"></d-input>
|
||||
</div>
|
||||
<div class="search-query__selector flex">
|
||||
<d-select v-model="searchStore.label_id" placeholder="Label" allow-clear v-if="sourceType === 2">
|
||||
<gc-option v-for="item in labelOptions" :key="item.value" :value="item.value" :name="`Label:${item.label}`">
|
||||
<div class="label-option">
|
||||
<span :style="{ backgroundColor: item.color }" class="label-option__color"></span>
|
||||
<span :title="item.label" class="label-option__label">{{ item.label }}</span>
|
||||
</div>
|
||||
</gc-option>
|
||||
</d-select>
|
||||
<d-select v-model="searchStore.filter" placeholder="状态" allow-clear>
|
||||
<gc-option v-for="item in filterOptions" :key="item.value" :value="item.value"
|
||||
:name="item.label"></gc-option>
|
||||
</d-select>
|
||||
<d-select v-model="searchStore.sort" placeholder="排序" allow-clear>
|
||||
<gc-option v-for="item in sortOptions" :key="item.value" :value="item.value"
|
||||
:name="item.label"></gc-option></d-select>
|
||||
</div>
|
||||
<!-- <d-button icon="icon-refresh" class="search-query__selector" @click="resetQuery">重置</d-button> -->
|
||||
<d-button @click="goCreate" variant="solid" color="primary">
|
||||
<Icon name="gt-add" color="white" /> 新讨论
|
||||
</d-button>
|
||||
</div>
|
||||
</div>
|
||||
<DataPanel skeleton :loading="loading" :empty="!discussionList?.length" animation class="discussion-list g-card">
|
||||
<div class="list-wrapper">
|
||||
<ListItem v-for="item in discussionList" :key="item.id" v-bind="item" :label_dict="labelOptions"
|
||||
:is-login="isLogin" :categoryQuery="!!query?.category_id && query.category_id !== 'allType'"
|
||||
:source-type="sourceType" />
|
||||
</div>
|
||||
</DataPanel>
|
||||
<!-- 分页 -->
|
||||
<d-pagination size="md" :total="total" :page-size-options="[10, 20, 50]" v-model:pageSize="query.size"
|
||||
v-model:pageIndex="query.page" :max-items="5" :can-change-page-size="true" :can-view-total="true"
|
||||
total-item-text="总计" auto-hide @page-index-change="query.enable_query = true"
|
||||
@page-size-change="query.enable_query = true" class="flex-center mt-20" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
|
||||
.container {
|
||||
margin-top: 24px;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
|
||||
:deep(.devui-submenu-title-content) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.disscussion-layout-wrapper {
|
||||
:deep(.devui-menu-vertical) {
|
||||
border-right: 0;
|
||||
background: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.disscuttion-menu-item {
|
||||
&:hover {
|
||||
background: $devui-list-item-hover-bg;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
|
||||
.left-menu {
|
||||
width: 300px;
|
||||
margin-right: 32px;
|
||||
overflow-y: scroll;
|
||||
max-height: calc(100vh - 280px);
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.secondary-title {
|
||||
font-size: 16px;
|
||||
color: var(--color-font);
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tertiary-title {
|
||||
height: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #707a87;
|
||||
line-height: 16px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
// 热心榜
|
||||
.answer-rank {
|
||||
margin-top: 25px;
|
||||
|
||||
.answer-rank-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.line {
|
||||
border-bottom: 1px solid $devui-line;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&-item {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__right {
|
||||
color: $devui-success;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
height: 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #707a87;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
// 空数据效果
|
||||
&-empty {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #707a87;
|
||||
line-height: 18px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label-option {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
&__color {
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&-query {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
|
||||
&__title {
|
||||
width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__selector {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
:deep(.devui-select) {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discussion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.left-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-query {
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__title {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
&__selector {
|
||||
flex: 0 0 100%;
|
||||
|
||||
:deep(.devui-select) {
|
||||
width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.type-menu {
|
||||
.devui-menu {
|
||||
&-item {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
background-color: $devui-global-bg;
|
||||
|
||||
&-vertical-wrapper {
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
&>.devui-menu-item {
|
||||
padding-left: 12px !important;
|
||||
}
|
||||
|
||||
&.layer_1 {
|
||||
&>.devui-menu-item {
|
||||
padding-left: 12px !important;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&+.layer_1 {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.layer_2 {
|
||||
&>.devui-menu-item {
|
||||
padding-left: 34px !important;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&+.layer_2 {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-select {
|
||||
background: var(--color-CG300) !important;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.devui-submenu.layer_2 {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.devui-submenu-title {
|
||||
background-color: $devui-global-bg !important;
|
||||
padding-left: 12px !important;
|
||||
height: 32px !important;
|
||||
line-height: 32px !important;
|
||||
padding-right: 0 !important;
|
||||
|
||||
&>i {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
&-query {
|
||||
&__selector {
|
||||
|
||||
/** :deep */
|
||||
.devui-select {
|
||||
&__selection {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
.devui-select__selection {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
.devui-select__selection {
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
|
||||
&+.devui-select {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background-color: #e6e7e8;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
990
src/components/Discussion/Module/List/orgDiscussion.vue
Normal file
990
src/components/Discussion/Module/List/orgDiscussion.vue
Normal file
@@ -0,0 +1,990 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import ListItem from './components/DiscussListItem.vue';
|
||||
import { discussList, getAllSectionAndTypes, repoLabelList, getAnswerRank } from '@/api/discussion';
|
||||
import type { sectionItemType, commonDictType } from '@/api/discussion/types';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { filterEmptyObj } from '@/utils';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
import { emitEvent } from '@/utils/eventBus';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
defineOptions({
|
||||
name: 'DiscussionList'
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sourceType: 1 | 2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(),
|
||||
{
|
||||
sourceType: 1
|
||||
}
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(true);
|
||||
const orgStore = orgInfoStore();
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
|
||||
|
||||
// 确认讨论是否开启
|
||||
const {
|
||||
id: source_id,
|
||||
discussOpen,
|
||||
getDiscussionStatus
|
||||
} = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level =
|
||||
props.sourceType === 1 ? orgStore.access_level : repoInfoStore().access_level;
|
||||
|
||||
// 获取所有内容分类&组别
|
||||
interface discussionTypeMenuItem {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
isGroup: boolean;
|
||||
category_list?: discussionTypeMenuItem[];
|
||||
}
|
||||
const discussionTypeList = ref<discussionTypeMenuItem[]>([]);
|
||||
const topicOptions = ref<discussionTypeMenuItem[]>([]);
|
||||
const topicActive = ref('');
|
||||
const subcatalogList = ref<discussionTypeMenuItem[]>([]);
|
||||
const getTypeAndSection = async() => {
|
||||
// 获取所有内容分类&组别
|
||||
const resData = await getAllSectionAndTypes({
|
||||
source_id: source_id.value,
|
||||
source_type: props.sourceType
|
||||
});
|
||||
const catalog = [{ key: '', label: '全部组别', icon: '📁', isGroup: false }];
|
||||
const subCatalog = [{ key: '', label: '全部分类', icon: '', isGroup: false }];
|
||||
if (!resData.error) {
|
||||
const data: sectionItemType = resData?.data?.data;
|
||||
const { section, unSection } = data;
|
||||
if (unSection && unSection.length > 0) {
|
||||
for (const each of unSection) {
|
||||
subCatalog.push({
|
||||
key: each.id,
|
||||
icon: each.category_icon,
|
||||
label: each.category_name,
|
||||
isGroup: false
|
||||
});
|
||||
}
|
||||
}
|
||||
if (section && section.length > 0) {
|
||||
for (const each of section) {
|
||||
if (isArray(each.category_list) && !isEmpty(each.category_list)) {
|
||||
const sectionInfo = {
|
||||
key: each.id,
|
||||
icon: each.section_icon,
|
||||
label: each.section_name,
|
||||
isGroup: true,
|
||||
category_list: each.category_list.map((item) => ({
|
||||
key: item.id,
|
||||
icon: item.category_icon,
|
||||
label: item.category_name,
|
||||
isGroup: false
|
||||
}))
|
||||
};
|
||||
sectionInfo.category_list.forEach((sub:discussionTypeMenuItem) => subCatalog.push(sub));
|
||||
catalog.push(sectionInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
discussionTypeList.value = subCatalog;
|
||||
topicOptions.value = catalog;
|
||||
handleTopic({ value: catalog[0].key });// 默认第一个大的主题
|
||||
};
|
||||
|
||||
// 获取社区热心榜
|
||||
interface answerRankType {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_photo: string;
|
||||
total: string;
|
||||
}
|
||||
const answerRankList = ref<answerRankType[]>([]);
|
||||
const getAnswerRankList = async() => {
|
||||
// 讨论-社区热心榜
|
||||
const res = await getAnswerRank({ source_id: source_id.value, source_type: props.sourceType });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
if (!isEmpty(resData)) {
|
||||
answerRankList.value = resData;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取Labels
|
||||
const labelOptions = ref<commonDictType[]>([]);
|
||||
const getLabels = async() => {
|
||||
if (props.sourceType === 1) {
|
||||
// const res = await orgLabelList({ project_id: source_id.value });
|
||||
// if (!res.error) {
|
||||
// const resData = res?.data?.data;
|
||||
// if (!isEmpty(resData.content)) {
|
||||
// labelOptions.value = resData.content.map((item: any) => ({
|
||||
// label: item.name,
|
||||
// value: item.id.toString(),
|
||||
// color: item.color
|
||||
// }));
|
||||
// }
|
||||
// }
|
||||
} else {
|
||||
const res = await repoLabelList({ project_id: source_id.value });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
if (!isEmpty(resData.content)) {
|
||||
labelOptions.value = resData.content.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id.toString(),
|
||||
color: item.color
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取讨论列表 query
|
||||
interface queryType {
|
||||
page: number;
|
||||
size: number;
|
||||
source_id: string;
|
||||
source_type: number;
|
||||
enable_query?: boolean;
|
||||
initial_query?: boolean; // 初次获取数据
|
||||
title?: string;
|
||||
category_id?: string;
|
||||
is_lock?: string; // 是否锁定:0:否;1:是
|
||||
is_closed?: string; // 是否关闭:0:否;1:是
|
||||
is_answered?: string; // 否已采纳答案:0:否;1:是
|
||||
label?: string;
|
||||
sort?: string; // 1:按创建时间倒序(默认);2:按创建时间正序;3:按评论数量倒序;4:按评论数量正序
|
||||
}
|
||||
const query = ref<queryType>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
source_id: '',
|
||||
source_type: props.sourceType,
|
||||
enable_query: false,
|
||||
initial_query: true,
|
||||
category_id: ''
|
||||
});
|
||||
const communityList = [
|
||||
{ label: '技术文章', name: 'article', path: '/organization/$namespace/article', devpress: true },
|
||||
{ label: '最新活动', name: 'activity', path: '/organization/$namespace/activity', devpress: true },
|
||||
{ label: '精品专栏', name: 'column', path: '/organization/$namespace/column', devpress: true },
|
||||
{ label: '热门讨论', name: 'orgDiscussion' }
|
||||
];
|
||||
const communityType = ref('');
|
||||
const handleTopic = (item:any) => {
|
||||
const arr = cloneDeep(item.value === '' ? discussionTypeList.value : ((topicOptions.value.find((v) => v.key === item.value)?.category_list || [])));
|
||||
subcatalogList.value = arr;
|
||||
topicActive.value = item.value;
|
||||
handleTypeChange({ value: arr[0].key });
|
||||
};
|
||||
// 分类菜单点击
|
||||
const handleTypeChange = (item:any) => {
|
||||
query.value.page = 1;
|
||||
query.value.category_id = item.value;
|
||||
fetchData(query.value);
|
||||
};
|
||||
// selector 值
|
||||
const searchStore = ref({ title: '', label_id: '', sort: '', filter: '', initial_query: true });
|
||||
|
||||
// 监听 title 变更
|
||||
const handleTitleChange = (e: KeyboardEvent) => {
|
||||
// 回车触发
|
||||
if (e.key === 'Enter') {
|
||||
query.value.page = 1;
|
||||
handleQuery('title', searchStore.value.title);
|
||||
}
|
||||
};
|
||||
// 监听 label,sort,filter 变更
|
||||
watch(
|
||||
() => searchStore.value.label_id,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
query.value.page = 1;
|
||||
!searchStore.value.initial_query && handleQuery('label_id', newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => searchStore.value.sort,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
query.value.page = 1;
|
||||
!searchStore.value.initial_query && handleQuery('sort', newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => searchStore.value.filter,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
delete query.value.is_lock;
|
||||
delete query.value.is_answered;
|
||||
delete query.value.is_closed;
|
||||
let transferFilter = { label: '', value: '' };
|
||||
switch (newVal) {
|
||||
case '1': {
|
||||
transferFilter = {
|
||||
label: 'is_lock',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '2': {
|
||||
transferFilter = {
|
||||
label: 'is_lock',
|
||||
value: '0'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '3': {
|
||||
transferFilter = {
|
||||
label: 'is_closed',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '4': {
|
||||
transferFilter = {
|
||||
label: 'is_closed',
|
||||
value: '0'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '5': {
|
||||
transferFilter = {
|
||||
label: 'is_answered',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case '6': {
|
||||
transferFilter = {
|
||||
label: 'is_answered',
|
||||
value: '0'
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
transferFilter = {
|
||||
label: 'clear_select',
|
||||
value: '1'
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
query.value.page = 1;
|
||||
!searchStore.value.initial_query && handleQuery(transferFilter.label, transferFilter.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 字典
|
||||
const sortOptions = [
|
||||
{ value: '1', label: '创建时间倒序' },
|
||||
{ value: '2', label: '创建时间正序' },
|
||||
{ value: '3', label: '评论数量倒序' },
|
||||
{ value: '4', label: '评论数量正序' }
|
||||
];
|
||||
const filterOptions = [
|
||||
{ value: '1', label: '已锁定' },
|
||||
{ value: '2', label: '未锁定' },
|
||||
{ value: '3', label: '已关闭' },
|
||||
{ value: '4', label: '未关闭' },
|
||||
{ value: '5', label: '已采纳回答' },
|
||||
{ value: '6', label: '未采纳回答' }
|
||||
];
|
||||
|
||||
const total = ref(0); // 讨论总条数
|
||||
const discussionList = ref(); // 讨论列表数据
|
||||
|
||||
// 路由进入时,获取初始 query 参数
|
||||
interface initialSeachParamType {
|
||||
category_id: string;
|
||||
label_id: string;
|
||||
sort: string;
|
||||
title: string;
|
||||
is_lock: string;
|
||||
is_closed: string;
|
||||
is_answered: string;
|
||||
}
|
||||
const fetchInitial = async() => {
|
||||
const initialQuery = route.query;
|
||||
const paramDict = [
|
||||
'category_id',
|
||||
'label_id',
|
||||
'sort',
|
||||
'title',
|
||||
'is_lock',
|
||||
'is_closed',
|
||||
'is_answered'
|
||||
];
|
||||
const filterdSearchParams = {} as initialSeachParamType;
|
||||
if (Object.keys(initialQuery).length) {
|
||||
for (const key of Object.keys(initialQuery)) {
|
||||
if (paramDict.includes(key)) {
|
||||
filterdSearchParams[key] = initialQuery[key];
|
||||
}
|
||||
}
|
||||
query.value = {
|
||||
...query.value,
|
||||
...filterEmptyObj(filterdSearchParams),
|
||||
enable_query: true
|
||||
};
|
||||
// searchStore 更新
|
||||
// 筛选方式确定后,后端修改下逻辑,不在前端做对应
|
||||
if (filterdSearchParams.title) searchStore.value.title = filterdSearchParams.title;
|
||||
if (filterdSearchParams.sort) searchStore.value.sort = filterdSearchParams.sort;
|
||||
if (filterdSearchParams.label_id) searchStore.value.label_id = filterdSearchParams.label_id;
|
||||
if (filterdSearchParams.is_lock) {
|
||||
if (filterdSearchParams.is_lock === '0') {
|
||||
searchStore.value.filter = '2';
|
||||
} else {
|
||||
searchStore.value.filter = '1';
|
||||
}
|
||||
}
|
||||
if (filterdSearchParams.is_closed) {
|
||||
if (filterdSearchParams.is_closed === '0') {
|
||||
searchStore.value.filter = '4';
|
||||
} else {
|
||||
searchStore.value.filter = '3';
|
||||
}
|
||||
}
|
||||
if (filterdSearchParams.is_answered) {
|
||||
if (filterdSearchParams.is_answered === '0') {
|
||||
searchStore.value.filter = '6';
|
||||
} else {
|
||||
searchStore.value.filter = '5';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
query.value = {
|
||||
...query.value,
|
||||
enable_query: true
|
||||
};
|
||||
}
|
||||
await fetchData(query.value);
|
||||
query.value.initial_query = false;
|
||||
searchStore.value.initial_query = false;
|
||||
};
|
||||
// 更新地址栏
|
||||
const updateURI = (key = '', val = '', resetFilter = false) => {
|
||||
const currentQuery = { ...route.query };
|
||||
if (resetFilter) {
|
||||
delete currentQuery.is_answered;
|
||||
delete currentQuery.is_lock;
|
||||
delete currentQuery.is_closed;
|
||||
}
|
||||
if (key) {
|
||||
currentQuery[key] = val;
|
||||
}
|
||||
const queryParams = filterEmptyObj({ ...currentQuery });
|
||||
router.push({ query: queryParams });
|
||||
};
|
||||
// 更新 query
|
||||
const handleQuery = (mode = '', value = '') => {
|
||||
switch (mode) {
|
||||
case 'title': {
|
||||
query.value = filterEmptyObj({
|
||||
...query.value,
|
||||
title: value,
|
||||
enable_query: true
|
||||
}) as queryType;
|
||||
// updateURI('title', value, false);
|
||||
break;
|
||||
}
|
||||
case 'is_lock': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = { ...temp, [mode]: value, enable_query: true };
|
||||
// updateURI(mode, value, true);
|
||||
break;
|
||||
}
|
||||
case 'is_closed': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = { ...temp, [mode]: value, enable_query: true };
|
||||
// updateURI(mode, value, true);
|
||||
break;
|
||||
}
|
||||
case 'is_answered': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = { ...temp, [mode]: value, enable_query: true };
|
||||
// updateURI(mode, value, true);
|
||||
break;
|
||||
}
|
||||
case 'clear_select': {
|
||||
const temp = { ...query.value };
|
||||
delete temp.is_closed;
|
||||
delete temp.is_lock;
|
||||
delete temp.is_answered;
|
||||
query.value = filterEmptyObj({ ...temp, enable_query: true }) as queryType;
|
||||
// updateURI('', '', true);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
query.value = filterEmptyObj({
|
||||
...query.value,
|
||||
[mode]: value,
|
||||
enable_query: true
|
||||
}) as queryType;
|
||||
// updateURI(mode, value, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 清空
|
||||
const resetQuery = () => {
|
||||
query.value = {
|
||||
page: 1,
|
||||
size: 10,
|
||||
source_id: '',
|
||||
source_type: props.sourceType,
|
||||
enable_query: true,
|
||||
initial_query: false,
|
||||
category_id: ''
|
||||
};
|
||||
searchStore.value = {
|
||||
title: '',
|
||||
label_id: '',
|
||||
sort: '',
|
||||
filter: '',
|
||||
initial_query: false
|
||||
};
|
||||
router.push({ query: {}});
|
||||
};
|
||||
// 获取讨论列表
|
||||
const fetchData = async(currentQuery: queryType) => {
|
||||
loading.value = true;
|
||||
const submitQuery = { ...currentQuery };
|
||||
if (submitQuery.category_id === '' || !submitQuery.category_id) {
|
||||
delete submitQuery.category_id;
|
||||
}
|
||||
delete submitQuery.enable_query;
|
||||
delete submitQuery.initial_query;
|
||||
const res = await discussList({
|
||||
...submitQuery,
|
||||
source_id: source_id.value
|
||||
});
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
total.value = resData.total;
|
||||
discussionList.value = resData.records;
|
||||
}
|
||||
loading.value = false;
|
||||
// enable_query 置回 false
|
||||
query.value = { ...query.value, enable_query: false };
|
||||
};
|
||||
// 监听 query 变化, 获取讨论列表
|
||||
watch(
|
||||
query,
|
||||
(newVal) => {
|
||||
if (newVal.enable_query && !newVal.initial_query) {
|
||||
//
|
||||
document.body.click(); // TODO: 华为pagination bug ,待修复
|
||||
fetchData(newVal);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 跳转新建
|
||||
const goCreate = () => {
|
||||
if (isLogin) {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
|
||||
} else {
|
||||
emitEvent('logout', true);
|
||||
}
|
||||
};
|
||||
const nsId = computed(() => orgStore.communityInfo?.ns_id);
|
||||
|
||||
// 跳转讨论分类管理
|
||||
const goSet = () => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
};
|
||||
const handleCommunity = (item:any) => {
|
||||
if (item.devpress) {
|
||||
const path = item.path.replace('$namespace', props.orgNamespace);
|
||||
window.open(path, '_self');
|
||||
} else router.push({ name: item.name });
|
||||
};
|
||||
const init = async() => {
|
||||
const query = route;
|
||||
if (query.name) communityType.value = query.name;
|
||||
await getDiscussionStatus();
|
||||
// 获取讨论数据
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
await getTypeAndSection();
|
||||
await fetchInitial();
|
||||
getLabels();
|
||||
getAnswerRankList();
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
};
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex container" v-if="discussOpen === '1'">
|
||||
<Card style="padding:0;">
|
||||
<div class="discussion">
|
||||
<div class="tabs flex items-center justify-between">
|
||||
<div class="tabs-nav flex items-center">
|
||||
<div v-for="comty in communityList" :key="comty.name" class="tabs-nav-option" :class="{active:comty.name === communityType}" @click="handleCommunity(comty)"><span>{{comty.label}}</span></div>
|
||||
</div>
|
||||
<div class="right-search pr-3">
|
||||
<div v-if="communityType==='orgDiscussion'">
|
||||
<div class="search">
|
||||
<div class="search-query">
|
||||
<div class="search-query__title">
|
||||
<d-input
|
||||
v-model="searchStore.title"
|
||||
placeholder="搜索讨论"
|
||||
prefix="search"
|
||||
@keydown="handleTitleChange"
|
||||
></d-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="communityType==='orgDiscussion'">
|
||||
<div class="filter-bar flex items-center justify-between bg-CG100 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
<d-select v-if="topicOptions.length>1" v-model="topicActive" class="w-[120px]" placeholder="主题" @value-change="handleTopic">
|
||||
<gc-option
|
||||
v-for="item in topicOptions"
|
||||
:key="item.key"
|
||||
:value="item.key"
|
||||
:name="item.label"
|
||||
></gc-option>
|
||||
</d-select>
|
||||
<d-select v-model="query.category_id" class="w-[180px]" placeholder="分类" @value-change="handleTypeChange">
|
||||
<gc-option
|
||||
v-for="item in subcatalogList"
|
||||
:key="item.key"
|
||||
:value="item.key"
|
||||
:name="item.label"
|
||||
></gc-option>
|
||||
</d-select>
|
||||
<d-select
|
||||
v-model="searchStore.label_id"
|
||||
placeholder="Label"
|
||||
allow-clear
|
||||
class="w-[200px]"
|
||||
v-if="sourceType === 2"
|
||||
>
|
||||
<gc-option
|
||||
v-for="item in labelOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:name="`Label:${item.label}`"
|
||||
>
|
||||
<div class="label-option">
|
||||
<span :style="{ backgroundColor: item.color }" class="label-option__color"></span>
|
||||
<span :title="item.label" class="label-option__label">{{ item.label }}</span>
|
||||
</div>
|
||||
</gc-option>
|
||||
</d-select>
|
||||
<d-select
|
||||
v-model="searchStore.filter"
|
||||
placeholder="状态"
|
||||
class="w-[120px]"
|
||||
allow-clear
|
||||
>
|
||||
<gc-option
|
||||
v-for="item in filterOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:name="item.label"
|
||||
></gc-option>
|
||||
</d-select>
|
||||
<d-select
|
||||
v-model="searchStore.sort"
|
||||
placeholder="排序"
|
||||
class="w-[150px]"
|
||||
allow-clear
|
||||
>
|
||||
<gc-option
|
||||
v-for="item in sortOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:name="item.label"
|
||||
></gc-option
|
||||
></d-select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="access_level >= 50" class="discussion-type text-[14px] cursor-pointer px-1 text-CG600 whitespace-nowrap" @click="goSet"><span>新建类别</span></div>
|
||||
<div class="text-CG600 cursor-pointer whitespace-nowrap" @click="resetQuery"><span>清空筛选</span></div>
|
||||
<d-button @click="goCreate" class="bg-[#fff] whitespace-nowrap"><Icon name="gt-add"/> 新讨论</d-button>
|
||||
</div>
|
||||
</div>
|
||||
<DataPanel
|
||||
skeleton
|
||||
:loading="loading"
|
||||
:empty="!discussionList?.length"
|
||||
animation
|
||||
:card="false"
|
||||
class="discussion-list"
|
||||
>
|
||||
<div class="list-wrapper">
|
||||
<ListItem
|
||||
v-for="item in discussionList"
|
||||
:key="item.id"
|
||||
v-bind="item"
|
||||
:label_dict="labelOptions"
|
||||
:is-login="isLogin"
|
||||
:categoryQuery="query?.category_id!==''"
|
||||
:source-type="sourceType"
|
||||
/>
|
||||
</div>
|
||||
</DataPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- 分页 -->
|
||||
<d-pagination
|
||||
size="md"
|
||||
class="px-[20px] py-[20px] flex justify-center"
|
||||
:total="total"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
v-model:pageSize="query.size"
|
||||
v-model:pageIndex="query.page"
|
||||
:max-items="5"
|
||||
:can-change-page-size="true"
|
||||
:can-view-total="true"
|
||||
total-item-text="总计"
|
||||
auto-hide
|
||||
@page-index-change="query.enable_query = true"
|
||||
@page-size-change="query.enable_query = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
.container {
|
||||
flex-direction: column;
|
||||
padding:0;
|
||||
.discussion{
|
||||
.tabs{
|
||||
height: 60px;
|
||||
border-bottom: 1px solid var(--color-G300);
|
||||
.tabs-nav{
|
||||
.tabs-nav-option{
|
||||
line-height: 58px;
|
||||
color: var(--color-CG600);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
margin: 0 12px;
|
||||
&::after{
|
||||
content: "";
|
||||
display: block;
|
||||
margin: auto;
|
||||
height: 2px;
|
||||
width: 0;
|
||||
background: transparent;
|
||||
//transition: width var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95)),background-color var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95));
|
||||
}
|
||||
&.active{color:var(--color-G900);}
|
||||
&.active::after{
|
||||
content: "";
|
||||
display: block;
|
||||
margin: auto;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: var(--color-G900);
|
||||
//transition: width var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95)),background-color var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95));
|
||||
}
|
||||
&:hover::after{
|
||||
content: "";
|
||||
display: block;
|
||||
margin: auto;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: var(--color-G900);
|
||||
//transition: width var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95)),background-color var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter-bar{
|
||||
.devui-select{
|
||||
position: relative;
|
||||
&:not(:first-child)::after{
|
||||
content:'';
|
||||
position: absolute;
|
||||
top:6px;
|
||||
left:0;
|
||||
height: 18px;
|
||||
width: 0;
|
||||
border-left: 1px solid var(--color-G300);
|
||||
}
|
||||
:deep(.devui-select__selection){
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
.left-menu {
|
||||
width: 300px;
|
||||
margin-right: 32px;
|
||||
overflow-y: scroll;
|
||||
max-height: calc(100vh - 280px);
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.secondary-title {
|
||||
font-size: 16px;
|
||||
color: var(--color-font);
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tertiary-title{
|
||||
height: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #707a87;
|
||||
line-height: 16px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
// 热心榜
|
||||
.answer-rank {
|
||||
margin-top: 25px;
|
||||
.answer-rank-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.line {
|
||||
border-bottom: 1px solid $devui-line;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
&-item {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
&__right {
|
||||
color: $devui-success;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
&-name {
|
||||
height: 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #707a87;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
// 空数据效果
|
||||
&-empty {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #707a87;
|
||||
line-height: 18px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-option {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
&__color {
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&__label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
&-query {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
&__selector {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
:deep(.devui-select){
|
||||
width:150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.discussion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
padding:0 20px;
|
||||
}
|
||||
.left-menu{
|
||||
width:100%;
|
||||
}
|
||||
.search-query{
|
||||
flex-wrap: wrap;
|
||||
&__title{
|
||||
flex:0 0 100%;
|
||||
}
|
||||
&__selector {
|
||||
flex:0 0 100%;
|
||||
:deep(.devui-select){
|
||||
width:0;
|
||||
flex:1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.type-menu {
|
||||
.devui-menu {
|
||||
&-item {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
background-color: $devui-global-bg;
|
||||
&-vertical-wrapper {
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
& > .devui-menu-item {
|
||||
padding-left: 12px !important;
|
||||
}
|
||||
&.layer_1 {
|
||||
& > .devui-menu-item {
|
||||
padding-left: 12px !important;
|
||||
height: 32px;
|
||||
}
|
||||
& + .layer_1 {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
}
|
||||
&.layer_2 {
|
||||
& > .devui-menu-item {
|
||||
padding-left: 34px !important;
|
||||
height: 32px;
|
||||
}
|
||||
& + .layer_2 {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-select {
|
||||
background: var(--color-CG300) !important;
|
||||
&::after {
|
||||
content: '';
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.devui-submenu.layer_2 {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.devui-submenu-title {
|
||||
background-color: $devui-global-bg !important;
|
||||
padding-left: 12px !important;
|
||||
height: 32px !important;
|
||||
line-height: 32px !important;
|
||||
padding-right: 0 !important;
|
||||
& > i {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
&-query {
|
||||
&__selector {
|
||||
/** :deep */
|
||||
.devui-select {
|
||||
&__selection {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
&:first-of-type {
|
||||
.devui-select__selection {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
&:last-of-type {
|
||||
.devui-select__selection {
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
& + .devui-select {
|
||||
position: relative;
|
||||
&:before {
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background-color: #e6e7e8;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
244
src/components/Discussion/Module/SectionCreate/index.vue
Normal file
244
src/components/Discussion/Module/SectionCreate/index.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import EmojiTitle from '@/components/Discussion/Module/components/DiscussionEmojiTitle.vue';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import debounce from 'lodash/debounce';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { getAllTypes, sectionDetail, sectionSave, sectionUpdate } from '@/api/discussion';
|
||||
import type { categoryType } from '@/api/discussion/types';
|
||||
import { debounceTime } from '@/constant/discuss';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { CheckboxGroup } from 'vue-devui/checkbox';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionSectionForm'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sourceType: 1|2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(), {
|
||||
sourceType: 1
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const sectionId = route.params.sectionId as string | undefined;
|
||||
const isEditMode = !!sectionId;
|
||||
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
|
||||
if (!isLogin) router.push('/404');
|
||||
|
||||
// 确认讨论是否开启
|
||||
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
type TypeCheckbox = { name: string; value: unknown };
|
||||
const typeList = ref([] as TypeCheckbox[]);
|
||||
// 获取所有 type
|
||||
const getDiscussionTypeList = async() => {
|
||||
loading.value = true;
|
||||
const res = await getAllTypes({ id: source_id.value, source_type: props.sourceType });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
typeList.value =
|
||||
isArray(resData) && !isEmpty(resData)
|
||||
? resData.map((item: categoryType) => ({
|
||||
name: item.category_name,
|
||||
value: item.id
|
||||
}))
|
||||
: [];
|
||||
if (isEditMode) {
|
||||
await getSectionDetail();
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const group = reactive({
|
||||
icon: '🔥',
|
||||
name: ''
|
||||
});
|
||||
const typeSelectedList = ref([] as TypeCheckbox[]);
|
||||
/**
|
||||
* 编辑组别中,获取当前组别的信息,并填充到 `group` 中
|
||||
*/
|
||||
const getSectionDetail = async() => {
|
||||
const res = await sectionDetail({ id: sectionId });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
group.icon = resData.section_icon;
|
||||
group.name = resData.section_name;
|
||||
typeSelectedList.value = resData.category_list.map((each: categoryType) => ({
|
||||
name: each.category_name,
|
||||
value: each.id
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const intialData = async() => {
|
||||
await getDiscussionStatus();
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
|
||||
if (access_level < 50) {
|
||||
router.replace({
|
||||
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
|
||||
});
|
||||
}
|
||||
await getDiscussionTypeList();
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
};
|
||||
|
||||
intialData();
|
||||
|
||||
const onCreate = debounce(async() => {
|
||||
if (!group.name.trim()) {
|
||||
Message.warning('分组组别名称不能为空');
|
||||
return;
|
||||
} else if (group.name.length > 16) {
|
||||
Message.warning('名称过长');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
const submitData = {
|
||||
section_name: group.name.trim(),
|
||||
section_icon: group.icon,
|
||||
category_id_list: !isEmpty(typeSelectedList.value)
|
||||
? typeSelectedList.value.map((each) => each.value)
|
||||
: [],
|
||||
source_id: source_id.value,
|
||||
source_type: props.sourceType
|
||||
};
|
||||
const res = await sectionSave(submitData);
|
||||
if (!res.error) {
|
||||
Message.success('已新建');
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
}
|
||||
loading.value = false;
|
||||
}, debounceTime);
|
||||
|
||||
const onUpdate = debounce(async() => {
|
||||
if (!group.name.trim()) {
|
||||
Message.warning('分组组别名称不能为空');
|
||||
return;
|
||||
} else if (group.name.length > 16) {
|
||||
Message.warning('名称过长');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
const submitData = {
|
||||
id: sectionId,
|
||||
section_name: group.name.trim(),
|
||||
section_icon: group.icon,
|
||||
category_id_list: !isEmpty(typeSelectedList.value)
|
||||
? typeSelectedList.value.map((each) => each.value)
|
||||
: [],
|
||||
source_id: source_id.value,
|
||||
source_type: props.sourceType
|
||||
};
|
||||
const res = await sectionUpdate(submitData);
|
||||
if (!res.error) {
|
||||
Message.success('已更新');
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
}
|
||||
loading.value = false;
|
||||
}, debounceTime);
|
||||
|
||||
const onCancel = async() => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discussion-section mt-6">
|
||||
<d-breadcrumb>
|
||||
<gc-breadcrumb-item :to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` }"
|
||||
><span class="title">讨论分类管理</span></gc-breadcrumb-item
|
||||
>
|
||||
<gc-breadcrumb-item v-if="!isEditMode"
|
||||
><span class="cur-title">新建组别</span></gc-breadcrumb-item
|
||||
>
|
||||
<gc-breadcrumb-item v-else><span class="cur-title">编辑组别</span></gc-breadcrumb-item>
|
||||
</d-breadcrumb>
|
||||
|
||||
<Card class="mt-6">
|
||||
<h3 class="form-label">分类组别名称</h3>
|
||||
<div class="section-name">
|
||||
<EmojiTitle
|
||||
v-model:icon="group.icon"
|
||||
v-model:title="group.name"
|
||||
placeholder="请输入分类组别名称"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="mt-6 form-label">包含的分类</h3>
|
||||
<p class="mt-4 mb-2 form-tip">注意:每个内容分类只能归属于一个唯一的组别</p>
|
||||
<CheckboxGroup
|
||||
v-model="typeSelectedList"
|
||||
:options="typeList"
|
||||
direction="row"
|
||||
/>
|
||||
<div class="flex gap-2 mt-40">
|
||||
<d-button @click="onCancel">取消</d-button>
|
||||
<d-button :loading="loading" v-if="!isEditMode" variant="solid" color="primary" @click="onCreate" :disabled="group.name.length<1 || group.name.length>16">创建</d-button>
|
||||
<d-button v-else :loading="loading" variant="solid" color="primary" @click="onUpdate" :disabled="group.name.length<1 || group.name.length>16">更新</d-button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
$g-commit-border-radius: 0.25rem;
|
||||
|
||||
.discussion-section {
|
||||
.section-name {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.discussion-create-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9a9b9c;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.cur-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2d2d2e;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #2d2d2e;
|
||||
line-height: 24px;
|
||||
}
|
||||
.form-tip {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #7e7e80;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
154
src/components/Discussion/Module/Select/index.vue
Normal file
154
src/components/Discussion/Module/Select/index.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import DiscussionTypeItem from '../components/DiscussionTypeItem.vue';
|
||||
import { ref } from 'vue';
|
||||
import type {
|
||||
discussionTypeOrSection,
|
||||
categoryType
|
||||
} from '@/api/discussion/types';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getAllTypes } from '@/api/discussion';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
import { Message } from 'vue-devui/message';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionSelectType'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sourceType: 1|2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(), {
|
||||
sourceType: 1
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
|
||||
if (!isLogin) router.push('/404');
|
||||
|
||||
// 确认讨论是否开启
|
||||
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
|
||||
|
||||
const loading = ref(false);
|
||||
const typeList = ref([] as discussionTypeOrSection[]);
|
||||
// 获取所有 type
|
||||
const getDiscussionTypeList = async() => {
|
||||
loading.value = true;
|
||||
const res = await getAllTypes({ id: source_id.value, source_type: props.sourceType });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
const tempData = access_level > 30 ? resData : resData.filter((item:categoryType) => item.category_type !== DISCUSS_FORMAT.ANNOUNCE);
|
||||
// 赋值
|
||||
typeList.value =
|
||||
isArray(resData) && !isEmpty(resData)
|
||||
? tempData.map((item: categoryType) => ({
|
||||
id: item.id,
|
||||
icon: item.category_icon,
|
||||
title: item.category_name,
|
||||
desc: item.category_desc,
|
||||
answerAcceptEnable: item.category_type === 2,
|
||||
isGroup: false
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const initialData = async() => {
|
||||
await getDiscussionStatus();
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
await getDiscussionTypeList();
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
};
|
||||
initialData();
|
||||
|
||||
const onDiscussionCreate = (item: discussionTypeOrSection) => {
|
||||
router.push({
|
||||
name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionCreate`,
|
||||
params: {
|
||||
discussionTypeId: item.id
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const goToTypeManage = () => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discussion-select space-y-20 mt-6">
|
||||
<d-skeleton :loading="loading">
|
||||
<header>
|
||||
<p class="discussion-select__title">请选择你要创建的讨论类型</p>
|
||||
<d-button v-if="access_level>30" variant="solid" color="primary" @click="goToTypeManage">
|
||||
讨论内容分类设置
|
||||
</d-button>
|
||||
</header>
|
||||
<Card simple>
|
||||
<DiscussionTypeItem v-for="item in typeList" :key="item.id" :info="item">
|
||||
<!-- 替换为 emoji -->
|
||||
<template #icon>
|
||||
<span class="emoji-icon">{{ item.icon }}</span>
|
||||
</template>
|
||||
<template #tools>
|
||||
<d-button class="create-btn" size="md" @click="onDiscussionCreate(item)"
|
||||
>发起讨论</d-button
|
||||
>
|
||||
</template>
|
||||
</DiscussionTypeItem>
|
||||
</Card>
|
||||
</d-skeleton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
.discussion-select {
|
||||
& > header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
&__title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #2d2d2e;
|
||||
line-height: 32px;
|
||||
}
|
||||
:deep(.discussion-type-item) {
|
||||
border-top: 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
box-shadow: none;
|
||||
&:first-of-type {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
&:last-of-type {
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 6px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
301
src/components/Discussion/Module/TypeCreate/index.vue
Normal file
301
src/components/Discussion/Module/TypeCreate/index.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { debounceTime, defaultGroup } from '@/constant/discuss';
|
||||
import type { categoryType, sectionType } from '@/api/discussion/types';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import debounce from 'lodash/debounce';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import EmojiTitle from '@/components/Discussion/Module/components/DiscussionEmojiTitle.vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getAllSections, typeDetail, typeSave, typeUpdate } from '@/api/discussion';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
|
||||
import { Message } from 'vue-devui/message';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionTypeForm'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sourceType: 1|2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(), {
|
||||
sourceType: 1
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const typeId = route.params.typeId as string | undefined;
|
||||
const loading = ref(false);
|
||||
const isEditMode = !!typeId;
|
||||
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
|
||||
if (!isLogin) router.push('/404');
|
||||
|
||||
// 确认讨论是否开启
|
||||
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
type SectionRadio = { label: string; value: string };
|
||||
const sectionList = ref([] as SectionRadio[]);
|
||||
// 获取所有 section
|
||||
const getDiscussionSectionList = async() => {
|
||||
loading.value = true;
|
||||
const res = await getAllSections({ source_id: source_id.value, source_type: props.sourceType });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
let allSections = [
|
||||
{
|
||||
label: defaultGroup.label,
|
||||
value: defaultGroup.value
|
||||
}
|
||||
];
|
||||
if (isArray(resData) && !isEmpty(resData)) {
|
||||
allSections = [...allSections].concat(
|
||||
resData.map((item: sectionType) => ({
|
||||
label: item.section_name,
|
||||
value: item.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
sectionList.value = allSections;
|
||||
if (isEditMode) {
|
||||
await getTypeDetail();
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const typeData = ref<categoryType>({
|
||||
id: '',
|
||||
category_icon: '🔥',
|
||||
category_name: '',
|
||||
category_type: DISCUSS_FORMAT.OPEN,
|
||||
section_id: defaultGroup.value,
|
||||
category_desc: ''
|
||||
});
|
||||
|
||||
const discussFormatList = ref([
|
||||
{
|
||||
value: DISCUSS_FORMAT.OPEN,
|
||||
label: '开放讨论形式',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
value: DISCUSS_FORMAT.QANDA,
|
||||
label: '问答形式',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
value: DISCUSS_FORMAT.ANNOUNCE,
|
||||
label: '公告形式',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
value: DISCUSS_FORMAT.VOTE,
|
||||
label: '投票形式',
|
||||
disabled: false
|
||||
}
|
||||
]);
|
||||
|
||||
// 获取内容分类详情
|
||||
const getTypeDetail = async() => {
|
||||
const res = await typeDetail({ id: typeId });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
const { category_icon, category_name, category_type, section_id, category_desc } = resData;
|
||||
typeData.value = {
|
||||
...typeData.value,
|
||||
category_icon,
|
||||
category_name,
|
||||
category_type,
|
||||
section_id: section_id || defaultGroup.value,
|
||||
category_desc
|
||||
};
|
||||
if (category_type === DISCUSS_FORMAT.VOTE) {
|
||||
discussFormatList.value = discussFormatList.value.map((item) => ({
|
||||
...item,
|
||||
disabled: item.value !== DISCUSS_FORMAT.VOTE
|
||||
}));
|
||||
} else {
|
||||
discussFormatList.value = discussFormatList.value.map((item) => ({
|
||||
...item,
|
||||
disabled: item.value === DISCUSS_FORMAT.VOTE
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initialData = async() => {
|
||||
await getDiscussionStatus();
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
|
||||
if (access_level < 50) {
|
||||
router.replace({
|
||||
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
|
||||
});
|
||||
}
|
||||
await getDiscussionSectionList();
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
};
|
||||
|
||||
initialData();
|
||||
|
||||
const onCreate = debounce(async() => {
|
||||
if (!typeData.value.category_name.trim()) {
|
||||
Message.warning('内容分类名称不能为空');
|
||||
return;
|
||||
} else if (typeData.value.category_name.length > 16) {
|
||||
Message.warning('名称过长');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
const { category_name, category_icon, category_desc, section_id, category_type } = typeData.value;
|
||||
const submitData = {
|
||||
category_name: category_name.trim(),
|
||||
category_icon,
|
||||
category_desc,
|
||||
section_id: section_id === 'default' ? '' : section_id,
|
||||
category_type,
|
||||
source_id: source_id.value,
|
||||
source_type: props.sourceType
|
||||
};
|
||||
const res = await typeSave(submitData);
|
||||
if (!res.error) {
|
||||
Message.success('已新建');
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
}
|
||||
loading.value = false;
|
||||
}, debounceTime);
|
||||
|
||||
const onUpdate = debounce(async() => {
|
||||
if (!typeData.value.category_name.trim()) {
|
||||
Message.warning('内容分类名称不能为空');
|
||||
return;
|
||||
} else if (typeData.value.category_name.length > 16) {
|
||||
Message.warning('名称过长');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
const { category_name, category_icon, category_desc, section_id, category_type } = typeData.value;
|
||||
const submitData = {
|
||||
id: typeId,
|
||||
category_name: category_name.trim(),
|
||||
category_icon,
|
||||
category_desc,
|
||||
section_id: section_id === 'default' ? '' : section_id,
|
||||
category_type
|
||||
};
|
||||
const res = await typeUpdate(submitData);
|
||||
if (!res.error) {
|
||||
Message.success('已更新');
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
}
|
||||
loading.value = false;
|
||||
}, debounceTime);
|
||||
|
||||
const onCancel = async() => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discussion-type mt-6">
|
||||
<d-breadcrumb>
|
||||
<gc-breadcrumb-item :to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` }">
|
||||
<span class="title">讨论分类管理</span>
|
||||
</gc-breadcrumb-item>
|
||||
<gc-breadcrumb-item>
|
||||
<span v-if="!isEditMode" class="cur-title">新建内容分类</span>
|
||||
<span v-else class="cur-title">编辑内容分类</span>
|
||||
</gc-breadcrumb-item>
|
||||
</d-breadcrumb>
|
||||
<Card class="mt-6">
|
||||
<h3 class="text-base font-bold">内容分类名称</h3>
|
||||
<p class="mt-4 form-tip">最多支持20个内容分类</p>
|
||||
<div class="type-name">
|
||||
<EmojiTitle
|
||||
v-model:icon="typeData.category_icon"
|
||||
v-model:title="typeData.category_name"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="mt-6 text-base font-bold">分类简介</h3>
|
||||
<div class="type-name">
|
||||
<d-textarea
|
||||
v-model="typeData.category_desc"
|
||||
placeholder="请输入内容分类简介"
|
||||
rows="2"
|
||||
show-count
|
||||
maxlength="2000"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="mt-6 text-base font-bold">讨论形式</h3>
|
||||
<div class="mt-10 form-tip">
|
||||
<p class="mb-10">请从下方选择适合当前内容分类的讨论形式,其中:</p>
|
||||
<ul class="list-disc list-inside">
|
||||
<li class="mb-1">
|
||||
开放讨论形式:不需要对问题给出明确的答案,适合分享技巧和窍门或只是简单的聊天讨论
|
||||
</li>
|
||||
<li class="mb-1">问答形式:针对问题进行讨论,对讨论的问题可以采纳出最佳答案</li>
|
||||
<li class="mb-1">
|
||||
公告形式:只有管理员可以在这些类别中发布新的讨论,但任何人都可以发表评论和回复
|
||||
</li>
|
||||
<li class="mb-1">投票形式:支持投票的方式来收集、了解社区用户的兴趣或偏好</li>
|
||||
</ul>
|
||||
</div>
|
||||
<d-radio-group class="mt-10" direction="row" v-model="typeData.category_type">
|
||||
<d-radio v-for="item in discussFormatList" :key="item.value" :value="item.value" :disabled="item.disabled">
|
||||
<span class="leading-6">{{item.label}}</span>
|
||||
</d-radio>
|
||||
</d-radio-group>
|
||||
<h3 class="mt-6 mb-2 font-bold text-base">分类组别设置</h3>
|
||||
<d-radio-group class="mt-10" direction="row" v-model="typeData.section_id">
|
||||
<d-radio v-for="item in sectionList" :key="item.value" :value="item.value">
|
||||
<span class="leading-6">{{item.label}}</span>
|
||||
</d-radio>
|
||||
</d-radio-group>
|
||||
<div class="flex gap-2 mt-40">
|
||||
<d-button @click="onCancel">取消</d-button>
|
||||
<d-button :loading="loading" variant="solid" color="primary" v-if="!route.params.typeId" @click="onCreate" :disabled="typeData.category_name.length<1 || typeData.category_name.length>16">创建</d-button>
|
||||
<d-button v-else :loading="loading" variant="solid" color="primary" @click="onUpdate" :disabled="typeData.category_name.length<1 || typeData.category_name.length>16">更新</d-button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
|
||||
.discussion-type {
|
||||
.type-name {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.discussion-create-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9a9b9c;
|
||||
line-height: 20px;
|
||||
}
|
||||
.cur-title {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.form-tip {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-light);
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
src/components/Discussion/Module/TypeManage/index.vue
Normal file
310
src/components/Discussion/Module/TypeManage/index.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import DiscussionTypeItem from '../components/DiscussionTypeItem.vue';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { getAllSectionAndTypes, typeDelete, sectionDelete } from '@/api/discussion';
|
||||
import type {
|
||||
discussionTypeOrSection,
|
||||
sectionItemType
|
||||
} from '@/api/discussion/types';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
|
||||
import { orgInfoStore } from '@/stores/Org';
|
||||
import { repoInfoStore } from '@/stores/Repo';
|
||||
|
||||
import MoreList from '@/components/MoreList/index.vue';
|
||||
import { GModal } from '@/components/Setting/index';
|
||||
import { TransAssetsUrl } from '@/utils/asset';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionTypeManage'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sourceType: 1|2; // 组织1,项目2
|
||||
orgNamespace?: string; // 组织namespace
|
||||
}>(), {
|
||||
sourceType: 1
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
|
||||
// 获取当前用户信息 & 是否登录
|
||||
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
|
||||
if (!isLogin) router.push('/404');
|
||||
|
||||
// 确认讨论是否开启
|
||||
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
|
||||
|
||||
const typeAndSectionList = ref<discussionTypeOrSection[]>([]);
|
||||
const allTypes = ref<discussionTypeOrSection[]>();
|
||||
const getTypeAndSection = async() => {
|
||||
// 获取所有内容分类&组别
|
||||
const resData = await getAllSectionAndTypes({ source_id: source_id.value, source_type: props.sourceType });
|
||||
const $typeAndSectionList = [];
|
||||
if (!resData.error) {
|
||||
const data: sectionItemType = resData?.data?.data;
|
||||
const { section, unSection } = data;
|
||||
if (unSection && unSection.length > 0) {
|
||||
for (const each of unSection) {
|
||||
$typeAndSectionList.push({
|
||||
id: each.id,
|
||||
icon: each.category_icon,
|
||||
title: each.category_name,
|
||||
desc: each.category_desc,
|
||||
answerAcceptEnable: each.category_type === DISCUSS_FORMAT.QANDA,
|
||||
isGroup: false,
|
||||
categoryType: each.category_type
|
||||
});
|
||||
}
|
||||
}
|
||||
if (section && section.length > 0) {
|
||||
for (const each of section) {
|
||||
const sectionInfo = {
|
||||
id: each.id,
|
||||
icon: each.section_icon,
|
||||
title: each.section_name,
|
||||
isGroup: true
|
||||
};
|
||||
$typeAndSectionList.push(sectionInfo);
|
||||
const { category_list } = each;
|
||||
if (category_list && category_list.length > 0) {
|
||||
for (const item of category_list) {
|
||||
$typeAndSectionList.push({
|
||||
id: item.id,
|
||||
icon: item.category_icon,
|
||||
title: item.category_name,
|
||||
desc: item.category_desc,
|
||||
answerAcceptEnable: item.category_type === DISCUSS_FORMAT.QANDA,
|
||||
isGroup: false,
|
||||
categoryType: item.category_type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
typeAndSectionList.value = $typeAndSectionList;
|
||||
allTypes.value = $typeAndSectionList.filter((item: discussionTypeOrSection) => !item.isGroup);
|
||||
};
|
||||
|
||||
const initialData = async() => {
|
||||
loading.value = true;
|
||||
await getDiscussionStatus();
|
||||
if (source_id.value && discussOpen.value === '1') {
|
||||
// 获取当前用户项目/组织权限
|
||||
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
|
||||
if (access_level < 50) {
|
||||
router.replace({
|
||||
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
|
||||
});
|
||||
}
|
||||
await getTypeAndSection();
|
||||
} else {
|
||||
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
|
||||
router.replace('/404');
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
initialData();
|
||||
|
||||
const onTypeCreate = () => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionTypeCreate` });
|
||||
};
|
||||
|
||||
const onSectionCreate = () => {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSectionCreate` });
|
||||
};
|
||||
|
||||
const onEdit = (item: discussionTypeOrSection) => {
|
||||
if (item.isGroup) {
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSectionEdit`, params: { sectionId: item.id }});
|
||||
return;
|
||||
}
|
||||
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionTypeEdit`, params: { typeId: item.id }});
|
||||
};
|
||||
|
||||
// 删除
|
||||
const deleteInfo = ref<any>({
|
||||
id: '',
|
||||
visible: false,
|
||||
isGroup: false,
|
||||
title: '',
|
||||
categoryType: 0,
|
||||
transfer_id: '', // type删除后,原type下的讨论,转移到哪个type
|
||||
transferOptions: [],
|
||||
text: '删除',
|
||||
btnLoading: false
|
||||
});
|
||||
const onDelete = (item: discussionTypeOrSection) => {
|
||||
if (!item.isGroup) {
|
||||
if (allTypes.value && allTypes.value.filter(e => e.categoryType !== DISCUSS_FORMAT.VOTE).length <= 1 && item.categoryType !== DISCUSS_FORMAT.VOTE) {
|
||||
Message.warning('至少保留一个非投票分类');
|
||||
return;
|
||||
} else {
|
||||
if (item.categoryType === DISCUSS_FORMAT.VOTE) {
|
||||
// 投票可迁移为其他类型,但投票相关数据会清空,给出弹窗提示
|
||||
deleteInfo.value.transferOptions =
|
||||
allTypes.value && allTypes.value.filter((each) => item.id !== each.id);
|
||||
} else {
|
||||
// 其他类型不可迁移为投票类型
|
||||
deleteInfo.value.transferOptions =
|
||||
allTypes.value && allTypes.value.filter((each) => item.id !== each.id && each.categoryType !== DISCUSS_FORMAT.VOTE);
|
||||
}
|
||||
}
|
||||
}
|
||||
deleteInfo.value = {
|
||||
...deleteInfo.value,
|
||||
id: item.id,
|
||||
visible: true,
|
||||
isGroup: item.isGroup,
|
||||
title: item.title,
|
||||
categoryType: item.categoryType,
|
||||
transfer_id: item.isGroup ? '' : deleteInfo.value.transferOptions[0].id,
|
||||
text: item.isGroup ? '删除' : '删除及转移'
|
||||
};
|
||||
};
|
||||
// 确认删除
|
||||
const onDeleteConfirm = async() => {
|
||||
deleteInfo.value.btnLoading = true;
|
||||
const deleteFn = deleteInfo.value.isGroup ? sectionDelete : typeDelete;
|
||||
const deleteData = {
|
||||
id: deleteInfo.value.id,
|
||||
...(!deleteInfo.value.isGroup && { transfer_id: deleteInfo.value.transfer_id })
|
||||
};
|
||||
const resData = await deleteFn(deleteData);
|
||||
if (!resData.error) {
|
||||
Message.success('已删除');
|
||||
}
|
||||
deleteInfo.value.btnLoading = false;
|
||||
deleteInfo.value.visible = false;
|
||||
getTypeAndSection();
|
||||
};
|
||||
|
||||
const IconSource = (import.meta as any).glob('@/assets/imgs/icon/*svg', { eager: true });
|
||||
const IconSettig = TransAssetsUrl(IconSource, 'icon-setting');
|
||||
const IconDelete = TransAssetsUrl(IconSource, 'icon-delete');
|
||||
const moreOpts = [
|
||||
{
|
||||
svg: IconSettig,
|
||||
icon: 'icon-delete',
|
||||
text: '编辑',
|
||||
handle: onEdit
|
||||
},
|
||||
{
|
||||
svg: IconDelete,
|
||||
icon: 'icon-delete',
|
||||
text: '删除',
|
||||
handle: onDelete
|
||||
}];
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discussion-type-manage space-y-20 my-6">
|
||||
<header class="space-y-24">
|
||||
<div class="header">
|
||||
<d-breadcrumb>
|
||||
<gc-breadcrumb-item :to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion` }"
|
||||
><span class="title">讨论列表</span></gc-breadcrumb-item
|
||||
>
|
||||
<gc-breadcrumb-item><span class="cur-title">讨论分类管理</span></gc-breadcrumb-item>
|
||||
</d-breadcrumb>
|
||||
<div class="space-x-3">
|
||||
<d-button variant="solid" type="primary" @click="onTypeCreate">
|
||||
<Icon name="gt-add" color="white" />新分类
|
||||
</d-button>
|
||||
<d-button variant="solid" type="primary" class="ml-2" @click="onSectionCreate">
|
||||
<Icon name="gt-add" color="white" />新组别
|
||||
</d-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-light mt-6">
|
||||
组别是多个相似内容类型的组合。组别中包含讨论的内容类型,以及每个类型中的讨论内容。
|
||||
</p>
|
||||
</header>
|
||||
<Card simple>
|
||||
<DataPanel :loading="loading" skeleton :empty="!typeAndSectionList.length">
|
||||
<section>
|
||||
<DiscussionTypeItem v-for="item in typeAndSectionList" :key="item.id" :info="item">
|
||||
<template #icon>
|
||||
<span class="emoji-icon">{{ item.icon }}</span>
|
||||
</template>
|
||||
<template #tools>
|
||||
<MoreList :moreOpts="moreOpts" :item="item"></MoreList>
|
||||
</template>
|
||||
</DiscussionTypeItem>
|
||||
</section>
|
||||
</DataPanel>
|
||||
</Card>
|
||||
|
||||
<GModal v-model="deleteInfo.visible" showWarnIcon @confirm="onDeleteConfirm" confirmColor="danger" :title="`确定删除${ deleteInfo.isGroup?'分组':'分类'} ${deleteInfo.title} 吗?`">
|
||||
<div v-if="deleteInfo.isGroup">删除该组别后,该组别下原有的内容分类将变为无组别状态</div>
|
||||
<div v-else>
|
||||
<p class="mb-2">如果该分类下已创建了讨论,你想把它们转移到哪个内容分类下:</p>
|
||||
<d-select class="mb-2" v-model="deleteInfo.transfer_id" :position="['bottom-end', 'top-end']">
|
||||
<gc-option
|
||||
v-for="item in deleteInfo.transferOptions"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
:name="item.title"
|
||||
></gc-option>
|
||||
</d-select>
|
||||
<div v-if="deleteInfo.categoryType === DISCUSS_FORMAT.VOTE" class="text-red-400">
|
||||
<p class="mb-1">警告:</p>
|
||||
<p class>如果投票分类被转移到一个非投票分类,已有的投票结果会被删除。</p>
|
||||
</div>
|
||||
</div>
|
||||
</GModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
|
||||
.discussion-type-manage{
|
||||
:deep(.g-custom-tag) {
|
||||
background-color: unset;
|
||||
color: $devui-aide-text;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
:deep(.g-custom-tag:hover) {
|
||||
color: $devui-text;
|
||||
}
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 24px;
|
||||
:deep(.setting-title) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9a9b9c;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.cur-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2d2d2e;
|
||||
line-height: 20px;
|
||||
}
|
||||
.space-x-2 > *:not(:last-child) {
|
||||
margin-right: 0.55rem;
|
||||
}
|
||||
.emoji-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, ref } from 'vue';
|
||||
defineOptions({
|
||||
name: 'DiscussionEmojiTitle'
|
||||
});
|
||||
const props = withDefaults(defineProps<{
|
||||
icon: string;
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
}>(), {
|
||||
icon: '🔥',
|
||||
title: '',
|
||||
placeholder: '请输入内容分类名称'
|
||||
});
|
||||
defineEmits(['update:icon', 'update:title']);
|
||||
const typeIcons = [
|
||||
'🔥',
|
||||
'📣',
|
||||
'💬',
|
||||
'🛠️',
|
||||
'🚧',
|
||||
'💡',
|
||||
'🗳️',
|
||||
'🙏',
|
||||
'🙌',
|
||||
'🔴',
|
||||
'🚀',
|
||||
'👍',
|
||||
'🎉',
|
||||
'✨',
|
||||
'☀️',
|
||||
'🌳',
|
||||
'🌈',
|
||||
'🌷',
|
||||
'👀',
|
||||
'📘',
|
||||
'📖',
|
||||
'👋',
|
||||
'💯',
|
||||
'📅',
|
||||
'💎'
|
||||
];
|
||||
|
||||
const titleError = ref(false);
|
||||
const validateTitle = () => {
|
||||
if (props.title) {
|
||||
titleError.value = false;
|
||||
} else {
|
||||
titleError.value = true;
|
||||
}
|
||||
};
|
||||
watch(() => props.title, () => {
|
||||
validateTitle();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="emoji-title-wrapper flex gap-2">
|
||||
<d-dropdown :position="['bottom-start']" align="start">
|
||||
<d-button class="cursor-pointer" style="width:32px">{{ icon || '🔥' }}</d-button>
|
||||
<template #menu>
|
||||
<ul class="type-icons">
|
||||
<li
|
||||
class="type-icon"
|
||||
v-for="icon in typeIcons"
|
||||
:key="icon"
|
||||
@click="$emit('update:icon', icon)"
|
||||
>
|
||||
{{ icon }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</d-dropdown>
|
||||
<d-input
|
||||
:model-value="title"
|
||||
class="max-w-[400px]"
|
||||
@update:model-value="$emit('update:title', $event)"
|
||||
@blur="validateTitle"
|
||||
:placeholder="placeholder || '请输入内容分类名称'"
|
||||
:error="titleError"
|
||||
maxLength="16"
|
||||
minLength="1"
|
||||
>
|
||||
<template #suffix>
|
||||
<span>{{title?.length || 0}}/16</span>
|
||||
</template>
|
||||
</d-input>
|
||||
</div>
|
||||
<p class="form-tip" v-if="titleError">名称不能为空</p>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
.type-icons {
|
||||
width: 210px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.type-icon {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid $devui-line;
|
||||
border-radius: 4px;
|
||||
margin: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.form-tip{
|
||||
margin-top: 4px;
|
||||
margin-left: calc(36px + 0.5rem);
|
||||
font-weight: 400;
|
||||
font-size: var(--devui-font-size, 12px);
|
||||
color: var(--devui-danger, #f66f6a);
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { discussUnLike, discussLike } from '@/api/discussion';
|
||||
import { emitEvent } from '@/utils/eventBus';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionLikeBtn'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
isLogin?: boolean; // 是否登录
|
||||
targetId: string;
|
||||
targetType: number;
|
||||
likeTotal: number;
|
||||
isLike: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLogin: true,
|
||||
targetId: '',
|
||||
targetType: 1,
|
||||
likeTotal: 0,
|
||||
isLike: false
|
||||
});
|
||||
|
||||
const basicColor = {
|
||||
border: '#E4E9F0',
|
||||
color: '#7E7E80',
|
||||
bg: '#FFFFFF'
|
||||
};
|
||||
const activeColor = {
|
||||
border: '#9FA7B3',
|
||||
color: '#252D3B',
|
||||
bg: '#F0F2F7'
|
||||
};
|
||||
|
||||
const dynamicStyle = computed(() => {
|
||||
return likeStatus.value
|
||||
? {
|
||||
color: activeColor.color,
|
||||
borderColor: activeColor.border,
|
||||
backgroundColor: activeColor.bg
|
||||
}
|
||||
: {
|
||||
color: basicColor.color,
|
||||
borderColor: basicColor.border,
|
||||
backgroundColor: basicColor.bg
|
||||
};
|
||||
});
|
||||
|
||||
const likeStatus = ref(props.isLike);
|
||||
const total = ref(props.likeTotal);
|
||||
|
||||
const handleClick = debounce(() => {
|
||||
if (props.isLogin) {
|
||||
const data = { target_id: props.targetId, target_type: props.targetType };
|
||||
likeStatus.value = !likeStatus.value;
|
||||
likeStatus.value ? discussLike(data) : discussUnLike(data);
|
||||
} else {
|
||||
// 跳登录
|
||||
emitEvent('logout', true);
|
||||
}
|
||||
}, 300);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="like-btn" :style="dynamicStyle" @click="handleClick">
|
||||
<d-icon name="arrow-up" :color="dynamicStyle.color" class="like-icon" size="12px"></d-icon>
|
||||
<span :class="['like-count', likeStatus ? 'update' : 'down']">
|
||||
<span>{{ isLike ? total - 1 : total }}</span>
|
||||
<span>{{ isLike ? total : total + 1 }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.like-btn {
|
||||
padding: 4px 8px;
|
||||
height: 24px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 12px;
|
||||
min-width: 48px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
.like-icon {
|
||||
position: relative;
|
||||
}
|
||||
.like-count {
|
||||
position: relative;
|
||||
transition: all 0.5s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 50%;
|
||||
&.down {
|
||||
transform: perspective(1px) translateY(0%);
|
||||
}
|
||||
&.update {
|
||||
transform: perspective(1px) translateY(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import Sortable from 'sortablejs';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionPollForm'
|
||||
});
|
||||
|
||||
const randomName = () => {
|
||||
const num = Math.random() * (9999 - 1000) + 1000;
|
||||
const time = new Date().getTime();
|
||||
return `${time}_${Math.ceil(num)}`;
|
||||
};
|
||||
|
||||
interface optionType {
|
||||
id: string | number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: optionType[];
|
||||
title?: string;
|
||||
titleEmpty?: boolean;
|
||||
validOptions?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits(['pollOptions', 'update:title']);
|
||||
|
||||
let sortableEl: any = null;
|
||||
const list = ref<optionType[]>();
|
||||
// defaultValue 不满足需求时,初始化两个选项
|
||||
if (!props.defaultValue || !props.defaultValue[0]) {
|
||||
const temp = [
|
||||
{ id: randomName(), value: '' },
|
||||
{ id: randomName(), value: '' }
|
||||
];
|
||||
list.value = temp.slice(0);
|
||||
} else {
|
||||
list.value = props.defaultValue.slice(0);
|
||||
}
|
||||
|
||||
const handleDelete = (item: optionType) => {
|
||||
const $list = isArray(list.value) && !isEmpty(list.value) ? list.value.slice(0) : [];
|
||||
$list.splice(
|
||||
$list.findIndex((e: optionType) => e.id === item.id),
|
||||
1
|
||||
);
|
||||
list.value = $list;
|
||||
};
|
||||
|
||||
const handleAddOption = () => {
|
||||
if (isArray(list.value) && !isEmpty(list.value)) {
|
||||
list.value.push({ id: randomName(), value: '' });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const el = document.getElementById('poll-options');
|
||||
sortableEl = new Sortable(el, {
|
||||
group: 'poll-options',
|
||||
handle: '.handle',
|
||||
filter: '.ignore-item',
|
||||
draggable: '.poll-options-item',
|
||||
onMove(evt: Sortable.MoveEvent) {
|
||||
const { dragged, related } = evt;
|
||||
/* 没有填写的 禁拖 */
|
||||
if (!dragged?.querySelector('input').value) return true;
|
||||
if (related?.querySelector('input').value) {
|
||||
/* 相邻没有填写的 禁拖 */
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onSort(evt: Sortable.SortableEvent) {
|
||||
// 保存排序数据
|
||||
if (list.value && isArray(list.value) && !isEmpty(list.value)) {
|
||||
const $list = [...list.value];
|
||||
const sep = $list.splice(evt.oldIndex, 1);
|
||||
$list.splice(evt.newIndex, 0, sep[0]);
|
||||
list.value = $list;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
onUnmounted(() => {
|
||||
sortableEl?.destroy();
|
||||
});
|
||||
|
||||
watch(
|
||||
list,
|
||||
(newList, oldList) => {
|
||||
emit('pollOptions', newList);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-base font-bold mb-4">投票问题</p>
|
||||
<d-input
|
||||
:model-value="props.title"
|
||||
@update:model-value="$emit('update:title', $event)"
|
||||
:error="titleEmpty"
|
||||
placeholder="请输入你要发起的投票问题(必填)"
|
||||
maxLength="64"
|
||||
></d-input>
|
||||
<p v-if="titleEmpty" class="poll-validate-title">投票问题不能为空</p>
|
||||
<p class="text-base font-bold mb-4 mt-6">投票选项</p>
|
||||
<p class="poll-tip mb-2">
|
||||
注意:请提供至少两个投票选项;如果编辑已投票的选项,系统将清空之前已投票的历史数据。
|
||||
</p>
|
||||
<p v-if="validOptions" class="poll-validate-options">至少提供两个不为空的投票选项</p>
|
||||
<div id="poll-options">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
class="poll-options-item flex justify-between mt-2 mb-2 gap-1"
|
||||
>
|
||||
<d-button
|
||||
:class="[item.value ? '' : 'ignore-item', 'handle']"
|
||||
:style="{ cursor: item.value ? 'move' : 'not-allowed' }"
|
||||
icon="drag"
|
||||
></d-button>
|
||||
<d-input
|
||||
:placeholder="index < 2 ? `选项${index + 1}(必填)` : '请设置选项值…'"
|
||||
name="qtitle"
|
||||
v-model="item.value"
|
||||
maxLength="64"
|
||||
></d-input>
|
||||
<d-button
|
||||
variant="text"
|
||||
@click="handleDelete(item)"
|
||||
:style="{ visibility: index < 2 ? 'hidden' : undefined }"
|
||||
>
|
||||
<Icon name="gt-delete" color="var(--color-lighter)" />
|
||||
</d-button>
|
||||
</div>
|
||||
<div class="poll-add-option" @click="handleAddOption">
|
||||
<d-icon name="add" class="mr-1" size="16px"></d-icon><span>增加选项</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poll-tip {
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
color: #7e7e80;
|
||||
}
|
||||
.poll-add-option {
|
||||
height: 32px;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px dashed var(--color-border-light);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
width: calc(100% - 58px);
|
||||
margin-left: 36px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.poll-validate-title,
|
||||
.poll-validate-options {
|
||||
margin-top: 4px;
|
||||
display: inline-block;
|
||||
min-height: 20px;
|
||||
line-height: 1.5;
|
||||
font-size: var(--devui-font-size, 12px);
|
||||
color: var(--devui-danger, #f66f6a);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import type { discussionTypeOrSection } from '@/api/discussion/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionTypeItem'
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
info: discussionTypeOrSection;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discussion-type-item" :class="{ 'group clearfix': info.isGroup }">
|
||||
<div class="middle space-y-2">
|
||||
<div class="title space-x-2">
|
||||
<slot name="icon"><d-icon :name="info.icon"></d-icon></slot>
|
||||
<span class="ellipsis font-bold">{{ info.title }}</span>
|
||||
<span v-if="info.answerAcceptEnable" class="answer-tag">可采纳回答</span>
|
||||
</div>
|
||||
<p class="ellipsis text-sm text-light" v-if="info.desc">{{ info.desc }}</p>
|
||||
</div>
|
||||
<div class="right">
|
||||
<slot name="tools"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'devui-theme/styles-var/devui-var.scss';
|
||||
.discussion-type-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
&.group {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
.left {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid $devui-line;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.group .left {
|
||||
border: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.middle {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width:0;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #2D2D2E;
|
||||
min-width:0;
|
||||
}
|
||||
.right {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.answer-tag {
|
||||
display:inline-block;
|
||||
padding:4px 8px;
|
||||
color:#0EB07B;
|
||||
background-color: rgba(14, 176, 123,0.1);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #0EB07B;
|
||||
line-height: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import FilterDropDown from '@/components/FilterDropDown/index.vue';
|
||||
import type { IOption } from '@/components/FilterDropDown/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionFilterLabel'
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
optionList:IOption[];
|
||||
selectedList:IOption[],
|
||||
submitIng?:boolean
|
||||
}>(), {
|
||||
optionList: () => [],
|
||||
selectedList: () => []
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-option-click': [item: IOption | null]
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const key = ref('');
|
||||
|
||||
// 转化了一下 selectList,加上 isLabel 属性
|
||||
const _selectedList = computed<IOption[]>(() => {
|
||||
if (props.selectedList[0]) {
|
||||
return props?.selectedList?.map(item => {
|
||||
return {
|
||||
...item,
|
||||
isLabel: true
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const _optionList = ref<any[]>();
|
||||
|
||||
watch(key, (newVal) => {
|
||||
if (newVal.trim()) {
|
||||
const filteredList = props.optionList.filter((item) => !!(item.label && item.label.match(newVal.trim())));
|
||||
_optionList.value = filteredList.map(item => {
|
||||
return {
|
||||
...item,
|
||||
isLabel: true
|
||||
};
|
||||
});
|
||||
} else {
|
||||
_optionList.value = props.optionList.map(item => {
|
||||
return {
|
||||
...item,
|
||||
isLabel: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const onOptionClick = (option:IOption | null) => {
|
||||
emit('on-option-click', option);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FilterDropDown
|
||||
v-model="key"
|
||||
title="关联 Label"
|
||||
:optionList="_optionList"
|
||||
:selectedList="_selectedList"
|
||||
:submitIng="submitIng"
|
||||
placeholder="搜索 Label…"
|
||||
emptyText="未设置 Label"
|
||||
:show-empty-option="false"
|
||||
hidden-tab
|
||||
:optionClickScope="false"
|
||||
:loading="loading"
|
||||
@on-option-click="onOptionClick"
|
||||
>
|
||||
</FilterDropDown>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import FilterDropDown from '@/components/FilterDropDown/index.vue';
|
||||
import type { IOption } from '@/components/FilterDropDown/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionFilterType'
|
||||
});
|
||||
|
||||
withDefaults(defineProps<{
|
||||
optionList:IOption[];
|
||||
selectedList:IOption[],
|
||||
submitIng?:boolean
|
||||
}>(), {
|
||||
optionList: () => [],
|
||||
selectedList: () => []
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-option-click': [item: IOption | null]
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const onOptionClick = (option:IOption | null) => {
|
||||
emit('on-option-click', option);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FilterDropDown
|
||||
hiddenInpout
|
||||
title="更换讨论分类"
|
||||
:optionList="optionList"
|
||||
:selectedList="selectedList"
|
||||
:submitIng="submitIng"
|
||||
placeholder="搜索讨论分类"
|
||||
emptyText="未设置讨论分类"
|
||||
hidden-tab
|
||||
optionClickScope
|
||||
:loading="loading"
|
||||
@on-option-click="onOptionClick"
|
||||
>
|
||||
</FilterDropDown>
|
||||
</template>
|
||||
|
||||
410
src/components/Discussion/Module/components/Sidebar/index.vue
Normal file
410
src/components/Discussion/Module/components/Sidebar/index.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Message } from 'vue-devui/message';
|
||||
import { DISCUSS_FORMAT } from '@/constant/discuss';
|
||||
import type {
|
||||
discussDetailType,
|
||||
categoryType,
|
||||
userInfoType
|
||||
} from '@/api/discussion/types';
|
||||
import {
|
||||
discussDetailRecentActiveUsers,
|
||||
getAllTypes,
|
||||
orgLabelList,
|
||||
repoLabelList,
|
||||
discussChangeRelatedLabels,
|
||||
discussChangeType,
|
||||
discussPin,
|
||||
disussTypePin,
|
||||
disussionLock,
|
||||
discussDelete
|
||||
} from '@/api/discussion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import AsideSetSkeleton from '@/views/Repo/components/AsideSetSkeleton.vue';
|
||||
import DiscussFilterLabel from './components/DiscussFilterLabel.vue';
|
||||
import DiscussFilterType from './components/DiscussFilterType.vue';
|
||||
import LabelTag from '@/components/LabelTag/index.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { IOption } from '@/components/FilterDropDown/types';
|
||||
|
||||
import { GModal } from '@/components/Setting/index';
|
||||
|
||||
defineOptions({
|
||||
name: 'DiscussionSidebar'
|
||||
});
|
||||
|
||||
// label接口数据结构
|
||||
interface labelType {
|
||||
color: string;
|
||||
description: string;
|
||||
id: number;
|
||||
name: string;
|
||||
text_color: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sourceType: 1 | 2; // 组织1,项目2
|
||||
discussDetail?: discussDetailType | undefined; // 讨论详情
|
||||
access_level?: number; // 用户权限
|
||||
userInfo?: userInfoType; // 用户信息
|
||||
sourceId: string; // 项目|组织id
|
||||
isDetail?: boolean; // 是否在详情页使用
|
||||
}>(), {
|
||||
sourceType: 1,
|
||||
access_level: 0,
|
||||
sourceId: '',
|
||||
isDetail: false
|
||||
});
|
||||
const emit = defineEmits(['updateDiscuss', 'createDiscuss']);
|
||||
|
||||
const loading = ref(false);
|
||||
const router = useRouter();
|
||||
|
||||
// 获取当前用户修改分类-权限
|
||||
const typeHandleAccess = computed(() => {
|
||||
if (props.access_level > 30) {
|
||||
return 'admin';
|
||||
} else if (props.access_level === 30) {
|
||||
return 'developer';
|
||||
} else return '';
|
||||
});
|
||||
|
||||
// 获取当前讨论类型-全信息
|
||||
const checkedType = ref<IOption[]>([]);
|
||||
const allTypes = ref<IOption[]>([]);
|
||||
// 获取所有内容分类
|
||||
const fetchAllTypes = async () => {
|
||||
checkedType.value = [{
|
||||
value: props.discussDetail?.category?.id as string,
|
||||
name: props.discussDetail?.category?.category_name as string,
|
||||
label: props.discussDetail?.category?.category_name
|
||||
}];
|
||||
const res = await getAllTypes({ id: props.sourceId, source_type: props.sourceType });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data;
|
||||
let temp = null;
|
||||
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE) {
|
||||
temp = resData.filter((e: categoryType) => e.category_type === DISCUSS_FORMAT.VOTE);
|
||||
} else {
|
||||
temp = typeHandleAccess.value === 'admin' ? resData.filter((e: categoryType) => e.category_type !== DISCUSS_FORMAT.VOTE) : resData.filter((e: categoryType) => e.category_type !== DISCUSS_FORMAT.VOTE && e.category_type !== DISCUSS_FORMAT.ANNOUNCE);
|
||||
}
|
||||
allTypes.value = temp.map((item: categoryType) => ({
|
||||
value: item.id,
|
||||
name: item.category_name,
|
||||
label: item.category_name
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const onTypeOptionClick = async (data: IOption) => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
const res = await discussChangeType({ id: props.discussDetail?.id, category_id: data.value });
|
||||
if (!res.error) {
|
||||
checkedType.value = [data];
|
||||
emit('updateDiscuss');
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// 获取讨论里最近活跃用户
|
||||
interface recentActiveUserType {
|
||||
id: string;
|
||||
photo: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
}
|
||||
const recentActiveUsers = ref<recentActiveUserType[]>([]);
|
||||
const fetchRecentActiveUsers = async () => {
|
||||
const res = await discussDetailRecentActiveUsers({ source_id: props.discussDetail?.id as string });
|
||||
if (!res.error) {
|
||||
const data = res?.data?.data;
|
||||
recentActiveUsers.value = data.map((item: recentActiveUserType) => ({
|
||||
...item
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
fetchRecentActiveUsers
|
||||
});
|
||||
|
||||
// 获取已选中的标签-全信息
|
||||
const checkedLabels = ref<IOption[]>([]);
|
||||
|
||||
const sourceLabels = ref<IOption[]>([]);
|
||||
// 已登录用户获取标签列表
|
||||
const fetchLabelList = async () => {
|
||||
if (props.sourceType === 1) {
|
||||
// const res = await orgLabelList({ project_id: props.sourceId });
|
||||
// if (!res.error) {
|
||||
// const resData = res?.data?.data?.content;
|
||||
// if (resData.length > 0) {
|
||||
// sourceLabels.value = resData.map((item:labelType) => (
|
||||
// {
|
||||
// value: item.id.toString(),
|
||||
// name: item.name,
|
||||
// label: item.name,
|
||||
// color: item.color
|
||||
// }
|
||||
// ));
|
||||
// }
|
||||
// }
|
||||
// TODO: 组织目前没label
|
||||
return;
|
||||
} else {
|
||||
const res = await repoLabelList({ project_id: props.sourceId });
|
||||
if (!res.error) {
|
||||
const resData = res?.data?.data?.content;
|
||||
if (resData.length > 0) {
|
||||
sourceLabels.value = resData.map((item: labelType) => (
|
||||
{
|
||||
value: item.id.toString(),
|
||||
name: item.name,
|
||||
label: item.name,
|
||||
color: item.color
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 checkedLabels 初始值
|
||||
const initialCheckedLabels = () => {
|
||||
if (sourceLabels.value?.length > 0) {
|
||||
if (props.isDetail) {
|
||||
// 详情初始化label数据
|
||||
if (props.discussDetail?.label && props.discussDetail.label?.length > 0) {
|
||||
const $checkedLabels = sourceLabels.value.filter(e => props.discussDetail?.label?.includes(e.value as string));
|
||||
checkedLabels.value = $checkedLabels;
|
||||
} else {
|
||||
checkedLabels.value = [];
|
||||
}
|
||||
} else {
|
||||
// 新建页
|
||||
checkedLabels.value = [];
|
||||
}
|
||||
} else {
|
||||
checkedLabels.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const initialData = async () => {
|
||||
if (props.discussDetail?.id) {
|
||||
await fetchAllTypes();
|
||||
fetchRecentActiveUsers();
|
||||
}
|
||||
await fetchLabelList();
|
||||
initialCheckedLabels();
|
||||
};
|
||||
|
||||
initialData();
|
||||
|
||||
const onLabelOptionClick = async (data: IOption) => {
|
||||
if (!props.isDetail) {
|
||||
// 新建讨论页
|
||||
let temp = checkedLabels.value.length > 0 ? checkedLabels.value : [];
|
||||
const index = temp?.findIndex((e) => e.value === data?.value);
|
||||
if (!data) {
|
||||
// 无数据 点击了未设置,清空
|
||||
temp = [];
|
||||
} else if (index > -1) {
|
||||
// 删除了该 label
|
||||
temp.splice(index, 1);
|
||||
} else {
|
||||
// 新增了一个 label
|
||||
temp.push(data);
|
||||
}
|
||||
checkedLabels.value = temp;
|
||||
emit('createDiscuss', checkedLabels.value.length > 0 ? checkedLabels.value.map(item => item.value) : []);
|
||||
} else {
|
||||
// 详情页
|
||||
if (loading.value) return;
|
||||
let tempIds = checkedLabels.value.map(item => item.value) || [];
|
||||
const index = tempIds?.findIndex((e) => e === data?.value);
|
||||
if (!data) {
|
||||
// 无数据 点击了未设置,清空
|
||||
tempIds = [];
|
||||
} else if (index > -1) {
|
||||
// 删除了该 label
|
||||
tempIds.splice(index, 1);
|
||||
} else {
|
||||
// 新增了一个 label
|
||||
tempIds.push(data.value as string);
|
||||
}
|
||||
loading.value = true;
|
||||
checkedLabels.value = tempIds.length > 0 ? sourceLabels.value.filter(e => tempIds.includes(e.value as string)) : [];
|
||||
await discussChangeRelatedLabels({ discuss_id: props.discussDetail?.id as string, label: tempIds as string[] });
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showHandleBtns = computed(() => {
|
||||
if (props.isDetail && (props.access_level > 30 || (props.userInfo?.id && props.discussDetail?.created_by === props.userInfo.id))) { return true; } else return false;
|
||||
});
|
||||
const lockModalVisible = ref(false);
|
||||
const lockLoading = ref(false);
|
||||
const debounceHandleLock = debounce(() => handleLock(), 600);
|
||||
const handleLock = async () => {
|
||||
const res = await disussionLock({
|
||||
id: props.discussDetail?.id,
|
||||
is_lock: props.discussDetail?.is_lock === 1 ? 0 : 1
|
||||
});
|
||||
if (!res.error) {
|
||||
Message.success(props.discussDetail?.is_lock === 1 ? '已解锁' : '已锁定');
|
||||
emit('updateDiscuss');
|
||||
lockModalVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteModalVisible = ref(false);
|
||||
const deleteLoading = ref(false);
|
||||
const handleDelete = async () => {
|
||||
const res = await discussDelete({ id: props.discussDetail?.id as string });
|
||||
if (!res.error) {
|
||||
deleteModalVisible.value = false;
|
||||
Message.success('已删除讨论');
|
||||
router.push({
|
||||
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePin = async () => {
|
||||
const res = await discussPin({
|
||||
id: props.discussDetail?.id as string,
|
||||
is_pin: props.discussDetail?.is_pin === 1 ? 0 : 1
|
||||
});
|
||||
if (!res.error) {
|
||||
Message.success(props.discussDetail?.is_pin === 0 ? '已置顶' : '已取消置顶');
|
||||
emit('updateDiscuss');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypePin = async () => {
|
||||
const res = await disussTypePin({
|
||||
id: props.discussDetail?.id as string,
|
||||
is_pin: props.discussDetail?.is_category_pin === 1 ? 0 : 1
|
||||
});
|
||||
if (!res.error) {
|
||||
Message.success(props.discussDetail?.is_category_pin === 0 ? '已分类置顶' : '已取消分类置顶');
|
||||
emit('updateDiscuss');
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="isDetail" class="sidebar-type mb-8">
|
||||
<AsideSetSkeleton title="讨论类型" :visibleDivider="false" :visible-set="!!typeHandleAccess">
|
||||
<p class="sidber-subtitle" v-if="checkedType?.length > 0">
|
||||
{{ checkedType[0]?.name }}
|
||||
</p>
|
||||
<template #menu>
|
||||
<DiscussFilterType @on-option-click="onTypeOptionClick" :option-list="allTypes" :selected-list="checkedType"
|
||||
:showEmptyOption="false" :submit-ing="loading">
|
||||
</DiscussFilterType>
|
||||
</template>
|
||||
</AsideSetSkeleton>
|
||||
</div>
|
||||
<!-- TODO: 组织有 label 后,移除v-if判断 -->
|
||||
<div v-if="sourceType === 2" class="sidebar-label mb-4">
|
||||
<AsideSetSkeleton title="Label" :visibleDivider="false" :visible-set="access_level >= 30">
|
||||
<span class="sidebar-subtitle" v-if="checkedLabels.length === 0">暂未设置 Label</span>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<LabelTag v-for="item in checkedLabels" :key="item.value" :color="item.color" :name="item.label" class="mr-1">
|
||||
</LabelTag>
|
||||
</div>
|
||||
<template #menu>
|
||||
<DiscussFilterLabel @on-option-click="onLabelOptionClick" :option-list="sourceLabels"
|
||||
:selected-list="checkedLabels" :submit-ing="loading"></DiscussFilterLabel>
|
||||
</template>
|
||||
</AsideSetSkeleton>
|
||||
</div>
|
||||
<div class="sidebar-participant mb-8" v-if="isDetail && recentActiveUsers.length > 0">
|
||||
<p class="mb-2">参与者</p>
|
||||
<div class="flex flex-wrap">
|
||||
<GLink v-for="item in recentActiveUsers" :key="item.id"
|
||||
:to="{ name: 'homepage', params: { namespace: item.username } }" target="_blank" class="mr-1 participant-link">
|
||||
<d-tooltip :content="item.username">
|
||||
<GAvatar :src="item.photo" :name="item.username" class="participant-avatar" :width="32" :height="32">
|
||||
</GAvatar>
|
||||
</d-tooltip>
|
||||
</GLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-handle-btns" v-if="showHandleBtns">
|
||||
<!-- <d-button variant="text">订阅讨论动态</d-button>
|
||||
<d-button variant="text">取消订阅讨论动态</d-button> -->
|
||||
<div class="mt-2 sidebar-subtitle" v-if="access_level > 30">
|
||||
<d-button variant="text" v-if="discussDetail?.is_lock === 0" @click="lockModalVisible = true">
|
||||
<Icon name="gt-lock" size="14px" class="mr-1" style="color:inherit"></Icon>锁定讨论
|
||||
</d-button>
|
||||
<d-button variant="text" v-else @click="handleLock">
|
||||
<Icon name="gt-openlock" size="14px" class="mr-1" style="color:inherit"></Icon>解锁讨论
|
||||
</d-button>
|
||||
</div>
|
||||
<div class="mt-2 sidebar-subtitle" v-if="access_level > 30">
|
||||
<d-button variant="text" @click="handlePin" v-if="discussDetail?.is_pin === 0">
|
||||
<Icon name="gt-to-top" size="16px" class="mr-1" style="color:inherit"></Icon>置顶讨论
|
||||
</d-button>
|
||||
<d-button variant="text" @click="handlePin" v-else>
|
||||
<Icon name="gt-to-bottom" size="16px" class="mr-1" style="color:inherit"></Icon>取消置顶
|
||||
</d-button>
|
||||
</div>
|
||||
<div class="mt-2 sidebar-subtitle" v-if="access_level > 30">
|
||||
<d-button variant="text" @click="handleTypePin" v-if="discussDetail?.is_category_pin === 0">
|
||||
<Icon name="gt-to-top" size="16px" class="mr-1" style="color:inherit"></Icon>置顶至当前分类
|
||||
</d-button>
|
||||
<d-button variant="text" v-else @click="handleTypePin">
|
||||
<Icon name="gt-to-bottom" size="16px" class="mr-1" style="color:inherit"></Icon>取消当前分类置顶
|
||||
</d-button>
|
||||
</div>
|
||||
<div class="mt-2 sidebar-subtitle">
|
||||
<d-button variant="text" @click="deleteModalVisible = true">
|
||||
<Icon name="gt-delete" size="16px" class="mr-1" style="color:inherit"></Icon>删除讨论
|
||||
</d-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GModal v-model="lockModalVisible" showWarnIcon @confirm="handleLock"
|
||||
:title="discussDetail?.is_lock === 1 ? '解锁讨论' : '锁定讨论'">
|
||||
<p v-if="discussDetail?.is_lock === 1">确定解锁讨论?</p>
|
||||
<p v-else>讨论锁定后,不允许非成员用户发表评论及投票。</p>
|
||||
</GModal>
|
||||
|
||||
<GModal v-model="deleteModalVisible" showWarnIcon @confirm="handleDelete" confirmColor="danger" title="删除讨论">
|
||||
<p>你确定要删除该讨论吗?</p>
|
||||
</GModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sidber-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2D2D2E;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #707A87;
|
||||
line-height: 20px;
|
||||
|
||||
:deep(.button-content) {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #707A87;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-handle-btns {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 16px;
|
||||
}
|
||||
</style>
|
||||
35
src/components/EmptyData/index.vue
Normal file
35
src/components/EmptyData/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<d-result class="py-5" icon="info" :title="title" :desc="desc" v-if="!hideIcon">
|
||||
<template #icon>
|
||||
<Animation name="dataEmpty" width="120px" height="120px" />
|
||||
</template>
|
||||
</d-result>
|
||||
<div class="text-center text-G600 py-20" v-else> {{ title }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Animation from '@/components/Animation/index.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string,
|
||||
desc?: string,
|
||||
icon?: string,
|
||||
hideIcon: boolean
|
||||
}>(),
|
||||
{
|
||||
title: '暂无数据',
|
||||
desc: '',
|
||||
hideIcon: true
|
||||
}
|
||||
);
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.devui-result__extra) {
|
||||
margin-top: 0;
|
||||
}
|
||||
:deep(.devui-result__title) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
35
src/components/Error/config.ts
Normal file
35
src/components/Error/config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import icon403 from '@/assets/imgs/error/403@2x.png';
|
||||
import icon404 from '@/assets/imgs/error/404@2x.png';
|
||||
import icon503 from '@/assets/imgs/error/503@2x.png';
|
||||
|
||||
const defaultErrorCode = 404;
|
||||
const errorConfig = {
|
||||
403: {
|
||||
animation: 'error403',
|
||||
avatar: icon403,
|
||||
title: '抱歉,你无权访问',
|
||||
desc: '',
|
||||
operationList: [
|
||||
{ text: '返回首页', href: '/', variant: 'solid', color: 'primary' }
|
||||
]
|
||||
},
|
||||
404: {
|
||||
animation: 'error404',
|
||||
avatar: icon404,
|
||||
title: '抱歉,你访问的页面找不到了',
|
||||
desc: '请返回首页继续浏览',
|
||||
operationList: [
|
||||
{ text: '返回首页', href: '/', variant: 'solid', color: 'primary' }
|
||||
]
|
||||
},
|
||||
503: {
|
||||
animation: 'error503',
|
||||
avatar: icon503,
|
||||
title: '抱歉,服务器维护中',
|
||||
desc: '服务器暂时维护中,请等待'
|
||||
}
|
||||
};
|
||||
|
||||
export const getErrorConfig = (errorCode: string|number) => {
|
||||
return errorConfig[errorCode] || errorConfig[defaultErrorCode];
|
||||
};
|
||||
45
src/components/Error/index.vue
Normal file
45
src/components/Error/index.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="error-page flex " :class="isMobile ? 'flex-col gap-[32px]' : 'h-[240px] gap-[64px]'">
|
||||
<Animation :name="error.animation" width="240px" height="240px" />
|
||||
<div class="bg-CG300 w-[1px]" v-if="!isMobile"></div>
|
||||
<div class="flex flex-col" :class="isMobile ? 'items-center' : 'justify-center'">
|
||||
<img :class="isMobile ? 'w-[108px] height-[40px]' : 'w-[216px] height-[80px]'" :src="error.avatar" />
|
||||
<div :class="isMobile ? 'text-[20px] mt-3' : 'text-[32px] mt-6'">{{ error.title }}</div>
|
||||
<div class="text-light mt-2">{{ error.desc }}</div>
|
||||
<div class="flex gap-4 mt-6 operation-list" :class="isMobile ? 'mt-8' : ''" v-if="error.operationList?.length">
|
||||
<GLink v-for="item in error.operationList" :key="item.text" :href="item.href">
|
||||
<d-button v-bind="item">
|
||||
{{ item.text }}
|
||||
</d-button>
|
||||
</GLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { getErrorConfig } from './config';
|
||||
import Animation from '@/components/Animation/index.vue';
|
||||
import { usePageResize } from '@/utils/hooks/usePageResize';
|
||||
const { isMobile } = usePageResize();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
code: string | number
|
||||
}>(), {
|
||||
code: '404'
|
||||
});
|
||||
|
||||
const error = computed(() => getErrorConfig(props.code));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error-page {
|
||||
.operation-list {
|
||||
:deep(.devui-button) {
|
||||
width: 160px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
166
src/components/EventAdapter/index.vue
Normal file
166
src/components/EventAdapter/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<!-- 项目事件 -->
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue';
|
||||
interface Event {
|
||||
eventData:any;
|
||||
};
|
||||
const visibilityMap = new Map([
|
||||
[0, '私有'],
|
||||
[20, '公开']
|
||||
]);
|
||||
const LockStatus = (state:any) => {
|
||||
return state ? '锁定' : '未锁定';
|
||||
};
|
||||
const DeleteStatus = (state:any) => {
|
||||
return state ? '已删除' : '未删除';
|
||||
};
|
||||
const switchArr = (arr:any[]) => {
|
||||
return arr[0] && !arr[1] ? '关闭' : '开启';
|
||||
};
|
||||
const props = defineProps<Event>();
|
||||
const { title } = toRefs(props.eventData);
|
||||
title.value = typeof title.value === 'string' && /^[\[\{/]/.test(title.value) ? JSON.parse(title.value) : title.value;
|
||||
</script>
|
||||
<template>
|
||||
<div class="detail">
|
||||
<div v-if="eventData.target_type==='MergeRequest'" class="merge">
|
||||
<slot name="mergeRequest" :dataRaw="eventData">
|
||||
<div class="text-xs mb-1"><span>源分支:</span><span>{{eventData.merge_request_info.source_branch}}</span></div>
|
||||
<div class="text-xs mb-1"><span>目标分支:</span><span>{{ eventData.merge_request_info.target_branch }}</span></div>
|
||||
<div class="flex items-center text-xs" v-if="eventData.target_title">
|
||||
<Icon name="gt-tooltip" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<GLink :href="eventData._links?.action_type">
|
||||
<span class="break-all">{{ eventData.target_title }}</span>
|
||||
</GLink>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="eventData.target_type==='Issue'" class="issue">
|
||||
<slot name="Issue" :dataRaw="eventData">
|
||||
<div class="text-xs">
|
||||
<GLink :href="eventData._links?eventData._links.action_type:'#'">
|
||||
<span class="break-all">{{ eventData.target_title }}</span>
|
||||
</GLink>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="eventData.target_type==='Label'" class="label">
|
||||
<slot name="Label" :dataRaw="eventData">
|
||||
<div class="flex items-center text-xs">
|
||||
<Icon name="icon-add-label" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<GLink :href="eventData._links?eventData._links.action_type:'#'">
|
||||
<span class="break-all">{{ eventData.target_title }}</span>
|
||||
</GLink>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="eventData.target_type==='Note'" class="comment">
|
||||
<slot name="Note" :dataRaw="eventData">
|
||||
<template v-if="eventData.note">
|
||||
<div>
|
||||
<GLink :href="eventData._links?eventData._links.action_type:'#'">
|
||||
<span>{{ eventData.note.body }}</span>
|
||||
</GLink>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center text-xs">
|
||||
<Icon name="gt-tooltip" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<span class="break-all">{{ eventData.target_title }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="eventData.target_type==='Milestone'" class="miles-stone">
|
||||
<slot name="Milestone" :dataRaw="eventData">
|
||||
<div class="flex items-center text-xs">
|
||||
<Icon name="gt-milestone" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<GLink :href="eventData._links?.action_type"><span class="font-[600]">{{ eventData.target_title }}</span></GLink>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="eventData.target_type==='Project'" class="repo">
|
||||
<slot name="Project" :dataRaw="eventData">
|
||||
<template v-if="title">
|
||||
<div v-if="title.name" class="text-xs break-all"><span>项目名称</span><span class="mx-1">{{title.name[0]}}</span>改为<span class="mx-1">{{title.name[1]}}</span></div>
|
||||
<div v-if="title.path" class="text-xs"><span>项目路径</span><span class="mx-1">{{title.path[0]}}</span>改为<span class="mx-1">{{title.path[1]}}</span></div>
|
||||
<div v-if="title.description" class="text-xs"><span>项目描述</span><span class="mx-1">{{title.description[0]}}</span>改为<span class="mx-1">{{title.description[1]}}</span></div>
|
||||
<div v-if="title.namespace" class="text-xs"><span>项目所属</span><span class="mx-1">{{title.namespace[0]}}</span>改为<span class="mx-1">{{title.namespace[1]}}</span></div>
|
||||
<div v-if="title.archived" class="text-xs"><span>项目锁定</span><span class="mx-1">{{LockStatus(title.archived[0])}}</span>改为<span class="mx-1">{{LockStatus(title.archived[1])}}</span></div>
|
||||
<div v-if="title.pending_delete" class="text-xs"><span>删除状态</span><span class="mx-1">{{DeleteStatus(title.pending_delete[0])}}</span>改为<span class="mx-1">{{DeleteStatus(title.pending_delete[1])}}</span></div>
|
||||
<div v-if="title.visibility_level" class="text-xs">
|
||||
<span>项目权限</span>
|
||||
<span class="mx-1">{{visibilityMap.get(title.visibility_level[0])}}</span>改为
|
||||
<span class="mx-1">{{visibilityMap.get(title.visibility_level[1])}}</span>
|
||||
</div>
|
||||
<div v-if="title.lfs_enabled" class="text-xs"><span>{{ switchArr(title.lfs_enabled) }}</span><span>Git LFS</span></div>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="eventData.target_type==='Group'" class="org">
|
||||
<slot name="Group" :dataRaw="eventData">
|
||||
<div v-if="eventData.group">
|
||||
<div class="flex items-center text-xs">
|
||||
<GAvatar :src="eventData.group.avatar_url" :name="eventData.group.name" :width="24" :height="24" :is_round="false" class="cursor-pointer mr-2"></GAvatar>
|
||||
<div class="break-all">
|
||||
<GLink :href="eventData._links?.group">
|
||||
<span class="break-all">{{ eventData.group.name }}</span>
|
||||
</GLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="eventData.group.description" class="flex items-center text-xs mt-2">
|
||||
<span class="break-all text-CG500">{{ eventData.group.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="eventData.target_type===null" class="other">
|
||||
<template v-if="eventData.action_name ==='pushed to'">
|
||||
<div class="text-xs"><span>目标分支:</span><span class="break-all">{{eventData.push_data.ref }}</span></div>
|
||||
<div class="flex items-center text-xs">
|
||||
<Icon name="gt-tooltip" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<span class="break-all">{{ eventData.push_data.commit_title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="(eventData.action_name ==='pushed new'||eventData.action_name ==='deleted')&&eventData.push_data">
|
||||
<div v-if="eventData.push_data.ref_type==='branch'">
|
||||
<div class="text-xs"><span>分支名称:</span><span class="break-all">{{eventData.push_data.ref}}</span></div>
|
||||
<div class="flex items-center text-xs" v-if="eventData.push_data.commit_title">
|
||||
<Icon name="gt-tooltip" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<span class="break-all">{{ eventData.push_data.commit_title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="eventData.push_data.ref_type==='tag'">
|
||||
<div class="text-xs mb-1"><span>Tag名称:</span><span class="break-all">{{eventData.push_data.ref}}</span></div>
|
||||
<div class="flex items-center text-xs" v-if="eventData.push_data.commit_title">
|
||||
<Icon name="gt-tooltip" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<span class="break-all">{{ eventData.push_data.commit_title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="eventData.action_name ==='created'">
|
||||
<div class="text-xs">
|
||||
<span>创建了项目</span>
|
||||
<GLink :href="eventData._links?.project"><span class="break-all">{{eventData.project_name}}</span></GLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="eventData.action_name ==='batch delete branches'">批量删除了分支
|
||||
</template>
|
||||
<template v-if="eventData.action_name ==='imported'||eventData.action_name ==='left'||eventData.action_name ==='joined'">
|
||||
<div class="text-xs" v-if="eventData.project_name"><span>项目名称:</span><span class="break-all">{{eventData.project_name}}</span></div>
|
||||
<div class="text-xs" v-else><span>组织名称:</span><span class="break-all">{{eventData.project_name}}</span></div>
|
||||
</template>
|
||||
<template v-if="eventData.action_name ==='change member'&&eventData.title">
|
||||
<div class="text-xs"><span>操作人:</span><span>{{eventData.title.username.join(",")}}</span></div>
|
||||
<div class="flex items-center text-xs" v-if="eventData.title.change">
|
||||
<Icon name="gt-tooltip" size="14px" class="mr-1 my-[2px]"></Icon>
|
||||
<span class="break-all">{{ eventData.title.change }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="eventData.action_name ==='removed due to membership expiration from'">
|
||||
<div><span class="break-all">由于有效期到期,{{eventData.author_username}}已自动退出{{ eventData.project_name }}</span></div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 其它的类型 -->
|
||||
<slot name="expandType" :dataRaw="eventData"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
9
src/components/FileIcon/README.md
Normal file
9
src/components/FileIcon/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|------------|-----------------------------------------------|---------|-------------------------------- |---------|
|
||||
| type | icon的类型,可以传入映射对象对应的key值,也可以直接传入可被组件识别的icon name | string | — | "folder" |
|
||||
| title? | 文件名称 | string | — | "标题" |
|
||||
| showTitle? | 是否显示文案 | boolean | — | true |
|
||||
| size? | icon的尺寸 | string | — | "18px" |
|
||||
| link? | 需要跳转的路由地址 | string | — | — |
|
||||
109
src/components/FileIcon/index.vue
Normal file
109
src/components/FileIcon/index.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="g-file-icon">
|
||||
<slot name="icon">
|
||||
<Icon :name="iconName" :size="size" class="mr-1" />
|
||||
</slot>
|
||||
<slot v-if="showTitle" name="title">
|
||||
<div class="g-file-item">
|
||||
<!-- submodule -->
|
||||
<template v-if="file?.type === 'commit'">
|
||||
<a :href="file.submodule_url" target="_blank" class="g-file-icon-link">
|
||||
<span class="g-file-icon-title" :title="title">
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</a>
|
||||
<a :href="file.submodule_link" target="_blank" class="g-file-icon-link text-light">
|
||||
<span class="g-file-icon-title" :title="title">
|
||||
@{{ file.blob_id.slice(0, 8) }}
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
<!-- link -->
|
||||
<router-link v-else-if="link" :to="routeLink" class="g-file-icon-link">
|
||||
<span class="g-file-icon-title" :title="title">
|
||||
{{ title }}
|
||||
</span>
|
||||
</router-link>
|
||||
<!-- text -->
|
||||
<span v-else class="g-file-icon-title" :title="title">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useFile } from '@/utils/hooks/useFile';
|
||||
import { useEscapeBranchName } from '@/utils/hooks/usebranchName';
|
||||
const { getIcon, getFolderIcon, getFileIcon } = useFile();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type: string;
|
||||
title?: string;
|
||||
// 是否显示文案
|
||||
showTitle?: boolean;
|
||||
size?: string;
|
||||
// 路由链接
|
||||
link?: string;
|
||||
maxWidth?: number;
|
||||
file?: object;
|
||||
}>(),
|
||||
{
|
||||
type: 'folder',
|
||||
title: '标题',
|
||||
showTitle: true,
|
||||
size: '18px',
|
||||
link: ''
|
||||
}
|
||||
);
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.file) {
|
||||
return getIcon(props.file);
|
||||
}
|
||||
return props.type === 'folder' ? getFolderIcon() : getFileIcon(props.title);
|
||||
});
|
||||
|
||||
const routeLink = computed(() => {
|
||||
return useEscapeBranchName(props.link);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.g-file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
&-link {
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
line-height: 1.5;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.g-file-item{
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}</style>
|
||||
140
src/components/FileSearch/Modal.vue
Normal file
140
src/components/FileSearch/Modal.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<d-modal
|
||||
v-model="visible"
|
||||
class="file-search-modal"
|
||||
:show-close="false"
|
||||
:style="{ width: '800px', height: '400px' }"
|
||||
close-on-click-overlay
|
||||
@close="handleClose"
|
||||
>
|
||||
<d-input ref="searchInput" placeholder="请输入关键词" suffix="search" clearable @input="handleSearch" />
|
||||
<div class="file-search-modal-list mt-4" ref="fileListRef" v-loading="loading">
|
||||
<gc-option
|
||||
v-for="(item, index) in fileList"
|
||||
:key="item"
|
||||
class="file-search-modal-list-item"
|
||||
:class="{active: index === searchIndex}"
|
||||
@click="handleFileSelect(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</gc-option>
|
||||
</div>
|
||||
<template #header></template>
|
||||
<template #footer></template>
|
||||
</d-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
list: Array<string>,
|
||||
loading?: boolean
|
||||
}>();
|
||||
|
||||
const fileList = ref([]); // 过滤后的文件列表
|
||||
watch(() => props.list, () => {
|
||||
fileList.value = props.list;
|
||||
});
|
||||
const fileFilter = (queryStr = '') => {
|
||||
queryStr = queryStr?.trim().toLowerCase();
|
||||
fileList.value = props.list.filter(item => item?.toLowerCase()?.includes(queryStr));
|
||||
};
|
||||
|
||||
const emit = defineEmits(['onSelect', 'onFocus']);
|
||||
// 前端加载全量文件,前端完成检索逻辑
|
||||
const handleFocus = async(forceLoad?: boolean) => {
|
||||
emit('onFocus', typeof forceLoad === 'boolean' ? forceLoad : false);
|
||||
};
|
||||
|
||||
const handleSearch = query => {
|
||||
resetSearchIndex();
|
||||
fileFilter(query);
|
||||
};
|
||||
|
||||
const resetFilter = () => {
|
||||
fileFilter();
|
||||
fileListRef.value?.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetFilter();
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const handleFileSelect = file => {
|
||||
handleClose();
|
||||
|
||||
emit('onSelect', file);
|
||||
};
|
||||
|
||||
const searchIndex = ref(0);
|
||||
const resetSearchIndex = () => {
|
||||
searchIndex.value = 0;
|
||||
};
|
||||
|
||||
const visible = ref(false);
|
||||
const searchInput = ref(null);
|
||||
const fileListRef = ref(null);
|
||||
|
||||
// 注册页面监听ctrl + p
|
||||
useEventListener(document, 'keydown', e => {
|
||||
if (!visible.value) {
|
||||
if (e?.key === 'p' && e.ctrlKey) {
|
||||
visible.value = true;
|
||||
resetSearchIndex();
|
||||
|
||||
// 打开modal触发onFocus加载数据
|
||||
handleFocus();
|
||||
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else {
|
||||
if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
|
||||
e.key === 'ArrowDown' ? (searchIndex.value++) : (searchIndex.value--);
|
||||
|
||||
if (searchIndex.value < 0) {
|
||||
resetSearchIndex();
|
||||
} else if (searchIndex.value >= fileList.value.length) {
|
||||
searchIndex.value = fileList.value.length - 1;
|
||||
}
|
||||
|
||||
fileListRef.value?.querySelector('.active')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
|
||||
e.stopPropagation();
|
||||
} else if (e.key === 'Enter') {
|
||||
const curFile = fileList.value[searchIndex.value];
|
||||
curFile && handleFileSelect(curFile);
|
||||
|
||||
visible.value = false;
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-search-modal {
|
||||
width: 800px;
|
||||
min-height: 400px;
|
||||
&-list {
|
||||
min-height: 300px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
:deep(.devui-loading__mask) {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
129
src/components/FileSearch/index.vue
Normal file
129
src/components/FileSearch/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<d-select
|
||||
ref="fileSearchRef"
|
||||
v-model="fileSearchKey"
|
||||
allow-clear
|
||||
:filter="fileFilter"
|
||||
:loading="loading"
|
||||
remote
|
||||
placeholder="搜索文件名、后缀ctrl+p"
|
||||
@focus="handleFocus"
|
||||
@toggle-change="handleToggleChange"
|
||||
>
|
||||
<div class="g-file-search-list" ref="fileListRef">
|
||||
<Option
|
||||
v-for="(item, index) in fileList"
|
||||
:key="item"
|
||||
class="g-file-search-list-item"
|
||||
:class="{active: searchIndex === index}"
|
||||
:value="item"
|
||||
@click="handleFileSelect(item)"
|
||||
>
|
||||
<div>{{ item }}</div>
|
||||
</Option>
|
||||
</div>
|
||||
<template #empty>
|
||||
<div v-if="loading" class="flex-center py-2 opacity-50">
|
||||
数据加载中...
|
||||
</div>
|
||||
<div v-else-if="!query && !fileList?.length" class="flex-center py-2 opacity-50">
|
||||
找不到相关记录
|
||||
</div>
|
||||
</template>
|
||||
</d-select>
|
||||
<SearchModal v-bind="$props" @onFocus="handleFocus" @onSelect="handleFileSelect" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { Option } from 'vue-devui/select';
|
||||
|
||||
import SearchModal from './Modal.vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
list: Array<string>,
|
||||
loading?: boolean
|
||||
}>();
|
||||
|
||||
const fileSearchKey = ref('');
|
||||
const fileList = ref(props.list); // 过滤后的文件列表
|
||||
watch(() => props.list, () => {
|
||||
fileList.value = props.list;
|
||||
});
|
||||
const fileFilter = (queryStr = '') => {
|
||||
setSearchIndex(0);
|
||||
|
||||
queryStr = queryStr?.trim().toLowerCase();
|
||||
fileList.value = props.list.filter(item => item?.toLowerCase()?.includes(queryStr));
|
||||
};
|
||||
|
||||
const emit = defineEmits(['onSelect', 'onFocus']);
|
||||
const fileSearchRef = ref(null);
|
||||
const dropdown = ref(false);
|
||||
const handleFileSelect = file => {
|
||||
dropdown.value = false;
|
||||
fileSearchKey.value = '';
|
||||
fileSearchRef.value.blur();
|
||||
|
||||
setTimeout(() => {
|
||||
fileSearchRef.value.toggleChange(false);
|
||||
emit('onSelect', file);
|
||||
});
|
||||
};
|
||||
// 前端加载全量文件,前端完成检索逻辑
|
||||
const handleFocus = async(forceLoad?: boolean) => {
|
||||
dropdown.value = true;
|
||||
|
||||
emit('onFocus', typeof forceLoad === 'boolean' ? forceLoad : false);
|
||||
};
|
||||
const handleToggleChange = isShow => {
|
||||
dropdown.value = isShow;
|
||||
};
|
||||
|
||||
// 注册页面监听
|
||||
const searchIndex = ref(0);
|
||||
const setSearchIndex = index => {
|
||||
searchIndex.value = index;
|
||||
if (index >= fileList.value.length) {
|
||||
searchIndex.value = fileList.value.length - 1;
|
||||
}
|
||||
if (index < 0) {
|
||||
searchIndex.value = 0;
|
||||
}
|
||||
|
||||
fileListRef.value?.querySelector('.active')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
};
|
||||
|
||||
const fileListRef = ref(null);
|
||||
useEventListener(document, 'keydown', e => {
|
||||
if (dropdown.value) {
|
||||
if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
|
||||
const index = e.key === 'ArrowDown' ? (searchIndex.value + 1) : (searchIndex.value - 1);
|
||||
setSearchIndex(index);
|
||||
|
||||
e.stopPropagation();
|
||||
} else if (e.key === 'Enter') {
|
||||
const curFile = fileList.value[searchIndex.value];
|
||||
curFile && handleFileSelect(curFile);
|
||||
|
||||
dropdown.value = false;
|
||||
setSearchIndex(0);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.g-file-search-list {
|
||||
&-item {
|
||||
&.active {
|
||||
background-color: var(--color-border-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
src/components/FileTree/index.vue
Normal file
82
src/components/FileTree/index.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<d-tree ref="treeRef" v-bind="$attrs">
|
||||
<template #loading></template>
|
||||
<template #icon></template>
|
||||
<template #content="{ nodeData }">
|
||||
<div>
|
||||
<span class="inline-block w-5 mr-1">
|
||||
<d-icon v-if="nodeData.loading" name="loading" class="spin" />
|
||||
<Icon v-else :name="getIcon(nodeData)" />
|
||||
</span>
|
||||
<span :title="nodeData.name">{{ nodeData.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</d-tree>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { useFile } from '@/utils/hooks/useFile';
|
||||
|
||||
const { getIcon } = useFile();
|
||||
|
||||
const treeRef = ref(null);
|
||||
const getTree = () => treeRef.value.treeFactory;
|
||||
const getTreeNodes = () => getTree().treeData.value;
|
||||
|
||||
const getTreeNodeById = id => getTreeNodes().find(item => item.id === id);
|
||||
const appendChildrenById = (id, nodeList) => {
|
||||
const parent = getTreeNodeById(id);
|
||||
const tree = getTree();
|
||||
nodeList?.forEach(node => {
|
||||
tree.insertBefore(parent, node);
|
||||
});
|
||||
};
|
||||
|
||||
const getTreeNodeByPath = path => getTreeNodes().find(item => item.path === path);
|
||||
const appendChildrenByPath = (path, nodeList) => {
|
||||
const parent = getTreeNodeByPath(path);
|
||||
const tree = getTree();
|
||||
nodeList?.forEach(node => {
|
||||
tree.insertBefore(parent, node);
|
||||
});
|
||||
};
|
||||
|
||||
const removeNodeById = id => {
|
||||
const node = getTreeNodeById(id);
|
||||
getTree().removeNode(node);
|
||||
};
|
||||
|
||||
const setNodeSelectedByPath = path => {
|
||||
getTreeNodes().forEach(node => {
|
||||
node.selected = node.path === path;
|
||||
});
|
||||
};
|
||||
|
||||
// 递归展开树
|
||||
const expandNodeByPath = path => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = getTreeNodeByPath(path);
|
||||
node && (node.expanded = true);
|
||||
|
||||
path = path.split('/');
|
||||
path.pop();
|
||||
expandNodeByPath(path.join('/'));
|
||||
};
|
||||
|
||||
// 建议:如下暴露的树节点增删改查函数,需要在data赋值后、在setTimeout中执行
|
||||
defineExpose({
|
||||
getTree,
|
||||
getTreeNodes,
|
||||
getTreeNodeById,
|
||||
appendChildrenById,
|
||||
getTreeNodeByPath,
|
||||
appendChildrenByPath,
|
||||
removeNodeById,
|
||||
setNodeSelectedByPath,
|
||||
expandNodeByPath
|
||||
});
|
||||
</script>
|
||||
39
src/components/FilterDropDown/CustomOption.vue
Normal file
39
src/components/FilterDropDown/CustomOption.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<gc-option :disabled="disabled" :value="value" class="custom-option">
|
||||
<!-- 勾选图案 未勾选隐藏 -->
|
||||
|
||||
<span class="custom-option-content">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<slot name="right"></slot>
|
||||
<Icon :style="{ visibility: selected ? 'visible' : 'hidden', opacity: disabled ? 0.5 : 1, marginLeft: 5 }" name="gt-success" size="14" color="var(--color-success)" :key="num"/>
|
||||
|
||||
</gc-option>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'CustomOption' });
|
||||
import { watch, ref } from 'vue';
|
||||
import type { IOption } from './types';
|
||||
|
||||
const props = defineProps<IOption>();
|
||||
const num = ref(0);
|
||||
watch(() => props.selected, () => num.value++);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-option {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
&-content {
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
4
src/components/FilterDropDown/README.md
Normal file
4
src/components/FilterDropDown/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
### 筛选组件
|
||||
- 分支筛选,分支tag切换;
|
||||
- label筛选
|
||||
- 人员筛选
|
||||
270
src/components/FilterDropDown/index.vue
Normal file
270
src/components/FilterDropDown/index.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="g-filter-dropdown custom-filter g-card">
|
||||
<slot v-if="showOptionHeader" name="header">
|
||||
<!-- 自定义header -->
|
||||
<div class="custom-filter-header">
|
||||
<h6>{{ title }}</h6>
|
||||
<d-icon name="icon-close" class="close-btn" @click="handleClose"></d-icon>
|
||||
</div>
|
||||
</slot>
|
||||
<hr class="hr" />
|
||||
<slot name="create"></slot>
|
||||
<slot name="input">
|
||||
<!-- 自定义input -->
|
||||
<div class="custom-filter-input" v-if="!hiddenInpout">
|
||||
<d-input :placeholder="placeholder" :modelValue="modelValue" @input="emit('update:modelValue', $event)"
|
||||
@focus="emit('input-focus')" @blur="emit('input-blur')" @change="$emit('input-change', $event)"
|
||||
@keydown="emit('input-keydown')" :maxlength="100"><template #suffix><span class='mx-1'>{{ modelValue?.length || 0}}/100</span></template></d-input>
|
||||
</div>
|
||||
</slot>
|
||||
<hr class="hr" v-if="!hiddenInpout" />
|
||||
<slot name="tab">
|
||||
<!-- 自定义 tab -->
|
||||
<div class="custom-filter-tab" v-if="!hiddenTab">
|
||||
<CustomTab class="custom-filter-tabbox" :defaultValue="defaultSwitchValue" :list="tabList" type="wrapped"
|
||||
@active-tab-change="$emit('active-tab-change', $event)"></CustomTab>
|
||||
</div>
|
||||
</slot>
|
||||
<div v-loading="submitIng">
|
||||
<div class="custom-filter-options" ref="options" @scroll="listenScroll" v-loading="loading">
|
||||
<!-- 自定义 option-group -->
|
||||
<slot name="d-option">
|
||||
<slot name="empty" v-if="showEmptyOption">
|
||||
<CustomOption v-show="optionList.length !== 0" @click="handleOptionClick(null)" value="" :selected="false" :disabled="false">
|
||||
{{ emptyText }}
|
||||
</CustomOption>
|
||||
</slot>
|
||||
|
||||
<CustomOption v-for="item in optionList" :value="item.value" :key="item.value" @click="handleOptionClick(item)"
|
||||
:selected="selectedList.some((obj) => obj.value === item.value)" :disabled="item.disabled">
|
||||
<!-- 用户 -->
|
||||
<template v-if="item.username">
|
||||
<GAvatar :name="pickNickName(item)" :src="item.avatar_url || item.avatar" :width="18" :height="18" class="avatar-style mr-2"></GAvatar>
|
||||
</template>
|
||||
<!-- label -->
|
||||
<template v-if="item.isLabel">
|
||||
<span class="circle mr-2" :style="['background-color:' + item.color]"></span>
|
||||
</template>
|
||||
|
||||
<span class="custom-filter-option-name" :title="item.name">{{ item.name }}</span>
|
||||
<span class="custom-filter-option-username " v-if="item.username">@{{ item.username }}</span>
|
||||
<template #right>
|
||||
<!-- 分支标签 -->
|
||||
<CustomTag borderColor="#C9D2D9" bgColor="#fff" v-if="item.tag">{{
|
||||
item.tag
|
||||
}}</CustomTag>
|
||||
</template>
|
||||
</CustomOption>
|
||||
<div v-show="loading" class="leading-10 text-center text-CG600" style="line-height: 3rem;"> </div>
|
||||
<div v-show="!loading && optionList.length === 0" class="leading-10 text-center text-CG600" style="line-height: 3rem;">暂无数据</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 查看全部分支 -->
|
||||
<slot name="option-footer">
|
||||
<div class="custom-filter-footer">
|
||||
<CustomOption v-loading="loading" v-if="showOptionFooter" @click="handleOptionClick('footer')" value="" :selected="false"
|
||||
:disabled="false">
|
||||
<div style="text-align: center">{{ optionFooterText }}</div>
|
||||
</CustomOption>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import CustomOption from './CustomOption.vue';
|
||||
import CustomTab from '@/components/CustomTab/index.vue';
|
||||
import CustomTag from '@/components/CustomTag/index.vue';
|
||||
import { pickNickName } from '@/utils';
|
||||
interface IOption {
|
||||
value: string | number // 必填,选项唯一标识
|
||||
name: string // 可选,选项显示内容
|
||||
disabled?: boolean // 可选,禁用单个选项
|
||||
selected?: boolean // option 是否选中
|
||||
label?: string // 标签
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string // 双向绑定 input value 数据
|
||||
title: string // 标题
|
||||
placeholder?: string // input placeholder text
|
||||
loading?: boolean // 加载
|
||||
optionList: IOption[] // 选项列表
|
||||
selectedList: IOption[] // 已勾选项 选中的option 单选|多选
|
||||
hiddenInpout?: boolean // 展示input
|
||||
hiddenTab?: boolean // 展示 tab
|
||||
defaultSwitchValue?: string // 展示 tab switch
|
||||
emptyText?: string // option 首个空位描述
|
||||
showEmptyOption?:boolean // 是否展示首个空位
|
||||
optionClickScope?: boolean // 点击 d-optopn 关闭
|
||||
optionFooterText?: string // d-option 最后一个 文案
|
||||
showOptionFooter?: boolean, // 展示最后一个 d-option
|
||||
showOptionHeader?: boolean // 展示头部
|
||||
submitIng?: boolean
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
placeholder: '请输入…',
|
||||
optionList: () => [],
|
||||
selectedList: () => [],
|
||||
loading: false,
|
||||
emptyText: '未设置',
|
||||
showEmptyOption: true,
|
||||
defaultSwitchValue: 'branch',
|
||||
showOptionHeader: true,
|
||||
optionFooterText: '查看全部分支',
|
||||
submitIng: false,
|
||||
optionClickScope: true
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
input: [value: string]
|
||||
'update:modelValue': [value: string]
|
||||
'input-focus': []
|
||||
'input-blur': []
|
||||
'input-keydown': []
|
||||
'input-change': [value: string] // 输入框失去焦点或按下回车时触发
|
||||
'active-tab-change': [value: string]
|
||||
'on-option-click': [item: IOption | null]
|
||||
'touch-bottom':[] // 触底
|
||||
}>();
|
||||
|
||||
const tabList = ref([
|
||||
{ id: 'branch', icon: 'gt-branches', title: '分支' },
|
||||
{ id: 'tag', icon: 'gt-tag', title: '标签' }
|
||||
]);
|
||||
|
||||
function handleClose() {
|
||||
document.body.click();
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function handleOptionClick(item: IOption) {
|
||||
if (item?.disabled) return;
|
||||
if (props.optionClickScope) document.body.click();
|
||||
emit('on-option-click', item);
|
||||
}
|
||||
|
||||
function listenScroll(event:Event) {
|
||||
const { offsetHeight, scrollHeight, scrollTop } = event.target;
|
||||
if (offsetHeight + scrollTop >= scrollHeight - 37 && !props.loading) {
|
||||
emit('touch-bottom');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-filter {
|
||||
width: 298px;
|
||||
max-height: 450px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
|
||||
.hr {
|
||||
margin: 0;
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&-input {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
&-options {
|
||||
height: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&-tab {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
&-tabbox {
|
||||
:deep(li) {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.devui-tabs__nav-content) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-option-username {
|
||||
font-weight: 400;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #4b4b4b;
|
||||
}
|
||||
.avatar-style {
|
||||
vertical-align: middle;
|
||||
min-width: 18px;
|
||||
}
|
||||
|
||||
&-options-empty {
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
opacity: .5;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.g-filter-dropdown {
|
||||
.custom-filter-tab {
|
||||
height: unset;
|
||||
.devui-tabs__nav {
|
||||
border-bottom: none;
|
||||
li {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
&:first-of-type {
|
||||
border-left: none;
|
||||
}
|
||||
&:last-of-type {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-filter-option-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
8
src/components/FilterDropDown/types.ts
Normal file
8
src/components/FilterDropDown/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type IOption ={
|
||||
value: string | number // 必填,选项唯一标识
|
||||
name: string // 可选,选项显示内容
|
||||
disabled?: boolean // 可选,禁用单个选项
|
||||
selected?: boolean // option 是否选中
|
||||
label?: string // 标签
|
||||
[propName:string]:any
|
||||
}
|
||||
79
src/components/FoldEllipsis/index.vue
Normal file
79
src/components/FoldEllipsis/index.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="g-fold-ellipsis" :class="{ fold: !isFold }" ref="RefDev">
|
||||
<span class="g-fold-ellipsis-text" :style="{ width: textWidth }" :class="{ 'whitespace-normal': !isFold }">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<span class="g-fold-ellipsis-switch" v-if="visible" @click="isFold = !isFold">
|
||||
<d-icon :name="isFold ? 'icon-chevron-down' : 'icon-chevron-up'" class="cursor-pointer"></d-icon>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'FoldEllipsis' });
|
||||
import { ref, onMounted } from 'vue';
|
||||
defineProps<{
|
||||
content: string;
|
||||
}>();
|
||||
const isFold = ref(true);
|
||||
const visible = ref(false);
|
||||
const textWidth = ref('auto');
|
||||
const RefDev = ref();
|
||||
const resolveFold = () => {
|
||||
const el = RefDev.value;
|
||||
if (!el) return;
|
||||
const containerHeight = el.clientHeight;
|
||||
const containerWidth = el.clientWidth;
|
||||
const parentWidth = el.parentElement.offsetWidth || el.parentElement.clientWidth;
|
||||
|
||||
// 创建一个临时元素
|
||||
const tempElement = document.createElement('span');
|
||||
tempElement.style.visibility = 'hidden';
|
||||
tempElement.style.position = 'absolute';
|
||||
tempElement.style.whiteSpace = 'wrap';
|
||||
|
||||
const style = window.getComputedStyle(el);
|
||||
tempElement.style.width = containerWidth + 'px';
|
||||
tempElement.style.fontSize = style.fontSize;
|
||||
tempElement.style.fontFamily = style.fontFamily;
|
||||
tempElement.style.lineHeight = style.lineHeight;
|
||||
tempElement.style.letterSpacing = style.letterSpacing;
|
||||
tempElement.style.wordBreak = style.wordBreak;
|
||||
tempElement.textContent = el.textContent;
|
||||
document.body.appendChild(tempElement);
|
||||
const textHeight = tempElement.clientHeight;
|
||||
// 移除临时元素
|
||||
document.body.removeChild(tempElement);
|
||||
|
||||
if (el.offsetLeft + el.offsetWidth < parentWidth - 10) return;
|
||||
if (textHeight > containerHeight) {
|
||||
visible.value = true;
|
||||
textWidth.value = containerWidth - 20 + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => resolveFold(), 300);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.g-fold-ellipsis {
|
||||
line-height: 20px;
|
||||
@apply inline-flex items-center overflow-hidden text-ellipsis;
|
||||
|
||||
&.fold {
|
||||
@apply items-start;
|
||||
.g-fold-ellipsis-text{
|
||||
@apply whitespace-pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.g-fold-ellipsis-text {
|
||||
@apply overflow-hidden text-ellipsis inline-block;
|
||||
}
|
||||
|
||||
.g-fold-ellipsis-switch {
|
||||
@apply inline-block align-middle h-[16px] w-[20px] p-0 bg-CG200 text-center rounded cursor-pointer active:bg-CG300;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user