搜索结果列表页面开发

This commit is contained in:
付民康
2025-03-12 18:41:20 +08:00
commit 7cebe8fc00
739 changed files with 88149 additions and 0 deletions

View 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
},
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

View 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 | 右侧按钮区域插槽 |

View 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>

View 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>

View 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 | 目标分支底部信息插槽 |

View 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>

View File

@@ -0,0 +1,6 @@
export type BranchCompareData = {
repoId: string | number;
sourceId: string | number;
targetId: string | number;
[propName: string]: any;
}

View 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 | 目标分支底部信息插槽 |

View 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>

View File

@@ -0,0 +1,7 @@
export type BranchCompareData = {
repoId: string | number;
targetRepoId: string | number;
sourceId: string | number;
targetId: string | number;
[propName: string]: any;
}

View 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
| 名称 | 说明 |
|---------------------|---------------------------------------------|

View 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>

View 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整体数据 |

View 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>

View 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>,
}

View 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>

View 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用于区别点击的是分支还是tagvalue为对应的值 | { type, value } |
| onTabChange | branch/tag切换 | type |
| update:modelValue | v-model数据双向绑定 | 当前的branch、tag等 |
### Slots
| 名称 | 说明 |
|---------------------|---------------------------------------------|
| footer | 底部区域 |

View 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>

View 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。此时若不传入 typetab 将默认选择分支
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>

View File

@@ -0,0 +1,28 @@
# Demo
### 说明
- 全局卡片组件
- 已全局声明
``` js
<Card>
<div></div>
</Card>
<Card simple>
<div></div>
</Card>
```
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|-----------|------------------------|----------------------------|----------|-----------|
| simple | 简单模式-无padding | boolean | - | false |
### Slots
| 名称 | 说明 |
|---------------------|---------------------------------------------|
| default | 中间显示区域 |

View 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>

View 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>

View 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 | 右侧按钮区域插槽 |

View 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>

View 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 | — | — |

View 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>

View 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>

View 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 | - | - |

View 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>

View 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; // 名称
}

View 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 | 点击加载更多按钮 | |

View 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>

View 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 插槽 |

View 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>

View 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 | 右侧按钮区域插槽 |

View 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>

View File

@@ -0,0 +1,15 @@
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|-----------|-----------------------------|---------|-----------|-------|
| content | 需要被复制的内容,复制优先级比插槽内容要高 | string | — | '' |
| always? | 是否一直显示复制按钮 | boolean | — | false |
| showText? | 是否显示文案插槽内容的显示优先级比content要高 | boolean | — | true |
| size? | 图标的size | string | — | '20px' |
### Slots
| 参数 | 说明 |
|----------|-------------|
| default | 文本插槽 |
| icon | 复制按钮位置对应的插槽 |

View 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>

View File

@@ -0,0 +1,9 @@
# CreateFormBox
### 说明
- 新建页面外框布局组件
``` js
<CreateFormBox></CreateFormBox>
```

View 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>

View File

@@ -0,0 +1,22 @@
# CustomHeader
### 说明
- 自定义header组件
``` js
<CustomHeader title='' count='' />
```
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|------------|--------|---------|----------|------|
| title? | 标题 | string | - | '标题' |
| count? | 计数 | number | - | 0 |
| showCount? | 是否显示计数 | boolean | - | true |
### Slots
| 名称 | 说明 |
|--------------------|--------|
| header-left | 组件左边插槽 |
| actions | 组件右边插槽 |

View 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>

View 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 | 组件单元数据尾部区域插槽 |

View 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>

View 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 | - |

View 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" />&nbsp;
</template>
<span class="g-custom-tab-title" :class="{ active: value === item.id }">{{ item.title }}</span>
<template v-if="item.count">&nbsp;
<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>

View 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>
```

View 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>

View File

@@ -0,0 +1,8 @@
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|----------------|-------|--------|-----------|---|
| title | tag内容 | string | — | '' |
| color? | 文本颜色 | string | — | '' |
| borderColor? | 边框颜色 | string | — | '' |
| bgColor? | 背景颜色 | string | — | '' |

View 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>

View 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失效 |

View 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>

View File

@@ -0,0 +1,29 @@
# Demo
### 说明
- xxxxxx描述组件并提供基础demo.建议demo使用最常见的用法即可
``` html
<Demo xxxx />
```
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|-----------|------------------------|----------------------------|----------|-----------|
| id | 实体key | string | - | - |
### Emits
| 方法 | 说明 | 返回值 |
|-----------------|------------------------------------|---------------------|
| onDelete | 删除 | id |
### Slots
| 名称 | 说明 |
|---------------------|---------------------------------------------|
| default | 中间显示区域 |

View 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>

View File

@@ -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 ? '' : `&nbsp;·&nbsp;已关闭` }}</span>
<span
v-if="category.category_type === DISCUSS_FORMAT.QANDA && is_answered === 1"
class="bottom-answered"
>&nbsp;·&nbsp;<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"
>&nbsp;·&nbsp;<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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&nbsp;·&nbsp;
<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>

View File

@@ -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>&nbsp;·&nbsp;
<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>

View File

@@ -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>

View File

@@ -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>
&nbsp;·&nbsp;
<span v-if="discussDetail?.closed_date && discussDetail?.is_closed === 1">
&nbsp;<Time :time="discussDetail?.closed_date"></Time>&nbsp;关闭
</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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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; // 是否锁定01
is_closed?: string; // 是否关闭01
is_answered?: string; // 否已采纳答案01
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);
}
};
// 监听 labelsortfilter 变更
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>

View 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; // 是否锁定01
is_closed?: string; // 是否关闭01
is_answered?: string; // 否已采纳答案01
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);
}
};
// 监听 labelsortfilter 变更
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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>&nbsp;
<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>

View 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>

View 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];
};

View 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>

View 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>

View File

@@ -0,0 +1,9 @@
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|------------|-----------------------------------------------|---------|-------------------------------- |---------|
| type | icon的类型可以传入映射对象对应的key值也可以直接传入可被组件识别的icon name | string | — | "folder" |
| title? | 文件名称 | string | — | "标题" |
| showTitle? | 是否显示文案 | boolean | — | true |
| size? | icon的尺寸 | string | — | "18px" |
| link? | 需要跳转的路由地址 | string | — | — |

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
### 筛选组件
- 分支筛选分支tag切换
- label筛选
- 人员筛选

View 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;">&nbsp;</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>

View File

@@ -0,0 +1,8 @@
export type IOption ={
value: string | number // 必填,选项唯一标识
name: string // 可选,选项显示内容
disabled?: boolean // 可选,禁用单个选项
selected?: boolean // option 是否选中
label?: string // 标签
[propName:string]:any
}

View 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