Browse Source

feat: 权限及客户端页面提交

master
AaronWu 1 year ago
parent
commit
3ceef2f0e0
  1. 3
      .env.development
  2. 1
      src/api/account/model.d.ts
  3. 7
      src/api/dict/model.d.ts
  4. 18
      src/api/issue/index.ts
  5. 21
      src/api/knowledgeBase/index.ts
  6. 2
      src/api/login/index.ts
  7. 11
      src/api/user/index.ts
  8. 12
      src/router/constant.ts
  9. 9
      src/router/generator-router.tsx
  10. 4
      src/router/index.ts
  11. 6
      src/router/outsideLayout.ts
  12. 14
      src/router/router-guards.ts
  13. 234
      src/views/client/entrance/index.vue
  14. 296
      src/views/client/issue/index.vue
  15. 363
      src/views/client/knowledgeBase/index.vue
  16. 21
      src/views/login/index.vue
  17. 103
      src/views/question/commom/tools.ts
  18. 4
      src/views/question/issue/detail.vue
  19. 12
      src/views/question/issue/index.vue
  20. 53
      src/views/question/knowledge/index.vue
  21. 15
      src/views/system/user/columns.tsx
  22. 30
      src/views/system/user/formSchemas.ts

3
.env.development

@ -9,7 +9,8 @@
# 只在开发模式中被载入
# 网站前缀
VITE_BASE_API_URL = http://192.168.2.116:8089/server/
VITE_BASE_API_URL = http://192.168.2.92:8089/server/
# VITE_BASE_API_URL = http://43.137.2.78:8082/server/
# base api
VITE_BASE_API = '/server/'

1
src/api/account/model.d.ts

@ -46,5 +46,6 @@ declare namespace API {
status: number;
roles: number[];
departmentName: string;
isAdmin: number;
};
}

7
src/api/dict/model.d.ts

@ -10,10 +10,11 @@ declare namespace API {
type DictValueType = {
id?: string;
dictTypeId: string;
dictValue: string;
enable: number;
dictTypeId?: string;
dictValue?: string;
enable?: number;
pendingStatus?: boolean;
typeId?: number;
};
type DeleteDictValueParams = {

18
src/api/issue/index.ts

@ -44,6 +44,24 @@ export function fetchIssuePageList(data: API.SearchPageListParams) {
);
}
/**
* @description
* @param {SearchPageListParams} data
* @returns
*/
export function fetchMyIssuePageList(data: API.SearchPageListParams) {
return request<BaseResponse<API.SearchPageListResult>>(
{
url: 'question/myList',
method: 'post',
data,
},
{
isGetDataDirectly: false,
},
);
}
/**
* @description
* @param {IssueType} data

21
src/api/knowledgeBase/index.ts

@ -13,17 +13,12 @@ import { request } from '@/utils/request';
* @param {SearchListParams} params
* @returns
*/
export function fetchKnowledgeBaseList(params: API.SearchListParams) {
return request<BaseResponse<API.SearchListResult>>(
{
url: 'knowledge/list',
method: 'get',
params,
},
{
isGetDataDirectly: false,
},
);
export function fetchKnowledgeBaseList(params: any) {
return request({
url: 'knowledge/list',
method: 'get',
params,
});
}
/**
@ -86,8 +81,8 @@ export function findOneById(params: { id: string }) {
*/
export function deleteKnowledgeBaseById(params: API.DeleteKnowledgeBaseParams) {
return request({
url: `knowledge/delById`,
method: 'post',
url: `knowledge/delete/${params.id}`,
method: 'delete',
params,
});
}

2
src/api/login/index.ts

@ -7,7 +7,7 @@ import { request } from '@/utils/request';
* @returns
*/
export function login(data: API.LoginParams) {
return request<BaseResponse<API.LoginResult>>({
return request<API.LoginResult>({
url: 'login',
method: 'post',
data,

11
src/api/user/index.ts

@ -70,6 +70,17 @@ export function updateUser(data: API.UserInfoType) {
});
}
/**
* @description
*/
export function updateState(params: { id: string; state: number }) {
return request({
url: `user/updateState`,
method: 'get',
params,
});
}
/**
* @description
*/

12
src/router/constant.ts

@ -13,7 +13,17 @@ export const PARENT_LAYOUT_NAME = 'ParentLayout';
export const PAGE_NOT_FOUND_NAME = 'PageNotFound';
// 路由白名单
export const whiteNameList = [LOGIN_NAME, 'icons', 'error', 'error-404'] as const; // no redirect whitelist
export const whiteNameList = [
LOGIN_NAME,
KNOWLEDGE_NAME,
ISSUE_NAME,
ENTRANCE_NAME,
'icons',
'error',
'error-404',
] as const; // no redirect whitelist
export const withoutLoginNameList = [LOGIN_NAME, 'icons', 'error', 'error-404'] as const;
export type WhiteNameList = typeof whiteNameList;

9
src/router/generator-router.tsx

@ -32,7 +32,7 @@ function asyncImportRoute(routes: any[] | undefined) {
const { children } = item;
if (component) {
item.component = dynamicImport(dynamicViewsModules, component as string);
}
}
children && asyncImportRoute(children);
});
}
@ -90,7 +90,6 @@ export function filterAsyncRoute( //transformObjToRoute
console.log('请正确配置路由:' + route?.name + '的component属性');
}
route.children && asyncImportRoute(route.children);
});
return routeList as any;
@ -109,11 +108,7 @@ export const generatorDynamicRouter = (asyncMenus: API.Menu[]) => {
console.log(routeList, '根据后端返回的权限路由生成');
// 给公共路由添加namePath
generatorNamePath(common);
const menus = [
...common,
...routeList,
...endRoutes,
];
const menus = [...common, ...routeList, ...endRoutes];
layout.children = menus;
const removeRoute = router.addRoute(layout);
// 获取所有没有包含children的路由,上面addRoute的时候,vue-router已经帮我们拍平了所有路由

4
src/router/index.ts

@ -4,7 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import { createRouterGuards } from './router-guards';
import outsideLayout from './outsideLayout';
import { whiteNameList } from './constant';
import { whiteNameList, withoutLoginNameList } from './constant';
import type { App } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
@ -41,7 +41,7 @@ export function resetRouter() {
export async function setupRouter(app: App) {
// 创建路由守卫
createRouterGuards(router, whiteNameList);
createRouterGuards(router, withoutLoginNameList);
app.use(router);

6
src/router/outsideLayout.ts

@ -22,7 +22,7 @@ export const KnowledgeRoute: RouteRecordRaw = {
},
};
export const Issue: RouteRecordRaw = {
export const IssueRoute: RouteRecordRaw = {
path: '/issue',
name: ISSUE_NAME,
component: () => import(/* webpackChunkName: "login" */ '@/views/client/issue/index.vue'),
@ -31,7 +31,7 @@ export const Issue: RouteRecordRaw = {
},
};
export const Entrance: RouteRecordRaw = {
export const EntranceRoute: RouteRecordRaw = {
path: '/entrance',
name: ENTRANCE_NAME,
component: () => import(/* webpackChunkName: "login" */ '@/views/client/entrance/index.vue'),
@ -40,4 +40,4 @@ export const Entrance: RouteRecordRaw = {
},
};
export default [LoginRoute, KnowledgeRoute, Issue, Entrance];
export default [LoginRoute, KnowledgeRoute, IssueRoute, EntranceRoute];

14
src/router/router-guards.ts

@ -13,12 +13,22 @@ NProgress.configure({ showSpinner: false }); // NProgress Configuration
const defaultRoutePath = '/dashboard/welcome';
export function createRouterGuards(router: Router, whiteNameList: WhiteNameList) {
export function createRouterGuards(router: Router, withoutLoginNameList: WhiteNameList) {
router.beforeEach(async (to, _, next) => {
NProgress.start(); // start progress bar
const userStore = useUserStore();
const token = Storage.get(ACCESS_TOKEN_KEY, null);
if (token) {
const isAdmin = userStore.userInfo.isAdmin;
if (isAdmin === 0) {
// 判断是否是管理员,如果不是管理员,则不允许访问系统管理相关的路由
const clientRoutes = ['/entrance', '/issue', '/knowledge'];
if (!clientRoutes.includes(to.path)) {
return next({ path: '/entrance' });
} else {
return next();
}
}
if (to.name === LOGIN_NAME) {
next({ path: defaultRoutePath });
} else {
@ -42,7 +52,7 @@ export function createRouterGuards(router: Router, whiteNameList: WhiteNameList)
}
} else {
// not login
if (whiteNameList.some((n) => n === to.name)) {
if (withoutLoginNameList.some((n) => n === to.name)) {
// 在免登录名单,直接进入
next();
} else {

234
src/views/client/entrance/index.vue

@ -14,6 +14,18 @@
<a-button type="text" class="favorite">
<star-outlined />
</a-button>
<Dropdown placement="bottomRight">
<Avatar :src="userInfo.headImg" :alt="userInfo.name">{{ userInfo.name }}</Avatar>
<template #overlay>
<Menu>
<Menu.Item>
<div class="flex items-center" @click.prevent="doLogout">
<poweroff-outlined />&nbsp; {{ $t('layout.header.dropdownItemLoginOut') }}
</div>
</Menu.Item>
</Menu>
</template>
</Dropdown>
</a-space>
</div>
</div>
@ -54,16 +66,34 @@
<div class="search-section">
<h1>👋 有什么可以帮你?</h1>
<div class="search-wrapper">
<a-input-search
v-model:value="searchText"
placeholder="在这里搜索"
size="large"
@search="onSearch"
class="rounded-search"
/>
<div class="search-container">
<a-input-search
v-model:value="searchText"
placeholder="在这里搜索"
size="large"
@search="onSearch"
@input="handleInput"
class="rounded-search"
/>
<!-- 搜索结果下拉框 -->
<div v-if="searchResults.length && searchText" class="search-results">
<div
v-for="item in searchResults"
:key="item.id"
class="result-item"
@click="handleResultClick(item)"
>
<div class="result-title">
<book-outlined class="icon" />
<span>{{ item.title }}</span>
</div>
<p class="result-desc">{{ item.description }}</p>
</div>
</div>
</div>
</div>
<div class="search-tags">
<a-tag v-for="tag in searchTags" :key="tag">{{ tag }}</a-tag>
<a-tag v-for="tag in searchTags" :key="tag.value">{{ tag.label }}</a-tag>
</div>
</div>
@ -92,8 +122,9 @@
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
<script setup lang="tsx">
import { ref, computed, nextTick } from 'vue';
import {
StarOutlined,
UserOutlined,
@ -101,13 +132,27 @@
MailOutlined,
FormOutlined,
BookOutlined,
PoweroffOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { Avatar, Menu, Dropdown, Modal, message } from 'ant-design-vue';
import { useUserStore } from '@/store/modules/user';
import { useKeepAliveStore } from '@/store/modules/keepAlive';
import { LOGIN_NAME } from '@/router/constant';
import { fetchKnowledgeBaseList, findOneById } from '@/api/knowledgeBase';
import { DictEnum } from '@/enums/dictEnum';
import { getDictionaryByTypeName } from '@/utils/dict';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const keepAliveStore = useKeepAliveStore();
const userInfo = computed(() => userStore.userInfo);
const searchText = ref('');
const searchTags = ref(['快捷指南', 'KB快捷键', 'DAM', 'CMS', 'Wiki', 'Community']);
const searchTags = ref<any>([]);
const startItems = ref([
{
@ -124,6 +169,48 @@
},
]);
const searchResults = ref<API.KnowledgeBaseType[]>([]);
let searchTimer: NodeJS.Timeout | null = null;
//
const handleInput = async () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
if (!searchText.value) {
searchResults.value = [];
return;
}
try {
// TODO: API
const res = await fetchKnowledgeBaseList({
title: searchText.value,
});
searchResults.value = res.map((item) => ({
id: item.id,
title: item.title,
description: item.description?.substring(0, 100) || '暂无描述',
}));
} catch (error) {
console.error('搜索失败:', error);
searchResults.value = [];
}
}, 300);
};
//
const handleResultClick = async (item) => {
const detail = await findOneById({ id: item.id });
console.log('detail: ', detail);
router.push({
path: '/knowledge',
query: {
id: item.id,
},
});
};
const goTo = (route: string) => {
console.log(`前往 ${route}`);
router.push(route);
@ -132,6 +219,43 @@
const onSearch = (value: string) => {
console.log('搜索:', value);
};
// 退
const doLogout = () => {
Modal.confirm({
title: '您确定要退出登录吗?',
icon: <QuestionCircleOutlined />,
centered: true,
onOk: async () => {
// rootadmin退
if (userStore.userInfo.phone !== '13553550634') {
// logout({})
await userStore.logout();
}
keepAliveStore.clear();
//
localStorage.clear();
message.success('成功退出登录');
await nextTick();
router.replace({
name: LOGIN_NAME,
query: {
redirect: route.fullPath,
},
});
},
});
};
const getTags = async () => {
const data = await getDictionaryByTypeName(DictEnum.TAG_TYPE);
searchTags.value = data;
};
const initData = async () => {
await getTags();
};
initData();
</script>
<style lang="less" scoped>
@ -307,27 +431,101 @@
.search-wrapper {
max-width: 600px;
margin: 0 auto;
.rounded-search {
:deep(.ant-input) {
border-radius: 24px 0 0 24px;
height: 48px;
padding-left: 24px;
border: 1px solid #d9d9d9;
&:focus, &:hover {
&:focus,
&:hover {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
}
:deep(.ant-input-group-addon) {
background:none;
background: none;
.ant-btn {
border-radius: 0 24px 24px 0;
height: 48px;
}
}
}
.search-container {
position: relative;
max-width: 600px;
margin: 0 auto;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-top: 8px;
max-height: 400px;
overflow-y: auto;
z-index: 1000;
.result-item {
padding: 12px 16px;
cursor: pointer;
transition: all 0.3s;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f6f8fa;
}
.result-title {
display: flex;
align-items: center;
gap: 8px;
color: #1f1f1f;
font-weight: 500;
margin-bottom: 4px;
.icon {
color: #1890ff;
font-size: 16px;
}
}
.result-desc {
color: #666;
font-size: 13px;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: #f5f5f5;
}
}
}
</style>

296
src/views/client/issue/index.vue

@ -1,5 +1,12 @@
<template>
<div class="knowledge-base">
<!-- 添加返回按钮 -->
<div class="back-button">
<a-button type="link" @click="goBack">
<template #icon><left-outlined /></template>
<span class="btn-text">返回</span>
</a-button>
</div>
<a-row :gutter="16">
<!-- 左侧目录树 -->
<a-col :span="6">
@ -8,8 +15,8 @@
<span class="card-title"> <folder-outlined /> 问题工单目录 </span>
</template>
<a-tree
v-if="expandAll"
v-model:selectedKeys="selectedKeys"
v-model:expandedKeys="expandedKeys"
:tree-data="treeData"
:defaultExpandAll="expandAll"
@select="onSelect"
@ -17,6 +24,18 @@
<template #switcherIcon="{ switcherCls }"
><down-outlined :class="switcherCls"
/></template>
<template #title="item">
<div class="flex items-center">
<span class="mr-3 w-[100px] truncate" :title="item.title">{{ item.title }}</span>
<a-tag v-if="item.isLeaf" :color="item.color">{{ item.stateText }}</a-tag></div
>
</template>
<!-- <template #icon="item">
<template v-if="item.isLeaf">
<a-tag :color="item.color">{{ item.stateText }}</a-tag>
</template>
</template> -->
</a-tree>
</a-card>
</a-col>
@ -44,9 +63,9 @@
<!-- 内容展示区域 -->
<div class="content-area">
<template v-if="selectedKeys.length">
<template v-if="curRow.id">
<!-- 这里可以根据选中的目录显示具体内容 -->
<Detail :id="curRow.id" />
<Detail :id="curRow.id" ref="detailRef" />
</template>
<template v-else>
<a-empty description="请选择左侧目录" />
@ -56,10 +75,35 @@
</a-col>
</a-row>
</div>
<DraggableModal
v-model:visible="visible"
:width="700"
:bodyStyle="{
height: '70vh',
}"
:force-render="true"
:title="`${curRow.id ? '编辑' : '新增'}问题工单`"
@ok="handleOk"
@cancel="handleCancel"
>
<SchemaForm>
<template #solution="{ formModel, field }">
<div class="ql-box">
<QuillEditor
ref="quillEditor"
theme="snow"
v-model:content="formModel[field]"
:options="editorOptions"
contentType="html"
/>
</div>
</template>
</SchemaForm>
</DraggableModal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, nextTick } from 'vue';
import {
FolderOutlined,
BookOutlined,
@ -71,19 +115,64 @@
DeleteOutlined,
ExportOutlined,
DownOutlined,
LeftOutlined,
} from '@ant-design/icons-vue';
import { Modal, message, Alert } from 'ant-design-vue';
import { fetchProdList, fetchVersionPageList } from '@/api/prodVersion';
import { fetchIssuePageList } from '@/api/issue';
import {
fetchMyIssuePageList,
createIssue,
updateIssue,
deleteIssueById,
deleteBatchIssueById,
findOneById,
updateIssueState,
addToknowledge,
} from '@/api/issue';
import Detail from '@/views/question/issue/detail.vue';
import { DraggableModal } from '@/components/core/draggable-modal';
import { useForm } from '@/components/core/schema-form';
import { getEditFormSchema } from '@/views/question/issue/formSchemas.tsx';
import { QuillEditor } from '@vueup/vue-quill';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { type TableListItem } from '@/views/question/issue/columns';
import { useRouter } from 'vue-router';
import { stateTypeList } from '@/views/question/issue/data';
const router = useRouter();
//
const treeData = ref<any[]>([]);
const expandAll = ref(false);
//
const selectedKeys = ref<string[]>([]);
const expandedKeys = ref<string[]>(['1', '2']);
const quillEditor = ref<InstanceType<typeof QuillEditor> | null>(null);
const detailRef = ref<InstanceType<typeof Detail> | null>(null);
const editorOptions = ref({
theme: 'snow',
modules: {
toolbar: [
[{ header: '1' }, { header: '2' }, { font: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['bold', 'italic', 'underline'],
['link', 'image'], //
],
},
});
const curRow = ref<any>({});
const visible = ref(false);
const [SchemaForm, formRef] = useForm({
labelWidth: 100,
labelAlign: 'right',
schemas: getEditFormSchema(curRow.value),
showActionButtonGroup: false,
actionColOptions: {
span: 24,
},
submitButtonOptions: {
text: '保存',
},
});
//
const featureCards = ref([
@ -110,27 +199,152 @@
//
const quickActions = ref([
{
title: '新建文档',
title: '新建工单',
icon: PlusOutlined,
onClick: () => console.log('新建文档'),
onClick: () => {
handleAdd();
},
},
{
title: '编辑文档',
title: '编辑工单',
icon: EditOutlined,
onClick: () => console.log('编辑文档'),
onClick: () => {
handleEdit(curRow.value);
},
},
// {
// title: '',
// icon: DeleteOutlined,
// onClick: () => console.log(''),
// },
{
title: '删除文档',
icon: DeleteOutlined,
onClick: () => console.log('删除文档'),
},
{
title: '导出文档',
title: '导出工单',
icon: ExportOutlined,
onClick: () => console.log('导出文档'),
onClick: () => message.info('功能开发中~'),
},
]);
const goBack = () => {
router.go(-1);
};
const resetFormFields = () => {
formRef?.resetFields();
};
const handleOk = async () => {
const values = await formRef?.validate();
if (values) {
console.log('values: ', values);
values.id = curRow.value.id;
if (values.files && Array.isArray(values.files) && values.files.length) {
values.files = values.files.map((e) => {
if (e.response) {
return {
name: e.name,
url: e.response.url,
id: e.response.id,
};
}
return {
...e,
};
});
values.fileIds = values.files.map((e) => e.id).join(',');
}
if (values?.tags && Array.isArray(values.tags) && values.tags.length) {
values.tags = values.tags.join(',');
}
await (values.id ? updateIssue : createIssue)(values);
message.success(`${values.id ? '编辑' : '新增'}成功`);
visible.value = false;
resetFormFields();
//
if (values.id) {
//
await initTreeData();
detailRef.value?.initData();
} else {
initTreeData();
}
}
};
const handleCancel = () => {
visible.value = false;
};
const openModal = () => {
visible.value = true;
formRef.clearValidate();
};
const handleAdd = () => {
curRow.value = {};
// formRef?.setFieldsValue(curRecord.value);
quillEditor.value?.setContents('');
resetFormFields();
openModal();
};
const handleEdit = async (record: TableListItem) => {
if (!record || !record.id) {
message.warning('请先选择左侧工单');
return;
}
if (record?.id) {
const res = await findOneById({
id: record.id,
});
console.log('res: ', res);
if (res?.files && Array.isArray(res.files) && res.files.length) {
res.files = res.files.map((e) => {
return {
name: e.originalFilename,
url: e.url,
id: e.id,
};
});
}
if (res.tags) {
res.tags = res.tags.split(',') || [];
}
curRow.value = res;
quillEditor.value?.setContents(res?.solution);
formRef?.setFieldsValue(res);
if (res?.productId) {
const { data } = await fetchVersionPageList({
productId: res?.productId,
current: 1,
size: 999,
});
if (data && Array.isArray(data) && data.length) {
formRef?.updateSchema({
field: 'versionId',
componentProps: {
options: data.map((e) => {
return {
label: e.name,
value: e.id,
};
}),
},
});
}
}
nextTick(() => {
openModal();
});
}
};
//
const onSelect = async (selectedKeys: string[], info: any) => {
console.log('selected', selectedKeys, info);
@ -147,6 +361,7 @@
};
} else {
console.log('选中非叶子节点:', selectedKeys[0]);
curRow.value = {};
}
};
@ -186,21 +401,30 @@
let childItem = item.children[j];
const {
data: { records },
} = await fetchIssuePageList({
} = await fetchMyIssuePageList({
versionId: childItem.id,
current: 1,
size: 999,
});
console.log('res: ', res);
childItem.children = (records as any).map((e) => {
return {
title: e.title,
key: e.id,
id: e.id,
isLeaf: true,
};
});
if (records && Array.isArray(records) && records.length) {
childItem.children = (records as any).map((e) => {
const { label, color } = stateTypeList.find((d) => d.value === e.state);
return {
title: e.title,
key: e.id,
id: e.id,
isLeaf: true,
state: e.state,
stateText: label,
color: color,
};
});
} else {
childItem.children = [];
}
}
}
}
@ -220,9 +444,23 @@
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7eb 100%);
min-height: 100vh;
.back-button {
margin-bottom: 16px;
.ant-btn-link {
padding: 0;
height: auto;
font-size: 16px;
&:hover {
color: #40a9ff;
}
}
}
.tree-card,
.content-card {
min-height: calc(100vh - 50px);
min-height: calc(100vh - 100px);
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@ -235,7 +473,7 @@
.content-area {
min-height: 300px;
max-height: calc(100vh - 164px);
max-height: calc(100vh - 210px);
overflow: scroll;
.selected-content {

363
src/views/client/knowledgeBase/index.vue

@ -1,18 +1,23 @@
<template>
<div class="knowledge-base">
<!-- 添加返回按钮 -->
<div class="back-button">
<a-button type="link" @click="goBack">
<template #icon><left-outlined /></template>
<span class="btn-text">返回</span>
</a-button>
</div>
<a-row :gutter="16">
<!-- 左侧目录树 -->
<a-col :span="6">
<a-card class="tree-card">
<template #title>
<span class="card-title"> <folder-outlined /> 知识库目录 </span>
<span class="card-title">
<folder-outlined class="title-icon" />
<span class="title-text">知识库目录</span>
</span>
</template>
<a-tree
v-model:selectedKeys="selectedKeys"
v-model:expandedKeys="expandedKeys"
:tree-data="treeData"
@select="onSelect"
/>
<a-tree v-model:selectedKeys="selectedKeys" :tree-data="treeData" @select="onSelect" />
</a-card>
</a-col>
@ -20,18 +25,43 @@
<a-col :span="18">
<a-card class="content-card">
<template #title>
<span class="card-title"> <book-outlined /> 知识库内容 </span>
<span class="card-title">
<book-outlined class="title-icon" />
<span class="title-text">知识库内容</span>
</span>
</template>
<!-- 内容展示区域 -->
<div class="content-area">
<template v-if="selectedKeys.length">
<!-- 这里可以根据选中的目录显示具体内容 -->
<div class="selected-content"> 已选择: {{ selectedKeys[0] }} </div>
</template>
<template v-else>
<a-empty description="请选择左侧目录" />
</template>
<a-spin :spinning="loading">
<template v-if="selectedKeys.length">
<!-- 这里可以根据选中的目录显示具体内容 -->
<div class="selected-content">
<div class="content-header">
<div class="header-main">
<h1 class="title">{{ curRowDetail.title }}</h1>
<div class="meta-info">
<span class="date">更新时间2024-01-09</span>
<span class="divider">|</span>
<span class="views">阅读123</span>
</div>
</div>
<div class="tags">
<template v-if="curRowDetail.tags">
<a-tag v-for="tag in curRowDetail.tags.split(',')" :key="tag" color="blue">
{{ tag }}
</a-tag>
</template>
</div>
</div>
<div class="content-divider"></div>
<div class="content-body markdown-body" v-html="curRowDetail.solution"></div>
</div>
</template>
<template v-else>
<a-empty description="请选择左侧目录" />
</template>
</a-spin>
</div>
</a-card>
</a-col>
@ -40,7 +70,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, watch } from 'vue';
import {
FolderOutlined,
BookOutlined,
@ -51,73 +81,46 @@
EditOutlined,
DeleteOutlined,
ExportOutlined,
LeftOutlined,
} from '@ant-design/icons-vue';
import { DictEnum } from '@/enums/dictEnum';
import { getDictionaryByTypeName } from '@/utils/dict';
import { fetchKnowledgeBaseList, findOneById } from '@/api/knowledgeBase';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
//
const treeData = ref([
{
title: '知识库',
key: '1',
children: [],
},
{
title: '问题工单',
key: '2',
children: [],
},
]);
const treeData = ref([]);
const loading = ref(false);
//
const selectedKeys = ref<string[]>([]);
const expandedKeys = ref<string[]>(['1', '2']);
//
const featureCards = ref([
{
title: '文档管理',
description: '集中管理所有知识文档',
icon: FileTextOutlined,
color: '#1890ff',
},
{
title: '团队协作',
description: '支持多人协同编辑',
icon: TeamOutlined,
color: '#52c41a',
},
{
title: '系统设置',
description: '自定义知识库配置',
icon: SettingOutlined,
color: '#722ed1',
},
]);
//
const quickActions = ref([
{
title: '新建文档',
icon: PlusOutlined,
onClick: () => console.log('新建文档'),
},
{
title: '编辑文档',
icon: EditOutlined,
onClick: () => console.log('编辑文档'),
},
{
title: '删除文档',
icon: DeleteOutlined,
onClick: () => console.log('删除文档'),
},
{
title: '导出文档',
icon: ExportOutlined,
onClick: () => console.log('导出文档'),
},
]);
const curRowDetail = ref<{
title: string;
tags: string;
solution: string;
}>({
title: '',
tags: '',
solution: '',
});
const goBack = () => {
router.go(-1);
};
watch(selectedKeys, async (newVal) => {
if (!newVal.length) return;
loading.value = true;
try {
const res = await findOneById({ id: newVal[0] });
curRowDetail.value = res;
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
});
//
const onSelect = (selectedKeys: string[], info: any) => {
@ -125,8 +128,17 @@
};
const initTreeData = async () => {
const res = await getDictionaryByTypeName(DictEnum.TAG_TYPE);
console.log('res: ', res);
const list = await fetchKnowledgeBaseList();
treeData.value = list.map((e) => {
return {
title: e.title,
key: e.id,
};
});
console.log('route: ', route);
if (route.query.id) {
selectedKeys.value = [route.query.id as string];
}
};
initTreeData();
</script>
@ -136,7 +148,19 @@
padding: 24px;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7eb 100%);
min-height: 100vh;
.back-button {
margin-bottom: 16px;
.ant-btn-link {
padding: 0;
height: auto;
font-size: 16px;
&:hover {
color: #40a9ff;
}
}
}
.tree-card,
.content-card {
min-height: calc(100vh - 50px);
@ -144,6 +168,26 @@
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
.title-icon {
font-size: 18px;
color: #3b82f6;
}
.title-text {
font-weight: 600;
background: linear-gradient(120deg, #3b82f6, #2563eb);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 1px;
}
}
.quick-actions {
margin-bottom: 16px;
padding: 16px 0;
@ -151,13 +195,166 @@
}
.content-area {
padding: 16px 0;
padding: 0 0 16px 0;
min-height: 300px;
.selected-content {
padding: 16px;
background: #fafafa;
border-radius: 4px;
padding: 10px 32px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.content-header {
.header-main {
margin-bottom: 16px;
.title {
font-size: 28px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 12px;
line-height: 1.4;
}
.meta-info {
color: #94a3b8;
font-size: 14px;
.divider {
margin: 0 12px;
color: #e2e8f0;
}
}
}
.tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 24px;
.ant-tag {
margin: 0;
padding: 4px 12px;
font-size: 13px;
border-radius: 6px;
border: none;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
transition: all 0.3s ease;
&:hover {
background: rgba(59, 130, 246, 0.2);
}
}
}
}
.content-divider {
height: 1px;
background: linear-gradient(to right, #e2e8f0, transparent);
margin: 0 -32px 24px;
}
.content-body {
font-size: 16px;
line-height: 1.8;
color: #334155;
:deep(h1, h2, h3, h4, h5, h6) {
color: #1e293b;
margin: 32px 0 16px;
font-weight: 600;
line-height: 1.4;
}
:deep(h1) {
font-size: 28px;
}
:deep(h2) {
font-size: 24px;
}
:deep(h3) {
font-size: 20px;
}
:deep(h4) {
font-size: 18px;
}
:deep(p) {
margin: 16px 0;
line-height: 1.8;
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:deep(pre) {
background: #f8fafc;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
margin: 20px 0;
border: 1px solid #e2e8f0;
}
:deep(code) {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', monospace;
font-size: 0.9em;
color: #2563eb;
}
:deep(blockquote) {
margin: 20px 0;
padding: 16px 24px;
background: #f8fafc;
border-left: 4px solid #3b82f6;
border-radius: 0 8px 8px 0;
color: #475569;
font-style: italic;
}
:deep(ul),
:deep(ol) {
padding-left: 24px;
margin: 16px 0;
}
:deep(li) {
margin: 8px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 24px 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
th,
td {
padding: 12px 16px;
border: 1px solid #e2e8f0;
}
th {
background: #f8fafc;
font-weight: 600;
color: #1e293b;
}
tr:hover {
background: #f8fafc;
}
}
}
}
}
}

21
src/views/login/index.vue

@ -165,7 +165,25 @@
});
} else {
message.success('登录成功!');
setTimeout(() => router.replace((route.query.redirect as string) ?? '/'));
const isAdmin = userStore.userInfo?.isAdmin;
console.log('isAdmin: ', isAdmin);
if (isAdmin === 0) {
const clientRoutes = ['/entrance', '/issue', 'knowledge'];
let replacePath = '';
if (route.query.redirect) {
if (clientRoutes.includes(route.query.redirect as string)) {
replacePath = route.query.redirect as string;
} else {
replacePath = '/entrance';
}
} else {
replacePath = '/entrance';
}
setTimeout(() => router.replace((replacePath as string) ?? '/'));
} else {
setTimeout(() => router.replace((route.query.redirect as string) ?? '/'));
}
}
state.loading = false;
message.destroy();
@ -338,7 +356,6 @@
}
}
}
}
.custom-input {

103
src/views/question/commom/tools.ts

@ -0,0 +1,103 @@
import { ref } from 'vue';
import { commonUpload } from '@/api/upload';
import { QuillEditor } from '@vueup/vue-quill';
const quillEditor = ref<InstanceType<typeof QuillEditor> | null>(null);
export const quillImageUploadCustom = (quill) => {
quillEditor.value = quill;
const toolbar = quillEditor.value?.getToolbar();
const formats = toolbar?.querySelectorAll('.ql-formats');
if (!formats) return;
// 创建一个自定义按钮,使用
const customButton = document.createElement('button');
const svgElement = createImageUploadSvg();
// 将img添加到button元素中
customButton.appendChild(svgElement);
// 给按钮添加点击事件
customButton.onclick = (e) => {
e.stopPropagation();
handleCustomImageUpload();
};
// 添加到工具栏尾部
formats[formats.length - 1].appendChild(customButton);
};
// 自定义文件上传函数
const handleCustomImageUpload = () => {
// 创建一个文件选择器
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*'; // 限制为图片文件
input.click();
input.onchange = async (e: any) => {
const file = e?.target?.files[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
// 发送文件到服务器进行上传
try {
const res = await commonUpload(formData);
console.log('res: ', res);
if (res?.url) {
const fileUrl = res?.url;
insertImage(fileUrl); // 将图片插入到 Quill 编辑器
}
} catch (error) {
console.error('文件上传失败', error);
}
}
};
};
// 插入图片到 Quill 编辑器
const insertImage = (url) => {
const quill = quillEditor.value?.getQuill();
console.log('quill: ', quill);
// quillEditor.value?.setContents(html + `<img src="${url}" alt="Image" />`);
const range = quill.getSelection();
console.log('range: ', range);
let index = 0;
if (range) {
index = range.index;
}
quill.insertEmbed(index, 'image', url);
};
const createImageUploadSvg = () => {
// 创建 SVG 元素
const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgElement.setAttribute('viewBox', '0 0 1024 1024');
svgElement.setAttribute('version', '1.1');
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svgElement.setAttribute('width', '20');
svgElement.setAttribute('height', '20');
// 创建第一个 path 元素
const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path1.setAttribute(
'd',
'M585.6696875 876.7315625H199.9878125C140.96 876.7315625 92.9375 828.7090625 92.9375 769.6821875V229.038125c0-59.0278125 48.0225-107.0503125 107.0503125-107.0503125h625.5853125c59.0278125 0 107.0484375 48.0225 107.0484375 107.0503125v304.6509375c0 24.534375-19.9575 44.49375-44.4890625 44.49375-24.534375 0-44.4946875-19.959375-44.4946875-44.49375V419.121875L638.6328125 624.1259375c-20.2275 20.2303125-47.0953125 31.3725-75.65625 31.3725-16.479375 0-33.0065625-3.9075-47.79375-11.2996875l-169.693125-84.8475c-2.563125-1.2965625-5.2509375-1.9396875-8.0484375-1.9396875a17.589375 17.589375 0 0 0-12.6121875 5.2696875l-0.0675 0.0675-142.84875 142.6621875v64.27125c0 9.96375 8.1084375 18.07125 18.075 18.07125h385.681875c24.5325 0 44.49 19.959375 44.49 44.49375-0.0009375 24.52875-19.9584375 44.484375-44.49 44.484375zM199.9878125 210.963125c-9.9665625 0-18.075 8.1075-18.075 18.0740625V579.621875l79.8965625-79.8440625c20.2228125-20.2275 47.0915625-31.36875 75.6525-31.36875 16.48125 0 33.0103125 3.9084375 47.799375 11.3034375l169.689375 84.8475c2.5621875 1.2965625 5.2509375 1.93875 8.049375 1.93875 4.7803125 0 9.2625-1.87125 12.616875-5.2696875l268.0246875-268.03125v-64.160625c0-9.9665625-8.10375-18.0740625-18.0646875-18.0740625H199.9878125z',
);
path1.setAttribute('fill', '#4b5563');
// 创建第二个 path 元素
const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path2.setAttribute(
'd',
'M788.009375 902.0121875c-20.7590625 0-37.648125-16.88625-37.648125-37.6434375v-153.75l-60.7565625 57.0909375c-7.03125 6.594375-16.1878125 10.2225-25.78875 10.2225-10.335 0-20.3221875-4.3275-27.3984375-11.874375-6.8840625-7.33875-10.5075-16.9040625-10.2-26.934375 0.3065625-10.0434375 4.516875-19.3640625 11.8565625-26.2490625L746.601875 610.925c10.2421875-12.3665625 25.2909375-19.4484375 41.3653125-19.4484375 16.155 0 31.2065625 7.081875 41.4478125 19.4484375l108.525 101.953125c7.340625 6.886875 11.551875 16.228125 11.855625 26.300625 0.3028125 10.05375-3.3253125 19.605-10.2159375 26.8959375-7.1203125 7.475625-17.1 11.758125-27.395625 11.7684375-9.5709375 0-18.718125-3.6309375-25.764375-10.2215625l-60.766875-57.045V864.36875c-0.0009375 20.7571875-16.888125 37.6434375-37.6434375 37.6434375zM354.411875 304.754375c-16.8346875 0-30.6075 13.77375-30.6075 30.6121875 0 16.8309375 13.7728125 30.6075 30.6075 30.6075 16.8384375 0 30.6121875-13.7765625 30.6121875-30.6075-0.0009375-16.843125-13.77375-30.6121875-30.6121875-30.6121875z m0 122.443125c-50.6034375 0-91.831875-41.2284375-91.831875-91.8309375 0-50.6071875 41.2284375-91.835625 91.831875-91.835625 50.6025 0 91.835625 41.2284375 91.835625 91.835625 0 50.6015625-41.233125 91.8309375-91.835625 91.8309375z',
);
path2.setAttribute('fill', '#4b5563');
// 将 path 元素添加到 SVG 元素中
svgElement.appendChild(path1);
svgElement.appendChild(path2);
return svgElement;
};

4
src/views/question/issue/detail.vue

@ -179,5 +179,9 @@
onMounted(async () => {
initData();
});
defineExpose({
initData,
});
</script>
<style lang="less" scoped></style>

12
src/views/question/issue/index.vue

@ -82,16 +82,17 @@
updateIssueState,
addToknowledge,
} from '@/api/issue';
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, ref, watch, onMounted } from 'vue';
import { Modal, message, Alert } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { useFormModal } from '@/hooks/useModal/index';
import { getEditFormSchema, getFlowFormSchema } from './formSchemas';
import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { ExclamationCircleOutlined, DeleteOutlinƒed } from '@ant-design/icons-vue';
import { stateTypeList } from './data';
import { fetchVersionPageList } from '@/api/prodVersion';
import { DraggableModal } from '@/components/core/draggable-modal';
import { useForm } from '@/components/core/schema-form';
import { quillImageUploadCustom } from '../commom/tools';
defineOptions({
name: 'issue',
@ -109,7 +110,7 @@
[{ header: '1' }, { header: '2' }, { font: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['bold', 'italic', 'underline'],
['link', 'image'], //
['link'], //
],
},
});
@ -142,6 +143,11 @@
},
);
// Quill
onMounted(() => {
quillImageUploadCustom(quillEditor.value);
});
const handleOk = async () => {
const values = await formRef?.validate();
if (values) {

53
src/views/question/knowledge/index.vue

@ -55,6 +55,7 @@
<template #solution="{ formModel, field }">
<div class="ql-box">
<QuillEditor
ref="quillEditor"
theme="snow"
v-model:content="formModel[field]"
:options="editorOptions"
@ -79,19 +80,21 @@
deleteBatchKnowledgeBaseById,
findOneById,
} from '@/api/knowledgeBase';
import { computed, nextTick, ref } from 'vue';
import { computed, nextTick, ref, watch, onMounted, h } from 'vue';
import { Modal, message, Alert } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { useFormModal } from '@/hooks/useModal/index';
import { getEditFormSchema, getFlowFormSchema } from './formSchemas';
import { getEditFormSchema } from './formSchemas';
import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { stateTypeList } from './data';
import { fetchVersionPageList } from '@/api/prodVersion';
import { DraggableModal } from '@/components/core/draggable-modal';
import { useForm } from '@/components/core/schema-form';
import { quillImageUploadCustom } from '../commom/tools';
import { createDictValue } from '@/api/dict';
import { DictEnum } from '@/enums/dictEnum';
import { getDictionaryByTypeName } from '@/utils/dict';
defineOptions({
name: 'issue',
name: 'knowledge',
});
const router = useRouter();
@ -99,6 +102,8 @@
const curRecord = ref<Partial<TableListItem>>({});
const visible = ref(false);
const quillEditor = ref<InstanceType<typeof QuillEditor> | null>(null);
const editorOptions = ref({
theme: 'snow',
modules: {
@ -106,7 +111,7 @@
[{ header: '1' }, { header: '2' }, { font: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['bold', 'italic', 'underline'],
['link', 'image'], //
['link'], //
],
},
});
@ -126,6 +131,22 @@
},
});
watch(
() => curRecord.value,
(newVal) => {
formRef?.setFieldsValue(newVal);
},
{
immediate: true,
deep: true,
},
);
// Quill
onMounted(() => {
quillImageUploadCustom(quillEditor.value);
});
const handleOk = async () => {
const values = await formRef?.validate();
if (values) {
@ -149,6 +170,19 @@
}
if (values?.tags && Array.isArray(values.tags) && values.tags.length) {
const allTags = (await getDictionaryByTypeName(DictEnum.TAG_TYPE)) || [];
// values.tagsallTags
const notInTags = values.tags.filter((tag) => {
return !allTags.some((e) => e.value === tag);
});
console.log('notInTags: ', notInTags);
for (const tag of notInTags) {
await createDictValue({
dictValue: tag,
typeId: 33,
enable: 1,
});
}
values.tags = values.tags.join(',');
}
@ -292,7 +326,11 @@
};
const handleAdd = () => {
curRecord.value = {};
formRef?.setFieldsValue(curRecord.value);
quillEditor.value?.setContents('');
resetFormFields();
openModal();
};
@ -318,8 +356,9 @@
}
curRecord.value = res;
quillEditor.value?.setContents(res?.solution);
formRef?.setFieldsValue(res);
// formRef?.setFieldsValue(res);
if (res?.productId) {
const { data } = await fetchVersionPageList({

15
src/views/system/user/columns.tsx

@ -1,7 +1,7 @@
import { debounce } from 'lodash-es';
import type { TableColumn } from '@/components/core/dynamic-table';
import { formatToDateTime } from '@/utils/dateUtil';
import { updateUser } from '@/api/user';
import { updateUser, updateState } from '@/api/user';
import { h } from 'vue';
import { Switch } from 'ant-design-vue';
import { sexTypeList } from './data';
@ -89,11 +89,11 @@ export const baseColumns: TableColumnItem[] = [
options: [
{
label: '启用',
value: 1,
value: 0,
},
{
label: '禁用',
value: 0,
value: 1,
},
],
},
@ -102,9 +102,12 @@ export const baseColumns: TableColumnItem[] = [
const onChange = (checked: boolean) => {
console.log('checked: ', checked);
record.pendingStatus = true;
const newState = checked ? 1 : 0;
const newState = checked ? 0 : 1;
record.state = newState;
updateUser(record)
updateState({
id: record.id!,
state: newState,
})
.then(() => {
record.state = newState;
})
@ -122,7 +125,7 @@ export const baseColumns: TableColumnItem[] = [
// );
// 渲染函数写法
return h(Switch, {
checked: record.state === 1 ? true : false,
checked: record.state === 0 ? true : false,
loading: record.pendingStatus,
onChange,
});

30
src/views/system/user/formSchemas.ts

@ -28,18 +28,18 @@ export const userSchemas: FormSchema<API.CreateUserParams>[] = [
},
rules: [{ required: true }],
},
{
field: 'password',
component: 'Input',
componentProps: {
type: 'password',
},
label: '密码',
colProps: {
span: 12,
},
rules: [{ required: true }],
},
// {
// field: 'password',
// component: 'Input',
// componentProps: {
// type: 'password',
// },
// label: '密码',
// colProps: {
// span: 12,
// },
// rules: [{ required: true }],
// },
{
field: 'email',
component: 'Input',
@ -91,7 +91,7 @@ export const userSchemas: FormSchema<API.CreateUserParams>[] = [
field: 'state',
component: 'RadioGroup',
label: '帐号状态',
defaultValue: 1,
defaultValue: 0,
colProps: {
span: 12,
},
@ -99,11 +99,11 @@ export const userSchemas: FormSchema<API.CreateUserParams>[] = [
options: [
{
label: '启用',
value: 1,
value: 0,
},
{
label: '禁用',
value: 0,
value: 1,
},
],
},

Loading…
Cancel
Save