first commit
This commit is contained in:
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_TITLE=CodePort 码头
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_TITLE=CodePort 码头
|
||||
VITE_API_BASE_URL=/api
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
80
README.md
Normal file
80
README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# CodePort 码头 - 管理后台
|
||||
|
||||
人才外包平台管理后台系统
|
||||
|
||||
## 项目简介
|
||||
|
||||
CodePort 码头是一个人才外包平台的管理后台系统,提供完整的业务管理功能。
|
||||
|
||||
## 功能模块
|
||||
|
||||
- **控制台** - 数据总览和工作台
|
||||
- **社区管理** - 帖子、评论、标签、城市圈子管理
|
||||
- **内容管理** - 文章管理
|
||||
- **客服管理** - 接入会话、会话列表
|
||||
- **项目管理** - 项目列表、招募管理、已成交项目、合同管理、会话管理
|
||||
- **人才管理** - 人才列表、简历模板管理
|
||||
- **用户管理** - 用户列表、认证管理、角色管理、岗位管理、等级配置
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Vue 3** - 渐进式 JavaScript 框架
|
||||
- **TypeScript** - 类型安全的 JavaScript 超集
|
||||
- **Vite** - 下一代前端构建工具
|
||||
- **Ant Design Vue 4** - 企业级 UI 组件库
|
||||
- **Vue Router 4** - 官方路由管理器
|
||||
- **Pinia** - 轻量级状态管理
|
||||
- **Axios** - HTTP 客户端
|
||||
|
||||
## 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览构建结果
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
codePort/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── assets/ # 资源文件
|
||||
│ ├── components/ # 公共组件
|
||||
│ ├── config/ # 项目配置
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ ├── mock/ # Mock 数据
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── types/ # TypeScript 类型
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面视图
|
||||
│ ├── App.vue # 根组件
|
||||
│ ├── main.ts # 入口文件
|
||||
│ └── style.css # 全局样式
|
||||
├── index.html # HTML 模板
|
||||
├── package.json # 依赖配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── vite.config.ts # Vite 配置
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
创建 `.env.development` 或 `.env.production` 文件配置环境变量:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=/api
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodePort 码头 - 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2947
package-lock.json
generated
Normal file
2947
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "codeport-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "CodePort 码头 - 人才外包平台管理后台",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@types/echarts": "^4.9.22",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.0",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.13.2",
|
||||
"echarts": "^6.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.412",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-icons": "^22.5.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
17
src/App.vue
Normal file
17
src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ConfigProvider } from 'ant-design-vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="zhCN">
|
||||
<router-view />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
91
src/auto-imports.d.ts
vendored
Normal file
91
src/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const axios: typeof import('axios').default
|
||||
const computed: typeof import('vue').computed
|
||||
const createApp: typeof import('vue').createApp
|
||||
const createPinia: typeof import('pinia').createPinia
|
||||
const customRef: typeof import('vue').customRef
|
||||
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||
const defineComponent: typeof import('vue').defineComponent
|
||||
const defineStore: typeof import('pinia').defineStore
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const getActivePinia: typeof import('pinia').getActivePinia
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const h: typeof import('vue').h
|
||||
const inject: typeof import('vue').inject
|
||||
const isProxy: typeof import('vue').isProxy
|
||||
const isReactive: typeof import('vue').isReactive
|
||||
const isReadonly: typeof import('vue').isReadonly
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const mapActions: typeof import('pinia').mapActions
|
||||
const mapGetters: typeof import('pinia').mapGetters
|
||||
const mapState: typeof import('pinia').mapState
|
||||
const mapStores: typeof import('pinia').mapStores
|
||||
const mapWritableState: typeof import('pinia').mapWritableState
|
||||
const markRaw: typeof import('vue').markRaw
|
||||
const nextTick: typeof import('vue').nextTick
|
||||
const onActivated: typeof import('vue').onActivated
|
||||
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||
const onDeactivated: typeof import('vue').onDeactivated
|
||||
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||
const onMounted: typeof import('vue').onMounted
|
||||
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const provide: typeof import('vue').provide
|
||||
const reactive: typeof import('vue').reactive
|
||||
const readonly: typeof import('vue').readonly
|
||||
const ref: typeof import('vue').ref
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const setActivePinia: typeof import('pinia').setActivePinia
|
||||
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const storeToRefs: typeof import('pinia').storeToRefs
|
||||
const toRaw: typeof import('vue').toRaw
|
||||
const toRef: typeof import('vue').toRef
|
||||
const toRefs: typeof import('vue').toRefs
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const unref: typeof import('vue').unref
|
||||
const useAttrs: typeof import('vue').useAttrs
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useId: typeof import('vue').useId
|
||||
const useLink: typeof import('vue-router').useLink
|
||||
const useModel: typeof import('vue').useModel
|
||||
const useRoute: typeof import('vue-router').useRoute
|
||||
const useRouter: typeof import('vue-router').useRouter
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
const watch: typeof import('vue').watch
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
76
src/components.d.ts
vendored
Normal file
76
src/components.d.ts
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
|
||||
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
ACard: typeof import('ant-design-vue/es')['Card']
|
||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||
ADrawer: typeof import('ant-design-vue/es')['Drawer']
|
||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
|
||||
ALayout: typeof import('ant-design-vue/es')['Layout']
|
||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
||||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
||||
AList: typeof import('ant-design-vue/es')['List']
|
||||
AListItem: typeof import('ant-design-vue/es')['ListItem']
|
||||
AMenu: typeof import('ant-design-vue/es')['Menu']
|
||||
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
|
||||
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
||||
AMenuItemGroup: typeof import('ant-design-vue/es')['MenuItemGroup']
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
|
||||
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
||||
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
|
||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATabPane: typeof import('ant-design-vue/es')['TabPane']
|
||||
ATabs: typeof import('ant-design-vue/es')['Tabs']
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATimeline: typeof import('ant-design-vue/es')['Timeline']
|
||||
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||
ChatConversation: typeof import('./components/ChatConversation.vue')['default']
|
||||
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||
RichTextEditor: typeof import('./components/RichTextEditor.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
385
src/components/ChatConversation.vue
Normal file
385
src/components/ChatConversation.vue
Normal file
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div class="conversation-container">
|
||||
<div v-if="conversation" class="chat-panel">
|
||||
<div class="chat-header">
|
||||
<div class="chat-user">
|
||||
<a-avatar :src="conversation.customer.avatar" :size="48" />
|
||||
<div class="chat-user-info">
|
||||
<div class="chat-user-name">
|
||||
{{ conversation.customer.nickname }}
|
||||
<a-tag color="gold" v-if="conversation.customer.vip">VIP</a-tag>
|
||||
</div>
|
||||
<div class="chat-user-meta">
|
||||
<span>{{ conversation.customer.city }}</span>
|
||||
<span>等级 {{ conversation.customer.level }}</span>
|
||||
<span>意图 {{ conversation.autoDetectedIntent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-actions" v-if="showActions">
|
||||
<a-dropdown placement="bottomRight">
|
||||
<a-button>
|
||||
<UserSwitchOutlined /> 分配
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => emitAssign(Number(key))">
|
||||
<a-menu-item v-for="agent in supportAgents" :key="agent.id">
|
||||
{{ agent.name }} · {{ agent.title }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-button
|
||||
v-if="conversation.status !== 'resolved'"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="emitResolve"
|
||||
>
|
||||
<CheckCircleOutlined /> 结单
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-tags">
|
||||
<slot name="tags" :conversation="conversation">
|
||||
<a-tag :color="channelMap[conversation.channel].color">{{ channelMap[conversation.channel].label }}</a-tag>
|
||||
<a-tag v-for="tag in conversation.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="chat-body" ref="messageListRef">
|
||||
<div
|
||||
v-for="message in conversation.messages"
|
||||
:key="message.id"
|
||||
:class="['chat-message', message.sender]"
|
||||
>
|
||||
<div class="chat-message-wrapper">
|
||||
<div class="chat-message-meta">
|
||||
<span class="meta-name">{{ message.senderName }}</span>
|
||||
<span class="meta-time">{{ formatDateTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="chat-message-content">{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-reply" v-if="quickReplies.length">
|
||||
<span class="quick-title">快捷回复</span>
|
||||
<a-space>
|
||||
<a-tag
|
||||
v-for="reply in quickReplies"
|
||||
:key="reply.key"
|
||||
color="blue"
|
||||
@click="applyQuickReply(reply.content)"
|
||||
>
|
||||
{{ reply.label }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="chat-composer">
|
||||
<a-textarea
|
||||
:value="modelValue"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
:disabled="sending || !conversation"
|
||||
placeholder="输入回复内容"
|
||||
@input="val => emitUpdate((val.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<div class="composer-actions">
|
||||
<div class="composer-hint">
|
||||
<span>Enter 发送</span>
|
||||
<span v-if="statusText">WebStork 状态:{{ statusText }}</span>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="emitUpdate('')" :disabled="sending">清空</a-button>
|
||||
<a-button type="primary" :loading="sending" @click="handleSend">
|
||||
<SendOutlined /> 发送
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chat-panel empty">
|
||||
<slot name="empty">
|
||||
<a-empty description="请选择会话" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
UserSwitchOutlined,
|
||||
CheckCircleOutlined,
|
||||
SendOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ConversationSession, ConversationAgent } from '@/types'
|
||||
import { formatDateTime } from '@/utils/common'
|
||||
|
||||
interface QuickReply {
|
||||
key: string
|
||||
label: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object as () => ConversationSession | null,
|
||||
default: null
|
||||
},
|
||||
quickReplies: {
|
||||
type: Array as () => QuickReply[],
|
||||
default: () => []
|
||||
},
|
||||
supportAgents: {
|
||||
type: Array as () => ConversationAgent[],
|
||||
default: () => []
|
||||
},
|
||||
sending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
statusText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'send', content: string): void
|
||||
(e: 'assign', agentId: number): void
|
||||
(e: 'resolve'): void
|
||||
}>()
|
||||
|
||||
const messageListRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const channelMap = {
|
||||
app: { label: 'App', color: 'blue' },
|
||||
web: { label: 'Web', color: 'geekblue' },
|
||||
wechat: { label: '企业微信', color: 'cyan' },
|
||||
miniapp: { label: '小程序', color: 'purple' }
|
||||
} as const
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messageListRef.value) {
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.conversation?.messages.length,
|
||||
() => {
|
||||
if (props.conversation) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.conversation?.id,
|
||||
() => {
|
||||
emit('update:modelValue', '')
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
|
||||
function emitUpdate(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!props.conversation || !props.modelValue.trim()) {
|
||||
message.warning('请输入内容')
|
||||
return
|
||||
}
|
||||
emit('send', props.modelValue)
|
||||
}
|
||||
|
||||
function emitAssign(agentId: number) {
|
||||
emit('assign', agentId)
|
||||
}
|
||||
|
||||
function emitResolve() {
|
||||
emit('resolve')
|
||||
}
|
||||
|
||||
function applyQuickReply(content: string) {
|
||||
emit('update:modelValue', content)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conversation-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-panel.empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-user {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-user-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-user-meta {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(180deg, #fafafa 0%, #ffffff 45%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.chat-message-wrapper {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.chat-message.agent {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-message.agent .chat-message-wrapper {
|
||||
border-color: #bae7ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.chat-message.user .chat-message-wrapper {
|
||||
border-color: #ffe7ba;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.chat-message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-message-content {
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.quick-reply {
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-title {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.composer-hint {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
41
src/components/HelloWorld.vue
Normal file
41
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
144
src/components/RichTextEditor.vue
Normal file
144
src/components/RichTextEditor.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="rich-editor">
|
||||
<div class="toolbar">
|
||||
<a-space size="small">
|
||||
<a-button size="small" @mousedown.prevent="exec('bold')">
|
||||
<BoldOutlined />
|
||||
</a-button>
|
||||
<a-button size="small" @mousedown.prevent="exec('italic')">
|
||||
<ItalicOutlined />
|
||||
</a-button>
|
||||
<a-button size="small" @mousedown.prevent="exec('underline')">
|
||||
<UnderlineOutlined />
|
||||
</a-button>
|
||||
<a-button size="small" @mousedown.prevent="exec('insertOrderedList')">
|
||||
<OrderedListOutlined />
|
||||
</a-button>
|
||||
<a-button size="small" @mousedown.prevent="exec('insertUnorderedList')">
|
||||
<UnorderedListOutlined />
|
||||
</a-button>
|
||||
<a-button size="small" @mousedown.prevent="createLink">
|
||||
<LinkOutlined />
|
||||
</a-button>
|
||||
<a-upload
|
||||
accept="image/*"
|
||||
:before-upload="handleImageUpload"
|
||||
:show-upload-list="false"
|
||||
>
|
||||
<a-button size="small">
|
||||
<PictureOutlined /> 图片
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</a-space>
|
||||
</div>
|
||||
<div
|
||||
class="editor"
|
||||
ref="editorRef"
|
||||
contenteditable
|
||||
:data-placeholder="placeholder"
|
||||
@input="handleInput"
|
||||
@blur="handleInput"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import {
|
||||
BoldOutlined,
|
||||
ItalicOutlined,
|
||||
UnderlineOutlined,
|
||||
OrderedListOutlined,
|
||||
UnorderedListOutlined,
|
||||
LinkOutlined,
|
||||
PictureOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { UploadProps } from 'ant-design-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入内容...'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const editorRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function setContent(value: string) {
|
||||
if (editorRef.value && editorRef.value.innerHTML !== value) {
|
||||
editorRef.value.innerHTML = value || ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
emit('update:modelValue', editorRef.value?.innerHTML || '')
|
||||
}
|
||||
|
||||
function exec(command: string, value?: string) {
|
||||
editorRef.value?.focus()
|
||||
document.execCommand(command, false, value)
|
||||
handleInput()
|
||||
}
|
||||
|
||||
function createLink() {
|
||||
const url = window.prompt('请输入链接地址')
|
||||
if (url) {
|
||||
exec('createLink', url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
exec('insertImage', reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setContent(props.modelValue)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
setContent(val)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-editor {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 220px;
|
||||
padding: 12px;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor:empty:before {
|
||||
content: attr(data-placeholder);
|
||||
color: #bfbfbf;
|
||||
}
|
||||
</style>
|
||||
4
src/config/index.ts
Normal file
4
src/config/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 配置模块统一导出
|
||||
*/
|
||||
export * from './project'
|
||||
324
src/config/project.ts
Normal file
324
src/config/project.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* CodePort 码头 - 项目配置
|
||||
*
|
||||
* 人才外包平台管理后台
|
||||
*/
|
||||
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
ReadOutlined,
|
||||
CustomerServiceOutlined,
|
||||
ProjectOutlined,
|
||||
IdcardOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
// 菜单项类型
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: Component
|
||||
path?: string
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
// 模块配置
|
||||
export interface ModuleConfig {
|
||||
id: string
|
||||
name: string
|
||||
icon?: Component
|
||||
menus: MenuItem[]
|
||||
routes: RouteConfig[]
|
||||
}
|
||||
|
||||
// 路由配置
|
||||
export interface RouteConfig {
|
||||
path: string
|
||||
name: string
|
||||
component: string
|
||||
title: string
|
||||
}
|
||||
|
||||
// 项目配置
|
||||
export interface ProjectConfig {
|
||||
id: string
|
||||
name: string
|
||||
logo: string
|
||||
shortName: string
|
||||
description: string
|
||||
modules: ModuleConfig[]
|
||||
}
|
||||
|
||||
// ==================== CodePort 业务模块 ====================
|
||||
|
||||
// 控制台模块
|
||||
export const dashboardModule: ModuleConfig = {
|
||||
id: 'dashboard',
|
||||
name: '控制台',
|
||||
icon: DashboardOutlined,
|
||||
menus: [
|
||||
{ key: 'dashboard', label: '控制台', path: '/dashboard' }
|
||||
],
|
||||
routes: [
|
||||
{ path: 'dashboard', name: 'Dashboard', component: '@/views/dashboard/index.vue', title: '控制台' }
|
||||
]
|
||||
}
|
||||
|
||||
// 社区管理模块
|
||||
export const communityModule: ModuleConfig = {
|
||||
id: 'community',
|
||||
name: '社区管理',
|
||||
icon: TeamOutlined,
|
||||
menus: [
|
||||
{
|
||||
key: 'community',
|
||||
label: '社区管理',
|
||||
children: [
|
||||
{ key: 'posts', label: '帖子管理', path: '/community/posts' },
|
||||
{ key: 'comments', label: '评论管理', path: '/community/comments' },
|
||||
{ key: 'tags', label: '标签管理', path: '/community/tags' },
|
||||
{ key: 'circles', label: '城市圈子', path: '/community/circles' }
|
||||
]
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'community/posts', name: 'Posts', component: '@/views/community/posts/index.vue', title: '帖子管理' },
|
||||
{ path: 'community/comments', name: 'Comments', component: '@/views/community/comments/index.vue', title: '评论管理' },
|
||||
{ path: 'community/tags', name: 'Tags', component: '@/views/community/tags/index.vue', title: '标签管理' },
|
||||
{ path: 'community/circles', name: 'CityCircles', component: '@/views/community/circles/index.vue', title: '城市圈子' }
|
||||
]
|
||||
}
|
||||
|
||||
// 内容管理模块
|
||||
export const contentModule: ModuleConfig = {
|
||||
id: 'content',
|
||||
name: '内容管理',
|
||||
icon: ReadOutlined,
|
||||
menus: [
|
||||
{
|
||||
key: 'content',
|
||||
label: '内容管理',
|
||||
children: [
|
||||
{ key: 'articles', label: '文章管理', path: '/content/articles' }
|
||||
]
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'content/articles', name: 'Articles', component: '@/views/content/articles/index.vue', title: '文章管理' }
|
||||
]
|
||||
}
|
||||
|
||||
// 客服管理模块
|
||||
export const supportModule: ModuleConfig = {
|
||||
id: 'support',
|
||||
name: '客服管理',
|
||||
icon: CustomerServiceOutlined,
|
||||
menus: [
|
||||
{
|
||||
key: 'support',
|
||||
label: '客服管理',
|
||||
children: [
|
||||
{ key: 'support-console', label: '接入会话', path: '/support/console' },
|
||||
{ key: 'support-conversations', label: '会话列表', path: '/support/conversations' }
|
||||
]
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'support/console', name: 'SupportConsole', component: '@/views/support/session/index.vue', title: '接入会话' },
|
||||
{ path: 'support/conversations', name: 'SupportConversations', component: '@/views/support/conversations/index.vue', title: '会话列表' }
|
||||
]
|
||||
}
|
||||
|
||||
// 项目管理模块
|
||||
export const projectModule: ModuleConfig = {
|
||||
id: 'project',
|
||||
name: '项目管理',
|
||||
icon: ProjectOutlined,
|
||||
menus: [
|
||||
{
|
||||
key: 'project',
|
||||
label: '项目管理',
|
||||
children: [
|
||||
{ key: 'projects', label: '项目列表', path: '/project/list' },
|
||||
{ key: 'recruitment', label: '招募管理', path: '/project/recruitment' },
|
||||
{ key: 'signed-projects', label: '已成交项目', path: '/project/signed' },
|
||||
{ key: 'contracts', label: '合同管理', path: '/project/contract' },
|
||||
{ key: 'sessions', label: '会话管理', path: '/project/sessions' }
|
||||
]
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'project/list', name: 'Projects', component: '@/views/project/list/index.vue', title: '项目列表' },
|
||||
{ path: 'project/recruitment', name: 'Recruitment', component: '@/views/project/recruitment/index.vue', title: '招募管理' },
|
||||
{ path: 'project/signed', name: 'SignedProjects', component: '@/views/project/signed/index.vue', title: '已成交项目' },
|
||||
{ path: 'project/contract', name: 'ContractManagement', component: '@/views/project/contract/index.vue', title: '合同管理' },
|
||||
{ path: 'project/sessions', name: 'ProjectSessions', component: '@/views/project/session/index.vue', title: '会话管理' },
|
||||
{ path: 'project/sessions/:id', name: 'ProjectSessionDetail', component: '@/views/project/session/detail.vue', title: '会话详情' }
|
||||
]
|
||||
}
|
||||
|
||||
// 人才管理模块
|
||||
export const talentModule: ModuleConfig = {
|
||||
id: 'talent',
|
||||
name: '人才管理',
|
||||
icon: IdcardOutlined,
|
||||
menus: [
|
||||
{
|
||||
key: 'talent',
|
||||
label: '人才管理',
|
||||
children: [
|
||||
{ key: 'talent-list', label: '人才列表', path: '/talent' },
|
||||
{ key: 'resume-templates', label: '简历模板', path: '/talent/resume-templates' }
|
||||
]
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'talent', name: 'Talent', component: '@/views/talent/index.vue', title: '人才管理' },
|
||||
{ path: 'talent/resume-templates', name: 'ResumeTemplates', component: '@/views/talent/resume-templates.vue', title: '简历模板管理' },
|
||||
{ path: 'talent/:id', name: 'TalentDetail', component: '@/views/talent/detail.vue', title: '人才详情' }
|
||||
]
|
||||
}
|
||||
|
||||
// 用户管理模块
|
||||
export const userModule: ModuleConfig = {
|
||||
id: 'user',
|
||||
name: '用户管理',
|
||||
icon: UserOutlined,
|
||||
menus: [
|
||||
{
|
||||
key: 'user',
|
||||
label: '用户管理',
|
||||
children: [
|
||||
{ key: 'users', label: '用户列表', path: '/user/list' },
|
||||
{ key: 'certification', label: '认证管理', path: '/user/certification' },
|
||||
{ key: 'roles', label: '角色管理', path: '/user/roles' },
|
||||
{ key: 'positions', label: '岗位管理', path: '/user/positions' },
|
||||
{ key: 'levels', label: '等级配置', path: '/user/levels' }
|
||||
]
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'user/list', name: 'Users', component: '@/views/user/list/index.vue', title: '用户列表' },
|
||||
{ path: 'user/roles', name: 'Roles', component: '@/views/user/roles/index.vue', title: '角色管理' },
|
||||
{ path: 'user/certification', name: 'UserCertification', component: '@/views/user/certification/index.vue', title: '认证管理' },
|
||||
{ path: 'user/positions', name: 'Positions', component: '@/views/user/positions/index.vue', title: '岗位管理' },
|
||||
{ path: 'user/levels', name: 'Levels', component: '@/views/user/levels/index.vue', title: '等级配置' }
|
||||
]
|
||||
}
|
||||
|
||||
// ==================== 项目配置 ====================
|
||||
|
||||
// CodePort 项目配置
|
||||
export const codePortProject: ProjectConfig = {
|
||||
id: 'codePort',
|
||||
name: 'CodePort 码头',
|
||||
logo: '码',
|
||||
shortName: 'CodePort',
|
||||
description: '人才外包平台管理后台',
|
||||
modules: [
|
||||
dashboardModule,
|
||||
communityModule,
|
||||
contentModule,
|
||||
supportModule,
|
||||
projectModule,
|
||||
talentModule,
|
||||
userModule
|
||||
]
|
||||
}
|
||||
|
||||
// 当前项目配置
|
||||
export const currentProject = codePortProject
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 获取当前项目配置
|
||||
*/
|
||||
export function getCurrentProject(): ProjectConfig {
|
||||
return currentProject
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模块
|
||||
*/
|
||||
export function getAllModules(): ModuleConfig[] {
|
||||
return currentProject.modules
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单配置
|
||||
*/
|
||||
export function getMenuConfig(): MenuItem[] {
|
||||
const menus: MenuItem[] = []
|
||||
|
||||
currentProject.modules.forEach(module => {
|
||||
module.menus.forEach(menu => {
|
||||
if (!menu.children) {
|
||||
menus.push({
|
||||
...menu,
|
||||
icon: module.icon
|
||||
})
|
||||
} else {
|
||||
menus.push({
|
||||
...menu,
|
||||
icon: module.icon
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return menus
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单项到路由的映射
|
||||
*/
|
||||
export function getMenuRouteMap(): Record<string, string> {
|
||||
const map: Record<string, string> = {}
|
||||
|
||||
currentProject.modules.forEach(module => {
|
||||
module.menus.forEach(menu => {
|
||||
if (menu.path) {
|
||||
map[menu.key] = menu.path
|
||||
}
|
||||
if (menu.children) {
|
||||
menu.children.forEach(child => {
|
||||
if (child.path) {
|
||||
map[child.key] = child.path
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有路由配置
|
||||
*/
|
||||
export function getRouteConfigs(): RouteConfig[] {
|
||||
const routes: RouteConfig[] = []
|
||||
|
||||
currentProject.modules.forEach(module => {
|
||||
routes.push(...module.routes)
|
||||
})
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务菜单(兼容原框架接口)
|
||||
*/
|
||||
export function getBusinessMenus(): MenuItem[] {
|
||||
return getMenuConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取框架菜单(CodePort 独立项目无框架菜单)
|
||||
*/
|
||||
export function getFrameworkMenus(): MenuItem[] {
|
||||
return []
|
||||
}
|
||||
23
src/layouts/EmbeddedLayout.vue
Normal file
23
src/layouts/EmbeddedLayout.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="embedded-layout">
|
||||
<!-- 嵌入模式下只显示内容区域,不显示菜单栏和头部 -->
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 嵌入式布局 - 用于被父框架 iframe 嵌套时使用
|
||||
// 不包含菜单栏、头部等导航元素,仅显示页面内容
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedded-layout {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.embedded-layout :deep(.ant-page-header) {
|
||||
padding: 0 0 16px 0;
|
||||
}
|
||||
</style>
|
||||
368
src/layouts/MainLayout.vue
Normal file
368
src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<a-layout class="main-layout">
|
||||
<!-- 左侧菜单栏 -->
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
class="sider"
|
||||
>
|
||||
<div class="logo">
|
||||
<span v-if="!collapsed">{{ projectConfig.name }}</span>
|
||||
<span v-else>{{ projectConfig.logo }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 使用动态菜单配置 -->
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<!-- 业务功能模块 -->
|
||||
<a-menu-item-group v-if="businessMenus.length > 0">
|
||||
<template #title>
|
||||
<span class="menu-group-title">{{ projectConfig.shortName }} 业务</span>
|
||||
</template>
|
||||
|
||||
<template v-for="menu in businessMenus" :key="menu.key">
|
||||
<!-- 单个菜单项 -->
|
||||
<a-menu-item v-if="!menu.children" :key="`item-${menu.key}`">
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<a-sub-menu v-else :key="`sub-${menu.key}`">
|
||||
<template #title>
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</template>
|
||||
<a-menu-item v-for="child in menu.children" :key="child.key">
|
||||
{{ child.label }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu-item-group>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<a-menu-divider v-if="businessMenus.length > 0 && frameworkMenus.length > 0" />
|
||||
|
||||
<!-- 框架功能模块 -->
|
||||
<a-menu-item-group v-if="frameworkMenus.length > 0">
|
||||
<template #title>
|
||||
<span class="menu-group-title">通用管理</span>
|
||||
</template>
|
||||
|
||||
<template v-for="menu in frameworkMenus" :key="menu.key">
|
||||
<!-- 单个菜单项 -->
|
||||
<a-menu-item v-if="!menu.children" :key="`item-${menu.key}`">
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<a-sub-menu v-else :key="`sub-${menu.key}`">
|
||||
<template #title>
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</template>
|
||||
<a-menu-item v-for="child in menu.children" :key="child.key">
|
||||
{{ child.label }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu-item-group>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<!-- 顶部栏 -->
|
||||
<a-layout-header class="header">
|
||||
<div class="header-left">
|
||||
<MenuFoldOutlined
|
||||
v-if="!collapsed"
|
||||
class="trigger"
|
||||
@click="collapsed = true"
|
||||
/>
|
||||
<MenuUnfoldOutlined
|
||||
v-else
|
||||
class="trigger"
|
||||
@click="collapsed = false"
|
||||
/>
|
||||
<!-- 面包屑导航 -->
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index">
|
||||
<router-link v-if="index < breadcrumbs.length - 1" :to="item.path">
|
||||
{{ item.title }}
|
||||
</router-link>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
|
||||
<!-- 右侧项目标识和用户信息 -->
|
||||
<div class="header-right">
|
||||
<!-- 项目标识 -->
|
||||
<a-tag color="blue" class="project-tag">
|
||||
{{ projectConfig.shortName }}
|
||||
</a-tag>
|
||||
|
||||
<a-dropdown>
|
||||
<div class="user-info">
|
||||
<a-avatar :src="userStore.avatar || undefined" :size="32">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<span class="username">{{ userStore.nickname || '管理员' }}</span>
|
||||
<DownOutlined />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleUserMenuClick">
|
||||
<a-menu-item key="profile">
|
||||
<UserOutlined />
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-item key="password">
|
||||
<LockOutlined />
|
||||
修改密码
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout">
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<a-layout-content class="content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
DownOutlined,
|
||||
LockOutlined,
|
||||
LogoutOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { getCurrentProject, getMenuConfig, getMenuRouteMap, getBusinessMenus, getFrameworkMenus } from '@/config'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 项目配置
|
||||
const projectConfig = getCurrentProject()
|
||||
const menuConfig = getMenuConfig()
|
||||
const menuRouteMap = getMenuRouteMap()
|
||||
const businessMenus = getBusinessMenus()
|
||||
const frameworkMenus = getFrameworkMenus()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const selectedKeys = ref<string[]>(['dashboard'])
|
||||
const openKeys = ref<string[]>([])
|
||||
|
||||
// 面包屑
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched.filter(item => item.meta?.title)
|
||||
return [
|
||||
{ title: '首页', path: '/' },
|
||||
...matched.map(item => ({
|
||||
title: item.meta?.title as string,
|
||||
path: item.path
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
// 菜单点击
|
||||
function handleMenuClick({ key }: { key: string | number }) {
|
||||
const path = menuRouteMap[String(key)]
|
||||
if (path) {
|
||||
router.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户菜单点击
|
||||
function handleUserMenuClick({ key }: { key: string | number }) {
|
||||
const k = String(key)
|
||||
switch (k) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'password':
|
||||
router.push('/password')
|
||||
break
|
||||
case 'logout':
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
onOk() {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 更新菜单选中状态
|
||||
function updateMenuState() {
|
||||
const path = route.path
|
||||
|
||||
// 查找匹配的菜单项
|
||||
for (const [menuKey, menuPath] of Object.entries(menuRouteMap)) {
|
||||
if (path === menuPath || path.startsWith(menuPath + '/')) {
|
||||
selectedKeys.value = [menuKey]
|
||||
|
||||
// 查找父级菜单并展开
|
||||
for (const menu of menuConfig) {
|
||||
if (menu.children?.some(child => child.key === menuKey)) {
|
||||
if (!openKeys.value.includes(menu.key)) {
|
||||
openKeys.value = [...openKeys.value, menu.key]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.path, updateMenuState, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sider {
|
||||
overflow: auto;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-layout > .ant-layout {
|
||||
margin-left: 200px;
|
||||
transition: margin-left 0.2s;
|
||||
}
|
||||
|
||||
.main-layout > .ant-layout-sider-collapsed + .ant-layout {
|
||||
margin-left: 80px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.project-tag {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
.username {
|
||||
margin: 0 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
height: calc(100vh - 64px - 32px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 菜单分组标题样式 */
|
||||
.menu-group-title {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item-group-title) {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item-divider) {
|
||||
margin: 8px 16px;
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
</style>
|
||||
15
src/main.ts
Normal file
15
src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
|
||||
// Ant Design Vue 全局样式
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
178
src/mock/article.ts
Normal file
178
src/mock/article.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 文章管理 mock
|
||||
*/
|
||||
import type {
|
||||
ArticleInfo,
|
||||
ArticleCategory,
|
||||
ArticleListResult,
|
||||
ArticleQueryParams,
|
||||
ArticleCreatePayload,
|
||||
RuleSubCategoryKey
|
||||
} from '@/types'
|
||||
|
||||
export const ArticleCategories: ArticleCategory[] = [
|
||||
{ key: 'announcement', name: '平台通告', description: '面向所有用户的公告与提醒', color: 'blue' },
|
||||
{ key: 'rule', name: '规则政策', description: '平台规则、合作协议及更新说明', color: 'purple' },
|
||||
{ key: 'activity', name: '活动细则', description: '活动玩法、报名及细则说明', color: 'orange' },
|
||||
{ key: 'guide', name: '使用指南', description: '功能介绍与操作手册', color: 'green' },
|
||||
{ key: 'other', name: '其他内容', description: '无法归类的文章', color: 'default' }
|
||||
]
|
||||
|
||||
// 规则政策相关的预设标签
|
||||
export const RulePolicyTags: Record<RuleSubCategoryKey, string[]> = {
|
||||
withdraw: ['提现', '银行卡', '到账时间', '手续费'],
|
||||
settlement: ['结算', '账单', '收益', '分成'],
|
||||
cooperation: ['合同', '协议', '合作', '条款'],
|
||||
violation: ['违规', '处罚', '封禁', '申诉'],
|
||||
privacy: ['隐私', '数据', '安全', '授权'],
|
||||
other: ['规则', '政策']
|
||||
}
|
||||
|
||||
const authors = [
|
||||
{ id: 1, name: '阿岚', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=alan' },
|
||||
{ id: 2, name: '林舟', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=linzhou' },
|
||||
{ id: 3, name: '南鸢', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=nanyuan' }
|
||||
]
|
||||
|
||||
function randomItem<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)]!
|
||||
}
|
||||
|
||||
function generateArticles(): ArticleInfo[] {
|
||||
const articles: ArticleInfo[] = []
|
||||
for (let i = 1; i <= 36; i++) {
|
||||
const category = ArticleCategories[i % ArticleCategories.length]!.key
|
||||
const author = randomItem(authors)
|
||||
const isPublished = Math.random() > 0.2
|
||||
const createdAt = new Date(Date.now() - i * 24 * 60 * 60 * 1000)
|
||||
const publishedAt = isPublished
|
||||
? new Date(createdAt.getTime() + 6 * 60 * 60 * 1000)
|
||||
: undefined
|
||||
|
||||
articles.push({
|
||||
id: i,
|
||||
title: `【${ArticleCategories[i % ArticleCategories.length]!.name}】第 ${i} 期内容`,
|
||||
summary:
|
||||
'围绕最新的活动安排及平台规范进行了说明,请项目方与人才密切关注,按照流程执行后续操作。',
|
||||
content:
|
||||
'为确保平台运营秩序与活动顺利开展,本次公告重点覆盖:\n' +
|
||||
'1. 活动报名流程与时间节点;\n2. 新增安全风控策略;\n3. 针对人才服务的评分制度更新;\n4. 常见问题处理 FAQ。\n' +
|
||||
'请项目方结合自身安排,按时完成信息填报,如有疑问可联系平台客服通道。',
|
||||
cover: `https://picsum.photos/seed/article-${i}/600/320`,
|
||||
category,
|
||||
tags: ['平台', '更新', i % 2 === 0 ? '活动' : '规则'],
|
||||
publishStatus: isPublished ? 'published' : 'draft',
|
||||
pinned: i % 7 === 0,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: new Date(createdAt.getTime() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
publishedAt: publishedAt?.toISOString(),
|
||||
author,
|
||||
viewCount: 200 + Math.floor(Math.random() * 2000),
|
||||
linkUrl: i % 5 === 0 ? 'https://nanxiislet.com/articles/' + i : undefined
|
||||
})
|
||||
}
|
||||
return articles
|
||||
}
|
||||
|
||||
let articleList = generateArticles()
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function mockGetArticleCategories(): Promise<ArticleCategory[]> {
|
||||
await delay(100)
|
||||
return ArticleCategories
|
||||
}
|
||||
|
||||
export async function mockGetArticleList(params: ArticleQueryParams = {}): Promise<ArticleListResult> {
|
||||
await delay(300)
|
||||
const { keyword, category, status, pinned, page = 1, pageSize = 10 } = params
|
||||
let filtered = [...articleList]
|
||||
|
||||
if (keyword) {
|
||||
const kw = keyword.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
item => item.title.toLowerCase().includes(kw) || item.summary.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
if (category) {
|
||||
filtered = filtered.filter(item => item.category === category)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filtered = filtered.filter(item => item.publishStatus === status)
|
||||
}
|
||||
|
||||
if (pinned !== undefined) {
|
||||
filtered = filtered.filter(item => item.pinned === pinned)
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
const list = filtered.slice(start, start + pageSize)
|
||||
|
||||
return {
|
||||
list,
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockUpdateArticleStatus(
|
||||
id: number,
|
||||
status: ArticleInfo['publishStatus']
|
||||
): Promise<ArticleInfo | undefined> {
|
||||
await delay(200)
|
||||
const target = articleList.find(item => item.id === id)
|
||||
if (target) {
|
||||
target.publishStatus = status
|
||||
if (status === 'published') {
|
||||
target.publishedAt = new Date().toISOString()
|
||||
}
|
||||
target.updatedAt = new Date().toISOString()
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export async function mockToggleArticlePin(id: number, pinned: boolean): Promise<void> {
|
||||
await delay(150)
|
||||
const target = articleList.find(item => item.id === id)
|
||||
if (target) {
|
||||
target.pinned = pinned
|
||||
target.updatedAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockDeleteArticle(id: number): Promise<void> {
|
||||
await delay(150)
|
||||
articleList = articleList.filter(item => item.id !== id)
|
||||
}
|
||||
|
||||
export async function mockCreateArticle(payload: ArticleCreatePayload): Promise<ArticleInfo> {
|
||||
await delay(300)
|
||||
const maxId = articleList.reduce((max, item) => Math.max(max, item.id), 0)
|
||||
const id = maxId + 1
|
||||
const now = new Date().toISOString()
|
||||
const article: ArticleInfo = {
|
||||
id,
|
||||
title: payload.title,
|
||||
summary: payload.summary,
|
||||
content: payload.content,
|
||||
cover: payload.cover,
|
||||
category: payload.category,
|
||||
subCategory: payload.subCategory,
|
||||
tags: payload.tags,
|
||||
publishStatus: payload.publishStatus,
|
||||
pinned: payload.pinned ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: payload.publishStatus === 'published' ? now : undefined,
|
||||
author: payload.author,
|
||||
viewCount: 0,
|
||||
linkUrl: payload.linkUrl
|
||||
}
|
||||
articleList = [article, ...articleList]
|
||||
return article
|
||||
}
|
||||
256
src/mock/certification.ts
Normal file
256
src/mock/certification.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 用户认证模拟数据
|
||||
*/
|
||||
import type {
|
||||
CertificationRecord,
|
||||
CertificationStats,
|
||||
CertificationQueryParams,
|
||||
CertificationListResult,
|
||||
CertificationStatus
|
||||
} from '@/types/certification'
|
||||
|
||||
// 第三方认证提供商
|
||||
const providers = ['阿里云实名认证', '腾讯云身份核验', '学信网', '天眼查', '企查查']
|
||||
|
||||
// 模拟身份证号脱敏
|
||||
function maskIdCard(idCard: string): string {
|
||||
return idCard.substring(0, 6) + '********' + idCard.substring(14)
|
||||
}
|
||||
|
||||
// 模拟手机号脱敏
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.substring(0, 3) + '****' + phone.substring(7)
|
||||
}
|
||||
|
||||
// 生成模拟认证数据
|
||||
function generateCertifications(): CertificationRecord[] {
|
||||
const names = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十', '郑十一', '冯十二']
|
||||
const schools = ['北京大学', '清华大学', '浙江大学', '复旦大学', '上海交通大学', '南京大学', '武汉大学', '中山大学']
|
||||
const majors = ['计算机科学与技术', '软件工程', '人工智能', '数据科学', '信息安全', '电子信息工程']
|
||||
const degrees = ['本科', '硕士', '博士']
|
||||
const certificates = ['PMP项目管理', '系统架构设计师', 'AWS认证', 'CPA注册会计师', '一级建造师', '高级工程师']
|
||||
const companies = ['阿里巴巴', '腾讯科技', '字节跳动', '华为技术', '京东集团', '美团点评']
|
||||
|
||||
const records: CertificationRecord[] = []
|
||||
let id = 1
|
||||
|
||||
// 生成实名认证记录
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const name = names[i % names.length]!
|
||||
const status: CertificationStatus = i < 12 ? 'verified' : i < 14 ? 'pending' : 'expired'
|
||||
const submittedAt = new Date(Date.now() - (i + 1) * 3 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
records.push({
|
||||
id: id++,
|
||||
userId: 1000 + i,
|
||||
userName: name,
|
||||
userAvatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=cert_${i}`,
|
||||
userPhone: maskPhone(`138${String(10000000 + i).padStart(8, '0')}`),
|
||||
userEmail: `user${i + 1}@example.com`,
|
||||
type: 'identity',
|
||||
status,
|
||||
identityInfo: {
|
||||
realName: name,
|
||||
idCardNumber: maskIdCard(`110101199${i}0101${1234 + i}`),
|
||||
faceVerified: status === 'verified',
|
||||
verifiedAt: status === 'verified' ? submittedAt : ''
|
||||
},
|
||||
thirdPartyProvider: providers[i % providers.length]!,
|
||||
thirdPartyOrderId: `ID${Date.now()}${i}`,
|
||||
submittedAt,
|
||||
verifiedAt: status === 'verified' ? submittedAt : undefined,
|
||||
createdAt: submittedAt,
|
||||
updatedAt: submittedAt
|
||||
})
|
||||
}
|
||||
|
||||
// 生成学历认证记录
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const name = names[(i + 3) % names.length]!
|
||||
const status: CertificationStatus = i < 8 ? 'verified' : 'pending'
|
||||
const submittedAt = new Date(Date.now() - (i + 1) * 5 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
records.push({
|
||||
id: id++,
|
||||
userId: 1000 + i + 3,
|
||||
userName: name,
|
||||
userAvatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=edu_${i}`,
|
||||
userPhone: maskPhone(`139${String(10000000 + i).padStart(8, '0')}`),
|
||||
userEmail: `edu${i + 1}@example.com`,
|
||||
type: 'education',
|
||||
status,
|
||||
educationInfo: {
|
||||
school: schools[i % schools.length]!,
|
||||
major: majors[i % majors.length]!,
|
||||
degree: degrees[i % degrees.length]!,
|
||||
graduationYear: 2018 + (i % 6),
|
||||
certificateNumber: `学位证书编号***${1000 + i}`,
|
||||
verifiedAt: status === 'verified' ? submittedAt : ''
|
||||
},
|
||||
thirdPartyProvider: '学信网',
|
||||
thirdPartyOrderId: `EDU${Date.now()}${i}`,
|
||||
submittedAt,
|
||||
verifiedAt: status === 'verified' ? submittedAt : undefined,
|
||||
createdAt: submittedAt,
|
||||
updatedAt: submittedAt
|
||||
})
|
||||
}
|
||||
|
||||
// 生成职业资格认证记录
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const name = names[(i + 5) % names.length]!
|
||||
const status: CertificationStatus = i < 6 ? 'verified' : i < 7 ? 'rejected' : 'pending'
|
||||
const submittedAt = new Date(Date.now() - (i + 1) * 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
records.push({
|
||||
id: id++,
|
||||
userId: 1000 + i + 5,
|
||||
userName: name,
|
||||
userAvatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=pro_${i}`,
|
||||
userPhone: maskPhone(`137${String(10000000 + i).padStart(8, '0')}`),
|
||||
userEmail: `pro${i + 1}@example.com`,
|
||||
type: 'professional',
|
||||
status,
|
||||
professionalInfo: {
|
||||
certificateName: certificates[i % certificates.length]!,
|
||||
certificateNumber: `资格证书编号***${2000 + i}`,
|
||||
issuer: '人力资源和社会保障部',
|
||||
issueDate: `${2020 + (i % 4)}-0${1 + (i % 9)}-15`,
|
||||
expiryDate: i % 2 === 0 ? `${2025 + (i % 3)}-0${1 + (i % 9)}-15` : undefined,
|
||||
verifiedAt: status === 'verified' ? submittedAt : ''
|
||||
},
|
||||
thirdPartyProvider: providers[(i + 2) % providers.length]!,
|
||||
thirdPartyOrderId: `PRO${Date.now()}${i}`,
|
||||
rejectReason: status === 'rejected' ? '证书信息与系统记录不符,请核实后重新提交' : undefined,
|
||||
submittedAt,
|
||||
verifiedAt: status === 'verified' ? submittedAt : undefined,
|
||||
createdAt: submittedAt,
|
||||
updatedAt: submittedAt
|
||||
})
|
||||
}
|
||||
|
||||
// 生成企业认证记录
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const name = names[(i + 7) % names.length]!
|
||||
const status: CertificationStatus = i < 4 ? 'verified' : 'pending'
|
||||
const submittedAt = new Date(Date.now() - (i + 1) * 10 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
records.push({
|
||||
id: id++,
|
||||
userId: 1000 + i + 7,
|
||||
userName: name,
|
||||
userAvatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=ent_${i}`,
|
||||
userPhone: maskPhone(`136${String(10000000 + i).padStart(8, '0')}`),
|
||||
userEmail: `ent${i + 1}@example.com`,
|
||||
type: 'enterprise',
|
||||
status,
|
||||
enterpriseInfo: {
|
||||
companyName: companies[i % companies.length]! + '(' + ['北京', '上海', '深圳', '杭州', '广州'][i % 5] + ')有限公司',
|
||||
unifiedSocialCreditCode: `91110000***${3000 + i}`,
|
||||
legalRepresentative: name,
|
||||
registeredCapital: `${1000 + i * 500}万人民币`,
|
||||
businessScope: '软件开发、技术咨询、技术服务、技术转让',
|
||||
verifiedAt: status === 'verified' ? submittedAt : ''
|
||||
},
|
||||
thirdPartyProvider: '天眼查',
|
||||
thirdPartyOrderId: `ENT${Date.now()}${i}`,
|
||||
submittedAt,
|
||||
verifiedAt: status === 'verified' ? submittedAt : undefined,
|
||||
createdAt: submittedAt,
|
||||
updatedAt: submittedAt
|
||||
})
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
const mockCertifications = generateCertifications()
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取认证统计
|
||||
export async function mockGetCertificationStats(): Promise<CertificationStats> {
|
||||
await delay(150)
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return {
|
||||
total: mockCertifications.length,
|
||||
verified: mockCertifications.filter(c => c.status === 'verified').length,
|
||||
pending: mockCertifications.filter(c => c.status === 'pending').length,
|
||||
rejected: mockCertifications.filter(c => c.status === 'rejected').length,
|
||||
expired: mockCertifications.filter(c => c.status === 'expired').length,
|
||||
todayNew: mockCertifications.filter(c => new Date(c.createdAt) >= today).length
|
||||
}
|
||||
}
|
||||
|
||||
// 获取认证列表
|
||||
export async function mockGetCertificationList(
|
||||
params: CertificationQueryParams = {}
|
||||
): Promise<CertificationListResult> {
|
||||
await delay(200)
|
||||
|
||||
const { page = 1, pageSize = 10, keyword, type, status, startDate, endDate } = params
|
||||
|
||||
let filtered = [...mockCertifications]
|
||||
|
||||
// 按关键词筛选
|
||||
if (keyword) {
|
||||
const kw = keyword.toLowerCase()
|
||||
filtered = filtered.filter(c =>
|
||||
c.userName.toLowerCase().includes(kw) ||
|
||||
c.userEmail.toLowerCase().includes(kw) ||
|
||||
c.userPhone.includes(kw) ||
|
||||
c.thirdPartyOrderId.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
// 按类型筛选
|
||||
if (type) {
|
||||
filtered = filtered.filter(c => c.type === type)
|
||||
}
|
||||
|
||||
// 按状态筛选
|
||||
if (status) {
|
||||
filtered = filtered.filter(c => c.status === status)
|
||||
}
|
||||
|
||||
// 按日期范围筛选
|
||||
if (startDate) {
|
||||
const start = new Date(startDate)
|
||||
filtered = filtered.filter(c => new Date(c.submittedAt) >= start)
|
||||
}
|
||||
if (endDate) {
|
||||
const end = new Date(endDate)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
filtered = filtered.filter(c => new Date(c.submittedAt) <= end)
|
||||
}
|
||||
|
||||
// 按提交时间倒序排列
|
||||
filtered.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
const list = filtered.slice(start, start + pageSize)
|
||||
|
||||
return {
|
||||
list,
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
// 获取认证详情
|
||||
export async function mockGetCertificationById(id: number): Promise<CertificationRecord | null> {
|
||||
await delay(150)
|
||||
return mockCertifications.find(c => c.id === id) || null
|
||||
}
|
||||
|
||||
// 获取用户的所有认证记录
|
||||
export async function mockGetUserCertifications(userId: number): Promise<CertificationRecord[]> {
|
||||
await delay(150)
|
||||
return mockCertifications.filter(c => c.userId === userId)
|
||||
}
|
||||
546
src/mock/cityCircle.ts
Normal file
546
src/mock/cityCircle.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* 城市圈子 Mock 数据
|
||||
*/
|
||||
import type {
|
||||
CityCircle,
|
||||
CityCircleQueryParams,
|
||||
CityCircleListResult,
|
||||
CircleTopic,
|
||||
CircleMember,
|
||||
HotDiscussion,
|
||||
CircleTag,
|
||||
CircleStatus
|
||||
} from '@/types/cityCircle'
|
||||
|
||||
// 模拟标签数据
|
||||
const mockCircleTags: CircleTag[] = [
|
||||
{ id: 1, name: '互联网', color: '#1677ff' },
|
||||
{ id: 2, name: 'AI', color: '#722ed1' },
|
||||
{ id: 3, name: '金融科技', color: '#13c2c2' },
|
||||
{ id: 4, name: '新能源', color: '#52c41a' },
|
||||
{ id: 5, name: '电商', color: '#fa541c' },
|
||||
{ id: 6, name: '游戏', color: '#eb2f96' },
|
||||
{ id: 7, name: '教育', color: '#faad14' },
|
||||
{ id: 8, name: '医疗健康', color: '#2f54eb' }
|
||||
]
|
||||
|
||||
// 模拟成员数据
|
||||
const mockMembers: CircleMember[] = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 101,
|
||||
nickname: 'Nova',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Nova',
|
||||
role: 'operator',
|
||||
title: '圈子主持人',
|
||||
tags: ['互联网'],
|
||||
operatorType: '全职坐班',
|
||||
joinedAt: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 102,
|
||||
nickname: 'Vega',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Vega',
|
||||
role: 'operator',
|
||||
title: '招聘合伙人',
|
||||
tags: ['AI'],
|
||||
operatorType: '开放推广',
|
||||
joinedAt: '2024-02-20'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userId: 103,
|
||||
nickname: 'CloudEdge',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=CloudEdge',
|
||||
role: 'operator',
|
||||
title: '技术顾问',
|
||||
tags: ['运营', '架构'],
|
||||
operatorType: '周五 AMA',
|
||||
joinedAt: '2024-03-10'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
userId: 104,
|
||||
nickname: 'Iris',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Iris',
|
||||
role: 'operator',
|
||||
title: '数据科学',
|
||||
tags: ['金融科技'],
|
||||
operatorType: '寻找合作',
|
||||
joinedAt: '2024-04-05'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
userId: 105,
|
||||
nickname: 'Kite',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Kite',
|
||||
role: 'operator',
|
||||
title: '运营志愿者',
|
||||
tags: ['活动组织'],
|
||||
operatorType: '可协作',
|
||||
joinedAt: '2024-04-15'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
userId: 106,
|
||||
nickname: '张三',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhangsan',
|
||||
role: 'member',
|
||||
tags: ['前端开发'],
|
||||
joinedAt: '2024-05-01'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
userId: 107,
|
||||
nickname: '李四',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=lisi',
|
||||
role: 'member',
|
||||
tags: ['后端开发'],
|
||||
joinedAt: '2024-05-10'
|
||||
}
|
||||
]
|
||||
|
||||
// 模拟话题数据
|
||||
const mockTopics: CircleTopic[] = [
|
||||
{ id: 1, name: '互联网 讨论', description: '关注互联网的案例、岗位与技术交流', postCount: 120, isHot: true, createdAt: '2024-01-01' },
|
||||
{ id: 2, name: 'AI 讨论', description: '关注 AI 的案例、岗位与技术交流', postCount: 102, isNew: true, createdAt: '2024-02-15' },
|
||||
{ id: 3, name: '金融科技 讨论', description: '探讨金融科技的案例、岗位与技术交流', postCount: 84, isHot: true, createdAt: '2024-03-01' },
|
||||
{ id: 4, name: '产品经理交流', description: '产品经理相关话题讨论', postCount: 65, createdAt: '2024-03-20' },
|
||||
{ id: 5, name: '远程工作', description: '远程办公经验分享', postCount: 48, createdAt: '2024-04-01' }
|
||||
]
|
||||
|
||||
// 模拟热门讨论
|
||||
const mockHotDiscussions: HotDiscussion[] = [
|
||||
{ id: 1, title: '互联网 讨论', topicName: '互联网', discussionCount: 120, tags: ['互联网', 'AI'] },
|
||||
{ id: 2, title: '金融科技 讨论', topicName: '金融科技', discussionCount: 84, tags: ['金融科技'] }
|
||||
]
|
||||
|
||||
// 模拟城市圈子数据
|
||||
const mockCityCircles: CityCircle[] = [
|
||||
{
|
||||
id: 1,
|
||||
cityId: 1,
|
||||
cityName: '北京',
|
||||
cityNameEn: 'BEIJING',
|
||||
cityCode: '110000',
|
||||
description: '中国的首都和科技创新中心,聚集了大量互联网公司和AI研究机构。',
|
||||
tags: [mockCircleTags[0]!, mockCircleTags[1]!, mockCircleTags[2]!],
|
||||
icon: '北',
|
||||
coverImage: '',
|
||||
stats: {
|
||||
developerCount: 3456,
|
||||
openPositionCount: 892,
|
||||
avgSalary: 35,
|
||||
activeTopicCount: 3,
|
||||
hotTopicCount: 2,
|
||||
coreMemberCount: 8
|
||||
},
|
||||
topics: mockTopics,
|
||||
hotDiscussions: mockHotDiscussions,
|
||||
members: mockMembers,
|
||||
operators: mockMembers.filter(m => m.role === 'operator'),
|
||||
status: 'active',
|
||||
sort: 1,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-12-01'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cityId: 2,
|
||||
cityName: '上海',
|
||||
cityNameEn: 'SHANGHAI',
|
||||
cityCode: '310000',
|
||||
description: '国际金融中心,聚集大量金融科技和外资企业。',
|
||||
tags: [mockCircleTags[2]!, mockCircleTags[4]!],
|
||||
icon: '沪',
|
||||
stats: {
|
||||
developerCount: 2890,
|
||||
openPositionCount: 756,
|
||||
avgSalary: 32,
|
||||
activeTopicCount: 2,
|
||||
hotTopicCount: 1,
|
||||
coreMemberCount: 6
|
||||
},
|
||||
topics: mockTopics.slice(0, 3),
|
||||
hotDiscussions: mockHotDiscussions.slice(0, 1),
|
||||
members: mockMembers.slice(0, 5),
|
||||
operators: mockMembers.filter(m => m.role === 'operator').slice(0, 3),
|
||||
status: 'active',
|
||||
sort: 2,
|
||||
createdAt: '2024-01-05',
|
||||
updatedAt: '2024-12-01'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cityId: 3,
|
||||
cityName: '杭州',
|
||||
cityNameEn: 'HANGZHOU',
|
||||
cityCode: '330100',
|
||||
description: '电商之都,阿里巴巴总部所在地,创业氛围浓厚。',
|
||||
tags: [mockCircleTags[4]!, mockCircleTags[0]!],
|
||||
icon: '杭',
|
||||
stats: {
|
||||
developerCount: 2100,
|
||||
openPositionCount: 520,
|
||||
avgSalary: 28,
|
||||
activeTopicCount: 2,
|
||||
hotTopicCount: 1,
|
||||
coreMemberCount: 5
|
||||
},
|
||||
topics: mockTopics.slice(0, 2),
|
||||
hotDiscussions: [],
|
||||
members: mockMembers.slice(0, 4),
|
||||
operators: mockMembers.filter(m => m.role === 'operator').slice(0, 2),
|
||||
status: 'active',
|
||||
sort: 3,
|
||||
createdAt: '2024-02-01',
|
||||
updatedAt: '2024-11-20'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
cityId: 5,
|
||||
cityName: '深圳',
|
||||
cityNameEn: 'SHENZHEN',
|
||||
cityCode: '440300',
|
||||
description: '创新之城,硬件创业和互联网企业云集。',
|
||||
tags: [mockCircleTags[0]!, mockCircleTags[3]!],
|
||||
icon: '深',
|
||||
stats: {
|
||||
developerCount: 2650,
|
||||
openPositionCount: 680,
|
||||
avgSalary: 30,
|
||||
activeTopicCount: 3,
|
||||
hotTopicCount: 2,
|
||||
coreMemberCount: 7
|
||||
},
|
||||
topics: mockTopics.slice(0, 4),
|
||||
hotDiscussions: mockHotDiscussions,
|
||||
members: mockMembers,
|
||||
operators: mockMembers.filter(m => m.role === 'operator').slice(0, 4),
|
||||
status: 'active',
|
||||
sort: 4,
|
||||
createdAt: '2024-02-10',
|
||||
updatedAt: '2024-11-25'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
cityId: 6,
|
||||
cityName: '成都',
|
||||
cityNameEn: 'CHENGDU',
|
||||
cityCode: '510100',
|
||||
description: '西南科技重镇,游戏和互联网企业快速发展。',
|
||||
tags: [mockCircleTags[5]!, mockCircleTags[0]!],
|
||||
icon: '蓉',
|
||||
stats: {
|
||||
developerCount: 1560,
|
||||
openPositionCount: 380,
|
||||
avgSalary: 22,
|
||||
activeTopicCount: 2,
|
||||
hotTopicCount: 1,
|
||||
coreMemberCount: 4
|
||||
},
|
||||
topics: mockTopics.slice(0, 2),
|
||||
hotDiscussions: mockHotDiscussions.slice(0, 1),
|
||||
members: mockMembers.slice(0, 3),
|
||||
operators: mockMembers.filter(m => m.role === 'operator').slice(0, 2),
|
||||
status: 'active',
|
||||
sort: 5,
|
||||
createdAt: '2024-03-01',
|
||||
updatedAt: '2024-11-15'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
cityId: 7,
|
||||
cityName: '广州',
|
||||
cityNameEn: 'GUANGZHOU',
|
||||
cityCode: '440100',
|
||||
description: '华南商业中心,传统与新兴产业并重。',
|
||||
tags: [mockCircleTags[4]!, mockCircleTags[6]!],
|
||||
icon: '穗',
|
||||
stats: {
|
||||
developerCount: 1890,
|
||||
openPositionCount: 450,
|
||||
avgSalary: 25,
|
||||
activeTopicCount: 2,
|
||||
hotTopicCount: 1,
|
||||
coreMemberCount: 5
|
||||
},
|
||||
topics: mockTopics.slice(0, 3),
|
||||
hotDiscussions: mockHotDiscussions.slice(0, 1),
|
||||
members: mockMembers.slice(0, 4),
|
||||
operators: mockMembers.filter(m => m.role === 'operator').slice(0, 3),
|
||||
status: 'pending',
|
||||
sort: 6,
|
||||
createdAt: '2024-03-15',
|
||||
updatedAt: '2024-11-10'
|
||||
}
|
||||
]
|
||||
|
||||
// 延迟模拟
|
||||
function delay(ms: number = 300): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取城市圈子列表
|
||||
export async function mockGetCityCircleList(params: CityCircleQueryParams = {}): Promise<CityCircleListResult> {
|
||||
await delay()
|
||||
|
||||
let list = [...mockCityCircles]
|
||||
|
||||
// 关键词筛选
|
||||
if (params.keyword) {
|
||||
const keyword = params.keyword.toLowerCase()
|
||||
list = list.filter(item =>
|
||||
item.cityName.toLowerCase().includes(keyword) ||
|
||||
item.cityNameEn?.toLowerCase().includes(keyword) ||
|
||||
item.description.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
// 城市ID筛选
|
||||
if (params.cityId) {
|
||||
list = list.filter(item => item.cityId === params.cityId)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if (params.status) {
|
||||
list = list.filter(item => item.status === params.status)
|
||||
}
|
||||
|
||||
// 排序
|
||||
list.sort((a, b) => a.sort - b.sort)
|
||||
|
||||
// 分页
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 10
|
||||
const start = (page - 1) * pageSize
|
||||
const pageList = list.slice(start, start + pageSize)
|
||||
|
||||
return {
|
||||
list: pageList,
|
||||
total: list.length,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
// 获取城市圈子详情
|
||||
export async function mockGetCityCircleDetail(id: number): Promise<CityCircle | null> {
|
||||
await delay()
|
||||
return mockCityCircles.find(item => item.id === id) || null
|
||||
}
|
||||
|
||||
// 创建城市圈子
|
||||
export async function mockCreateCityCircle(data: Partial<CityCircle>): Promise<CityCircle> {
|
||||
await delay(500)
|
||||
|
||||
const newCircle: CityCircle = {
|
||||
id: mockCityCircles.length + 1,
|
||||
cityId: data.cityId || 0,
|
||||
cityName: data.cityName || '',
|
||||
cityNameEn: data.cityNameEn,
|
||||
cityCode: data.cityCode || '',
|
||||
description: data.description || '',
|
||||
tags: data.tags || [],
|
||||
icon: data.icon,
|
||||
coverImage: data.coverImage,
|
||||
stats: {
|
||||
developerCount: 0,
|
||||
openPositionCount: 0,
|
||||
avgSalary: 0,
|
||||
activeTopicCount: 0,
|
||||
hotTopicCount: 0,
|
||||
coreMemberCount: 0
|
||||
},
|
||||
topics: [],
|
||||
hotDiscussions: [],
|
||||
members: [],
|
||||
operators: [],
|
||||
status: data.status || 'pending',
|
||||
sort: data.sort || mockCityCircles.length + 1,
|
||||
createdAt: new Date().toISOString().split('T')[0]!,
|
||||
updatedAt: new Date().toISOString().split('T')[0]!
|
||||
}
|
||||
|
||||
mockCityCircles.push(newCircle)
|
||||
return newCircle
|
||||
}
|
||||
|
||||
// 更新城市圈子
|
||||
export async function mockUpdateCityCircle(id: number, data: Partial<CityCircle>): Promise<boolean> {
|
||||
await delay(500)
|
||||
|
||||
const index = mockCityCircles.findIndex(item => item.id === id)
|
||||
if (index === -1) return false
|
||||
|
||||
mockCityCircles[index] = {
|
||||
...mockCityCircles[index]!,
|
||||
...data,
|
||||
updatedAt: new Date().toISOString().split('T')[0]!
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 删除城市圈子
|
||||
export async function mockDeleteCityCircle(id: number): Promise<boolean> {
|
||||
await delay(500)
|
||||
|
||||
const index = mockCityCircles.findIndex(item => item.id === id)
|
||||
if (index === -1) return false
|
||||
|
||||
mockCityCircles.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
// 更新圈子状态
|
||||
export async function mockUpdateCircleStatus(id: number, status: CircleStatus): Promise<boolean> {
|
||||
await delay(300)
|
||||
|
||||
const circle = mockCityCircles.find(item => item.id === id)
|
||||
if (!circle) return false
|
||||
|
||||
circle.status = status
|
||||
circle.updatedAt = new Date().toISOString().split('T')[0]!
|
||||
return true
|
||||
}
|
||||
|
||||
// 添加话题
|
||||
export async function mockAddCircleTopic(circleId: number, topic: Omit<CircleTopic, 'id' | 'postCount' | 'createdAt'>): Promise<CircleTopic | null> {
|
||||
await delay(300)
|
||||
|
||||
const circle = mockCityCircles.find(item => item.id === circleId)
|
||||
if (!circle) return null
|
||||
|
||||
const newTopic: CircleTopic = {
|
||||
id: circle.topics.length + 100,
|
||||
name: topic.name,
|
||||
description: topic.description,
|
||||
postCount: 0,
|
||||
isHot: topic.isHot,
|
||||
isNew: true,
|
||||
createdAt: new Date().toISOString().split('T')[0]!
|
||||
}
|
||||
|
||||
circle.topics.push(newTopic)
|
||||
circle.stats.activeTopicCount = circle.topics.length
|
||||
return newTopic
|
||||
}
|
||||
|
||||
// 删除话题
|
||||
export async function mockDeleteCircleTopic(circleId: number, topicId: number): Promise<boolean> {
|
||||
await delay(300)
|
||||
|
||||
const circle = mockCityCircles.find(item => item.id === circleId)
|
||||
if (!circle) return false
|
||||
|
||||
const index = circle.topics.findIndex(t => t.id === topicId)
|
||||
if (index === -1) return false
|
||||
|
||||
circle.topics.splice(index, 1)
|
||||
circle.stats.activeTopicCount = circle.topics.length
|
||||
return true
|
||||
}
|
||||
|
||||
// 添加成员
|
||||
export async function mockAddCircleMember(circleId: number, member: Omit<CircleMember, 'id' | 'joinedAt'>): Promise<CircleMember | null> {
|
||||
await delay(300)
|
||||
|
||||
const circle = mockCityCircles.find(item => item.id === circleId)
|
||||
if (!circle) return null
|
||||
|
||||
const newMember: CircleMember = {
|
||||
...member,
|
||||
id: circle.members.length + 200,
|
||||
joinedAt: new Date().toISOString().split('T')[0]!
|
||||
}
|
||||
|
||||
circle.members.push(newMember)
|
||||
|
||||
if (newMember.role === 'operator') {
|
||||
circle.operators.push(newMember)
|
||||
circle.stats.coreMemberCount = circle.operators.length
|
||||
}
|
||||
|
||||
return newMember
|
||||
}
|
||||
|
||||
// 移除成员
|
||||
export async function mockRemoveCircleMember(circleId: number, memberId: number): Promise<boolean> {
|
||||
await delay(300)
|
||||
|
||||
const circle = mockCityCircles.find(item => item.id === circleId)
|
||||
if (!circle) return false
|
||||
|
||||
const memberIndex = circle.members.findIndex(m => m.id === memberId)
|
||||
if (memberIndex === -1) return false
|
||||
|
||||
const member = circle.members[memberIndex]
|
||||
circle.members.splice(memberIndex, 1)
|
||||
|
||||
if (member?.role === 'operator') {
|
||||
const opIndex = circle.operators.findIndex(o => o.id === memberId)
|
||||
if (opIndex !== -1) {
|
||||
circle.operators.splice(opIndex, 1)
|
||||
circle.stats.coreMemberCount = circle.operators.length
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 更新成员角色
|
||||
export async function mockUpdateMemberRole(circleId: number, memberId: number, role: 'operator' | 'member', operatorType?: string): Promise<boolean> {
|
||||
await delay(300)
|
||||
|
||||
const circle = mockCityCircles.find(item => item.id === circleId)
|
||||
if (!circle) return false
|
||||
|
||||
const member = circle.members.find(m => m.id === memberId)
|
||||
if (!member) return false
|
||||
|
||||
const wasOperator = member.role === 'operator'
|
||||
member.role = role
|
||||
|
||||
if (role === 'operator') {
|
||||
member.operatorType = operatorType
|
||||
if (!wasOperator) {
|
||||
circle.operators.push(member)
|
||||
}
|
||||
} else {
|
||||
member.operatorType = undefined
|
||||
if (wasOperator) {
|
||||
const opIndex = circle.operators.findIndex(o => o.id === memberId)
|
||||
if (opIndex !== -1) {
|
||||
circle.operators.splice(opIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
circle.stats.coreMemberCount = circle.operators.length
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取可用标签列表
|
||||
export async function mockGetCircleTags(): Promise<CircleTag[]> {
|
||||
await delay(200)
|
||||
return [...mockCircleTags]
|
||||
}
|
||||
|
||||
// 获取可绑定的城市列表(从城市管理)
|
||||
export async function mockGetAvailableCities(): Promise<Array<{ id: number; name: string; code: string }>> {
|
||||
await delay(200)
|
||||
return [
|
||||
{ id: 1, name: '北京市', code: '110000' },
|
||||
{ id: 2, name: '上海市', code: '310000' },
|
||||
{ id: 3, name: '杭州市', code: '330100' },
|
||||
{ id: 4, name: '南京市', code: '320100' },
|
||||
{ id: 5, name: '深圳市', code: '440300' },
|
||||
{ id: 6, name: '成都市', code: '510100' },
|
||||
{ id: 7, name: '广州市', code: '440100' },
|
||||
{ id: 8, name: '武汉市', code: '420100' },
|
||||
{ id: 9, name: '西安市', code: '610100' },
|
||||
{ id: 10, name: '苏州市', code: '320500' }
|
||||
]
|
||||
}
|
||||
164
src/mock/comment.ts
Normal file
164
src/mock/comment.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 评论管理模拟数据
|
||||
*/
|
||||
import type { CommentRecord, CommentUser, CommentPost, CommentQueryParams, CommentListResult, CommentStatus, CommentStats } from '@/types'
|
||||
|
||||
// 模拟帖子
|
||||
const mockPosts: CommentPost[] = [
|
||||
{ id: 1, title: 'Vue3 组合式API最佳实践分享', authorName: '前端小王' },
|
||||
{ id: 2, title: '如何高效学习TypeScript', authorName: '码农老张' },
|
||||
{ id: 3, title: 'React vs Vue 深度对比', authorName: '全栈开发者' },
|
||||
{ id: 4, title: 'Node.js性能优化技巧', authorName: '后端大神' },
|
||||
{ id: 5, title: '程序员如何提升软技能', authorName: '技术管理者' },
|
||||
{ id: 6, title: '2024年前端发展趋势', authorName: '前端架构师' }
|
||||
]
|
||||
|
||||
// 模拟用户
|
||||
const mockUsers: CommentUser[] = [
|
||||
{ id: 101, username: 'coder_li', nickname: '程序员小李', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=li' },
|
||||
{ id: 102, username: 'dev_wang', nickname: '开发者小王', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=wang' },
|
||||
{ id: 103, username: 'tech_zhang', nickname: '技术张', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhang' },
|
||||
{ id: 104, username: 'front_zhao', nickname: '前端赵', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhao' },
|
||||
{ id: 105, username: 'back_sun', nickname: '后端孙', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=sun' },
|
||||
{ id: 106, username: 'full_zhou', nickname: '全栈周', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhou' }
|
||||
]
|
||||
|
||||
// 评论内容模板
|
||||
const commentContents = [
|
||||
'写得太好了,学到了很多!',
|
||||
'感谢分享,正好需要这个',
|
||||
'这个观点我不太同意,我觉得...',
|
||||
'楼主能详细说一下具体实现吗?',
|
||||
'已收藏,以后慢慢看',
|
||||
'代码示例很清晰,赞一个',
|
||||
'有没有相关的项目案例推荐?',
|
||||
'这个问题我也遇到过,当时是这样解决的...',
|
||||
'期待楼主更多的分享',
|
||||
'干货满满,建议加精'
|
||||
]
|
||||
|
||||
const replyContents = [
|
||||
'同意你的观点',
|
||||
'补充一点,还可以这样做...',
|
||||
'谢谢回复,明白了',
|
||||
'这个解释很清楚',
|
||||
'我试了一下,确实可行'
|
||||
]
|
||||
|
||||
const statuses: CommentStatus[] = ['normal', 'normal', 'normal', 'normal', 'hidden']
|
||||
|
||||
// 生成模拟评论
|
||||
function generateMockComments(): CommentRecord[] {
|
||||
const records: CommentRecord[] = []
|
||||
let id = 1
|
||||
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const post = mockPosts[i % mockPosts.length]!
|
||||
const user = mockUsers[i % mockUsers.length]!
|
||||
const isReply = i % 4 === 3
|
||||
const createdAt = new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000))
|
||||
|
||||
records.push({
|
||||
id: id++,
|
||||
post,
|
||||
user,
|
||||
content: isReply ? replyContents[i % replyContents.length]! : commentContents[i % commentContents.length]!,
|
||||
likeCount: Math.floor(Math.random() * 100),
|
||||
replyCount: isReply ? 0 : Math.floor(Math.random() * 10),
|
||||
parentId: isReply ? Math.floor(Math.random() * (id - 1)) + 1 : undefined,
|
||||
parentContent: isReply ? commentContents[Math.floor(Math.random() * commentContents.length)] : undefined,
|
||||
status: statuses[i % statuses.length]!,
|
||||
createdAt: createdAt.toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
return records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
let mockComments = generateMockComments()
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取评论列表
|
||||
export async function mockGetCommentList(params: CommentQueryParams = {}): Promise<CommentListResult> {
|
||||
await delay(300)
|
||||
const { page = 1, pageSize = 10, keyword, postId, status, isReply } = params
|
||||
|
||||
let filtered = [...mockComments]
|
||||
|
||||
if (keyword) {
|
||||
filtered = filtered.filter(c => c.content.includes(keyword) || c.user.nickname.includes(keyword))
|
||||
}
|
||||
if (postId) {
|
||||
filtered = filtered.filter(c => c.post.id === postId)
|
||||
}
|
||||
if (status) {
|
||||
filtered = filtered.filter(c => c.status === status)
|
||||
}
|
||||
if (isReply !== undefined) {
|
||||
filtered = filtered.filter(c => isReply ? c.parentId !== undefined : c.parentId === undefined)
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
return { list: filtered.slice(start, start + pageSize), total: filtered.length, page, pageSize }
|
||||
}
|
||||
|
||||
// 获取评论统计
|
||||
export async function mockGetCommentStats(): Promise<CommentStats> {
|
||||
await delay(100)
|
||||
const today = new Date().toDateString()
|
||||
return {
|
||||
total: mockComments.length,
|
||||
todayNew: mockComments.filter(c => new Date(c.createdAt).toDateString() === today).length,
|
||||
normal: mockComments.filter(c => c.status === 'normal').length,
|
||||
hidden: mockComments.filter(c => c.status === 'hidden').length
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏评论
|
||||
export async function mockHideComment(id: number): Promise<void> {
|
||||
await delay(200)
|
||||
const comment = mockComments.find(c => c.id === id)
|
||||
if (comment) comment.status = 'hidden'
|
||||
}
|
||||
|
||||
// 恢复评论
|
||||
export async function mockRestoreComment(id: number): Promise<void> {
|
||||
await delay(200)
|
||||
const comment = mockComments.find(c => c.id === id)
|
||||
if (comment) comment.status = 'normal'
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
export async function mockDeleteComment(id: number): Promise<void> {
|
||||
await delay(200)
|
||||
mockComments = mockComments.filter(c => c.id !== id)
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
export async function mockBatchDeleteComments(ids: number[]): Promise<void> {
|
||||
await delay(300)
|
||||
mockComments = mockComments.filter(c => !ids.includes(c.id))
|
||||
}
|
||||
|
||||
// 获取帖子列表(用于筛选)
|
||||
export async function mockGetCommentPosts(): Promise<CommentPost[]> {
|
||||
await delay(100)
|
||||
return mockPosts
|
||||
}
|
||||
|
||||
// 编辑评论
|
||||
export async function mockUpdateComment(id: number, data: { content: string; likeCount?: number; replyCount?: number }): Promise<CommentRecord | null> {
|
||||
await delay(200)
|
||||
const comment = mockComments.find(c => c.id === id)
|
||||
if (comment) {
|
||||
comment.content = data.content
|
||||
if (data.likeCount !== undefined) comment.likeCount = data.likeCount
|
||||
if (data.replyCount !== undefined) comment.replyCount = data.replyCount
|
||||
return comment
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
335
src/mock/conversation.ts
Normal file
335
src/mock/conversation.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 客服会话相关模拟数据
|
||||
*/
|
||||
import type {
|
||||
ConversationAgent,
|
||||
ConversationListQuery,
|
||||
ConversationListResult,
|
||||
ConversationMessage,
|
||||
ConversationMetrics,
|
||||
ConversationPriority,
|
||||
ConversationSession,
|
||||
ConversationStatus,
|
||||
ConversationTag
|
||||
} from '@/types'
|
||||
|
||||
const supportAgents: ConversationAgent[] = [
|
||||
{
|
||||
id: 11,
|
||||
name: '林清',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=agent_lin',
|
||||
title: '资深客服',
|
||||
workload: 8,
|
||||
expertise: ['会员权益', '支付问题']
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: '周晚',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=agent_zhou',
|
||||
title: '体验顾问',
|
||||
workload: 5,
|
||||
expertise: ['产品咨询', '活动政策']
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: '程斐',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=agent_cheng',
|
||||
title: '资深专家',
|
||||
workload: 6,
|
||||
expertise: ['订单售后', '履约异常']
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: '白凝',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=agent_bai',
|
||||
title: '运营支持',
|
||||
workload: 3,
|
||||
expertise: ['社群转接', '投诉升级']
|
||||
}
|
||||
]
|
||||
|
||||
const sessionTags: ConversationTag[] = [
|
||||
{ id: 1, name: '支付', color: '#1890ff' },
|
||||
{ id: 2, name: '权益', color: '#722ed1' },
|
||||
{ id: 3, name: '功能', color: '#13c2c2' },
|
||||
{ id: 4, name: '投诉', color: '#ff4d4f' },
|
||||
{ id: 5, name: '活动', color: '#fa8c16' },
|
||||
{ id: 6, name: 'BUG', color: '#a0d911' }
|
||||
]
|
||||
|
||||
const intents = ['开票需求', '会员续费', '提现异常', '功能咨询', '活动规则', '体验反馈']
|
||||
const sources = ['App 内反馈', '官网咨询', '小程序客服', '社群转接']
|
||||
const customerLevels = ['L1', 'L2', 'L3', 'L4', 'L5']
|
||||
const cities = ['杭州', '上海', '成都', '武汉', '广州', '深圳']
|
||||
const customerNames = ['南鸢', '时年', '望舒', '青禾', '阿岚', '林舟', '潮生', '江迟', '枝夏', '安意']
|
||||
|
||||
const channelPool: Array<ConversationSession['channel']> = ['app', 'web', 'wechat', 'miniapp']
|
||||
const priorityPool: ConversationPriority[] = ['normal', 'normal', 'high', 'vip']
|
||||
const statusPool: ConversationStatus[] = ['waiting', 'active', 'pending', 'resolved', 'active', 'pending']
|
||||
|
||||
function randomItem<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]!
|
||||
}
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function pickTags(): ConversationTag[] {
|
||||
const count = randomInt(1, 3)
|
||||
const shuffled = [...sessionTags].sort(() => Math.random() - 0.5)
|
||||
return shuffled.slice(0, count)
|
||||
}
|
||||
|
||||
function buildMessages(
|
||||
customerName: string,
|
||||
customerAvatar: string,
|
||||
agent?: ConversationAgent,
|
||||
startTime?: Date
|
||||
): ConversationMessage[] {
|
||||
const userMessages = [
|
||||
'你好,我想咨询一下昨天支付的订单还没有到账是怎么回事?',
|
||||
'我看到权益说明里有些不一致,麻烦帮我核实下。',
|
||||
'现在打开功能的时候会提示网络错误,可以帮忙看看吗?',
|
||||
'关于新的社群活动我有些疑问,规则是不是已经更新了?'
|
||||
]
|
||||
const agentMessages = [
|
||||
'您好,已经为您核实到订单,目前支付渠道反馈处理中,大约 10 分钟内到账。',
|
||||
'权益说明我们刚刚做了更新,我发一份最新版给您,您稍等查看。',
|
||||
'收到,我们正在排查这个异常,稍后会第一时间同步处理进度。',
|
||||
'关于活动规则我帮您确认了,新的版本中需要满足邀请条件才可报名。'
|
||||
]
|
||||
|
||||
const messages: ConversationMessage[] = []
|
||||
const base = startTime ? new Date(startTime) : new Date(Date.now() - randomInt(10, 72) * 60 * 1000)
|
||||
let pointer = base.getTime()
|
||||
const rounds = agent ? randomInt(3, 6) : randomInt(2, 4)
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const userMessage: ConversationMessage = {
|
||||
id: Number(`${pointer}${i}`),
|
||||
sender: 'user',
|
||||
senderName: customerName,
|
||||
content: randomItem(userMessages),
|
||||
timestamp: new Date(pointer).toISOString(),
|
||||
avatar: customerAvatar
|
||||
}
|
||||
messages.push(userMessage)
|
||||
pointer += randomInt(1, 6) * 60 * 1000
|
||||
if (agent) {
|
||||
const agentMessage: ConversationMessage = {
|
||||
id: Number(`${pointer}${i}`),
|
||||
sender: 'agent',
|
||||
senderName: agent.name,
|
||||
content: randomItem(agentMessages),
|
||||
timestamp: new Date(pointer).toISOString(),
|
||||
avatar: agent.avatar
|
||||
}
|
||||
messages.push(agentMessage)
|
||||
pointer += randomInt(2, 8) * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function generateMockConversations(): ConversationSession[] {
|
||||
const sessions: ConversationSession[] = []
|
||||
for (let i = 1; i <= 36; i++) {
|
||||
const customer = {
|
||||
id: 2000 + i,
|
||||
nickname: customerNames[i % customerNames.length]!,
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=session_${i}`,
|
||||
city: randomItem(cities),
|
||||
vip: Math.random() > 0.7,
|
||||
level: randomItem(customerLevels),
|
||||
intents: [randomItem(intents)]
|
||||
}
|
||||
const assignedAgent = Math.random() > 0.2 ? randomItem(supportAgents) : undefined
|
||||
let status = randomItem(statusPool)
|
||||
if (!assignedAgent && status !== 'waiting') {
|
||||
status = 'waiting'
|
||||
}
|
||||
const createdAt = new Date(Date.now() - randomInt(1, 7) * 24 * 60 * 60 * 1000)
|
||||
const messages = buildMessages(customer.nickname, customer.avatar, assignedAgent, createdAt)
|
||||
const lastMessage = messages[messages.length - 1]!
|
||||
const firstAgentMessage = messages.find(msg => msg.sender === 'agent')
|
||||
const priority = randomItem(priorityPool)
|
||||
|
||||
sessions.push({
|
||||
id: i,
|
||||
sessionCode: `KF${202400 + i}`,
|
||||
channel: randomItem(channelPool),
|
||||
status,
|
||||
priority,
|
||||
createdAt: messages[0]!.timestamp,
|
||||
lastMessageAt: lastMessage.timestamp,
|
||||
lastMessage: lastMessage.content,
|
||||
unreadCount: status === 'waiting' ? randomInt(1, 6) : Math.max(0, randomInt(0, 3) - 1),
|
||||
totalMessages: messages.length,
|
||||
waitingTime: status === 'waiting' ? randomInt(5, 25) : randomInt(1, 10),
|
||||
firstResponseAt: firstAgentMessage?.timestamp,
|
||||
resolvedAt: status === 'resolved' ? lastMessage.timestamp : undefined,
|
||||
satisfaction: status === 'resolved' ? Number((4.2 + Math.random() * 0.7).toFixed(1)) : undefined,
|
||||
autoDetectedIntent: randomItem(intents),
|
||||
source: randomItem(sources),
|
||||
tags: pickTags(),
|
||||
customer,
|
||||
assignedAgent: assignedAgent ? { ...assignedAgent } : undefined,
|
||||
messages
|
||||
})
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime())
|
||||
}
|
||||
|
||||
let conversationSessions: ConversationSession[] = generateMockConversations()
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function buildMetrics(sessions: ConversationSession[]): ConversationMetrics {
|
||||
const waitingCount = sessions.filter(session => session.status === 'waiting').length
|
||||
const activeCount = sessions.filter(session => session.status === 'active').length
|
||||
const pendingCount = sessions.filter(session => session.status === 'pending').length
|
||||
const resolvedToday = sessions.filter(session => {
|
||||
if (!session.resolvedAt) return false
|
||||
const resolvedDate = new Date(session.resolvedAt)
|
||||
const now = new Date()
|
||||
return (
|
||||
resolvedDate.getFullYear() === now.getFullYear() &&
|
||||
resolvedDate.getMonth() === now.getMonth() &&
|
||||
resolvedDate.getDate() === now.getDate()
|
||||
)
|
||||
}).length
|
||||
|
||||
const responseDiffs = sessions
|
||||
.filter(session => session.firstResponseAt)
|
||||
.map(session => new Date(session.firstResponseAt!).getTime() - new Date(session.createdAt).getTime())
|
||||
|
||||
const satisfactionScores = sessions
|
||||
.filter(session => session.satisfaction)
|
||||
.map(session => session.satisfaction!)
|
||||
|
||||
const avgFirstResponse =
|
||||
responseDiffs.length > 0 ? Math.round(responseDiffs.reduce((a, b) => a + b, 0) / responseDiffs.length / 60000) : 0
|
||||
const satisfaction =
|
||||
satisfactionScores.length > 0
|
||||
? Number((satisfactionScores.reduce((a, b) => a + b, 0) / satisfactionScores.length).toFixed(1))
|
||||
: 0
|
||||
|
||||
return {
|
||||
waitingCount,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
resolvedToday,
|
||||
satisfaction,
|
||||
avgFirstResponse
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockGetConversationList(
|
||||
params: ConversationListQuery = {}
|
||||
): Promise<ConversationListResult> {
|
||||
await delay(250)
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
keyword,
|
||||
status = 'all',
|
||||
channel = 'all',
|
||||
priority = 'all',
|
||||
vipOnly,
|
||||
dateRange
|
||||
} = params
|
||||
|
||||
let filtered = [...conversationSessions]
|
||||
|
||||
if (keyword) {
|
||||
filtered = filtered.filter(session =>
|
||||
[session.sessionCode, session.customer.nickname, session.lastMessage, session.assignedAgent?.name || '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
if (status !== 'all') {
|
||||
filtered = filtered.filter(session => session.status === status)
|
||||
}
|
||||
|
||||
if (channel !== 'all') {
|
||||
filtered = filtered.filter(session => session.channel === channel)
|
||||
}
|
||||
|
||||
if (priority !== 'all') {
|
||||
filtered = filtered.filter(session => session.priority === priority)
|
||||
}
|
||||
|
||||
if (vipOnly) {
|
||||
filtered = filtered.filter(session => session.customer.vip)
|
||||
}
|
||||
|
||||
if (dateRange && dateRange.length === 2) {
|
||||
const [start, end] = dateRange
|
||||
const startTime = new Date(start).getTime()
|
||||
const endTime = new Date(end).getTime()
|
||||
filtered = filtered.filter(session => {
|
||||
const created = new Date(session.createdAt).getTime()
|
||||
return created >= startTime && created <= endTime
|
||||
})
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
const list = filtered.slice(start, start + pageSize)
|
||||
|
||||
return {
|
||||
list,
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
metrics: buildMetrics(conversationSessions)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockUpdateConversationStatus(
|
||||
sessionId: number,
|
||||
status: ConversationStatus
|
||||
): Promise<ConversationSession | undefined> {
|
||||
await delay(200)
|
||||
const target = conversationSessions.find(session => session.id === sessionId)
|
||||
if (!target) return undefined
|
||||
target.status = status
|
||||
if (status === 'resolved' || status === 'closed') {
|
||||
target.resolvedAt = new Date().toISOString()
|
||||
target.unreadCount = 0
|
||||
target.waitingTime = 0
|
||||
}
|
||||
if (status !== 'waiting' && !target.firstResponseAt) {
|
||||
target.firstResponseAt = new Date().toISOString()
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export async function mockAssignConversationAgent(
|
||||
sessionId: number,
|
||||
agentId: number
|
||||
): Promise<ConversationSession | undefined> {
|
||||
await delay(200)
|
||||
const target = conversationSessions.find(session => session.id === sessionId)
|
||||
const agent = supportAgents.find(item => item.id === agentId)
|
||||
if (!target || !agent) return undefined
|
||||
target.assignedAgent = { ...agent }
|
||||
if (target.status === 'waiting') {
|
||||
target.status = 'active'
|
||||
}
|
||||
if (!target.firstResponseAt) {
|
||||
target.firstResponseAt = new Date().toISOString()
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export function mockGetSupportAgents(): ConversationAgent[] {
|
||||
return supportAgents.map(agent => ({ ...agent }))
|
||||
}
|
||||
14
src/mock/index.ts
Normal file
14
src/mock/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Mock数据统一导出
|
||||
*/
|
||||
export * from './user'
|
||||
export * from './post'
|
||||
export * from './project'
|
||||
export * from './recruitment'
|
||||
export * from './comment'
|
||||
export * from './talent'
|
||||
export * from './conversation'
|
||||
export * from './article'
|
||||
export * from './cityCircle'
|
||||
export * from './certification'
|
||||
export * from './signedProject'
|
||||
236
src/mock/post.ts
Normal file
236
src/mock/post.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 帖子相关模拟数据
|
||||
*/
|
||||
import type { PostInfo, PostTag, PostAuthor, PostQueryParams, PostListResult } from '@/types'
|
||||
|
||||
// 模拟标签数据
|
||||
const mockTags: PostTag[] = [
|
||||
{ id: 1, name: 'Vue', color: '#42b883' },
|
||||
{ id: 2, name: 'React', color: '#61dafb' },
|
||||
{ id: 3, name: 'TypeScript', color: '#3178c6' },
|
||||
{ id: 4, name: 'JavaScript', color: '#f7df1e' },
|
||||
{ id: 5, name: 'Node.js', color: '#339933' },
|
||||
{ id: 6, name: 'Python', color: '#3776ab' },
|
||||
{ id: 7, name: 'Go', color: '#00add8' },
|
||||
{ id: 8, name: '求助', color: '#ff4d4f' },
|
||||
{ id: 9, name: '分享', color: '#722ed1' },
|
||||
{ id: 10, name: '讨论', color: '#fa8c16' },
|
||||
{ id: 11, name: '官方', color: '#1890ff' } // 官方标签
|
||||
]
|
||||
|
||||
// 模拟用户数据
|
||||
const mockAuthors: PostAuthor[] = [
|
||||
{ id: 0, username: 'official', nickname: '官方', avatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=official' }, // 官方账号
|
||||
{ id: 1, username: 'zhangsan', nickname: '张三', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhangsan' },
|
||||
{ id: 2, username: 'lisi', nickname: '李四', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=lisi' },
|
||||
{ id: 3, username: 'wangwu', nickname: '王五', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=wangwu' },
|
||||
{ id: 4, username: 'zhaoliu', nickname: '赵六', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhaoliu' },
|
||||
{ id: 5, username: 'coder001', nickname: '代码狂人', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=coder001' },
|
||||
{ id: 6, username: 'devmaster', nickname: '开发大神', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=devmaster' }
|
||||
]
|
||||
|
||||
// 生成随机帖子内容
|
||||
const postTitles = [
|
||||
'Vue3 Composition API 最佳实践总结',
|
||||
'TypeScript 高级类型体操指南',
|
||||
'React Hooks 深入理解与应用',
|
||||
'前端性能优化的 10 个关键点',
|
||||
'Node.js 微服务架构设计',
|
||||
'Go 语言并发编程详解',
|
||||
'Python 数据分析入门到精通',
|
||||
'如何成为一名优秀的全栈工程师',
|
||||
'求助:Webpack 打包优化问题',
|
||||
'分享我的开源项目经验',
|
||||
'CSS Grid 布局完全指南',
|
||||
'Docker 容器化部署实战',
|
||||
'MongoDB 数据库设计最佳实践',
|
||||
'Git 工作流程规范建议',
|
||||
'REST API 设计原则讨论'
|
||||
]
|
||||
|
||||
const postContents = [
|
||||
'最近在项目中使用了这个技术,遇到了一些问题,经过研究后总结了一些经验分享给大家...',
|
||||
'这是一篇关于技术实践的文章,希望能够帮助到正在学习的同学们...',
|
||||
'在实际开发中,我们经常会遇到这类问题,今天来详细讲解一下解决方案...',
|
||||
'作为一名开发者,我认为掌握这些技能是非常重要的,下面是我的一些心得体会...',
|
||||
'经过长时间的实践和总结,我整理了这份详细的教程,涵盖了从入门到进阶的所有内容...'
|
||||
]
|
||||
|
||||
// 生成模拟帖子数据
|
||||
function generateMockPosts(): PostInfo[] {
|
||||
const posts: PostInfo[] = []
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const viewCount = Math.floor(Math.random() * 3000)
|
||||
const likeCount = Math.floor(Math.random() * 1000)
|
||||
const isHot = viewCount > 1000 || likeCount > 500
|
||||
const tag1 = mockTags[i % mockTags.length]!
|
||||
const tag2 = mockTags[(i + 3) % mockTags.length]!
|
||||
|
||||
posts.push({
|
||||
id: i,
|
||||
title: postTitles[i % postTitles.length]!,
|
||||
content: postContents[i % postContents.length]!,
|
||||
author: mockAuthors[(i % (mockAuthors.length - 1)) + 1]!, // 跳过官方账号
|
||||
tags: tag1.id === tag2.id ? [tag1] : [tag1, tag2],
|
||||
viewCount,
|
||||
likeCount,
|
||||
commentCount: Math.floor(Math.random() * 200),
|
||||
isHot,
|
||||
isOfficial: false, // 普通帖子都不是官方
|
||||
status: Math.random() > 0.1 ? 'published' : 'hidden',
|
||||
createdAt: new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000)).toISOString(),
|
||||
updatedAt: new Date(Date.now() - Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000)).toISOString()
|
||||
})
|
||||
}
|
||||
return posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
let mockPosts = generateMockPosts()
|
||||
|
||||
// 模拟延迟
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取帖子列表
|
||||
export async function mockGetPostList(params: PostQueryParams = {}): Promise<PostListResult> {
|
||||
await delay(300)
|
||||
const { page = 1, pageSize = 10, keyword, tagId, status, isHot, isOfficial } = params
|
||||
|
||||
let filtered = [...mockPosts]
|
||||
|
||||
if (keyword) {
|
||||
filtered = filtered.filter(p => p.title.includes(keyword) || p.author.nickname.includes(keyword))
|
||||
}
|
||||
if (tagId) {
|
||||
filtered = filtered.filter(p => p.tags.some(t => t.id === tagId))
|
||||
}
|
||||
if (status) {
|
||||
filtered = filtered.filter(p => p.status === status)
|
||||
}
|
||||
if (isHot !== undefined) {
|
||||
filtered = filtered.filter(p => p.isHot === isHot)
|
||||
}
|
||||
if (isOfficial !== undefined) {
|
||||
filtered = filtered.filter(p => p.isOfficial === isOfficial)
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
const list = filtered.slice(start, start + pageSize)
|
||||
|
||||
return { list, total: filtered.length, page, pageSize }
|
||||
}
|
||||
|
||||
// 删除帖子
|
||||
export async function mockDeletePost(id: number): Promise<void> {
|
||||
await delay(200)
|
||||
mockPosts = mockPosts.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
// 批量删除帖子
|
||||
export async function mockBatchDeletePosts(ids: number[]): Promise<void> {
|
||||
await delay(300)
|
||||
mockPosts = mockPosts.filter(p => !ids.includes(p.id))
|
||||
}
|
||||
|
||||
// 编辑帖子
|
||||
export async function mockUpdatePost(id: number, data: {
|
||||
title?: string
|
||||
content?: string
|
||||
tagIds?: number[]
|
||||
viewCount?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
}): Promise<PostInfo | null> {
|
||||
await delay(200)
|
||||
const post = mockPosts.find(p => p.id === id)
|
||||
if (post) {
|
||||
if (data.title !== undefined) post.title = data.title
|
||||
if (data.content !== undefined) post.content = data.content
|
||||
if (data.tagIds !== undefined) {
|
||||
post.tags = mockTags.filter(t => data.tagIds!.includes(t.id))
|
||||
}
|
||||
if (data.viewCount !== undefined) post.viewCount = data.viewCount
|
||||
if (data.likeCount !== undefined) post.likeCount = data.likeCount
|
||||
if (data.commentCount !== undefined) post.commentCount = data.commentCount
|
||||
post.updatedAt = new Date().toISOString()
|
||||
return post
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置/取消热门
|
||||
export async function mockToggleHot(id: number, isHot: boolean): Promise<PostInfo | null> {
|
||||
await delay(200)
|
||||
const post = mockPosts.find(p => p.id === id)
|
||||
if (post) {
|
||||
post.isHot = isHot
|
||||
post.updatedAt = new Date().toISOString()
|
||||
return post
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
export async function mockGetTags(): Promise<PostTag[]> {
|
||||
await delay(100)
|
||||
return mockTags
|
||||
}
|
||||
|
||||
// 创建帖子(发布官方帖子)
|
||||
export async function mockCreatePost(data: {
|
||||
title: string
|
||||
content: string
|
||||
tagIds: number[]
|
||||
isOfficial?: boolean
|
||||
}): Promise<PostInfo> {
|
||||
await delay(300)
|
||||
const officialTag = mockTags.find(t => t.name === '官方')!
|
||||
const officialAuthor = mockAuthors.find(a => a.username === 'official')!
|
||||
|
||||
const newPost: PostInfo = {
|
||||
id: Math.max(...mockPosts.map(p => p.id)) + 1,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
author: data.isOfficial ? officialAuthor : mockAuthors[1]!,
|
||||
tags: data.isOfficial
|
||||
? [officialTag, ...mockTags.filter(t => data.tagIds.includes(t.id) && t.id !== officialTag.id)]
|
||||
: mockTags.filter(t => data.tagIds.includes(t.id)),
|
||||
viewCount: 0,
|
||||
likeCount: 0,
|
||||
commentCount: 0,
|
||||
isHot: false,
|
||||
isOfficial: data.isOfficial ?? false,
|
||||
status: 'published',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
mockPosts.unshift(newPost) // 添加到列表开头
|
||||
return newPost
|
||||
}
|
||||
|
||||
// 设置/取消官方
|
||||
export async function mockToggleOfficial(id: number, isOfficial: boolean): Promise<PostInfo | null> {
|
||||
await delay(200)
|
||||
const post = mockPosts.find(p => p.id === id)
|
||||
const officialTag = mockTags.find(t => t.name === '官方')!
|
||||
const officialAuthor = mockAuthors.find(a => a.username === 'official')!
|
||||
|
||||
if (post) {
|
||||
post.isOfficial = isOfficial
|
||||
if (isOfficial) {
|
||||
// 设为官方时,添加官方标签并修改作者
|
||||
if (!post.tags.some(t => t.id === officialTag.id)) {
|
||||
post.tags.unshift(officialTag)
|
||||
}
|
||||
post.author = officialAuthor
|
||||
} else {
|
||||
// 取消官方时,移除官方标签
|
||||
post.tags = post.tags.filter(t => t.id !== officialTag.id)
|
||||
}
|
||||
post.updatedAt = new Date().toISOString()
|
||||
return post
|
||||
}
|
||||
return null
|
||||
}
|
||||
183
src/mock/project.ts
Normal file
183
src/mock/project.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 项目相关模拟数据
|
||||
*/
|
||||
import type { ProjectInfo, ProjectTag, ProjectPublisher, ProjectQueryParams, ProjectListResult, WorkType } from '@/types'
|
||||
|
||||
// 模拟项目标签
|
||||
const mockProjectTags: ProjectTag[] = [
|
||||
{ id: 1, name: 'Vue', color: '#42b883' },
|
||||
{ id: 2, name: 'React', color: '#61dafb' },
|
||||
{ id: 3, name: 'Node.js', color: '#339933' },
|
||||
{ id: 4, name: 'Python', color: '#3776ab' },
|
||||
{ id: 5, name: 'Java', color: '#007396' },
|
||||
{ id: 6, name: 'Go', color: '#00add8' },
|
||||
{ id: 7, name: '小程序', color: '#07c160' },
|
||||
{ id: 8, name: 'APP开发', color: '#ff6b6b' },
|
||||
{ id: 9, name: '数据分析', color: '#845ef7' },
|
||||
{ id: 10, name: 'AI/ML', color: '#ff922b' }
|
||||
]
|
||||
|
||||
// 模拟发布人
|
||||
const mockPublishers: ProjectPublisher[] = [
|
||||
{ id: 1, username: 'techcorp', nickname: '科技有限公司', avatar: 'https://api.dicebear.com/7.x/identicon/svg?seed=techcorp', company: '科技有限公司' },
|
||||
{ id: 2, username: 'startup', nickname: '创业团队', avatar: 'https://api.dicebear.com/7.x/identicon/svg?seed=startup', company: '创新科技' },
|
||||
{ id: 3, username: 'bigcompany', nickname: '大厂HR', avatar: 'https://api.dicebear.com/7.x/identicon/svg?seed=bigcompany', company: '某大厂' },
|
||||
{ id: 4, username: 'freelancer', nickname: '独立开发者', avatar: 'https://api.dicebear.com/7.x/identicon/svg?seed=freelancer' },
|
||||
{ id: 5, username: 'agency', nickname: '外包公司', avatar: 'https://api.dicebear.com/7.x/identicon/svg?seed=agency', company: '软件外包' }
|
||||
]
|
||||
|
||||
const projectNames = [
|
||||
'企业官网开发项目', '电商小程序开发', '数据可视化平台', 'CRM系统定制开发',
|
||||
'APP后端接口开发', '在线教育平台开发', '社交应用开发', '物流管理系统',
|
||||
'AI智能客服系统', '区块链应用开发', '医疗健康APP', '金融风控系统'
|
||||
]
|
||||
|
||||
const locations = ['北京', '上海', '深圳', '杭州', '广州', '成都', '武汉', '南京', '不限']
|
||||
const workTypes: WorkType[] = ['fulltime', 'parttime', 'remote', 'internship']
|
||||
|
||||
const descriptions = [
|
||||
'我们正在寻找有经验的开发者加入我们的项目,参与核心功能的开发和优化。项目采用最新的技术栈,有良好的代码规范和开发流程。',
|
||||
'这是一个具有挑战性的项目,需要您具备扎实的编程基础和良好的学习能力。我们提供灵活的工作时间和有竞争力的薪资待遇。',
|
||||
'加入我们的团队,您将有机会参与大型项目的架构设计和技术选型,与优秀的工程师一起工作,不断提升自己的技术能力。'
|
||||
]
|
||||
|
||||
const requirements = [
|
||||
'1. 3年以上相关开发经验\n2. 熟悉主流开发框架\n3. 良好的代码规范意识\n4. 具备团队协作精神\n5. 有大型项目经验优先',
|
||||
'1. 本科及以上学历\n2. 熟练掌握至少一门编程语言\n3. 了解常用设计模式\n4. 具备独立解决问题的能力\n5. 有开源项目经验优先',
|
||||
'1. 2年以上工作经验\n2. 熟悉敏捷开发流程\n3. 具备良好的沟通能力\n4. 能够承受一定的工作压力\n5. 对技术有热情'
|
||||
]
|
||||
|
||||
const benefits = [
|
||||
'五险一金、带薪年假、弹性工作、免费午餐、定期团建、技术分享会',
|
||||
'远程办公、股权激励、年终奖金、技术培训、健身房、零食饮料',
|
||||
'双休、节日福利、项目奖金、晋升通道清晰、技术氛围好'
|
||||
]
|
||||
|
||||
|
||||
|
||||
// 生成模拟项目数据
|
||||
function generateMockProjects(): ProjectInfo[] {
|
||||
const projects: ProjectInfo[] = []
|
||||
for (let i = 1; i <= 40; i++) {
|
||||
const salaryMin = Math.floor(Math.random() * 20 + 5) * 1000
|
||||
const salaryMax = salaryMin + Math.floor(Math.random() * 15 + 5) * 1000
|
||||
const deadlineDate = new Date(Date.now() + Math.floor(Math.random() * 60 + 10) * 24 * 60 * 60 * 1000)
|
||||
const isExpired = Math.random() > 0.9
|
||||
const tag1 = mockProjectTags[i % mockProjectTags.length]!
|
||||
const tag2 = mockProjectTags[(i + 3) % mockProjectTags.length]!
|
||||
|
||||
const isPinned = Math.random() > 0.8
|
||||
const pinnedDays = [3, 7, 14, 30][Math.floor(Math.random() * 4)]
|
||||
|
||||
projects.push({
|
||||
id: i,
|
||||
name: projectNames[i % projectNames.length]!,
|
||||
salaryMin,
|
||||
salaryMax,
|
||||
salaryUnit: Math.random() > 0.7 ? '项目' : '月',
|
||||
publisher: mockPublishers[i % mockPublishers.length]!,
|
||||
location: locations[i % locations.length]!,
|
||||
workType: workTypes[i % workTypes.length]!,
|
||||
description: descriptions[i % descriptions.length]!,
|
||||
requirements: requirements[i % requirements.length]!,
|
||||
benefits: benefits[i % benefits.length]!,
|
||||
tags: tag1.id === tag2.id ? [tag1] : [tag1, tag2],
|
||||
contactEmail: `hr${i}@example.com`,
|
||||
deadline: deadlineDate.toISOString(),
|
||||
status: isExpired ? 'expired' : (Math.random() > 0.2 ? 'active' : 'closed'),
|
||||
createdAt: new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000)).toISOString(),
|
||||
updatedAt: new Date(Date.now() - Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000)).toISOString(),
|
||||
isPinned,
|
||||
pinnedUntil: isPinned ? new Date(Date.now() + pinnedDays! * 24 * 60 * 60 * 1000).toISOString() : undefined,
|
||||
exposureCount: Math.floor(Math.random() * 500),
|
||||
lastExposureAt: Math.random() > 0.5 ? new Date(Date.now() - Math.floor(Math.random() * 3 * 24 * 60 * 60 * 1000)).toISOString() : undefined
|
||||
})
|
||||
}
|
||||
return projects.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
let mockProjects = generateMockProjects()
|
||||
|
||||
// 模拟延迟
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取项目列表
|
||||
export async function mockGetProjectList(params: ProjectQueryParams = {}): Promise<ProjectListResult> {
|
||||
await delay(300)
|
||||
const { page = 1, pageSize = 10, keyword, workType, tagId, status, location } = params
|
||||
|
||||
let filtered = [...mockProjects]
|
||||
|
||||
if (keyword) {
|
||||
filtered = filtered.filter(p => p.name.includes(keyword) || p.publisher.nickname.includes(keyword))
|
||||
}
|
||||
if (workType) {
|
||||
filtered = filtered.filter(p => p.workType === workType)
|
||||
}
|
||||
if (tagId) {
|
||||
filtered = filtered.filter(p => p.tags.some(t => t.id === tagId))
|
||||
}
|
||||
if (status) {
|
||||
filtered = filtered.filter(p => p.status === status)
|
||||
}
|
||||
if (location) {
|
||||
filtered = filtered.filter(p => p.location === location || p.location === '不限')
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
const list = filtered.slice(start, start + pageSize)
|
||||
|
||||
return { list, total: filtered.length, page, pageSize }
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
export async function mockDeleteProject(id: number): Promise<void> {
|
||||
await delay(200)
|
||||
mockProjects = mockProjects.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
// 批量删除项目
|
||||
export async function mockBatchDeleteProjects(ids: number[]): Promise<void> {
|
||||
await delay(300)
|
||||
mockProjects = mockProjects.filter(p => !ids.includes(p.id))
|
||||
}
|
||||
|
||||
// 获取项目标签列表
|
||||
export async function mockGetProjectTags(): Promise<ProjectTag[]> {
|
||||
await delay(100)
|
||||
return mockProjectTags
|
||||
}
|
||||
|
||||
// 获取工作地点列表
|
||||
export async function mockGetLocations(): Promise<string[]> {
|
||||
await delay(100)
|
||||
return locations
|
||||
}
|
||||
|
||||
// 设置项目置顶
|
||||
export async function mockSetProjectPin(id: number, isPinned: boolean, days?: number): Promise<ProjectInfo | null> {
|
||||
await delay(200)
|
||||
const project = mockProjects.find(p => p.id === id)
|
||||
if (project) {
|
||||
project.isPinned = isPinned
|
||||
project.pinnedUntil = isPinned && days ? new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString() : undefined
|
||||
project.updatedAt = new Date().toISOString()
|
||||
return project
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 手动曝光项目
|
||||
export async function mockExposeProject(id: number): Promise<ProjectInfo | null> {
|
||||
await delay(200)
|
||||
const project = mockProjects.find(p => p.id === id)
|
||||
if (project) {
|
||||
project.exposureCount += 1
|
||||
project.lastExposureAt = new Date().toISOString()
|
||||
project.updatedAt = new Date().toISOString()
|
||||
return project
|
||||
}
|
||||
return null
|
||||
}
|
||||
211
src/mock/projectSession.ts
Normal file
211
src/mock/projectSession.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 项目会话管理模拟数据
|
||||
*/
|
||||
import type {
|
||||
ProjectSession,
|
||||
ProjectMessage,
|
||||
ProjectMember,
|
||||
ProjectProgressNode,
|
||||
ProjectMaterial,
|
||||
ProjectRole
|
||||
} from '@/types'
|
||||
import { mockGetRecruitmentList } from './recruitment'
|
||||
import { mockGetRecruitmentProjects } from './recruitment'
|
||||
|
||||
// 模拟项目会话数据
|
||||
const mockSessions: Map<number, ProjectSession> = new Map()
|
||||
|
||||
// 初始化模拟数据
|
||||
async function initMockData() {
|
||||
const projects = await mockGetRecruitmentProjects()
|
||||
for (const project of projects) {
|
||||
mockSessions.set(project.id, {
|
||||
id: project.id,
|
||||
projectId: project.id,
|
||||
projectInfo: {
|
||||
...project,
|
||||
workType: project.workType as any, // 模拟数据类型兼容处理
|
||||
tags: [], // 简化
|
||||
id: project.id,
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isPinned: false,
|
||||
exposureCount: 0,
|
||||
salaryMin: project.salaryMin,
|
||||
salaryMax: project.salaryMax,
|
||||
salaryUnit: project.salaryUnit,
|
||||
description: '这是一个模拟的项目描述。',
|
||||
requirements: '熟悉Vue3, TS...',
|
||||
benefits: '远程办公',
|
||||
contactEmail: 'contact@example.com',
|
||||
deadline: new Date().toISOString(),
|
||||
publisher: { id: 1, username: 'admin', nickname: '管理员', avatar: '' }
|
||||
},
|
||||
members: generateMockMembers(project.id),
|
||||
messages: generateMockMessages(project.id),
|
||||
progressNodes: [
|
||||
{ id: 1, title: '立项', status: 'completed', completedAt: '2023-10-01' },
|
||||
{ id: 2, title: '需求对齐', status: 'completed', completedAt: '2023-10-05' },
|
||||
{ id: 3, title: '开发中', status: 'processing', description: '前端页面开发进行中...' },
|
||||
{ id: 4, title: '联调', status: 'pending' },
|
||||
{ id: 5, title: '验收', status: 'pending' },
|
||||
{ id: 6, title: '结项', status: 'pending' }
|
||||
],
|
||||
materials: [
|
||||
{ id: 1, name: '需求文档 (v1.0)', url: '#', type: 'doc', size: '2.4MB', uploadedBy: 'Elaine', uploadedAt: '10-01 10:00' },
|
||||
{ id: 2, name: '接口说明', url: '#', type: 'doc', size: '1.2MB', uploadedBy: 'Mia', uploadedAt: '10-02 14:30' },
|
||||
{ id: 3, name: '设计稿链接', url: '#', type: 'other', size: '-', uploadedBy: 'Leo', uploadedAt: '10-02 16:00' }
|
||||
],
|
||||
onlineCount: 3
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 生成模拟成员
|
||||
function generateMockMembers(projectId: number): ProjectMember[] {
|
||||
// 这里写死一些数据,实际应从招募中获取approved的用户
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
userId: 201,
|
||||
role: 'leader',
|
||||
isLeader: true,
|
||||
status: 'online',
|
||||
joinedAt: '2023-10-01',
|
||||
info: { id: 201, username: 'zhangsan', nickname: '张三', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhang', email: '', skills: [], experience: '5年', introduction: '' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 101,
|
||||
role: 'product',
|
||||
isLeader: false,
|
||||
status: 'online',
|
||||
joinedAt: '2023-10-02',
|
||||
info: { id: 101, username: 'elaine', nickname: 'Elaine', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=elaine', email: '', skills: [], experience: '3年', introduction: '' }
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userId: 102,
|
||||
role: 'frontend',
|
||||
isLeader: false,
|
||||
status: 'offline',
|
||||
joinedAt: '2023-10-03',
|
||||
info: { id: 102, username: 'leo', nickname: 'Leo', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=leo', email: '', skills: [], experience: '2年', introduction: '' }
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
userId: 103,
|
||||
role: 'backend',
|
||||
isLeader: false,
|
||||
status: 'online',
|
||||
joinedAt: '2023-10-03',
|
||||
info: { id: 103, username: 'mia', nickname: 'Mia', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=mia', email: '', skills: [], experience: '4年', introduction: '' }
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
userId: 104,
|
||||
role: 'test',
|
||||
isLeader: false,
|
||||
status: 'offline',
|
||||
joinedAt: '2023-10-04',
|
||||
info: { id: 104, username: 'noah', nickname: 'Noah', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=noah', email: '', skills: [], experience: '3年', introduction: '' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 生成模拟消息
|
||||
function generateMockMessages(projectId: number): ProjectMessage[] {
|
||||
const now = new Date()
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
sessionId: projectId,
|
||||
senderId: 101,
|
||||
senderName: 'Elaine',
|
||||
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=elaine',
|
||||
senderRole: 'product',
|
||||
content: '今天先把需求边界对齐一下,重点是交付节奏。',
|
||||
sentAt: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sessionId: projectId,
|
||||
senderId: 201,
|
||||
senderName: '张三',
|
||||
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhang',
|
||||
senderRole: 'leader',
|
||||
content: '收到,我先同步一个可落地的里程碑拆解。',
|
||||
sentAt: new Date(now.getTime() - 1000 * 60 * 29).toISOString(),
|
||||
type: 'text'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 确保数据已初始化
|
||||
initMockData()
|
||||
|
||||
export async function mockGetProjectSession(projectId: number): Promise<ProjectSession> {
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
if (mockSessions.size === 0) await initMockData()
|
||||
const session = mockSessions.get(projectId)
|
||||
if (!session) throw new Error('Session not found')
|
||||
return session
|
||||
}
|
||||
|
||||
export async function mockSendProjectMessage(projectId: number, content: string): Promise<ProjectMessage> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
const msg: ProjectMessage = {
|
||||
id: Date.now(),
|
||||
sessionId: projectId,
|
||||
senderId: 999, // 当前用户
|
||||
senderName: 'Luna', // 当前用户
|
||||
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=luna',
|
||||
senderRole: 'leader', // 假设当前用户是负责人
|
||||
content,
|
||||
sentAt: new Date().toISOString(),
|
||||
type: 'text'
|
||||
}
|
||||
const session = mockSessions.get(projectId)
|
||||
if (session) {
|
||||
session.messages.push(msg)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// 获取可邀请的候选人列表(已通过招募申请的人员)
|
||||
export async function mockGetInvitableCandidates(projectId: number) {
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
// 获取该项目的approved的申请记录
|
||||
const recruitmentResult = await mockGetRecruitmentList({ projectId, status: 'approved' })
|
||||
// 排除已经是成员的人(简化逻辑:这里直接返回所有approved的)
|
||||
return recruitmentResult.list.map(record => ({
|
||||
id: record.applicant.id,
|
||||
applicantId: record.applicant.id,
|
||||
name: record.applicant.nickname || record.applicant.username,
|
||||
role: record.publisher.position || '候选人', // 这里简化,实际可能是申请时的意向岗位
|
||||
avatar: record.applicant.avatar,
|
||||
email: record.applicant.email
|
||||
}))
|
||||
}
|
||||
|
||||
// 邀请成员
|
||||
export async function mockInviteMember(projectId: number, candidateId: number, role: ProjectRole) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
const session = mockSessions.get(projectId)
|
||||
if (session) {
|
||||
// 简化:直接添加一个模拟成员
|
||||
const newMember: ProjectMember = {
|
||||
id: Date.now(),
|
||||
userId: candidateId,
|
||||
role,
|
||||
isLeader: false,
|
||||
status: 'offline',
|
||||
joinedAt: new Date().toISOString(),
|
||||
info: { id: candidateId, username: 'new_member', nickname: '新成员', avatar: '', email: '', skills: [], experience: '', introduction: '' }
|
||||
}
|
||||
session.members.push(newMember)
|
||||
}
|
||||
}
|
||||
162
src/mock/recruitment.ts
Normal file
162
src/mock/recruitment.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 招募管理模拟数据
|
||||
*/
|
||||
import type { RecruitmentRecord, ApplicantInfo, RecruitmentProject, RecruitmentQueryParams, RecruitmentListResult, RecruitmentStatus, RecruitmentStats, PublisherInfo } from '@/types'
|
||||
|
||||
// 模拟项目数据
|
||||
const mockProjects: RecruitmentProject[] = [
|
||||
{ id: 1, name: '企业官网开发项目', workType: 'remote', location: '不限', salaryMin: 15000, salaryMax: 25000, salaryUnit: '月', publisherName: '科技有限公司' },
|
||||
{ id: 2, name: '电商小程序开发', workType: 'fulltime', location: '上海', salaryMin: 20000, salaryMax: 35000, salaryUnit: '月', publisherName: '创业团队' },
|
||||
{ id: 3, name: 'CRM系统定制开发', workType: 'freelance', location: '北京', salaryMin: 50000, salaryMax: 80000, salaryUnit: '项目', publisherName: '某大厂' },
|
||||
{ id: 4, name: 'AI智能客服系统', workType: 'fulltime', location: '深圳', salaryMin: 25000, salaryMax: 40000, salaryUnit: '月', publisherName: '独立开发者' },
|
||||
{ id: 5, name: '数据可视化平台', workType: 'parttime', location: '杭州', salaryMin: 8000, salaryMax: 15000, salaryUnit: '月', publisherName: '外包公司' }
|
||||
]
|
||||
|
||||
// 模拟申请人
|
||||
const mockApplicants: ApplicantInfo[] = [
|
||||
{ id: 101, username: 'dev_zhang', nickname: '张开发', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhang', email: 'zhang@example.com', phone: '13800001111', skills: ['Vue', 'TypeScript', 'Node.js'], experience: '5年', introduction: '全栈开发工程师,擅长Vue生态和Node.js后端开发,有多个大型项目经验。', portfolioUrl: 'https://github.com/zhangdev' },
|
||||
{ id: 102, username: 'code_li', nickname: '李程序', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=li', email: 'li@example.com', skills: ['React', 'Python', 'Docker'], experience: '3年', introduction: '前端开发为主,熟悉React技术栈,有一定后端开发能力。' },
|
||||
{ id: 103, username: 'wang_coder', nickname: '王码农', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=wang', email: 'wang@example.com', phone: '13900002222', skills: ['Java', 'MySQL', 'K8s'], experience: '7年', introduction: '资深Java开发,精通微服务架构,有大厂背景。', resumeUrl: 'https://resume.example.com/wang' },
|
||||
{ id: 104, username: 'zhao_dev', nickname: '赵工程', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhao', email: 'zhao@example.com', skills: ['Go', 'Python', 'AWS'], experience: '4年', introduction: '后端开发工程师,专注于Go语言和云原生开发。' },
|
||||
{ id: 105, username: 'sun_tech', nickname: '孙技术', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=sun', email: 'sun@example.com', phone: '13700003333', skills: ['Vue', 'React', 'TypeScript'], experience: '2年', introduction: '前端开发新人,学习能力强,对技术有热情。' },
|
||||
{ id: 106, username: 'zhou_full', nickname: '周全栈', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhou', email: 'zhou@example.com', skills: ['Node.js', 'MongoDB', 'Docker'], experience: '6年', introduction: '全栈工程师,前后端通吃,有独立完成项目的能力。', portfolioUrl: 'https://zhoudev.com' }
|
||||
]
|
||||
|
||||
// 模拟发布人数据
|
||||
const mockPublishers: PublisherInfo[] = [
|
||||
{ id: 201, username: 'hr_tech', nickname: '陈HR', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=hr1', email: 'hr@techcompany.com', phone: '13600001111', company: '科技有限公司', position: '人事经理', verified: true },
|
||||
{ id: 202, username: 'cto_startup', nickname: '刘CTO', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=cto1', email: 'cto@startup.com', phone: '13600002222', company: '创业团队', position: '技术总监', verified: true },
|
||||
{ id: 203, username: 'pm_bigtech', nickname: '李产品', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=pm1', email: 'pm@bigtech.com', company: '某大厂', position: '项目经理', verified: true },
|
||||
{ id: 204, username: 'indie_dev', nickname: '独立开发者小明', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=indie1', email: 'indie@dev.com', position: '独立开发者', verified: false },
|
||||
{ id: 205, username: 'outsource_mgr', nickname: '外包管理小周', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=out1', email: 'mgr@outsource.com', phone: '13600003333', company: '外包公司', position: '项目经理', verified: true }
|
||||
]
|
||||
|
||||
const coverLetters = [
|
||||
'您好,我对贵公司的项目非常感兴趣。我有丰富的相关经验,相信能够胜任这份工作。期待与您进一步沟通!',
|
||||
'看到招聘信息后非常激动,这正是我一直想参与的项目类型。我的技能和经验与岗位要求高度匹配,希望能有机会加入团队。',
|
||||
'我是一名有多年经验的开发者,对技术有持续的热情。贵项目的技术栈正是我擅长的领域,相信我能为项目带来价值。',
|
||||
'非常期待能够参与这个项目。我之前有类似项目的开发经验,可以快速上手。薪资可以商议,更看重项目本身的发展前景。'
|
||||
]
|
||||
|
||||
const statuses: RecruitmentStatus[] = ['pending', 'approved', 'rejected', 'withdrawn', 'expired']
|
||||
|
||||
// 生成模拟招募记录
|
||||
function generateMockRecruitments(): RecruitmentRecord[] {
|
||||
const records: RecruitmentRecord[] = []
|
||||
for (let i = 1; i <= 60; i++) {
|
||||
const project = mockProjects[i % mockProjects.length]!
|
||||
const applicant = mockApplicants[i % mockApplicants.length]!
|
||||
const status = statuses[i % statuses.length]!
|
||||
const appliedAt = new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000))
|
||||
const coverLetter = coverLetters[i % coverLetters.length]!
|
||||
|
||||
records.push({
|
||||
id: i,
|
||||
project,
|
||||
applicant,
|
||||
publisher: mockPublishers[i % mockPublishers.length]!,
|
||||
expectedSalary: Math.floor(Math.random() * 20 + 10) * 1000,
|
||||
availableDate: new Date(Date.now() + Math.floor(Math.random() * 30 + 7) * 24 * 60 * 60 * 1000).toISOString(),
|
||||
coverLetter,
|
||||
status,
|
||||
appliedAt: appliedAt.toISOString(),
|
||||
processedAt: status !== 'pending' ? new Date(appliedAt.getTime() + Math.floor(Math.random() * 3 * 24 * 60 * 60 * 1000)).toISOString() : undefined,
|
||||
processedBy: status !== 'pending' ? '管理员' : undefined,
|
||||
rejectReason: status === 'rejected' ? '技能不匹配项目需求' : undefined,
|
||||
remark: Math.random() > 0.7 ? '候选人沟通态度良好' : undefined
|
||||
})
|
||||
}
|
||||
return records.sort((a, b) => new Date(b.appliedAt).getTime() - new Date(a.appliedAt).getTime())
|
||||
}
|
||||
|
||||
let mockRecruitments = generateMockRecruitments()
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取招募列表
|
||||
export async function mockGetRecruitmentList(params: RecruitmentQueryParams = {}): Promise<RecruitmentListResult> {
|
||||
await delay(300)
|
||||
const { page = 1, pageSize = 10, keyword, projectId, status, startDate, endDate } = params
|
||||
|
||||
let filtered = [...mockRecruitments]
|
||||
|
||||
if (keyword) {
|
||||
filtered = filtered.filter(r =>
|
||||
r.project.name.includes(keyword) ||
|
||||
r.applicant.nickname.includes(keyword) ||
|
||||
r.applicant.username.includes(keyword)
|
||||
)
|
||||
}
|
||||
if (projectId) {
|
||||
filtered = filtered.filter(r => r.project.id === projectId)
|
||||
}
|
||||
if (status) {
|
||||
filtered = filtered.filter(r => r.status === status)
|
||||
}
|
||||
if (startDate) {
|
||||
filtered = filtered.filter(r => new Date(r.appliedAt) >= new Date(startDate))
|
||||
}
|
||||
if (endDate) {
|
||||
filtered = filtered.filter(r => new Date(r.appliedAt) <= new Date(endDate))
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
return { list: filtered.slice(start, start + pageSize), total: filtered.length, page, pageSize }
|
||||
}
|
||||
|
||||
// 获取招募统计
|
||||
export async function mockGetRecruitmentStats(): Promise<RecruitmentStats> {
|
||||
await delay(100)
|
||||
const today = new Date().toDateString()
|
||||
return {
|
||||
total: mockRecruitments.length,
|
||||
pending: mockRecruitments.filter(r => r.status === 'pending').length,
|
||||
approved: mockRecruitments.filter(r => r.status === 'approved').length,
|
||||
rejected: mockRecruitments.filter(r => r.status === 'rejected').length,
|
||||
todayNew: mockRecruitments.filter(r => new Date(r.appliedAt).toDateString() === today).length
|
||||
}
|
||||
}
|
||||
|
||||
// 审批通过
|
||||
export async function mockApproveRecruitment(id: number): Promise<void> {
|
||||
await delay(200)
|
||||
const record = mockRecruitments.find(r => r.id === id)
|
||||
if (record) {
|
||||
record.status = 'approved'
|
||||
record.processedAt = new Date().toISOString()
|
||||
record.processedBy = '管理员'
|
||||
}
|
||||
}
|
||||
|
||||
// 审批拒绝
|
||||
export async function mockRejectRecruitment(id: number, reason: string): Promise<void> {
|
||||
await delay(200)
|
||||
const record = mockRecruitments.find(r => r.id === id)
|
||||
if (record) {
|
||||
record.status = 'rejected'
|
||||
record.processedAt = new Date().toISOString()
|
||||
record.processedBy = '管理员'
|
||||
record.rejectReason = reason
|
||||
}
|
||||
}
|
||||
|
||||
// 删除招募记录
|
||||
export async function mockDeleteRecruitment(id: number): Promise<void> {
|
||||
await delay(200)
|
||||
mockRecruitments = mockRecruitments.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
export async function mockBatchDeleteRecruitments(ids: number[]): Promise<void> {
|
||||
await delay(300)
|
||||
mockRecruitments = mockRecruitments.filter(r => !ids.includes(r.id))
|
||||
}
|
||||
|
||||
// 获取项目列表(用于筛选)
|
||||
export async function mockGetRecruitmentProjects(): Promise<RecruitmentProject[]> {
|
||||
await delay(100)
|
||||
return mockProjects
|
||||
}
|
||||
|
||||
346
src/mock/signedProject.ts
Normal file
346
src/mock/signedProject.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* 已成交项目和合同管理模拟数据
|
||||
*/
|
||||
import type {
|
||||
SignedProjectRecord,
|
||||
SignedProjectStats,
|
||||
SignedProjectQueryParams,
|
||||
SignedProjectListResult,
|
||||
ProjectProgressStatus,
|
||||
ProjectMilestone,
|
||||
ContractPartyInfo,
|
||||
ContractInfo,
|
||||
ContractRecord,
|
||||
ContractStats,
|
||||
ContractQueryParams,
|
||||
ContractListResult,
|
||||
ContractType,
|
||||
ContractStatus
|
||||
} from '@/types/signedProject'
|
||||
|
||||
// 模拟发布人数据
|
||||
const mockPublishers: ContractPartyInfo[] = [
|
||||
{ id: 201, username: 'hr_tech', nickname: '陈HR', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=pub1', email: 'hr@techcompany.com', phone: '13600001111', company: '科技有限公司', position: '人事经理', verified: true },
|
||||
{ id: 202, username: 'cto_startup', nickname: '刘CTO', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=pub2', email: 'cto@startup.com', phone: '13600002222', company: '创业团队', position: '技术总监', verified: true },
|
||||
{ id: 203, username: 'pm_bigtech', nickname: '李产品', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=pub3', email: 'pm@bigtech.com', company: '某大厂', position: '项目经理', verified: true }
|
||||
]
|
||||
|
||||
// 模拟签约人数据
|
||||
const mockContractors: ContractPartyInfo[] = [
|
||||
{ id: 101, username: 'dev_zhang', nickname: '张开发', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=con1', email: 'zhang@example.com', phone: '13800001111', position: '全栈开发工程师', verified: true },
|
||||
{ id: 102, username: 'code_li', nickname: '李程序', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=con2', email: 'li@example.com', position: '前端开发工程师', verified: true },
|
||||
{ id: 103, username: 'wang_coder', nickname: '王码农', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=con3', email: 'wang@example.com', phone: '13900002222', position: '后端开发工程师', verified: true }
|
||||
]
|
||||
|
||||
const projectNames = [
|
||||
'企业官网开发项目',
|
||||
'电商小程序开发',
|
||||
'CRM系统定制开发',
|
||||
'AI智能客服系统',
|
||||
'数据可视化平台',
|
||||
'移动端APP开发',
|
||||
'OA办公系统',
|
||||
'在线教育平台'
|
||||
]
|
||||
|
||||
const workTypes = ['remote', 'fulltime', 'parttime', 'freelance']
|
||||
const locations = ['北京', '上海', '深圳', '杭州', '广州', '远程']
|
||||
|
||||
// 生成里程碑
|
||||
function generateMilestones(projectId: number, progressPercent: number): ProjectMilestone[] {
|
||||
const milestoneTemplates = [
|
||||
{ title: '需求确认', description: '完成需求文档和原型确认' },
|
||||
{ title: '设计完成', description: '完成UI/UX设计和技术方案' },
|
||||
{ title: '开发阶段一', description: '完成核心功能开发' },
|
||||
{ title: '开发阶段二', description: '完成辅助功能开发' },
|
||||
{ title: '测试验收', description: '完成测试和问题修复' },
|
||||
{ title: '项目交付', description: '完成部署上线和文档交付' }
|
||||
]
|
||||
|
||||
return milestoneTemplates.map((template, index) => {
|
||||
const plannedDaysFromStart = (index + 1) * 15
|
||||
const baseDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)
|
||||
const plannedDate = new Date(baseDate.getTime() + plannedDaysFromStart * 24 * 60 * 60 * 1000)
|
||||
|
||||
let status: ProjectMilestone['status'] = 'pending'
|
||||
let actualDate: string | undefined
|
||||
|
||||
const milestoneProgress = ((index + 1) / milestoneTemplates.length) * 100
|
||||
if (progressPercent >= milestoneProgress) {
|
||||
status = 'completed'
|
||||
actualDate = new Date(plannedDate.getTime() + (Math.random() - 0.5) * 5 * 24 * 60 * 60 * 1000).toISOString()
|
||||
} else if (progressPercent >= milestoneProgress - 15) {
|
||||
status = 'in_progress'
|
||||
}
|
||||
|
||||
return {
|
||||
id: projectId * 100 + index,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
plannedDate: plannedDate.toISOString(),
|
||||
actualDate,
|
||||
status,
|
||||
deliverables: index < 3 ? ['文档', '源代码'] : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生成已成交项目
|
||||
function generateSignedProjects(): SignedProjectRecord[] {
|
||||
const records: SignedProjectRecord[] = []
|
||||
const progressStatuses: ProjectProgressStatus[] = ['signed', 'in_progress', 'delivered', 'accepted', 'completed', 'disputed']
|
||||
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const publisher = mockPublishers[i % mockPublishers.length]!
|
||||
const contractor = mockContractors[i % mockContractors.length]!
|
||||
const projectName = projectNames[i % projectNames.length]!
|
||||
const progressStatus = progressStatuses[i % progressStatuses.length]!
|
||||
|
||||
// 根据状态计算进度百分比
|
||||
let progressPercent = 0
|
||||
switch (progressStatus) {
|
||||
case 'signed': progressPercent = 0; break
|
||||
case 'in_progress': progressPercent = 20 + Math.floor(Math.random() * 50); break
|
||||
case 'delivered': progressPercent = 85 + Math.floor(Math.random() * 10); break
|
||||
case 'accepted': progressPercent = 95; break
|
||||
case 'completed': progressPercent = 100; break
|
||||
case 'disputed': progressPercent = 50 + Math.floor(Math.random() * 30); break
|
||||
}
|
||||
|
||||
const contractAmount = (20 + Math.floor(Math.random() * 80)) * 1000
|
||||
const paidPercent = progressStatus === 'completed' ? 1 : progressPercent / 100 * 0.8
|
||||
const paidAmount = Math.floor(contractAmount * paidPercent)
|
||||
|
||||
const signedAt = new Date(Date.now() - (60 + i * 5) * 24 * 60 * 60 * 1000)
|
||||
|
||||
const contract: ContractInfo = {
|
||||
id: i * 10,
|
||||
contractNo: `HT${2024}${String(i).padStart(6, '0')}`,
|
||||
title: `${projectName}服务合同`,
|
||||
type: 'project',
|
||||
amount: contractAmount,
|
||||
currency: 'CNY',
|
||||
signedAt: signedAt.toISOString(),
|
||||
startDate: new Date(signedAt.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(signedAt.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: progressStatus === 'completed' ? 'completed' : 'active',
|
||||
attachmentUrl: '/contracts/sample.pdf',
|
||||
attachmentName: `${projectName}-合同.pdf`
|
||||
}
|
||||
|
||||
records.push({
|
||||
id: i,
|
||||
projectName,
|
||||
projectDescription: `${projectName}的定制开发服务,包含需求分析、设计、开发、测试和部署。`,
|
||||
workType: workTypes[i % workTypes.length]!,
|
||||
location: locations[i % locations.length]!,
|
||||
contractAmount,
|
||||
paidAmount,
|
||||
pendingAmount: contractAmount - paidAmount,
|
||||
currency: 'CNY',
|
||||
publisher,
|
||||
contractor,
|
||||
contract,
|
||||
progressStatus,
|
||||
progressPercent,
|
||||
milestones: generateMilestones(i, progressPercent),
|
||||
signedAt: signedAt.toISOString(),
|
||||
startedAt: progressStatus !== 'signed' ? new Date(signedAt.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString() : undefined,
|
||||
deliveredAt: ['delivered', 'accepted', 'completed'].includes(progressStatus) ? new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() : undefined,
|
||||
completedAt: progressStatus === 'completed' ? new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() : undefined,
|
||||
publisherRating: progressStatus === 'completed' ? 80 + Math.floor(Math.random() * 20) : undefined,
|
||||
contractorRating: progressStatus === 'completed' ? 80 + Math.floor(Math.random() * 20) : undefined,
|
||||
createdAt: signedAt.toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
return records.sort((a, b) => new Date(b.signedAt).getTime() - new Date(a.signedAt).getTime())
|
||||
}
|
||||
|
||||
// 生成合同记录
|
||||
function generateContracts(): ContractRecord[] {
|
||||
const records: ContractRecord[] = []
|
||||
const contractTypes: ContractType[] = ['service', 'project', 'freelance', 'nda', 'other']
|
||||
const contractStatuses: ContractStatus[] = ['draft', 'pending_sign', 'active', 'completed', 'terminated', 'expired']
|
||||
|
||||
for (let i = 1; i <= 40; i++) {
|
||||
const partyA = mockPublishers[i % mockPublishers.length]!
|
||||
const partyB = mockContractors[i % mockContractors.length]!
|
||||
const projectName = projectNames[i % projectNames.length]!
|
||||
const type = contractTypes[i % contractTypes.length]!
|
||||
const status = contractStatuses[i % contractStatuses.length]!
|
||||
|
||||
const amount = type === 'nda' ? 0 : (10 + Math.floor(Math.random() * 90)) * 1000
|
||||
const createdAt = new Date(Date.now() - (30 + i * 3) * 24 * 60 * 60 * 1000)
|
||||
const effectiveDate = new Date(createdAt.getTime() + 5 * 24 * 60 * 60 * 1000)
|
||||
const expiryDate = new Date(effectiveDate.getTime() + 365 * 24 * 60 * 60 * 1000)
|
||||
|
||||
records.push({
|
||||
id: i,
|
||||
contractNo: `HT${2024}${String(i).padStart(6, '0')}`,
|
||||
title: type === 'nda' ? `${projectName}保密协议` : `${projectName}${ContractTypeMap[type]}`,
|
||||
type,
|
||||
partyA,
|
||||
partyB,
|
||||
relatedProjectId: type !== 'nda' && type !== 'other' ? i : undefined,
|
||||
relatedProjectName: type !== 'nda' && type !== 'other' ? projectName : undefined,
|
||||
amount,
|
||||
currency: 'CNY',
|
||||
signedAt: ['active', 'completed'].includes(status) ? new Date(effectiveDate.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString() : undefined,
|
||||
effectiveDate: effectiveDate.toISOString(),
|
||||
expiryDate: expiryDate.toISOString(),
|
||||
status,
|
||||
attachments: status !== 'draft' ? [{
|
||||
id: i * 10,
|
||||
name: `合同-${projectName}.pdf`,
|
||||
url: '/contracts/sample.pdf',
|
||||
size: 1024 * 500 + Math.floor(Math.random() * 1024 * 500),
|
||||
uploadedAt: createdAt.toISOString()
|
||||
}] : [],
|
||||
signRecords: ['active', 'completed'].includes(status) ? [
|
||||
{ partyType: 'A' as const, signedBy: partyA.nickname, signedAt: new Date(effectiveDate.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(), signMethod: 'electronic' as const },
|
||||
{ partyType: 'B' as const, signedBy: partyB.nickname, signedAt: new Date(effectiveDate.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(), signMethod: 'electronic' as const }
|
||||
] : [],
|
||||
remark: i % 5 === 0 ? '重点项目合同' : undefined,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
return records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
const mockSignedProjects = generateSignedProjects()
|
||||
const mockContracts = generateContracts()
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// ================== 已成交项目 API ==================
|
||||
|
||||
export async function mockGetSignedProjectStats(): Promise<SignedProjectStats> {
|
||||
await delay(150)
|
||||
return {
|
||||
total: mockSignedProjects.length,
|
||||
inProgress: mockSignedProjects.filter(p => ['signed', 'in_progress', 'delivered'].includes(p.progressStatus)).length,
|
||||
completed: mockSignedProjects.filter(p => p.progressStatus === 'completed').length,
|
||||
disputed: mockSignedProjects.filter(p => p.progressStatus === 'disputed').length,
|
||||
totalAmount: mockSignedProjects.reduce((sum, p) => sum + p.contractAmount, 0),
|
||||
paidAmount: mockSignedProjects.reduce((sum, p) => sum + p.paidAmount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockGetSignedProjectList(
|
||||
params: SignedProjectQueryParams = {}
|
||||
): Promise<SignedProjectListResult> {
|
||||
await delay(200)
|
||||
|
||||
const { page = 1, pageSize = 10, keyword, progressStatus, startDate, endDate } = params
|
||||
|
||||
let filtered = [...mockSignedProjects]
|
||||
|
||||
if (keyword) {
|
||||
const kw = keyword.toLowerCase()
|
||||
filtered = filtered.filter(p =>
|
||||
p.projectName.toLowerCase().includes(kw) ||
|
||||
p.publisher.nickname.toLowerCase().includes(kw) ||
|
||||
p.contractor.nickname.toLowerCase().includes(kw) ||
|
||||
p.contract.contractNo.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
if (progressStatus) {
|
||||
filtered = filtered.filter(p => p.progressStatus === progressStatus)
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
filtered = filtered.filter(p => new Date(p.signedAt) >= new Date(startDate))
|
||||
}
|
||||
if (endDate) {
|
||||
const end = new Date(endDate)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
filtered = filtered.filter(p => new Date(p.signedAt) <= end)
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
return {
|
||||
list: filtered.slice(start, start + pageSize),
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockGetSignedProjectById(id: number): Promise<SignedProjectRecord | null> {
|
||||
await delay(150)
|
||||
return mockSignedProjects.find(p => p.id === id) || null
|
||||
}
|
||||
|
||||
// ================== 合同管理 API ==================
|
||||
|
||||
export async function mockGetContractStats(): Promise<ContractStats> {
|
||||
await delay(150)
|
||||
return {
|
||||
total: mockContracts.length,
|
||||
active: mockContracts.filter(c => c.status === 'active').length,
|
||||
pendingSign: mockContracts.filter(c => c.status === 'pending_sign').length,
|
||||
completed: mockContracts.filter(c => c.status === 'completed').length,
|
||||
expired: mockContracts.filter(c => c.status === 'expired').length,
|
||||
totalAmount: mockContracts.reduce((sum, c) => sum + c.amount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockGetContractList(
|
||||
params: ContractQueryParams = {}
|
||||
): Promise<ContractListResult> {
|
||||
await delay(200)
|
||||
|
||||
const { page = 1, pageSize = 10, keyword, type, status, startDate, endDate } = params
|
||||
|
||||
let filtered = [...mockContracts]
|
||||
|
||||
if (keyword) {
|
||||
const kw = keyword.toLowerCase()
|
||||
filtered = filtered.filter(c =>
|
||||
c.title.toLowerCase().includes(kw) ||
|
||||
c.contractNo.toLowerCase().includes(kw) ||
|
||||
c.partyA.nickname.toLowerCase().includes(kw) ||
|
||||
c.partyB.nickname.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
if (type) {
|
||||
filtered = filtered.filter(c => c.type === type)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filtered = filtered.filter(c => c.status === status)
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
filtered = filtered.filter(c => new Date(c.createdAt) >= new Date(startDate))
|
||||
}
|
||||
if (endDate) {
|
||||
const end = new Date(endDate)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
filtered = filtered.filter(c => new Date(c.createdAt) <= end)
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
return {
|
||||
list: filtered.slice(start, start + pageSize),
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockGetContractById(id: number): Promise<ContractRecord | null> {
|
||||
await delay(150)
|
||||
return mockContracts.find(c => c.id === id) || null
|
||||
}
|
||||
|
||||
// 导入合同类型映射
|
||||
import { ContractTypeMap } from '@/types/signedProject'
|
||||
457
src/mock/talent.ts
Normal file
457
src/mock/talent.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* 人才管理模拟数据
|
||||
*/
|
||||
import type { TalentProfile, TalentQueryParams, TalentListResult, TalentStatus, TalentProjectRecord, TalentTag, TalentResume, ResumeTemplate, TalentIncomeRecord, TalentIncomeType } from '@/types'
|
||||
|
||||
const skillTagPool: TalentTag[] = [
|
||||
{ id: 201, name: 'Vue3', color: '#42b883' },
|
||||
{ id: 202, name: 'React18', color: '#61dafb' },
|
||||
{ id: 203, name: 'Node.js', color: '#339933' },
|
||||
{ id: 204, name: 'NestJS', color: '#d97706' },
|
||||
{ id: 205, name: 'Nuxt', color: '#00DC82' },
|
||||
{ id: 206, name: 'TypeScript', color: '#3178c6' },
|
||||
{ id: 207, name: '数据可视化', color: '#845ef7' },
|
||||
{ id: 208, name: 'AI 算法', color: '#ff922b' },
|
||||
{ id: 209, name: '低代码', color: '#13c2c2' },
|
||||
{ id: 210, name: '移动端', color: '#ff6b6b' }
|
||||
]
|
||||
|
||||
const cityTagPool: TalentTag[] = [
|
||||
{ id: 301, name: '上海', color: '#2f54eb' },
|
||||
{ id: 302, name: '北京', color: '#9254de' },
|
||||
{ id: 303, name: '深圳', color: '#13c2c2' },
|
||||
{ id: 304, name: '杭州', color: '#52c41a' },
|
||||
{ id: 305, name: '成都', color: '#fa8c16' },
|
||||
{ id: 306, name: '远程', color: '#a0aec0' }
|
||||
]
|
||||
|
||||
const projectTemplates: Array<Omit<TalentProjectRecord, 'id' | 'deliveryDate' | 'score'>> = [
|
||||
{ name: '智能客服平台', role: '前端负责人' },
|
||||
{ name: '跨境电商系统', role: '全栈交付' },
|
||||
{ name: 'IoT 设备中台', role: '架构设计' },
|
||||
{ name: '实景互动平台', role: '可视化开发' },
|
||||
{ name: '企业私有化部署', role: '技术顾问' },
|
||||
{ name: 'AI 训练标注平台', role: '交互设计' }
|
||||
]
|
||||
|
||||
const talentNames = ['林晓', '周渝', '宋岚', '杜言', '霍启', '唐叶', '江澄', '顾宁', '温槿', '秦若', '程恺', '沈律']
|
||||
const positionPool = ['高级前端工程师', '全栈技术专家', '可视化工程师', '后端架构师', '低代码顾问', 'AI 产品工程师']
|
||||
const statusPool: TalentStatus[] = ['available', 'busy', 'onboarding', 'resting']
|
||||
const introTemplates = [
|
||||
'聚焦企业级前端体系建设,擅长性能优化与大型项目的组件化拆分,拥有多行业交付经验。',
|
||||
'熟悉从需求分析到上线的全链路流程,习惯以目标导向来拆解项目,具备优秀的沟通协调能力。',
|
||||
'擅长数据可视化和实时互动场景,对前后端协作、部署流程有完整认知,能够独立承担复杂任务。'
|
||||
]
|
||||
|
||||
const companies = ['字节跳动', '腾讯科技', '阿里巴巴', '美团点评', '蚂蚁金服', '快手科技', '京东集团', '网易游戏']
|
||||
const universities = ['浙江大学', '复旦大学', '上海交通大学', '同济大学', '武汉大学', '华中科技大学', '南京大学', '电子科技大学']
|
||||
const majors = ['计算机科学与技术', '软件工程', '信息管理与信息系统', '电子信息工程', '通信工程', '数学与应用数学']
|
||||
const degrees = ['本科', '硕士']
|
||||
|
||||
function pickTags(pool: TalentTag[], count: number): TalentTag[] {
|
||||
const shuffled = [...pool].sort(() => Math.random() - 0.5)
|
||||
return shuffled.slice(0, count)
|
||||
}
|
||||
|
||||
function buildRecentProjects(seed: number): TalentProjectRecord[] {
|
||||
return Array.from({ length: 3 }, (_, index) => {
|
||||
const template = projectTemplates[(seed + index) % projectTemplates.length]!
|
||||
const deliveredAt = new Date(Date.now() - (seed * 10 + index * 5) * 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
id: seed * 100 + index,
|
||||
name: template.name,
|
||||
role: template.role,
|
||||
deliveryDate: deliveredAt.toISOString(),
|
||||
score: Math.floor(75 + Math.random() * 25), // 75-100分
|
||||
income: Math.floor(5000 + Math.random() * 30000) // 5000-35000元
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建收益记录列表
|
||||
function buildIncomeRecords(seed: number, projects: TalentProjectRecord[]): TalentIncomeRecord[] {
|
||||
const records: TalentIncomeRecord[] = []
|
||||
let recordId = seed * 1000
|
||||
|
||||
// 1. 项目收益记录(从项目数据生成)
|
||||
projects.forEach(project => {
|
||||
if (project.income) {
|
||||
records.push({
|
||||
id: recordId++,
|
||||
type: 'project' as TalentIncomeType,
|
||||
title: project.name,
|
||||
amount: project.income,
|
||||
isIncome: true,
|
||||
relatedProjectId: project.id,
|
||||
createdAt: project.deliveryDate,
|
||||
remark: `${project.role} - 项目已交付并完成验收`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 任务收益记录
|
||||
const taskTitles = ['开发电商首页', '完成API接口开发', 'Bug修复', '功能优化', '代码审查']
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const daysAgo = 10 + i * 8 + Math.floor(Math.random() * 5)
|
||||
records.push({
|
||||
id: recordId++,
|
||||
type: 'task' as TalentIncomeType,
|
||||
title: taskTitles[i % taskTitles.length]!,
|
||||
amount: Math.floor(100 + Math.random() * 400),
|
||||
isIncome: true,
|
||||
createdAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remark: '任务完成奖励'
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 奖励记录
|
||||
const rewardTitles = ['发布优质帖子获得奖励', '被评为月度优秀人才', '推荐新用户奖励']
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const daysAgo = 15 + i * 12
|
||||
records.push({
|
||||
id: recordId++,
|
||||
type: 'reward' as TalentIncomeType,
|
||||
title: rewardTitles[i % rewardTitles.length]!,
|
||||
amount: Math.floor(50 + Math.random() * 200),
|
||||
isIncome: true,
|
||||
createdAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 支出记录
|
||||
const expenseTitles = ['兑换技术书籍', '购买会员服务', '提现手续费']
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const daysAgo = 20 + i * 10
|
||||
records.push({
|
||||
id: recordId++,
|
||||
type: 'other' as TalentIncomeType,
|
||||
title: expenseTitles[i % expenseTitles.length]!,
|
||||
amount: Math.floor(20 + Math.random() * 100),
|
||||
isIncome: false,
|
||||
createdAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
return records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
function buildWorkExperiences(seed: number, years: number): TalentProfile['workExperiences'] {
|
||||
const count = Math.min(Math.floor(years / 2) + 1, 3)
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const isCurrent = index === 0
|
||||
const startYear = 2024 - (index + 1) * 2
|
||||
return {
|
||||
id: seed * 1000 + index,
|
||||
company: companies[(seed + index) % companies.length]!,
|
||||
position: positionPool[(seed + index) % positionPool.length]!,
|
||||
industry: '互联网',
|
||||
department: '研发部',
|
||||
startTime: `${startYear}-03`,
|
||||
endTime: isCurrent ? null : `${startYear + 2}-02`,
|
||||
description: '负责核心业务系统的架构设计与开发,主导了技术栈升级,通过引入新技术提升了团队30%的开发效率。',
|
||||
tags: ['Vue3', 'React', 'Node.js', '架构设计'].slice(0, 3)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildEducationExperiences(seed: number): TalentProfile['educationExperiences'] {
|
||||
const degree = degrees[seed % degrees.length]
|
||||
const endYear = 2024 - (3 + (seed % 8))
|
||||
return [
|
||||
{
|
||||
id: seed * 2000 + 1,
|
||||
school: universities[seed % universities.length]!,
|
||||
major: majors[seed % majors.length]!,
|
||||
degree: degree!,
|
||||
startTime: `${endYear - 4}-09`,
|
||||
endTime: `${endYear}-06`,
|
||||
description: '在校期间多次获得奖学金,担任学生会技术部长,组织过多次校园黑客马拉松活动。'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function createTalent(index: number): TalentProfile {
|
||||
const seed = index + 1
|
||||
const status = statusPool[index % statusPool.length]!
|
||||
const skillTags = pickTags(skillTagPool, 3)
|
||||
const cityTag = cityTagPool[index % cityTagPool.length]!
|
||||
const recentProjects = buildRecentProjects(seed)
|
||||
const experienceYears = 3 + (index % 8)
|
||||
const projectCount = 8 + (index * 2) + Math.floor(Math.random() * 5)
|
||||
const unit = seed % 2 === 0 ? 'day' : 'month'
|
||||
const amount = unit === 'day' ? 1800 + seed * 120 : 28000 + seed * 1500
|
||||
|
||||
// 信誉评分和项目评分 - 100分制
|
||||
const creditRating = Math.floor(70 + (projectCount / 30) * 15 + Math.random() * 15)
|
||||
const projectRating = Math.floor(75 + Math.random() * 20)
|
||||
const avgRating = (creditRating + projectRating) / 2
|
||||
|
||||
// 领域方向
|
||||
const domainPool = ['Web3 / SaaS', 'AI / 机器学习', '企业服务', '电商零售', 'ToB 平台', '金融科技', '物联网']
|
||||
|
||||
// 生成附件简历
|
||||
const resumeAttachments: TalentResume[] = Math.random() > 0.3 ? [
|
||||
{
|
||||
id: seed * 10 + 1,
|
||||
fileName: `${talentNames[index % talentNames.length]}_简历.pdf`,
|
||||
fileUrl: `https://example.com/resumes/${seed}_resume.pdf`,
|
||||
fileSize: 1024 * 512 + Math.floor(Math.random() * 1024 * 256),
|
||||
uploadedAt: new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000)).toISOString()
|
||||
}
|
||||
] : []
|
||||
|
||||
const workExperiences = buildWorkExperiences(seed, experienceYears)
|
||||
const educationExperiences = buildEducationExperiences(seed)
|
||||
const city = cityTag.name
|
||||
|
||||
// 社交统计
|
||||
const completedTaskCount = projectCount + Math.floor(Math.random() * 20)
|
||||
const ongoingTaskCount = status === 'busy' ? 1 + Math.floor(Math.random() * 3) : Math.floor(Math.random() * 2)
|
||||
const socialStats = {
|
||||
completedTaskCount,
|
||||
ongoingTaskCount,
|
||||
followingCount: 50 + Math.floor(Math.random() * 200),
|
||||
followerCount: 500 + Math.floor(Math.random() * 3000),
|
||||
starCount: 100 + Math.floor(Math.random() * 500),
|
||||
likeCount: 200 + Math.floor(Math.random() * 1000)
|
||||
}
|
||||
|
||||
// 签到信息
|
||||
const today = new Date()
|
||||
const consecutiveDays = Math.floor(Math.random() * 30)
|
||||
const totalDays = consecutiveDays + Math.floor(Math.random() * 100)
|
||||
const checkedInDates: string[] = []
|
||||
for (let i = 0; i < Math.min(consecutiveDays, 15); i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
if (date.getMonth() === today.getMonth()) {
|
||||
checkedInDates.push(date.toISOString().split('T')[0]!)
|
||||
}
|
||||
}
|
||||
|
||||
const checkInInfo = {
|
||||
consecutiveDays,
|
||||
totalDays,
|
||||
lastCheckIn: consecutiveDays > 0 ? today.toISOString().split('T')[0] : undefined,
|
||||
todayCheckedIn: consecutiveDays > 0 && Math.random() > 0.3,
|
||||
checkedInDates
|
||||
}
|
||||
|
||||
return {
|
||||
id: seed,
|
||||
realName: talentNames[index % talentNames.length]!,
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=talent_${seed}`,
|
||||
gender: Math.random() > 0.3 ? 'male' : 'female',
|
||||
age: 22 + experienceYears,
|
||||
phone: `138${String(seed).padStart(8, '0')}`,
|
||||
email: `talent${seed}@example.com`,
|
||||
positionTitle: positionPool[index % positionPool.length]!,
|
||||
domain: domainPool[index % domainPool.length],
|
||||
verified: avgRating >= 80 || projectCount >= 15,
|
||||
skillTags,
|
||||
cityTag,
|
||||
projectCount,
|
||||
experienceYears,
|
||||
degree: educationExperiences[0]!.degree,
|
||||
fee: {
|
||||
amount,
|
||||
unit,
|
||||
currency: 'CNY'
|
||||
},
|
||||
rating: {
|
||||
credit: creditRating,
|
||||
project: projectRating
|
||||
},
|
||||
level: projectCount > 30 ? 5 : projectCount > 15 ? 4 : projectCount > 8 ? 3 : projectCount > 3 ? 2 : 1,
|
||||
status,
|
||||
introduction: introTemplates[index % introTemplates.length]!,
|
||||
recentProjects,
|
||||
availableFrom: new Date(
|
||||
Date.now() + (status === 'busy' ? 12 : status === 'onboarding' ? 5 : status === 'resting' ? 8 : 0) * 24 * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
hot: avgRating >= 90,
|
||||
socialStats,
|
||||
checkInInfo,
|
||||
// 收益钱包
|
||||
wallet: {
|
||||
balance: Math.floor(5000 + Math.random() * 50000),
|
||||
totalIncome: Math.floor(20000 + Math.random() * 200000),
|
||||
totalExpense: Math.floor(1000 + Math.random() * 10000),
|
||||
currency: 'CNY'
|
||||
},
|
||||
// 收益记录列表
|
||||
incomeRecords: buildIncomeRecords(seed, recentProjects),
|
||||
resumeAttachments,
|
||||
selectedTemplateId: Math.random() > 0.5 ? (index % 3) + 1 : undefined,
|
||||
|
||||
// 详细简历
|
||||
workExperiences,
|
||||
educationExperiences,
|
||||
jobIntention: {
|
||||
jobStatus: status === 'available' ? '离职-随时到岗' : '在职-考虑机会',
|
||||
jobType: '全职',
|
||||
expectedSalary: `${Math.floor(amount / 1000)}K-${Math.floor(amount / 1000) + 5}K`,
|
||||
expectedCity: [city],
|
||||
expectedIndustry: ['互联网', '人工智能'],
|
||||
expectedPosition: [positionPool[index % positionPool.length]!]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockTalents: TalentProfile[] = Array.from({ length: 12 }, (_, index) => createTalent(index))
|
||||
|
||||
export function getAllTalentProfiles(): TalentProfile[] {
|
||||
return mockTalents
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function mockGetTalentList(params: TalentQueryParams = {}): Promise<TalentListResult> {
|
||||
await delay(200)
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 8,
|
||||
keyword,
|
||||
status,
|
||||
tagId,
|
||||
cityTagId,
|
||||
hotOnly
|
||||
} = params
|
||||
|
||||
let filtered = [...mockTalents]
|
||||
|
||||
if (keyword) {
|
||||
filtered = filtered.filter(
|
||||
talent =>
|
||||
talent.realName.includes(keyword) ||
|
||||
talent.positionTitle.includes(keyword) ||
|
||||
talent.introduction.includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filtered = filtered.filter(talent => talent.status === status)
|
||||
}
|
||||
|
||||
if (tagId) {
|
||||
filtered = filtered.filter(talent => talent.skillTags.some(tag => tag.id === tagId) || talent.cityTag.id === tagId)
|
||||
}
|
||||
|
||||
if (cityTagId) {
|
||||
filtered = filtered.filter(talent => talent.cityTag.id === cityTagId)
|
||||
}
|
||||
|
||||
if (hotOnly) {
|
||||
filtered = filtered.filter(talent => talent.hot)
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
const list = filtered.slice(start, start + pageSize)
|
||||
|
||||
return {
|
||||
list,
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取人才详情
|
||||
*/
|
||||
export async function mockGetTalentById(id: number): Promise<TalentProfile | null> {
|
||||
await delay(150)
|
||||
return mockTalents.find(talent => talent.id === id) || null
|
||||
}
|
||||
|
||||
// 在线简历模板
|
||||
const mockResumeTemplates: ResumeTemplate[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '简约专业型',
|
||||
thumbnail: 'https://picsum.photos/seed/resume1/200/280',
|
||||
description: '简洁大方,突出专业技能和项目经验,适合技术岗位',
|
||||
isDefault: true,
|
||||
createdAt: '2024-01-15T10:00:00Z',
|
||||
tags: ['简约', '技术', '蓝色'],
|
||||
useCount: 12503
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '创意设计型',
|
||||
thumbnail: 'https://picsum.photos/seed/resume2/200/280',
|
||||
description: '突出个人特色与作品集,适合设计创意岗位',
|
||||
isDefault: false,
|
||||
createdAt: '2024-02-20T14:30:00Z',
|
||||
tags: ['创意', '设计', '渐变'],
|
||||
useCount: 8462
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '全栈展示型',
|
||||
thumbnail: 'https://picsum.photos/seed/resume3/200/280',
|
||||
description: '全面展示前后端技术能力,适合全栈开发者',
|
||||
isDefault: false,
|
||||
createdAt: '2024-03-10T09:15:00Z',
|
||||
tags: ['全栈', '丰富', '深色'],
|
||||
useCount: 10234
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '项目经理型',
|
||||
thumbnail: 'https://picsum.photos/seed/resume4/200/280',
|
||||
description: '突出项目管理能力和团队协作经验',
|
||||
isDefault: false,
|
||||
createdAt: '2024-04-05T16:45:00Z',
|
||||
tags: ['管理', '商务', '灰色'],
|
||||
useCount: 5678
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 获取简历模板列表
|
||||
*/
|
||||
export async function mockGetResumeTemplates(): Promise<ResumeTemplate[]> {
|
||||
await delay(150)
|
||||
return mockResumeTemplates
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新简历模板
|
||||
*/
|
||||
export async function mockUpdateResumeTemplate(id: number, data: Partial<ResumeTemplate>): Promise<ResumeTemplate | null> {
|
||||
await delay(200)
|
||||
const template = mockResumeTemplates.find(t => t.id === id)
|
||||
if (template) {
|
||||
Object.assign(template, data)
|
||||
return template
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认模板
|
||||
*/
|
||||
export async function mockSetDefaultTemplate(id: number): Promise<boolean> {
|
||||
await delay(200)
|
||||
mockResumeTemplates.forEach(t => {
|
||||
t.isDefault = t.id === id
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除简历模板
|
||||
*/
|
||||
export async function mockDeleteResumeTemplate(id: number): Promise<boolean> {
|
||||
await delay(200)
|
||||
const index = mockResumeTemplates.findIndex(t => t.id === id)
|
||||
if (index > -1 && !mockResumeTemplates[index]?.isDefault) {
|
||||
mockResumeTemplates.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
386
src/mock/user.ts
Normal file
386
src/mock/user.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* 用户相关模拟数据
|
||||
*/
|
||||
import type {
|
||||
LoginParams,
|
||||
LoginResult,
|
||||
CaptchaResult,
|
||||
UserInfo,
|
||||
PlatformUserItem,
|
||||
UserListQueryParams,
|
||||
UserListResult,
|
||||
PublishedRecruitmentSummary
|
||||
} from '@/types'
|
||||
import { getAllTalentProfiles } from './talent'
|
||||
|
||||
// 模拟验证码
|
||||
const captchaCodes: Map<string, string> = new Map()
|
||||
|
||||
// 模拟用户数据
|
||||
const mockUsers: Array<{ username: string; password: string; userInfo: UserInfo, [key: number]: any }> = [
|
||||
{
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
userInfo: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nickname: '超级管理员',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
|
||||
email: 'admin@nanxiislet.com',
|
||||
phone: '13800138000',
|
||||
role: 'admin',
|
||||
permissions: ['*'],
|
||||
createTime: '2024-01-01 00:00:00',
|
||||
lastLoginTime: new Date().toLocaleString()
|
||||
}
|
||||
},
|
||||
{
|
||||
username: 'user',
|
||||
password: '123456',
|
||||
userInfo: {
|
||||
id: 2,
|
||||
username: 'user',
|
||||
nickname: '普通用户',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user',
|
||||
email: 'user@nanxiislet.com',
|
||||
phone: '13900139000',
|
||||
role: 'user',
|
||||
permissions: ['read'],
|
||||
createTime: '2024-06-01 00:00:00',
|
||||
lastLoginTime: new Date().toLocaleString()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 生成随机验证码
|
||||
function generateCaptcha(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// 生成SVG验证码图片
|
||||
function generateCaptchaSvg(code: string): string {
|
||||
const colors = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#1890ff']
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">`
|
||||
svg += `<rect width="120" height="40" fill="#f0f2f5"/>`
|
||||
|
||||
// 添加干扰线
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x1 = Math.random() * 120
|
||||
const y1 = Math.random() * 40
|
||||
const x2 = Math.random() * 120
|
||||
const y2 = Math.random() * 40
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]
|
||||
svg += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="1"/>`
|
||||
}
|
||||
|
||||
// 添加验证码文字
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const x = 15 + i * 25
|
||||
const y = 28 + Math.random() * 6 - 3
|
||||
const rotate = Math.random() * 30 - 15
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]
|
||||
svg += `<text x="${x}" y="${y}" fill="${color}" font-size="22" font-weight="bold" transform="rotate(${rotate} ${x} ${y})">${code[i]}</text>`
|
||||
}
|
||||
|
||||
svg += `</svg>`
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`
|
||||
}
|
||||
|
||||
// 模拟延迟
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
export async function mockGetCaptcha(): Promise<CaptchaResult> {
|
||||
await delay(300)
|
||||
const captchaKey = 'captcha_' + Date.now()
|
||||
const code = generateCaptcha()
|
||||
captchaCodes.set(captchaKey, code)
|
||||
|
||||
// 5分钟后过期
|
||||
setTimeout(() => captchaCodes.delete(captchaKey), 5 * 60 * 1000)
|
||||
|
||||
return {
|
||||
captchaKey,
|
||||
captchaImage: generateCaptchaSvg(code)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export async function mockLogin(params: LoginParams): Promise<LoginResult> {
|
||||
await delay(500)
|
||||
|
||||
// 验证验证码
|
||||
const storedCode = captchaCodes.get(params.captchaKey)
|
||||
if (!storedCode || storedCode.toUpperCase() !== params.captcha.toUpperCase()) {
|
||||
throw new Error('验证码错误')
|
||||
}
|
||||
|
||||
// 验证用户
|
||||
const user = mockUsers.find(
|
||||
u => u.username === params.username && u.password === params.password
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户名或密码错误')
|
||||
}
|
||||
|
||||
// 删除已使用的验证码
|
||||
captchaCodes.delete(params.captchaKey)
|
||||
|
||||
return {
|
||||
token: 'mock_token_' + Date.now(),
|
||||
refreshToken: 'mock_refresh_token_' + Date.now(),
|
||||
expires: Date.now() + 24 * 60 * 60 * 1000,
|
||||
userInfo: user.userInfo
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
export async function mockGetUserInfo(): Promise<UserInfo> {
|
||||
await delay(200)
|
||||
return mockUsers[0]!.userInfo
|
||||
}
|
||||
|
||||
function generatePhone(seed: number): string {
|
||||
const base = String(10000000 + ((seed * 7919) % 90000000))
|
||||
return '138' + base.slice(-8)
|
||||
}
|
||||
|
||||
function normalizeDateTime(input?: string): string {
|
||||
if (!input) return new Date().toISOString()
|
||||
const safe = input.includes('T') ? input : input.replace(' ', 'T')
|
||||
const date = new Date(safe)
|
||||
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString()
|
||||
}
|
||||
|
||||
// 构建发布招募统计
|
||||
function buildPublishStats(userId: number) {
|
||||
const totalPublished = Math.floor(2 + (userId % 5))
|
||||
const activePublished = Math.floor(totalPublished * 0.6)
|
||||
return {
|
||||
totalPublished,
|
||||
activePublished,
|
||||
closedPublished: totalPublished - activePublished,
|
||||
totalApplicants: totalPublished * Math.floor(3 + Math.random() * 10)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建发布的招募记录
|
||||
function buildPublishedRecruitments(userId: number): PublishedRecruitmentSummary[] {
|
||||
const projectNames = [
|
||||
'企业官网开发项目',
|
||||
'移动端APP开发',
|
||||
'CRM系统定制开发',
|
||||
'电商小程序开发',
|
||||
'数据可视化平台',
|
||||
'AI智能客服系统'
|
||||
]
|
||||
const workTypes = ['remote', 'fulltime', 'parttime', 'freelance']
|
||||
const locations = ['北京', '上海', '深圳', '杭州', '广州', '不限']
|
||||
|
||||
const count = 2 + (userId % 4)
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const salaryMin = 10 + Math.floor(Math.random() * 15)
|
||||
const salaryMax = salaryMin + 5 + Math.floor(Math.random() * 10)
|
||||
const daysAgo = 5 + index * 10 + Math.floor(Math.random() * 5)
|
||||
|
||||
return {
|
||||
id: userId * 100 + index,
|
||||
projectName: projectNames[(userId + index) % projectNames.length]!,
|
||||
workType: workTypes[(userId + index) % workTypes.length]!,
|
||||
location: locations[(userId + index) % locations.length]!,
|
||||
salaryRange: `${salaryMin}k-${salaryMax}k`,
|
||||
applicantCount: Math.floor(3 + Math.random() * 15),
|
||||
status: (index === 0 ? 'active' : Math.random() > 0.5 ? 'active' : 'closed') as 'active' | 'closed',
|
||||
publishedAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mapAuthUsersToPlatformUsers(): PlatformUserItem[] {
|
||||
return mockUsers.map(user => ({
|
||||
id: user.userInfo.id,
|
||||
realName: user.userInfo.nickname,
|
||||
username: user.userInfo.username,
|
||||
nickname: user.userInfo.nickname,
|
||||
avatar: user.userInfo.avatar || '',
|
||||
email: user.userInfo.email || `${user.userInfo.username}@nanxiislet.com`,
|
||||
phone: user.userInfo.phone || generatePhone(user.userInfo.id),
|
||||
role: user.userInfo.role,
|
||||
joinedAt: normalizeDateTime(user.userInfo.createTime),
|
||||
lastActiveAt: normalizeDateTime(user.userInfo.lastLoginTime),
|
||||
tags: user.userInfo.permissions || [],
|
||||
isTalent: false,
|
||||
cityTag: { id: 0, name: '总部', color: '#8c8c8c' }
|
||||
}))
|
||||
}
|
||||
|
||||
function buildPlatformUsersFromTalents(): PlatformUserItem[] {
|
||||
const talents = getAllTalentProfiles()
|
||||
return talents.map((talent, index) => {
|
||||
const joinedAt = new Date(Date.now() - (index + 3) * 12 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const lastActiveAt = new Date(Date.now() - Math.floor(Math.random() * 5) * 24 * 60 * 60 * 1000).toISOString()
|
||||
const BANK_NAMES = ['中国工商银行', '中国建设银行', '中国农业银行', '招商银行', '中国银行']
|
||||
|
||||
return {
|
||||
id: 1000 + talent.id,
|
||||
realName: talent.realName,
|
||||
username: `talent_${talent.id}`,
|
||||
nickname: talent.realName,
|
||||
avatar: talent.avatar,
|
||||
email: `talent${talent.id}@nanxiislet.com`,
|
||||
phone: generatePhone(talent.id + 100),
|
||||
role: 'talent',
|
||||
joinedAt,
|
||||
lastActiveAt,
|
||||
tags: talent.skillTags.map(tag => tag.name),
|
||||
isTalent: true,
|
||||
cityTag: talent.cityTag,
|
||||
introduction: talent.introduction,
|
||||
paymentMethods: {
|
||||
bankCard: {
|
||||
bankName: BANK_NAMES[index % BANK_NAMES.length]!,
|
||||
accountName: talent.realName,
|
||||
accountNo: `621226${(1000000000 + index).toString()}`
|
||||
},
|
||||
alipay: Math.random() > 0.5 ? {
|
||||
accountName: talent.realName,
|
||||
accountNo: generatePhone(talent.id + 100)
|
||||
} : undefined
|
||||
},
|
||||
talentSummary: {
|
||||
status: talent.status,
|
||||
projectCount: talent.projectCount,
|
||||
experienceYears: talent.experienceYears,
|
||||
fee: talent.fee,
|
||||
rating: talent.rating,
|
||||
level: talent.level,
|
||||
hot: talent.hot,
|
||||
availableFrom: talent.availableFrom,
|
||||
skillTags: talent.skillTags,
|
||||
cityTag: talent.cityTag,
|
||||
// 新增字段
|
||||
verified: talent.verified,
|
||||
domain: talent.domain,
|
||||
socialStats: talent.socialStats,
|
||||
wallet: talent.wallet,
|
||||
incomeRecords: talent.incomeRecords,
|
||||
// 发布招募数据
|
||||
publishStats: buildPublishStats(talent.id),
|
||||
publishedRecruitments: buildPublishedRecruitments(talent.id)
|
||||
},
|
||||
signInStats: buildSignInStats(talent.id),
|
||||
contacts: buildContacts() // 新增联系人
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 模拟生成联系人数据
|
||||
const companies = ['TechFlow', 'DesignCo', 'Freelance', 'MetaWorks', 'PixelStudio']
|
||||
const roles = ['Product Manager', 'Frontend Developer', 'Backend Engineer', 'QA Engineer', 'UI/UX Designer', 'Full Stack Dev']
|
||||
const statuses = ['Online Now', 'Active 1h ago', 'Active 5m ago', 'Offline', 'Active 1d ago']
|
||||
|
||||
function buildContacts(): any[] {
|
||||
const count = Math.floor(Math.random() * 8) + 2 // 2-10个联系人
|
||||
const contacts = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = 1000 + i
|
||||
const gender = Math.random() > 0.5 ? 'women' : 'men'
|
||||
const name = ['Elaine', 'Leo', 'Mia', 'Noah', 'Sarah', 'David', 'James', 'Lucas'][i % 8]
|
||||
|
||||
contacts.push({
|
||||
id,
|
||||
name,
|
||||
avatar: `https://randomuser.me/api/portraits/${gender}/${Math.floor(Math.random() * 50)}.jpg`,
|
||||
company: Math.random() > 0.3 ? companies[Math.floor(Math.random() * companies.length)] : undefined,
|
||||
role: roles[Math.floor(Math.random() * roles.length)],
|
||||
status: statuses[Math.floor(Math.random() * statuses.length)],
|
||||
isMutal: Math.random() > 0.5
|
||||
})
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
// 生成随机签到数据
|
||||
function buildSignInStats(userId: number) {
|
||||
const currentMonthDays = Math.floor(Math.random() * 22) // 随机本月签到天数
|
||||
const rewardPerDay = 10 // 假设每日签到奖励 10 code币
|
||||
const totalReward = currentMonthDays * rewardPerDay
|
||||
|
||||
const history = []
|
||||
const today = new Date()
|
||||
for (let i = 0; i < currentMonthDays; i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
history.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
reward: rewardPerDay,
|
||||
isMakeup: Math.random() > 0.9 // 10% 概率补签
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
currentMonthDays,
|
||||
totalReward,
|
||||
lastSignInDate: history.length > 0 ? history[0].date : undefined,
|
||||
history
|
||||
}
|
||||
}
|
||||
|
||||
let platformUsers: PlatformUserItem[] = [
|
||||
...mapAuthUsersToPlatformUsers().map(user => ({
|
||||
...user,
|
||||
signInStats: buildSignInStats(user.id)
|
||||
})),
|
||||
...buildPlatformUsersFromTalents()
|
||||
]
|
||||
|
||||
export async function mockGetPlatformUserList(
|
||||
params: UserListQueryParams = {}
|
||||
): Promise<UserListResult> {
|
||||
await delay(200)
|
||||
const { page = 1, pageSize = 10, keyword, status, hotOnly, cityTagId } = params
|
||||
let filtered = [...platformUsers]
|
||||
|
||||
if (keyword) {
|
||||
filtered = filtered.filter(user =>
|
||||
[user.realName, user.nickname, user.username, user.email].some(field =>
|
||||
field.toLowerCase().includes(keyword.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filtered = filtered.filter(user => user.talentSummary?.status === status)
|
||||
}
|
||||
|
||||
if (hotOnly) {
|
||||
filtered = filtered.filter(user => user.talentSummary?.hot)
|
||||
}
|
||||
|
||||
if (cityTagId) {
|
||||
filtered = filtered.filter(user => user.cityTag.id === cityTagId)
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize
|
||||
const list = filtered.slice(start, start + pageSize)
|
||||
|
||||
return {
|
||||
list,
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockDeletePlatformUser(id: number): Promise<void> {
|
||||
await delay(150)
|
||||
platformUsers = platformUsers.filter(user => user.id !== id)
|
||||
}
|
||||
299
src/router/index.ts
Normal file
299
src/router/index.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* CodePort 路由配置
|
||||
*
|
||||
* 支持两种模式:
|
||||
* 1. 独立模式 - 使用 MainLayout,包含菜单栏
|
||||
* 2. 嵌入模式 - 使用 EmbeddedLayout,不包含菜单栏(被父框架 iframe 嵌套时)
|
||||
*/
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// 检测是否为嵌入模式
|
||||
function isEmbeddedMode(): boolean {
|
||||
// 检查 URL 参数
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
if (urlParams.get('__embedded') === 'true') {
|
||||
return true
|
||||
}
|
||||
// 检查是否在 iframe 中
|
||||
try {
|
||||
return window.self !== window.top
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 页面路由配置(共用)
|
||||
const pageRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: {
|
||||
title: '控制台',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 社区管理
|
||||
{
|
||||
path: 'community/posts',
|
||||
name: 'Posts',
|
||||
component: () => import('@/views/community/posts/index.vue'),
|
||||
meta: {
|
||||
title: '帖子管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'community/comments',
|
||||
name: 'Comments',
|
||||
component: () => import('@/views/community/comments/index.vue'),
|
||||
meta: {
|
||||
title: '评论管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'community/tags',
|
||||
name: 'Tags',
|
||||
component: () => import('@/views/community/tags/index.vue'),
|
||||
meta: {
|
||||
title: '标签管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'community/circles',
|
||||
name: 'CityCircles',
|
||||
component: () => import('@/views/community/circles/index.vue'),
|
||||
meta: {
|
||||
title: '城市圈子',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 客服管理
|
||||
{
|
||||
path: 'support/console',
|
||||
name: 'SupportConsole',
|
||||
component: () => import('@/views/support/session/index.vue'),
|
||||
meta: {
|
||||
title: '接入会话',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'support/conversations',
|
||||
name: 'SupportConversations',
|
||||
component: () => import('@/views/support/conversations/index.vue'),
|
||||
meta: {
|
||||
title: '会话列表',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 内容/文章管理
|
||||
{
|
||||
path: 'content/articles',
|
||||
name: 'Articles',
|
||||
component: () => import('@/views/content/articles/index.vue'),
|
||||
meta: {
|
||||
title: '文章管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 项目管理
|
||||
{
|
||||
path: 'project/list',
|
||||
name: 'Projects',
|
||||
component: () => import('@/views/project/list/index.vue'),
|
||||
meta: {
|
||||
title: '项目列表',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'project/recruitment',
|
||||
name: 'Recruitment',
|
||||
component: () => import('@/views/project/recruitment/index.vue'),
|
||||
meta: {
|
||||
title: '招募管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'project/signed',
|
||||
name: 'SignedProjects',
|
||||
component: () => import('@/views/project/signed/index.vue'),
|
||||
meta: {
|
||||
title: '已成交项目',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'project/contract',
|
||||
name: 'ContractManagement',
|
||||
component: () => import('@/views/project/contract/index.vue'),
|
||||
meta: {
|
||||
title: '合同管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'project/sessions',
|
||||
name: 'ProjectSessions',
|
||||
component: () => import('@/views/project/session/index.vue'),
|
||||
meta: {
|
||||
title: '会话管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'project/sessions/:id',
|
||||
name: 'ProjectSessionDetail',
|
||||
component: () => import('@/views/project/session/detail.vue'),
|
||||
meta: {
|
||||
title: '会话详情',
|
||||
requiresAuth: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'talent',
|
||||
name: 'Talent',
|
||||
component: () => import('@/views/talent/index.vue'),
|
||||
meta: {
|
||||
title: '人才管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'talent/resume-templates',
|
||||
name: 'ResumeTemplates',
|
||||
component: () => import('@/views/talent/resume-templates.vue'),
|
||||
meta: {
|
||||
title: '简历模板管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'talent/:id',
|
||||
name: 'TalentDetail',
|
||||
component: () => import('@/views/talent/detail.vue'),
|
||||
meta: {
|
||||
title: '人才详情',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
|
||||
// 用户管理
|
||||
{
|
||||
path: 'user/list',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/user/list/index.vue'),
|
||||
meta: {
|
||||
title: '用户列表',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'user/roles',
|
||||
name: 'Roles',
|
||||
component: () => import('@/views/user/roles/index.vue'),
|
||||
meta: {
|
||||
title: '角色管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'user/certification',
|
||||
name: 'UserCertification',
|
||||
component: () => import('@/views/user/certification/index.vue'),
|
||||
meta: {
|
||||
title: '认证管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'user/positions',
|
||||
name: 'Positions',
|
||||
component: () => import('@/views/user/positions/index.vue'),
|
||||
meta: {
|
||||
title: '岗位管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'user/levels',
|
||||
name: 'Levels',
|
||||
component: () => import('@/views/user/levels/index.vue'),
|
||||
meta: {
|
||||
title: '等级配置',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 根据模式选择布局
|
||||
const isEmbedded = isEmbeddedMode()
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// 登录页面 - 仅在独立模式下使用
|
||||
...(!isEmbedded ? [{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
requiresAuth: false
|
||||
}
|
||||
}] : []),
|
||||
|
||||
{
|
||||
path: '/',
|
||||
// 根据模式选择布局
|
||||
component: isEmbedded
|
||||
? () => import('@/layouts/EmbeddedLayout.vue')
|
||||
: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: pageRoutes
|
||||
},
|
||||
|
||||
// 404页面
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: {
|
||||
title: '页面不存在'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = `${to.meta.title || 'CodePort'} - 码头管理后台`
|
||||
|
||||
// 嵌入模式下跳过登录检查
|
||||
if (isEmbedded) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
// 需要登录但未登录,跳转到登录页
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
} else if (to.path === '/login' && token) {
|
||||
// 已登录访问登录页,跳转到首页
|
||||
next({ path: '/' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
10
src/stores/index.ts
Normal file
10
src/stores/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Pinia Store 统一导出
|
||||
*/
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
|
||||
export * from './user'
|
||||
116
src/stores/user.ts
Normal file
116
src/stores/user.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 用户状态管理
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserInfo, LoginParams, LoginResult, CaptchaResult } from '@/types'
|
||||
import { mockGetCaptcha, mockLogin, mockGetUserInfo } from '@/mock'
|
||||
|
||||
// 是否使用Mock数据
|
||||
const USE_MOCK = true
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
const token = ref<string>(localStorage.getItem('token') || '')
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const username = computed(() => userInfo.value?.username || '')
|
||||
const nickname = computed(() => userInfo.value?.nickname || userInfo.value?.username || '')
|
||||
const avatar = computed(() => userInfo.value?.avatar || '')
|
||||
|
||||
// 获取验证码
|
||||
async function getCaptcha(): Promise<CaptchaResult> {
|
||||
if (USE_MOCK) {
|
||||
return await mockGetCaptcha()
|
||||
}
|
||||
// 真实API调用
|
||||
const { request } = await import('@/utils/request')
|
||||
const res = await request.get<CaptchaResult>('/auth/captcha')
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
// 登录
|
||||
async function login(params: LoginParams): Promise<LoginResult> {
|
||||
let data: LoginResult
|
||||
|
||||
if (USE_MOCK) {
|
||||
data = await mockLogin(params)
|
||||
} else {
|
||||
// 真实API调用
|
||||
const { request } = await import('@/utils/request')
|
||||
const res = await request.post<LoginResult>('/auth/login', params)
|
||||
data = res.data.data
|
||||
}
|
||||
|
||||
// 保存token
|
||||
token.value = data.token
|
||||
localStorage.setItem('token', data.token)
|
||||
|
||||
// 保存用户信息
|
||||
userInfo.value = data.userInfo
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 登出
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
const { request } = await import('@/utils/request')
|
||||
await request.post('/auth/logout')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error)
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async function getUserInfo(): Promise<UserInfo> {
|
||||
if (USE_MOCK) {
|
||||
const info = await mockGetUserInfo()
|
||||
userInfo.value = info
|
||||
return info
|
||||
}
|
||||
const { request } = await import('@/utils/request')
|
||||
const res = await request.get<UserInfo>('/user/info')
|
||||
userInfo.value = res.data.data
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
function setUserInfo(info: UserInfo) {
|
||||
userInfo.value = info
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
function resetState() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
token,
|
||||
userInfo,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
username,
|
||||
nickname,
|
||||
avatar,
|
||||
// 方法
|
||||
getCaptcha,
|
||||
login,
|
||||
logout,
|
||||
getUserInfo,
|
||||
setUserInfo,
|
||||
resetState
|
||||
}
|
||||
})
|
||||
50
src/style.css
Normal file
50
src/style.css
Normal file
@@ -0,0 +1,50 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
83
src/types/article.ts
Normal file
83
src/types/article.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 文章/通告管理相关类型
|
||||
*/
|
||||
|
||||
export type ArticleCategoryKey = 'announcement' | 'rule' | 'activity' | 'guide' | 'other'
|
||||
|
||||
export interface ArticleCategory {
|
||||
key: ArticleCategoryKey
|
||||
name: string
|
||||
description?: string
|
||||
color: string
|
||||
}
|
||||
|
||||
// 规则政策二级分类
|
||||
export type RuleSubCategoryKey = 'withdraw' | 'settlement' | 'cooperation' | 'violation' | 'privacy' | 'other'
|
||||
|
||||
export interface RuleSubCategory {
|
||||
key: RuleSubCategoryKey
|
||||
name: string
|
||||
}
|
||||
|
||||
// 规则政策二级分类列表
|
||||
export const RuleSubCategories: RuleSubCategory[] = [
|
||||
{ key: 'withdraw', name: '提现规则' },
|
||||
{ key: 'settlement', name: '结算规则' },
|
||||
{ key: 'cooperation', name: '合作协议' },
|
||||
{ key: 'violation', name: '违规处理' },
|
||||
{ key: 'privacy', name: '隐私政策' },
|
||||
{ key: 'other', name: '其他规则' }
|
||||
]
|
||||
|
||||
export interface ArticleInfo {
|
||||
id: number
|
||||
title: string
|
||||
summary: string
|
||||
content: string
|
||||
cover?: string
|
||||
category: ArticleCategoryKey
|
||||
subCategory?: RuleSubCategoryKey // 规则政策的二级分类
|
||||
tags: string[]
|
||||
publishStatus: 'draft' | 'published'
|
||||
pinned: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt?: string
|
||||
author: {
|
||||
id: number
|
||||
name: string
|
||||
avatar?: string
|
||||
}
|
||||
viewCount: number
|
||||
linkUrl?: string
|
||||
}
|
||||
|
||||
export interface ArticleQueryParams {
|
||||
keyword?: string
|
||||
category?: ArticleCategoryKey
|
||||
status?: ArticleInfo['publishStatus']
|
||||
pinned?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface ArticleListResult {
|
||||
list: ArticleInfo[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ArticleCreatePayload {
|
||||
title: string
|
||||
summary: string
|
||||
content: string
|
||||
category: ArticleCategoryKey
|
||||
subCategory?: RuleSubCategoryKey // 规则政策的二级分类
|
||||
tags: string[]
|
||||
publishStatus: ArticleInfo['publishStatus']
|
||||
pinned?: boolean
|
||||
cover?: string
|
||||
linkUrl?: string
|
||||
author: ArticleInfo['author']
|
||||
}
|
||||
134
src/types/certification.ts
Normal file
134
src/types/certification.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 用户认证相关类型定义
|
||||
*/
|
||||
|
||||
// 认证类型
|
||||
export type CertificationType = 'identity' | 'education' | 'professional' | 'enterprise'
|
||||
|
||||
// 认证类型映射
|
||||
export const CertificationTypeMap: Record<CertificationType, string> = {
|
||||
identity: '实名认证',
|
||||
education: '学历认证',
|
||||
professional: '职业资格认证',
|
||||
enterprise: '企业认证'
|
||||
}
|
||||
|
||||
// 认证状态
|
||||
export type CertificationStatus = 'pending' | 'verified' | 'rejected' | 'expired'
|
||||
|
||||
// 认证状态映射
|
||||
export const CertificationStatusMap: Record<CertificationStatus, string> = {
|
||||
pending: '审核中',
|
||||
verified: '已认证',
|
||||
rejected: '已拒绝',
|
||||
expired: '已过期'
|
||||
}
|
||||
|
||||
// 认证状态徽章映射
|
||||
export const CertificationStatusBadgeMap: Record<CertificationStatus, 'processing' | 'success' | 'error' | 'default'> = {
|
||||
pending: 'processing',
|
||||
verified: 'success',
|
||||
rejected: 'error',
|
||||
expired: 'default'
|
||||
}
|
||||
|
||||
// 实名认证信息
|
||||
export interface IdentityCertification {
|
||||
realName: string // 真实姓名
|
||||
idCardNumber: string // 身份证号(脱敏显示)
|
||||
idCardFrontUrl?: string // 身份证正面照片URL
|
||||
idCardBackUrl?: string // 身份证背面照片URL
|
||||
faceVerified: boolean // 是否通过人脸识别
|
||||
verifiedAt: string // 认证时间
|
||||
}
|
||||
|
||||
// 学历认证信息
|
||||
export interface EducationCertification {
|
||||
school: string // 学校名称
|
||||
major: string // 专业
|
||||
degree: string // 学位(本科、硕士、博士等)
|
||||
graduationYear: number // 毕业年份
|
||||
certificateNumber: string // 学位证书编号(脱敏)
|
||||
verifiedAt: string // 认证时间
|
||||
}
|
||||
|
||||
// 职业资格认证信息
|
||||
export interface ProfessionalCertification {
|
||||
certificateName: string // 证书名称
|
||||
certificateNumber: string // 证书编号(脱敏)
|
||||
issuer: string // 发证机构
|
||||
issueDate: string // 发证日期
|
||||
expiryDate?: string // 有效期(可选,部分证书永久有效)
|
||||
verifiedAt: string // 认证时间
|
||||
}
|
||||
|
||||
// 企业认证信息
|
||||
export interface EnterpriseCertification {
|
||||
companyName: string // 企业名称
|
||||
unifiedSocialCreditCode: string // 统一社会信用代码(脱敏)
|
||||
legalRepresentative: string // 法定代表人
|
||||
registeredCapital: string // 注册资本
|
||||
businessScope: string // 经营范围
|
||||
businessLicenseUrl?: string // 营业执照照片URL
|
||||
verifiedAt: string // 认证时间
|
||||
}
|
||||
|
||||
// 认证记录
|
||||
export interface CertificationRecord {
|
||||
id: number
|
||||
userId: number
|
||||
userName: string
|
||||
userAvatar: string
|
||||
userPhone: string
|
||||
userEmail: string
|
||||
type: CertificationType
|
||||
status: CertificationStatus
|
||||
|
||||
// 认证详情(根据类型不同包含不同内容)
|
||||
identityInfo?: IdentityCertification
|
||||
educationInfo?: EducationCertification
|
||||
professionalInfo?: ProfessionalCertification
|
||||
enterpriseInfo?: EnterpriseCertification
|
||||
|
||||
// 第三方认证信息
|
||||
thirdPartyProvider: string // 第三方认证提供商
|
||||
thirdPartyOrderId: string // 第三方订单号
|
||||
|
||||
// 审核信息
|
||||
rejectReason?: string // 拒绝原因
|
||||
submittedAt: string // 提交时间
|
||||
verifiedAt?: string // 认证通过时间
|
||||
expiredAt?: string // 过期时间
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 认证统计
|
||||
export interface CertificationStats {
|
||||
total: number
|
||||
verified: number
|
||||
pending: number
|
||||
rejected: number
|
||||
expired: number
|
||||
todayNew: number
|
||||
}
|
||||
|
||||
// 查询参数
|
||||
export interface CertificationQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
type?: CertificationType
|
||||
status?: CertificationStatus
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 查询结果
|
||||
export interface CertificationListResult {
|
||||
list: CertificationRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
122
src/types/cityCircle.ts
Normal file
122
src/types/cityCircle.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 城市圈子相关类型定义
|
||||
*/
|
||||
|
||||
// 圈子标签
|
||||
export interface CircleTag {
|
||||
id: number
|
||||
name: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
// 帖子话题
|
||||
export interface CircleTopic {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
postCount: number
|
||||
isHot?: boolean // 热门话题
|
||||
isNew?: boolean // 最新话题
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 热门讨论
|
||||
export interface HotDiscussion {
|
||||
id: number
|
||||
title: string
|
||||
topicName: string
|
||||
discussionCount: number
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
// 圈子成员角色
|
||||
export type CircleMemberRole = 'owner' | 'operator' | 'member'
|
||||
|
||||
// 圈子成员
|
||||
export interface CircleMember {
|
||||
id: number
|
||||
userId: number
|
||||
nickname: string
|
||||
avatar?: string
|
||||
role: CircleMemberRole
|
||||
title?: string // 职位描述,如 "圈子主持人"、"招聘合伙人"
|
||||
tags?: string[] // 成员标签,如 "互联网"、"AI"
|
||||
operatorType?: string // 运营人员类型,如 "全职运营"、"开放推广"
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
// 城市圈子状态
|
||||
export type CircleStatus = 'active' | 'inactive' | 'pending'
|
||||
|
||||
// 城市圈子统计数据
|
||||
export interface CircleStats {
|
||||
developerCount: number // 开发者数量
|
||||
openPositionCount: number // 开放岗位
|
||||
avgSalary: number // 平均薪资 (K)
|
||||
activeTopicCount: number // 活跃话题数
|
||||
hotTopicCount: number // 热门讨论数
|
||||
coreMemberCount: number // 核心成员数
|
||||
}
|
||||
|
||||
// 城市圈子
|
||||
export interface CityCircle {
|
||||
id: number
|
||||
cityId: number // 关联城市管理的城市ID
|
||||
cityName: string
|
||||
cityNameEn?: string // 城市英文名
|
||||
cityCode: string
|
||||
description: string // 城市简介
|
||||
tags: CircleTag[] // 城市标签
|
||||
icon?: string // 城市图标/logo
|
||||
coverImage?: string // 封面图
|
||||
stats: CircleStats
|
||||
topics: CircleTopic[]
|
||||
hotDiscussions: HotDiscussion[]
|
||||
members: CircleMember[]
|
||||
operators: CircleMember[] // 主要运营人员
|
||||
status: CircleStatus
|
||||
sort: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 城市圈子列表查询参数
|
||||
export interface CityCircleQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
cityId?: number
|
||||
status?: CircleStatus
|
||||
}
|
||||
|
||||
// 城市圈子列表结果
|
||||
export interface CityCircleListResult {
|
||||
list: CityCircle[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 创建/编辑城市圈子表单
|
||||
export interface CityCircleFormData {
|
||||
id?: number
|
||||
cityId: number
|
||||
description: string
|
||||
tags: number[] // 标签ID列表
|
||||
status: CircleStatus
|
||||
sort: number
|
||||
}
|
||||
|
||||
// 添加成员表单
|
||||
export interface AddMemberFormData {
|
||||
userId: number
|
||||
role: CircleMemberRole
|
||||
title?: string
|
||||
operatorType?: string
|
||||
}
|
||||
|
||||
// 创建话题表单
|
||||
export interface CreateTopicFormData {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
72
src/types/comment.ts
Normal file
72
src/types/comment.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 评论管理相关类型定义
|
||||
*/
|
||||
|
||||
// 评论状态
|
||||
export type CommentStatus = 'normal' | 'hidden' | 'deleted'
|
||||
|
||||
// 评论状态映射
|
||||
export const CommentStatusMap: Record<CommentStatus, string> = {
|
||||
normal: '正常',
|
||||
hidden: '已隐藏',
|
||||
deleted: '已删除'
|
||||
}
|
||||
|
||||
// 评论者信息
|
||||
export interface CommentUser {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
// 关联的帖子简要信息
|
||||
export interface CommentPost {
|
||||
id: number
|
||||
title: string
|
||||
authorName: string
|
||||
}
|
||||
|
||||
// 评论记录
|
||||
export interface CommentRecord {
|
||||
id: number
|
||||
post: CommentPost // 关联帖子
|
||||
user: CommentUser // 评论者
|
||||
content: string // 评论内容
|
||||
likeCount: number // 点赞数
|
||||
replyCount: number // 回复数(一级评论才有)
|
||||
parentId?: number // 父评论ID(回复才有)
|
||||
parentContent?: string // 父评论内容摘要
|
||||
status: CommentStatus // 状态
|
||||
createdAt: string // 评论时间
|
||||
}
|
||||
|
||||
// 评论查询参数
|
||||
export interface CommentQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string // 搜索评论内容
|
||||
postId?: number // 帖子ID
|
||||
userId?: number // 用户ID
|
||||
status?: CommentStatus // 状态
|
||||
isReply?: boolean // 是否为回复
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 评论列表响应
|
||||
export interface CommentListResult {
|
||||
list: CommentRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 评论统计
|
||||
export interface CommentStats {
|
||||
total: number
|
||||
todayNew: number
|
||||
normal: number
|
||||
hidden: number
|
||||
}
|
||||
|
||||
92
src/types/conversation.ts
Normal file
92
src/types/conversation.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 客服会话相关类型定义
|
||||
*/
|
||||
|
||||
export type ConversationChannel = 'app' | 'web' | 'wechat' | 'miniapp'
|
||||
export type ConversationStatus = 'waiting' | 'active' | 'pending' | 'resolved' | 'closed'
|
||||
export type ConversationPriority = 'normal' | 'high' | 'vip'
|
||||
|
||||
export interface ConversationTag {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface ConversationCustomer {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar: string
|
||||
city: string
|
||||
vip: boolean
|
||||
level: string
|
||||
intents: string[]
|
||||
}
|
||||
|
||||
export interface ConversationAgent {
|
||||
id: number
|
||||
name: string
|
||||
avatar: string
|
||||
title: string
|
||||
workload: number
|
||||
expertise: string[]
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: number
|
||||
sender: 'user' | 'agent' | 'system'
|
||||
senderName: string
|
||||
content: string
|
||||
timestamp: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface ConversationSession {
|
||||
id: number
|
||||
sessionCode: string
|
||||
channel: ConversationChannel
|
||||
status: ConversationStatus
|
||||
priority: ConversationPriority
|
||||
createdAt: string
|
||||
lastMessageAt: string
|
||||
lastMessage: string
|
||||
unreadCount: number
|
||||
totalMessages: number
|
||||
waitingTime: number
|
||||
firstResponseAt?: string
|
||||
resolvedAt?: string
|
||||
satisfaction?: number
|
||||
autoDetectedIntent: string
|
||||
source: string
|
||||
tags: ConversationTag[]
|
||||
customer: ConversationCustomer
|
||||
assignedAgent?: ConversationAgent
|
||||
messages: ConversationMessage[]
|
||||
}
|
||||
|
||||
export interface ConversationListQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: ConversationStatus | 'all'
|
||||
channel?: ConversationChannel | 'all'
|
||||
priority?: ConversationPriority | 'all'
|
||||
vipOnly?: boolean
|
||||
dateRange?: [string, string]
|
||||
}
|
||||
|
||||
export interface ConversationMetrics {
|
||||
waitingCount: number
|
||||
activeCount: number
|
||||
pendingCount: number
|
||||
resolvedToday: number
|
||||
satisfaction: number
|
||||
avgFirstResponse: number
|
||||
}
|
||||
|
||||
export interface ConversationListResult {
|
||||
list: ConversationSession[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
metrics: ConversationMetrics
|
||||
}
|
||||
17
src/types/index.ts
Normal file
17
src/types/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 类型统一导出
|
||||
*/
|
||||
|
||||
export * from './user'
|
||||
export * from './response'
|
||||
export * from './post'
|
||||
export * from './project'
|
||||
export * from './recruitment'
|
||||
export * from './comment'
|
||||
export * from './talent'
|
||||
export * from './conversation'
|
||||
export * from './article'
|
||||
export * from './cityCircle'
|
||||
export * from './certification'
|
||||
export * from './signedProject'
|
||||
export * from './projectSession'
|
||||
58
src/types/post.ts
Normal file
58
src/types/post.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 帖子相关类型定义
|
||||
*/
|
||||
|
||||
// 帖子标签
|
||||
export interface PostTag {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
// 帖子作者信息
|
||||
export interface PostAuthor {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
// 帖子信息
|
||||
export interface PostInfo {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
author: PostAuthor
|
||||
tags: PostTag[]
|
||||
viewCount: number
|
||||
likeCount: number
|
||||
commentCount: number
|
||||
isHot: boolean // 是否热门:查看>1000 或 点赞>500
|
||||
isOfficial: boolean // 是否官方帖子
|
||||
status: 'published' | 'hidden' | 'deleted'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 帖子查询参数
|
||||
export interface PostQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
tagId?: number
|
||||
authorId?: number
|
||||
status?: string
|
||||
isHot?: boolean
|
||||
isOfficial?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 帖子列表响应
|
||||
export interface PostListResult {
|
||||
list: PostInfo[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
78
src/types/project.ts
Normal file
78
src/types/project.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 项目相关类型定义
|
||||
*/
|
||||
|
||||
// 项目标签
|
||||
export interface ProjectTag {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
// 项目发布人
|
||||
export interface ProjectPublisher {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
company?: string
|
||||
}
|
||||
|
||||
// 工作类型
|
||||
export type WorkType = 'fulltime' | 'parttime' | 'remote' | 'internship' | 'freelance'
|
||||
|
||||
// 工作类型映射
|
||||
export const WorkTypeMap: Record<WorkType, string> = {
|
||||
fulltime: '全职',
|
||||
parttime: '兼职',
|
||||
remote: '远程',
|
||||
internship: '实习',
|
||||
freelance: '自由接单'
|
||||
}
|
||||
|
||||
// 项目信息
|
||||
export interface ProjectInfo {
|
||||
id: number
|
||||
name: string // 项目名称
|
||||
salaryMin: number // 最低金额
|
||||
salaryMax: number // 最高金额
|
||||
salaryUnit: string // 金额单位(月/次/项目)
|
||||
publisher: ProjectPublisher // 发布人
|
||||
location: string // 工作地点
|
||||
workType: WorkType // 工作类型
|
||||
description: string // 项目描述
|
||||
requirements: string // 任职要求
|
||||
benefits: string // 福利待遇
|
||||
tags: ProjectTag[] // 项目标签
|
||||
contactEmail: string // 联系邮箱
|
||||
deadline: string // 招聘截止日期
|
||||
status: 'active' | 'closed' | 'expired' // 项目状态
|
||||
createdAt: string // 发布时间
|
||||
updatedAt: string // 更新时间
|
||||
// 置顶相关
|
||||
isPinned: boolean // 是否置顶
|
||||
pinnedUntil?: string // 置顶到期时间
|
||||
// 曝光相关
|
||||
exposureCount: number // 曝光次数
|
||||
lastExposureAt?: string // 最后曝光时间
|
||||
}
|
||||
|
||||
// 项目查询参数
|
||||
export interface ProjectQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
workType?: WorkType
|
||||
tagId?: number
|
||||
status?: string
|
||||
location?: string
|
||||
}
|
||||
|
||||
// 项目列表响应
|
||||
export interface ProjectListResult {
|
||||
list: ProjectInfo[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
84
src/types/projectSession.ts
Normal file
84
src/types/projectSession.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 项目会话管理相关类型定义
|
||||
*/
|
||||
import type { ApplicantInfo } from './recruitment'
|
||||
import type { ProjectInfo } from './project'
|
||||
|
||||
// 项目成员角色
|
||||
export type ProjectRole = 'leader' | 'product' | 'frontend' | 'backend' | 'test' | 'design'
|
||||
|
||||
// 角色的中文映射
|
||||
export const ProjectRoleMap: Record<ProjectRole, string> = {
|
||||
leader: '负责人',
|
||||
product: '产品经理',
|
||||
frontend: '前端开发',
|
||||
backend: '后端开发',
|
||||
test: '测试工程师',
|
||||
design: 'UI设计师'
|
||||
}
|
||||
|
||||
// 项目成员信息
|
||||
export interface ProjectMember {
|
||||
id: number // 成员记录ID
|
||||
userId: number // 用户ID (对应ApplicantInfo.id)
|
||||
role: ProjectRole // 担任角色
|
||||
info: ApplicantInfo // 详细信息
|
||||
isLeader: boolean // 是否负责人
|
||||
status: 'online' | 'offline' // 在线状态
|
||||
joinedAt: string // 加入时间
|
||||
}
|
||||
|
||||
// 项目流程节点
|
||||
export interface ProjectProgressNode {
|
||||
id: number
|
||||
title: string
|
||||
status: 'pending' | 'processing' | 'completed' // 待开始 | 进行中 | 已完成
|
||||
completedAt?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 项目文件资料
|
||||
export interface ProjectMaterial {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
type: 'doc' | 'image' | 'archive' | 'other'
|
||||
size: string
|
||||
uploadedBy: string
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
// 会话消息
|
||||
export interface ProjectMessage {
|
||||
id: number
|
||||
sessionId: number
|
||||
senderId: number
|
||||
senderName: string
|
||||
senderAvatar: string
|
||||
senderRole: ProjectRole
|
||||
content: string
|
||||
sentAt: string
|
||||
type: 'text' | 'file' | 'image' | 'system'
|
||||
}
|
||||
|
||||
// 项目会话详情
|
||||
export interface ProjectSession {
|
||||
id: number // 会话ID
|
||||
projectId: number // 关联项目ID
|
||||
projectInfo: ProjectInfo // 项目基本信息
|
||||
members: ProjectMember[] // 团队成员
|
||||
progressNodes: ProjectProgressNode[] // 项目进度
|
||||
materials: ProjectMaterial[] // 文件资料
|
||||
messages: ProjectMessage[] // 聊天记录 (通常只加载最近的)
|
||||
onlineCount: number // 在线人数
|
||||
}
|
||||
|
||||
// 会话列表项 (用于左侧导航或列表页)
|
||||
export interface ProjectSessionListItem {
|
||||
id: number
|
||||
projectId: number
|
||||
projectName: string
|
||||
lastMessage?: string
|
||||
lastMessageTime?: string
|
||||
unreadCount: number
|
||||
}
|
||||
101
src/types/recruitment.ts
Normal file
101
src/types/recruitment.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 招募管理相关类型定义
|
||||
*/
|
||||
|
||||
// 申请状态
|
||||
export type RecruitmentStatus = 'pending' | 'approved' | 'rejected' | 'withdrawn' | 'expired'
|
||||
|
||||
// 申请状态映射(社区招募场景:发布者-申请者之间的状态)
|
||||
export const RecruitmentStatusMap: Record<RecruitmentStatus, string> = {
|
||||
pending: '待回复',
|
||||
approved: '已接受',
|
||||
rejected: '已拒绝',
|
||||
withdrawn: '已撤回',
|
||||
expired: '已过期'
|
||||
}
|
||||
|
||||
// 申请人信息
|
||||
export interface ApplicantInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
email: string
|
||||
phone?: string
|
||||
skills: string[] // 技能标签
|
||||
experience: string // 工作经验(如:3年)
|
||||
introduction: string // 个人简介
|
||||
portfolioUrl?: string // 作品集链接
|
||||
resumeUrl?: string // 简历链接
|
||||
}
|
||||
|
||||
// 关联的项目简要信息
|
||||
export interface RecruitmentProject {
|
||||
id: number
|
||||
name: string
|
||||
workType: string
|
||||
location: string
|
||||
salaryMin: number
|
||||
salaryMax: number
|
||||
salaryUnit: string
|
||||
publisherName: string
|
||||
}
|
||||
|
||||
// 发布人信息
|
||||
export interface PublisherInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
email: string
|
||||
phone?: string
|
||||
company?: string // 公司名称
|
||||
position?: string // 职位
|
||||
verified: boolean // 是否认证
|
||||
}
|
||||
|
||||
// 招募申请记录
|
||||
export interface RecruitmentRecord {
|
||||
id: number
|
||||
project: RecruitmentProject // 关联项目
|
||||
applicant: ApplicantInfo // 申请人
|
||||
publisher: PublisherInfo // 发布人
|
||||
expectedSalary: number // 期望薪资
|
||||
availableDate: string // 可到岗日期
|
||||
coverLetter: string // 申请说明/求职信
|
||||
status: RecruitmentStatus // 申请状态
|
||||
appliedAt: string // 申请时间
|
||||
processedAt?: string // 处理时间
|
||||
processedBy?: string // 处理人
|
||||
rejectReason?: string // 拒绝原因
|
||||
remark?: string // 备注
|
||||
}
|
||||
|
||||
// 招募查询参数
|
||||
export interface RecruitmentQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string // 搜索项目名/申请人
|
||||
projectId?: number // 项目ID
|
||||
status?: RecruitmentStatus // 状态
|
||||
startDate?: string // 申请开始日期
|
||||
endDate?: string // 申请结束日期
|
||||
}
|
||||
|
||||
// 招募列表响应
|
||||
export interface RecruitmentListResult {
|
||||
list: RecruitmentRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 招募统计
|
||||
export interface RecruitmentStats {
|
||||
total: number
|
||||
pending: number
|
||||
approved: number
|
||||
rejected: number
|
||||
todayNew: number
|
||||
}
|
||||
|
||||
32
src/types/response.ts
Normal file
32
src/types/response.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 响应相关类型定义
|
||||
*/
|
||||
|
||||
// 基础响应结构
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PageParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PageResult<T = unknown> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// 列表响应
|
||||
export interface ListResult<T = unknown> {
|
||||
list: T[]
|
||||
total: number
|
||||
}
|
||||
251
src/types/signedProject.ts
Normal file
251
src/types/signedProject.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 已成交项目相关类型定义
|
||||
*/
|
||||
|
||||
// 项目进度状态
|
||||
export type ProjectProgressStatus = 'signed' | 'in_progress' | 'delivered' | 'accepted' | 'completed' | 'disputed'
|
||||
|
||||
// 项目进度状态映射
|
||||
export const ProjectProgressStatusMap: Record<ProjectProgressStatus, string> = {
|
||||
signed: '已签约',
|
||||
in_progress: '进行中',
|
||||
delivered: '已交付',
|
||||
accepted: '已验收',
|
||||
completed: '已完成',
|
||||
disputed: '争议处理'
|
||||
}
|
||||
|
||||
// 项目进度状态徽章映射
|
||||
export const ProjectProgressStatusBadgeMap: Record<ProjectProgressStatus, 'default' | 'processing' | 'success' | 'warning' | 'error'> = {
|
||||
signed: 'default',
|
||||
in_progress: 'processing',
|
||||
delivered: 'warning',
|
||||
accepted: 'success',
|
||||
completed: 'success',
|
||||
disputed: 'error'
|
||||
}
|
||||
|
||||
// 签约人信息
|
||||
export interface ContractPartyInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
email: string
|
||||
phone?: string
|
||||
company?: string
|
||||
position?: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
// 合同信息
|
||||
export interface ContractInfo {
|
||||
id: number
|
||||
contractNo: string // 合同编号
|
||||
title: string // 合同标题
|
||||
type: 'service' | 'project' | 'freelance' // 合同类型
|
||||
amount: number // 合同金额
|
||||
currency: 'CNY'
|
||||
signedAt: string // 签约时间
|
||||
startDate: string // 开始日期
|
||||
endDate: string // 结束日期
|
||||
status: 'active' | 'completed' | 'terminated' | 'expired' // 合同状态
|
||||
attachmentUrl?: string // 合同附件URL
|
||||
attachmentName?: string // 附件名称
|
||||
remark?: string // 备注
|
||||
}
|
||||
|
||||
// 项目进度里程碑
|
||||
export interface ProjectMilestone {
|
||||
id: number
|
||||
title: string // 里程碑标题
|
||||
description?: string // 描述
|
||||
plannedDate: string // 计划完成日期
|
||||
actualDate?: string // 实际完成日期
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'delayed'
|
||||
deliverables?: string[] // 交付物
|
||||
}
|
||||
|
||||
// 已成交项目记录
|
||||
export interface SignedProjectRecord {
|
||||
id: number
|
||||
// 项目基本信息
|
||||
projectName: string
|
||||
projectDescription: string
|
||||
workType: string
|
||||
location: string
|
||||
|
||||
// 金额信息
|
||||
contractAmount: number // 签约金额
|
||||
paidAmount: number // 已支付金额
|
||||
pendingAmount: number // 待支付金额
|
||||
currency: 'CNY'
|
||||
|
||||
// 发布人和签约人
|
||||
publisher: ContractPartyInfo // 发布人/甲方
|
||||
contractor: ContractPartyInfo // 签约人/乙方
|
||||
|
||||
// 合同信息
|
||||
contract: ContractInfo
|
||||
|
||||
// 项目进度
|
||||
progressStatus: ProjectProgressStatus
|
||||
progressPercent: number // 进度百分比 0-100
|
||||
milestones: ProjectMilestone[] // 里程碑列表
|
||||
|
||||
// 时间信息
|
||||
signedAt: string // 签约时间
|
||||
startedAt?: string // 项目开始时间
|
||||
deliveredAt?: string // 交付时间
|
||||
completedAt?: string // 完成时间
|
||||
|
||||
// 评价信息
|
||||
publisherRating?: number // 发布人给的评分
|
||||
contractorRating?: number // 签约人给的评分
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 已成交项目统计
|
||||
export interface SignedProjectStats {
|
||||
total: number
|
||||
inProgress: number
|
||||
completed: number
|
||||
disputed: number
|
||||
totalAmount: number
|
||||
paidAmount: number
|
||||
}
|
||||
|
||||
// 查询参数
|
||||
export interface SignedProjectQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
progressStatus?: ProjectProgressStatus
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 查询结果
|
||||
export interface SignedProjectListResult {
|
||||
list: SignedProjectRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// ================== 合同管理类型 ==================
|
||||
|
||||
// 合同类型
|
||||
export type ContractType = 'service' | 'project' | 'freelance' | 'nda' | 'other'
|
||||
|
||||
// 合同类型映射
|
||||
export const ContractTypeMap: Record<ContractType, string> = {
|
||||
service: '服务合同',
|
||||
project: '项目合同',
|
||||
freelance: '自由职业合同',
|
||||
nda: '保密协议',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
// 合同状态
|
||||
export type ContractStatus = 'draft' | 'pending_sign' | 'active' | 'completed' | 'terminated' | 'expired'
|
||||
|
||||
// 合同状态映射
|
||||
export const ContractStatusMap: Record<ContractStatus, string> = {
|
||||
draft: '草稿',
|
||||
pending_sign: '待签署',
|
||||
active: '生效中',
|
||||
completed: '已完成',
|
||||
terminated: '已终止',
|
||||
expired: '已过期'
|
||||
}
|
||||
|
||||
// 合同状态徽章
|
||||
export const ContractStatusBadgeMap: Record<ContractStatus, 'default' | 'processing' | 'success' | 'warning' | 'error'> = {
|
||||
draft: 'default',
|
||||
pending_sign: 'processing',
|
||||
active: 'success',
|
||||
completed: 'success',
|
||||
terminated: 'error',
|
||||
expired: 'warning'
|
||||
}
|
||||
|
||||
// 完整合同记录
|
||||
export interface ContractRecord {
|
||||
id: number
|
||||
contractNo: string // 合同编号
|
||||
title: string // 合同标题
|
||||
type: ContractType // 合同类型
|
||||
|
||||
// 合同双方
|
||||
partyA: ContractPartyInfo // 甲方
|
||||
partyB: ContractPartyInfo // 乙方
|
||||
|
||||
// 关联项目
|
||||
relatedProjectId?: number
|
||||
relatedProjectName?: string
|
||||
|
||||
// 金额
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
|
||||
// 时间
|
||||
signedAt?: string // 签署时间
|
||||
effectiveDate: string // 生效日期
|
||||
expiryDate: string // 到期日期
|
||||
|
||||
// 状态
|
||||
status: ContractStatus
|
||||
|
||||
// 附件
|
||||
attachments: {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
}[]
|
||||
|
||||
// 签署记录
|
||||
signRecords: {
|
||||
partyType: 'A' | 'B'
|
||||
signedBy: string
|
||||
signedAt: string
|
||||
signMethod: 'electronic' | 'manual'
|
||||
}[]
|
||||
|
||||
remark?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 合同统计
|
||||
export interface ContractStats {
|
||||
total: number
|
||||
active: number
|
||||
pendingSign: number
|
||||
completed: number
|
||||
expired: number
|
||||
totalAmount: number
|
||||
}
|
||||
|
||||
// 合同查询参数
|
||||
export interface ContractQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
type?: ContractType
|
||||
status?: ContractStatus
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 合同列表结果
|
||||
export interface ContractListResult {
|
||||
list: ContractRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
236
src/types/talent.ts
Normal file
236
src/types/talent.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 人才管理类型定义
|
||||
*/
|
||||
import type { PostTag } from './post'
|
||||
|
||||
// 人员状态
|
||||
export type TalentStatus = 'available' | 'busy' | 'onboarding' | 'resting'
|
||||
|
||||
// 状态文本映射
|
||||
export const TalentStatusMap: Record<TalentStatus, string> = {
|
||||
available: '可约',
|
||||
busy: '忙碌中',
|
||||
onboarding: '即将空闲',
|
||||
resting: '休整中'
|
||||
}
|
||||
|
||||
// 状态徽章映射
|
||||
export const TalentStatusBadgeMap: Record<
|
||||
TalentStatus,
|
||||
'success' | 'processing' | 'error' | 'default' | 'warning'
|
||||
> = {
|
||||
available: 'success',
|
||||
busy: 'error',
|
||||
onboarding: 'processing',
|
||||
resting: 'default'
|
||||
}
|
||||
|
||||
// 状态色值映射
|
||||
export const TalentStatusColorMap: Record<TalentStatus, string> = {
|
||||
available: '#52c41a',
|
||||
busy: '#fa541c',
|
||||
onboarding: '#1890ff',
|
||||
resting: '#d9d9d9'
|
||||
}
|
||||
|
||||
export type TalentTag = PostTag
|
||||
|
||||
export type TalentFeeUnit = 'day' | 'month'
|
||||
|
||||
export interface TalentFee {
|
||||
amount: number
|
||||
unit: TalentFeeUnit
|
||||
currency: 'CNY'
|
||||
}
|
||||
|
||||
// 评分信息(信誉评分由后台计算)- 100分制
|
||||
export interface TalentRating {
|
||||
credit: number // 信誉评分 0-100(后台计算:基于履约、评价、投诉等)
|
||||
project: number // 项目评分 0-100
|
||||
}
|
||||
|
||||
// 评分等级映射
|
||||
export function getRatingGrade(score: number): { grade: string; color: string } {
|
||||
if (score >= 90) return { grade: 'A+', color: '#52c41a' }
|
||||
if (score >= 80) return { grade: 'A', color: '#73d13d' }
|
||||
if (score >= 70) return { grade: 'B+', color: '#1890ff' }
|
||||
if (score >= 60) return { grade: 'B', color: '#40a9ff' }
|
||||
if (score >= 50) return { grade: 'C', color: '#faad14' }
|
||||
return { grade: 'D', color: '#ff4d4f' }
|
||||
}
|
||||
|
||||
// 用户社交统计
|
||||
export interface UserSocialStats {
|
||||
completedTaskCount: number // 已完成任务数
|
||||
ongoingTaskCount: number // 进行中任务数
|
||||
followingCount: number // 关注数
|
||||
followerCount: number // 粉丝数
|
||||
starCount: number // 星标/收藏数
|
||||
likeCount: number // 点赞数
|
||||
}
|
||||
|
||||
// 签到信息
|
||||
export interface CheckInInfo {
|
||||
consecutiveDays: number // 连续签到天数
|
||||
totalDays: number // 累计签到天数
|
||||
lastCheckIn?: string // 最后签到日期
|
||||
todayCheckedIn: boolean // 今日是否已签到
|
||||
checkedInDates: string[] // 本月已签到日期列表 (格式: 'YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export interface TalentProjectRecord {
|
||||
id: number
|
||||
name: string
|
||||
role: string
|
||||
deliveryDate: string
|
||||
score: number // 项目评分 0-100
|
||||
income?: number // 项目收益(元)
|
||||
}
|
||||
|
||||
// 收益记录类型 (人才收益)
|
||||
export type TalentIncomeType = 'project' | 'task' | 'reward' | 'other'
|
||||
|
||||
// 收益记录类型映射
|
||||
export const TalentIncomeTypeMap: Record<TalentIncomeType, string> = {
|
||||
project: '项目结算',
|
||||
task: '完成任务',
|
||||
reward: '奖励',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
// 收益记录 (人才收益)
|
||||
export interface TalentIncomeRecord {
|
||||
id: number
|
||||
type: TalentIncomeType
|
||||
title: string // 收益标题,如项目名称、任务名称
|
||||
amount: number // 金额(正数为收入,负数为支出)
|
||||
isIncome: boolean // 是否为收入(用于区分收入/支出)
|
||||
relatedProjectId?: number // 关联项目ID
|
||||
createdAt: string // 创建时间
|
||||
remark?: string // 备注
|
||||
}
|
||||
|
||||
// 附件简历
|
||||
export interface TalentResume {
|
||||
id: number
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileSize: number // 文件大小(字节)
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
// 在线简历模板
|
||||
export interface ResumeTemplate {
|
||||
id: number
|
||||
name: string
|
||||
thumbnail: string // 模板缩略图
|
||||
description: string
|
||||
isDefault: boolean
|
||||
createdAt: string
|
||||
tags?: string[]
|
||||
useCount?: number
|
||||
}
|
||||
|
||||
// 工作经历
|
||||
export interface WorkExperience {
|
||||
id: number
|
||||
company: string
|
||||
industry?: string
|
||||
department?: string
|
||||
position: string
|
||||
startTime: string
|
||||
endTime: string | null // null 表示至今
|
||||
description: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
// 教育经历
|
||||
export interface EducationExperience {
|
||||
id: number
|
||||
school: string
|
||||
major: string
|
||||
degree: string // 本科, 硕士, 博士...
|
||||
startTime: string
|
||||
endTime: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 求职意向
|
||||
export interface JobIntention {
|
||||
jobStatus: string // 离职-随时到岗, 在职-月内到岗...
|
||||
jobType: string // 全职, 兼职, 外包...
|
||||
expectedSalary: string
|
||||
expectedCity: string[]
|
||||
expectedIndustry: string[]
|
||||
expectedPosition: string[]
|
||||
}
|
||||
|
||||
export interface TalentProfile {
|
||||
id: number
|
||||
realName: string
|
||||
avatar: string
|
||||
gender: 'male' | 'female'
|
||||
age: number
|
||||
phone?: string
|
||||
email?: string
|
||||
wechat?: string
|
||||
positionTitle: string
|
||||
domain?: string // 领域/方向,如 "Web3 / SaaS"
|
||||
verified: boolean // 是否认证
|
||||
skillTags: TalentTag[]
|
||||
cityTag: TalentTag
|
||||
projectCount: number
|
||||
experienceYears: number
|
||||
degree: string // 最高学历
|
||||
fee: TalentFee
|
||||
rating: TalentRating
|
||||
level: number
|
||||
status: TalentStatus
|
||||
introduction: string
|
||||
recentProjects: TalentProjectRecord[]
|
||||
availableFrom: string
|
||||
hot: boolean
|
||||
|
||||
// 社交统计
|
||||
socialStats: UserSocialStats
|
||||
|
||||
// 签到信息
|
||||
checkInInfo: CheckInInfo
|
||||
|
||||
// 收益钱包
|
||||
wallet: {
|
||||
balance: number // 当前余额
|
||||
totalIncome: number // 累计收入
|
||||
totalExpense: number // 累计支出
|
||||
currency: 'CNY' // 币种
|
||||
}
|
||||
|
||||
// 收益记录列表
|
||||
incomeRecords: TalentIncomeRecord[]
|
||||
|
||||
// 简历相关
|
||||
resumeAttachments: TalentResume[] // 附件简历列表
|
||||
selectedTemplateId?: number // 使用的在线简历模板ID
|
||||
|
||||
// 详细简历数据
|
||||
workExperiences: WorkExperience[]
|
||||
educationExperiences: EducationExperience[]
|
||||
jobIntention: JobIntention
|
||||
}
|
||||
|
||||
export interface TalentQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: TalentStatus
|
||||
tagId?: number
|
||||
cityTagId?: number
|
||||
hotOnly?: boolean
|
||||
}
|
||||
|
||||
export interface TalentListResult {
|
||||
list: TalentProfile[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
162
src/types/user.ts
Normal file
162
src/types/user.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 用户相关类型定义
|
||||
*/
|
||||
import type { TalentStatus, TalentFee, TalentRating, TalentTag, TalentIncomeRecord } from './talent'
|
||||
|
||||
// 用户信息(用于鉴权等场景)
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: string
|
||||
permissions?: string[]
|
||||
createTime?: string
|
||||
lastLoginTime?: string
|
||||
}
|
||||
|
||||
// 登录请求参数
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
captcha: string
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 登录响应数据
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
refreshToken?: string
|
||||
expires: number
|
||||
userInfo: UserInfo
|
||||
}
|
||||
|
||||
// 验证码响应
|
||||
export interface CaptchaResult {
|
||||
captchaKey: string
|
||||
captchaImage: string
|
||||
}
|
||||
|
||||
// 用户与人才数据关联摘要
|
||||
export interface UserTalentSummary {
|
||||
status: TalentStatus
|
||||
projectCount: number
|
||||
experienceYears: number
|
||||
fee: TalentFee
|
||||
rating: TalentRating
|
||||
level: number
|
||||
hot: boolean
|
||||
availableFrom: string
|
||||
skillTags: TalentTag[]
|
||||
cityTag: TalentTag
|
||||
// 新增字段
|
||||
verified?: boolean // 是否认证
|
||||
domain?: string // 领域/方向
|
||||
socialStats?: {
|
||||
completedTaskCount: number
|
||||
ongoingTaskCount: number
|
||||
followingCount: number
|
||||
followerCount: number
|
||||
starCount: number
|
||||
likeCount: number
|
||||
}
|
||||
// 收益钱包
|
||||
wallet?: {
|
||||
balance: number // 当前余额
|
||||
totalIncome: number // 累计收入
|
||||
totalExpense: number // 累计支出
|
||||
currency: 'CNY' // 币种
|
||||
}
|
||||
// 收益记录列表
|
||||
incomeRecords?: TalentIncomeRecord[]
|
||||
// 发布招募统计
|
||||
publishStats?: {
|
||||
totalPublished: number // 总发布数
|
||||
activePublished: number // 进行中
|
||||
closedPublished: number // 已关闭
|
||||
totalApplicants: number // 总申请人数
|
||||
}
|
||||
// 发布的招募记录(简要)
|
||||
publishedRecruitments?: PublishedRecruitmentSummary[]
|
||||
}
|
||||
|
||||
// 发布的招募摘要
|
||||
export interface PublishedRecruitmentSummary {
|
||||
id: number
|
||||
projectName: string // 项目名称
|
||||
workType: string // 工作类型
|
||||
location: string // 工作地点
|
||||
salaryRange: string // 薪资范围
|
||||
applicantCount: number // 申请人数
|
||||
status: 'active' | 'closed' // 状态
|
||||
publishedAt: string // 发布时间
|
||||
}
|
||||
|
||||
// 平台用户列表项(为人才衍生的数据源)
|
||||
export interface PlatformUserItem {
|
||||
id: number
|
||||
realName: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
email: string
|
||||
phone: string
|
||||
role: string
|
||||
joinedAt: string
|
||||
lastActiveAt: string
|
||||
tags: string[]
|
||||
isTalent: boolean
|
||||
cityTag: TalentTag
|
||||
talentSummary?: UserTalentSummary
|
||||
introduction?: string
|
||||
paymentMethods?: {
|
||||
bankCard?: {
|
||||
bankName: string
|
||||
accountName: string
|
||||
accountNo: string
|
||||
}
|
||||
alipay?: {
|
||||
accountName: string
|
||||
accountNo: string
|
||||
}
|
||||
}
|
||||
// 签到数据
|
||||
signInStats?: {
|
||||
currentMonthDays: number // 本月签到天数
|
||||
totalReward: number // 本月累计获得奖励
|
||||
lastSignInDate?: string // 最后签到时间
|
||||
history: {
|
||||
date: string // YYYY-MM-DD
|
||||
reward: number // 获得奖励
|
||||
isMakeup?: boolean // 是否补签
|
||||
}[]
|
||||
}
|
||||
// 联系人列表
|
||||
contacts?: {
|
||||
id: number
|
||||
name: string
|
||||
avatar: string
|
||||
company?: string
|
||||
role: string
|
||||
status: string
|
||||
isMutal: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface UserListQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: TalentStatus
|
||||
hotOnly?: boolean
|
||||
cityTagId?: number
|
||||
}
|
||||
|
||||
export interface UserListResult {
|
||||
list: PlatformUserItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
76
src/utils/common.ts
Normal file
76
src/utils/common.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 公共工具函数
|
||||
*/
|
||||
import type { Key } from 'ant-design-vue/es/table/interface'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(str: string): string {
|
||||
return new Date(str).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(str: string): string {
|
||||
return new Date(str).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(超过1000显示为x.xk)
|
||||
*/
|
||||
export function formatCount(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数组中随机取一个元素(带类型安全)
|
||||
*/
|
||||
export function randomItem<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]!
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟延迟
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格选择变更处理器类型
|
||||
*/
|
||||
export type TableSelectChange = (keys: Key[]) => void
|
||||
|
||||
/**
|
||||
* 表格分页变更处理器类型
|
||||
*/
|
||||
export type TablePaginationChange = (pagination: TablePaginationConfig) => void
|
||||
|
||||
/**
|
||||
* 创建表格分页变更处理函数
|
||||
*/
|
||||
export function createTableChangeHandler(
|
||||
paginationRef: { current: number; pageSize: number },
|
||||
loadFn: () => void
|
||||
) {
|
||||
return (pagination: TablePaginationConfig) => {
|
||||
paginationRef.current = pagination.current || 1
|
||||
paginationRef.pageSize = pagination.pageSize || 10
|
||||
loadFn()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建表格选择变更处理函数
|
||||
*/
|
||||
export function createSelectChangeHandler(selectedKeysRef: { value: Key[] }) {
|
||||
return (keys: Key[]) => {
|
||||
selectedKeysRef.value = keys
|
||||
}
|
||||
}
|
||||
111
src/utils/request.ts
Normal file
111
src/utils/request.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Axios 基础配置
|
||||
*/
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// 创建axios实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 从localStorage获取token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const res = response.data
|
||||
|
||||
// 根据业务状态码判断请求是否成功
|
||||
if (res.code === 200 || res.success) {
|
||||
return response
|
||||
}
|
||||
|
||||
// 处理业务错误
|
||||
message.error(res.message || '请求失败')
|
||||
|
||||
// 处理特定错误码
|
||||
if (res.code === 401) {
|
||||
// token过期或未授权,跳转登录
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
},
|
||||
(error) => {
|
||||
console.error('响应错误:', error)
|
||||
|
||||
// 处理HTTP错误
|
||||
let errorMessage = '网络错误,请稍后重试'
|
||||
if (error.response) {
|
||||
const { status } = error.response
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMessage = '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
errorMessage = '未授权,请重新登录'
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
break
|
||||
case 403:
|
||||
errorMessage = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
errorMessage = '请求资源不存在'
|
||||
break
|
||||
case 500:
|
||||
errorMessage = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
errorMessage = `请求失败(${status})`
|
||||
}
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请稍后重试'
|
||||
}
|
||||
|
||||
message.error(errorMessage)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 封装请求方法
|
||||
export const request = {
|
||||
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
|
||||
return service.get(url, config)
|
||||
},
|
||||
|
||||
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
|
||||
return service.post(url, data, config)
|
||||
},
|
||||
|
||||
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
|
||||
return service.put(url, data, config)
|
||||
},
|
||||
|
||||
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
|
||||
return service.delete(url, config)
|
||||
}
|
||||
}
|
||||
|
||||
export default service
|
||||
1515
src/views/community/circles/index.vue
Normal file
1515
src/views/community/circles/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
566
src/views/community/comments/index.vue
Normal file
566
src/views/community/comments/index.vue
Normal file
@@ -0,0 +1,566 @@
|
||||
<template>
|
||||
<div class="comment-manage">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic title="评论总数" :value="stats.total" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic title="今日新增" :value="stats.todayNew" :value-style="{ color: '#1890ff' }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic title="正常显示" :value="stats.normal" :value-style="{ color: '#52c41a' }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic title="已隐藏" :value="stats.hidden" :value-style="{ color: '#faad14' }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<a-card class="search-card" :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="评论内容/用户" allow-clear style="width: 150px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="帖子">
|
||||
<a-select v-model:value="searchForm.postId" placeholder="选择帖子" allow-clear style="width: 200px">
|
||||
<a-select-option v-for="p in postList" :key="p.id" :value="p.id">{{ p.title }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 100px">
|
||||
<a-select-option v-for="(label, key) in CommentStatusMap" :key="key" :value="key">{{ label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="类型">
|
||||
<a-select v-model:value="searchForm.isReply" placeholder="类型" allow-clear style="width: 100px">
|
||||
<a-select-option value="false">主评论</a-select-option>
|
||||
<a-select-option value="true">回复</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch"><SearchOutlined /> 搜索</a-button>
|
||||
<a-button @click="handleReset"><ReloadOutlined /> 重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>评论列表</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
|
||||
<DeleteOutlined /> 批量删除 {{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
|
||||
row-key="id"
|
||||
:scroll="{ y: 'calc(100vh - 520px)' }"
|
||||
size="small"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 评论者 -->
|
||||
<template v-if="column.key === 'user'">
|
||||
<div class="user-cell">
|
||||
<a-avatar :src="record.user.avatar" :size="28" />
|
||||
<span class="nickname">{{ record.user.nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 关联帖子 -->
|
||||
<template v-else-if="column.key === 'post'">
|
||||
<a-tooltip :title="record.post.title">
|
||||
<span class="post-title">{{ record.post.title }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<!-- 评论内容 -->
|
||||
<template v-else-if="column.key === 'content'">
|
||||
<div class="content-cell">
|
||||
<a-tag v-if="record.parentId" size="small" color="blue">回复</a-tag>
|
||||
<span class="content-text">{{ record.content }}</span>
|
||||
</div>
|
||||
<div v-if="record.parentContent" class="parent-content">
|
||||
回复:{{ record.parentContent }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 互动数据 -->
|
||||
<template v-else-if="column.key === 'interaction'">
|
||||
<span><LikeOutlined /> {{ record.likeCount }}</span>
|
||||
<span v-if="!record.parentId" style="margin-left: 12px"><MessageOutlined /> {{ record.replyCount }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'normal' ? 'green' : 'orange'">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 时间 -->
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="showDetail(record)"><EyeOutlined /> 查看</a-button>
|
||||
<a-button type="link" size="small" @click="showEditModal(record)"><EditOutlined /> 编辑</a-button>
|
||||
<a-button v-if="record.status === 'normal'" type="link" size="small" @click="handleHide(record.id)">
|
||||
<EyeInvisibleOutlined /> 隐藏
|
||||
</a-button>
|
||||
<a-button v-else type="link" size="small" style="color: #52c41a" @click="handleRestore(record.id)">
|
||||
<EyeOutlined /> 恢复
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除此评论?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger><DeleteOutlined /></a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal v-model:open="detailVisible" title="评论详情" :footer="null" width="560px">
|
||||
<template v-if="currentRecord">
|
||||
<div class="detail-header">
|
||||
<a-avatar :src="currentRecord.user.avatar" :size="48" />
|
||||
<div class="user-info">
|
||||
<h3>{{ currentRecord.user.nickname }}</h3>
|
||||
<p>@{{ currentRecord.user.username }} · {{ formatDateTime(currentRecord.createdAt) }}</p>
|
||||
</div>
|
||||
<a-tag :color="currentRecord.status === 'normal' ? 'green' : 'orange'">
|
||||
{{ CommentStatusMap[currentRecord.status] }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="detail-post">
|
||||
<label>关联帖子</label>
|
||||
<p>{{ currentRecord.post.title }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentRecord.parentContent" class="detail-parent">
|
||||
<label>回复评论</label>
|
||||
<p>{{ currentRecord.parentContent }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<label>评论内容</label>
|
||||
<p>{{ currentRecord.content }}</p>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="2" size="small" class="detail-meta">
|
||||
<a-descriptions-item label="点赞数">{{ currentRecord.likeCount }}</a-descriptions-item>
|
||||
<a-descriptions-item label="回复数">{{ currentRecord.replyCount }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑评论弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="editVisible"
|
||||
title="编辑评论"
|
||||
width="600px"
|
||||
:confirm-loading="editLoading"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="handleEditSubmit"
|
||||
>
|
||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 19 }">
|
||||
<a-form-item label="评论内容" required>
|
||||
<a-textarea
|
||||
v-model:value="editForm.content"
|
||||
placeholder="请输入评论内容"
|
||||
:rows="4"
|
||||
:maxlength="1000"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="点赞数" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
||||
<a-input-number
|
||||
v-model:value="editForm.likeCount"
|
||||
:min="0"
|
||||
:max="999999"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="回复数" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
||||
<a-input-number
|
||||
v-model:value="editForm.replyCount"
|
||||
:min="0"
|
||||
:max="999999"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Key } from 'ant-design-vue/es/table/interface'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined, ReloadOutlined, DeleteOutlined, EyeOutlined,
|
||||
EyeInvisibleOutlined, LikeOutlined, MessageOutlined, EditOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { CommentRecord, CommentPost, CommentStats, CommentStatus } from '@/types'
|
||||
import { CommentStatusMap } from '@/types'
|
||||
import {
|
||||
mockGetCommentList, mockGetCommentStats, mockGetCommentPosts,
|
||||
mockHideComment, mockRestoreComment, mockDeleteComment, mockBatchDeleteComments, mockUpdateComment
|
||||
} from '@/mock'
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
postId: undefined as number | undefined,
|
||||
status: undefined as CommentStatus | undefined,
|
||||
isReply: undefined as string | undefined // 'true' | 'false' | undefined
|
||||
})
|
||||
const loading = ref(false)
|
||||
const list = ref<CommentRecord[]>([])
|
||||
const postList = ref<CommentPost[]>([])
|
||||
const stats = ref<CommentStats>({ total: 0, todayNew: 0, normal: 0, hidden: 0 })
|
||||
const selectedRowKeys = ref<Key[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<CommentRecord | null>(null)
|
||||
|
||||
// 编辑相关
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editCommentId = ref<number | null>(null)
|
||||
const editForm = reactive({
|
||||
content: '',
|
||||
likeCount: 0,
|
||||
replyCount: 0
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '评论者', key: 'user', width: 140 },
|
||||
{ title: '关联帖子', key: 'post', width: 180, ellipsis: true },
|
||||
{ title: '评论内容', key: 'content', ellipsis: true },
|
||||
{ title: '互动', key: 'interaction', width: 100 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '时间', key: 'createdAt', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
function formatDate(str: string): string {
|
||||
return new Date(str).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDateTime(str: string): string {
|
||||
return new Date(str).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
return CommentStatusMap[status as CommentStatus] || status
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [res, statsRes] = await Promise.all([
|
||||
mockGetCommentList({
|
||||
keyword: searchForm.keyword,
|
||||
postId: searchForm.postId,
|
||||
status: searchForm.status,
|
||||
isReply: searchForm.isReply === 'true' ? true : searchForm.isReply === 'false' ? false : undefined,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
}),
|
||||
mockGetCommentStats()
|
||||
])
|
||||
list.value = res.list
|
||||
pagination.total = res.total
|
||||
stats.value = statsRes
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
postList.value = await mockGetCommentPosts()
|
||||
}
|
||||
|
||||
function handleSearch() { pagination.current = 1; loadData() }
|
||||
function handleReset() {
|
||||
Object.assign(searchForm, { keyword: '', postId: undefined, status: undefined, isReply: undefined })
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
function handleTableChange(pag: TablePaginationConfig) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadData()
|
||||
}
|
||||
function onSelectChange(keys: Key[]) { selectedRowKeys.value = keys }
|
||||
function showDetail(record: CommentRecord | Record<string, unknown>) { currentRecord.value = record as CommentRecord; detailVisible.value = true }
|
||||
|
||||
async function handleHide(id: number) {
|
||||
await mockHideComment(id)
|
||||
message.success('已隐藏')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleRestore(id: number) {
|
||||
await mockRestoreComment(id)
|
||||
message.success('已恢复')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await mockDeleteComment(id)
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!selectedRowKeys.value.length) return
|
||||
await mockBatchDeleteComments(selectedRowKeys.value as number[])
|
||||
message.success(`已删除 ${selectedRowKeys.value.length} 条评论`)
|
||||
selectedRowKeys.value = []
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 显示编辑弹窗
|
||||
function showEditModal(record: CommentRecord | Record<string, unknown>) {
|
||||
const comment = record as CommentRecord
|
||||
editCommentId.value = comment.id
|
||||
editForm.content = comment.content
|
||||
editForm.likeCount = comment.likeCount
|
||||
editForm.replyCount = comment.replyCount
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
// 提交编辑
|
||||
async function handleEditSubmit() {
|
||||
if (!editForm.content.trim()) {
|
||||
message.warning('请输入评论内容')
|
||||
return
|
||||
}
|
||||
if (!editCommentId.value) return
|
||||
|
||||
editLoading.value = true
|
||||
try {
|
||||
await mockUpdateComment(editCommentId.value, {
|
||||
content: editForm.content.trim(),
|
||||
likeCount: editForm.likeCount,
|
||||
replyCount: editForm.replyCount
|
||||
})
|
||||
message.success('编辑成功')
|
||||
editVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
message.error('编辑失败')
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadPosts(); loadData() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment-manage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card :deep(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-card :deep(.ant-statistic-content) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-card :deep(.ant-card-body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-card :deep(.ant-card-head) {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.table-card :deep(.ant-card-head-title) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.table-card :deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-pagination) {
|
||||
margin: 8px 0 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-cell .nickname {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.parent-content {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid #e8e8e8;
|
||||
}
|
||||
|
||||
/* 详情弹窗 */
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-header .user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-header .user-info h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.detail-header .user-info p {
|
||||
margin: 4px 0 0;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-post,
|
||||
.detail-parent,
|
||||
.detail-content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-post label,
|
||||
.detail-parent label,
|
||||
.detail-content label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.detail-post p,
|
||||
.detail-content p {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.detail-parent p {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
835
src/views/community/posts/index.vue
Normal file
835
src/views/community/posts/index.vue
Normal file
@@ -0,0 +1,835 @@
|
||||
<template>
|
||||
<div class="post-manage">
|
||||
<!-- 搜索筛选区域 -->
|
||||
<a-card class="search-card" :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="搜索标题/用户" allow-clear style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="标签">
|
||||
<a-select v-model:value="searchForm.tagId" placeholder="选择标签" allow-clear style="width: 140px">
|
||||
<a-select-option v-for="tag in tagList" :key="tag.id" :value="tag.id">
|
||||
{{ tag.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="选择状态" allow-clear style="width: 120px">
|
||||
<a-select-option value="published">已发布</a-select-option>
|
||||
<a-select-option value="hidden">已隐藏</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="热门">
|
||||
<a-select v-model:value="searchForm.isHot" placeholder="是否热门" allow-clear style="width: 120px">
|
||||
<a-select-option value="true">热门</a-select-option>
|
||||
<a-select-option value="false">普通</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="官方">
|
||||
<a-select v-model:value="searchForm.isOfficial" placeholder="是否官方" allow-clear style="width: 120px">
|
||||
<a-select-option value="true">官方</a-select-option>
|
||||
<a-select-option value="false">普通</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<SearchOutlined /> 搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>
|
||||
<span>帖子列表</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="showPublishModal">
|
||||
<PlusOutlined /> 发布官方帖子
|
||||
</a-button>
|
||||
<a-button type="primary" danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
|
||||
<DeleteOutlined /> 批量删除 {{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 帖子表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="postList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
|
||||
row-key="id"
|
||||
:scroll="{ y: 'calc(100vh - 380px)' }"
|
||||
size="middle"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 标题列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<div class="title-cell">
|
||||
<a-tag v-if="record.isOfficial" color="blue" class="official-tag">
|
||||
<CrownOutlined /> 官方
|
||||
</a-tag>
|
||||
<a-tag v-if="record.isHot" color="red" class="hot-tag">
|
||||
<FireOutlined /> 热门
|
||||
</a-tag>
|
||||
<span class="post-title" @click="showDetail(record)">{{ record.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 作者列 -->
|
||||
<template v-else-if="column.key === 'author'">
|
||||
<div class="author-cell">
|
||||
<a-avatar :src="record.author.avatar" :size="32" />
|
||||
<span class="author-name">{{ record.author.nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 标签列 -->
|
||||
<template v-else-if="column.key === 'tags'">
|
||||
<a-tag v-for="tag in record.tags" :key="tag.id" :color="tag.color">
|
||||
{{ tag.name }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 数据统计列 -->
|
||||
<template v-else-if="column.key === 'stats'">
|
||||
<a-space :size="16">
|
||||
<span><EyeOutlined /> {{ formatNumber(record.viewCount) }}</span>
|
||||
<span><LikeOutlined /> {{ formatNumber(record.likeCount) }}</span>
|
||||
<span><CommentOutlined /> {{ formatNumber(record.commentCount) }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'published' ? 'green' : 'orange'">
|
||||
{{ record.status === 'published' ? '已发布' : '已隐藏' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 发布时间列 -->
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="showDetail(record)">
|
||||
<EyeOutlined /> 查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="showEditModal(record)">
|
||||
<EditOutlined /> 编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:style="{ color: record.isHot ? '#faad14' : '#ff4d4f' }"
|
||||
@click="handleToggleHot(record)"
|
||||
>
|
||||
<FireOutlined /> {{ record.isHot ? '取消热门' : '设为热门' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:style="{ color: record.isOfficial ? '#faad14' : '#1890ff' }"
|
||||
@click="handleToggleOfficial(record)"
|
||||
>
|
||||
<CrownOutlined /> {{ record.isOfficial ? '取消官方' : '设为官方' }}
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这篇帖子吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 帖子详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="detailVisible"
|
||||
title="帖子详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
class="detail-modal"
|
||||
>
|
||||
<template v-if="currentPost">
|
||||
<div class="detail-header">
|
||||
<div class="detail-title">
|
||||
<a-tag v-if="currentPost.isOfficial" color="blue"><CrownOutlined /> 官方</a-tag>
|
||||
<a-tag v-if="currentPost.isHot" color="red"><FireOutlined /> 热门</a-tag>
|
||||
<h2>{{ currentPost.title }}</h2>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<div class="author-cell">
|
||||
<a-avatar :src="currentPost.author.avatar" :size="36" />
|
||||
<div class="author-info">
|
||||
<span class="author-name">{{ currentPost.author.nickname }}</span>
|
||||
<span class="author-username">@{{ currentPost.author.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-right">
|
||||
<span class="publish-time">{{ formatDate(currentPost.createdAt) }}</span>
|
||||
<a-tag :color="currentPost.status === 'published' ? 'green' : 'orange'">
|
||||
{{ currentPost.status === 'published' ? '已发布' : '已隐藏' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-tags">
|
||||
<a-tag v-for="tag in currentPost.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="detail-content">
|
||||
{{ currentPost.content }}
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="detail-stats">
|
||||
<div class="stat-item">
|
||||
<EyeOutlined />
|
||||
<span>{{ currentPost.viewCount }} 浏览</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<LikeOutlined />
|
||||
<span>{{ currentPost.likeCount }} 点赞</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<CommentOutlined />
|
||||
<span>{{ currentPost.commentCount }} 评论</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="post-id">帖子ID: {{ currentPost.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑帖子弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="editVisible"
|
||||
title="编辑帖子"
|
||||
width="700px"
|
||||
:confirm-loading="editLoading"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="handleEditSubmit"
|
||||
>
|
||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 19 }">
|
||||
<a-form-item label="标题" required>
|
||||
<a-input v-model:value="editForm.title" placeholder="请输入帖子标题" :maxlength="100" show-count />
|
||||
</a-form-item>
|
||||
<a-form-item label="标签">
|
||||
<a-select
|
||||
v-model:value="editForm.tagIds"
|
||||
mode="multiple"
|
||||
placeholder="选择标签(可多选)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="tag in tagList" :key="tag.id" :value="tag.id">
|
||||
<a-tag :color="tag.color" style="margin-right: 4px">{{ tag.name }}</a-tag>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="内容" required>
|
||||
<a-textarea
|
||||
v-model:value="editForm.content"
|
||||
placeholder="请输入帖子内容"
|
||||
:rows="6"
|
||||
:maxlength="5000"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-divider orientation="left">数据统计</a-divider>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="浏览量" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
||||
<a-input-number
|
||||
v-model:value="editForm.viewCount"
|
||||
:min="0"
|
||||
:max="9999999"
|
||||
style="width: 100%"
|
||||
placeholder="浏览数"
|
||||
>
|
||||
<template #addonBefore><EyeOutlined /></template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="点赞数" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
||||
<a-input-number
|
||||
v-model:value="editForm.likeCount"
|
||||
:min="0"
|
||||
:max="9999999"
|
||||
style="width: 100%"
|
||||
placeholder="点赞数"
|
||||
>
|
||||
<template #addonBefore><LikeOutlined /></template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="评论数" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
||||
<a-input-number
|
||||
v-model:value="editForm.commentCount"
|
||||
:min="0"
|
||||
:max="9999999"
|
||||
style="width: 100%"
|
||||
placeholder="评论数"
|
||||
>
|
||||
<template #addonBefore><CommentOutlined /></template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 发布官方帖子弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="publishVisible"
|
||||
title="发布官方帖子"
|
||||
width="700px"
|
||||
:confirm-loading="publishLoading"
|
||||
ok-text="发布"
|
||||
cancel-text="取消"
|
||||
@ok="handlePublishSubmit"
|
||||
>
|
||||
<a-alert
|
||||
message="发布后将以「官方」身份发布,自动添加官方标签"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 19 }">
|
||||
<a-form-item label="标题" required>
|
||||
<a-input v-model:value="publishForm.title" placeholder="请输入帖子标题" :maxlength="100" show-count />
|
||||
</a-form-item>
|
||||
<a-form-item label="标签">
|
||||
<a-select
|
||||
v-model:value="publishForm.tagIds"
|
||||
mode="multiple"
|
||||
placeholder="选择额外标签(可多选)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="tag in tagList.filter(t => t.name !== '官方')"
|
||||
:key="tag.id"
|
||||
:value="tag.id"
|
||||
>
|
||||
<a-tag :color="tag.color" style="margin-right: 4px">{{ tag.name }}</a-tag>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="内容" required>
|
||||
<a-textarea
|
||||
v-model:value="publishForm.content"
|
||||
placeholder="请输入帖子内容"
|
||||
:rows="8"
|
||||
:maxlength="5000"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Key } from 'ant-design-vue/es/table/interface'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
FireOutlined,
|
||||
EyeOutlined,
|
||||
LikeOutlined,
|
||||
CommentOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
CrownOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { PostInfo, PostTag } from '@/types'
|
||||
import { mockGetPostList, mockDeletePost, mockBatchDeletePosts, mockGetTags, mockUpdatePost, mockToggleHot, mockCreatePost, mockToggleOfficial } from '@/mock'
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
tagId: undefined as number | undefined,
|
||||
status: undefined as string | undefined,
|
||||
isHot: undefined as string | undefined, // 'true' | 'false' | undefined
|
||||
isOfficial: undefined as string | undefined // 'true' | 'false' | undefined
|
||||
})
|
||||
|
||||
// 列表数据
|
||||
const loading = ref(false)
|
||||
const postList = ref<PostInfo[]>([])
|
||||
const tagList = ref<PostTag[]>([])
|
||||
const selectedRowKeys = ref<Key[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const currentPost = ref<PostInfo | null>(null)
|
||||
|
||||
// 编辑相关
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editPostId = ref<number | null>(null)
|
||||
const editForm = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
tagIds: [] as number[],
|
||||
viewCount: 0,
|
||||
likeCount: 0,
|
||||
commentCount: 0
|
||||
})
|
||||
|
||||
// 发布帖子相关
|
||||
const publishVisible = ref(false)
|
||||
const publishLoading = ref(false)
|
||||
const publishForm = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
tagIds: [] as number[]
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{ title: '标题', key: 'title', width: 300 },
|
||||
{ title: '作者', key: 'author', width: 150 },
|
||||
{ title: '标签', key: 'tags', width: 160 },
|
||||
{ title: '数据统计', key: 'stats', width: 200 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '发布时间', key: 'createdAt', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 300, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return String(num)
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 加载标签列表
|
||||
async function loadTags() {
|
||||
tagList.value = await mockGetTags()
|
||||
}
|
||||
|
||||
// 加载帖子列表
|
||||
async function loadPosts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetPostList({
|
||||
keyword: searchForm.keyword,
|
||||
tagId: searchForm.tagId,
|
||||
status: searchForm.status,
|
||||
isHot: searchForm.isHot === 'true' ? true : searchForm.isHot === 'false' ? false : undefined,
|
||||
isOfficial: searchForm.isOfficial === 'true' ? true : searchForm.isOfficial === 'false' ? false : undefined,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
postList.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch (error) {
|
||||
console.error('加载帖子列表失败:', error)
|
||||
message.error('加载帖子列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
// 重置
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.tagId = undefined
|
||||
searchForm.status = undefined
|
||||
searchForm.isHot = undefined
|
||||
searchForm.isOfficial = undefined
|
||||
pagination.current = 1
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
function handleTableChange(pag: TablePaginationConfig) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
// 选择变化
|
||||
function onSelectChange(keys: Key[]) {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
function showDetail(record: PostInfo | Record<string, unknown>) {
|
||||
currentPost.value = record as PostInfo
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 删除帖子
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await mockDeletePost(id)
|
||||
message.success('删除成功')
|
||||
loadPosts()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
async function handleBatchDelete() {
|
||||
if (!selectedRowKeys.value.length) return
|
||||
try {
|
||||
await mockBatchDeletePosts(selectedRowKeys.value as number[])
|
||||
message.success(`成功删除 ${selectedRowKeys.value.length} 篇帖子`)
|
||||
selectedRowKeys.value = []
|
||||
loadPosts()
|
||||
} catch (error) {
|
||||
message.error('批量删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示编辑弹窗
|
||||
function showEditModal(record: PostInfo | Record<string, unknown>) {
|
||||
const post = record as PostInfo
|
||||
editPostId.value = post.id
|
||||
editForm.title = post.title
|
||||
editForm.content = post.content
|
||||
editForm.tagIds = post.tags.map(t => t.id)
|
||||
editForm.viewCount = post.viewCount
|
||||
editForm.likeCount = post.likeCount
|
||||
editForm.commentCount = post.commentCount
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
// 提交编辑
|
||||
async function handleEditSubmit() {
|
||||
if (!editForm.title.trim()) {
|
||||
message.warning('请输入标题')
|
||||
return
|
||||
}
|
||||
if (!editForm.content.trim()) {
|
||||
message.warning('请输入内容')
|
||||
return
|
||||
}
|
||||
if (!editPostId.value) return
|
||||
|
||||
editLoading.value = true
|
||||
try {
|
||||
await mockUpdatePost(editPostId.value, {
|
||||
title: editForm.title.trim(),
|
||||
content: editForm.content.trim(),
|
||||
tagIds: editForm.tagIds,
|
||||
viewCount: editForm.viewCount,
|
||||
likeCount: editForm.likeCount,
|
||||
commentCount: editForm.commentCount
|
||||
})
|
||||
message.success('编辑成功')
|
||||
editVisible.value = false
|
||||
loadPosts()
|
||||
} catch (error) {
|
||||
message.error('编辑失败')
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置/取消热门
|
||||
async function handleToggleHot(record: PostInfo | Record<string, unknown>) {
|
||||
const post = record as PostInfo
|
||||
const newHotStatus = !post.isHot
|
||||
try {
|
||||
await mockToggleHot(post.id, newHotStatus)
|
||||
message.success(newHotStatus ? '已设为热门' : '已取消热门')
|
||||
loadPosts()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 设置/取消官方
|
||||
async function handleToggleOfficial(record: PostInfo | Record<string, unknown>) {
|
||||
const post = record as PostInfo
|
||||
const newOfficialStatus = !post.isOfficial
|
||||
try {
|
||||
await mockToggleOfficial(post.id, newOfficialStatus)
|
||||
message.success(newOfficialStatus ? '已设为官方' : '已取消官方')
|
||||
loadPosts()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示发布弹窗
|
||||
function showPublishModal() {
|
||||
publishForm.title = ''
|
||||
publishForm.content = ''
|
||||
publishForm.tagIds = []
|
||||
publishVisible.value = true
|
||||
}
|
||||
|
||||
// 提交发布
|
||||
async function handlePublishSubmit() {
|
||||
if (!publishForm.title.trim()) {
|
||||
message.warning('请输入标题')
|
||||
return
|
||||
}
|
||||
if (!publishForm.content.trim()) {
|
||||
message.warning('请输入内容')
|
||||
return
|
||||
}
|
||||
|
||||
publishLoading.value = true
|
||||
try {
|
||||
await mockCreatePost({
|
||||
title: publishForm.title.trim(),
|
||||
content: publishForm.content.trim(),
|
||||
tagIds: publishForm.tagIds,
|
||||
isOfficial: true // 官方帖子
|
||||
})
|
||||
message.success('发布成功')
|
||||
publishVisible.value = false
|
||||
pagination.current = 1 // 跳转到第一页查看新帖子
|
||||
loadPosts()
|
||||
} catch (error) {
|
||||
message.error('发布失败')
|
||||
} finally {
|
||||
publishLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTags()
|
||||
loadPosts()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.post-manage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.table-card .ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hot-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.official-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
cursor: pointer;
|
||||
color: #1890ff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.author-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-pagination) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* 详情弹窗样式 */
|
||||
.detail-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.author-info .author-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.author-info .author-username {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.meta-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.publish-time {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-item .post-id {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.detail-modal .ant-modal-body) {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
848
src/views/community/tags/index.vue
Normal file
848
src/views/community/tags/index.vue
Normal file
@@ -0,0 +1,848 @@
|
||||
<template>
|
||||
<div class="tag-manage">
|
||||
<a-page-header title="标签管理" sub-title="统一维护帖子、项目与招募标签体系" />
|
||||
|
||||
<a-row :gutter="16" class="summary-row">
|
||||
<a-col v-for="card in summaryList" :key="card.key" :span="6">
|
||||
<a-card class="summary-card">
|
||||
<div class="summary-title">{{ card.title }}</div>
|
||||
<div class="summary-value">{{ card.value }}</div>
|
||||
<div class="summary-desc">{{ card.desc }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card class="tab-card" :bordered="false">
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<TagsOutlined />
|
||||
<span>标签类型</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button size="small" @click="handleRefresh(activeTab)">
|
||||
<ReloadOutlined /> 刷新当前
|
||||
</a-button>
|
||||
<a-button type="primary" size="small" @click="openCreate(activeTab)">
|
||||
<PlusOutlined /> 新增标签
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="tag-tabs">
|
||||
<a-tab-pane key="post" tab="帖子标签">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="帖子标签用于社区内容的分类展示,建议维持 5~12 个高频标签"
|
||||
class="tab-tip"
|
||||
/>
|
||||
<a-table
|
||||
:columns="postColumns"
|
||||
:data-source="postTags"
|
||||
:loading="loadingMap.post"
|
||||
row-key="id"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'color'">
|
||||
<div class="color-cell">
|
||||
<span class="color-dot" :style="{ backgroundColor: record.color }"></span>
|
||||
<span>{{ record.color }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<span>{{ record.usage }} 次</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openEdit('post', record)">
|
||||
<EditOutlined /> 编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除该标签?" @confirm="handleDelete('post', record.id)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="project" tab="项目标签">
|
||||
<a-alert
|
||||
type="success"
|
||||
show-icon
|
||||
message="项目标签通常用于描述技术栈、业务方向,可配合招募信息使用"
|
||||
class="tab-tip"
|
||||
/>
|
||||
<a-table
|
||||
:columns="projectColumns"
|
||||
:data-source="projectTags"
|
||||
:loading="loadingMap.project"
|
||||
row-key="id"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'color'">
|
||||
<div class="color-cell">
|
||||
<span class="color-dot" :style="{ backgroundColor: record.color }"></span>
|
||||
<span>{{ record.color }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'scene'">
|
||||
<a-tag color="blue">{{ record.scene }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<span>{{ record.usage }} 次</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openEdit('project', record)">
|
||||
<EditOutlined /> 编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确认删除该标签?" @confirm="handleDelete('project', record.id)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="location" tab="地址标签">
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="地址标签用于统一项目工作地点,可与地图或搜索联动"
|
||||
class="tab-tip"
|
||||
/>
|
||||
<a-table
|
||||
:columns="locationColumns"
|
||||
:data-source="addressTags"
|
||||
:loading="loadingMap.location"
|
||||
row-key="id"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'region'">
|
||||
<a-tag color="gold">
|
||||
<EnvironmentOutlined /> {{ record.region }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<span>{{ record.usage }} 个项目</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openEdit('location', record)">
|
||||
<EditOutlined /> 编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确定移除该地址标签?" @confirm="handleDelete('location', record.id)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="workType" tab="工作类型标签">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="工作类型标签影响项目/招募列表的筛选维度,可自定义开启或关闭"
|
||||
class="tab-tip"
|
||||
/>
|
||||
<a-table
|
||||
:columns="workTypeColumns"
|
||||
:data-source="workTypeTags"
|
||||
:loading="loadingMap.workType"
|
||||
row-key="key"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'color'">
|
||||
<div class="color-cell">
|
||||
<span class="color-dot" :style="{ backgroundColor: record.color }"></span>
|
||||
<span>{{ record.color }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enabled'">
|
||||
<a-switch
|
||||
v-model:checked="record.enabled"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
@change="checked => toggleWorkType(record, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openEdit('workType', record)">
|
||||
<EditOutlined /> 编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除该类型?" @confirm="handleDelete('workType', record.key)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
width="520px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="modalVisible = false"
|
||||
destroy-on-close
|
||||
>
|
||||
<a-form :model="formModel" layout="vertical" class="tag-form">
|
||||
<a-form-item label="标签名称" required>
|
||||
<a-input v-model:value="formModel.name" placeholder="请输入标签名称" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="modalType !== 'location'" label="标签颜色">
|
||||
<div class="color-picker-wrapper">
|
||||
<input
|
||||
type="color"
|
||||
v-model="formModel.color"
|
||||
class="color-picker-input"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="formModel.color"
|
||||
placeholder="#1677ff"
|
||||
style="flex: 1"
|
||||
>
|
||||
<template #addonBefore>
|
||||
<span class="color-preview" :style="{ backgroundColor: formModel.color }"></span>
|
||||
</template>
|
||||
</a-input>
|
||||
<div class="color-presets">
|
||||
<span
|
||||
v-for="color in presetColors"
|
||||
:key="color"
|
||||
class="preset-color"
|
||||
:class="{ active: formModel.color === color }"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="formModel.color = color"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="modalType === 'project'" label="应用场景">
|
||||
<a-select v-model:value="formModel.scene" placeholder="选择适用场景">
|
||||
<a-select-option v-for="scene in projectScenes" :key="scene" :value="scene">
|
||||
{{ scene }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="modalType === 'location'" label="所属区域">
|
||||
<a-select v-model:value="formModel.region" placeholder="选择所属区域">
|
||||
<a-select-option v-for="region in regionOptions" :key="region" :value="region">
|
||||
{{ region }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="modalType === 'workType'" label="类型标识(英文)" required>
|
||||
<a-input v-model:value="formModel.key" placeholder="例如 remote" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="modalType === 'workType'" label="类型说明">
|
||||
<a-textarea v-model:value="formModel.desc" :rows="3" placeholder="补充说明工作方式" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
TagsOutlined,
|
||||
EnvironmentOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { PostTag, ProjectTag, WorkType } from '@/types'
|
||||
import { WorkTypeMap } from '@/types'
|
||||
import { mockGetTags, mockGetProjectTags, mockGetLocations } from '@/mock'
|
||||
|
||||
type TagCategory = 'post' | 'project' | 'location' | 'workType'
|
||||
|
||||
interface PostTagItem extends PostTag {
|
||||
usage: number
|
||||
}
|
||||
|
||||
interface ProjectTagItem extends ProjectTag {
|
||||
usage: number
|
||||
scene: string
|
||||
}
|
||||
|
||||
interface AddressTagItem {
|
||||
id: number
|
||||
name: string
|
||||
region: string
|
||||
usage: number
|
||||
}
|
||||
|
||||
interface WorkTypeTagItem {
|
||||
key: string
|
||||
name: string
|
||||
desc: string
|
||||
color: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface TagFormModel {
|
||||
id: number | string | undefined
|
||||
name: string
|
||||
color: string
|
||||
scene: string
|
||||
region: string
|
||||
key: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
const activeTab = ref<TagCategory>('post')
|
||||
const loadingMap = reactive<Record<TagCategory, boolean>>({
|
||||
post: false,
|
||||
project: false,
|
||||
location: false,
|
||||
workType: false
|
||||
})
|
||||
|
||||
const postTags = ref<PostTagItem[]>([])
|
||||
const projectTags = ref<ProjectTagItem[]>([])
|
||||
const addressTags = ref<AddressTagItem[]>([])
|
||||
const workTypeTags = ref<WorkTypeTagItem[]>([])
|
||||
|
||||
const projectScenes = ['前端开发', '后端服务', 'AI/数据', '移动应用', '设计体验', '其他']
|
||||
const regionOptions = ['核心城市', '热门城市', '远程优先', '其他']
|
||||
const presetColors = [
|
||||
'#1677ff', '#13c2c2', '#52c41a', '#faad14', '#ff4d4f',
|
||||
'#722ed1', '#eb2f96', '#fa541c', '#2f54eb', '#a0d911'
|
||||
]
|
||||
const workTypeDescMap: Partial<Record<WorkType, string>> = {
|
||||
fulltime: '标准全职办公,适用于团队常规岗位',
|
||||
parttime: '灵活时间段,可根据排期投入',
|
||||
remote: '远程优先,可在任意地点办公',
|
||||
freelance: '以项目为单位的合作方式',
|
||||
internship: '适用于实习生/试用阶段岗位'
|
||||
}
|
||||
const workTypeColorMap: Record<WorkType, string> = {
|
||||
fulltime: '#1677ff',
|
||||
parttime: '#13c2c2',
|
||||
remote: '#52c41a',
|
||||
freelance: '#722ed1',
|
||||
internship: '#faad14'
|
||||
}
|
||||
|
||||
const idCounter = reactive({
|
||||
post: 1000,
|
||||
project: 2000,
|
||||
location: 3000
|
||||
})
|
||||
|
||||
const summaryList = computed(() => [
|
||||
{ key: 'post', title: '帖子标签', value: `${postTags.value.length}`, desc: '社区帖子使用' },
|
||||
{ key: 'project', title: '项目标签', value: `${projectTags.value.length}`, desc: '项目/招募使用' },
|
||||
{ key: 'location', title: '地址标签', value: `${addressTags.value.length}`, desc: '统一地点选项' },
|
||||
{
|
||||
key: 'workType',
|
||||
title: '工作类型',
|
||||
value: `${workTypeTags.value.filter(item => item.enabled).length}/${workTypeTags.value.length}`,
|
||||
desc: '启用/全部类型'
|
||||
}
|
||||
])
|
||||
|
||||
const postColumns = [
|
||||
{ title: '标签名称', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: '展示颜色', key: 'color', width: 180 },
|
||||
{ title: '使用次数', dataIndex: 'usage', key: 'usage', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 160 }
|
||||
]
|
||||
|
||||
const projectColumns = [
|
||||
{ title: '标签名称', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: '展示颜色', key: 'color', width: 180 },
|
||||
{ title: '应用场景', key: 'scene', width: 180 },
|
||||
{ title: '使用次数', dataIndex: 'usage', key: 'usage', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 160 }
|
||||
]
|
||||
|
||||
const locationColumns = [
|
||||
{ title: '地点名称', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: '所属区域', key: 'region', width: 180 },
|
||||
{ title: '关联项目', dataIndex: 'usage', key: 'usage', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 160 }
|
||||
]
|
||||
|
||||
const workTypeColumns = [
|
||||
{ title: '类型标识', dataIndex: 'key', key: 'key', width: 130 },
|
||||
{ title: '类型名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '展示颜色', key: 'color', width: 180 },
|
||||
{ title: '启用状态', key: 'enabled', width: 160 },
|
||||
{ title: '说明', dataIndex: 'desc', key: 'desc' },
|
||||
{ title: '操作', key: 'action', width: 160 }
|
||||
]
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const modalType = ref<TagCategory>('post')
|
||||
const isEdit = ref(false)
|
||||
const formModel = reactive<TagFormModel>({
|
||||
id: undefined,
|
||||
name: '',
|
||||
color: '#1677ff',
|
||||
scene: projectScenes[0] || '',
|
||||
region: regionOptions[0] || '',
|
||||
key: '',
|
||||
desc: ''
|
||||
})
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
const map: Record<TagCategory, string> = {
|
||||
post: '帖子标签',
|
||||
project: '项目标签',
|
||||
location: '地址标签',
|
||||
workType: '工作类型标签'
|
||||
}
|
||||
return `${isEdit.value ? '编辑' : '新增'}${map[modalType.value]}`
|
||||
})
|
||||
|
||||
function resetForm(type: TagCategory) {
|
||||
formModel.id = undefined
|
||||
formModel.name = ''
|
||||
formModel.scene = projectScenes[0] || ''
|
||||
formModel.region = regionOptions[0] || ''
|
||||
formModel.key = ''
|
||||
formModel.desc = ''
|
||||
if (type === 'project') {
|
||||
formModel.color = '#13c2c2'
|
||||
} else if (type === 'workType') {
|
||||
formModel.color = '#722ed1'
|
||||
} else {
|
||||
formModel.color = '#1677ff'
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(type: TagCategory) {
|
||||
modalType.value = type
|
||||
isEdit.value = false
|
||||
resetForm(type)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(type: TagCategory, record: Record<string, unknown>) {
|
||||
modalType.value = type
|
||||
isEdit.value = true
|
||||
if (type === 'post') {
|
||||
Object.assign(formModel, {
|
||||
id: record.id as number,
|
||||
name: record.name as string,
|
||||
color: record.color as string
|
||||
})
|
||||
} else if (type === 'project') {
|
||||
Object.assign(formModel, {
|
||||
id: record.id as number,
|
||||
name: record.name as string,
|
||||
color: record.color as string,
|
||||
scene: record.scene as string
|
||||
})
|
||||
} else if (type === 'location') {
|
||||
Object.assign(formModel, {
|
||||
id: record.id as number,
|
||||
name: record.name as string,
|
||||
region: record.region as string
|
||||
})
|
||||
} else if (type === 'workType') {
|
||||
Object.assign(formModel, {
|
||||
id: record.key as string,
|
||||
name: record.name as string,
|
||||
key: record.key as string,
|
||||
color: record.color as string,
|
||||
desc: record.desc as string
|
||||
})
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!formModel.name.trim()) {
|
||||
message.warning('请输入标签名称')
|
||||
return false
|
||||
}
|
||||
if (modalType.value === 'workType' && !formModel.key.trim()) {
|
||||
message.warning('请输入类型标识')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!validateForm()) return
|
||||
|
||||
const type = modalType.value
|
||||
if (type === 'post') {
|
||||
if (isEdit.value) {
|
||||
const target = postTags.value.find(item => item.id === formModel.id)
|
||||
if (target) {
|
||||
target.name = formModel.name.trim()
|
||||
target.color = formModel.color
|
||||
}
|
||||
} else {
|
||||
postTags.value.push({
|
||||
id: ++idCounter.post,
|
||||
name: formModel.name.trim(),
|
||||
color: formModel.color,
|
||||
usage: 0
|
||||
})
|
||||
}
|
||||
} else if (type === 'project') {
|
||||
if (isEdit.value) {
|
||||
const target = projectTags.value.find(item => item.id === formModel.id)
|
||||
if (target) {
|
||||
target.name = formModel.name.trim()
|
||||
target.color = formModel.color
|
||||
target.scene = formModel.scene
|
||||
}
|
||||
} else {
|
||||
projectTags.value.push({
|
||||
id: ++idCounter.project,
|
||||
name: formModel.name.trim(),
|
||||
color: formModel.color,
|
||||
scene: formModel.scene,
|
||||
usage: 0
|
||||
})
|
||||
}
|
||||
} else if (type === 'location') {
|
||||
if (isEdit.value) {
|
||||
const target = addressTags.value.find(item => item.id === formModel.id)
|
||||
if (target) {
|
||||
target.name = formModel.name.trim()
|
||||
target.region = formModel.region
|
||||
}
|
||||
} else {
|
||||
addressTags.value.push({
|
||||
id: ++idCounter.location,
|
||||
name: formModel.name.trim(),
|
||||
region: formModel.region,
|
||||
usage: 0
|
||||
})
|
||||
}
|
||||
} else if (type === 'workType') {
|
||||
const key = formModel.key.trim()
|
||||
if (!key) {
|
||||
message.warning('请输入类型标识')
|
||||
return
|
||||
}
|
||||
if (!isEdit.value && workTypeTags.value.some(item => item.key === key)) {
|
||||
message.warning('类型标识已存在')
|
||||
return
|
||||
}
|
||||
if (isEdit.value) {
|
||||
const duplicate = workTypeTags.value.some(item => item.key === key && item.key !== formModel.id)
|
||||
if (duplicate) {
|
||||
message.warning('类型标识已存在')
|
||||
return
|
||||
}
|
||||
const target = workTypeTags.value.find(item => item.key === formModel.id)
|
||||
if (target) {
|
||||
target.key = key
|
||||
target.name = formModel.name.trim()
|
||||
target.color = formModel.color
|
||||
target.desc = formModel.desc
|
||||
}
|
||||
} else {
|
||||
workTypeTags.value.push({
|
||||
key,
|
||||
name: formModel.name.trim(),
|
||||
color: formModel.color,
|
||||
desc: formModel.desc.trim() || '支持灵活安排',
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
message.success(isEdit.value ? '标签信息已更新' : '新增标签成功')
|
||||
modalVisible.value = false
|
||||
}
|
||||
|
||||
function handleDelete(type: TagCategory, identifier: number | string) {
|
||||
if (type === 'post') {
|
||||
postTags.value = postTags.value.filter(item => item.id !== identifier)
|
||||
} else if (type === 'project') {
|
||||
projectTags.value = projectTags.value.filter(item => item.id !== identifier)
|
||||
} else if (type === 'location') {
|
||||
addressTags.value = addressTags.value.filter(item => item.id !== identifier)
|
||||
} else if (type === 'workType') {
|
||||
workTypeTags.value = workTypeTags.value.filter(item => item.key !== identifier)
|
||||
}
|
||||
message.success('已删除标签')
|
||||
}
|
||||
|
||||
function toggleWorkType(record: WorkTypeTagItem | Record<string, any>, checked: boolean) {
|
||||
const target = record as WorkTypeTagItem
|
||||
target.enabled = checked
|
||||
message.success(checked ? '已启用该类型' : '已停用该类型')
|
||||
}
|
||||
|
||||
async function loadPostTags() {
|
||||
loadingMap.post = true
|
||||
try {
|
||||
const res = await mockGetTags()
|
||||
postTags.value = res.map(tag => ({
|
||||
...tag,
|
||||
usage: Math.floor(Math.random() * 400 + 20)
|
||||
}))
|
||||
} finally {
|
||||
loadingMap.post = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectTags() {
|
||||
loadingMap.project = true
|
||||
try {
|
||||
const res = await mockGetProjectTags()
|
||||
projectTags.value = res.map((tag, index) => ({
|
||||
...tag,
|
||||
scene: projectScenes[index % projectScenes.length]!,
|
||||
usage: Math.floor(Math.random() * 260 + 10)
|
||||
}))
|
||||
} finally {
|
||||
loadingMap.project = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAddressTags() {
|
||||
loadingMap.location = true
|
||||
try {
|
||||
const list = await mockGetLocations()
|
||||
addressTags.value = list.map((name, index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
region: regionOptions[index % regionOptions.length]!,
|
||||
usage: Math.floor(Math.random() * 100 + 5)
|
||||
}))
|
||||
} finally {
|
||||
loadingMap.location = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadWorkTypeTags() {
|
||||
loadingMap.workType = true
|
||||
workTypeTags.value = Object.entries(WorkTypeMap).map(([key, name]) => ({
|
||||
key,
|
||||
name,
|
||||
color: workTypeColorMap[key as WorkType] || '#1677ff',
|
||||
desc: workTypeDescMap[key as WorkType] || '可结合项目灵活配置',
|
||||
enabled: true
|
||||
}))
|
||||
loadingMap.workType = false
|
||||
}
|
||||
|
||||
function handleRefresh(type: TagCategory) {
|
||||
if (type === 'post') {
|
||||
loadPostTags()
|
||||
} else if (type === 'project') {
|
||||
loadProjectTags()
|
||||
} else if (type === 'location') {
|
||||
loadAddressTags()
|
||||
} else if (type === 'workType') {
|
||||
loadWorkTypeTags()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPostTags()
|
||||
loadProjectTags()
|
||||
loadAddressTags()
|
||||
loadWorkTypeTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-manage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(120deg, #f5f9ff, #fff);
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 14px;
|
||||
color: #7c8798;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0;
|
||||
color: #1d1b4c;
|
||||
}
|
||||
|
||||
.summary-desc {
|
||||
font-size: 12px;
|
||||
color: #9aa3b1;
|
||||
}
|
||||
|
||||
.tab-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.tag-tabs .ant-tabs-content-holder) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.tag-tabs .ant-tabs-content) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tabpane) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.color-picker-wrapper > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.color-picker-input::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.color-picker-input::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.color-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-color {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.preset-color:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.preset-color.active {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.tag-form :deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
769
src/views/content/articles/index.vue
Normal file
769
src/views/content/articles/index.vue
Normal file
@@ -0,0 +1,769 @@
|
||||
<template>
|
||||
<div class="article-page">
|
||||
<a-page-header title="文章管理" sub-title="用于发布平台通告、规则政策与活动细则">
|
||||
<template #tags>
|
||||
<a-tag color="blue">文章总数 {{ statistics.total }}</a-tag>
|
||||
<a-tag color="green">已发布 {{ statistics.published }}</a-tag>
|
||||
<a-tag color="orange">草稿 {{ statistics.draft }}</a-tag>
|
||||
<a-tag color="purple">置顶 {{ statistics.pinned }}</a-tag>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="openCreateDrawer">
|
||||
<PlusOutlined /> 新建文章
|
||||
</a-button>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card class="filter-card" :loading="loadingCategories" :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="搜索标题/摘要" allow-clear style="width: 220px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="分类">
|
||||
<a-select v-model:value="searchForm.category" placeholder="全部分类" allow-clear style="width: 160px">
|
||||
<a-select-option v-for="category in categories" :key="category.key" :value="category.key">
|
||||
{{ category.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="全部状态" allow-clear style="width: 140px">
|
||||
<a-select-option value="published">已发布</a-select-option>
|
||||
<a-select-option value="draft">草稿</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="置顶">
|
||||
<a-select v-model:value="searchForm.pinned" placeholder="全部" allow-clear style="width: 140px">
|
||||
<a-select-option value="true">置顶</a-select-option>
|
||||
<a-select-option value="false">未置顶</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<SearchOutlined /> 搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-row :gutter="16" class="category-row">
|
||||
<a-col v-for="category in categories" :key="category.key" :span="4">
|
||||
<a-card size="small" class="category-card">
|
||||
<div class="category-card-header">
|
||||
<a-tag :color="category.color">{{ category.name }}</a-tag>
|
||||
</div>
|
||||
<div class="category-card-desc">{{ category.description }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<a-table row-key="id" :columns="columns" :data-source="articleList" :loading="loading" :pagination="pagination"
|
||||
:scroll="{ y: 'calc(100vh - 460px)' }" @change="handleTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<div class="title-cell">
|
||||
<a-tag v-if="record.pinned" color="orange">置顶</a-tag>
|
||||
<span class="article-title" @click="showDetail(record as ArticleInfo)">{{ record.title }}</span>
|
||||
<a-tag :color="getCategoryDisplay(record.category).color">{{ getCategoryDisplay(record.category).name
|
||||
}}</a-tag>
|
||||
</div>
|
||||
<div class="article-summary">{{ record.summary }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.publishStatus)">
|
||||
{{ getStatusLabel(record.publishStatus) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'stats'">
|
||||
<div class="stats-cell">
|
||||
<span>阅读 {{ record.viewCount }}</span>
|
||||
<span>更新 {{ formatDateTime(record.updatedAt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'author'">
|
||||
<div class="author-cell">
|
||||
<a-avatar :src="record.author.avatar" :size="32" />
|
||||
<span>{{ record.author.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="togglePin(record as ArticleInfo)">
|
||||
<PushpinOutlined /> {{ record.pinned ? '取消置顶' : '设为置顶' }}
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button type="link" size="small">
|
||||
<MoreOutlined /> 状态
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="updateStatus(record as ArticleInfo, 'published')">
|
||||
<CheckCircleOutlined /> 发布
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="updateStatus(record as ArticleInfo, 'draft')">
|
||||
<EditOutlined /> 草稿
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-popconfirm title="确认删除该文章吗?" ok-text="删除" cancel-text="取消"
|
||||
@confirm="handleDelete(record as ArticleInfo)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-drawer :open="createVisible" width="840px" title="新建文章" placement="right" :mask-closable="false"
|
||||
@close="closeCreateDrawer">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="closeCreateDrawer">取消</a-button>
|
||||
<a-button type="primary" :loading="submitLoading" @click="handleSubmitArticle">保存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-form layout="vertical" class="article-form" :model="articleForm">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-form-item label="标题" required>
|
||||
<a-input v-model:value="articleForm.title" placeholder="请输入标题" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="分类" required>
|
||||
<a-select v-model:value="articleForm.category" placeholder="请选择分类" @change="handleCategoryChange">
|
||||
<a-select-option v-for="category in categories" :key="category.key" :value="category.key">
|
||||
{{ category.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 规则政策二级分类 -->
|
||||
<a-form-item v-if="articleForm.category === 'rule'" label="规则类型" required>
|
||||
<a-select v-model:value="articleForm.subCategory" placeholder="请选择规则类型" @change="handleSubCategoryChange">
|
||||
<a-select-option v-for="sub in ruleSubCategories" :key="sub.key" :value="sub.key">
|
||||
{{ sub.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div class="sub-category-hint">选择规则类型后将自动推荐相关标签</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="摘要" required>
|
||||
<a-textarea v-model:value="articleForm.summary" :rows="3" placeholder="请输入摘要,便于列表展示" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="标签">
|
||||
<a-select v-model:value="articleForm.tags" mode="tags" placeholder="输入或选择标签" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="外部链接">
|
||||
<a-input v-model:value="articleForm.linkUrl" placeholder="https://example.com" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="发布状态">
|
||||
<a-radio-group v-model:value="articleForm.status">
|
||||
<a-radio-button value="draft">草稿</a-radio-button>
|
||||
<a-radio-button value="published">发布</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="置顶">
|
||||
<a-switch v-model:checked="articleForm.pinned" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="封面图">
|
||||
<a-upload v-model:file-list="coverFileList" list-type="picture-card" :max-count="1"
|
||||
:before-upload="handleCoverBeforeUpload" @remove="handleCoverRemove">
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">上传封面</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
<a-form-item label="正文内容" required>
|
||||
<RichTextEditor v-model="articleForm.content" placeholder="请输入正文内容,可插入图片、链接等" />
|
||||
<div class="editor-hint">支持基础富文本样式,图片将以 base64 格式预览</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
|
||||
<a-drawer :open="detailVisible" width="900px" title="文章详情" placement="right" @close="detailVisible = false">
|
||||
<template v-if="currentArticle">
|
||||
<div class="detail-header">
|
||||
<div class="detail-title">{{ currentArticle.title }}</div>
|
||||
<div class="detail-meta">
|
||||
<a-tag :color="getCategoryDisplay(currentArticle.category).color">{{
|
||||
getCategoryDisplay(currentArticle.category).name
|
||||
}}</a-tag>
|
||||
<a-tag :color="statusColorMap[currentArticle.publishStatus]">{{ statusLabelMap[currentArticle.publishStatus]
|
||||
}}</a-tag>
|
||||
<span>更新 {{ formatDateTime(currentArticle.updatedAt) }}</span>
|
||||
<span v-if="currentArticle.publishedAt">发布 {{ formatDateTime(currentArticle.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
<div class="detail-content">
|
||||
<pre>{{ currentArticle.content }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
<a-empty v-else description="暂无文章" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { TablePaginationConfig, UploadProps } from 'ant-design-vue'
|
||||
import type { UploadFile } from 'ant-design-vue/es/upload/interface'
|
||||
import type { ColumnType } from 'ant-design-vue/es/table'
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
PushpinOutlined,
|
||||
DeleteOutlined,
|
||||
CheckCircleOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ArticleInfo, ArticleCategoryKey, ArticleCategory, RuleSubCategoryKey } from '@/types'
|
||||
import { RuleSubCategories } from '@/types'
|
||||
import {
|
||||
mockGetArticleList,
|
||||
mockGetArticleCategories,
|
||||
mockToggleArticlePin,
|
||||
mockUpdateArticleStatus,
|
||||
mockDeleteArticle,
|
||||
mockCreateArticle,
|
||||
RulePolicyTags
|
||||
} from '@/mock'
|
||||
import { formatDateTime } from '@/utils/common'
|
||||
import RichTextEditor from '@/components/RichTextEditor.vue'
|
||||
|
||||
// 规则政策二级分类列表
|
||||
const ruleSubCategories = RuleSubCategories
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingCategories = ref(false)
|
||||
const categories = ref<ArticleCategory[]>([])
|
||||
const articleList = ref<ArticleInfo[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const currentArticle = ref<ArticleInfo | null>(null)
|
||||
const createVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
|
||||
interface PaginationState {
|
||||
current: number
|
||||
pageSize: number
|
||||
total: number
|
||||
showSizeChanger: boolean
|
||||
showQuickJumper: boolean
|
||||
showTotal: (total: number) => string
|
||||
}
|
||||
|
||||
const pagination = reactive<PaginationState>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
interface ArticleSearchForm {
|
||||
keyword: string
|
||||
category?: ArticleCategoryKey
|
||||
status?: ArticleInfo['publishStatus']
|
||||
pinned?: 'true' | 'false'
|
||||
}
|
||||
|
||||
const searchForm = reactive<ArticleSearchForm>({
|
||||
keyword: '',
|
||||
category: undefined,
|
||||
status: undefined,
|
||||
pinned: undefined
|
||||
})
|
||||
|
||||
interface ArticleStatistics {
|
||||
total: number
|
||||
published: number
|
||||
draft: number
|
||||
pinned: number
|
||||
}
|
||||
|
||||
const statistics = reactive<ArticleStatistics>({
|
||||
total: 0,
|
||||
published: 0,
|
||||
draft: 0,
|
||||
pinned: 0
|
||||
})
|
||||
|
||||
interface ArticleFormState {
|
||||
title: string
|
||||
summary: string
|
||||
category?: ArticleCategoryKey
|
||||
subCategory?: RuleSubCategoryKey
|
||||
tags: string[]
|
||||
status: ArticleInfo['publishStatus']
|
||||
pinned: boolean
|
||||
linkUrl: string
|
||||
cover: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const articleForm = reactive<ArticleFormState>({
|
||||
title: '',
|
||||
summary: '',
|
||||
category: undefined,
|
||||
subCategory: undefined,
|
||||
tags: [],
|
||||
status: 'draft',
|
||||
pinned: false,
|
||||
linkUrl: '',
|
||||
cover: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
const coverFileList = ref<UploadFile[]>([])
|
||||
const defaultAuthor = {
|
||||
id: 100,
|
||||
name: '平台运营'
|
||||
}
|
||||
|
||||
const statusLabelMap: Record<ArticleInfo['publishStatus'], string> = {
|
||||
published: '已发布',
|
||||
draft: '草稿'
|
||||
}
|
||||
|
||||
const statusColorMap: Record<ArticleInfo['publishStatus'], string> = {
|
||||
published: 'green',
|
||||
draft: 'orange'
|
||||
}
|
||||
|
||||
const columns: ColumnType<ArticleInfo>[] = [
|
||||
{ title: '标题 / 分类', key: 'title', dataIndex: 'title', width: 320 },
|
||||
{ title: '状态', key: 'status', dataIndex: 'publishStatus', width: 120 },
|
||||
{ title: '数据', key: 'stats', width: 220 },
|
||||
{ title: '作者', key: 'author', dataIndex: ['author', 'name'], width: 160 },
|
||||
{ title: '操作', key: 'action', width: 260, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 辅助函数用于处理Record索引
|
||||
function getStatusColor(status: string): string {
|
||||
return statusColorMap[status as ArticleInfo['publishStatus']] || 'default'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
return statusLabelMap[status as ArticleInfo['publishStatus']] || status
|
||||
}
|
||||
|
||||
const categoryMap = reactive<Partial<Record<ArticleCategoryKey, ArticleCategory>>>({})
|
||||
|
||||
function getCategoryDisplay(key: ArticleCategoryKey): ArticleCategory {
|
||||
return (
|
||||
categoryMap[key] ?? {
|
||||
key,
|
||||
name: '未分类',
|
||||
color: 'default'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
loadingCategories.value = true
|
||||
try {
|
||||
const list = await mockGetArticleCategories()
|
||||
categories.value = list
|
||||
list.forEach(item => {
|
||||
categoryMap[item.key] = item
|
||||
})
|
||||
if (!articleForm.category && list.length) {
|
||||
articleForm.category = list[0]!.key
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('分类加载失败')
|
||||
} finally {
|
||||
loadingCategories.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetArticleList({
|
||||
keyword: searchForm.keyword || undefined,
|
||||
category: searchForm.category,
|
||||
status: searchForm.status,
|
||||
pinned:
|
||||
searchForm.pinned === undefined
|
||||
? undefined
|
||||
: searchForm.pinned === 'true',
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
articleList.value = res.list
|
||||
pagination.total = res.total
|
||||
statistics.total = res.total
|
||||
statistics.published = res.list.filter(item => item.publishStatus === 'published').length
|
||||
statistics.draft = res.list.filter(item => item.publishStatus === 'draft').length
|
||||
statistics.pinned = res.list.filter(item => item.pinned).length
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('文章列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.category = undefined
|
||||
searchForm.status = undefined
|
||||
searchForm.pinned = undefined
|
||||
pagination.current = 1
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: TablePaginationConfig) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
async function togglePin(record: ArticleInfo) {
|
||||
try {
|
||||
await mockToggleArticlePin(record.id, !record.pinned)
|
||||
message.success(`${record.pinned ? '取消' : '设置'}置顶成功`)
|
||||
loadArticles()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(record: ArticleInfo, status: ArticleInfo['publishStatus']) {
|
||||
try {
|
||||
await mockUpdateArticleStatus(record.id, status)
|
||||
message.success('状态已更新')
|
||||
loadArticles()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(record: ArticleInfo) {
|
||||
try {
|
||||
await mockDeleteArticle(record.id)
|
||||
message.success('删除成功')
|
||||
loadArticles()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function showDetail(record: ArticleInfo) {
|
||||
currentArticle.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function resetArticleForm() {
|
||||
articleForm.title = ''
|
||||
articleForm.summary = ''
|
||||
articleForm.category = categories.value[0]?.key
|
||||
articleForm.subCategory = undefined
|
||||
articleForm.tags = []
|
||||
articleForm.status = 'draft'
|
||||
articleForm.pinned = false
|
||||
articleForm.linkUrl = ''
|
||||
articleForm.cover = ''
|
||||
articleForm.content = ''
|
||||
coverFileList.value = []
|
||||
}
|
||||
|
||||
// 处理分类变化
|
||||
function handleCategoryChange() {
|
||||
const category = articleForm.category
|
||||
// 如果切换到规则政策,清空标签,等待选择二级分类后再推荐
|
||||
if (category === 'rule') {
|
||||
articleForm.subCategory = undefined
|
||||
articleForm.tags = []
|
||||
} else {
|
||||
articleForm.subCategory = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 处理二级分类变化,自动推荐标签
|
||||
function handleSubCategoryChange() {
|
||||
const subCategory = articleForm.subCategory
|
||||
if (!subCategory) return
|
||||
const recommendedTags = RulePolicyTags[subCategory] || []
|
||||
// 合并现有标签和推荐标签,去重
|
||||
const existingTags = articleForm.tags.filter(t => !Object.values(RulePolicyTags).flat().includes(t))
|
||||
articleForm.tags = [...new Set([...existingTags, ...recommendedTags])]
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
resetArticleForm()
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
function closeCreateDrawer() {
|
||||
createVisible.value = false
|
||||
resetArticleForm()
|
||||
}
|
||||
|
||||
type UploadFileWithOrigin = UploadFile & { originFileObj?: File }
|
||||
|
||||
const handleCoverBeforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const uploadFile = file as UploadFileWithOrigin
|
||||
const source = uploadFile.originFileObj
|
||||
|
||||
if (!source) {
|
||||
message.error('无法读取原始文件,请重试')
|
||||
return false
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string
|
||||
articleForm.cover = url
|
||||
coverFileList.value = [
|
||||
{
|
||||
uid: uploadFile.uid || String(Date.now()),
|
||||
name: uploadFile.name || source.name,
|
||||
status: 'done',
|
||||
url
|
||||
}
|
||||
]
|
||||
}
|
||||
reader.readAsDataURL(source)
|
||||
return false
|
||||
}
|
||||
|
||||
const handleCoverRemove: UploadProps['onRemove'] = () => {
|
||||
coverFileList.value = []
|
||||
articleForm.cover = ''
|
||||
return true
|
||||
}
|
||||
|
||||
function validateArticleForm(): boolean {
|
||||
if (!articleForm.title.trim()) {
|
||||
message.warning('请输入标题')
|
||||
return false
|
||||
}
|
||||
if (!articleForm.category) {
|
||||
message.warning('请选择分类')
|
||||
return false
|
||||
}
|
||||
// 规则政策必须选择二级分类
|
||||
if (articleForm.category === 'rule' && !articleForm.subCategory) {
|
||||
message.warning('请选择规则类型')
|
||||
return false
|
||||
}
|
||||
if (!articleForm.summary.trim()) {
|
||||
message.warning('请输入摘要')
|
||||
return false
|
||||
}
|
||||
const contentText = articleForm.content.replace(/<[^>]+>/g, '').replace(/ /g, '').trim()
|
||||
if (!contentText && !articleForm.content.includes('<img')) {
|
||||
message.warning('请输入正文内容')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSubmitArticle() {
|
||||
if (!validateArticleForm()) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await mockCreateArticle({
|
||||
title: articleForm.title,
|
||||
summary: articleForm.summary,
|
||||
content: articleForm.content,
|
||||
category: articleForm.category!,
|
||||
subCategory: articleForm.subCategory,
|
||||
tags: articleForm.tags,
|
||||
publishStatus: articleForm.status,
|
||||
pinned: articleForm.pinned,
|
||||
cover: articleForm.cover,
|
||||
linkUrl: articleForm.linkUrl || undefined,
|
||||
author: defaultAuthor
|
||||
})
|
||||
message.success('创建成功')
|
||||
closeCreateDrawer()
|
||||
loadArticles()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('创建失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadArticles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.article-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.category-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.category-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.category-card-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.table-card .ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.article-title:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.stats-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.author-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.detail-content pre {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.article-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-hint {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.sub-category-hint {
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
495
src/views/dashboard/index.vue
Normal file
495
src/views/dashboard/index.vue
Normal file
@@ -0,0 +1,495 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<a-page-header title="控制台" sub-title="欢迎回到楠溪屿后台,当前服务运行稳定">
|
||||
<template #tags>
|
||||
<a-tag color="blue">内容活跃</a-tag>
|
||||
<a-tag color="green">数据同步完成</a-tag>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-row :gutter="16" class="overview-row">
|
||||
<a-col v-for="card in overviewCards" :key="card.key" :span="6">
|
||||
<a-card class="overview-card">
|
||||
<div class="card-header">
|
||||
<component :is="card.icon" class="card-icon" :style="{ color: card.color }" />
|
||||
<span class="card-title">{{ card.title }}</span>
|
||||
</div>
|
||||
<div class="card-value">{{ card.value }}</div>
|
||||
<div class="card-desc">{{ card.desc }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" class="content-grid">
|
||||
<a-col :span="16">
|
||||
<div class="column-stack left-stack">
|
||||
<a-card title="社区热度" :loading="loading" class="community-card">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<span class="extra-item"><FireOutlined /> 热门帖子 {{ overview.posts.hot }}</span>
|
||||
<span class="extra-item"><RiseOutlined /> 累计浏览 {{ formatNumber(contentSummary.totalViews) }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-list :data-source="hotPosts" :split="false">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<div class="post-item">
|
||||
<div class="post-main">
|
||||
<a-tag v-if="item.isHot" color="red">热门</a-tag>
|
||||
<span class="post-title">{{ item.title }}</span>
|
||||
</div>
|
||||
<div class="post-meta">
|
||||
<span><UserOutlined /> {{ item.author.nickname }}</span>
|
||||
<span><EyeOutlined /> {{ formatNumber(item.viewCount) }}</span>
|
||||
<span><LikeOutlined /> {{ formatNumber(item.likeCount) }}</span>
|
||||
<span><CommentOutlined /> {{ formatNumber(item.commentCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<a-card title="项目进度" :loading="loading">
|
||||
<div v-for="project in projectPipeline" :key="project.id" class="project-item">
|
||||
<div class="project-header">
|
||||
<span class="project-name">{{ project.name }}</span>
|
||||
<a-tag color="purple">{{ WorkTypeMap[project.workType] }}</a-tag>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span><CalendarOutlined /> 截止 {{ formatDate(project.deadline) }}</span>
|
||||
<span><ClockCircleOutlined /> {{ describeDeadline(project.deadline) }}</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="getProjectProgress(project.deadline)"
|
||||
:status="isExpiringSoon(project.deadline) ? 'exception' : 'active'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="column-stack right-stack">
|
||||
<a-card title="运行提醒" :loading="loading" class="insight-card">
|
||||
<a-list :data-source="insightList" :split="false">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<div class="insight-item">
|
||||
<a-tag :color="item.color">{{ item.badge }}</a-tag>
|
||||
<div class="insight-content">
|
||||
<div class="insight-title">{{ item.title }}</div>
|
||||
<div class="insight-desc">{{ item.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<a-card title="招募动态" :loading="loading">
|
||||
<a-timeline>
|
||||
<a-timeline-item
|
||||
v-for="record in recruitmentTimeline"
|
||||
:key="record.id"
|
||||
:color="getRecruitStatusColor(record.status)"
|
||||
>
|
||||
<div class="recruit-item">
|
||||
<div class="recruit-name">{{ record.applicant.nickname }} · {{ RecruitmentStatusMap[record.status] }}</div>
|
||||
<div class="recruit-project">{{ record.project.name }}</div>
|
||||
<div class="recruit-time">{{ formatDateTime(record.appliedAt) }}</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</a-card>
|
||||
|
||||
<a-card title="快捷入口" class="quick-card">
|
||||
<a-space direction="vertical" style="width: 100%">
|
||||
<a-button
|
||||
v-for="action in quickActions"
|
||||
:key="action.key"
|
||||
:type="action.type"
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
<component :is="action.icon" />
|
||||
<span>{{ action.label }}</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
FileTextOutlined,
|
||||
ProjectOutlined,
|
||||
TeamOutlined,
|
||||
FireOutlined,
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
RiseOutlined,
|
||||
EyeOutlined,
|
||||
LikeOutlined,
|
||||
CommentOutlined,
|
||||
NotificationOutlined,
|
||||
PlusOutlined,
|
||||
TagsOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { PostInfo, ProjectInfo, RecruitmentRecord, RecruitmentStatus } from '@/types'
|
||||
import { WorkTypeMap, RecruitmentStatusMap } from '@/types'
|
||||
import {
|
||||
mockGetPostList,
|
||||
mockGetProjectList,
|
||||
mockGetRecruitmentList,
|
||||
mockGetRecruitmentStats
|
||||
} from '@/mock'
|
||||
|
||||
const loading = ref(false)
|
||||
const hotPosts = ref<PostInfo[]>([])
|
||||
const projectPipeline = ref<ProjectInfo[]>([])
|
||||
const recruitmentTimeline = ref<RecruitmentRecord[]>([])
|
||||
const authorCount = ref(0)
|
||||
const contentSummary = reactive({ totalViews: 0, totalComments: 0 })
|
||||
const overview = reactive({
|
||||
posts: { total: 0, hot: 0 },
|
||||
projects: { total: 0, active: 0, expiring: 0 },
|
||||
recruit: { total: 0, pending: 0, approved: 0, todayNew: 0 }
|
||||
})
|
||||
|
||||
const overviewCards = computed(() => [
|
||||
{
|
||||
key: 'creators',
|
||||
title: '活跃创作者',
|
||||
value: authorCount.value,
|
||||
desc: `近七天新增 ${Math.max(authorCount.value - 6, 0)} 人`,
|
||||
icon: UserOutlined,
|
||||
color: '#52c41a'
|
||||
},
|
||||
{
|
||||
key: 'posts',
|
||||
title: '帖子总数',
|
||||
value: overview.posts.total,
|
||||
desc: `热门帖子 ${overview.posts.hot} 篇`,
|
||||
icon: FileTextOutlined,
|
||||
color: '#1890ff'
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
title: '进行中项目',
|
||||
value: overview.projects.active,
|
||||
desc: `即将截止 ${overview.projects.expiring} 个`,
|
||||
icon: ProjectOutlined,
|
||||
color: '#722ed1'
|
||||
},
|
||||
{
|
||||
key: 'recruit',
|
||||
title: '待审核申请',
|
||||
value: overview.recruit.pending,
|
||||
desc: `今日新增 ${overview.recruit.todayNew} 条`,
|
||||
icon: TeamOutlined,
|
||||
color: '#fa8c16'
|
||||
}
|
||||
])
|
||||
|
||||
const insightList = computed(() => [
|
||||
{
|
||||
key: 'community',
|
||||
badge: '社区',
|
||||
color: 'blue',
|
||||
title: '内容热度保持增长',
|
||||
desc: `累计浏览 ${formatNumber(contentSummary.totalViews)} · 评论 ${formatNumber(contentSummary.totalComments)}`
|
||||
},
|
||||
{
|
||||
key: 'project',
|
||||
badge: '项目',
|
||||
color: overview.projects.expiring > 0 ? 'orange' : 'green',
|
||||
title: overview.projects.expiring > 0 ? '存在即将截止的项目' : '项目进展顺利',
|
||||
desc:
|
||||
overview.projects.expiring > 0
|
||||
? `未来 7 天需关注 ${overview.projects.expiring} 个项目`
|
||||
: '所有项目均处于安全周期'
|
||||
},
|
||||
{
|
||||
key: 'recruit',
|
||||
badge: '招募',
|
||||
color: overview.recruit.pending > 0 ? 'red' : 'green',
|
||||
title: '招募审批提醒',
|
||||
desc:
|
||||
overview.recruit.pending > 0
|
||||
? `还有 ${overview.recruit.pending} 条申请待处理`
|
||||
: '暂无待处理的招募申请'
|
||||
}
|
||||
])
|
||||
|
||||
const quickActions = [
|
||||
{ key: 'notice', label: '发布社区公告', icon: NotificationOutlined, type: 'primary' as const },
|
||||
{ key: 'project', label: '创建新项目', icon: PlusOutlined, type: 'default' as const },
|
||||
{ key: 'recruit', label: '审核招募申请', icon: TeamOutlined, type: 'default' as const },
|
||||
{ key: 'tag', label: '管理标签体系', icon: TagsOutlined, type: 'default' as const },
|
||||
{ key: 'setting', label: '系统配置', icon: SettingOutlined, type: 'default' as const }
|
||||
]
|
||||
|
||||
async function loadDashboardData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [postRes, projectRes, recruitStats, recruitListRes] = await Promise.all([
|
||||
mockGetPostList({ page: 1, pageSize: 500 }),
|
||||
mockGetProjectList({ page: 1, pageSize: 200 }),
|
||||
mockGetRecruitmentStats(),
|
||||
mockGetRecruitmentList({ page: 1, pageSize: 6 })
|
||||
])
|
||||
|
||||
overview.posts.total = postRes.total
|
||||
const uniqueAuthors = new Set(postRes.list.map(post => post.author.id))
|
||||
authorCount.value = uniqueAuthors.size
|
||||
overview.posts.hot = postRes.list.filter(post => post.isHot).length
|
||||
hotPosts.value = [...postRes.list].sort((a, b) => b.viewCount - a.viewCount).slice(0, 5)
|
||||
contentSummary.totalViews = postRes.list.reduce((sum, post) => sum + post.viewCount, 0)
|
||||
contentSummary.totalComments = postRes.list.reduce((sum, post) => sum + post.commentCount, 0)
|
||||
|
||||
overview.projects.total = projectRes.total
|
||||
overview.projects.active = projectRes.list.filter(project => project.status === 'active').length
|
||||
overview.projects.expiring = projectRes.list.filter(project => isExpiringSoon(project.deadline)).length
|
||||
projectPipeline.value = [...projectRes.list]
|
||||
.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime())
|
||||
.slice(0, 4)
|
||||
|
||||
overview.recruit.total = recruitStats.total
|
||||
overview.recruit.pending = recruitStats.pending
|
||||
overview.recruit.approved = recruitStats.approved
|
||||
overview.recruit.todayNew = recruitStats.todayNew
|
||||
recruitmentTimeline.value = recruitListRes.list.slice(0, 6)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('仪表盘数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + 'w'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
|
||||
return String(num)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function describeDeadline(dateStr: string): string {
|
||||
const diff = daysLeft(dateStr)
|
||||
if (diff < 0) return '已截止'
|
||||
if (diff === 0) return '今日截止'
|
||||
return `剩余 ${diff} 天`
|
||||
}
|
||||
|
||||
function daysLeft(dateStr: string): number {
|
||||
const diff = new Date(dateStr).getTime() - Date.now()
|
||||
return Math.ceil(diff / (24 * 60 * 60 * 1000))
|
||||
}
|
||||
|
||||
function isExpiringSoon(dateStr: string, days = 7): boolean {
|
||||
const diff = daysLeft(dateStr)
|
||||
return diff >= 0 && diff <= days
|
||||
}
|
||||
|
||||
function getProjectProgress(dateStr: string): number {
|
||||
const totalWindow = 60
|
||||
const diff = daysLeft(dateStr)
|
||||
if (diff <= 0) return 100
|
||||
if (diff >= totalWindow) return 20
|
||||
return Math.min(100, Math.max(20, Math.round(((totalWindow - diff) / totalWindow) * 100)))
|
||||
}
|
||||
|
||||
function getRecruitStatusColor(status: RecruitmentStatus): string {
|
||||
const map: Record<RecruitmentStatus, string> = {
|
||||
pending: 'orange',
|
||||
approved: 'green',
|
||||
rejected: 'red',
|
||||
withdrawn: 'gray',
|
||||
expired: 'gray'
|
||||
}
|
||||
return map[status] || 'blue'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 24px;
|
||||
padding-right: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.overview-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(120deg, #f2f7ff, #ffffff);
|
||||
min-height: 130px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #74809a;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0;
|
||||
color: #1d1b4c;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
color: #939bb4;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.column-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.community-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.extra-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #606a80;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.insight-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.insight-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.insight-title {
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.insight-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.quick-card :deep(.ant-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.project-item + .project-item {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.recruit-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.recruit-name {
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.recruit-project {
|
||||
font-size: 13px;
|
||||
color: #5c6370;
|
||||
}
|
||||
|
||||
.recruit-time {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
</style>
|
||||
65
src/views/error/404.vue
Normal file
65
src/views/error/404.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<a-result
|
||||
status="404"
|
||||
title="404"
|
||||
sub-title="抱歉,您访问的页面不存在"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="goHome">返回首页</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 检测是否为嵌入模式
|
||||
function isEmbeddedMode(): boolean {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
if (urlParams.get('__embedded') === 'true') {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
return window.self !== window.top
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
if (isEmbeddedMode()) {
|
||||
// 嵌入模式下,通知父框架导航到首页
|
||||
// 使用 postMessage 通知父框架
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'NAVIGATE_HOME',
|
||||
source: 'codePort'
|
||||
}, '*')
|
||||
} catch {
|
||||
// 如果 postMessage 失败,尝试直接修改父框架的 location
|
||||
// 这在同源情况下有效
|
||||
try {
|
||||
window.top.location.href = '/'
|
||||
} catch {
|
||||
// 最后的 fallback:在当前 iframe 内跳转
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
428
src/views/login/index.vue
Normal file
428
src/views/login/index.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="bg-decoration">
|
||||
<div class="decoration-circle circle-1"></div>
|
||||
<div class="decoration-circle circle-2"></div>
|
||||
<div class="decoration-circle circle-3"></div>
|
||||
<div class="decoration-line line-1"></div>
|
||||
<div class="decoration-line line-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="logo-area">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">◇</span>
|
||||
<span class="logo-text">楠溪屿</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录卡片 - 偏右位置 -->
|
||||
<div class="login-wrapper">
|
||||
<div class="login-box">
|
||||
<div class="login-header">
|
||||
<h1>欢迎回来</h1>
|
||||
<p>登录您的管理员账户</p>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
ref="formRef"
|
||||
class="login-form"
|
||||
@finish="handleLogin"
|
||||
>
|
||||
<a-form-item name="username">
|
||||
<a-input
|
||||
v-model:value="loginForm.username"
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
class="custom-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined class="input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="loginForm.password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
class="custom-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined class="input-icon" />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="captcha">
|
||||
<div class="captcha-row">
|
||||
<a-input
|
||||
v-model:value="loginForm.captcha"
|
||||
placeholder="验证码"
|
||||
size="large"
|
||||
class="captcha-input custom-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<SafetyCertificateOutlined class="input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
<div class="captcha-image" @click="refreshCaptcha" title="点击刷新验证码">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码" />
|
||||
<a-spin v-else size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="rememberMe">记住我</a-checkbox>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span class="footer-text">程序员论坛社区管理平台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const captchaImage = ref('')
|
||||
const captchaKey = ref('')
|
||||
const rememberMe = ref(false)
|
||||
|
||||
// 登录表单
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules: Record<string, Rule[]> = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
async function refreshCaptcha() {
|
||||
try {
|
||||
const data = await userStore.getCaptcha()
|
||||
captchaImage.value = data.captchaImage
|
||||
captchaKey.value = data.captchaKey
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录处理
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login({
|
||||
...loginForm,
|
||||
captchaKey: captchaKey.value
|
||||
})
|
||||
message.success('登录成功,欢迎回来!')
|
||||
router.push('/')
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '登录失败'
|
||||
message.error(errorMessage)
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #e8f4fc 0%, #f5f0fa 50%, #fef6f0 100%);
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.circle-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: linear-gradient(135deg, rgba(114, 197, 229, 0.3) 0%, rgba(114, 197, 229, 0.1) 100%);
|
||||
top: -50px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.circle-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, rgba(250, 176, 162, 0.4) 0%, rgba(250, 176, 162, 0.1) 100%);
|
||||
bottom: 100px;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.circle-3 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: linear-gradient(135deg, rgba(186, 220, 231, 0.3) 0%, rgba(186, 220, 231, 0.1) 100%);
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.decoration-line {
|
||||
position: absolute;
|
||||
height: 1px;
|
||||
background: rgba(250, 176, 162, 0.5);
|
||||
}
|
||||
|
||||
.line-1 {
|
||||
width: 100%;
|
||||
top: 45%;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.line-2 {
|
||||
width: 100%;
|
||||
top: 55%;
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
|
||||
/* Logo区域 */
|
||||
.logo-area {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 28px;
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* 登录卡片包装器 - 偏右布局 */
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding-right: 12%;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 380px;
|
||||
padding: 40px 36px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: left;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 26px;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 统一输入框样式 */
|
||||
.custom-input,
|
||||
.custom-input :deep(.ant-input),
|
||||
.custom-input :deep(.ant-input-password) {
|
||||
border-radius: 8px !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.custom-input:hover {
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.custom-input:focus,
|
||||
.custom-input:focus-within,
|
||||
.custom-input.ant-input-affix-wrapper-focused {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
color: #b0b0b0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 110px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.captcha-image:hover {
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.captcha-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 覆盖 antd 默认样式 */
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:deep(.ant-input) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-btn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
|
||||
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 底部文字 */
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.login-wrapper {
|
||||
justify-content: center;
|
||||
padding-right: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
578
src/views/project/contract/index.vue
Normal file
578
src/views/project/contract/index.vue
Normal file
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<div class="contract-page">
|
||||
<a-page-header title="合同管理" sub-title="管理平台所有项目合同">
|
||||
<template #tags>
|
||||
<a-tag color="green">生效中 {{ stats.active }}</a-tag>
|
||||
<a-tag color="blue">待签署 {{ stats.pendingSign }}</a-tag>
|
||||
<a-tag color="orange">已过期 {{ stats.expired }}</a-tag>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-icon"><FileTextOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-title">总合同数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-active">
|
||||
<div class="stat-icon"><CheckCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.active }}</div>
|
||||
<div class="stat-title">生效中</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-pending">
|
||||
<div class="stat-icon"><ClockCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.pendingSign }}</div>
|
||||
<div class="stat-title">待签署</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-completed">
|
||||
<div class="stat-icon"><FileDoneOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.completed }}</div>
|
||||
<div class="stat-title">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-expired">
|
||||
<div class="stat-icon"><WarningOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.expired }}</div>
|
||||
<div class="stat-title">已过期</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-amount">
|
||||
<div class="stat-icon"><PayCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">¥{{ formatAmount(stats.totalAmount) }}</div>
|
||||
<div class="stat-title">总金额</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<a-card class="filter-card">
|
||||
<div class="search-bar">
|
||||
<a-form layout="inline" class="search-form">
|
||||
<a-form-item>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索合同标题 / 编号 / 签约人"
|
||||
allow-clear
|
||||
style="width: 260px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="filterType"
|
||||
placeholder="合同类型"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option v-for="(label, key) in ContractTypeMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="filterStatus"
|
||||
placeholder="合同状态"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option v-for="(label, key) in ContractStatusMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
style="width: 240px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" @click="handleSearch">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-card class="main-card" :loading="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="contractList"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'contract'">
|
||||
<div class="contract-cell">
|
||||
<div class="contract-title">{{ record.title }}</div>
|
||||
<div class="contract-no">{{ record.contractNo }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="getTypeColor(record.type)">
|
||||
{{ ContractTypeMap[record.type as ContractType] }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'parties'">
|
||||
<div class="parties-cell">
|
||||
<div class="party">
|
||||
<a-avatar :src="record.partyA.avatar" :size="24" />
|
||||
<span>{{ record.partyA.nickname }}</span>
|
||||
<span class="role">(甲方)</span>
|
||||
</div>
|
||||
<div class="party">
|
||||
<a-avatar :src="record.partyB.avatar" :size="24" />
|
||||
<span>{{ record.partyB.nickname }}</span>
|
||||
<span class="role">(乙方)</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
<span :class="{ 'amount-zero': record.amount === 0 }">
|
||||
{{ record.amount > 0 ? '¥' + record.amount.toLocaleString() : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'period'">
|
||||
<div class="period-cell">
|
||||
<div>{{ formatDate(record.effectiveDate) }}</div>
|
||||
<div>→ {{ formatDate(record.expiryDate) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="ContractStatusBadgeMap[record.status as ContractStatus]"
|
||||
:text="ContractStatusMap[record.status as ContractStatus]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="showDetail(record as ContractRecord)">
|
||||
<EyeOutlined /> 详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
:title="currentRecord ? currentRecord.title : ''"
|
||||
width="680"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="currentRecord">
|
||||
<!-- 合同基本信息 -->
|
||||
<a-descriptions :column="2" bordered size="small" title="合同信息">
|
||||
<a-descriptions-item label="合同编号">
|
||||
{{ currentRecord.contractNo }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同类型">
|
||||
<a-tag :color="getTypeColor(currentRecord.type)">
|
||||
{{ ContractTypeMap[currentRecord.type] }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同金额">
|
||||
<span class="amount-highlight" v-if="currentRecord.amount > 0">
|
||||
¥{{ currentRecord.amount.toLocaleString() }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同状态">
|
||||
<a-badge
|
||||
:status="ContractStatusBadgeMap[currentRecord.status]"
|
||||
:text="ContractStatusMap[currentRecord.status]"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="生效日期">
|
||||
{{ formatDate(currentRecord.effectiveDate) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="到期日期">
|
||||
{{ formatDate(currentRecord.expiryDate) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="关联项目" :span="2" v-if="currentRecord.relatedProjectName">
|
||||
{{ currentRecord.relatedProjectName }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 签约双方 -->
|
||||
<a-divider>签约双方</a-divider>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<div class="party-card">
|
||||
<div class="party-header">
|
||||
<a-avatar :src="currentRecord.partyA.avatar" :size="48" />
|
||||
<div class="party-info">
|
||||
<div class="party-name">
|
||||
{{ currentRecord.partyA.nickname }}
|
||||
<CheckCircleFilled v-if="currentRecord.partyA.verified" class="verified" />
|
||||
</div>
|
||||
<div class="party-role">甲方</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="公司">{{ currentRecord.partyA.company || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.partyA.email }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="party-card">
|
||||
<div class="party-header">
|
||||
<a-avatar :src="currentRecord.partyB.avatar" :size="48" />
|
||||
<div class="party-info">
|
||||
<div class="party-name">
|
||||
{{ currentRecord.partyB.nickname }}
|
||||
<CheckCircleFilled v-if="currentRecord.partyB.verified" class="verified" />
|
||||
</div>
|
||||
<div class="party-role">乙方</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="职位">{{ currentRecord.partyB.position || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.partyB.email }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 签署记录 -->
|
||||
<a-divider>签署记录</a-divider>
|
||||
<a-timeline v-if="currentRecord.signRecords.length">
|
||||
<a-timeline-item v-for="(record, index) in currentRecord.signRecords" :key="index" color="green">
|
||||
<div class="sign-record">
|
||||
<div class="sign-info">
|
||||
<a-tag :color="record.partyType === 'A' ? 'blue' : 'green'" size="small">
|
||||
{{ record.partyType === 'A' ? '甲方' : '乙方' }}
|
||||
</a-tag>
|
||||
<span class="sign-by">{{ record.signedBy }}</span>
|
||||
<span class="sign-method">
|
||||
{{ record.signMethod === 'electronic' ? '电子签署' : '手动签署' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sign-time">{{ formatDateTime(record.signedAt) }}</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<a-empty v-else description="暂无签署记录" :image-style="{ height: '40px' }" />
|
||||
|
||||
<!-- 合同附件 -->
|
||||
<a-divider>合同附件</a-divider>
|
||||
<div v-if="currentRecord.attachments.length" class="attachment-list">
|
||||
<div v-for="file in currentRecord.attachments" :key="file.id" class="attachment-item">
|
||||
<FileOutlined class="file-icon" />
|
||||
<div class="file-info">
|
||||
<a :href="file.url" target="_blank">{{ file.name }}</a>
|
||||
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||
</div>
|
||||
<span class="file-date">{{ formatDate(file.uploadedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无附件" :image-style="{ height: '40px' }" />
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import {
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileDoneOutlined,
|
||||
WarningOutlined,
|
||||
PayCircleOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleFilled,
|
||||
FileOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ContractRecord, ContractType, ContractStatus } from '@/types'
|
||||
import { ContractTypeMap, ContractStatusMap, ContractStatusBadgeMap } from '@/types'
|
||||
import { mockGetContractStats, mockGetContractList } from '@/mock'
|
||||
import { formatDateTime } from '@/utils/common'
|
||||
|
||||
const loading = ref(false)
|
||||
const contractList = ref<ContractRecord[]>([])
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
active: 0,
|
||||
pendingSign: 0,
|
||||
completed: 0,
|
||||
expired: 0,
|
||||
totalAmount: 0
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const filterType = ref<ContractType | undefined>()
|
||||
const filterStatus = ref<ContractStatus | undefined>()
|
||||
const dateRange = ref<[Dayjs, Dayjs] | undefined>()
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<ContractRecord | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ title: '合同', key: 'contract', width: 220 },
|
||||
{ title: '类型', key: 'type', width: 100 },
|
||||
{ title: '签约双方', key: 'parties', width: 180 },
|
||||
{ title: '金额', key: 'amount', width: 120 },
|
||||
{ title: '有效期', key: 'period', width: 130 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 100 }
|
||||
]
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 10000) {
|
||||
return (amount / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return amount.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(str: string): string {
|
||||
return new Date(str).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function getTypeColor(type: ContractType): string {
|
||||
const colors: Record<ContractType, string> = {
|
||||
service: '#1890ff',
|
||||
project: '#52c41a',
|
||||
freelance: '#722ed1',
|
||||
nda: '#8c8c8c',
|
||||
other: '#faad14'
|
||||
}
|
||||
return colors[type]
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await mockGetContractStats()
|
||||
Object.assign(stats, data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetContractList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
startDate: dateRange.value?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange.value?.[1]?.format('YYYY-MM-DD')
|
||||
})
|
||||
contractList.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchKeyword.value = ''
|
||||
filterType.value = undefined
|
||||
filterStatus.value = undefined
|
||||
dateRange.value = undefined
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current?: number; pageSize?: number }) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadData()
|
||||
}
|
||||
|
||||
function showDetail(record: ContractRecord) {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.stats-row { margin-bottom: 0; }
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }
|
||||
|
||||
.stat-total { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.stat-active { background: linear-gradient(135deg, #11998e, #38ef7d); }
|
||||
.stat-pending { background: linear-gradient(135deg, #4facfe, #00f2fe); }
|
||||
.stat-completed { background: linear-gradient(135deg, #a8edea, #fed6e3); color: #333; }
|
||||
.stat-expired { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||
.stat-amount { background: linear-gradient(135deg, #fa709a, #fee140); }
|
||||
|
||||
.stat-icon { font-size: 28px; opacity: 0.9; }
|
||||
.stat-content { flex: 1; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; }
|
||||
.stat-title { font-size: 13px; opacity: 0.85; margin-top: 2px; }
|
||||
|
||||
.filter-card, .main-card { border-radius: 12px; }
|
||||
|
||||
.search-bar { padding: 8px; }
|
||||
.search-form { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.search-form :deep(.ant-form-item) { margin-bottom: 0; margin-right: 0; }
|
||||
|
||||
.contract-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.contract-title { font-weight: 600; color: #1a1a2e; }
|
||||
.contract-no { font-size: 12px; color: #8c8c8c; font-family: monospace; }
|
||||
|
||||
.parties-cell { display: flex; flex-direction: column; gap: 6px; }
|
||||
.party { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.party .role { color: #8c8c8c; font-size: 12px; }
|
||||
|
||||
.period-cell { font-size: 12px; color: #666; line-height: 1.5; }
|
||||
|
||||
.amount-zero { color: #8c8c8c; }
|
||||
|
||||
/* 详情样式 */
|
||||
.party-card {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.party-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.party-info { flex: 1; }
|
||||
.party-name { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.party-role { font-size: 12px; color: #8c8c8c; margin-top: 2px; }
|
||||
.verified { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.amount-highlight { font-size: 18px; font-weight: 700; color: #f5222d; }
|
||||
|
||||
.sign-record {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sign-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sign-by { font-weight: 500; }
|
||||
.sign-method { font-size: 12px; color: #8c8c8c; }
|
||||
.sign-time { font-size: 12px; color: #8c8c8c; }
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-icon { font-size: 24px; color: #1890ff; }
|
||||
.file-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.file-info a { color: #1890ff; }
|
||||
.file-size { font-size: 12px; color: #8c8c8c; }
|
||||
.file-date { font-size: 12px; color: #8c8c8c; }
|
||||
</style>
|
||||
575
src/views/project/list/index.vue
Normal file
575
src/views/project/list/index.vue
Normal file
@@ -0,0 +1,575 @@
|
||||
<template>
|
||||
<div class="project-manage">
|
||||
<!-- 搜索筛选区域 -->
|
||||
<a-card class="search-card" :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="项目名称/发布人" allow-clear style="width: 180px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="工作类型">
|
||||
<a-select v-model:value="searchForm.workType" placeholder="选择类型" allow-clear style="width: 120px">
|
||||
<a-select-option v-for="(label, key) in WorkTypeMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="工作地点">
|
||||
<a-select v-model:value="searchForm.location" placeholder="选择地点" allow-clear style="width: 120px">
|
||||
<a-select-option v-for="loc in locationList" :key="loc" :value="loc">
|
||||
{{ loc }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="选择状态" allow-clear style="width: 110px">
|
||||
<a-select-option value="active">招聘中</a-select-option>
|
||||
<a-select-option value="closed">已关闭</a-select-option>
|
||||
<a-select-option value="expired">已过期</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<SearchOutlined /> 搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>项目列表</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
|
||||
<DeleteOutlined /> 批量删除 {{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="projectList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
|
||||
row-key="id"
|
||||
:scroll="{ y: 'calc(100vh - 380px)' }"
|
||||
size="middle"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 项目名称 -->
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="name-cell">
|
||||
<a-tag v-if="record.isPinned" color="red"><PushpinOutlined /> 置顶</a-tag>
|
||||
<a-tag v-if="isRecentlyExposed(record.lastExposureAt)" color="volcano"><RocketOutlined /> 热门</a-tag>
|
||||
<span class="project-name" @click="showDetail(record)">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 薪资 -->
|
||||
<template v-else-if="column.key === 'salary'">
|
||||
<span class="salary">{{ formatSalaryFromRecord(record) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 发布人 -->
|
||||
<template v-else-if="column.key === 'publisher'">
|
||||
<div class="publisher-cell">
|
||||
<a-avatar :src="record.publisher.avatar" :size="28" />
|
||||
<span>{{ record.publisher.nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 工作类型 -->
|
||||
<template v-else-if="column.key === 'workType'">
|
||||
<a-tag :color="getWorkTypeColor(record.workType)">
|
||||
{{ getWorkTypeLabel(record.workType) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 标签 -->
|
||||
<template v-else-if="column.key === 'tags'">
|
||||
<a-tag v-for="tag in record.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 截止日期 -->
|
||||
<template v-else-if="column.key === 'deadline'">
|
||||
<span :class="{ 'text-danger': isExpiringSoon(record.deadline) }">
|
||||
{{ formatDate(record.deadline) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="showDetail(record)">
|
||||
<EyeOutlined /> 查看
|
||||
</a-button>
|
||||
<a-tooltip :title="record.isPinned ? '取消置顶' : '设为置顶'">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:style="{ color: record.isPinned ? '#f5222d' : undefined }"
|
||||
@click="record.isPinned ? handleCancelPin(record.id) : showPinModal(record)"
|
||||
>
|
||||
<PushpinOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="手动曝光">
|
||||
<a-button type="link" size="small" @click="handleExpose(record.id)">
|
||||
<RocketOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm title="确定要删除此项目吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
title="项目详情"
|
||||
width="700"
|
||||
placement="right"
|
||||
>
|
||||
<template v-if="currentProject">
|
||||
<div class="detail-section">
|
||||
<h2 class="detail-title">{{ currentProject.name }}</h2>
|
||||
<div class="detail-meta">
|
||||
<a-tag :color="getStatusColor(currentProject.status)" size="large">
|
||||
{{ getStatusText(currentProject.status) }}
|
||||
</a-tag>
|
||||
<a-tag :color="getWorkTypeColor(currentProject.workType)">
|
||||
{{ WorkTypeMap[currentProject.workType] }}
|
||||
</a-tag>
|
||||
<span class="salary-big">{{ formatSalary(currentProject) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-descriptions :column="2" :labelStyle="{ fontWeight: 500 }">
|
||||
<a-descriptions-item label="工作地点">{{ currentProject.location }}</a-descriptions-item>
|
||||
<a-descriptions-item label="发布时间">{{ formatDate(currentProject.createdAt) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="截止日期">
|
||||
<span :class="{ 'text-danger': isExpiringSoon(currentProject.deadline) }">
|
||||
{{ formatDate(currentProject.deadline) }}
|
||||
</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="联系邮箱">
|
||||
<a :href="'mailto:' + currentProject.contactEmail">{{ currentProject.contactEmail }}</a>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发布人" :span="2">
|
||||
<div class="publisher-cell">
|
||||
<a-avatar :src="currentProject.publisher.avatar" :size="32" />
|
||||
<div class="publisher-info">
|
||||
<span class="name">{{ currentProject.publisher.nickname }}</span>
|
||||
<span class="company" v-if="currentProject.publisher.company">{{ currentProject.publisher.company }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="项目标签" :span="2">
|
||||
<a-tag v-for="tag in currentProject.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">项目描述</a-divider>
|
||||
<div class="detail-content">{{ currentProject.description }}</div>
|
||||
|
||||
<a-divider orientation="left">任职要求</a-divider>
|
||||
<div class="detail-content pre-wrap">{{ currentProject.requirements }}</div>
|
||||
|
||||
<a-divider orientation="left">福利待遇</a-divider>
|
||||
<div class="detail-content">{{ currentProject.benefits }}</div>
|
||||
|
||||
<a-divider orientation="left">曝光数据</a-divider>
|
||||
<a-descriptions :column="2">
|
||||
<a-descriptions-item label="曝光次数">{{ currentProject.exposureCount }} 次</a-descriptions-item>
|
||||
<a-descriptions-item label="最近曝光">
|
||||
{{ currentProject.lastExposureAt ? formatDate(currentProject.lastExposureAt) : '暂无' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="置顶状态">
|
||||
<a-tag v-if="currentProject.isPinned" color="red">
|
||||
置顶中(至 {{ formatDate(currentProject.pinnedUntil || '') }})
|
||||
</a-tag>
|
||||
<span v-else>未置顶</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 置顶弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="pinModalVisible"
|
||||
title="设置项目置顶"
|
||||
:confirm-loading="pinLoading"
|
||||
@ok="handleConfirmPin"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="置顶周期" required>
|
||||
<a-radio-group v-model:value="pinDays">
|
||||
<a-radio-button :value="3">3天</a-radio-button>
|
||||
<a-radio-button :value="7">7天</a-radio-button>
|
||||
<a-radio-button :value="14">14天</a-radio-button>
|
||||
<a-radio-button :value="30">30天</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-alert type="info" show-icon>
|
||||
<template #message>
|
||||
置顶后,项目将在列表中优先展示,曝光率显著提升
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Key } from 'ant-design-vue/es/table/interface'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { SearchOutlined, ReloadOutlined, DeleteOutlined, EyeOutlined, PushpinOutlined, RocketOutlined } from '@ant-design/icons-vue'
|
||||
import type { ProjectInfo, WorkType } from '@/types'
|
||||
import { WorkTypeMap } from '@/types'
|
||||
import { mockGetProjectList, mockDeleteProject, mockBatchDeleteProjects, mockGetLocations, mockSetProjectPin, mockExposeProject } from '@/mock'
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
workType: undefined as WorkType | undefined,
|
||||
location: undefined as string | undefined,
|
||||
status: undefined as string | undefined
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const projectList = ref<ProjectInfo[]>([])
|
||||
const locationList = ref<string[]>([])
|
||||
const selectedRowKeys = ref<Key[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const currentProject = ref<ProjectInfo | null>(null)
|
||||
|
||||
// 置顶相关
|
||||
const pinModalVisible = ref(false)
|
||||
const pinLoading = ref(false)
|
||||
const pinProjectId = ref<number | null>(null)
|
||||
const pinDays = ref(7)
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 表格列
|
||||
const columns = [
|
||||
{ title: '项目名称', key: 'name', width: 200 },
|
||||
{ title: '薪资待遇', key: 'salary', width: 140 },
|
||||
{ title: '发布人', key: 'publisher', width: 140 },
|
||||
{ title: '工作地点', dataIndex: 'location', width: 100 },
|
||||
{ title: '工作类型', key: 'workType', width: 100 },
|
||||
{ title: '标签', key: 'tags', width: 150 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '截止日期', key: 'deadline', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
// 格式化薪资
|
||||
function formatSalary(project: ProjectInfo): string {
|
||||
const min = project.salaryMin >= 1000 ? `${project.salaryMin / 1000}k` : project.salaryMin
|
||||
const max = project.salaryMax >= 1000 ? `${project.salaryMax / 1000}k` : project.salaryMax
|
||||
return `${min}-${max}/${project.salaryUnit}`
|
||||
}
|
||||
|
||||
// 格式化薪资(从record)
|
||||
function formatSalaryFromRecord(record: Record<string, unknown>): string {
|
||||
return formatSalary(record as unknown as ProjectInfo)
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 工作类型颜色
|
||||
function getWorkTypeColor(type: string): string {
|
||||
const colors: Record<WorkType, string> = {
|
||||
fulltime: 'blue', parttime: 'cyan', remote: 'green', internship: 'orange', freelance: 'purple'
|
||||
}
|
||||
return colors[type as WorkType] || 'default'
|
||||
}
|
||||
|
||||
// 工作类型标签
|
||||
function getWorkTypeLabel(type: string): string {
|
||||
return WorkTypeMap[type as WorkType] || type
|
||||
}
|
||||
|
||||
// 状态颜色
|
||||
function getStatusColor(status: string): string {
|
||||
return { active: 'green', closed: 'default', expired: 'red' }[status] || 'default'
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
function getStatusText(status: string): string {
|
||||
return { active: '招聘中', closed: '已关闭', expired: '已过期' }[status] || status
|
||||
}
|
||||
|
||||
// 是否即将过期(7天内)
|
||||
function isExpiringSoon(deadline: string): boolean {
|
||||
const diff = new Date(deadline).getTime() - Date.now()
|
||||
return diff > 0 && diff < 7 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// 是否最近有曝光(24小时内)
|
||||
function isRecentlyExposed(lastExposureAt?: string): boolean {
|
||||
if (!lastExposureAt) return false
|
||||
const diff = Date.now() - new Date(lastExposureAt).getTime()
|
||||
return diff < 24 * 60 * 60 * 1000 // 24小时内
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetProjectList({ ...searchForm, page: pagination.current, pageSize: pagination.pageSize })
|
||||
projectList.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch (e) {
|
||||
message.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
locationList.value = await mockGetLocations()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadProjects()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.assign(searchForm, { keyword: '', workType: undefined, location: undefined, status: undefined })
|
||||
pagination.current = 1
|
||||
loadProjects()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: TablePaginationConfig) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadProjects()
|
||||
}
|
||||
|
||||
function onSelectChange(keys: Key[]) {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
|
||||
function showDetail(record: ProjectInfo | Record<string, unknown>) {
|
||||
currentProject.value = record as ProjectInfo
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await mockDeleteProject(id)
|
||||
message.success('删除成功')
|
||||
loadProjects()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!selectedRowKeys.value.length) return
|
||||
await mockBatchDeleteProjects(selectedRowKeys.value as number[])
|
||||
message.success(`删除 ${selectedRowKeys.value.length} 个项目`)
|
||||
selectedRowKeys.value = []
|
||||
loadProjects()
|
||||
}
|
||||
|
||||
// 显示置顶弹窗
|
||||
function showPinModal(record: ProjectInfo | Record<string, unknown>) {
|
||||
const project = record as ProjectInfo
|
||||
pinProjectId.value = project.id
|
||||
pinDays.value = 7
|
||||
pinModalVisible.value = true
|
||||
}
|
||||
|
||||
// 确认置顶
|
||||
async function handleConfirmPin() {
|
||||
if (!pinProjectId.value) return
|
||||
pinLoading.value = true
|
||||
try {
|
||||
await mockSetProjectPin(pinProjectId.value, true, pinDays.value)
|
||||
message.success(`置顶成功,有效期 ${pinDays.value} 天`)
|
||||
pinModalVisible.value = false
|
||||
loadProjects()
|
||||
} catch (e) {
|
||||
message.error('置顶失败')
|
||||
} finally {
|
||||
pinLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消置顶
|
||||
async function handleCancelPin(id: number) {
|
||||
try {
|
||||
await mockSetProjectPin(id, false)
|
||||
message.success('已取消置顶')
|
||||
loadProjects()
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 手动曝光
|
||||
async function handleExpose(id: number) {
|
||||
try {
|
||||
await mockExposeProject(id)
|
||||
message.success('曝光成功,项目将获得更多展示机会')
|
||||
loadProjects()
|
||||
} catch (e) {
|
||||
message.error('曝光失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLocations()
|
||||
loadProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.project-manage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.table-card .ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-table-pagination) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.salary {
|
||||
color: #f5222d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.publisher-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.publisher-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.publisher-info .name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.publisher-info .company {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
/* 详情样式 */
|
||||
.detail-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.salary-big {
|
||||
font-size: 18px;
|
||||
color: #f5222d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
570
src/views/project/recruitment/index.vue
Normal file
570
src/views/project/recruitment/index.vue
Normal file
@@ -0,0 +1,570 @@
|
||||
<template>
|
||||
<div class="recruitment-manage">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic title="总申请数" :value="stats.total" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card pending">
|
||||
<a-statistic title="待回复" :value="stats.pending" :value-style="{ color: '#faad14' }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card approved">
|
||||
<a-statistic title="已接受" :value="stats.approved" :value-style="{ color: '#52c41a' }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card rejected">
|
||||
<a-statistic title="已拒绝" :value="stats.rejected" :value-style="{ color: '#ff4d4f' }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<a-card class="search-card" :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="项目名/申请人" allow-clear style="width: 160px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="项目">
|
||||
<a-select v-model:value="searchForm.projectId" placeholder="选择项目" allow-clear style="width: 180px">
|
||||
<a-select-option v-for="p in projectList" :key="p.id" :value="p.id">{{ p.name }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="选择状态" allow-clear style="width: 110px">
|
||||
<a-select-option v-for="(label, key) in RecruitmentStatusMap" :key="key" :value="key">{{ label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch"><SearchOutlined /> 搜索</a-button>
|
||||
<a-button @click="handleReset"><ReloadOutlined /> 重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>申请列表</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
|
||||
<DeleteOutlined /> 批量删除 {{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
|
||||
row-key="id"
|
||||
:scroll="{ y: 'calc(100vh - 520px)' }"
|
||||
size="small"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 申请人 -->
|
||||
<template v-if="column.key === 'applicant'">
|
||||
<div class="applicant-cell">
|
||||
<a-avatar :src="record.applicant.avatar" :size="32" />
|
||||
<div class="applicant-info">
|
||||
<span class="name">{{ record.applicant.nickname }}</span>
|
||||
<span class="exp">{{ record.applicant.experience }}经验</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 项目 -->
|
||||
<template v-else-if="column.key === 'project'">
|
||||
<div class="project-cell">
|
||||
<span class="project-name">{{ record.project.name }}</span>
|
||||
<span class="project-location">{{ record.project.location }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 发布人 -->
|
||||
<template v-else-if="column.key === 'publisher'">
|
||||
<div class="publisher-cell">
|
||||
<a-avatar :src="record.publisher.avatar" :size="28" />
|
||||
<div class="publisher-info">
|
||||
<span class="name">
|
||||
{{ record.publisher.nickname }}
|
||||
<CheckCircleFilled v-if="record.publisher.verified" class="verified-icon" />
|
||||
</span>
|
||||
<span class="company">{{ record.publisher.company || record.publisher.position }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 期望薪资 -->
|
||||
<template v-else-if="column.key === 'expectedSalary'">
|
||||
<span class="salary">{{ (record.expectedSalary / 1000).toFixed(0) }}k</span>
|
||||
</template>
|
||||
|
||||
<!-- 技能 -->
|
||||
<template v-else-if="column.key === 'skills'">
|
||||
<a-tag v-for="skill in record.applicant.skills.slice(0, 3)" :key="skill" size="small">{{ skill }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">{{ getStatusLabel(record.status) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 申请时间 -->
|
||||
<template v-else-if="column.key === 'appliedAt'">
|
||||
{{ formatDate(record.appliedAt) }}
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="showDetail(record)"><EyeOutlined /> 查看</a-button>
|
||||
<a-popconfirm title="确定删除此记录?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger><DeleteOutlined /> 删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer v-model:open="detailVisible" title="申请详情" width="680" placement="right">
|
||||
<template v-if="currentRecord">
|
||||
<!-- 申请人信息 -->
|
||||
<div class="detail-section">
|
||||
<h3><UserOutlined /> 申请人信息</h3>
|
||||
<div class="applicant-header">
|
||||
<a-avatar :src="currentRecord.applicant.avatar" :size="64" />
|
||||
<div class="applicant-main">
|
||||
<h2>{{ currentRecord.applicant.nickname }}</h2>
|
||||
<p>@{{ currentRecord.applicant.username }} · {{ currentRecord.applicant.experience }}经验</p>
|
||||
</div>
|
||||
<a-tag :color="getStatusColor(currentRecord.status)" size="large">
|
||||
{{ RecruitmentStatusMap[currentRecord.status] }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<a-descriptions :column="2" size="small" class="mt-12">
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.applicant.email }}</a-descriptions-item>
|
||||
<a-descriptions-item label="电话">{{ currentRecord.applicant.phone || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="技能标签" :span="2">
|
||||
<a-tag v-for="skill in currentRecord.applicant.skills" :key="skill" color="blue">{{ skill }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="作品集" :span="2">
|
||||
<a v-if="currentRecord.applicant.portfolioUrl" :href="currentRecord.applicant.portfolioUrl" target="_blank">
|
||||
{{ currentRecord.applicant.portfolioUrl }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="info-block">
|
||||
<label>个人简介</label>
|
||||
<p>{{ currentRecord.applicant.introduction }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 发布人信息 -->
|
||||
<div class="detail-section">
|
||||
<h3><TeamOutlined /> 发布人信息</h3>
|
||||
<div class="publisher-header">
|
||||
<a-avatar :src="currentRecord.publisher.avatar" :size="48" />
|
||||
<div class="publisher-main">
|
||||
<h4>
|
||||
{{ currentRecord.publisher.nickname }}
|
||||
<CheckCircleFilled v-if="currentRecord.publisher.verified" class="verified-icon" />
|
||||
</h4>
|
||||
<p>{{ currentRecord.publisher.company || '' }} {{ currentRecord.publisher.position }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="2" size="small" class="mt-12">
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.publisher.email }}</a-descriptions-item>
|
||||
<a-descriptions-item label="电话">{{ currentRecord.publisher.phone || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 申请的项目 -->
|
||||
<div class="detail-section">
|
||||
<h3><ProjectOutlined /> 申请项目</h3>
|
||||
<a-descriptions :column="2" size="small">
|
||||
<a-descriptions-item label="项目名称" :span="2">{{ currentRecord.project.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="工作地点">{{ currentRecord.project.location }}</a-descriptions-item>
|
||||
<a-descriptions-item label="工作类型">{{ currentRecord.project.workType }}</a-descriptions-item>
|
||||
<a-descriptions-item label="项目薪资">
|
||||
<span class="salary">{{ currentRecord.project.salaryMin/1000 }}k-{{ currentRecord.project.salaryMax/1000 }}k/{{ currentRecord.project.salaryUnit }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 申请详情 -->
|
||||
<div class="detail-section">
|
||||
<h3><FormOutlined /> 申请详情</h3>
|
||||
<a-descriptions :column="2" size="small">
|
||||
<a-descriptions-item label="期望薪资">
|
||||
<span class="salary">{{ currentRecord.expectedSalary/1000 }}k</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="可到岗日期">{{ formatDate(currentRecord.availableDate) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="申请时间">{{ formatDateTime(currentRecord.appliedAt) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="处理时间">{{ currentRecord.processedAt ? formatDateTime(currentRecord.processedAt) : '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="info-block">
|
||||
<label>申请说明</label>
|
||||
<p>{{ currentRecord.coverLetter }}</p>
|
||||
</div>
|
||||
<div v-if="currentRecord.rejectReason" class="info-block reject">
|
||||
<label>拒绝原因</label>
|
||||
<p>{{ currentRecord.rejectReason }}</p>
|
||||
</div>
|
||||
<div v-if="currentRecord.remark" class="info-block">
|
||||
<label>备注</label>
|
||||
<p>{{ currentRecord.remark }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Key } from 'ant-design-vue/es/table/interface'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { SearchOutlined, ReloadOutlined, DeleteOutlined, EyeOutlined, UserOutlined, ProjectOutlined, FormOutlined, TeamOutlined, CheckCircleFilled } from '@ant-design/icons-vue'
|
||||
import type { RecruitmentRecord, RecruitmentProject, RecruitmentStats, RecruitmentStatus } from '@/types'
|
||||
import { RecruitmentStatusMap } from '@/types'
|
||||
import { mockGetRecruitmentList, mockGetRecruitmentStats, mockGetRecruitmentProjects, mockDeleteRecruitment, mockBatchDeleteRecruitments } from '@/mock'
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
projectId: undefined as number | undefined,
|
||||
status: undefined as RecruitmentStatus | undefined
|
||||
})
|
||||
const loading = ref(false)
|
||||
const list = ref<RecruitmentRecord[]>([])
|
||||
const projectList = ref<RecruitmentProject[]>([])
|
||||
const stats = ref<RecruitmentStats>({ total: 0, pending: 0, approved: 0, rejected: 0, todayNew: 0 })
|
||||
const selectedRowKeys = ref<Key[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<RecruitmentRecord | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '申请人', key: 'applicant', width: 140 },
|
||||
{ title: '申请项目', key: 'project', width: 160 },
|
||||
{ title: '发布人', key: 'publisher', width: 150 },
|
||||
{ title: '期望薪资', key: 'expectedSalary', width: 90 },
|
||||
{ title: '技能', key: 'skills', width: 160 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '申请时间', key: 'appliedAt', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 110, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
const colors: Record<RecruitmentStatus, string> = {
|
||||
pending: 'orange', approved: 'green', rejected: 'red', withdrawn: 'default', expired: 'default'
|
||||
}
|
||||
return colors[status as RecruitmentStatus] || 'default'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
return RecruitmentStatusMap[status as RecruitmentStatus] || status
|
||||
}
|
||||
|
||||
function formatDate(str: string): string {
|
||||
return new Date(str).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDateTime(str: string): string {
|
||||
return new Date(str).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [res, statsRes] = await Promise.all([
|
||||
mockGetRecruitmentList({ ...searchForm, page: pagination.current, pageSize: pagination.pageSize }),
|
||||
mockGetRecruitmentStats()
|
||||
])
|
||||
list.value = res.list
|
||||
pagination.total = res.total
|
||||
stats.value = statsRes
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
projectList.value = await mockGetRecruitmentProjects()
|
||||
}
|
||||
|
||||
function handleSearch() { pagination.current = 1; loadData() }
|
||||
function handleReset() {
|
||||
Object.assign(searchForm, { keyword: '', projectId: undefined, status: undefined })
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
function handleTableChange(pag: TablePaginationConfig) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadData()
|
||||
}
|
||||
function onSelectChange(keys: Key[]) { selectedRowKeys.value = keys }
|
||||
function showDetail(record: RecruitmentRecord | Record<string, unknown>) { currentRecord.value = record as RecruitmentRecord; detailVisible.value = true }
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await mockDeleteRecruitment(id)
|
||||
message.success('已删除')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!selectedRowKeys.value.length) return
|
||||
await mockBatchDeleteRecruitments(selectedRowKeys.value as number[])
|
||||
message.success(`已删除 ${selectedRowKeys.value.length} 条记录`)
|
||||
selectedRowKeys.value = []
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => { loadProjects(); loadData() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recruitment-manage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card :deep(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-card :deep(.ant-statistic-content) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-card :deep(.ant-card-body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-card :deep(.ant-card-head) {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.table-card :deep(.ant-card-head-title) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.table-card :deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-pagination) {
|
||||
margin: 8px 0 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.applicant-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.applicant-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.applicant-info .name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.applicant-info .exp {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.project-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-cell .project-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-cell .project-location {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 发布人单元格 */
|
||||
.publisher-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.publisher-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.publisher-info .name {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.publisher-info .company {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
color: #1890ff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 发布人详情头部 */
|
||||
.publisher-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.publisher-main h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.publisher-main p {
|
||||
margin: 4px 0 0;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.salary {
|
||||
color: #f5222d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 详情样式 */
|
||||
.detail-section h3 {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.applicant-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.applicant-main h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.applicant-main p {
|
||||
margin: 4px 0 0;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-block label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.info-block p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-block.reject {
|
||||
background: #fff2f0;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
134
src/views/project/session/components/InviteModal.vue
Normal file
134
src/views/project/session/components/InviteModal.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="添加项目成员"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
width="600px"
|
||||
>
|
||||
<div class="invite-form">
|
||||
<div class="form-item">
|
||||
<span class="label">岗位角色:</span>
|
||||
<a-select v-model:value="selectedRole" style="width: 200px">
|
||||
<a-select-option v-for="(name, role) in ProjectRoleMap" :key="role" :value="role">
|
||||
{{ name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div class="section-title">候选人列表 (已通过招募)</div>
|
||||
|
||||
<a-list :data-source="candidates" :loading="loadingList" item-layout="horizontal" class="candidate-list">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleInvite(item)"
|
||||
:disabled="invitedIds.has(item.id)"
|
||||
>
|
||||
{{ invitedIds.has(item.id) ? '已添加' : '添加' }}
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta :description="item.role">
|
||||
<template #title>
|
||||
{{ item.name }}
|
||||
</template>
|
||||
<template #avatar>
|
||||
<a-avatar :src="item.avatar" />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ProjectRoleMap } from '@/types'
|
||||
import type { ProjectRole } from '@/types'
|
||||
import { mockGetInvitableCandidates, mockInviteMember } from '@/mock/projectSession'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
projectId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:open', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingList = ref(false)
|
||||
const selectedRole = ref<ProjectRole>('frontend')
|
||||
const candidates = ref<any[]>([])
|
||||
const invitedIds = ref<Set<number>>(new Set())
|
||||
|
||||
watch(() => props.open, (val) => {
|
||||
if (val) {
|
||||
loadCandidates()
|
||||
invitedIds.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadCandidates() {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const list = await mockGetInvitableCandidates(props.projectId)
|
||||
candidates.value = list
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loadingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvite(user: any) {
|
||||
loading.value = true
|
||||
try {
|
||||
await mockInviteMember(props.projectId, user.id, selectedRole.value)
|
||||
message.success(`已添加 ${user.name}`)
|
||||
invitedIds.value.add(user.id)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invite-form {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.candidate-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
</style>
|
||||
217
src/views/project/session/detail.vue
Normal file
217
src/views/project/session/detail.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="session-detail-page">
|
||||
<a-page-header
|
||||
:title="session?.projectInfo.name"
|
||||
sub-title="会话详情管理"
|
||||
@back="$router.back()"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button key="refresh" @click="loadData">刷新</a-button>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div v-if="loading" class="loading-box">
|
||||
<a-spin />
|
||||
</div>
|
||||
|
||||
<div v-else-if="session" class="content-container">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="overview" tab="项目概览">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="16">
|
||||
<a-card title="项目开发进度" :bordered="false" class="mb-4">
|
||||
<a-steps :current="currentStepIdx">
|
||||
<a-step
|
||||
v-for="node in session.progressNodes"
|
||||
:key="node.id"
|
||||
:title="node.title"
|
||||
:description="node.completedAt || node.description"
|
||||
/>
|
||||
</a-steps>
|
||||
<div class="step-actions" style="margin-top: 24px; text-align: right;">
|
||||
<a-button type="primary" size="small">更新进度(模拟)</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="基本信息" :bordered="false">
|
||||
<a-descriptions bordered>
|
||||
<a-descriptions-item label="分类">{{ session.projectInfo.workType }}</a-descriptions-item>
|
||||
<a-descriptions-item label="薪资">{{ session.projectInfo.salaryMin }}-{{ session.projectInfo.salaryMax }} {{ session.projectInfo.salaryUnit }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地点">{{ session.projectInfo.location }}</a-descriptions-item>
|
||||
<a-descriptions-item label="发布人">{{ session.projectInfo.publisher.nickname }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ session.projectInfo.contactEmail }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatDate(session.projectInfo.createdAt) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="3">
|
||||
{{ session.projectInfo.description }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="要求" :span="3">
|
||||
{{ session.projectInfo.requirements }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-card title="资料文件" :bordered="false">
|
||||
<a-list :data-source="session.materials" size="small">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :description="`${item.type} · ${item.size}`">
|
||||
<template #title>
|
||||
<a :href="item.url" target="_blank">{{ item.name }}</a>
|
||||
</template>
|
||||
<template #avatar>
|
||||
<FileOutlined />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="team" tab="团队成员">
|
||||
<a-card :bordered="false">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="showInvite = true">
|
||||
<PlusOutlined /> 添加成员
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table :data-source="session.members" row-key="id" :pagination="false">
|
||||
<a-table-column title="成员" key="member">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-avatar :src="record.info.avatar" />
|
||||
<span>{{ record.info.nickname }}</span>
|
||||
<a-tag v-if="record.isLeader" color="blue">负责人</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="角色" key="role" data-index="role">
|
||||
<template #default="{ text }">
|
||||
{{ ProjectRoleMap[text] }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="加入时间" key="joinedAt">
|
||||
<template #default="{ record }">
|
||||
{{ formatDate(record.joinedAt) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" key="status">
|
||||
<template #default="{ record }">
|
||||
<a-badge :status="record.status === 'online' ? 'success' : 'default'" :text="record.status === 'online' ? '在线' : '离线'" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" key="action">
|
||||
<template #default="{ record }">
|
||||
<a-popconfirm title="确定移除该成员?" @confirm="message.success('模拟移除成功')">
|
||||
<a-button type="link" danger size="small" :disabled="record.isLeader">移除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="messages" tab="会话历史">
|
||||
<a-card :bordered="false">
|
||||
<a-table :data-source="session.messages" row-key="id">
|
||||
<a-table-column title="发送人" key="sender">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-avatar :src="record.senderAvatar" :size="24" />
|
||||
<span>{{ record.senderName }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="角色" data-index="senderRole">
|
||||
<template #default="{ text }">
|
||||
{{ ProjectRoleMap[text] || text }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="内容" data-index="content" />
|
||||
<a-table-column title="时间" data-index="sentAt">
|
||||
<template #default="{ text }">
|
||||
{{ formatDateTime(text) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<InviteModal
|
||||
v-if="session"
|
||||
v-model:open="showInvite"
|
||||
:project-id="session.projectId"
|
||||
@success="handleInviteSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { FileOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { mockGetProjectSession } from '@/mock/projectSession'
|
||||
import type { ProjectSession } from '@/types'
|
||||
import { ProjectRoleMap } from '@/types'
|
||||
import { formatDate, formatDateTime } from '@/utils/common'
|
||||
import InviteModal from './components/InviteModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const session = ref<ProjectSession | null>(null)
|
||||
const activeTab = ref('overview')
|
||||
const showInvite = ref(false)
|
||||
|
||||
const currentStepIdx = computed(() => {
|
||||
if (!session.value) return 0
|
||||
return session.value.progressNodes.findIndex(n => n.status === 'processing')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
const id = Number(route.params.id)
|
||||
if (!id) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
session.value = await mockGetProjectSession(id)
|
||||
} catch (e) {
|
||||
message.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleInviteSuccess() {
|
||||
loadData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.session-detail-page {
|
||||
padding: 16px;
|
||||
background: #f0f2f5;
|
||||
min-height: 100%;
|
||||
}
|
||||
.loading-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.content-container {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
175
src/views/project/session/index.vue
Normal file
175
src/views/project/session/index.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="session-list-page">
|
||||
<a-page-header title="项目会话管理" sub-title="管理项目即时通讯及开发进度" />
|
||||
|
||||
<a-card>
|
||||
<div class="table-operations" style="margin-bottom: 16px;">
|
||||
<a-form layout="inline" :model="queryParams">
|
||||
<a-form-item>
|
||||
<a-input v-model:value="queryParams.keyword" placeholder="项目名称/负责人" allow-clear>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearch">查询</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="sessionList"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'projectName'">
|
||||
<a-button type="link" @click="handleViewDetail(record as any)">
|
||||
{{ record.projectInfo.name }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'leader'">
|
||||
<div v-if="getLeader(record as any)" class="leader-cell">
|
||||
<a-avatar :src="getLeader(record as any)?.info.avatar" :size="24" />
|
||||
<span>{{ getLeader(record as any)?.info.nickname }}</span>
|
||||
</div>
|
||||
<span v-else class="text-secondary">未设置</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'members'">
|
||||
<a-avatar-group :max-count="3">
|
||||
<a-avatar
|
||||
v-for="member in record.members"
|
||||
:key="member.id"
|
||||
:src="member.info.avatar"
|
||||
/>
|
||||
</a-avatar-group>
|
||||
<span style="margin-left: 8px; color: #8c8c8c;">({{ record.members.length }}人)</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<a-tag :color="getLatestNode(record as any)?.status === 'completed' ? 'green' : 'blue'">
|
||||
{{ getLatestNode(record as any)?.title || '未开始' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record as any)">管理</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SearchOutlined } from '@ant-design/icons-vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { mockGetRecruitmentProjects } from '@/mock/recruitment'
|
||||
import { mockGetProjectSession } from '@/mock/projectSession'
|
||||
import type { ProjectSession, ProjectMember } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const sessionList = ref<ProjectSession[]>([])
|
||||
const queryParams = reactive({
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
const pagination = reactive<TablePaginationConfig>({
|
||||
total: 0,
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '项目名称', key: 'projectName', dataIndex: ['projectInfo', 'name'] },
|
||||
{ title: '负责人', key: 'leader', width: 200 },
|
||||
{ title: '团队成员', key: 'members', width: 200 },
|
||||
{ title: '当前进度', key: 'progress', width: 150 },
|
||||
{ title: '在线人数', dataIndex: 'onlineCount', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 100 }
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 模拟:先获取项目列表,再获取每个项目的会话信息
|
||||
// 实际后端应提供 /api/project/sessions 列表接口
|
||||
const projects = await mockGetRecruitmentProjects()
|
||||
const list: ProjectSession[] = []
|
||||
|
||||
// 简单过滤
|
||||
const keyword = queryParams.keyword.toLowerCase()
|
||||
|
||||
for (const p of projects) {
|
||||
if (keyword && !p.name.toLowerCase().includes(keyword)) continue
|
||||
|
||||
try {
|
||||
const session = await mockGetProjectSession(p.id)
|
||||
list.push(session)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
sessionList.value = list
|
||||
pagination.total = list.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: TablePaginationConfig) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
// loadData() // 纯前端分页暂不需要重新加载
|
||||
}
|
||||
|
||||
function handleViewDetail(record: ProjectSession) {
|
||||
router.push(`/project/sessions/${record.id}`)
|
||||
}
|
||||
|
||||
function getLeader(session: ProjectSession): ProjectMember | undefined {
|
||||
return session.members.find(m => m.isLeader)
|
||||
}
|
||||
|
||||
function getLatestNode(session: ProjectSession) {
|
||||
// 找最后一个完成的,或者正在进行的
|
||||
const processing = session.progressNodes.find(n => n.status === 'processing')
|
||||
if (processing) return processing
|
||||
|
||||
// 找最后一个完成的
|
||||
const completed = [...session.progressNodes].reverse().find(n => n.status === 'completed')
|
||||
return completed
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.leader-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.text-secondary {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
</style>
|
||||
571
src/views/project/signed/index.vue
Normal file
571
src/views/project/signed/index.vue
Normal file
@@ -0,0 +1,571 @@
|
||||
<template>
|
||||
<div class="signed-project-page">
|
||||
<a-page-header title="已成交项目" sub-title="管理平台已签约和执行中的项目">
|
||||
<template #tags>
|
||||
<a-tag color="blue">进行中 {{ stats.inProgress }}</a-tag>
|
||||
<a-tag color="green">已完成 {{ stats.completed }}</a-tag>
|
||||
<a-tag color="red">争议处理 {{ stats.disputed }}</a-tag>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-icon"><ProjectOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-title">总项目数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-progress">
|
||||
<div class="stat-icon"><SyncOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.inProgress }}</div>
|
||||
<div class="stat-title">进行中</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-completed">
|
||||
<div class="stat-icon"><CheckCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.completed }}</div>
|
||||
<div class="stat-title">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-disputed">
|
||||
<div class="stat-icon"><ExclamationCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.disputed }}</div>
|
||||
<div class="stat-title">争议处理</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-amount">
|
||||
<div class="stat-icon"><PayCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">¥{{ formatAmount(stats.totalAmount) }}</div>
|
||||
<div class="stat-title">总金额</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-paid">
|
||||
<div class="stat-icon"><WalletOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">¥{{ formatAmount(stats.paidAmount) }}</div>
|
||||
<div class="stat-title">已支付</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<a-card class="filter-card">
|
||||
<div class="search-bar">
|
||||
<a-form layout="inline" class="search-form">
|
||||
<a-form-item>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索项目名 / 合同号 / 人员"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="filterStatus"
|
||||
placeholder="项目状态"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option v-for="(label, key) in ProjectProgressStatusMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
style="width: 240px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" @click="handleSearch">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-card class="main-card" :loading="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="projectList"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'project'">
|
||||
<div class="project-cell">
|
||||
<div class="project-name">{{ record.projectName }}</div>
|
||||
<div class="contract-no">{{ record.contract.contractNo }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'parties'">
|
||||
<div class="parties-cell">
|
||||
<div class="party">
|
||||
<a-avatar :src="record.publisher.avatar" :size="24" />
|
||||
<span>{{ record.publisher.nickname }}</span>
|
||||
<a-tag size="small" color="blue">甲方</a-tag>
|
||||
</div>
|
||||
<div class="party">
|
||||
<a-avatar :src="record.contractor.avatar" :size="24" />
|
||||
<span>{{ record.contractor.nickname }}</span>
|
||||
<a-tag size="small" color="green">乙方</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
<div class="amount-cell">
|
||||
<div class="total-amount">¥{{ record.contractAmount.toLocaleString() }}</div>
|
||||
<div class="paid-info">
|
||||
已付 ¥{{ record.paidAmount.toLocaleString() }}
|
||||
<span v-if="record.pendingAmount > 0" class="pending">
|
||||
· 待付 ¥{{ record.pendingAmount.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<div class="progress-cell">
|
||||
<a-badge
|
||||
:status="ProjectProgressStatusBadgeMap[record.progressStatus as ProjectProgressStatus]"
|
||||
:text="ProjectProgressStatusMap[record.progressStatus as ProjectProgressStatus]"
|
||||
/>
|
||||
<a-progress :percent="record.progressPercent" :size="60" type="circle" :width="45" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'signedAt'">
|
||||
{{ formatDateTime(record.signedAt) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="showDetail(record as SignedProjectRecord)">
|
||||
<EyeOutlined /> 详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
:title="currentRecord ? currentRecord.projectName : ''"
|
||||
width="720"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="currentRecord">
|
||||
<!-- 项目基本信息 -->
|
||||
<a-descriptions :column="2" bordered size="small" title="项目信息">
|
||||
<a-descriptions-item label="项目名称" :span="2">
|
||||
{{ currentRecord.projectName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同编号">
|
||||
{{ currentRecord.contract.contractNo }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="项目状态">
|
||||
<a-badge
|
||||
:status="ProjectProgressStatusBadgeMap[currentRecord.progressStatus]"
|
||||
:text="ProjectProgressStatusMap[currentRecord.progressStatus]"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="工作类型">
|
||||
{{ getWorkTypeLabel(currentRecord.workType) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="工作地点">
|
||||
{{ currentRecord.location }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 双方信息 -->
|
||||
<a-divider>签约双方</a-divider>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<div class="party-card">
|
||||
<div class="party-header">
|
||||
<a-avatar :src="currentRecord.publisher.avatar" :size="48" />
|
||||
<div class="party-info">
|
||||
<div class="party-name">
|
||||
{{ currentRecord.publisher.nickname }}
|
||||
<CheckCircleFilled v-if="currentRecord.publisher.verified" class="verified" />
|
||||
</div>
|
||||
<div class="party-role">甲方(发布人)</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="公司">{{ currentRecord.publisher.company || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.publisher.email }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="party-card">
|
||||
<div class="party-header">
|
||||
<a-avatar :src="currentRecord.contractor.avatar" :size="48" />
|
||||
<div class="party-info">
|
||||
<div class="party-name">
|
||||
{{ currentRecord.contractor.nickname }}
|
||||
<CheckCircleFilled v-if="currentRecord.contractor.verified" class="verified" />
|
||||
</div>
|
||||
<div class="party-role">乙方(签约人)</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="职位">{{ currentRecord.contractor.position || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.contractor.email }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 合同信息 -->
|
||||
<a-divider>合同信息</a-divider>
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="合同金额">
|
||||
<span class="amount-highlight">¥{{ currentRecord.contractAmount.toLocaleString() }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="已支付">
|
||||
¥{{ currentRecord.paidAmount.toLocaleString() }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="签约时间">
|
||||
{{ formatDateTime(currentRecord.signedAt) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同周期">
|
||||
{{ formatDate(currentRecord.contract.startDate) }} - {{ formatDate(currentRecord.contract.endDate) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同附件" :span="2">
|
||||
<a v-if="currentRecord.contract.attachmentUrl" :href="currentRecord.contract.attachmentUrl" target="_blank">
|
||||
<FileOutlined /> {{ currentRecord.contract.attachmentName }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 项目进度 -->
|
||||
<a-divider>项目进度 ({{ currentRecord.progressPercent }}%)</a-divider>
|
||||
<a-progress :percent="currentRecord.progressPercent" :stroke-color="getProgressColor(currentRecord.progressPercent)" />
|
||||
|
||||
<a-timeline class="milestone-timeline">
|
||||
<a-timeline-item
|
||||
v-for="milestone in currentRecord.milestones"
|
||||
:key="milestone.id"
|
||||
:color="getMilestoneColor(milestone.status)"
|
||||
>
|
||||
<div class="milestone-item">
|
||||
<div class="milestone-header">
|
||||
<span class="milestone-title">{{ milestone.title }}</span>
|
||||
<a-tag :color="getMilestoneTagColor(milestone.status)" size="small">
|
||||
{{ getMilestoneStatusText(milestone.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="milestone-desc">{{ milestone.description }}</div>
|
||||
<div class="milestone-date">
|
||||
计划: {{ formatDate(milestone.plannedDate) }}
|
||||
<span v-if="milestone.actualDate"> · 实际: {{ formatDate(milestone.actualDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import {
|
||||
ProjectOutlined,
|
||||
SyncOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PayCircleOutlined,
|
||||
WalletOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleFilled,
|
||||
FileOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { SignedProjectRecord, ProjectProgressStatus } from '@/types'
|
||||
import { ProjectProgressStatusMap, ProjectProgressStatusBadgeMap } from '@/types'
|
||||
import { mockGetSignedProjectStats, mockGetSignedProjectList } from '@/mock'
|
||||
import { formatDateTime } from '@/utils/common'
|
||||
|
||||
const loading = ref(false)
|
||||
const projectList = ref<SignedProjectRecord[]>([])
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
inProgress: 0,
|
||||
completed: 0,
|
||||
disputed: 0,
|
||||
totalAmount: 0,
|
||||
paidAmount: 0
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const filterStatus = ref<ProjectProgressStatus | undefined>()
|
||||
const dateRange = ref<[Dayjs, Dayjs] | undefined>()
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<SignedProjectRecord | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ title: '项目', key: 'project', width: 200 },
|
||||
{ title: '签约双方', key: 'parties', width: 200 },
|
||||
{ title: '合同金额', key: 'amount', width: 180 },
|
||||
{ title: '项目进度', key: 'progress', width: 150 },
|
||||
{ title: '签约时间', key: 'signedAt', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 100 }
|
||||
]
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 10000) {
|
||||
return (amount / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return amount.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(str: string): string {
|
||||
return new Date(str).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function getWorkTypeLabel(workType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
remote: '远程',
|
||||
fulltime: '全职',
|
||||
parttime: '兼职',
|
||||
freelance: '自由职业'
|
||||
}
|
||||
return labels[workType] || workType
|
||||
}
|
||||
|
||||
function getProgressColor(percent: number): string {
|
||||
if (percent >= 80) return '#52c41a'
|
||||
if (percent >= 50) return '#1890ff'
|
||||
return '#faad14'
|
||||
}
|
||||
|
||||
function getMilestoneColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
completed: 'green',
|
||||
in_progress: 'blue',
|
||||
pending: 'gray',
|
||||
delayed: 'red'
|
||||
}
|
||||
return colors[status] || 'gray'
|
||||
}
|
||||
|
||||
function getMilestoneTagColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
completed: 'success',
|
||||
in_progress: 'processing',
|
||||
pending: 'default',
|
||||
delayed: 'error'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
function getMilestoneStatusText(status: string): string {
|
||||
const texts: Record<string, string> = {
|
||||
completed: '已完成',
|
||||
in_progress: '进行中',
|
||||
pending: '待开始',
|
||||
delayed: '已延期'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await mockGetSignedProjectStats()
|
||||
Object.assign(stats, data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetSignedProjectList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
progressStatus: filterStatus.value,
|
||||
startDate: dateRange.value?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange.value?.[1]?.format('YYYY-MM-DD')
|
||||
})
|
||||
projectList.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchKeyword.value = ''
|
||||
filterStatus.value = undefined
|
||||
dateRange.value = undefined
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current?: number; pageSize?: number }) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadData()
|
||||
}
|
||||
|
||||
function showDetail(record: SignedProjectRecord) {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signed-project-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-total { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.stat-progress { background: linear-gradient(135deg, #4facfe, #00f2fe); }
|
||||
.stat-completed { background: linear-gradient(135deg, #11998e, #38ef7d); }
|
||||
.stat-disputed { background: linear-gradient(135deg, #ff416c, #ff4b2b); }
|
||||
.stat-amount { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||
.stat-paid { background: linear-gradient(135deg, #fa709a, #fee140); }
|
||||
|
||||
.stat-icon { font-size: 28px; opacity: 0.9; }
|
||||
.stat-content { flex: 1; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; }
|
||||
.stat-title { font-size: 13px; opacity: 0.85; margin-top: 2px; }
|
||||
|
||||
.filter-card, .main-card { border-radius: 12px; }
|
||||
|
||||
.search-bar { padding: 8px; }
|
||||
.search-form { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.search-form :deep(.ant-form-item) { margin-bottom: 0; margin-right: 0; }
|
||||
|
||||
.project-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.project-name { font-weight: 600; color: #1a1a2e; }
|
||||
.contract-no { font-size: 12px; color: #8c8c8c; font-family: monospace; }
|
||||
|
||||
.parties-cell { display: flex; flex-direction: column; gap: 8px; }
|
||||
.party { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
|
||||
.amount-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.total-amount { font-size: 15px; font-weight: 600; color: #f5222d; }
|
||||
.paid-info { font-size: 12px; color: #52c41a; }
|
||||
.pending { color: #faad14; }
|
||||
|
||||
.progress-cell { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* 详情样式 */
|
||||
.party-card {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.party-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.party-info { flex: 1; }
|
||||
.party-name { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.party-role { font-size: 12px; color: #8c8c8c; margin-top: 2px; }
|
||||
.verified { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.amount-highlight { font-size: 18px; font-weight: 700; color: #f5222d; }
|
||||
|
||||
.milestone-timeline { margin-top: 16px; }
|
||||
.milestone-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.milestone-header { display: flex; align-items: center; gap: 8px; }
|
||||
.milestone-title { font-weight: 500; }
|
||||
.milestone-desc { font-size: 13px; color: #666; }
|
||||
.milestone-date { font-size: 12px; color: #8c8c8c; }
|
||||
</style>
|
||||
832
src/views/support/conversations/index.vue
Normal file
832
src/views/support/conversations/index.vue
Normal file
@@ -0,0 +1,832 @@
|
||||
<template>
|
||||
<div class="support-page">
|
||||
<a-page-header title="客服管理" sub-title="聚焦前台全渠道会话,追踪实时处理效率">
|
||||
<template #tags>
|
||||
<a-tag color="volcano">待接入 {{ metrics.waitingCount }}</a-tag>
|
||||
<a-tag color="blue">进行中 {{ metrics.activeCount }}</a-tag>
|
||||
<a-tag color="purple">待跟进 {{ metrics.pendingCount }}</a-tag>
|
||||
<a-tag color="green">今日结单 {{ metrics.resolvedToday }}</a-tag>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<span class="metric-extra">满意度 {{ metrics.satisfaction }}/5</span>
|
||||
<span class="metric-extra">首响 {{ metrics.avgFirstResponse }} 分钟</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-row :gutter="16" class="metric-row">
|
||||
<a-col v-for="card in overviewCards" :key="card.key" :span="6">
|
||||
<a-card class="metric-card">
|
||||
<div class="metric-header">
|
||||
<component :is="card.icon" :style="{ color: card.color }" class="metric-icon" />
|
||||
<span class="metric-title">{{ card.title }}</span>
|
||||
</div>
|
||||
<div class="metric-value">{{ card.value }}</div>
|
||||
<div class="metric-desc">{{ card.desc }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card class="filter-card" :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="搜索会话编码/客户/客服" allow-clear style="width: 220px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="全部状态" style="width: 140px">
|
||||
<a-select-option v-for="option in statusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="渠道">
|
||||
<a-select v-model:value="searchForm.channel" placeholder="全部渠道" style="width: 140px">
|
||||
<a-select-option v-for="option in channelOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="优先级">
|
||||
<a-select v-model:value="searchForm.priority" placeholder="全部优先级" style="width: 140px">
|
||||
<a-select-option v-for="option in priorityOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="日期范围">
|
||||
<a-range-picker v-model:value="searchForm.dateRange" value-format="YYYY-MM-DD" format="YYYY-MM-DD"
|
||||
style="width: 280px" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-switch v-model:checked="searchForm.vipOnly" /> 仅看 VIP
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<SearchOutlined /> 搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>会话列表</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" :disabled="!selectedRowKeys.length" :loading="batchResolving"
|
||||
@click="handleBatchResolve">
|
||||
<CheckCircleOutlined /> 批量结单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-table row-key="id" size="middle" :columns="columns" :data-source="conversationList" :loading="loading"
|
||||
:pagination="pagination" :row-selection="rowSelection" :scroll="{ y: 'calc(100vh - 420px)' }"
|
||||
@change="handleTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'session'">
|
||||
<div class="session-cell">
|
||||
<div class="session-top">
|
||||
<span class="session-code">{{ record.sessionCode }}</span>
|
||||
<a-tag :color="getChannelInfo(record.channel).color">{{ getChannelInfo(record.channel).label }}</a-tag>
|
||||
<a-tag :color="getPriorityInfo(record.priority).color">
|
||||
{{ getPriorityInfo(record.priority).label }}
|
||||
</a-tag>
|
||||
<a-tag v-if="record.customer.vip" color="gold">VIP</a-tag>
|
||||
</div>
|
||||
<div class="session-customer">
|
||||
<a-avatar :src="record.customer.avatar" :size="36" />
|
||||
<div class="customer-info">
|
||||
<div class="customer-name">
|
||||
{{ record.customer.nickname }}
|
||||
<span class="customer-level">{{ record.customer.level }}</span>
|
||||
</div>
|
||||
<div class="customer-meta">
|
||||
<EnvironmentOutlined /> {{ record.customer.city }}
|
||||
<span class="customer-intent">{{ record.autoDetectedIntent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-tags">
|
||||
<a-tag v-for="tag in record.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lastMessage'">
|
||||
<div class="message-cell">
|
||||
<div class="message-text">{{ record.lastMessage }}</div>
|
||||
<div class="message-meta">
|
||||
<ClockCircleOutlined />
|
||||
<span>{{ formatDateTime(record.lastMessageAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'stats'">
|
||||
<div class="stat-block">
|
||||
<span>
|
||||
<MessageOutlined /> 共 {{ record.totalMessages }} 条
|
||||
</span>
|
||||
<span>
|
||||
<BellOutlined /> 未读 {{ record.unreadCount }}
|
||||
</span>
|
||||
<span>
|
||||
<DashboardOutlined /> 等待 {{ record.waitingTime }} 分钟
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'agent'">
|
||||
<div class="agent-cell">
|
||||
<template v-if="record.assignedAgent">
|
||||
<a-avatar :src="record.assignedAgent.avatar" :size="36" />
|
||||
<div class="agent-info">
|
||||
<span class="agent-name">{{ record.assignedAgent.name }}</span>
|
||||
<span class="agent-title">{{ record.assignedAgent.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag v-else color="orange">未分配</a-tag>
|
||||
<a-dropdown trigger="click">
|
||||
<a-button type="link" class="assign-btn">
|
||||
<UserSwitchOutlined /> 转派
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleAssign(record as ConversationSession, Number(key))">
|
||||
<a-menu-item v-for="agent in supportAgents" :key="agent.id">
|
||||
<span>{{ agent.name }} · {{ agent.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusInfo(record.status).color">
|
||||
{{ getStatusInfo(record.status).label }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'updatedAt'">
|
||||
{{ formatDateTime(record.lastMessageAt) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="showDetail(record as ConversationSession)">
|
||||
<EyeOutlined /> 查看
|
||||
</a-button>
|
||||
<a-popconfirm title="确认将该会话标记为已结单?" ok-text="确认" cancel-text="取消"
|
||||
@confirm="handleResolve(record as ConversationSession)">
|
||||
<a-button type="link" size="small" :loading="updatingSessionId === record.id">
|
||||
<CheckCircleOutlined /> 结单
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-drawer v-model:open="detailVisible" width="720px" placement="right"
|
||||
:title="currentSession ? `会话 ${currentSession.sessionCode}` : '会话详情'">
|
||||
<template #extra>
|
||||
<a-button type="link" v-if="currentSession && currentSession.status !== 'resolved'"
|
||||
:loading="updatingSessionId === currentSession.id" @click="handleResolve(currentSession)">
|
||||
<CheckCircleOutlined /> 标记结单
|
||||
</a-button>
|
||||
</template>
|
||||
<div v-if="currentSession" class="drawer-content">
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-customer">
|
||||
<a-avatar :src="currentSession.customer.avatar" :size="48" />
|
||||
<div class="drawer-customer-info">
|
||||
<div class="name-line">
|
||||
<span class="name">{{ currentSession.customer.nickname }}</span>
|
||||
<a-tag color="gold" v-if="currentSession.customer.vip">VIP</a-tag>
|
||||
</div>
|
||||
<div class="customer-extra">
|
||||
<EnvironmentOutlined /> {{ currentSession.customer.city }}
|
||||
<span>等级 {{ currentSession.customer.level }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-meta">
|
||||
<a-tag :color="channelMap[currentSession.channel].color">{{ channelMap[currentSession.channel].label
|
||||
}}</a-tag>
|
||||
<a-tag :color="priorityMap[currentSession.priority].color">{{ priorityMap[currentSession.priority].label
|
||||
}}</a-tag>
|
||||
<a-tag :color="statusMap[currentSession.status].color">{{ statusMap[currentSession.status].label }}</a-tag>
|
||||
</div>
|
||||
<div class="drawer-tags">
|
||||
<a-tag v-for="tag in currentSession.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
|
||||
</div>
|
||||
<div class="drawer-stats">
|
||||
<div>
|
||||
<span class="stat-label">来源</span>
|
||||
<span>{{ currentSession.source }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="stat-label">识别意图</span>
|
||||
<span>{{ currentSession.autoDetectedIntent }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="stat-label">首响</span>
|
||||
<span>{{ currentSession.firstResponseAt ? formatDateTime(currentSession.firstResponseAt) : '待响应' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
<div class="message-list">
|
||||
<div v-for="message in currentSession.messages" :key="message.id" :class="['message-item', message.sender]">
|
||||
<div class="drawer-message-meta">
|
||||
<span class="meta-name">{{ message.senderName }}</span>
|
||||
<span class="meta-time">{{ formatDateTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="message-content">{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无会话数据" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import type { Key } from 'ant-design-vue/es/table/interface'
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
CustomerServiceOutlined,
|
||||
MessageOutlined,
|
||||
TeamOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
BellOutlined,
|
||||
DashboardOutlined,
|
||||
EyeOutlined,
|
||||
EnvironmentOutlined,
|
||||
UserSwitchOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ConversationAgent, ConversationChannel, ConversationPriority, ConversationSession, ConversationStatus } from '@/types'
|
||||
import {
|
||||
mockGetConversationList,
|
||||
mockUpdateConversationStatus,
|
||||
mockAssignConversationAgent,
|
||||
mockGetSupportAgents
|
||||
} from '@/mock'
|
||||
import { formatDateTime } from '@/utils/common'
|
||||
|
||||
type StatusFilter = ConversationStatus | 'all'
|
||||
type PriorityFilter = ConversationPriority | 'all'
|
||||
type ChannelFilter = ConversationSession['channel'] | 'all'
|
||||
|
||||
const loading = ref(false)
|
||||
const conversationList = ref<ConversationSession[]>([])
|
||||
const selectedRowKeys = ref<Key[]>([])
|
||||
const batchResolving = ref(false)
|
||||
const updatingSessionId = ref<number | null>(null)
|
||||
const detailVisible = ref(false)
|
||||
const currentSession = ref<ConversationSession | null>(null)
|
||||
const supportAgents = ref<ConversationAgent[]>([])
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const metrics = reactive({
|
||||
waitingCount: 0,
|
||||
activeCount: 0,
|
||||
pendingCount: 0,
|
||||
resolvedToday: 0,
|
||||
satisfaction: 0,
|
||||
avgFirstResponse: 0
|
||||
})
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
status: 'all' as StatusFilter,
|
||||
channel: 'all' as ChannelFilter,
|
||||
priority: 'all' as PriorityFilter,
|
||||
vipOnly: false,
|
||||
dateRange: undefined as [string, string] | undefined
|
||||
})
|
||||
|
||||
const overviewCards = computed(() => [
|
||||
{
|
||||
key: 'waiting',
|
||||
title: '待接入',
|
||||
value: metrics.waitingCount,
|
||||
desc: `平均等待 ${averageWaitingTime.value} 分钟`,
|
||||
icon: CustomerServiceOutlined,
|
||||
color: '#fa8c16'
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
title: '进行中',
|
||||
value: metrics.activeCount,
|
||||
desc: `首响 ${metrics.avgFirstResponse} 分钟`,
|
||||
icon: TeamOutlined,
|
||||
color: '#1890ff'
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
title: '待跟进',
|
||||
value: metrics.pendingCount,
|
||||
desc: '建议优先处理',
|
||||
icon: BellOutlined,
|
||||
color: '#722ed1'
|
||||
},
|
||||
{
|
||||
key: 'satisfaction',
|
||||
title: '满意度',
|
||||
value: `${metrics.satisfaction}/5`,
|
||||
desc: `今日结单 ${metrics.resolvedToday}`,
|
||||
icon: MessageOutlined,
|
||||
color: '#52c41a'
|
||||
}
|
||||
])
|
||||
|
||||
const averageWaitingTime = computed(() => {
|
||||
if (!conversationList.value.length) return 0
|
||||
const total = conversationList.value.reduce((sum, item) => sum + item.waitingTime, 0)
|
||||
return Math.round(total / conversationList.value.length)
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '待接入', value: 'waiting' },
|
||||
{ label: '进行中', value: 'active' },
|
||||
{ label: '待跟进', value: 'pending' },
|
||||
{ label: '已结单', value: 'resolved' }
|
||||
]
|
||||
|
||||
const channelOptions = [
|
||||
{ label: '全部渠道', value: 'all' },
|
||||
{ label: 'App', value: 'app' },
|
||||
{ label: 'Web', value: 'web' },
|
||||
{ label: '企业微信', value: 'wechat' },
|
||||
{ label: '小程序', value: 'miniapp' }
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: '全部优先级', value: 'all' },
|
||||
{ label: '普通', value: 'normal' },
|
||||
{ label: '加急', value: 'high' },
|
||||
{ label: 'VIP', value: 'vip' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ title: '会话', key: 'session', width: 280 },
|
||||
{ title: '最近内容', key: 'lastMessage', width: 260 },
|
||||
{ title: '统计', key: 'stats', width: 200 },
|
||||
{ title: '处理人', key: 'agent', width: 200 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '更新时间', key: 'updatedAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
const statusMap: Record<ConversationStatus, { label: string; color: string }> = {
|
||||
waiting: { label: '待接入', color: 'orange' },
|
||||
active: { label: '进行中', color: 'blue' },
|
||||
pending: { label: '待跟进', color: 'purple' },
|
||||
resolved: { label: '已结单', color: 'green' },
|
||||
closed: { label: '已关闭', color: 'default' }
|
||||
}
|
||||
|
||||
const channelMap: Record<ConversationChannel, { label: string; color: string }> = {
|
||||
app: { label: 'App', color: 'blue' },
|
||||
web: { label: 'Web', color: 'geekblue' },
|
||||
wechat: { label: '企业微信', color: 'cyan' },
|
||||
miniapp: { label: '小程序', color: 'purple' }
|
||||
}
|
||||
|
||||
const priorityMap: Record<ConversationPriority, { label: string; color: string }> = {
|
||||
normal: { label: '普通', color: 'default' },
|
||||
high: { label: '加急', color: 'orange' },
|
||||
vip: { label: 'VIP', color: 'red' }
|
||||
}
|
||||
|
||||
// 辅助函数用于处理模板中的类型安全访问
|
||||
function getChannelInfo(channel: string): { label: string; color: string } {
|
||||
return channelMap[channel as ConversationChannel] || { label: channel, color: 'default' }
|
||||
}
|
||||
|
||||
function getPriorityInfo(priority: string): { label: string; color: string } {
|
||||
return priorityMap[priority as ConversationPriority] || { label: priority, color: 'default' }
|
||||
}
|
||||
|
||||
function getStatusInfo(status: string): { label: string; color: string } {
|
||||
return statusMap[status as ConversationStatus] || { label: status, color: 'default' }
|
||||
}
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: onSelectChange
|
||||
}))
|
||||
|
||||
function onSelectChange(keys: Key[]) {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetConversationList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
status: searchForm.status,
|
||||
channel: searchForm.channel,
|
||||
priority: searchForm.priority,
|
||||
vipOnly: searchForm.vipOnly,
|
||||
dateRange: searchForm.dateRange
|
||||
})
|
||||
conversationList.value = res.list
|
||||
pagination.total = res.total
|
||||
Object.assign(metrics, res.metrics)
|
||||
if (detailVisible.value && currentSession.value) {
|
||||
const refreshed = res.list.find(item => item.id === currentSession.value?.id)
|
||||
if (refreshed) {
|
||||
currentSession.value = refreshed
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('客服会话数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = 'all'
|
||||
searchForm.channel = 'all'
|
||||
searchForm.priority = 'all'
|
||||
searchForm.vipOnly = false
|
||||
searchForm.dateRange = undefined
|
||||
pagination.current = 1
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: TablePaginationConfig) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
async function handleBatchResolve() {
|
||||
if (!selectedRowKeys.value.length) return
|
||||
batchResolving.value = true
|
||||
try {
|
||||
await Promise.all(selectedRowKeys.value.map(key => mockUpdateConversationStatus(Number(key), 'resolved')))
|
||||
message.success(`已结单 ${selectedRowKeys.value.length} 条会话`)
|
||||
selectedRowKeys.value = []
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('批量结单失败,请稍后重试')
|
||||
} finally {
|
||||
batchResolving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolve(record: ConversationSession) {
|
||||
updatingSessionId.value = record.id
|
||||
try {
|
||||
await mockUpdateConversationStatus(record.id, 'resolved')
|
||||
message.success('会话已结单')
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('结单失败,请稍后重试')
|
||||
} finally {
|
||||
updatingSessionId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign(record: ConversationSession, agentId: number) {
|
||||
updatingSessionId.value = record.id
|
||||
try {
|
||||
await mockAssignConversationAgent(record.id, agentId)
|
||||
message.success('分配成功')
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('分配失败,请稍后重试')
|
||||
} finally {
|
||||
updatingSessionId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function showDetail(record: ConversationSession) {
|
||||
currentSession.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
supportAgents.value = mockGetSupportAgents()
|
||||
loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.support-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(120deg, #f8fbff, #ffffff);
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #7a8194;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.metric-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.metric-extra {
|
||||
font-size: 13px;
|
||||
color: #5c6370;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.table-card .ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.session-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-code {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.session-customer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.customer-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.customer-level {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.customer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.customer-intent {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: #f6ffed;
|
||||
color: #389e0d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.message-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
color: #1a1a2e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #5c6370;
|
||||
}
|
||||
|
||||
.agent-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agent-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: #5c6370;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.assign-btn {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drawer-customer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drawer-customer-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drawer-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawer-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.drawer-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #5c6370;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.message-item.user {
|
||||
background-color: #fff7e6;
|
||||
border-color: #ffe7ba;
|
||||
}
|
||||
|
||||
.message-item.agent {
|
||||
background-color: #f6ffed;
|
||||
border-color: #d9f7be;
|
||||
}
|
||||
|
||||
.drawer-message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
513
src/views/support/session/index.vue
Normal file
513
src/views/support/session/index.vue
Normal file
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div class="session-workspace">
|
||||
<div class="session-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<CustomerServiceOutlined />
|
||||
<span>接入会话</span>
|
||||
<a-tag color="blue">{{ filteredSessions.length }}</a-tag>
|
||||
</div>
|
||||
<div class="connection-status">
|
||||
<a-badge :status="connectionStatusMap[connectionStatus].status"
|
||||
:text="connectionStatusMap[connectionStatus].text" />
|
||||
<div class="realtime-toggle">
|
||||
<span>实时模拟</span>
|
||||
<a-switch size="small" v-model:checked="realtimeEnabled" />
|
||||
</div>
|
||||
<a-button size="small" type="link" @click="handleReconnect">
|
||||
<ReloadOutlined /> 重连
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-tools">
|
||||
<a-input v-model:value="keyword" :disabled="loading" allow-clear placeholder="搜索会话/客户" class="panel-search">
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<a-radio-group v-model:value="statusFilter" size="small">
|
||||
<a-radio-button value="all">全部 {{ sessionCounts.total }}</a-radio-button>
|
||||
<a-radio-button value="waiting">待接入 {{ sessionCounts.waiting }}</a-radio-button>
|
||||
<a-radio-button value="active">进行中 {{ sessionCounts.active }}</a-radio-button>
|
||||
<a-radio-button value="pending">待跟进 {{ sessionCounts.pending }}</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="session-list" v-if="filteredSessions.length">
|
||||
<div v-for="session in filteredSessions" :key="session.id"
|
||||
:class="['session-card', { active: currentSession && currentSession.id === session.id }]"
|
||||
@click="handleSelectSession(session)">
|
||||
<div class="session-card-header">
|
||||
<div class="session-card-title">
|
||||
<span class="session-code">{{ session.sessionCode }}</span>
|
||||
<a-tag :color="statusMap[session.status].color">{{ statusMap[session.status].label }}</a-tag>
|
||||
</div>
|
||||
<span class="session-time">{{ formatDateTime(session.lastMessageAt) }}</span>
|
||||
</div>
|
||||
<div class="session-card-body">
|
||||
<a-avatar :src="session.customer.avatar" :size="40" />
|
||||
<div class="session-card-info">
|
||||
<div class="session-name">
|
||||
{{ session.customer.nickname }}
|
||||
</div>
|
||||
<div class="session-last">{{ session.lastMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-footer">
|
||||
<span class="session-meta">
|
||||
{{ session.assignedAgent ? session.assignedAgent.name : '待分配' }}
|
||||
<span class="divider">·</span>
|
||||
未读 {{ session.unreadCount }}
|
||||
</span>
|
||||
<a-button v-if="session.status === 'waiting'" size="small" type="link"
|
||||
@click.stop="handleTakeOver(session)">
|
||||
接入
|
||||
</a-button>
|
||||
<a-button v-else-if="session.status === 'pending'" size="small" type="link"
|
||||
@click.stop="handleSelectSession(session)">
|
||||
跟进
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else :description="loading ? '加载中…' : '暂无会话'" />
|
||||
</div>
|
||||
|
||||
<div class="chat-panel">
|
||||
<ChatConversation v-model="composerText" :conversation="currentSession" :quick-replies="quickReplies"
|
||||
:support-agents="supportAgents" :sending="sending" :status-text="connectionStatusMap[connectionStatus].text"
|
||||
@assign="handleAssign" @resolve="handleResolveCurrent" @send="handleSendMessage">
|
||||
<template #empty>
|
||||
<a-empty description="请选择左侧会话" />
|
||||
</template>
|
||||
</ChatConversation>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
CustomerServiceOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ConversationAgent, ConversationSession, ConversationStatus } from '@/types'
|
||||
import {
|
||||
mockGetConversationList,
|
||||
mockAssignConversationAgent,
|
||||
mockUpdateConversationStatus,
|
||||
mockGetSupportAgents
|
||||
} from '@/mock'
|
||||
import { formatDateTime, delay } from '@/utils/common'
|
||||
import ChatConversation from '@/components/ChatConversation.vue'
|
||||
|
||||
type StatusFilter = ConversationStatus | 'all'
|
||||
type ConnectionState = 'idle' | 'connecting' | 'connected'
|
||||
|
||||
const loading = ref(false)
|
||||
const sessionList = ref<ConversationSession[]>([])
|
||||
const currentSession = ref<ConversationSession | null>(null)
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref<StatusFilter>('all')
|
||||
const composerText = ref('')
|
||||
const sending = ref(false)
|
||||
const supportAgents = ref<ConversationAgent[]>([])
|
||||
const connectionStatus = ref<ConnectionState>('idle')
|
||||
const realtimeEnabled = ref(true)
|
||||
let realtimeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const quickReplies = [
|
||||
{ key: 'confirm', label: '已收到', content: '您好,我们已收到您的反馈,会尽快解决。' },
|
||||
{ key: 'wait', label: '排查中', content: '问题正在排查中,麻烦您稍候,我们稍后同步处理结果。' },
|
||||
{ key: 'thanks', label: '感谢', content: '感谢您的耐心配合,如有其他疑问欢迎随时联系。' }
|
||||
]
|
||||
|
||||
const statusMap: Record<ConversationStatus, { label: string; color: string }> = {
|
||||
waiting: { label: '待接入', color: 'orange' },
|
||||
active: { label: '进行中', color: 'blue' },
|
||||
pending: { label: '待跟进', color: 'purple' },
|
||||
resolved: { label: '已结单', color: 'green' },
|
||||
closed: { label: '已关闭', color: 'default' }
|
||||
}
|
||||
|
||||
// channelMap - 已在模板直接使用statusMap来展示
|
||||
|
||||
const connectionStatusMap: Record<ConnectionState, { status: 'default' | 'processing' | 'success'; text: string }> = {
|
||||
idle: { status: 'default', text: '待接入 WebStork' },
|
||||
connecting: { status: 'processing', text: '连接中…' },
|
||||
connected: { status: 'success', text: '实时通道正常' }
|
||||
}
|
||||
|
||||
const sessionCounts = computed(() => ({
|
||||
total: sessionList.value.length,
|
||||
waiting: sessionList.value.filter(session => session.status === 'waiting').length,
|
||||
active: sessionList.value.filter(session => session.status === 'active').length,
|
||||
pending: sessionList.value.filter(session => session.status === 'pending').length
|
||||
}))
|
||||
|
||||
const filteredSessions = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
return sessionList.value.filter(session => {
|
||||
const matchKeyword =
|
||||
!kw ||
|
||||
session.sessionCode.toLowerCase().includes(kw) ||
|
||||
session.customer.nickname.toLowerCase().includes(kw) ||
|
||||
session.lastMessage.toLowerCase().includes(kw)
|
||||
const matchStatus = statusFilter.value === 'all' ? true : session.status === statusFilter.value
|
||||
return matchKeyword && matchStatus
|
||||
})
|
||||
})
|
||||
|
||||
async function loadSessions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetConversationList({ page: 1, pageSize: 50 })
|
||||
sessionList.value = res.list.map(session => ({
|
||||
...session,
|
||||
messages: [...session.messages]
|
||||
}))
|
||||
if (!currentSession.value && sessionList.value.length) {
|
||||
currentSession.value = sessionList.value[0] ?? null
|
||||
} else if (currentSession.value) {
|
||||
const refreshed = sessionList.value.find(item => item.id === currentSession.value?.id)
|
||||
currentSession.value = refreshed ?? sessionList.value[0] ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('会话数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (realtimeEnabled.value) {
|
||||
startRealtimeSimulation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectSession(session: ConversationSession) {
|
||||
currentSession.value = session
|
||||
}
|
||||
|
||||
async function handleTakeOver(session: ConversationSession) {
|
||||
try {
|
||||
await mockUpdateConversationStatus(session.id, 'active')
|
||||
message.success('已接入会话')
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('接入失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign(agentId: number) {
|
||||
if (!currentSession.value) return
|
||||
try {
|
||||
await mockAssignConversationAgent(currentSession.value.id, agentId)
|
||||
message.success('分配成功')
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('分配失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolveCurrent() {
|
||||
if (!currentSession.value) return
|
||||
try {
|
||||
await mockUpdateConversationStatus(currentSession.value.id, 'resolved')
|
||||
message.success('会话已结单')
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('结单失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage(payload?: string) {
|
||||
if (!currentSession.value) return
|
||||
const content = (payload ?? composerText.value).trim()
|
||||
if (!content) {
|
||||
message.warning('请输入内容')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
await delay(200)
|
||||
const now = new Date().toISOString()
|
||||
const newMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'agent' as const,
|
||||
senderName: currentSession.value.assignedAgent?.name || '平台客服',
|
||||
content,
|
||||
timestamp: now,
|
||||
avatar: currentSession.value.assignedAgent?.avatar
|
||||
}
|
||||
currentSession.value.messages.push(newMessage)
|
||||
currentSession.value.lastMessage = content
|
||||
currentSession.value.lastMessageAt = now
|
||||
currentSession.value.unreadCount = 0
|
||||
composerText.value = ''
|
||||
const index = sessionList.value.findIndex(session => session.id === currentSession.value?.id)
|
||||
if (index > -1) {
|
||||
sessionList.value[index] = { ...currentSession.value }
|
||||
}
|
||||
message.success('消息已发送')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('发送失败,请稍后重试')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function initWebstorkBridge() {
|
||||
connectionStatus.value = 'connecting'
|
||||
setTimeout(() => {
|
||||
connectionStatus.value = 'connected'
|
||||
}, 600)
|
||||
}
|
||||
|
||||
function handleReconnect() {
|
||||
initWebstorkBridge()
|
||||
message.info('正在为 WebStork 做准备')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSessions()
|
||||
supportAgents.value = mockGetSupportAgents()
|
||||
initWebstorkBridge()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRealtimeSimulation()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => realtimeEnabled.value,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
startRealtimeSimulation()
|
||||
} else {
|
||||
stopRealtimeSimulation()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sessionList.value.length,
|
||||
() => {
|
||||
if (realtimeEnabled.value) {
|
||||
startRealtimeSimulation()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const realtimeMessages = [
|
||||
'我这边又遇到新的异常了,麻烦再看看。',
|
||||
'还有其他优惠可以参加吗?',
|
||||
'刚刚的操作还是失败,请再指导下。',
|
||||
'收到回执了,现在需要我做什么?',
|
||||
'信息已经补充,麻烦尽快回复。'
|
||||
]
|
||||
|
||||
function startRealtimeSimulation() {
|
||||
stopRealtimeSimulation()
|
||||
if (!realtimeEnabled.value) return
|
||||
realtimeTimer = setInterval(() => {
|
||||
if (!sessionList.value.length) return
|
||||
const target = sessionList.value[Math.floor(Math.random() * sessionList.value.length)]!
|
||||
const content = realtimeMessages[Math.floor(Math.random() * realtimeMessages.length)]!
|
||||
const now = new Date().toISOString()
|
||||
const incoming = {
|
||||
id: Number(`${target.id}${Date.now()}`),
|
||||
sender: 'user' as const,
|
||||
senderName: target.customer.nickname,
|
||||
content,
|
||||
timestamp: now,
|
||||
avatar: target.customer.avatar
|
||||
}
|
||||
target.messages.push(incoming)
|
||||
target.lastMessage = content
|
||||
target.lastMessageAt = now
|
||||
if (!currentSession.value || currentSession.value.id !== target.id) {
|
||||
target.unreadCount += 1
|
||||
} else {
|
||||
currentSession.value.unreadCount = 0
|
||||
}
|
||||
}, 8000)
|
||||
}
|
||||
|
||||
function stopRealtimeSimulation() {
|
||||
if (realtimeTimer) {
|
||||
clearInterval(realtimeTimer)
|
||||
realtimeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.session-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.realtime-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.panel-tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-search {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.session-card.active {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
.session-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.session-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-code {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.session-card-body {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-card-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-last {
|
||||
font-size: 12px;
|
||||
color: #5c6370;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
250
src/views/talent/components/OnlineResume.vue
Normal file
250
src/views/talent/components/OnlineResume.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<a-card class="info-card resume-card" :title="showHeader ? '在线简历' : ''" :bordered="false">
|
||||
<template #extra>
|
||||
<div v-if="talent.jobIntention" class="job-status-tag">
|
||||
{{ talent.jobIntention.jobStatus }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 求职意向 -->
|
||||
<div class="resume-section">
|
||||
<h4 class="section-title">
|
||||
<span class="icon-wrapper"><AimOutlined /></span>
|
||||
求职意向
|
||||
</h4>
|
||||
<div class="intention-grid">
|
||||
<div class="intention-item">
|
||||
<span class="label">期望职位</span>
|
||||
<span class="value">{{ talent.jobIntention?.expectedPosition?.join('、') }}</span>
|
||||
</div>
|
||||
<div class="intention-item">
|
||||
<span class="label">期望薪资</span>
|
||||
<span class="value salary-text">{{ talent.jobIntention?.expectedSalary }}</span>
|
||||
</div>
|
||||
<div class="intention-item">
|
||||
<span class="label">期望城市</span>
|
||||
<span class="value">{{ talent.jobIntention?.expectedCity?.join('、') }}</span>
|
||||
</div>
|
||||
<div class="intention-item">
|
||||
<span class="label">期望行业</span>
|
||||
<span class="value">{{ talent.jobIntention?.expectedIndustry?.join('、') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 工作经历 -->
|
||||
<div class="resume-section">
|
||||
<h4 class="section-title">
|
||||
<span class="icon-wrapper"><BankOutlined /></span>
|
||||
工作经历
|
||||
</h4>
|
||||
<div class="experience-list">
|
||||
<div v-for="exp in talent.workExperiences" :key="exp.id" class="experience-item">
|
||||
<div class="exp-header">
|
||||
<span class="company-name">{{ exp.company }}</span>
|
||||
<span class="exp-period">{{ exp.startTime }} 至 {{ exp.endTime || '至今' }}</span>
|
||||
</div>
|
||||
<div class="exp-sub">
|
||||
<span class="position">{{ exp.position }}</span>
|
||||
<a-divider type="vertical" />
|
||||
<span class="department">{{ exp.department }}</span>
|
||||
</div>
|
||||
<div class="exp-tags" v-if="exp.tags && exp.tags.length">
|
||||
<a-tag v-for="tag in exp.tags" :key="tag" size="small">{{ tag }}</a-tag>
|
||||
</div>
|
||||
<p class="exp-desc">{{ exp.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 教育经历 -->
|
||||
<div class="resume-section">
|
||||
<h4 class="section-title">
|
||||
<span class="icon-wrapper"><ReadOutlined /></span>
|
||||
教育经历
|
||||
</h4>
|
||||
<div class="experience-list">
|
||||
<div v-for="edu in talent.educationExperiences" :key="edu.id" class="experience-item">
|
||||
<div class="exp-header">
|
||||
<span class="school-name">{{ edu.school }}</span>
|
||||
<span class="exp-period">{{ edu.startTime }} 至 {{ edu.endTime }}</span>
|
||||
</div>
|
||||
<div class="exp-sub">
|
||||
<span class="major">{{ edu.degree }} · {{ edu.major }}</span>
|
||||
</div>
|
||||
<p v-if="edu.description" class="exp-desc">{{ edu.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AimOutlined,
|
||||
BankOutlined,
|
||||
ReadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { TalentProfile } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
talent: TalentProfile
|
||||
showHeader?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-card {
|
||||
border-radius: 12px;
|
||||
/* margin-bottom: 16px; */
|
||||
/* box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); */
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* Resume Styles */
|
||||
.resume-card :deep(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
min-height: auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.resume-card :deep(.ant-card-head-title) {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.job-status-tag {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.resume-section {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.intention-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.intention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.intention-item .label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.intention-item .value {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.salary-text {
|
||||
color: #fa8c16 !important;
|
||||
}
|
||||
|
||||
.experience-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.experience-item {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.experience-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #d9d9d9;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px #d9d9d9;
|
||||
}
|
||||
|
||||
.experience-item:hover::before {
|
||||
background: #1890ff;
|
||||
box-shadow: 0 0 0 1px #1890ff;
|
||||
}
|
||||
|
||||
.exp-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.company-name, .school-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.exp-period {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.exp-sub {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.exp-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.exp-desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
663
src/views/talent/detail.vue
Normal file
663
src/views/talent/detail.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<template>
|
||||
<div class="talent-detail-page">
|
||||
<!-- 返回按钮 -->
|
||||
<a-page-header
|
||||
class="page-header"
|
||||
:title="talent?.realName || '人才详情'"
|
||||
:sub-title="talent?.positionTitle"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #tags>
|
||||
<a-tag v-if="talent?.hot" color="red">HOT</a-tag>
|
||||
<a-badge
|
||||
v-if="talent"
|
||||
:status="TalentStatusBadgeMap[talent.status]"
|
||||
:text="TalentStatusMap[talent.status]"
|
||||
/>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleContact">
|
||||
<MailOutlined />
|
||||
联系人才
|
||||
</a-button>
|
||||
<a-button @click="handleInvite">
|
||||
<TeamOutlined />
|
||||
邀请入驻项目
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="talent" class="detail-content">
|
||||
<!-- 基本信息卡片 -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-card class="info-card profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="avatar-section">
|
||||
<a-avatar :src="talent.avatar" :size="96" />
|
||||
<div v-if="talent.hot" class="hot-badge">
|
||||
<FireOutlined />
|
||||
热门
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<div class="name-row">
|
||||
<span class="name">{{ talent.realName }}</span>
|
||||
<span class="title">{{ talent.positionTitle }}</span>
|
||||
</div>
|
||||
<div class="location-row">
|
||||
<EnvironmentOutlined />
|
||||
<a-tag :color="talent.cityTag.color">{{ talent.cityTag.name }}</a-tag>
|
||||
</div>
|
||||
<div class="skill-tags">
|
||||
<a-tag
|
||||
v-for="tag in talent.skillTags"
|
||||
:key="tag.id"
|
||||
:color="tag.color"
|
||||
class="skill-tag"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rating-section">
|
||||
<div class="overall-rating">
|
||||
<span class="rating-value">{{ overallRating }}</span>
|
||||
<span class="rating-unit">/100</span>
|
||||
</div>
|
||||
<span class="rating-label">综合评分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="intro-section">
|
||||
<h4>
|
||||
<UserOutlined /> 个人简介
|
||||
<a-tag
|
||||
v-for="resume in talent.resumeAttachments"
|
||||
:key="resume.id"
|
||||
color="blue"
|
||||
style="margin-left: 12px; cursor: pointer"
|
||||
>
|
||||
<PaperClipOutlined /> {{ resume.fileName }}
|
||||
</a-tag>
|
||||
</h4>
|
||||
<p class="intro-text">{{ talent.introduction }}</p>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 在线简历 -->
|
||||
<OnlineResume :talent="talent" :show-header="true" style="margin-bottom: 16px" />
|
||||
|
||||
<!-- 项目经历 -->
|
||||
<a-card class="info-card project-card" title="项目经历">
|
||||
<template #extra>
|
||||
<span class="project-count">
|
||||
累计交付 <strong>{{ talent.projectCount }}</strong> 个项目
|
||||
</span>
|
||||
</template>
|
||||
<a-timeline>
|
||||
<a-timeline-item
|
||||
v-for="project in talent.recentProjects"
|
||||
:key="project.id"
|
||||
:color="getProjectTimelineColor(project.score)"
|
||||
>
|
||||
<div class="project-timeline-item">
|
||||
<div class="project-main">
|
||||
<span class="project-name">{{ project.name }}</span>
|
||||
<a-tag color="blue" size="small">{{ project.role }}</a-tag>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span class="project-date">
|
||||
<CalendarOutlined />
|
||||
交付于 {{ formatDate(project.deliveryDate) }}
|
||||
</span>
|
||||
<span class="project-score">
|
||||
<StarOutlined />
|
||||
评分 {{ project.score }}分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<!-- 状态信息 -->
|
||||
<a-card class="info-card status-card">
|
||||
<div class="status-header">
|
||||
<ClockCircleOutlined :style="{ fontSize: '24px', color: statusColor }" />
|
||||
<div class="status-info">
|
||||
<span class="status-label">当前状态</span>
|
||||
<span class="status-value" :style="{ color: statusColor }">
|
||||
{{ TalentStatusMap[talent.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
<div class="availability-info">
|
||||
<span class="availability-label">可预约时间</span>
|
||||
<span class="availability-value">{{ availabilityText }}</span>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 费用信息 -->
|
||||
<a-card class="info-card fee-card">
|
||||
<div class="fee-header">
|
||||
<DollarOutlined :style="{ fontSize: '24px', color: '#52c41a' }" />
|
||||
<span class="fee-title">服务费用</span>
|
||||
</div>
|
||||
<div class="fee-amount">
|
||||
<span class="currency">¥</span>
|
||||
<span class="amount">{{ talent.fee.amount.toLocaleString() }}</span>
|
||||
<span class="unit">/ {{ talent.fee.unit === 'day' ? '天' : '月' }}</span>
|
||||
</div>
|
||||
<div class="fee-desc">
|
||||
{{ talent.fee.unit === 'day' ? '按日结算,灵活高效' : '按月结算,长期合作优惠' }}
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<a-card class="info-card rating-card" title="详细评分">
|
||||
<div class="rating-item">
|
||||
<span class="rating-name">
|
||||
<TeamOutlined /> 信誉评分
|
||||
</span>
|
||||
<div class="rating-bar">
|
||||
<a-progress
|
||||
:percent="talent.rating.credit"
|
||||
:show-info="false"
|
||||
stroke-color="#1890ff"
|
||||
/>
|
||||
<span class="rating-num">{{ talent.rating.credit }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rating-item">
|
||||
<span class="rating-name">
|
||||
<ProjectOutlined /> 项目评分
|
||||
</span>
|
||||
<div class="rating-bar">
|
||||
<a-progress
|
||||
:percent="talent.rating.project"
|
||||
:show-info="false"
|
||||
stroke-color="#52c41a"
|
||||
/>
|
||||
<span class="rating-num">{{ talent.rating.project }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 经验统计 -->
|
||||
<a-card class="info-card stats-card">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-statistic
|
||||
title="从业经验"
|
||||
:value="talent.experienceYears"
|
||||
suffix="年"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrophyOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-statistic
|
||||
title="项目数量"
|
||||
:value="talent.projectCount"
|
||||
suffix="个"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<FolderOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<a-empty v-else-if="!loading" description="未找到该人才信息">
|
||||
<a-button type="primary" @click="handleBack">返回列表</a-button>
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
MailOutlined,
|
||||
TeamOutlined,
|
||||
FireOutlined,
|
||||
EnvironmentOutlined,
|
||||
UserOutlined,
|
||||
CalendarOutlined,
|
||||
StarOutlined,
|
||||
ClockCircleOutlined,
|
||||
DollarOutlined,
|
||||
ProjectOutlined,
|
||||
TrophyOutlined,
|
||||
FolderOutlined,
|
||||
PaperClipOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { TalentProfile } from '@/types'
|
||||
import { TalentStatusMap, TalentStatusBadgeMap, TalentStatusColorMap } from '@/types'
|
||||
import { mockGetTalentById } from '@/mock'
|
||||
import { formatDate } from '@/utils/common'
|
||||
import OnlineResume from './components/OnlineResume.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const talent = ref<TalentProfile | null>(null)
|
||||
|
||||
const overallRating = computed(() => {
|
||||
if (!talent.value) return 0
|
||||
return Math.round((talent.value.rating.credit + talent.value.rating.project) / 2)
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!talent.value) return '#d9d9d9'
|
||||
return TalentStatusColorMap[talent.value.status]
|
||||
})
|
||||
|
||||
const availabilityText = computed(() => {
|
||||
if (!talent.value) return ''
|
||||
if (talent.value.status === 'available') {
|
||||
return '可立即安排'
|
||||
}
|
||||
return `预计 ${formatDate(talent.value.availableFrom)} 可约`
|
||||
})
|
||||
|
||||
function getProjectTimelineColor(score: number): string {
|
||||
if (score >= 90) return 'green'
|
||||
if (score >= 80) return 'blue'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
async function loadTalentDetail() {
|
||||
const id = Number(route.params.id)
|
||||
if (!id || isNaN(id)) {
|
||||
message.error('无效的人才ID')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await mockGetTalentById(id)
|
||||
if (data) {
|
||||
talent.value = data
|
||||
} else {
|
||||
message.warning('未找到该人才信息')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('加载人才信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
router.push('/talent')
|
||||
}
|
||||
|
||||
function handleContact() {
|
||||
message.success(`已发送联系请求给 ${talent.value?.realName}`)
|
||||
}
|
||||
|
||||
function handleInvite() {
|
||||
message.info('邀请入驻项目功能开发中...')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTalentDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.talent-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Profile Card */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hot-badge {
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(135deg, #ff4d4f, #ff7a45);
|
||||
color: #fff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
color: #7a8194;
|
||||
}
|
||||
|
||||
.location-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.skill-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.rating-section {
|
||||
text-align: center;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #fff7e6, #ffe7ba);
|
||||
border-radius: 12px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.overall-rating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rating-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #fa8c16;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rating-unit {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Intro Section */
|
||||
.intro-section h4 {
|
||||
font-size: 14px;
|
||||
color: #5c6370;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Project Card */
|
||||
.project-count {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.project-count strong {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.project-timeline-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.project-date,
|
||||
.project-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-score {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
/* Status Card */
|
||||
.status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.availability-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.availability-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.availability-value {
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Fee Card */
|
||||
.fee-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fee-title {
|
||||
font-size: 14px;
|
||||
color: #5c6370;
|
||||
}
|
||||
|
||||
.fee-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-size: 18px;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.fee-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* Rating Card */
|
||||
.rating-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rating-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rating-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #5c6370;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rating-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rating-bar :deep(.ant-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rating-num {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
/* Resume Styles moved to OnlineResume.vue */
|
||||
|
||||
/* Stats Card */
|
||||
.stats-card :deep(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
</style>
|
||||
483
src/views/talent/index.vue
Normal file
483
src/views/talent/index.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<div class="talent-page">
|
||||
<a-page-header title="人才管理" sub-title="聚焦平台人才供给,快速匹配项目需求">
|
||||
<template #tags>
|
||||
<a-tag color="red">热门人才 {{ talentSummary.hot }}</a-tag>
|
||||
<a-tag color="green">可约人数 {{ talentSummary.available }}</a-tag>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card class="filter-card">
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">人员状态</span>
|
||||
<a-radio-group v-model:value="statusFilter" size="small">
|
||||
<a-radio-button value="all">全部</a-radio-button>
|
||||
<a-radio-button value="available">可约</a-radio-button>
|
||||
<a-radio-button value="busy">忙碌中</a-radio-button>
|
||||
<a-radio-button value="onboarding">即将空闲</a-radio-button>
|
||||
<a-radio-button value="resting">休整中</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">只看热门</span>
|
||||
<a-switch v-model:checked="hotOnly" />
|
||||
</div>
|
||||
<a-button type="primary" :loading="loading" @click="loadTalentData">
|
||||
<ReloadOutlined />
|
||||
<span>刷新数据</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="人才列表" :loading="loading" class="talent-card">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<span class="summary-item">总数 {{ talentSummary.total }}</span>
|
||||
<span class="summary-item">忙碌 {{ talentSummary.busy }}</span>
|
||||
<span class="summary-item">即将空闲 {{ talentSummary.onboarding }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-row :gutter="16">
|
||||
<a-col v-for="talent in talentList" :key="talent.id" :span="12">
|
||||
<div class="talent-item" @click="goToDetail(talent.id)">
|
||||
<div class="talent-top">
|
||||
<div class="talent-avatar">
|
||||
<a-avatar :src="talent.avatar" :size="64" />
|
||||
<a-tag v-if="isTalentHot(talent)" color="red" class="talent-hot-tag">HOT</a-tag>
|
||||
</div>
|
||||
<div class="talent-basic">
|
||||
<div class="talent-name">
|
||||
<span>{{ talent.realName }}</span>
|
||||
<CheckCircleFilled v-if="talent.verified" class="verified-icon" />
|
||||
<span class="talent-title">· {{ talent.positionTitle }}</span>
|
||||
</div>
|
||||
<div class="talent-domain" v-if="talent.domain">
|
||||
{{ talent.domain }}
|
||||
</div>
|
||||
<div class="talent-meta">
|
||||
<a-tag :color="talent.cityTag.color" class="city-tag">{{ talent.cityTag.name }}</a-tag>
|
||||
<a-badge :status="TalentStatusBadgeMap[talent.status]" :text="TalentStatusMap[talent.status]" />
|
||||
</div>
|
||||
<div class="talent-tags">
|
||||
<a-tag v-for="tag in talent.skillTags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="talent-score">
|
||||
<div class="talent-score-grade" :style="{ color: getRatingColor(talent.rating.credit) }">
|
||||
{{ getRatingGrade(talent.rating.credit) }}
|
||||
</div>
|
||||
<div class="talent-score-value">{{ talent.rating.credit }}/100</div>
|
||||
<div class="talent-score-desc">信誉分</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="talent-stat">
|
||||
<span><CheckCircleOutlined /> {{ talent.socialStats?.completedTaskCount || talent.projectCount }} 已完成</span>
|
||||
<span><ClockCircleOutlined /> {{ talent.socialStats?.ongoingTaskCount || 0 }} 进行中</span>
|
||||
<span>{{ formatTalentFee(talent.fee) }}</span>
|
||||
</div>
|
||||
<div class="talent-social-stats">
|
||||
<span><UserOutlined /> {{ formatCount(talent.socialStats?.followingCount || 0) }} 关注</span>
|
||||
<span><TeamOutlined /> {{ formatCount(talent.socialStats?.followerCount || 0) }} 粉丝</span>
|
||||
<span><StarOutlined /> {{ talent.socialStats?.starCount || 0 }} 星标</span>
|
||||
<span><LikeOutlined /> {{ talent.socialStats?.likeCount || 0 }} 点赞</span>
|
||||
</div>
|
||||
<div class="talent-rating-meta">
|
||||
<span>信誉评分 {{ talent.rating.credit }}/100</span>
|
||||
<span>项目评分 {{ talent.rating.project }}/100</span>
|
||||
<span class="talent-availability">{{ formatTalentAvailability(talent) }}</span>
|
||||
</div>
|
||||
<div class="talent-intro">{{ talent.introduction }}</div>
|
||||
<div class="talent-projects">
|
||||
<span class="talent-projects-label">平台项目</span>
|
||||
<div class="talent-project-list">
|
||||
<div v-for="project in talent.recentProjects" :key="project.id" class="talent-project-pill">
|
||||
<span class="project-name">{{ project.name }}</span>
|
||||
<span class="project-role">{{ project.role }}</span>
|
||||
<span class="project-score">{{ project.score }}分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="talent.resumeAttachments.length" class="talent-resume">
|
||||
<span class="talent-resume-label">附件简历</span>
|
||||
<a-tag color="blue" v-for="resume in talent.resumeAttachments" :key="resume.id">
|
||||
<PaperClipOutlined /> {{ resume.fileName }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="talent-actions">
|
||||
<a-button type="link" size="small" class="view-detail-btn">
|
||||
<EyeOutlined />
|
||||
查看详情
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
PaperClipOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
StarOutlined,
|
||||
LikeOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { TalentProfile, TalentStatus } from '@/types'
|
||||
import { TalentStatusMap, TalentStatusBadgeMap, getRatingGrade as getGradeFn } from '@/types'
|
||||
import { mockGetTalentList } from '@/mock'
|
||||
import { formatDate } from '@/utils/common'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
type StatusFilter = TalentStatus | 'all'
|
||||
|
||||
const loading = ref(false)
|
||||
const talentList = ref<TalentProfile[]>([])
|
||||
const statusFilter = ref<StatusFilter>('all')
|
||||
const hotOnly = ref(false)
|
||||
const talentSummary = reactive({
|
||||
total: 0,
|
||||
available: 0,
|
||||
busy: 0,
|
||||
onboarding: 0,
|
||||
resting: 0,
|
||||
hot: 0
|
||||
})
|
||||
|
||||
async function loadTalentData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetTalentList({
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
hotOnly: hotOnly.value
|
||||
})
|
||||
talentList.value = res.list
|
||||
talentSummary.total = res.total
|
||||
talentSummary.available = res.list.filter(item => item.status === 'available').length
|
||||
talentSummary.busy = res.list.filter(item => item.status === 'busy').length
|
||||
talentSummary.onboarding = res.list.filter(item => item.status === 'onboarding').length
|
||||
talentSummary.resting = res.list.filter(item => item.status === 'resting').length
|
||||
talentSummary.hot = res.list.filter(isTalentHot).length
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('人才数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTalentOverallRating(rating: TalentProfile['rating']): number {
|
||||
return Math.round((rating.credit + rating.project) / 2)
|
||||
}
|
||||
|
||||
function getRatingGrade(score: number): string {
|
||||
return getGradeFn(score).grade
|
||||
}
|
||||
|
||||
function getRatingColor(score: number): string {
|
||||
return getGradeFn(score).color
|
||||
}
|
||||
|
||||
function formatCount(num: number): string {
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + '万'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
|
||||
return String(num)
|
||||
}
|
||||
|
||||
function formatTalentFee(fee: TalentProfile['fee']): string {
|
||||
const unitLabel = fee.unit === 'day' ? '日结' : '月结'
|
||||
return `${unitLabel} · ¥${fee.amount.toLocaleString()}`
|
||||
}
|
||||
|
||||
function formatTalentAvailability(talent: TalentProfile): string {
|
||||
if (talent.status === 'available') {
|
||||
return '可立即安排'
|
||||
}
|
||||
return `预计 ${formatDate(talent.availableFrom)} 可约`
|
||||
}
|
||||
|
||||
function isTalentHot(talent: TalentProfile): boolean {
|
||||
return talent.hot || Number(getTalentOverallRating(talent.rating)) >= 4.8
|
||||
}
|
||||
|
||||
function goToDetail(id: number) {
|
||||
router.push(`/talent/${id}`)
|
||||
}
|
||||
|
||||
watch([statusFilter, hotOnly], () => {
|
||||
loadTalentData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTalentData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.talent-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: #5c6370;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.talent-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
font-size: 12px;
|
||||
color: #606a80;
|
||||
}
|
||||
|
||||
.talent-item {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.talent-item:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 16px rgba(24, 144, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.talent-item:hover .view-detail-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.talent-top {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.talent-avatar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.talent-hot-tag {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.talent-basic {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.talent-name {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.talent-domain {
|
||||
font-size: 13px;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.talent-title {
|
||||
font-size: 14px;
|
||||
color: #7a8194;
|
||||
}
|
||||
|
||||
.talent-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.city-tag {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.talent-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.talent-score {
|
||||
min-width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.talent-score-grade {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.talent-score-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #52c41a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.talent-score-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.talent-social-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #606a80;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.talent-social-stats span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.talent-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #5c6370;
|
||||
}
|
||||
|
||||
.talent-rating-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.talent-availability {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.talent-intro {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.talent-projects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.talent-projects-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.talent-project-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.talent-project-pill {
|
||||
background-color: #f7f9fc;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #4a4f63;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-score {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.talent-resume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.talent-resume-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.talent-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.view-detail-btn {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
466
src/views/talent/resume-templates.vue
Normal file
466
src/views/talent/resume-templates.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div class="resume-template-page">
|
||||
<a-page-header title="简历模板管理" sub-title="管理平台在线简历模板,为人才提供多样化的简历展示方式">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<PlusOutlined /> 添加模板
|
||||
</a-button>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="template-grid">
|
||||
<div v-for="template in templates" :key="template.id" class="template-item">
|
||||
<div class="template-thumbnail-wrapper">
|
||||
<img :src="template.thumbnail" :alt="template.name" class="thumbnail-img" />
|
||||
|
||||
<div class="thumbnail-overlay">
|
||||
<a-button type="primary" shape="round" @click="handlePreview(template)">
|
||||
<EyeOutlined /> 预览
|
||||
</a-button>
|
||||
<a-button v-if="!template.isDefault" shape="round" @click="handleSetDefault(template.id)">
|
||||
<CrownOutlined /> 设为默认
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="template.isDefault" class="default-badge">
|
||||
<CrownFilled /> 默认
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="template-info">
|
||||
<div class="info-header">
|
||||
<span class="template-name">{{ template.name }}</span>
|
||||
<span class="use-count" v-if="template.useCount">
|
||||
<UserOutlined /> {{ (template.useCount / 10000).toFixed(1) }}w 人在用
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="template-desc" :title="template.description">{{ template.description }}</p>
|
||||
|
||||
<div class="template-tags" v-if="template.tags && template.tags.length">
|
||||
<a-tag v-for="tag in template.tags" :key="tag" color="blue">{{ tag }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="template-actions">
|
||||
<span class="create-time">上架: {{ formatDate(template.createdAt) }}</span>
|
||||
<div class="action-buttons">
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" size="small" @click="handleEdit(template)">
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除" v-if="!template.isDefault">
|
||||
<a-popconfirm title="确定要删除此模板吗?" @confirm="handleDelete(template.id)">
|
||||
<a-button type="text" size="small" danger>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="previewVisible"
|
||||
:title="`模板预览 - ${currentPreview?.name}`"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
centered
|
||||
class="preview-modal"
|
||||
>
|
||||
<div class="template-preview-container">
|
||||
<OnlineResume
|
||||
v-if="previewTalent"
|
||||
:talent="previewTalent"
|
||||
:show-header="false"
|
||||
class="preview-content"
|
||||
/>
|
||||
<div class="preview-meta">
|
||||
<p>{{ currentPreview?.description }}</p>
|
||||
<div class="preview-tags" v-if="currentPreview?.tags">
|
||||
<a-tag v-for="tag in currentPreview.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑/添加弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑模板' : '添加模板'"
|
||||
:confirm-loading="modalLoading"
|
||||
@ok="handleModalSubmit"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-form-item label="模板名称" required>
|
||||
<a-input v-model:value="formData.name" placeholder="请输入模板名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="模板描述" required>
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
placeholder="请输入模板描述"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="标签">
|
||||
<a-select
|
||||
v-model:value="formData.tags"
|
||||
mode="tags"
|
||||
placeholder="请输入标签,回车确认"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="缩略图URL">
|
||||
<a-input v-model:value="formData.thumbnail" placeholder="请输入缩略图URL" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.thumbnail">
|
||||
<div class="preview-thumbnail">
|
||||
<span class="preview-label">缩略图预览</span>
|
||||
<img :src="formData.thumbnail" alt="预览" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CrownOutlined,
|
||||
CrownFilled,
|
||||
EyeOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ResumeTemplate, TalentProfile } from '@/types'
|
||||
import {
|
||||
mockGetResumeTemplates,
|
||||
mockUpdateResumeTemplate,
|
||||
mockSetDefaultTemplate,
|
||||
mockDeleteResumeTemplate,
|
||||
getAllTalentProfiles
|
||||
} from '@/mock'
|
||||
import OnlineResume from './components/OnlineResume.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const templates = ref<ResumeTemplate[]>([])
|
||||
const modalVisible = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
const currentPreview = ref<ResumeTemplate | null>(null)
|
||||
const previewTalent = ref<TalentProfile | null>(null)
|
||||
|
||||
// 获取预览用的示例数据
|
||||
const sampleTalents = getAllTalentProfiles()
|
||||
if (sampleTalents.length > 0) {
|
||||
previewTalent.value = sampleTalents[0] || null
|
||||
}
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
thumbnail: '',
|
||||
tags: [] as string[]
|
||||
})
|
||||
|
||||
async function loadTemplates() {
|
||||
loading.value = true
|
||||
try {
|
||||
templates.value = await mockGetResumeTemplates()
|
||||
} catch (e) {
|
||||
message.error('加载模板失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function showAddModal() {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
formData.value = { name: '', description: '', thumbnail: '', tags: [] }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(template: ResumeTemplate) {
|
||||
isEdit.value = true
|
||||
editId.value = template.id
|
||||
formData.value = {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
thumbnail: template.thumbnail,
|
||||
tags: template.tags || []
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handlePreview(template: ResumeTemplate) {
|
||||
currentPreview.value = template
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleModalSubmit() {
|
||||
if (!formData.value.name || !formData.value.description) {
|
||||
message.warning('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
modalLoading.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
// @ts-ignore
|
||||
await mockUpdateResumeTemplate(editId.value, formData.value)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
// 模拟添加(实际应调用添加API)
|
||||
message.success('添加成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
await loadTemplates()
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
modalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetDefault(id: number) {
|
||||
try {
|
||||
await mockSetDefaultTemplate(id)
|
||||
message.success('已设为默认模板')
|
||||
await loadTemplates()
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
const success = await mockDeleteResumeTemplate(id)
|
||||
if (success) {
|
||||
message.success('删除成功')
|
||||
await loadTemplates()
|
||||
} else {
|
||||
message.error('无法删除默认模板')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resume-template-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.template-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: #e6f7ff;
|
||||
}
|
||||
|
||||
.template-thumbnail-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4;
|
||||
background: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumbnail-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.template-item:hover .thumbnail-img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.thumbnail-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.template-item:hover .thumbnail-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: #faad14;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-shadow: 0 2px 8px rgba(250, 173, 20, 0.4);
|
||||
}
|
||||
|
||||
.template-info {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.use-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.template-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.template-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.template-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.create-time {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.preview-tags {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.template-preview-container {
|
||||
background: #f5f7fa;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.preview-thumbnail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.preview-thumbnail img {
|
||||
max-width: 200px;
|
||||
max-height: 280px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
670
src/views/user/certification/index.vue
Normal file
670
src/views/user/certification/index.vue
Normal file
@@ -0,0 +1,670 @@
|
||||
<template>
|
||||
<div class="certification-page">
|
||||
<a-page-header title="用户认证管理" sub-title="查看和管理平台用户的认证信息">
|
||||
<template #tags>
|
||||
<a-tag color="green">已认证 {{ stats.verified }}</a-tag>
|
||||
<a-tag color="blue">审核中 {{ stats.pending }}</a-tag>
|
||||
<a-tag color="red">已拒绝 {{ stats.rejected }}</a-tag>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-icon"><SafetyCertificateOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-title">总认证数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-verified">
|
||||
<div class="stat-icon"><CheckCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.verified }}</div>
|
||||
<div class="stat-title">已通过</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-pending">
|
||||
<div class="stat-icon"><ClockCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
<div class="stat-title">审核中</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-rejected">
|
||||
<div class="stat-icon"><CloseCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.rejected }}</div>
|
||||
<div class="stat-title">已拒绝</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-expired">
|
||||
<div class="stat-icon"><ExclamationCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.expired }}</div>
|
||||
<div class="stat-title">已过期</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-today">
|
||||
<div class="stat-icon"><CalendarOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.todayNew }}</div>
|
||||
<div class="stat-title">今日新增</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<a-card class="filter-card">
|
||||
<div class="search-bar">
|
||||
<a-form layout="inline" class="search-form">
|
||||
<a-form-item>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索用户名 / 邮箱 / 手机号"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="filterType"
|
||||
placeholder="认证类型"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="identity">实名认证</a-select-option>
|
||||
<a-select-option value="education">学历认证</a-select-option>
|
||||
<a-select-option value="professional">职业资格认证</a-select-option>
|
||||
<a-select-option value="enterprise">企业认证</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="filterStatus"
|
||||
placeholder="认证状态"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="verified">已认证</a-select-option>
|
||||
<a-select-option value="pending">审核中</a-select-option>
|
||||
<a-select-option value="rejected">已拒绝</a-select-option>
|
||||
<a-select-option value="expired">已过期</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
style="width: 240px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" @click="handleSearch">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-card class="main-card" :loading="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="certificationList"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'user'">
|
||||
<div class="user-cell">
|
||||
<a-avatar :src="record.userAvatar" :size="40" />
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ record.userName }}</div>
|
||||
<div class="user-contact">
|
||||
<span>{{ record.userPhone }}</span>
|
||||
<span>{{ record.userEmail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="getTypeColor(record.type)">
|
||||
{{ CertificationTypeMap[record.type as CertificationType] }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="CertificationStatusBadgeMap[record.status as CertificationStatus]"
|
||||
:text="CertificationStatusMap[record.status as CertificationStatus]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'certInfo'">
|
||||
<div class="cert-info">
|
||||
<template v-if="record.type === 'identity' && record.identityInfo">
|
||||
<div>姓名: {{ record.identityInfo.realName }}</div>
|
||||
<div>身份证: {{ record.identityInfo.idCardNumber }}</div>
|
||||
</template>
|
||||
<template v-else-if="record.type === 'education' && record.educationInfo">
|
||||
<div>{{ record.educationInfo.school }}</div>
|
||||
<div>{{ record.educationInfo.major }} · {{ record.educationInfo.degree }}</div>
|
||||
</template>
|
||||
<template v-else-if="record.type === 'professional' && record.professionalInfo">
|
||||
<div>{{ record.professionalInfo.certificateName }}</div>
|
||||
<div>{{ record.professionalInfo.issuer }}</div>
|
||||
</template>
|
||||
<template v-else-if="record.type === 'enterprise' && record.enterpriseInfo">
|
||||
<div>{{ record.enterpriseInfo.companyName }}</div>
|
||||
<div>法人: {{ record.enterpriseInfo.legalRepresentative }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'provider'">
|
||||
<div class="provider-info">
|
||||
<div>{{ record.thirdPartyProvider }}</div>
|
||||
<div class="order-id">{{ record.thirdPartyOrderId }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'time'">
|
||||
<div class="time-info">
|
||||
<div>提交: {{ formatDateTime(record.submittedAt) }}</div>
|
||||
<div v-if="record.verifiedAt">通过: {{ formatDateTime(record.verifiedAt) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="showDetail(record as CertificationRecord)">
|
||||
<EyeOutlined /> 详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
:title="currentRecord ? `${currentRecord.userName} - ${CertificationTypeMap[currentRecord.type]}` : ''"
|
||||
width="520"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="currentRecord">
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<!-- 用户信息 -->
|
||||
<a-descriptions-item label="用户信息">
|
||||
<div class="detail-user">
|
||||
<a-avatar :src="currentRecord.userAvatar" :size="48" />
|
||||
<div class="detail-user-info">
|
||||
<div class="detail-user-name">{{ currentRecord.userName }}</div>
|
||||
<div>{{ currentRecord.userPhone }}</div>
|
||||
<div>{{ currentRecord.userEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
|
||||
<!-- 认证状态 -->
|
||||
<a-descriptions-item label="认证状态">
|
||||
<a-badge
|
||||
:status="CertificationStatusBadgeMap[currentRecord.status as CertificationStatus]"
|
||||
:text="CertificationStatusMap[currentRecord.status as CertificationStatus]"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
|
||||
<!-- 认证类型 -->
|
||||
<a-descriptions-item label="认证类型">
|
||||
<a-tag :color="getTypeColor(currentRecord.type)">
|
||||
{{ CertificationTypeMap[currentRecord.type as CertificationType] }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 认证详情 -->
|
||||
<a-divider>认证信息</a-divider>
|
||||
|
||||
<!-- 实名认证详情 -->
|
||||
<template v-if="currentRecord.type === 'identity' && currentRecord.identityInfo">
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<a-descriptions-item label="真实姓名">
|
||||
{{ currentRecord.identityInfo.realName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="身份证号">
|
||||
{{ currentRecord.identityInfo.idCardNumber }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="人脸识别">
|
||||
<a-tag :color="currentRecord.identityInfo.faceVerified ? 'green' : 'default'">
|
||||
{{ currentRecord.identityInfo.faceVerified ? '已通过' : '未验证' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="认证时间" v-if="currentRecord.identityInfo.verifiedAt">
|
||||
{{ formatDateTime(currentRecord.identityInfo.verifiedAt) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
|
||||
<!-- 学历认证详情 -->
|
||||
<template v-else-if="currentRecord.type === 'education' && currentRecord.educationInfo">
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<a-descriptions-item label="学校名称">
|
||||
{{ currentRecord.educationInfo.school }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="专业">
|
||||
{{ currentRecord.educationInfo.major }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="学位">
|
||||
{{ currentRecord.educationInfo.degree }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="毕业年份">
|
||||
{{ currentRecord.educationInfo.graduationYear }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="证书编号">
|
||||
{{ currentRecord.educationInfo.certificateNumber }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="认证时间" v-if="currentRecord.educationInfo.verifiedAt">
|
||||
{{ formatDateTime(currentRecord.educationInfo.verifiedAt) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
|
||||
<!-- 职业资格认证详情 -->
|
||||
<template v-else-if="currentRecord.type === 'professional' && currentRecord.professionalInfo">
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<a-descriptions-item label="证书名称">
|
||||
{{ currentRecord.professionalInfo.certificateName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="证书编号">
|
||||
{{ currentRecord.professionalInfo.certificateNumber }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发证机构">
|
||||
{{ currentRecord.professionalInfo.issuer }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发证日期">
|
||||
{{ currentRecord.professionalInfo.issueDate }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="有效期至" v-if="currentRecord.professionalInfo.expiryDate">
|
||||
{{ currentRecord.professionalInfo.expiryDate }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="有效期" v-else>
|
||||
<a-tag color="green">永久有效</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="认证时间" v-if="currentRecord.professionalInfo.verifiedAt">
|
||||
{{ formatDateTime(currentRecord.professionalInfo.verifiedAt) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
|
||||
<!-- 企业认证详情 -->
|
||||
<template v-else-if="currentRecord.type === 'enterprise' && currentRecord.enterpriseInfo">
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<a-descriptions-item label="企业名称">
|
||||
{{ currentRecord.enterpriseInfo.companyName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="统一社会信用代码">
|
||||
{{ currentRecord.enterpriseInfo.unifiedSocialCreditCode }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="法定代表人">
|
||||
{{ currentRecord.enterpriseInfo.legalRepresentative }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="注册资本">
|
||||
{{ currentRecord.enterpriseInfo.registeredCapital }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="经营范围">
|
||||
{{ currentRecord.enterpriseInfo.businessScope }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="认证时间" v-if="currentRecord.enterpriseInfo.verifiedAt">
|
||||
{{ formatDateTime(currentRecord.enterpriseInfo.verifiedAt) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
|
||||
<!-- 第三方认证信息 -->
|
||||
<a-divider>第三方认证</a-divider>
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<a-descriptions-item label="认证提供商">
|
||||
{{ currentRecord.thirdPartyProvider }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="订单号">
|
||||
{{ currentRecord.thirdPartyOrderId }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="提交时间">
|
||||
{{ formatDateTime(currentRecord.submittedAt) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="认证通过时间" v-if="currentRecord.verifiedAt">
|
||||
{{ formatDateTime(currentRecord.verifiedAt) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 拒绝原因 -->
|
||||
<template v-if="currentRecord.status === 'rejected' && currentRecord.rejectReason">
|
||||
<a-divider>拒绝原因</a-divider>
|
||||
<a-alert type="error" :message="currentRecord.rejectReason" show-icon />
|
||||
</template>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import {
|
||||
SafetyCertificateOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CalendarOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { CertificationRecord, CertificationType, CertificationStatus } from '@/types'
|
||||
import { CertificationTypeMap, CertificationStatusMap, CertificationStatusBadgeMap } from '@/types'
|
||||
import { mockGetCertificationStats, mockGetCertificationList } from '@/mock'
|
||||
import { formatDateTime } from '@/utils/common'
|
||||
|
||||
const loading = ref(false)
|
||||
const certificationList = ref<CertificationRecord[]>([])
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
verified: 0,
|
||||
pending: 0,
|
||||
rejected: 0,
|
||||
expired: 0,
|
||||
todayNew: 0
|
||||
})
|
||||
|
||||
// 筛选条件
|
||||
const searchKeyword = ref('')
|
||||
const filterType = ref<CertificationType | undefined>()
|
||||
const filterStatus = ref<CertificationStatus | undefined>()
|
||||
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined)
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 详情抽屉
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<CertificationRecord | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ title: '用户', key: 'user', width: 200 },
|
||||
{ title: '认证类型', key: 'type', width: 120 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '认证信息', key: 'certInfo', width: 200 },
|
||||
{ title: '认证提供商', key: 'provider', width: 160 },
|
||||
{ title: '时间', key: 'time', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 100 }
|
||||
]
|
||||
|
||||
function getTypeColor(type: CertificationType): string {
|
||||
const colors: Record<CertificationType, string> = {
|
||||
identity: '#1890ff',
|
||||
education: '#52c41a',
|
||||
professional: '#722ed1',
|
||||
enterprise: '#fa8c16'
|
||||
}
|
||||
return colors[type]
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await mockGetCertificationStats()
|
||||
Object.assign(stats, data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetCertificationList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
startDate: dateRange.value?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange.value?.[1]?.format('YYYY-MM-DD')
|
||||
})
|
||||
certificationList.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchKeyword.value = ''
|
||||
filterType.value = undefined
|
||||
filterStatus.value = undefined
|
||||
dateRange.value = undefined
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current?: number; pageSize?: number }) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadData()
|
||||
}
|
||||
|
||||
function showDetail(record: CertificationRecord) {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.certification-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
.stat-verified {
|
||||
background: linear-gradient(135deg, #11998e, #38ef7d);
|
||||
}
|
||||
|
||||
.stat-pending {
|
||||
background: linear-gradient(135deg, #4facfe, #00f2fe);
|
||||
}
|
||||
|
||||
.stat-rejected {
|
||||
background: linear-gradient(135deg, #ff416c, #ff4b2b);
|
||||
}
|
||||
|
||||
.stat-expired {
|
||||
background: linear-gradient(135deg, #8e9eab, #eef2f3);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-today {
|
||||
background: linear-gradient(135deg, #fa709a, #fee140);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-form :deep(.ant-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.user-contact {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cert-info {
|
||||
font-size: 13px;
|
||||
color: #4a4f63;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.order-id {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
font-size: 12px;
|
||||
color: #606a80;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-user {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.detail-user-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
</style>
|
||||
291
src/views/user/levels/index.vue
Normal file
291
src/views/user/levels/index.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="level-manage">
|
||||
<a-page-header title="等级管理" sub-title="管理用户及人才的等级成长体系" />
|
||||
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<!-- 操作栏 -->
|
||||
<div class="table-operator">
|
||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||
<a-tab-pane key="post" tab="发帖等级" />
|
||||
<a-tab-pane key="order" tab="接单等级" />
|
||||
<a-tab-pane key="job" tab="岗位发布等级" />
|
||||
<a-tab-pane key="promotion" tab="用户晋升等级" />
|
||||
</a-tabs>
|
||||
<a-button type="primary" @click="handleAdd" style="margin-top: 16px">
|
||||
<PlusOutlined /> 新建等级
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataList"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="record.color">Lv.{{ record.level }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'condition'">
|
||||
<div class="condition-cell">
|
||||
<span>积分/经验 ≥ {{ record.minScore }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'rights'">
|
||||
<a-tag v-for="right in record.rights" :key="right">{{ right }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 'enabled'"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
@change="(checked: any) => record.status = checked ? 'enabled' : 'disabled'"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除该等级配置吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
@ok="handleModalOk"
|
||||
:confirm-loading="modalLoading"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="等级数值" name="level">
|
||||
<a-input-number v-model:value="formState.level" :min="1" style="width: 100%" placeholder="如:1" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="等级名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="如:见习人才" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="代表颜色" name="color">
|
||||
<a-input v-model:value="formState.color" type="color" style="width: 100px" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formState.status">
|
||||
<a-radio value="enabled">启用</a-radio>
|
||||
<a-radio value="disabled">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="left">升级条件</a-divider>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="所需积分/经验值" name="minScore">
|
||||
<a-input-number v-model:value="formState.minScore" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="等级权益 (回车添加)" name="rights">
|
||||
<a-select
|
||||
v-model:value="formState.rights"
|
||||
mode="tags"
|
||||
style="width: 100%"
|
||||
placeholder="输入权益描述并回车"
|
||||
:options="[]"
|
||||
></a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
|
||||
interface LevelItem {
|
||||
id: number
|
||||
type: 'post' | 'order' | 'job' | 'promotion' // 帖子, 接单, 岗位, 晋升
|
||||
level: number
|
||||
name: string
|
||||
color: string
|
||||
minScore: number // 积分/经验值要求
|
||||
rights: string[]
|
||||
status: 'enabled' | 'disabled'
|
||||
}
|
||||
|
||||
const levelTypeMap: Record<string, string> = {
|
||||
post: '发帖等级',
|
||||
order: '接单等级',
|
||||
job: '岗位发布等级',
|
||||
promotion: '用户晋升等级'
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('post')
|
||||
const dataList = ref<LevelItem[]>([])
|
||||
|
||||
const columns = [
|
||||
{ title: '等级', dataIndex: 'level', key: 'level', sorter: (a: any, b: any) => a.level - b.level },
|
||||
{ title: '等级名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '所需积分/经验', dataIndex: 'minScore', key: 'minScore' },
|
||||
{ title: '权益', key: 'rights' },
|
||||
{ title: '状态', key: 'status', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
// Modal相关
|
||||
const modalVisible = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formState = reactive<Omit<LevelItem, 'id'> & { id?: number }>({
|
||||
type: 'post',
|
||||
level: 1,
|
||||
name: '',
|
||||
color: '#1890ff',
|
||||
minScore: 0,
|
||||
rights: [],
|
||||
status: 'enabled'
|
||||
})
|
||||
|
||||
const rules: Record<string, any> = {
|
||||
level: [{ required: true, message: '请输入等级数值', trigger: 'change' }],
|
||||
name: [{ required: true, message: '请输入等级名称', trigger: 'blur' }],
|
||||
color: [{ required: true, message: '请选择颜色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
const typeName = levelTypeMap[activeTab.value]
|
||||
return (isEdit.value ? '编辑' : '新建') + typeName
|
||||
})
|
||||
|
||||
// Mock Data
|
||||
const mockLevels: LevelItem[] = [
|
||||
// 发帖等级
|
||||
{ id: 1, type: 'post', level: 1, name: '萌新', color: '#8c8c8c', minScore: 0, rights: ['每日发帖 3'], status: 'enabled' },
|
||||
{ id: 2, type: 'post', level: 2, name: '活跃', color: '#52c41a', minScore: 100, rights: ['每日发帖 10', '上传图片'], status: 'enabled' },
|
||||
{ id: 3, type: 'post', level: 3, name: '达人', color: '#faad14', minScore: 500, rights: ['无限发帖', '加精权限'], status: 'enabled' },
|
||||
|
||||
// 接单等级
|
||||
{ id: 11, type: 'order', level: 1, name: '见习', color: '#8c8c8c', minScore: 0, rights: ['同时进行 1 单'], status: 'enabled' },
|
||||
{ id: 12, type: 'order', level: 2, name: '正式', color: '#1890ff', minScore: 300, rights: ['同时进行 3 单'], status: 'enabled' },
|
||||
{ id: 13, type: 'order', level: 3, name: '专家', color: '#722ed1', minScore: 1000, rights: ['同时进行 5 单', '优先派单'], status: 'enabled' },
|
||||
|
||||
// 岗位发布等级
|
||||
{ id: 21, type: 'job', level: 1, name: '普通企业', color: '#8c8c8c', minScore: 0, rights: ['发布 3 个岗位'], status: 'enabled' },
|
||||
{ id: 22, type: 'job', level: 2, name: '认证企业', color: '#13c2c2', minScore: 500, rights: ['发布 10 个岗位', '岗位置顶'], status: 'enabled' },
|
||||
{ id: 23, type: 'job', level: 3, name: '金牌企业', color: '#f5222d', minScore: 2000, rights: ['无限发布', '专属顾问'], status: 'enabled' },
|
||||
|
||||
// 用户晋升等级
|
||||
{ id: 31, type: 'promotion', level: 1, name: '实习生', color: '#bfbfbf', minScore: 0, rights: ['入职培训'], status: 'enabled' },
|
||||
{ id: 32, type: 'promotion', level: 2, name: '初级职员', color: '#52c41a', minScore: 100, rights: ['五险一金'], status: 'enabled' },
|
||||
{ id: 33, type: 'promotion', level: 3, name: '中级骨干', color: '#1890ff', minScore: 500, rights: ['年终奖金'], status: 'enabled' },
|
||||
{ id: 34, type: 'promotion', level: 4, name: '高级专家', color: '#722ed1', minScore: 2000, rights: ['股票期权', '带薪休假'], status: 'enabled' },
|
||||
{ id: 35, type: 'promotion', level: 5, name: '资深总监', color: '#f5222d', minScore: 5000, rights: ['分红权益', '高端医疗'], status: 'enabled' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
function loadData() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
dataList.value = mockLevels
|
||||
.filter(item => item.type === activeTab.value)
|
||||
.sort((a, b) => a.level - b.level)
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
formState.id = undefined
|
||||
formState.level = (dataList.value.length || 0) + 1
|
||||
formState.name = ''
|
||||
formState.color = '#1890ff'
|
||||
formState.minScore = 0
|
||||
formState.rights = []
|
||||
formState.status = 'enabled'
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: any) {
|
||||
isEdit.value = true
|
||||
Object.assign(formState, JSON.parse(JSON.stringify(record)))
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (id) {
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModalOk() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
modalLoading.value = true
|
||||
setTimeout(() => {
|
||||
message.success(isEdit.value ? '修改成功' : '新建成功')
|
||||
modalVisible.value = false
|
||||
modalLoading.value = false
|
||||
loadData()
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
// validation failed
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.level-manage {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-operator {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.condition-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
1235
src/views/user/list/index.vue
Normal file
1235
src/views/user/list/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
266
src/views/user/positions/index.vue
Normal file
266
src/views/user/positions/index.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="position-manage">
|
||||
<a-page-header title="岗位管理" sub-title="管理开发者岗位及职级体系" />
|
||||
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="table-page-search-wrapper">
|
||||
<a-form layout="inline">
|
||||
<a-row :gutter="48">
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-form-item label="岗位名称">
|
||||
<a-input v-model:value="queryParam.name" placeholder="请输入岗位名称" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-form-item label="岗位编码">
|
||||
<a-input v-model:value="queryParam.code" placeholder="请输入岗位编码" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleQuery">查询</a-button>
|
||||
<a-button @click="resetQuery">重置</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="table-operator">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新建
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'normal' ? 'success' : 'error'"
|
||||
:text="record.status === 'normal' ? '正常' : '停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除该岗位吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
@ok="handleModalOk"
|
||||
:confirm-loading="modalLoading"
|
||||
>
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="岗位名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="如:高级前端工程师" />
|
||||
</a-form-item>
|
||||
<a-form-item label="岗位编码" name="code">
|
||||
<a-input v-model:value="formState.code" placeholder="如:senior_fe_dev" />
|
||||
</a-form-item>
|
||||
<a-form-item label="显示顺序" name="sort">
|
||||
<a-input-number v-model:value="formState.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="岗位状态" name="status">
|
||||
<a-radio-group v-model:value="formState.status">
|
||||
<a-radio value="normal">正常</a-radio>
|
||||
<a-radio value="disabled">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formState.remark" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
|
||||
interface PositionItem {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
sort: number
|
||||
status: 'normal' | 'disabled'
|
||||
remark?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// 查询参数
|
||||
const queryParam = reactive({
|
||||
name: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dataList = ref<PositionItem[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '岗位名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '岗位编码', dataIndex: 'code', key: 'code' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', sorter: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
// Modal相关
|
||||
const modalVisible = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formState = reactive<Omit<PositionItem, 'id' | 'createdAt'> & { id?: number }>({
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 0,
|
||||
status: 'normal',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules: Record<string, any> = {
|
||||
name: [{ required: true, message: '请输入岗位名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入岗位编码', trigger: 'blur' }],
|
||||
sort: [{ required: true, message: '请输入显示顺序', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => isEdit.value ? '编辑岗位' : '新建岗位')
|
||||
|
||||
// Mock Data
|
||||
const mockPositions: PositionItem[] = [
|
||||
{ id: 1, name: '项目经理', code: 'pm', sort: 1, status: 'normal', remark: '负责项目统筹', createdAt: '2024-01-01' },
|
||||
{ id: 2, name: '产品经理', code: 'pdm', sort: 2, status: 'normal', remark: '需求分析', createdAt: '2024-01-02' },
|
||||
{ id: 3, name: '架构师', code: 'architect', sort: 3, status: 'normal', remark: '技术架构设计', createdAt: '2024-01-03' },
|
||||
{ id: 4, name: '高级后端开发', code: 'senior_be', sort: 4, status: 'normal', remark: 'Java/Go/Node.js', createdAt: '2024-01-04' },
|
||||
{ id: 5, name: '高级前端开发', code: 'senior_fe', sort: 5, status: 'normal', remark: 'Vue/React', createdAt: '2024-01-05' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
function loadData() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
let list = [...mockPositions]
|
||||
if (queryParam.name) {
|
||||
list = list.filter(item => item.name.includes(queryParam.name))
|
||||
}
|
||||
if (queryParam.code) {
|
||||
list = list.filter(item => item.code.includes(queryParam.code))
|
||||
}
|
||||
dataList.value = list
|
||||
pagination.total = list.length
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
queryParam.name = ''
|
||||
queryParam.code = ''
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
formState.id = undefined
|
||||
formState.name = ''
|
||||
formState.code = ''
|
||||
formState.sort = 0
|
||||
formState.status = 'normal'
|
||||
formState.remark = ''
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: any) {
|
||||
isEdit.value = true
|
||||
Object.assign(formState, JSON.parse(JSON.stringify(record)))
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (id) {
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModalOk() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
modalLoading.value = true
|
||||
setTimeout(() => {
|
||||
message.success(isEdit.value ? '修改成功' : '新建成功')
|
||||
modalVisible.value = false
|
||||
modalLoading.value = false
|
||||
loadData()
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
// validation failed
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.position-manage {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-page-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-operator {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
265
src/views/user/roles/index.vue
Normal file
265
src/views/user/roles/index.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="role-manage">
|
||||
<a-page-header title="角色管理" sub-title="管理系统用户角色及权限分配" />
|
||||
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="table-page-search-wrapper">
|
||||
<a-form layout="inline" :model="queryParam">
|
||||
<a-form-item label="角色名称">
|
||||
<a-input v-model:value="queryParam.name" placeholder="请输入角色名称" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="权限字符">
|
||||
<a-input v-model:value="queryParam.key" placeholder="请输入权限字符" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleQuery">查询</a-button>
|
||||
<a-button @click="resetQuery">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="table-operator">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新建角色
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'enabled' ? 'success' : 'error'"
|
||||
:text="record.status === 'enabled' ? '正常' : '停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handleAssignPermission(record)">权限</a-button>
|
||||
<a-popconfirm title="确定删除该角色吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
@ok="handleModalOk"
|
||||
:confirm-loading="modalLoading"
|
||||
>
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="角色名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="如:超级管理员" />
|
||||
</a-form-item>
|
||||
<a-form-item label="权限字符" name="key">
|
||||
<a-input v-model:value="formState.key" placeholder="如:admin" />
|
||||
</a-form-item>
|
||||
<a-form-item label="显示顺序" name="sort">
|
||||
<a-input-number v-model:value="formState.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formState.status">
|
||||
<a-radio value="enabled">正常</a-radio>
|
||||
<a-radio value="disabled">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formState.remark" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
|
||||
interface RoleItem {
|
||||
id: number
|
||||
name: string
|
||||
key: string
|
||||
sort: number
|
||||
status: 'enabled' | 'disabled'
|
||||
remark?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// 查询参数
|
||||
const queryParam = reactive({
|
||||
name: '',
|
||||
key: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dataList = ref<RoleItem[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '角色名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '权限字符', dataIndex: 'key', key: 'key' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', sorter: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: '操作', key: 'action', width: 200 }
|
||||
]
|
||||
|
||||
// Modal相关
|
||||
const modalVisible = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formState = reactive<Omit<RoleItem, 'id' | 'createdAt'> & { id?: number }>({
|
||||
name: '',
|
||||
key: '',
|
||||
sort: 0,
|
||||
status: 'enabled',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules: Record<string, any> = {
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
key: [{ required: true, message: '请输入权限字符', trigger: 'blur' }],
|
||||
sort: [{ required: true, message: '请输入显示顺序', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => isEdit.value ? '编辑角色' : '新建角色')
|
||||
|
||||
// Mock Data - 包含用户请求的角色
|
||||
const mockRoles: RoleItem[] = [
|
||||
{ id: 1, name: '超级管理员', key: 'admin', sort: 1, status: 'enabled', remark: '系统全部权限', createdAt: '2024-01-01' },
|
||||
{ id: 2, name: '管理员', key: 'manager', sort: 2, status: 'enabled', remark: '日常运营管理', createdAt: '2024-01-02' },
|
||||
{ id: 3, name: '客服', key: 'support', sort: 3, status: 'enabled', remark: '客户服务与咨询', createdAt: '2024-01-03' },
|
||||
{ id: 4, name: '用户', key: 'user', sort: 4, status: 'enabled', remark: '普通注册用户', createdAt: '2024-01-04' },
|
||||
{ id: 5, name: '财务', key: 'finance', sort: 5, status: 'enabled', remark: '财务审批与核算', createdAt: '2024-01-05' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
function loadData() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
let list = [...mockRoles]
|
||||
if (queryParam.name) {
|
||||
list = list.filter(item => item.name.includes(queryParam.name))
|
||||
}
|
||||
if (queryParam.key) {
|
||||
list = list.filter(item => item.key.includes(queryParam.key))
|
||||
}
|
||||
dataList.value = list
|
||||
pagination.total = list.length
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
queryParam.name = ''
|
||||
queryParam.key = ''
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
formState.id = undefined
|
||||
formState.name = ''
|
||||
formState.key = ''
|
||||
formState.sort = 0
|
||||
formState.status = 'enabled'
|
||||
formState.remark = ''
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: any) {
|
||||
isEdit.value = true
|
||||
Object.assign(formState, JSON.parse(JSON.stringify(record)))
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleAssignPermission(record: any) {
|
||||
message.info(`正在为 ${record.name} 分配权限(功能开发中)`)
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (id) {
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModalOk() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
modalLoading.value = true
|
||||
setTimeout(() => {
|
||||
message.success(isEdit.value ? '修改成功' : '新建成功')
|
||||
modalVisible.value = false
|
||||
modalLoading.value = false
|
||||
loadData()
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
// validation failed
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.role-manage {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-page-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-operator {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
21
tsconfig.app.json
Normal file
21
tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
57
vite.config.ts
Normal file
57
vite.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// Vue函数自动导入
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
{
|
||||
'axios': [
|
||||
['default', 'axios']
|
||||
]
|
||||
}
|
||||
],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
eslintrc: {
|
||||
enabled: false
|
||||
}
|
||||
}),
|
||||
// 组件自动导入
|
||||
Components({
|
||||
resolvers: [
|
||||
// Ant Design Vue组件自动导入
|
||||
AntDesignVueResolver({
|
||||
importStyle: false
|
||||
}),
|
||||
// Ant Design Vue图标自动导入
|
||||
IconsResolver({
|
||||
prefix: 'icon',
|
||||
enabledCollections: ['ant-design']
|
||||
})
|
||||
],
|
||||
dts: 'src/components.d.ts'
|
||||
}),
|
||||
// 图标插件
|
||||
Icons({
|
||||
autoInstall: true,
|
||||
compiler: 'vue3'
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user