first commit

This commit is contained in:
super
2025-12-28 22:09:44 +08:00
commit 5457ce7cf4
83 changed files with 24640 additions and 0 deletions

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
VITE_APP_TITLE=CodePort 码头
VITE_API_BASE_URL=http://localhost:3000/api

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
VITE_APP_TITLE=CodePort 码头
VITE_API_BASE_URL=/api

24
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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']
}
}

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

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

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

@@ -0,0 +1,4 @@
/**
* 配置模块统一导出
*/
export * from './project'

324
src/config/project.ts Normal file
View 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 []
}

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View 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')
}
}
})