Procházet zdrojové kódy

fix: 添加权限控制

bill před 2 roky
rodič
revize
46ec626b27

+ 9 - 0
src/api/constant.ts

@@ -49,3 +49,12 @@ export const CHECK_MEMBER_PHONE = '/smart-site/projectTeam/checkUserName'
 export const ADD_MEMBER = '/smart-site/projectTeam/addUser'
 export const DEL_MEMBER = '/smart-site/projectTeam/deleteUser'
 export const UPDATE_MEBER = '/smart-site/projectTeam/updateRemark'
+
+// 标注
+export const GET_TAGGING_LIST = '/smart-site/projectTeam/list'
+
+// 角色
+export const GET_ROLE_LIST = '/smart-site/projectRole/list'
+export const ADD_ROLE = '/smart-site/projectRole/addUser'
+export const DEL_ROLE = '/smart-site/projectRole/deleteUser'
+export const UPDATE_ROLE = '/smart-site/projectRole/updateRemark'

+ 2 - 0
src/api/index.ts

@@ -4,6 +4,8 @@ export * from './scene'
 export * from './record'
 export * from './project'
 export * from './member'
+export * from './tagging'
+export * from './role'
 
 export type PageResult<T = any> = {
   pageNum: number

+ 8 - 5
src/api/member.ts

@@ -6,11 +6,16 @@ import {
   UPDATE_MEBER,
   CHECK_MEMBER_PHONE
 } from './constant'
-import type { PageResult, PageRequest, Project } from './'
+import type { PageResult, PageRequest, Project, Role } from './'
 
 export interface Member {
   teamId: number
   userName: string
+  projectName?: Project['projectName']
+  projectId: Project['projectId']
+  roleName?: Role['roleName']
+  roleId: Role['roleId']
+  ddUserName: string
   remark: string
 }
 
@@ -25,10 +30,8 @@ export const fetchMembers = (
 export const deleteMember = (teamId: Member['teamId']) =>
   axios.post(DEL_MEMBER, { teamId })
 
-export const addMember = (
-  projectId: Project['projectId'],
-  member: Omit<Member, 'teamId'>
-) => axios.post(ADD_MEMBER, { projectId, ...member })
+export const addMember = (member: Omit<Member, 'teamId'>) =>
+  axios.post(ADD_MEMBER, { ...member })
 
 export const updateMember = (member: PartialPart<Member, 'userName'>) =>
   axios.post(UPDATE_MEBER, member)

+ 30 - 0
src/api/role.ts

@@ -0,0 +1,30 @@
+import axios from './instance'
+import { GET_ROLE_LIST, ADD_ROLE, DEL_ROLE, UPDATE_ROLE } from './constant'
+import type { PageResult, PageRequest, Project } from './'
+import { RoutesName } from '@/router'
+
+export interface Role {
+  roleId: number
+  roleName: string
+  remark: string
+  roleMenus: RoutesName[]
+  createTime: string
+}
+
+export type Roles = Role[]
+
+export const fetchRoles = (
+  params: PageRequest<
+    Partial<Pick<Role, 'roleName'>> & { projectId: Project['projectId'] }
+  >
+) => axios.post<PageResult<Role>>(GET_ROLE_LIST, params)
+
+export const deleteRole = (roleId: Role['roleId']) =>
+  axios.post(DEL_ROLE, { roleId })
+
+export const addRole = (
+  projectId: Project['projectId'],
+  role: Omit<Role, 'roleId' | 'createTime'>
+) => axios.post(ADD_ROLE, { projectId, ...role })
+
+export const updateRole = (role: Role) => axios.post(UPDATE_ROLE, role)

+ 35 - 0
src/api/tagging.ts

@@ -0,0 +1,35 @@
+import axios from './instance'
+import { GET_TAGGING_LIST } from './constant'
+import type { PageResult, PageRequest, Project } from './'
+
+export enum TaggingStatus {
+  pending,
+  progress,
+  unsolved,
+  solved
+}
+
+export const TaggingStatusDesc = {
+  [TaggingStatus.pending]: '待处理',
+  [TaggingStatus.progress]: '进行中',
+  [TaggingStatus.unsolved]: '未解决',
+  [TaggingStatus.solved]: '已解决'
+}
+
+export interface Tagging {
+  status: TaggingStatus
+  teamId: number
+  userName: string
+  remark: string
+}
+
+export type Taggings = Tagging[]
+
+export type FetchTaggingsProps = PageRequest<
+  Pick<Tagging, 'userName'> & {
+    projectId: Project['projectId']
+    status?: Tagging['status']
+  }
+>
+export const fetchTaggings = (params: FetchTaggingsProps) =>
+  axios.post<PageResult<Tagging>>(GET_TAGGING_LIST, params)

+ 18 - 1
src/api/user.ts

@@ -7,12 +7,15 @@ import {
   UPLOAD_FILE
 } from './constant'
 import { jsonToForm } from '@/shared'
+import type { Role } from './'
+import { RoutesName } from '@/router'
 
 export interface User {
   nickname: string
   phone: string
   avatar: string
   email: string
+  role: Role
 }
 
 type SUser = {
@@ -20,13 +23,27 @@ type SUser = {
   nickName: string
   userName: string
   email: string
+  role: Role
 }
 
 const toLocal = (data: SUser): User => ({
   nickname: data.nickName,
   avatar: data.head,
   phone: data.userName,
-  email: data.email
+  email: data.email,
+  role: {
+    roleId: 1,
+    roleName: '',
+    remark: '',
+    createTime: '',
+    roleMenus: [
+      RoutesName.projectMembers,
+      RoutesName.projectRoles,
+      RoutesName.projectTaggings,
+      RoutesName.projectScenes,
+      RoutesName.personal
+    ]
+  }
 })
 
 export const fetchUser = async (): Promise<User> => {

+ 2 - 0
src/components.d.ts

@@ -26,6 +26,7 @@ declare module '@vue/runtime-core' {
     AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
     AModal: typeof import('ant-design-vue/es')['Modal']
     ARadio: typeof import('ant-design-vue/es')['Radio']
+    ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
     ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     ASelect: typeof import('ant-design-vue/es')['Select']
     ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
@@ -34,6 +35,7 @@ declare module '@vue/runtime-core' {
     ATabs: typeof import('ant-design-vue/es')['Tabs']
     ATextarea: typeof import('ant-design-vue/es')['Textarea']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+    ATree: typeof import('ant-design-vue/es')['Tree']
     AUpload: typeof import('ant-design-vue/es')['Upload']
     DataList: typeof import('./components/data-list/index.vue')['default']
     DeleteOutlined: typeof import('@ant-design/icons-vue')['DeleteOutlined']

+ 8 - 2
src/layout/sider.vue

@@ -26,14 +26,20 @@
 import { RoutesName, routesMetas } from '@/router'
 import { computed } from 'vue'
 import { router, getRouteTreePaths } from '@/router'
+import { useUserStore } from '@/store'
 
 import type { MenuProps } from 'ant-design-vue/es/menu'
 
 defineOptions<{ name: 'layout-sider' }>()
 
-type MenuName = typeof menus[number]
+type MenuName = typeof allMenus[number]
+const userStore = useUserStore()
+const allMenus = [RoutesName.personal, RoutesName.projects] as const
 
-const menus = [RoutesName.personal, RoutesName.projects] as const
+const menus = computed(() => {
+  const roleMenus = userStore.current.role.roleMenus
+  return allMenus.filter(menu => roleMenus.includes(menu))
+})
 const selectMenu: MenuProps['onSelect'] = ({ key }) => {
   router.push({ name: key as RoutesName })
 }

+ 14 - 4
src/main.ts

@@ -1,12 +1,22 @@
-import { createApp } from 'vue'
+import { createApp, watch } from 'vue'
 import '@/assets/iconfont/iconfont.css'
 import 'ant-design-vue/lib/message/style/index.less'
 import './style.scss'
 import App from './App.vue'
-import router from './router'
-import store from './store'
+import { router, enableRouteNames } from './router'
+import { pinia, useUserStore } from './store'
 
 export const app = createApp(App)
 app.use(router)
-app.use(store)
+app.use(pinia)
 app.mount('#app')
+
+const userStore = useUserStore(pinia)
+
+watch(
+  () => userStore.$state.current.role.roleMenus,
+  nMenus => {
+    enableRouteNames([...nMenus])
+  },
+  { flush: 'sync', immediate: true }
+)

+ 14 - 0
src/router/config.ts

@@ -36,6 +36,16 @@ export const routes = [
             path: routesPaths[RoutesName.projectScenes],
             name: RoutesName.projectScenes,
             component: () => import('@/views/scene/list.vue')
+          },
+          {
+            path: routesPaths[RoutesName.projectTaggings],
+            name: RoutesName.projectTaggings,
+            component: () => import('@/views/taggings/list.vue')
+          },
+          {
+            path: routesPaths[RoutesName.projectRoles],
+            name: RoutesName.projectRoles,
+            component: () => import('@/views/role/list.vue')
           }
         ]
       }
@@ -50,6 +60,10 @@ export const routes = [
         path: routesPaths[RoutesName.login],
         name: RoutesName.login,
         component: () => import('@/views/system/login.vue')
+      },
+      {
+        path: '*',
+        redirect: routesPaths[RoutesName.login]
       }
     ]
   }

+ 15 - 3
src/router/constant.ts

@@ -5,7 +5,9 @@ export enum RoutesName {
   project = 'project',
   projectScenes = 'projectScenes',
   projectMembers = 'projectMembers',
-  projectMaterial = 'projectInfos'
+  projectMaterial = 'projectInfos',
+  projectTaggings = 'projectTaggings',
+  projectRoles = 'projectRoles'
 }
 
 export const routesPaths = {
@@ -16,7 +18,9 @@ export const routesPaths = {
   [RoutesName.project]: 'project/:id',
   [RoutesName.projectScenes]: 'scenes',
   [RoutesName.projectMembers]: 'members',
-  [RoutesName.projectMaterial]: 'infos'
+  [RoutesName.projectMaterial]: 'infos',
+  [RoutesName.projectTaggings]: 'taggings',
+  [RoutesName.projectRoles]: 'roles'
 }
 
 export const routesMetas = {
@@ -40,6 +44,12 @@ export const routesMetas = {
   [RoutesName.projectMembers]: {
     title: '成员管理'
   },
+  [RoutesName.projectTaggings]: {
+    title: '项目标注'
+  },
+  [RoutesName.projectRoles]: {
+    title: '项目角色'
+  },
   [RoutesName.project]: {
     title: '项目'
   }
@@ -57,7 +67,9 @@ export const routeTrees: RouteTree[] = [
         children: [
           { name: RoutesName.projectMaterial },
           { name: RoutesName.projectMembers },
-          { name: RoutesName.projectScenes }
+          { name: RoutesName.projectTaggings },
+          { name: RoutesName.projectScenes },
+          { name: RoutesName.projectRoles }
         ]
       }
     ]

+ 26 - 2
src/router/index.ts

@@ -1,13 +1,37 @@
 import { createRouter, createWebHashHistory } from 'vue-router'
 import { routes } from './config'
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
 import { routesMetas, routeTrees } from './constant'
 
-import type { RoutesName } from './constant'
+import { RoutesName } from './constant'
 
 export const history = createWebHashHistory()
 export const router = createRouter({ history, routes })
 
+const defaultRouteName = RoutesName.projects
+const enables = ref<RoutesName[]>([])
+export const enableRouteNames = (routerNames: RoutesName[]) => {
+  enables.value = routerNames
+  const current = router.currentRoute.value.name
+  if (current && !enables.value.includes(current as RoutesName)) {
+    router.replace(defaultRouteName)
+  }
+}
+
+router.beforeEach((to, from) => {
+  if (to.name === defaultRouteName) {
+    return true
+  }
+  const goto = enables.value.includes(to.name as RoutesName)
+  if (goto) {
+    return true
+  }
+  if (!from || !enables.value.includes(from.name as RoutesName)) {
+    router.replace({ name: defaultRouteName })
+  }
+  return false
+})
+
 export const getRouteTreePaths = (
   name: RoutesName = router.currentRoute.value.name as RoutesName,
   trees = routeTrees

+ 34 - 3
src/store/user.ts

@@ -9,13 +9,23 @@ import {
 import { defineStore } from 'pinia'
 
 import type { User, UserMeta, LoginState } from '@/api'
+import { RoutesName } from '@/router'
+
+export const LeastMenus = [RoutesName.projects, RoutesName.login]
 
 const defState = {
   current: {
     nickname: '游客',
     phone: '',
     email: '',
-    avatar: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png'
+    avatar: 'https://4dkk.4dage.com/head/18819272208/head_1662022947583.png',
+    role: {
+      roleId: 1,
+      roleName: '',
+      remark: '',
+      createTime: '',
+      roleMenus: LeastMenus
+    }
   } as User,
   meta: {
     projectCount: 0,
@@ -25,11 +35,32 @@ const defState = {
   } as UserMeta
 }
 
+const userStorage = localStorage.getItem('user')
+if (userStorage) {
+  try {
+    defState.current = JSON.parse(userStorage)
+  } catch {}
+}
+
 export const useUserStore = defineStore('user', {
   state: () => defState,
   actions: {
+    initUser(user: User) {
+      const storeUser = {
+        ...user,
+        role: {
+          ...user.role,
+          roleMenus: Array.from(
+            new Set([...LeastMenus, ...user.role.roleMenus])
+          )
+        }
+      }
+      localStorage.setItem('user', JSON.stringify(storeUser))
+      this.current = storeUser
+    },
     async fetchUser() {
-      this.current = await fetchUser()
+      const user = await fetchUser()
+      this.initUser(user)
     },
     async fetchMeta() {
       this.meta = await fetchUserMeta()
@@ -42,7 +73,7 @@ export const useUserStore = defineStore('user', {
         phone: state.phone,
         password: state.password
       })
-      this.current = user
+      this.initUser(user)
       setToken(token)
     },
     async logout() {

+ 15 - 0
src/views/member/columns.ts

@@ -19,6 +19,21 @@ export const memberColumns: ColumnsType<Member> = [
     key: 'userName'
   },
   {
+    title: '邀请项目',
+    dataIndex: 'projectName',
+    key: 'projectName'
+  },
+  {
+    title: '项目角色',
+    dataIndex: 'roleName',
+    key: 'roleName'
+  },
+  {
+    title: '绑定账号',
+    dataIndex: 'ddUserName',
+    key: 'ddUserName'
+  },
+  {
     title: '备注',
     key: 'remark',
     dataIndex: 'remark'

+ 68 - 5
src/views/member/edit.vue

@@ -39,6 +39,46 @@
         />
       </a-form-item>
       <a-form-item
+        name="projectId"
+        label="邀请项目"
+        :rules="[{ required: true, message: '请选择由我创建的项目' }]"
+      >
+        <a-select
+          v-model:value="editMember.projectId"
+          placeholder="请选择由我创建的项目"
+        >
+          <a-select-option
+            v-for="project in projectOptions"
+            :key="project.projectId"
+            :value="project.projectId"
+            >{{ project.projectName }}</a-select-option
+          >
+        </a-select>
+      </a-form-item>
+      <a-form-item
+        name="roleId"
+        label="项目角色"
+        :rules="[{ required: true, message: '请选择项目角色' }]"
+      >
+        <a-select
+          v-model:value="editMember.roleId"
+          placeholder="请选择项目角色"
+        >
+          <a-select-option
+            v-for="role in roleOptions"
+            :key="role.roleId"
+            :value="role.roleId"
+            >{{ role.roleName }}</a-select-option
+          >
+        </a-select>
+      </a-form-item>
+      <a-form-item name="ddUserName" label="绑定账号">
+        <a-input
+          v-model:value="editMember.ddUserName"
+          placeholder="请输入当前用户绑定的钉钉账号"
+        />
+      </a-form-item>
+      <a-form-item
         name="remark"
         label="备注"
         :rules="[{ required: false, max: 50, message: '备注最多50字' }]"
@@ -49,16 +89,17 @@
           style="height: 104px; resize: none"
           placeholder="请输入备注,最多50字"
         />
-        <!-- <a-input v-model:value="editMember.remark" placeholder="请输入备注" /> -->
       </a-form-item>
     </a-form>
   </a-modal>
 </template>
 
 <script lang="ts" setup>
-import { ref, defineProps, toRaw } from 'vue'
+import { ref, defineProps, toRaw, onMounted, computed } from 'vue'
+import { fetchProjects, fetchRoles } from '@/api'
+import router from '@/router'
 
-import type { Member } from '@/api'
+import type { Member, Project, Role } from '@/api'
 import type { FormInstance } from 'ant-design-vue'
 
 export type EditMember = PartialPart<Member, 'teamId'>
@@ -71,8 +112,21 @@ const props = defineProps<{
   onCancel: () => void
 }>()
 
+const projectOptions = ref<Pick<Project, 'projectId' | 'projectName'>[]>([])
+const roleOptions = ref<Pick<Role, 'roleId' | 'roleName'>[]>()
+const currentProjectId = computed(() =>
+  Number(router.currentRoute.value.params.id)
+)
 const editMember = ref<EditMember>(
-  props.member ? { ...props.member } : { userName: '', remark: '' }
+  props.member
+    ? { ...props.member }
+    : {
+        userName: '',
+        remark: '',
+        roleId: null as unknown as number,
+        ddUserName: '',
+        projectId: currentProjectId.value
+      }
 )
 const fromRef = ref<FormInstance>()
 const visible = ref(true)
@@ -81,10 +135,19 @@ const saveHandler = async () => {
   await fromRef.value?.validate()
   await props.onSave({
     ...toRaw(editMember.value),
-    remark: editMember.value.remark || ''
+    remark: editMember.value.remark || '',
+    ddUserName: editMember.value.ddUserName || ''
   })
   visible.value = false
 }
+
+onMounted(async () => {
+  const allParams = { pageNum: 1, pageSize: 100000 }
+  projectOptions.value = (await fetchProjects(allParams)).list
+  roleOptions.value = (
+    await fetchRoles({ ...allParams, projectId: currentProjectId.value })
+  ).list
+})
 </script>
 
 <style lang="scss" scoped>

+ 2 - 2
src/views/member/list.vue

@@ -33,7 +33,7 @@
 
 <script lang="ts" setup>
 import EditMember from './edit.vue'
-import { computed, reactive, watchEffect } from 'vue'
+import { computed, reactive } from 'vue'
 import { BodyPanlHeader, BodyPanlBody } from '@/layout/panl'
 import { usePaging } from '@/hook'
 import { router } from '@/router'
@@ -74,7 +74,7 @@ const setMember = (member?: Member) => {
         await updateMember(member as Member)
       } else {
         await checkMemberUserName(member.userName)
-        await addMember(params.projectId, member)
+        await addMember(member)
       }
       await updateList()
     }

+ 0 - 0
src/views/personal/edit.vue


+ 0 - 0
src/views/personal/list.vue


+ 13 - 2
src/views/project/columns.ts

@@ -1,11 +1,14 @@
 import { h } from 'vue'
 import { ProjectStatusDesc, ProjectStatus } from '@/api'
 import { router, RoutesName } from '@/router'
+import { useUserStore, pinia } from '@/store'
 import unProjectPic from '@/assets/images/un-project-pic.png'
 
 import type { SimpleProject } from '@/api'
 import type { ColumnsType } from 'ant-design-vue/es/table'
 
+const userStore = useUserStore(pinia)
+
 export const ProjectStatusComp = (props: SimpleProject) => {
   const desc = ProjectStatusDesc[props.projectStatus]
   const style = {
@@ -65,9 +68,17 @@ export const projectColumns: ColumnsType<SimpleProject> = [
     dataIndex: 'projectId',
     key: 'projectId',
     customRender({ record }) {
+      const allMenus = [
+        RoutesName.projectScenes,
+        RoutesName.projectTaggings,
+        RoutesName.projectMembers,
+        RoutesName.projectRoles
+      ]
+      const roleMenus = userStore.$state.current.role.roleMenus
+      const targetMenu = allMenus.find(menu => roleMenus.includes(menu))
       const onManage = () => {
         router.push({
-          name: RoutesName.projectScenes,
+          name: targetMenu,
           params: { id: record.projectId }
         })
       }
@@ -79,7 +90,7 @@ export const projectColumns: ColumnsType<SimpleProject> = [
         {
           class: 'table-actions'
         },
-        [renderManage]
+        targetMenu ? [renderManage] : []
       )
     }
   }

+ 17 - 4
src/views/project/detailed.vue

@@ -49,8 +49,8 @@ import EditProject from './edit.vue'
 import { Modal } from 'ant-design-vue'
 import { HeadPanl, BodyPanl } from '@/layout/panl'
 import { router, RoutesName, routesMetas } from '@/router'
-import { computed, onActivated, onDeactivated, toRef } from 'vue'
-import { useProject, ProjectStatus } from '@/store'
+import { computed, onActivated, onDeactivated, ref, toRef, watch } from 'vue'
+import { useProject, ProjectStatus, useUserStore } from '@/store'
 import { useRealtime } from '@/hook'
 import { renderModal } from '@/helper'
 import { uploadFile } from '@/api'
@@ -63,14 +63,27 @@ const simples = computed(() => [
   { label: '进行中', value: 2 }
 ])
 
-const tabOptions = [
+const allOptions = [
   // RoutesName.projectMaterial,
   RoutesName.projectScenes,
-  RoutesName.projectMembers
+  RoutesName.projectTaggings,
+  RoutesName.projectMembers,
+  RoutesName.projectRoles
 ].map(name => ({
   key: name,
   label: routesMetas[name].title
 }))
+
+const tabOptions = ref(allOptions)
+
+watch(
+  () => useUserStore().$state.current.role.roleMenus,
+  enables => {
+    tabOptions.value = allOptions.filter(option => enables.includes(option.key))
+  },
+  { immediate: true }
+)
+
 const activeTabName = computed(
   () => router.currentRoute.value.name as RoutesName
 )

+ 31 - 0
src/views/role/columns.ts

@@ -0,0 +1,31 @@
+import type { Role } from '@/api'
+import type { ColumnsType } from 'ant-design-vue/es/table'
+
+export const roleColumns: ColumnsType<Role> = [
+  // {
+  //   title: '序列',
+  //   dataIndex: 'teamId',
+  //   key: 'teamId',
+  //   customRender: ({ index }) => index + 1
+  // },
+  {
+    title: '角色名称',
+    dataIndex: 'roleName',
+    key: 'roleName'
+  },
+  {
+    title: '所属项目',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '备注',
+    dataIndex: 'remark',
+    key: 'remark'
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createTime',
+    key: 'createTime'
+  }
+]

+ 150 - 0
src/views/role/edit.vue

@@ -0,0 +1,150 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :title="`${role?.roleId ? '修改' : '新增'}项目角色`"
+    width="480px"
+    :after-close="onCancel"
+    @ok="saveHandler"
+  >
+    <template #footer>
+      <a-button class="action-bottom" size="middle" @click="visible = false">
+        取消
+      </a-button>
+      <a-button
+        class="action-bottom"
+        type="primary"
+        size="middle"
+        @click="saveHandler"
+      >
+        保存
+      </a-button>
+    </template>
+
+    <a-form
+      ref="fromRef"
+      :model="editRole"
+      class="form"
+      label-align="right"
+      :label-col="{ span: 5 }"
+    >
+      <a-form-item
+        name="roleName"
+        label="角色名称"
+        :rules="[{ required: true, message: '请输入角色名称' }]"
+      >
+        <a-input
+          v-model:value="editRole.roleName"
+          placeholder="请输入角色名称"
+        />
+      </a-form-item>
+      <a-form-item
+        name="remark"
+        label="备注"
+        :rules="[{ required: false, max: 50, message: '备注最多50字' }]"
+      >
+        <a-textarea
+          v-model:value.trim="editRole.remark"
+          :resize="false"
+          style="height: 104px; resize: none"
+          placeholder="请输入备注,最多50字"
+        />
+      </a-form-item>
+      <a-form-item name="roleMenus" label="菜单分配">
+        <div class="menu-layer ant-input">
+          <a-tree
+            v-model:checkedKeys="editRole.roleMenus"
+            default-expand-all
+            checkable
+            :tree-data="roleMenusTree"
+          />
+        </div>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, defineProps, toRaw } from 'vue'
+import { RoutesName, routesMetas } from '@/router'
+
+import type { Role } from '@/api'
+import type { FormInstance, TreeProps } from 'ant-design-vue'
+
+export type EditRole = PartialPart<Role, 'roleId' | 'createTime'>
+
+defineOptions<{ name: 'edit-member' }>()
+
+const props = defineProps<{
+  role?: EditRole
+  onSave: (data: EditRole) => void
+  onCancel: () => void
+}>()
+
+const roleMenusTree: TreeProps['treeData'] = [
+  {
+    title: routesMetas[RoutesName.personal].title,
+    key: RoutesName.personal
+  },
+  {
+    title: routesMetas[RoutesName.project].title,
+    key: RoutesName.project,
+    children: [
+      {
+        title: routesMetas[RoutesName.projectScenes].title,
+        key: RoutesName.projectScenes
+      },
+      {
+        title: routesMetas[RoutesName.projectTaggings].title,
+        key: RoutesName.projectTaggings
+      },
+      {
+        title: routesMetas[RoutesName.projectMembers].title,
+        key: RoutesName.projectMembers
+      },
+      {
+        title: routesMetas[RoutesName.projectRoles].title,
+        key: RoutesName.projectRoles
+      }
+    ]
+  }
+]
+
+const editRole = ref<EditRole>(
+  props.role
+    ? { ...props.role }
+    : {
+        remark: '',
+        roleName: '',
+        roleMenus: [RoutesName.projectRoles, RoutesName.projectMembers]
+      }
+)
+const fromRef = ref<FormInstance>()
+const visible = ref(true)
+
+const saveHandler = async () => {
+  await fromRef.value?.validate()
+  await props.onSave({
+    ...toRaw(editRole.value),
+    remark: editRole.value.remark || '',
+    roleMenus: editRole.value.roleMenus || []
+  })
+  visible.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  p {
+    margin-bottom: 0;
+    color: #646566;
+    font-size: 14px;
+  }
+}
+
+.menu-layer {
+}
+</style>

+ 92 - 0
src/views/role/list.vue

@@ -0,0 +1,92 @@
+<template>
+  <BodyPanlHeader>
+    <a-button type="primary" @click="setRole()">新增角色</a-button>
+    <div>
+      <a-input-search
+        v-model:value="params.roleName"
+        style="width: 280px"
+        placeholder="请输入角色名称"
+        allow-clear
+        @search="updateList"
+      />
+    </div>
+  </BodyPanlHeader>
+
+  <BodyPanlBody>
+    <a-table
+      :data-source="list"
+      :columns="roleColumns"
+      :pagination="pagination"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'action'">
+          <div class="table-actions">
+            <a @click="setRole(record)">编辑</a>
+            <a class="warn" @click="delRoleHandler(record)">删除</a>
+          </div>
+        </template>
+      </template>
+    </a-table>
+  </BodyPanlBody>
+</template>
+
+<script lang="ts" setup>
+import EditRole from './edit.vue'
+import { computed, reactive } from 'vue'
+import { BodyPanlHeader, BodyPanlBody } from '@/layout/panl'
+import { usePaging } from '@/hook'
+import { router } from '@/router'
+import { renderModal } from '@/helper'
+import { roleColumns as baseColumns } from './columns'
+import { Modal } from 'ant-design-vue'
+import { fetchRoles, addRole, updateRole, deleteRole } from '@/api'
+
+import type { Role } from '@/api'
+
+const roleColumns = [
+  ...baseColumns,
+  {
+    title: '操作',
+    dataIndex: 'action',
+    key: 'action'
+  }
+]
+
+const params = reactive({
+  roleName: '',
+  projectId: computed(() => Number(router.currentRoute.value.params.id))
+})
+
+const { list, pagination, updateList } = usePaging(fetchRoles, params)
+
+const setRole = (role?: Role) => {
+  renderModal(EditRole, {
+    role,
+    async onSave(role) {
+      if (role.roleId) {
+        await updateRole(role as Role)
+      } else {
+        await addRole(params.projectId, role)
+      }
+      await updateList()
+    }
+  })
+}
+
+const delRoleHandler = (role: Role) => {
+  Modal.confirm({
+    content: '确定要删除此角色',
+    title: '系统提示',
+    width: '400px',
+    okText: '删除',
+    icon: null,
+    cancelText: '取消',
+    onOk: async () => {
+      try {
+        await deleteRole(role.roleId)
+        await updateList()
+      } catch {}
+    }
+  })
+}
+</script>

+ 46 - 0
src/views/taggings/columns.ts

@@ -0,0 +1,46 @@
+import type { Tagging } from '@/api'
+import type { ColumnsType } from 'ant-design-vue/es/table'
+import { h } from 'vue'
+import { TaggingStatusDesc } from '@/api'
+
+export const taggingColumns: ColumnsType<Tagging> = [
+  // {
+  //   title: '序列',
+  //   dataIndex: 'teamId',
+  //   key: 'teamId',
+  //   customRender: ({ index }) => index + 1
+  // },
+  {
+    title: '备注名称',
+    key: 'remark',
+    dataIndex: 'remark'
+  },
+  {
+    title: '创建人',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '涉及的成员',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '状态',
+    dataIndex: 'status',
+    key: 'status',
+    customRender(data) {
+      return h('span', TaggingStatusDesc[data.record.status] || '未知')
+    }
+  },
+  {
+    title: '最后修改人',
+    dataIndex: 'userName',
+    key: 'userName'
+  },
+  {
+    title: '最后修改时间',
+    dataIndex: 'createTime',
+    key: 'createTime'
+  }
+]

+ 93 - 0
src/views/taggings/list.vue

@@ -0,0 +1,93 @@
+<template>
+  <BodyPanlHeader>
+    <div>
+      <a-radio-group v-model:value="type">
+        <a-radio-button value="all">所有</a-radio-button>
+        <a-radio-button v-for="option in types" :key="option" :value="option">
+          {{ TaggingStatusDesc[option] }}
+        </a-radio-button>
+      </a-radio-group>
+    </div>
+    <div>
+      <a-input-search
+        v-model:value="filterName"
+        style="width: 280px"
+        placeholder="请输入资料名称或备注关键字"
+        @search="updateMaterials"
+      />
+    </div>
+  </BodyPanlHeader>
+
+  <BodyPanlBody>
+    <a-table
+      :data-source="materials"
+      :columns="materialColumns"
+      :pagination="pagination"
+    >
+      <template #bodyCell="{ column }">
+        <template v-if="column.key === 'action'">
+          <div class="table-actions">
+            <a>查看</a>
+            <a>前往</a>
+          </div>
+        </template>
+      </template>
+    </a-table>
+  </BodyPanlBody>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, computed, watch } from 'vue'
+import { BodyPanlHeader, BodyPanlBody } from '@/layout/panl'
+import { useRealtime } from '@/hook'
+import { router } from '@/router'
+import { taggingColumns as baseColumns } from './columns'
+import { fetchTaggings, TaggingStatus, TaggingStatusDesc } from '@/api'
+import type { FetchTaggingsProps } from '@/api'
+
+const materialColumns = [
+  ...baseColumns,
+  {
+    title: '操作',
+    dataIndex: 'action',
+    key: 'action'
+  }
+]
+
+const types = [
+  TaggingStatus.pending,
+  TaggingStatus.progress,
+  TaggingStatus.unsolved,
+  TaggingStatus.solved
+]
+const type = ref<TaggingStatus | 'all'>('all')
+const filterName = ref('')
+const projectId = computed(() => Number(router.currentRoute.value.params.id))
+const pagination = reactive({
+  current: 1,
+  total: 0,
+  pageSize: 12,
+  onChange: (current: number) => {
+    pagination.current = current
+    updateMaterials()
+  }
+})
+
+const [materials, updateMaterials] = useRealtime(async () => {
+  const params: FetchTaggingsProps = {
+    userName: filterName.value,
+    projectId: projectId.value,
+    pageNum: pagination.current,
+    pageSize: pagination.pageSize
+  }
+  if (type.value !== 'all') {
+    params.status = type.value
+  }
+  const result = await fetchTaggings(params)
+  pagination.total = result.total
+
+  return result.list
+}, [])
+
+watch(type, updateMaterials)
+</script>