feat: 企业版合作模式(后端、管理端、小程序)

- API:合作模式配置、用户选择、企业版入口与迁移 SQL

- 管理端:合作方式管理、企业/用户侧设置与 CSV 导出等

- 小程序:合作模式弹窗与结果/简历等页对接;开发脚本小调整

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-23 16:08:13 +08:00
parent d7137f9349
commit 830e3611d7
40 changed files with 2518 additions and 33 deletions

View File

@@ -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;

View File

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

View File

@@ -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' } }

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

View File

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

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

View File

@@ -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 可择一合作意向与超管侧配置一致仅影响本企业代码为小写英文数字下划线132 已保存的代码不可改
</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}(仅 132 位小写字母、数字、下划线)`)
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;

View File

@@ -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;

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

View File

@@ -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> {

View File

@@ -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 后将弹出选择关闭项不会对用户展示代码为小写英文数字下划线132 保存后不可改
</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}(仅 132 位小写字母、数字、下划线)`)
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;