feat: 企业版合作模式(后端、管理端、小程序)
- API:合作模式配置、用户选择、企业版入口与迁移 SQL - 管理端:合作方式管理、企业/用户侧设置与 CSV 导出等 - 小程序:合作模式弹窗与结果/简历等页对接;开发脚本小调整 Made-with: Cursor
This commit is contained in:
@@ -51,6 +51,17 @@
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="user.cooperationModeTitle || user.cooperationModeCode" class="ud-coop-box">
|
||||
<div class="ud-coop-box__head"><el-icon><Connection /></el-icon> 合作意向</div>
|
||||
<div class="ud-coop-box__main">{{ user.cooperationModeTitle || user.cooperationModeCode }}</div>
|
||||
<div
|
||||
v-if="user.cooperationModeCode && user.cooperationModeTitle && user.cooperationModeCode !== user.cooperationModeTitle"
|
||||
class="ud-coop-box__code"
|
||||
>
|
||||
{{ user.cooperationModeCode }}
|
||||
</div>
|
||||
<div v-if="user.cooperationChosenAt" class="ud-coop-box__time">选择时间 {{ formatDate(user.cooperationChosenAt) }}</div>
|
||||
</div>
|
||||
<div class="ud-dimension-tags" v-if="profileTags.length">
|
||||
<div class="ud-dimension-tags__title">维度标签</div>
|
||||
<el-tag v-for="t in profileTags" :key="t" size="small" class="ud-dimension-tags__item">{{ t }}</el-tag>
|
||||
@@ -842,6 +853,44 @@ function openMail(email: string) {
|
||||
margin: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.ud-coop-box {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
.ud-coop-box__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
margin-bottom: 6px;
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
.ud-coop-box__main {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #14532d;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ud-coop-box__code {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.ud-coop-box__time {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.ud-main-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -76,7 +76,8 @@ import {
|
||||
User,
|
||||
ShoppingCart,
|
||||
Share,
|
||||
Setting
|
||||
Setting,
|
||||
Connection
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -90,6 +91,7 @@ const navItems = [
|
||||
{ path: '/admin/users', icon: User, label: '用户运营' },
|
||||
{ path: '/admin/orders', icon: ShoppingCart, label: '订单运营' },
|
||||
{ path: '/admin/distribution', icon: Share, label: '分销推广' },
|
||||
{ path: '/admin/cooperation-choices', icon: Connection, label: '合作意向' },
|
||||
{ path: '/admin/settings', icon: Setting, label: '企业设置' },
|
||||
]
|
||||
|
||||
@@ -103,6 +105,9 @@ const isActive = (path: string) => {
|
||||
if (path === '/admin/orders') {
|
||||
return route.path === '/admin/orders'
|
||||
}
|
||||
if (path === '/admin/cooperation-choices') {
|
||||
return route.path === '/admin/cooperation-choices'
|
||||
}
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/admin/Distribution.vue'),
|
||||
meta: { title: '分销推广' }
|
||||
},
|
||||
{
|
||||
path: 'cooperation-choices',
|
||||
name: 'AdminCooperationChoices',
|
||||
component: () => import('@/views/admin/CooperationChoices.vue'),
|
||||
meta: { title: '合作意向' }
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
redirect: { path: '/admin/orders', query: { tab: 'questions' } }
|
||||
|
||||
45
admin/src/utils/downloadCsv.ts
Normal file
45
admin/src/utils/downloadCsv.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getApiV1BaseURL } from '@/utils/request'
|
||||
import { getBearerTokenForCurrentApp } from '@/utils/authStorage'
|
||||
|
||||
/**
|
||||
* GET 下载 CSV(带当前后台 Bearer;不走 JSON 拦截器,用于 export 等二进制响应)
|
||||
*/
|
||||
export async function downloadCsvGet(path: string, filename: string, query?: Record<string, string | number | undefined>) {
|
||||
const base = getApiV1BaseURL().replace(/\/$/, '')
|
||||
const p = path.replace(/^\//, '')
|
||||
const fullPath = `${base}/${p}`.replace(/\/{2,}/g, '/')
|
||||
const u = new URL(fullPath, window.location.origin)
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === '') return
|
||||
u.searchParams.set(k, String(v))
|
||||
})
|
||||
}
|
||||
const token = getBearerTokenForCurrentApp()
|
||||
const res = await fetch(u.toString(), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try {
|
||||
const t = await res.text()
|
||||
if (t) {
|
||||
try {
|
||||
const j = JSON.parse(t)
|
||||
msg = j.message || j.msg || msg
|
||||
} catch {
|
||||
msg = t.slice(0, 200)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ function showBizErrorOnce(message: string) {
|
||||
}
|
||||
|
||||
// 获取API基础URL(开发环境留空 VITE_API_BASE_URL 时走同源 /api/v1,由 Vite 代理到本机后端)
|
||||
const getBaseURL = (): string => {
|
||||
export const getApiV1BaseURL = (): string => {
|
||||
const raw = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||
const envURL = typeof raw === 'string' ? raw.trim() : ''
|
||||
if (envURL) {
|
||||
@@ -28,6 +28,8 @@ const getBaseURL = (): string => {
|
||||
return '/api/v1'
|
||||
}
|
||||
|
||||
const getBaseURL = getApiV1BaseURL
|
||||
|
||||
// 本地连云库时接口可能较慢:开发环境默认放宽;可用 VITE_REQUEST_TIMEOUT_MS 覆盖
|
||||
const requestTimeoutMs = (() => {
|
||||
const raw = import.meta.env.VITE_REQUEST_TIMEOUT_MS
|
||||
|
||||
166
admin/src/views/admin/CooperationChoices.vue
Normal file
166
admin/src/views/admin/CooperationChoices.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>合作意向</h2>
|
||||
<p class="subtitle">用户在企业版完成三项测评并选择合作模式后的记录;可导出为 CSV 用 Excel 打开</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="!canUse"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前账号未绑定企业,无数据范围"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索微信昵称、手机号、模式代码"
|
||||
clearable
|
||||
class="search-input"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="onSearch" :loading="loading">查询</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出 CSV</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe class="data-table" empty-text="暂无选择记录">
|
||||
<el-table-column prop="userId" label="用户ID" width="88" />
|
||||
<el-table-column prop="nickname" label="微信昵称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="modeCode" label="模式代码" width="120" />
|
||||
<el-table-column prop="modeTitle" label="模式标题" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="chosenAtText" label="选择时间" width="170" />
|
||||
<el-table-column prop="updatedAtText" label="更新时间" width="170" />
|
||||
</el-table>
|
||||
|
||||
<div class="pager-wrap" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { downloadCsvGet } from '@/utils/downloadCsv'
|
||||
import { getAdminRole } from '@/utils/authStorage'
|
||||
|
||||
const canUse = computed(() => {
|
||||
const r = getAdminRole()
|
||||
return r === 'admin' || r === 'enterprise_admin'
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const keyword = ref('')
|
||||
|
||||
const load = async () => {
|
||||
if (!canUse.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/enterprise/cooperation-choices', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined
|
||||
}
|
||||
})
|
||||
if (res.code === 200) {
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total ?? 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
const doExport = async () => {
|
||||
if (!canUse.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const q: Record<string, string> = {}
|
||||
const k = keyword.value.trim()
|
||||
if (k) q.keyword = k
|
||||
await downloadCsvGet(
|
||||
'admin/enterprise/cooperation-choices/export',
|
||||
`cooperation-choices.csv`,
|
||||
q
|
||||
)
|
||||
ElMessage.success('已开始下载')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canUse.value) load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
.header-left h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search-input {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
.pager-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -62,6 +62,71 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isEnterpriseAdmin()" class="coop-embed-section" v-loading="coopLoading">
|
||||
<div class="cunkebao-embed-header">
|
||||
<h4 class="cunkebao-embed-title">合作模式</h4>
|
||||
<p class="hint-muted cunkebao-embed-desc">
|
||||
用户在本企业版完成简历、面相与 MBTI 后,可择一合作意向;与超管侧配置一致,仅影响本企业。代码为小写英文、数字、下划线(1–32 位),已保存的代码不可改。
|
||||
</p>
|
||||
</div>
|
||||
<div class="coop-embed-toolbar">
|
||||
<el-button type="primary" link @click="addCoopRow">+ 新增合作模式</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-if="coopModes.length"
|
||||
:data="coopModes"
|
||||
border
|
||||
size="small"
|
||||
class="w-full"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column label="代码" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.code"
|
||||
size="small"
|
||||
placeholder="如 partner_project"
|
||||
:disabled="row.codeLocked"
|
||||
maxlength="32"
|
||||
@blur="() => normalizeSettingsCoopCode(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="78" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" width="104" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.sortOrder" :min="0" :max="9999" size="small" controls-position="right" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标题" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.title" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.description" type="textarea" :rows="2" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="72" align="center" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link size="small" @click="removeCoopRow($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-else class="hint-muted">暂无配置,可点击「新增合作模式」添加。</p>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" class="save-btn" :loading="coopSaving" @click="saveCooperationModes">
|
||||
保存合作模式
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="canConfigureCunkebaoKeys()">
|
||||
<div class="cunkebao-embed-section" v-loading="cunkebaoLoading">
|
||||
<div class="cunkebao-embed-header">
|
||||
@@ -274,6 +339,117 @@ const cunkebaoUnified = reactive({
|
||||
const cunkebaoLoading = ref(false)
|
||||
const cunkebaoSaving = ref(false)
|
||||
|
||||
/** 企业管理员:合作模式 */
|
||||
type CoopModeRow = {
|
||||
code: string
|
||||
title: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
enabled: boolean
|
||||
codeLocked: boolean
|
||||
}
|
||||
|
||||
const coopModes = ref<CoopModeRow[]>([])
|
||||
const coopLoading = ref(false)
|
||||
const coopSaving = ref(false)
|
||||
|
||||
const COOP_CODE_RE = /^[a-z0-9_]{1,32}$/
|
||||
|
||||
const normalizeSettingsCoopCode = (row: CoopModeRow) => {
|
||||
row.code = String(row.code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '')
|
||||
}
|
||||
|
||||
const addCoopRow = () => {
|
||||
const maxSort = coopModes.value.reduce((m, r) => Math.max(m, Number(r.sortOrder) || 0), 0)
|
||||
coopModes.value.push({
|
||||
code: '',
|
||||
title: '',
|
||||
description: '',
|
||||
sortOrder: maxSort + 10,
|
||||
enabled: true,
|
||||
codeLocked: false
|
||||
})
|
||||
}
|
||||
|
||||
const removeCoopRow = (index: number) => {
|
||||
coopModes.value = coopModes.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const loadCooperationModes = async () => {
|
||||
if (!isEnterpriseAdmin()) return
|
||||
coopLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/enterprise/cooperation-modes')
|
||||
const list = res?.data?.list ?? []
|
||||
coopModes.value = Array.isArray(list)
|
||||
? list.map((row: any) => ({
|
||||
code: String(row.code || ''),
|
||||
title: String(row.title || ''),
|
||||
description: String(row.description || ''),
|
||||
sortOrder: Number(row.sortOrder) || 0,
|
||||
enabled: !!row.enabled,
|
||||
codeLocked: true
|
||||
}))
|
||||
: []
|
||||
} catch (e: any) {
|
||||
coopModes.value = []
|
||||
ElMessage.error(e?.message || '加载合作模式失败')
|
||||
} finally {
|
||||
coopLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveCooperationModes = async () => {
|
||||
if (!isEnterpriseAdmin()) return
|
||||
const seen = new Set<string>()
|
||||
const modes: Record<string, unknown>[] = []
|
||||
for (const r of coopModes.value) {
|
||||
const code = String(r.code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '')
|
||||
if (!code) {
|
||||
ElMessage.error('每行需填写模式代码,或先删除空行再保存')
|
||||
return
|
||||
}
|
||||
if (!COOP_CODE_RE.test(code)) {
|
||||
ElMessage.error(`模式代码不合法:${code}(仅 1–32 位小写字母、数字、下划线)`)
|
||||
return
|
||||
}
|
||||
if (seen.has(code)) {
|
||||
ElMessage.error(`模式代码重复:${code}`)
|
||||
return
|
||||
}
|
||||
seen.add(code)
|
||||
modes.push({
|
||||
modeCode: code,
|
||||
enabled: r.enabled,
|
||||
sortOrder: r.sortOrder,
|
||||
title: r.title,
|
||||
description: r.description
|
||||
})
|
||||
}
|
||||
if (modes.length === 0) {
|
||||
ElMessage.error('请至少保留一条合作模式')
|
||||
return
|
||||
}
|
||||
coopSaving.value = true
|
||||
try {
|
||||
const res: any = await request.put('/admin/enterprise/cooperation-modes', { modes })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('合作模式已保存')
|
||||
await loadCooperationModes()
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
coopSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadCunkebaoKeys = async () => {
|
||||
if (!canConfigureCunkebaoKeys()) return
|
||||
cunkebaoLoading.value = true
|
||||
@@ -410,6 +586,7 @@ watch(
|
||||
if (tab === 'features') {
|
||||
if (isEnterpriseAdmin()) {
|
||||
loadAdminPermissions()
|
||||
loadCooperationModes()
|
||||
}
|
||||
if (canConfigureCunkebaoKeys()) {
|
||||
loadCunkebaoKeys()
|
||||
@@ -427,6 +604,7 @@ onMounted(() => {
|
||||
if (activeTab.value === 'features') {
|
||||
if (isEnterpriseAdmin()) {
|
||||
loadAdminPermissions()
|
||||
loadCooperationModes()
|
||||
}
|
||||
if (canConfigureCunkebaoKeys()) {
|
||||
loadCunkebaoKeys()
|
||||
@@ -469,6 +647,16 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.coop-embed-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.coop-embed-toolbar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cunkebao-embed-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-summary profile-summary--five">
|
||||
<div class="profile-summary profile-summary--six">
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-blue">👥</div>
|
||||
<div class="summary-body">
|
||||
@@ -61,6 +61,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-coop">🤝</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">已选合作意向</div>
|
||||
<div class="summary-value">
|
||||
{{ profileStats.cooperationCount }}
|
||||
<span class="summary-sub">/ {{ pageUserCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
@@ -149,6 +159,20 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="合作意向" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.cooperationModeTitle || row.cooperationModeCode" class="coop-cell">
|
||||
<span class="coop-title">{{ row.cooperationModeTitle || row.cooperationModeCode || '—' }}</span>
|
||||
<span
|
||||
v-if="row.cooperationModeCode && row.cooperationModeTitle && row.cooperationModeCode !== row.cooperationModeTitle"
|
||||
class="coop-code"
|
||||
>{{ row.cooperationModeCode }}</span>
|
||||
<div v-if="row.cooperationChosenAt" class="coop-time">{{ formatDate(row.cooperationChosenAt) }}</div>
|
||||
</div>
|
||||
<span v-else class="coop-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="测评次数 · 最近活跃" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="activity-cell">
|
||||
@@ -751,6 +775,7 @@ const profileStats = computed(() => {
|
||||
let mbtiCount = 0
|
||||
let sbtiCount = 0
|
||||
let anyTestCount = 0
|
||||
let cooperationCount = 0
|
||||
for (const u of list) {
|
||||
const hasFace = !!(
|
||||
u.faceMbtiType ||
|
||||
@@ -767,8 +792,9 @@ const profileStats = computed(() => {
|
||||
if (hasMbti) mbtiCount++
|
||||
if (hasSbti) sbtiCount++
|
||||
if (hasFace || hasMbti || hasSbti || hasDisc || hasPdp) anyTestCount++
|
||||
if (u.cooperationModeCode || u.cooperationModeTitle || u.cooperationChosenAt) cooperationCount++
|
||||
}
|
||||
return { faceCount, mbtiCount, sbtiCount, anyTestCount }
|
||||
return { faceCount, mbtiCount, sbtiCount, anyTestCount, cooperationCount }
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -1242,6 +1268,16 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&.profile-summary--six {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
@media (max-width: 1280px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -1278,6 +1314,7 @@ onMounted(() => {
|
||||
&.ic-indigo { background: #eef2ff; }
|
||||
&.ic-violet { background: #f5f3ff; }
|
||||
&.ic-amber { background: #fffbeb; }
|
||||
&.ic-coop { background: #f0fdf4; }
|
||||
}
|
||||
|
||||
.summary-body {
|
||||
@@ -1380,6 +1417,29 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.coop-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.coop-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
.coop-code {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.coop-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.coop-empty {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.user-info-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
193
admin/src/views/superadmin/CooperationChoicesPanel.vue
Normal file
193
admin/src/views/superadmin/CooperationChoicesPanel.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="ccp-wrap">
|
||||
<p class="ccp-hint">
|
||||
先选择要查看的企业,再加载或导出该企业在小程序中记录的用户合作意向(与表 <code>mbti_user_cooperation_choices</code> 一致)。
|
||||
</p>
|
||||
|
||||
<div class="ccp-toolbar">
|
||||
<el-select
|
||||
v-model="enterpriseId"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择企业"
|
||||
class="ccp-select"
|
||||
:loading="entLoading"
|
||||
@visible-change="onEntDropdown"
|
||||
>
|
||||
<el-option v-for="e in entOptions" :key="e.id" :label="e.label" :value="e.id" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索昵称/手机/模式代码"
|
||||
clearable
|
||||
class="ccp-search"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-button type="primary" :disabled="!enterpriseId" :loading="loading" @click="onSearch">查询</el-button>
|
||||
<el-button :disabled="!enterpriseId" :loading="exporting" @click="doExport">导出 CSV</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe class="ccp-table" empty-text="暂无数据或无权限">
|
||||
<el-table-column prop="enterpriseId" label="企业ID" width="88" />
|
||||
<el-table-column prop="enterpriseName" label="企业名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="userId" label="用户ID" width="88" />
|
||||
<el-table-column prop="nickname" label="微信昵称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="modeCode" label="模式代码" width="120" />
|
||||
<el-table-column prop="modeTitle" label="模式标题" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="chosenAtText" label="选择时间" width="170" />
|
||||
<el-table-column prop="updatedAtText" label="更新时间" width="170" />
|
||||
</el-table>
|
||||
|
||||
<div class="pager-wrap" v-if="enterpriseId && total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { downloadCsvGet } from '@/utils/downloadCsv'
|
||||
|
||||
const entLoading = ref(false)
|
||||
const entOptions = ref<{ id: number; label: string }[]>([])
|
||||
|
||||
const enterpriseId = ref<number | undefined>(undefined)
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const loadEntOptions = async () => {
|
||||
if (entOptions.value.length > 0) return
|
||||
entLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/enterprises', { params: { page: 1, pageSize: 500 } })
|
||||
const raw = res?.data?.list ?? res?.data?.data?.list
|
||||
const rows = Array.isArray(raw) ? raw : []
|
||||
entOptions.value = rows.map((r: any) => ({
|
||||
id: Number(r.id),
|
||||
label: `${r.name || '未命名'} (#${r.id})`
|
||||
}))
|
||||
} catch {
|
||||
entOptions.value = []
|
||||
} finally {
|
||||
entLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onEntDropdown = (open: boolean) => {
|
||||
if (open) loadEntOptions()
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
const eid = enterpriseId.value
|
||||
if (!eid) {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/superadmin/enterprises/${eid}/cooperation-choices`, {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined
|
||||
}
|
||||
})
|
||||
if (res.code === 200) {
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total ?? 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
const doExport = async () => {
|
||||
const eid = enterpriseId.value
|
||||
if (!eid) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const q: Record<string, string> = {}
|
||||
const k = keyword.value.trim()
|
||||
if (k) q.keyword = k
|
||||
await downloadCsvGet(
|
||||
`superadmin/enterprises/${eid}/cooperation-choices/export`,
|
||||
`cooperation-choices-e${eid}.csv`,
|
||||
q
|
||||
)
|
||||
ElMessage.success('已开始下载')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(enterpriseId, () => {
|
||||
page.value = 1
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ccp-wrap {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.ccp-hint {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 16px 0;
|
||||
code {
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
.ccp-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ccp-select {
|
||||
min-width: 280px;
|
||||
}
|
||||
.ccp-search {
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ccp-table {
|
||||
width: 100%;
|
||||
}
|
||||
.pager-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@
|
||||
<Enterprises v-if="activeTab === 'companies'" embedded />
|
||||
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
|
||||
<SoulArticles v-if="activeTab === 'soulArticles'" />
|
||||
<CooperationChoicesPanel v-if="activeTab === 'cooperation'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,10 +40,11 @@ import { request } from '@/utils/request'
|
||||
import Enterprises from './Enterprises.vue'
|
||||
import Users from './Users.vue'
|
||||
import SoulArticles from './SoulArticles.vue'
|
||||
import CooperationChoicesPanel from './CooperationChoicesPanel.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['companies', 'users', 'soulArticles'] as const
|
||||
const TAB_IDS = ['companies', 'users', 'soulArticles', 'cooperation'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
@@ -96,7 +98,8 @@ async function runOrphanMigrate() {
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '企业列表', value: 'companies' },
|
||||
{ label: '用户总览', value: 'users' },
|
||||
{ label: '引流文章', value: 'soulArticles' }
|
||||
{ label: '引流文章', value: 'soulArticles' },
|
||||
{ label: '合作意向', value: 'cooperation' }
|
||||
]
|
||||
|
||||
function queryWithoutTab(): Record<string, string> {
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
<el-dialog
|
||||
v-model="showEditDialog"
|
||||
title="编辑企业"
|
||||
width="440px"
|
||||
width="min(720px, 96vw)"
|
||||
class="custom-dialog"
|
||||
:show-close="true"
|
||||
align-center
|
||||
@@ -331,6 +331,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="合作模式(企业版小程序)">
|
||||
<div v-loading="editCoopLoading" class="coop-edit-block">
|
||||
<p class="perm-form-hint">
|
||||
用户在该企业完成简历、面相与 MBTI 后将弹出选择;关闭项不会对用户展示。代码为小写英文、数字、下划线(1–32 位),保存后不可改。
|
||||
</p>
|
||||
<div class="coop-edit-toolbar">
|
||||
<el-button type="primary" link @click="addEditCoopRow">+ 新增合作模式</el-button>
|
||||
</div>
|
||||
<el-table v-if="editCoopModes.length" :data="editCoopModes" border size="small" class="coop-edit-table">
|
||||
<el-table-column label="代码" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.code"
|
||||
size="small"
|
||||
placeholder="如 partner_project"
|
||||
:disabled="row.codeLocked"
|
||||
maxlength="32"
|
||||
@blur="() => normalizeCoopCode(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="78" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" width="104" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.sortOrder" :min="0" :max="9999" size="small" controls-position="right" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标题" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.title" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.description" type="textarea" :rows="2" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="72" align="center" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link size="small" @click="removeEditCoopRow($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-else class="hint-muted">暂无配置,可点击「新增合作模式」添加;保存后未包含的旧记录将从数据库删除。</p>
|
||||
<div class="coop-edit-actions">
|
||||
<el-button type="primary" plain size="small" :loading="editCoopSaving" @click="saveEditCooperationModes">
|
||||
保存合作模式
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@@ -808,6 +864,119 @@ const inviteQrcodeEnterpriseB64 = ref('')
|
||||
const inviteQrcodePersonalB64 = ref('')
|
||||
const inviteQrcodeError = ref('')
|
||||
|
||||
/** 编辑弹窗:合作模式(GET/PUT enterprises/:id/cooperation-modes) */
|
||||
type EditCoopRow = {
|
||||
code: string
|
||||
title: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
enabled: boolean
|
||||
/** 服务端已有记录为 true,保存后代码不可改 */
|
||||
codeLocked: boolean
|
||||
}
|
||||
|
||||
const editCoopModes = ref<EditCoopRow[]>([])
|
||||
const editCoopLoading = ref(false)
|
||||
const editCoopSaving = ref(false)
|
||||
|
||||
const normalizeCoopCode = (row: EditCoopRow) => {
|
||||
row.code = String(row.code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '')
|
||||
}
|
||||
|
||||
const addEditCoopRow = () => {
|
||||
const maxSort = editCoopModes.value.reduce((m, r) => Math.max(m, Number(r.sortOrder) || 0), 0)
|
||||
editCoopModes.value.push({
|
||||
code: '',
|
||||
title: '',
|
||||
description: '',
|
||||
sortOrder: maxSort + 10,
|
||||
enabled: true,
|
||||
codeLocked: false
|
||||
})
|
||||
}
|
||||
|
||||
const removeEditCoopRow = (index: number) => {
|
||||
editCoopModes.value = editCoopModes.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const loadEditCooperationModes = async () => {
|
||||
const id = currentEditId.value
|
||||
if (!id) return
|
||||
editCoopLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/enterprises/${id}/cooperation-modes`)
|
||||
const list = res?.data?.list ?? []
|
||||
editCoopModes.value = Array.isArray(list)
|
||||
? list.map((row: any) => ({
|
||||
code: String(row.code || ''),
|
||||
title: String(row.title || ''),
|
||||
description: String(row.description || ''),
|
||||
sortOrder: Number(row.sortOrder) || 0,
|
||||
enabled: !!row.enabled,
|
||||
codeLocked: true
|
||||
}))
|
||||
: []
|
||||
} catch (e: any) {
|
||||
editCoopModes.value = []
|
||||
ElMessage.error(e?.message || '加载合作模式失败')
|
||||
} finally {
|
||||
editCoopLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEditCooperationModes = async () => {
|
||||
const id = currentEditId.value
|
||||
if (!id) return
|
||||
const COOP_CODE_RE = /^[a-z0-9_]{1,32}$/
|
||||
const seen = new Set<string>()
|
||||
const modes: Record<string, unknown>[] = []
|
||||
for (const r of editCoopModes.value) {
|
||||
const code = String(r.code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '')
|
||||
if (!code) {
|
||||
ElMessage.error('每行需填写模式代码,或先删除空行再保存')
|
||||
return
|
||||
}
|
||||
if (!COOP_CODE_RE.test(code)) {
|
||||
ElMessage.error(`模式代码不合法:${code}(仅 1–32 位小写字母、数字、下划线)`)
|
||||
return
|
||||
}
|
||||
if (seen.has(code)) {
|
||||
ElMessage.error(`模式代码重复:${code}`)
|
||||
return
|
||||
}
|
||||
seen.add(code)
|
||||
modes.push({
|
||||
modeCode: code,
|
||||
enabled: r.enabled,
|
||||
sortOrder: r.sortOrder,
|
||||
title: r.title,
|
||||
description: r.description
|
||||
})
|
||||
}
|
||||
if (modes.length === 0) {
|
||||
ElMessage.error('请至少保留一条合作模式')
|
||||
return
|
||||
}
|
||||
editCoopSaving.value = true
|
||||
try {
|
||||
const res: any = await request.put(`/enterprises/${id}/cooperation-modes`, { modes })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('合作模式已保存')
|
||||
await loadEditCooperationModes()
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
editCoopSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetInviteQrcodeDialog = () => {
|
||||
inviteDialogEnterprise.value = null
|
||||
inviteQrcodeEnterpriseB64.value = ''
|
||||
@@ -1248,6 +1417,7 @@ const handleEditDialogOpen = async () => {
|
||||
|
||||
console.log('表单数据已填充:', editEnterprise)
|
||||
pendingEditData.value = null
|
||||
await loadEditCooperationModes()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2200,6 +2370,10 @@ watch([searchTerm, statusFilter], () => {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.coop-edit-toolbar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 权限开关组(编辑 / 创建弹窗) */
|
||||
.perm-switch-group {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user