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;

View File

@@ -35,7 +35,8 @@ export default defineConfig({
server: {
host: '0.0.0.0', // 允许局域网访问
port: Number(process.env.MBTI_ADMIN_PORT) || 5173,
strictPort: true, // 端口被占用则直接失败,避免静默改端口导致「打不开」
// false与其它 Vite 项目(如万推同用 5173并存时占用则自动用 5174、5175…请以终端打印的 Local 为准
strictPort: false,
proxy: {
'/api': {
// 与 .env.development 配合VITE_API_BASE_URL 留空时,浏览器请求 /api/* 由这里转发到本机 ThinkPHP

View File

@@ -0,0 +1,450 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 企业版:三项完成判定 + 合作模式配置与用户选择
*/
class EnterpriseCooperationService
{
public const MODE_SALARY = 'salary';
public const MODE_STARTUP_EQUITY = 'startup_equity';
public const MODE_KNOWLEDGE_PAY = 'knowledge_pay';
/** @return array<string, array{title:string,description:string}> */
public static function builtinModeDefs(): array
{
return [
self::MODE_SALARY => [
'title' => '工资',
'description' => '全职/薪资导向合作',
],
self::MODE_STARTUP_EQUITY => [
'title' => '创业分红',
'description' => '合伙/股权激励类合作',
],
self::MODE_KNOWLEDGE_PAY => [
'title' => '知识付费',
'description' => '课程/咨询/付费内容类合作',
],
];
}
public static function validModeCodes(): array
{
return array_keys(self::builtinModeDefs());
}
/** 与表字段 modeCode varchar(32) 一致:小写字母/数字/下划线 */
public static function normalizeModeCode(string $code): string
{
return strtolower(trim($code));
}
public static function isValidModeCodeString(string $code): bool
{
if ($code === '' || strlen($code) > 32) {
return false;
}
return (bool) preg_match('/^[a-z0-9_]+$/', $code);
}
public static function getWechatEnterpriseId(int $wechatUserId): ?int
{
$row = Db::name('wechat_users')->where('id', $wechatUserId)->field('enterpriseId')->find();
if (!$row) {
return null;
}
$eid = (int) ($row['enterpriseId'] ?? 0);
return $eid > 0 ? $eid : null;
}
/**
* 简历环节:该企业下有上传记录,或有 enterpriseId 匹配的 resume 测评记录
*/
public static function resumeDone(int $userId, int $enterpriseId): bool
{
$n = (int) Db::name('enterprise_resume_uploads')
->where('userId', $userId)
->where('enterpriseId', $enterpriseId)
->count();
if ($n > 0) {
return true;
}
return (int) Db::name('test_results')
->where('userId', $userId)
->where('enterpriseId', $enterpriseId)
->where('testType', 'resume')
->count() > 0;
}
/**
* 面相/人脸testType in face|ai且 enterpriseId 一致(首版 strictly 绑定企业维度)
*/
public static function faceDone(int $userId, int $enterpriseId): bool
{
return (int) Db::name('test_results')
->where('userId', $userId)
->where('enterpriseId', $enterpriseId)
->whereIn('testType', ['face', 'ai'])
->count() > 0;
}
public static function mbtiDone(int $userId, int $enterpriseId): bool
{
return (int) Db::name('test_results')
->where('userId', $userId)
->where('enterpriseId', $enterpriseId)
->where('testType', 'mbti')
->count() > 0;
}
/** @return array{resumeDone:bool,faceDone:bool,mbtiDone:bool} */
public static function onboardingFlags(int $userId, int $enterpriseId): array
{
return [
'resumeDone' => self::resumeDone($userId, $enterpriseId),
'faceDone' => self::faceDone($userId, $enterpriseId),
'mbtiDone' => self::mbtiDone($userId, $enterpriseId),
];
}
/**
* 新企业、尚无任意合作模式时,写入三条默认(仅当该企业 0 条记录,避免管理端删行后再次读列表又被补回)
*/
public static function ensureDefaultConfigs(int $enterpriseId): void
{
$n = (int) Db::name('enterprise_cooperation_modes')
->where('enterpriseId', $enterpriseId)
->count();
if ($n > 0) {
return;
}
$defs = self::builtinModeDefs();
$now = time();
foreach ($defs as $code => $meta) {
Db::name('enterprise_cooperation_modes')->insert([
'enterpriseId' => $enterpriseId,
'modeCode' => $code,
'enabled' => 1,
'sortOrder' => (int) (array_search($code, array_keys($defs), true) * 10),
'title' => $meta['title'],
'description' => $meta['description'],
'createdAt' => $now,
'updatedAt' => $now,
]);
}
}
/**
* @return list<array{code:string,title:string,description:string,sortOrder:int,enabled:bool}>
*/
public static function listModesForEnterprise(int $enterpriseId, bool $onlyEnabled): array
{
self::ensureDefaultConfigs($enterpriseId);
$q = Db::name('enterprise_cooperation_modes')
->where('enterpriseId', $enterpriseId)
->order('sortOrder', 'asc')
->order('id', 'asc');
if ($onlyEnabled) {
$q->where('enabled', 1);
}
$rows = $q->select()->toArray();
$out = [];
$defs = self::builtinModeDefs();
foreach ($rows as $row) {
$code = (string) ($row['modeCode'] ?? '');
if ($code === '' || !self::isValidModeCodeString($code)) {
continue;
}
$def = $defs[$code] ?? null;
$title = trim((string) ($row['title'] ?? ''));
if ($title === '' && $def) {
$title = $def['title'];
}
if ($title === '') {
$title = '合作模式';
}
$desc = $row['description'] ?? null;
$description = $desc !== null && (string) $desc !== ''
? (string) $desc
: ($def['description'] ?? '');
$out[] = [
'code' => $code,
'title' => $title,
'description' => $description,
'sortOrder' => (int) ($row['sortOrder'] ?? 0),
'enabled' => (int) ($row['enabled'] ?? 0) === 1,
];
}
return $out;
}
/**
* 批量写入配置(超管 / 企业管理员)
*
* @param list<array{modeCode:string,enabled?:bool,sortOrder?:int,title?:string,description?:string}> $modes
*/
public static function saveConfigs(int $enterpriseId, array $modes): void
{
$defs = self::builtinModeDefs();
$now = time();
$byCode = [];
foreach ($modes as $item) {
$raw = (string) ($item['modeCode'] ?? $item['code'] ?? '');
$code = self::normalizeModeCode($raw);
if ($code === '' || !self::isValidModeCodeString($code)) {
continue;
}
$byCode[$code] = $item;
}
if ($byCode === []) {
throw new \InvalidArgumentException('请至少保存一条合作模式,且模式代码为 132 位小写字母、数字或下划线');
}
$codes = array_keys($byCode);
Db::startTrans();
try {
Db::name('enterprise_cooperation_modes')
->where('enterpriseId', $enterpriseId)
->whereNotIn('modeCode', $codes)
->delete();
foreach ($byCode as $code => $item) {
$row = Db::name('enterprise_cooperation_modes')
->where('enterpriseId', $enterpriseId)
->where('modeCode', $code)
->find();
$def = $defs[$code] ?? null;
$t = trim((string) ($item['title'] ?? ''));
$title = $t !== '' ? $t : ($def['title'] ?? '合作模式');
$description = array_key_exists('description', $item)
? (string) $item['description']
: ($def['description'] ?? '');
$payload = [
'enabled' => isset($item['enabled']) ? ((int) (bool) $item['enabled']) : 1,
'sortOrder' => isset($item['sortOrder']) ? (int) $item['sortOrder'] : 0,
'title' => $title,
'description' => $description,
'updatedAt' => $now,
];
if ($row) {
Db::name('enterprise_cooperation_modes')
->where('id', (int) $row['id'])
->update($payload);
} else {
$payload['enterpriseId'] = $enterpriseId;
$payload['modeCode'] = $code;
$payload['createdAt'] = $now;
Db::name('enterprise_cooperation_modes')->insert($payload);
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
public static function getUserChoice(int $userId, int $enterpriseId): ?array
{
$row = Db::name('user_cooperation_choices')
->where('userId', $userId)
->where('enterpriseId', $enterpriseId)
->find();
return $row ?: null;
}
public static function saveUserChoice(int $userId, int $enterpriseId, string $modeCode): void
{
$modeCode = self::normalizeModeCode($modeCode);
$now = time();
$row = Db::name('user_cooperation_choices')
->where('userId', $userId)
->where('enterpriseId', $enterpriseId)
->find();
if ($row) {
Db::name('user_cooperation_choices')
->where('id', (int) $row['id'])
->update([
'modeCode' => $modeCode,
'chosenAt' => $now,
'updatedAt' => $now,
]);
} else {
Db::name('user_cooperation_choices')->insert([
'userId' => $userId,
'enterpriseId' => $enterpriseId,
'modeCode' => $modeCode,
'chosenAt' => $now,
'updatedAt' => $now,
]);
}
}
public static function isModeEnabledForEnterprise(int $enterpriseId, string $modeCode): bool
{
$code = self::normalizeModeCode($modeCode);
$row = Db::name('enterprise_cooperation_modes')
->where('enterpriseId', $enterpriseId)
->where('modeCode', $code)
->find();
if (!$row) {
return false;
}
return (int) ($row['enabled'] ?? 0) === 1;
}
/**
* 管理端:某企业下用户合作意向查询(带筛选)
*/
private static function userCooperationChoicesBaseQuery(int $enterpriseId, string $keyword = '')
{
$q = Db::name('user_cooperation_choices')->alias('uc')
->join('wechat_users w', 'uc.userId = w.id')
->leftJoin('enterprise_cooperation_modes ecm', 'ecm.enterpriseId = uc.enterpriseId AND ecm.modeCode = uc.modeCode')
->leftJoin('enterprises ent', 'ent.id = uc.enterpriseId')
->where('uc.enterpriseId', $enterpriseId);
if ($keyword !== '') {
$like = '%' . addcslashes($keyword, '%_\\') . '%';
$q->where(function ($query) use ($like) {
$query->where('w.nickname', 'like', $like)
->whereOr('w.phone', 'like', $like)
->whereOr('uc.modeCode', 'like', $like);
});
}
return $q;
}
private static function mapUserCooperationChoiceRow(array $row): array
{
$ca = (int) ($row['chosenAt'] ?? 0);
$ua = (int) ($row['updatedAt'] ?? 0);
return [
'id' => (int) ($row['id'] ?? 0),
'userId' => (int) ($row['userId'] ?? 0),
'enterpriseId' => (int) ($row['enterpriseId'] ?? 0),
'enterpriseName' => (string) ($row['enterpriseName'] ?? ''),
'modeCode' => (string) ($row['modeCode'] ?? ''),
'modeTitle' => (string) ($row['modeTitle'] ?? ''),
'nickname' => (string) ($row['nickname'] ?? ''),
'phone' => (string) ($row['phone'] ?? ''),
'chosenAt' => $ca,
'updatedAt' => $ua,
'chosenAtText' => $ca > 0 ? date('Y-m-d H:i:s', $ca) : '',
'updatedAtText' => $ua > 0 ? date('Y-m-d H:i:s', $ua) : '',
];
}
/**
* @return array{list: list<array>, total: int}
*/
public static function listUserCooperationChoices(int $enterpriseId, int $page, int $pageSize, string $keyword = ''): array
{
$page = max(1, $page);
$pageSize = min(max($pageSize, 1), 100);
$field = 'uc.id,uc.userId,uc.enterpriseId,uc.modeCode,uc.chosenAt,uc.updatedAt,'
. 'w.nickname,w.phone,ecm.title as modeTitle,ent.name as enterpriseName';
$total = (int) self::userCooperationChoicesBaseQuery($enterpriseId, $keyword)->count();
$rows = self::userCooperationChoicesBaseQuery($enterpriseId, $keyword)
->field($field)
->order('uc.chosenAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
$list = [];
foreach ($rows as $row) {
$list[] = self::mapUserCooperationChoiceRow($row);
}
return ['list' => $list, 'total' => $total];
}
/**
* @return list<array>
*/
public static function listUserCooperationChoicesForExport(int $enterpriseId, string $keyword = '', int $maxRows = 10000): array
{
$field = 'uc.id,uc.userId,uc.enterpriseId,uc.modeCode,uc.chosenAt,uc.updatedAt,'
. 'w.nickname,w.phone,ecm.title as modeTitle,ent.name as enterpriseName';
$rows = self::userCooperationChoicesBaseQuery($enterpriseId, $keyword)
->field($field)
->order('uc.chosenAt', 'desc')
->limit(max(1, min($maxRows, 50000)))
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$out[] = self::mapUserCooperationChoiceRow($row);
}
return $out;
}
public static function buildUserCooperationChoicesCsvContent(array $rows, bool $includeEnterpriseColumns): string
{
$lines = [];
$headers = $includeEnterpriseColumns
? ['企业ID', '企业名称', '用户ID', '微信昵称', '手机号', '模式代码', '模式标题', '选择时间', '更新时间']
: ['用户ID', '微信昵称', '手机号', '模式代码', '模式标题', '选择时间', '更新时间'];
$lines[] = self::csvLine($headers);
foreach ($rows as $r) {
if ($includeEnterpriseColumns) {
$lines[] = self::csvLine([
(string) ($r['enterpriseId'] ?? ''),
(string) ($r['enterpriseName'] ?? ''),
(string) ($r['userId'] ?? ''),
(string) ($r['nickname'] ?? ''),
(string) ($r['phone'] ?? ''),
(string) ($r['modeCode'] ?? ''),
(string) ($r['modeTitle'] ?? ''),
(string) ($r['chosenAtText'] ?? ''),
(string) ($r['updatedAtText'] ?? ''),
]);
} else {
$lines[] = self::csvLine([
(string) ($r['userId'] ?? ''),
(string) ($r['nickname'] ?? ''),
(string) ($r['phone'] ?? ''),
(string) ($r['modeCode'] ?? ''),
(string) ($r['modeTitle'] ?? ''),
(string) ($r['chosenAtText'] ?? ''),
(string) ($r['updatedAtText'] ?? ''),
]);
}
}
return implode("\r\n", $lines);
}
private static function csvLine(array $cells): string
{
$parts = [];
foreach ($cells as $c) {
$s = (string) $c;
$s = str_replace('"', '""', $s);
$parts[] = '"' . $s . '"';
}
return implode(',', $parts);
}
}

View File

@@ -170,6 +170,7 @@ class AppUser extends BaseController
$ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
$enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
}
$coopMap = [];
if (!empty($ids)) {
// 测试统计严格按 test_results.enterpriseId 归属企业过滤
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
@@ -284,6 +285,33 @@ class AppUser extends BaseController
} catch (\Throwable $e) {
$coldFaceMap = [];
}
// 本企业下用户合作意向user_cooperation_choices 唯一 keyuserId + enterpriseId
if ($enterpriseId) {
try {
$eid = (int) $enterpriseId;
$coopRows = Db::name('user_cooperation_choices')->alias('uc')
->leftJoin('enterprise_cooperation_modes ecm', 'ecm.enterpriseId = uc.enterpriseId AND ecm.modeCode = uc.modeCode')
->whereIn('uc.userId', $ids)
->where('uc.enterpriseId', $eid)
->field('uc.userId, uc.modeCode, uc.chosenAt, uc.updatedAt, ecm.title as modeTitle')
->select()
->toArray();
foreach ($coopRows as $cr) {
$uCo = (int) ($cr['userId'] ?? 0);
if ($uCo > 0) {
$coopMap[$uCo] = [
'modeCode' => (string) ($cr['modeCode'] ?? ''),
'modeTitle' => (string) ($cr['modeTitle'] ?? ''),
'chosenAt' => (int) ($cr['chosenAt'] ?? 0),
'updatedAt' => (int) ($cr['updatedAt'] ?? 0),
];
}
}
} catch (\Throwable $e) {
$coopMap = [];
}
}
}
foreach ($list as &$row) {
@@ -317,6 +345,18 @@ class AppUser extends BaseController
$row['coldFaceScore'] = $cf && $cf['score'] !== null ? (int) $cf['score'] : null;
$row['coldFaceLevel'] = $cf && !empty($cf['level']) ? (string) $cf['level'] : null;
$row['coldFaceUpdatedAt'] = $cf ? ($cf['updatedAt'] ?? null) : null;
$co = $coopMap[$id] ?? null;
if ($co && ($co['modeCode'] !== '' || $co['modeTitle'] !== '' || (int) ($co['chosenAt'] ?? 0) > 0)) {
$row['cooperationModeCode'] = $co['modeCode'] !== '' ? $co['modeCode'] : null;
$row['cooperationModeTitle'] = $co['modeTitle'] !== '' ? $co['modeTitle'] : null;
$chAt = (int) ($co['chosenAt'] ?? 0);
$row['cooperationChosenAt'] = $chAt > 0 ? $chAt : null;
} else {
$row['cooperationModeCode'] = null;
$row['cooperationModeTitle'] = null;
$row['cooperationChosenAt'] = null;
}
}
return paginate_response($list, $total, $page, $pageSize);
@@ -432,6 +472,30 @@ class AppUser extends BaseController
$data['coldFaceLevel'] = $coldFace['level'] ?? null;
$data['coldFaceUpdatedAt'] = $coldFace['updatedAt'] ?? null;
$data['cooperationModeCode'] = null;
$data['cooperationModeTitle'] = null;
$data['cooperationChosenAt'] = null;
if ($enterpriseId) {
try {
$cr = Db::name('user_cooperation_choices')->alias('uc')
->leftJoin('enterprise_cooperation_modes ecm', 'ecm.enterpriseId = uc.enterpriseId AND ecm.modeCode = uc.modeCode')
->where('uc.userId', $id)
->where('uc.enterpriseId', (int) $enterpriseId)
->field('uc.modeCode, uc.chosenAt, ecm.title as modeTitle')
->find();
if ($cr) {
$mc = trim((string) ($cr['modeCode'] ?? ''));
$mt = trim((string) ($cr['modeTitle'] ?? ''));
$data['cooperationModeCode'] = $mc !== '' ? $mc : null;
$data['cooperationModeTitle'] = $mt !== '' ? $mt : null;
$chAt = (int) ($cr['chosenAt'] ?? 0);
$data['cooperationChosenAt'] = $chAt > 0 ? $chAt : null;
}
} catch (\Throwable $e) {
// 表未迁移时忽略
}
}
return success($data);
}

View File

@@ -0,0 +1,146 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use app\common\service\EnterpriseCooperationService;
use app\model\Enterprise as EnterpriseModel;
use think\facade\Db;
use think\facade\Request;
use think\Response;
/**
* 企业管理员:本企业合作模式配置
*/
class EnterpriseCooperation extends BaseController
{
public function get()
{
$user = $this->request->user ?? null;
if (!$this->canAccessEnterpriseCooperation($user)) {
return error('仅已绑定企业的管理员可配置', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$list = EnterpriseCooperationService::listModesForEnterprise($eid, false);
return success(['list' => $list]);
}
public function update()
{
$user = $this->request->user ?? null;
if (!$this->canAccessEnterpriseCooperation($user)) {
return error('仅已绑定企业的管理员可配置', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$body = Request::put();
if (!is_array($body)) {
$body = [];
}
$modes = $body['modes'] ?? null;
if (!is_array($modes)) {
return error('请提交 modes 数组', 400);
}
try {
EnterpriseCooperationService::saveConfigs($eid, $modes);
} catch (\InvalidArgumentException $e) {
return error($e->getMessage(), 400);
}
$list = EnterpriseCooperationService::listModesForEnterprise($eid, false);
return success(['list' => $list], '已保存');
}
/**
* GET enterprise/cooperation-choices
* 本企业下用户已选合作意向(分页)
*/
public function listChoices()
{
$user = $this->request->user ?? null;
if (!$this->canAccessEnterpriseCooperation($user)) {
return error('无权限访问', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$keyword = trim((string) Request::param('keyword', ''));
$data = EnterpriseCooperationService::listUserCooperationChoices($eid, $page, $pageSize, $keyword);
return paginate_response($data['list'], $data['total'], $page, $pageSize);
}
/**
* GET enterprise/cooperation-choices/export
* 导出本企业 CSV
*/
public function exportChoices()
{
$user = $this->request->user ?? null;
if (!$this->canAccessEnterpriseCooperation($user)) {
return error('无权限访问', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$keyword = trim((string) Request::param('keyword', ''));
$rows = EnterpriseCooperationService::listUserCooperationChoicesForExport($eid, $keyword, 10000);
$csv = "\xEF\xBB\xBF" . EnterpriseCooperationService::buildUserCooperationChoicesCsvContent($rows, false);
$name = 'cooperation-choices-e' . $eid . '.csv';
return Response::create($csv, 'html', 200)
->contentType('text/csv; charset=UTF-8')
->header(['Content-Disposition' => 'attachment; filename="' . $name . '"']);
}
private function canAccessEnterpriseCooperation($user): bool
{
if (!is_array($user)) {
return false;
}
$r = (string) ($user['role'] ?? '');
return in_array($r, ['admin', 'enterprise_admin'], true);
}
private function resolveEnterpriseId(array $user): ?int
{
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$eid = $adminRow['enterpriseId'] ?? null;
if ($eid === null || $eid === '') {
return null;
}
return (int) $eid;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\EnterpriseCooperationService;
use think\facade\Request;
/**
* 企业合作模式:小程序端
*/
class Cooperation extends BaseController
{
/**
* GET /api/enterprise/cooperation-modes
* 当前用户绑定企业下、已启用的合作模式(需登录、需有企业)
*/
public function modes()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$eid = EnterpriseCooperationService::getWechatEnterpriseId($userId);
if (!$eid) {
return error('未绑定企业', 403);
}
$list = EnterpriseCooperationService::listModesForEnterprise($eid, true);
return success(['list' => $list]);
}
/**
* GET /api/user/cooperation-status
*/
public function status()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$eid = EnterpriseCooperationService::getWechatEnterpriseId($userId);
if (!$eid) {
return success([
'enterpriseId' => null,
'resumeDone' => false,
'faceDone' => false,
'mbtiDone' => false,
'allDone' => false,
'chosen' => false,
'chosenModeCode' => null,
'modes' => [],
]);
}
$flags = EnterpriseCooperationService::onboardingFlags($userId, $eid);
$allDone = $flags['resumeDone'] && $flags['faceDone'] && $flags['mbtiDone'];
$choiceRow = EnterpriseCooperationService::getUserChoice($userId, $eid);
$chosen = $choiceRow !== null;
$chosenCode = $chosen ? (string) ($choiceRow['modeCode'] ?? '') : null;
$modes = [];
if ($allDone && !$chosen) {
$modes = EnterpriseCooperationService::listModesForEnterprise($eid, true);
}
return success([
'enterpriseId' => $eid,
'resumeDone' => $flags['resumeDone'],
'faceDone' => $flags['faceDone'],
'mbtiDone' => $flags['mbtiDone'],
'allDone' => $allDone,
'chosen' => $chosen,
'chosenModeCode' => $chosenCode,
'modes' => $modes,
]);
}
/**
* POST /api/user/cooperation-preference
* body: { "modeCode": "salary" }
*/
public function submitPreference()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$eid = EnterpriseCooperationService::getWechatEnterpriseId($userId);
if (!$eid) {
return error('未绑定企业', 403);
}
$modeCode = EnterpriseCooperationService::normalizeModeCode(
(string) (Request::post('modeCode', '') ?: Request::param('modeCode', ''))
);
if ($modeCode === '' || !EnterpriseCooperationService::isValidModeCodeString($modeCode)) {
return error('无效的合作模式', 400);
}
$flags = EnterpriseCooperationService::onboardingFlags($userId, $eid);
if (!$flags['resumeDone'] || !$flags['faceDone'] || !$flags['mbtiDone']) {
return error('请先完成简历上传、面相分析与 MBTI 测评', 400);
}
if (!EnterpriseCooperationService::isModeEnabledForEnterprise($eid, $modeCode)) {
return error('该合作模式未开放', 400);
}
EnterpriseCooperationService::saveUserChoice($userId, $eid, $modeCode);
return success([
'modeCode' => $modeCode,
], '已保存');
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\EnterpriseCooperationService;
use app\model\Enterprise as EnterpriseModel;
use think\facade\Request;
use think\Response;
/**
* 超管:企业合作模式配置
*/
class EnterpriseCooperation extends BaseController
{
/**
* GET enterprises/:id/cooperation-modes
*/
public function get($id)
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
$eid = (int) $id;
if ($eid <= 0) {
return error('无效的企业 ID', 400);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$list = EnterpriseCooperationService::listModesForEnterprise($eid, false);
return success(['list' => $list]);
}
/**
* PUT enterprises/:id/cooperation-modes
* Body: { "modes": [ { "modeCode", "enabled", "sortOrder", "title", "description" }, ... ] }
*/
public function update($id)
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
$eid = (int) $id;
if ($eid <= 0) {
return error('无效的企业 ID', 400);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$body = Request::put();
if (!is_array($body)) {
$body = [];
}
$modes = $body['modes'] ?? null;
if (!is_array($modes)) {
return error('请提交 modes 数组', 400);
}
try {
EnterpriseCooperationService::saveConfigs($eid, $modes);
} catch (\InvalidArgumentException $e) {
return error($e->getMessage(), 400);
}
$list = EnterpriseCooperationService::listModesForEnterprise($eid, false);
return success(['list' => $list], '已保存');
}
/**
* GET enterprises/:id/cooperation-choices
*/
public function listUserChoices($id)
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
$eid = (int) $id;
if ($eid <= 0) {
return error('无效的企业 ID', 400);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$keyword = trim((string) Request::param('keyword', ''));
$data = EnterpriseCooperationService::listUserCooperationChoices($eid, $page, $pageSize, $keyword);
return paginate_response($data['list'], $data['total'], $page, $pageSize);
}
/**
* GET enterprises/:id/cooperation-choices/export
*/
public function exportUserChoices($id)
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
$eid = (int) $id;
if ($eid <= 0) {
return error('无效的企业 ID', 400);
}
if (!EnterpriseModel::find($eid)) {
return error('企业不存在', 404);
}
$keyword = trim((string) Request::param('keyword', ''));
$rows = EnterpriseCooperationService::listUserCooperationChoicesForExport($eid, $keyword, 10000);
$csv = "\xEF\xBB\xBF" . EnterpriseCooperationService::buildUserCooperationChoicesCsvContent($rows, true);
$name = 'cooperation-choices-e' . $eid . '.csv';
return Response::create($csv, 'html', 200)
->contentType('text/csv; charset=UTF-8')
->header(['Content-Disposition' => 'attachment; filename="' . $name . '"']);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace app\model;
use think\Model;
class EnterpriseCooperationMode extends Model
{
protected $name = 'enterprise_cooperation_modes';
protected $schema = [
'id' => 'int',
'enterpriseId' => 'int',
'modeCode' => 'string',
'enabled' => 'int',
'sortOrder' => 'int',
'title' => 'string',
'description' => 'string',
'createdAt' => 'int',
'updatedAt' => 'int',
];
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
}

View File

@@ -0,0 +1,20 @@
<?php
namespace app\model;
use think\Model;
class UserCooperationChoice extends Model
{
protected $name = 'user_cooperation_choices';
protected $schema = [
'id' => 'int',
'userId' => 'int',
'enterpriseId' => 'int',
'modeCode' => 'string',
'chosenAt' => 'int',
'updatedAt' => 'int',
];
}

View File

@@ -0,0 +1,29 @@
-- 企业合作模式配置 + 用户选择ThinkPHP Db::name 无表前缀写法 → mbti_*
-- 执行前确认 database.prefix 为 mbti_
CREATE TABLE IF NOT EXISTS `mbti_enterprise_cooperation_modes` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`enterpriseId` int unsigned NOT NULL COMMENT '企业 ID',
`modeCode` varchar(32) NOT NULL COMMENT 'salary|startup_equity|knowledge_pay',
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否对用户展示',
`sortOrder` int NOT NULL DEFAULT 0,
`title` varchar(128) NOT NULL DEFAULT '',
`description` varchar(512) NULL DEFAULT NULL,
`createdAt` int unsigned NULL DEFAULT NULL,
`updatedAt` int unsigned NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ent_mode` (`enterpriseId`, `modeCode`),
KEY `idx_enterpriseId` (`enterpriseId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业合作模式配置';
CREATE TABLE IF NOT EXISTS `mbti_user_cooperation_choices` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`userId` int unsigned NOT NULL COMMENT 'wechat_users.id',
`enterpriseId` int unsigned NOT NULL,
`modeCode` varchar(32) NOT NULL,
`chosenAt` int unsigned NOT NULL,
`updatedAt` int unsigned NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_ent` (`userId`, `enterpriseId`),
KEY `idx_enterpriseId` (`enterpriseId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户在企业下的合作模式选择';

View File

@@ -0,0 +1,184 @@
# 企业版:简历上传 / 人脸 / MBTI → 合作模式(需完善资料)
> 文档用途:产品与开发对齐;可被 AI 直接读取用于分模块实现(小程序弹框、前置条件校验、`api` 接口与管理端配置)。
> 关联:`miniprogram/utils/phoneAuth.js` 中 `isReportProfileComplete()`(昵称+头像+手机);后端与 `Test::isWechatProfileComplete` 对齐。
---
## 1. 背景与目标
- **目标**:在企业版业务中,用户须完成 **简历上传、人脸/面相分析、MBTI 测评****三者完成顺序不限,可被打乱**),全部完成后必须再 **选择「合作模式」**
- **说明(产品口径)**:第一环对用户表述为 **「简历上传」**;上传成功后服务端可异步生成「简历分析报告」。**什么叫「简历这一环算完成」**:按你们约定的业务规则判定,通常是 **上传成功且达到约定的完成态**(例如库里已有记录、报告可查看等),详见 §3。文档里如出现「前置校验」三字指的就是**没到这一步就不让用户进下一步**。)
- **约束****选择合作模式前,用户须已「完善资料」**(手机号 + 昵称 + 头像必填)。
- **展示**:小程序侧合作模式以 **弹框Modal** 呈现;选项由 **管理端配置**
---
## 2. 适用范围
| 维度 | 说明 |
|------|------|
| 客户端 | **企业版小程序**(实现时与现有企业上下文 `enterpriseId` / `enterprisePermissions` / 入口路由对齐) |
| 用户身份 | 已登录;未完成资料则先引导 **`/pages/user-profile/index`** |
| 非适用范围 | 个人版、分享落地访客;审核/提审模式沿用现有 `reviewMode` / `miniprogramAuditMode` 等合规策略 |
---
## 3. 端到端流程(三项完成顺序不限)
**前置条件(企业版)**:以下三项在用户维度均达到「已完成」状态(**不限先后**
- **简历上传**完成(用户完成简历文件上传;是否要求报告已出齐由实现约定,建议在文档/接口层写死「完成事件」)
- 人脸(面相)分析完成
- MBTI 测评完成
```
┌─────────────────────────────────────────────────────────┐
│ 简历上传 / 人脸 / MBTI 三项可任意顺序完成(并行入口亦可) │
└─────────────────────────────────────────────────────────┘
三项均已标记完成?
【校验】「完善资料」是否满足(硬性,见 §4
弹出「合作模式」弹框§6
用户必选一项(或必选 + 备注,见 §10
提交成功 → 关闭弹框并进入后续业务(或停留提示)
```
**实现锚点**
1. **简历上传**:现有简历上传与分析链路(如上传页 + `pages/result/resume`);以 **上传成功且业务认定该环节完成**(例如已持久化简历分析任务/结果)时写入 **`resumeDone`**(服务端为准更可靠)。
2. **人脸**:现有拍照/分析链路(如 `pages/index/camera``pages/index/result`);完成时写入 **faceDone**
3. **MBTI**:现有测评与结果页(如 `pages/result/mbti`);完成时写入 **mbtiDone**
4. **合作模式**:当 **`resumeDone && faceDone && mbtiDone`**(命名可统一为 enterprise onboarding flags且 §4「完善资料」校验通过时触发弹框**最后一项完成的那一个页面**可作为「首次满足条件」的触发时机(避免重复弹窗需幂等标记)。
---
## 4. 「完善资料」前置条件(硬性)
> **用语说明**:不写「门禁」一词时,本节就是指——**头像、昵称、手机号不齐就不给弹出合作模式弹框**,须先去资料页补齐。
**判定**:与小程序 `isReportProfileComplete()` 一致——**昵称、头像、手机号均已非空**。
**未满足**:禁止打开合作模式弹框;`Toast` + 跳转 `pages/user-profile/index`(可加 query `from=cooperation_gate`)。
**满足后**:拉取 §5 配置的选项并展示弹框。
---
## 5. 管理端配置(暂定三种模式)
### 5.1 枚举(首期)
| modeCode (`code`) | 展示名 | 默认含义 |
|-------------------|--------|----------|
| `salary` | 工资 | 全职/薪资导向合作 |
| `startup_equity` | 创业分红 | 合伙/股权激励类合作 |
| `knowledge_pay` | 知识付费 | 课程/咨询/付费内容类合作 |
### 5.2 建议配置字段
| 字段 | 说明 |
|------|------|
| `enterpriseId` | 企业维度(平台默认模板可与某默认企业绑定或单独表) |
| `modeCode` | `salary` \| `startup_equity` \| `knowledge_pay` |
| `enabled` | 是否对用户展示 |
| `sortOrder` | 弹框内排序 |
| `title` / `description` | 可选覆盖默认文案 |
| `updatedAt` | 审计 |
**权限**:仅管理员可改;终端只读列表。
---
## 6. 小程序:弹框交互
### 6.1 触发
- 满足 §3**三项均完成,与完成顺序无关**、§4 后 **自动弹出一次**(建议:`cooperationChoiceSubmitted` / 服务端状态双重判断)。
- 可选:在任一相关结果页提供「选择合作模式」入口(若三项未齐则 Toast 引导未完成项);同样先走 §4。
### 6.2 内容结构
- 标题:如「请选择合作模式」。
- 简要说明一行。
- 纵向 **单选**列表,仅展示 `enabled=true` 的配置项。
- **确认**:未选时可禁用主按钮或 Toast。
- **稍后**(可选):是否允许跳过由产品决定;若企业版强制意愿收集,可不提供「稍后」。
### 6.3 反馈
- 成功:`showToast`,必要时更新本地与服务端扩展字段。
- 失败:展示服务端 `message`,保留弹框内容便于重试。
---
## 7. 接口约定(草案)
以下路径为建议命名,落地时与现有路由风格统一。
### 7.1 获取可选合作模式
- **GET** `/api/enterprise/cooperation-modes`
- 认证Bearer`enterpriseId` 从 Token 或 query 解析。
- **响应示例**
```json
{
"code": 200,
"data": {
"list": [
{ "code": "salary", "title": "工资", "description": "…", "sortOrder": 10 }
]
}
}
```
### 7.2 提交用户选择
- **POST** `/api/user/cooperation-preference`
- **Body**`{ "modeCode": "salary" }`,可选 `{ "mbtiTestResultId": 123 }` 便于审计。
- **校验**`modeCode` 必须在该企业当前启用集合内。
### 7.3 幂等
同一用户同一企业重复提交:**覆盖最近一次** 或 **409 拒绝** —— 由产品拍板。
---
## 8. 数据与状态
- 用户维度存储:`cooperation_mode_code``cooperation_chosen_at`(表名实现时与 `wechat_users`/扩展表统一)。
- 前端可选用 `storage` 缓存「已提交」以降低重复弹窗(须与服务端最终一致)。
---
## 9. 验收标准
1. 企业版路径:仅 **简历上传、人脸、MBTI 三项均完成****顺序任意**;完成事件以接口/状态为准;简历侧以「上传 + 约定完成态」为准)且 **资料完善** 后出现弹框。
2. 资料不完善:**无法提交**合作模式并跳转资料页。
3. 管理端关闭某模式:小程序 **不出现**该项。
4. 提交成功:服务端可查;客户端符合「不重复骚扰」策略。
5. 审核模式下是否与营销类入口同样隐藏:**待运营确认**,可与现有审核工具函数并列处理。
---
## 10. 待产品确认
- 「三项均完成」的精确定义(依赖哪些接口字段或页面生命周期;**与先后顺序无关**)。
- 是否允许「稍后」、是否支持多选/备注。
- 配置入口:**企业后台** 与 **超级管理后台** 的归属。
- 与个人版路径并存时的降级策略。
---
## 修订记录
| 日期 | 说明 |
|------|------|
| (创建) | 初版需求草案,存放于 `api/docs` |
| 修订 | 明确简历 / 人脸 / MBTI **完成顺序可打乱**,仅以三项均完成为必要条件 |
| 修订 | 第一环产品口径改为 **简历上传**(与分析/报告异步关系在 §3 说明) |
| 2026-04-20 | **已落地**`mbti_enterprise_cooperation_modes` / `mbti_user_cooperation_choices`、小程序接口与弹框、企业后台与超管配置页 |

View File

@@ -54,6 +54,10 @@ Route::group('api', function () {
Route::post('enterprise/resume-uploads/set-default', 'api.EnterpriseResume/setDefault');
Route::post('enterprise/resume-uploads/delete', 'api.EnterpriseResume/delete');
Route::post('enterprise/resume-uploads', 'api.EnterpriseResume/add');
// 企业合作模式(需微信登录且绑定企业)
Route::get('enterprise/cooperation-modes', 'api.Cooperation/modes');
Route::get('user/cooperation-status', 'api.Cooperation/status');
Route::post('user/cooperation-preference', 'api.Cooperation/submitPreference');
// 小程序用户更新资料
Route::put('auth/wechat/profile', 'api.Auth/updateWechatProfile');
// 小程序用户上传图片(头像等)
@@ -203,6 +207,10 @@ Route::group('api/v1/admin', function () {
// 企业功能开关(企业管理员,受超管 permissionsCeiling 约束)
Route::get('enterprise/permissions', 'admin.EnterprisePermissions/index');
Route::put('enterprise/permissions', 'admin.EnterprisePermissions/update');
Route::get('enterprise/cooperation-modes', 'admin.EnterpriseCooperation/get');
Route::put('enterprise/cooperation-modes', 'admin.EnterpriseCooperation/update');
Route::get('enterprise/cooperation-choices/export', 'admin.EnterpriseCooperation/exportChoices');
Route::get('enterprise/cooperation-choices', 'admin.EnterpriseCooperation/listChoices');
// 分销管理(企业管理员)
Route::get('distribution/overview', 'admin.Distribution/overview');
@@ -247,6 +255,10 @@ Route::group('api/v1/superadmin', function () {
// 企业管理(超管专用)
// 注意:带参数的路由要放在不带参数的路由之前,避免路由匹配冲突
Route::get('enterprises/:id/cooperation-modes', 'superadmin.EnterpriseCooperation/get')->pattern(['id' => '\d+']);
Route::put('enterprises/:id/cooperation-modes', 'superadmin.EnterpriseCooperation/update')->pattern(['id' => '\d+']);
Route::get('enterprises/:id/cooperation-choices/export', 'superadmin.EnterpriseCooperation/exportUserChoices')->pattern(['id' => '\d+']);
Route::get('enterprises/:id/cooperation-choices', 'superadmin.EnterpriseCooperation/listUserChoices')->pattern(['id' => '\d+']);
Route::get('enterprises/:id/detail', 'superadmin.Enterprise/detail'); // 详细详情接口
Route::get('enterprises/:id', 'superadmin.Enterprise/detail');
Route::get('enterprises', 'superadmin.Enterprise/index');
@@ -406,6 +418,10 @@ Route::group('api/v1', function () {
// 企业管理(超管,兼容旧版)
// 注意:带参数的路由要放在不带参数的路由之前,避免路由匹配冲突
Route::get('enterprises/:id/cooperation-modes', 'superadmin.EnterpriseCooperation/get')->pattern(['id' => '\d+']);
Route::put('enterprises/:id/cooperation-modes', 'superadmin.EnterpriseCooperation/update')->pattern(['id' => '\d+']);
Route::get('enterprises/:id/cooperation-choices/export', 'superadmin.EnterpriseCooperation/exportUserChoices')->pattern(['id' => '\d+']);
Route::get('enterprises/:id/cooperation-choices', 'superadmin.EnterpriseCooperation/listUserChoices')->pattern(['id' => '\d+']);
Route::get('enterprises/:id/detail', 'superadmin.Enterprise/detail'); // 详细详情接口
Route::get('enterprises/:id', 'superadmin.Enterprise/detail');
Route::get('enterprises', 'superadmin.Enterprise/index');

View File

@@ -58,8 +58,8 @@ App({
defaultEnterpriseId: null,
// API 基础地址:默认走线上;本机/内网调试可在开发者工具执行
// wx.setStorageSync('apiBaseOverride', 'https://你的调试域名') 后重启小程序
apiBase: 'https://mbtiapi.quwanzhi.com',
//apiBase: 'http://mbti.com',
//apiBase: 'https://mbtiapi.quwanzhi.com',
apiBase: 'http://mbti.com',
// VIP信息
vipInfo: null,
// 测试次数

View File

@@ -0,0 +1,77 @@
const { request } = require('../../utils/request.js')
Component({
properties: {
visible: {
type: Boolean,
value: false
},
modes: {
type: Array,
value: []
}
},
data: {
selected: '',
submitting: false
},
observers: {
visible(v) {
if (v) {
const modes = this.properties.modes || []
const first = modes.length ? modes[0].code : ''
this.setData({ selected: first || '', submitting: false })
}
},
modes(modes) {
if (this.properties.visible && modes && modes.length) {
const cur = this.data.selected
const ok = modes.some((m) => m && m.code === cur)
if (!ok) this.setData({ selected: modes[0].code })
}
}
},
methods: {
noop() {},
onMask() {},
onRadioChange(e) {
const v = e.detail && e.detail.value ? String(e.detail.value) : ''
if (v) this.setData({ selected: v })
},
onConfirm() {
const code = (this.data.selected || '').trim()
if (!code) {
wx.showToast({ title: '请选择一项', icon: 'none' })
return
}
if (this.data.submitting) return
this.setData({ submitting: true })
request({
url: '/api/user/cooperation-preference',
method: 'POST',
data: { modeCode: code },
success: (res) => {
const body = res.data || {}
if (res.statusCode === 200 && body.code === 200) {
wx.showToast({ title: body.message || '已提交', icon: 'success' })
this.triggerEvent('success', body.data || {})
} else {
wx.showToast({ title: body.message || '提交失败', icon: 'none' })
}
},
fail: () => {
wx.showToast({ title: '网络错误', icon: 'none' })
},
complete: () => {
this.setData({ submitting: false })
}
})
}
}
})

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,20 @@
<view class="cmd-mask" wx:if="{{visible}}" catchtouchmove="noop" catchtap="onMask">
<view class="cmd-panel" catchtap="noop">
<text class="cmd-title">请选择合作模式</text>
<text class="cmd-desc">三项测评已完成,请选择与您更契合的合作方式(提交后可在企业侧活动中使用)。</text>
<radio-group class="cmd-list" bindchange="onRadioChange">
<label class="cmd-item" wx:for="{{modes}}" wx:key="code">
<radio value="{{item.code}}" checked="{{selected === item.code}}" color="#ff6b8a" />
<view class="cmd-item-main">
<text class="cmd-item-title">{{item.title}}</text>
<text class="cmd-item-desc">{{item.description}}</text>
</view>
</label>
</radio-group>
<view class="cmd-actions">
<button class="cmd-btn cmd-btn--primary" hover-class="none" loading="{{submitting}}" bindtap="onConfirm">确认提交</button>
</view>
</view>
</view>

View File

@@ -0,0 +1,104 @@
.cmd-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 12500;
background: rgba(15, 23, 42, 0.48);
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
}
.cmd-panel {
width: 100%;
max-width: 620rpx;
background: #fff;
border-radius: 28rpx;
padding: 40rpx 36rpx 36rpx;
box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, 0.12);
}
.cmd-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #111827;
margin-bottom: 16rpx;
}
.cmd-desc {
display: block;
font-size: 24rpx;
line-height: 1.55;
color: #6b7280;
margin-bottom: 28rpx;
}
.cmd-list {
display: block;
}
.cmd-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
padding: 20rpx 0;
border-bottom: 2rpx solid #f3f4f6;
}
.cmd-item:last-child {
border-bottom: none;
}
.cmd-item radio {
margin-top: 6rpx;
}
.cmd-item-main {
flex: 1;
min-width: 0;
}
.cmd-item-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #111827;
margin-bottom: 8rpx;
}
.cmd-item-desc {
display: block;
font-size: 24rpx;
line-height: 1.45;
color: #6b7280;
}
.cmd-actions {
margin-top: 28rpx;
}
.cmd-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
font-size: 28rpx;
font-weight: 600;
border-radius: 16rpx;
padding: 0;
margin: 0;
border: none;
}
.cmd-btn::after {
border: none;
}
.cmd-btn--primary {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8e53 100%);
color: #fff;
}

View File

@@ -54,6 +54,7 @@ function mergeSceneBlocks(apiData, baseResult) {
}
}
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
const enterpriseCooperation = require('../../utils/enterpriseCooperation.js')
Page({
data: {
@@ -119,7 +120,9 @@ Page({
reportTitle: '分析报告',
aiAnalysisText: '智能分析',
showInviteCodeDialog: false,
hideInviteCodeEntry: false
hideInviteCodeEntry: false,
showCooperationModal: false,
cooperationModes: []
},
onLoad(options) {
@@ -278,6 +281,7 @@ Page({
aiAnalysisText: tc.aiAnalysisText || '智能分析'
})
}
enterpriseCooperation.maybeShowCooperationModal(this)
},
// 面相/骨相 Tab 切换
@@ -598,6 +602,10 @@ Page({
inviteCodeGate.finishInviteCodeGate(this, true)
},
onCooperationSuccess() {
this.setData({ showCooperationModal: false, cooperationModes: [] })
},
/** 支付成功后:仅绑定手机号,不重复发起支付 */
onPostPayBindPhone(e) {
const { code, errMsg } = e.detail || {}

View File

@@ -5,6 +5,7 @@
"enableShareAppMessage": true,
"enableShareTimeline": true,
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog",
"cooperation-mode-dialog": "/components/cooperation-mode-dialog/cooperation-mode-dialog"
}
}

View File

@@ -465,4 +465,9 @@
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
<cooperation-mode-dialog
visible="{{showCooperationModal}}"
modes="{{cooperationModes}}"
bind:success="onCooperationSuccess"
/>
</view>

View File

@@ -1,8 +1,8 @@
{
"navigationBarTitleText": "推广中心",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
{
"navigationBarTitleText": "推广中心",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
}

View File

@@ -28,6 +28,7 @@ const {
markCamera
} = require('../../utils/resultJourneyState.js')
const resultScrollSync = require('../../utils/resultSectionScrollSync.js')
const enterpriseCooperation = require('../../utils/enterpriseCooperation.js')
Page({
data: {
@@ -77,7 +78,9 @@ Page({
activeSection: '',
showInviteCodeDialog: false,
/** 审核/提审时隐藏填写邀请码入口(与 runtime 开关同步) */
hideInviteCodeEntry: false
hideInviteCodeEntry: false,
showCooperationModal: false,
cooperationModes: []
},
onTapSectionNav(e) {
@@ -136,6 +139,7 @@ Page({
if (raw) this.applyResult(raw)
}
this._syncJourney()
enterpriseCooperation.maybeShowCooperationModal(this)
},
goCompleteProfile() {
@@ -445,6 +449,10 @@ Page({
inviteCodeGate.finishInviteCodeGate(this, true)
},
onCooperationSuccess() {
this.setData({ showCooperationModal: false, cooperationModes: [] })
},
// 付费解锁按钮:就地触发微信手机号授权,然后调用 unlockFullReport
onGetPhoneNumberForMbtiPay(e) {
const { code, errMsg } = e.detail || {}

View File

@@ -3,6 +3,7 @@
"navigationBarBackgroundColor": "#FF6B8A",
"navigationBarTextStyle": "white",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog",
"cooperation-mode-dialog": "/components/cooperation-mode-dialog/cooperation-mode-dialog"
}
}

View File

@@ -336,4 +336,9 @@
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
<cooperation-mode-dialog
visible="{{showCooperationModal}}"
modes="{{cooperationModes}}"
bind:success="onCooperationSuccess"
/>
</view>

View File

@@ -5,6 +5,7 @@ const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
const { triggerTestResultCompleted } = require('../../utils/pushHook')
const enterpriseCooperation = require('../../utils/enterpriseCooperation.js')
Page({
data: {
@@ -27,7 +28,9 @@ Page({
paying: false,
profileGate: false,
showInviteCodeDialog: false,
hideInviteCodeEntry: false
hideInviteCodeEntry: false,
showCooperationModal: false,
cooperationModes: []
},
onLoad(options) {
@@ -66,6 +69,7 @@ Page({
if (id && this.data.profileGate) {
this.loadFromHistory(String(id))
}
enterpriseCooperation.maybeShowCooperationModal(this)
},
goCompleteProfile() {
@@ -291,6 +295,10 @@ Page({
inviteCodeGate.finishInviteCodeGate(this, true)
},
onCooperationSuccess() {
this.setData({ showCooperationModal: false, cooperationModes: [] })
},
goHome() {
const scope = (app.globalData && app.globalData.appScope) || 'personal'
if (scope === 'enterprise') {

View File

@@ -1,7 +1,8 @@
{
"navigationBarTitleText": "简历综合分析",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
}
{
"navigationBarTitleText": "简历综合分析",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog",
"cooperation-mode-dialog": "/components/cooperation-mode-dialog/cooperation-mode-dialog"
}
}

View File

@@ -107,5 +107,10 @@
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
<cooperation-mode-dialog
visible="{{showCooperationModal}}"
modes="{{cooperationModes}}"
bind:success="onCooperationSuccess"
/>
</view>

View File

@@ -0,0 +1,105 @@
/**
* 企业版合作模式弹框:仅在 enterprise 定价 + 企业上下文 + 非审核隐藏 + 三项完成且未选择时触发
*/
const { getEffectiveEnterpriseId } = require('./enterpriseContext.js')
const { shouldHideInviteCodeEntry } = require('./miniprogramAuditGate.js')
let flowLock = false
function shouldRunCooperationFlow(app) {
const gd = app && app.globalData
if (!gd) return false
try {
if (shouldHideInviteCodeEntry(gd)) return false
} catch (e) {}
return gd.appScope === 'enterprise' || getEffectiveEnterpriseId() != null
}
/**
* @param {WechatMiniprogram.Page.TrivialInstance | null} page
*/
function maybeShowCooperationModal(page) {
const app = getApp()
if (!page || typeof page.setData !== 'function') return
if (flowLock) return
if (!app.ensureLogin) return
app.ensureLogin().then((ok) => {
if (!ok) return
flowLock = true
const unlock = () => {
flowLock = false
}
Promise.resolve(app.getRuntimeConfig ? app.getRuntimeConfig() : null)
.then((cfg) => {
if (!cfg || cfg.pricingType !== 'enterprise') {
unlock()
return
}
if (!shouldRunCooperationFlow(app)) {
unlock()
return
}
try {
const phoneAuth = require('./phoneAuth.js')
if (!phoneAuth.isReportProfileComplete()) {
wx.showToast({ title: '请先完善手机号与资料', icon: 'none' })
setTimeout(() => {
wx.navigateTo({
url: '/pages/user-profile/index?from=cooperation_gate',
fail: () => {}
})
}, 400)
unlock()
return
}
} catch (e) {
unlock()
return
}
const apiBase = (app.globalData && app.globalData.apiBase) || ''
const token = wx.getStorageSync('token') || (app.globalData && app.globalData.token) || ''
if (!apiBase || !token) {
unlock()
return
}
wx.request({
url: `${apiBase.replace(/\/$/, '')}/api/user/cooperation-status`,
method: 'GET',
header: { Authorization: `Bearer ${token}` },
complete: unlock,
success: (res) => {
const body = res.data || {}
if (res.statusCode !== 200 || body.code !== 200 || !body.data) return
const d = body.data
const eid = d.enterpriseId != null ? Number(d.enterpriseId) : 0
if (!eid) return
if (d.chosen) return
if (!d.allDone) return
const modes = Array.isArray(d.modes) ? d.modes : []
if (modes.length === 0) return
page.setData({
showCooperationModal: true,
cooperationModes: modes
})
}
})
})
.catch(unlock)
})
}
module.exports = {
shouldRunCooperationFlow,
maybeShowCooperationModal
}

View File

@@ -9,7 +9,7 @@ ADMIN="$ROOT/admin"
LOG_DIR="$ROOT/api/runtime"
LOG_FILE="$LOG_DIR/php-dev-server.log"
API_PORT="${MBTI_API_PORT:-8787}"
ADMIN_PORT="${MBTI_ADMIN_PORT:-5173}"
ADMIN_PORT_DESIRED="${MBTI_ADMIN_PORT:-5173}"
die() { echo "$*" >&2; exit 1; }
@@ -49,6 +49,30 @@ done
mkdir -p "$LOG_DIR"
_port_listen_pids() {
lsof -tiTCP:"$1" -sTCP:LISTEN 2>/dev/null || true
}
_pick_next_free_admin_port_from() {
local start="$1"
local max_jump=80
local p="$start"
local end=$((start + max_jump))
while [[ "$p" -le "$end" ]]; do
if [[ -z "$(_port_listen_pids "$p")" ]]; then
echo "$p"
return 0
fi
p=$((p + 1))
done
die "无法在端口 ${start}-${end} 内找到空闲端口(请先关闭多余前端或设置 MBTI_ADMIN_PORT"
}
ADMIN_PORT="$(_pick_next_free_admin_port_from "$ADMIN_PORT_DESIRED")"
if [[ "$ADMIN_PORT" != "$ADMIN_PORT_DESIRED" ]]; then
echo "WARN: 端口 ${ADMIN_PORT_DESIRED} 已被占用,前端改用 ${ADMIN_PORT}"
fi
free_port() {
local port="$1"
local pids
@@ -62,7 +86,6 @@ free_port() {
}
free_port "$API_PORT"
free_port "$ADMIN_PORT"
PHP_VER="$("$PHP_BIN" -r 'echo PHP_VERSION;')"
echo "OK PHP: $PHP_BIN ($PHP_VER)"

View File

@@ -11,7 +11,7 @@ VITE_LOG="$LOG_DIR/vite-dev-server.log"
PHP_PID_FILE="$LOG_DIR/dev-php.pid"
VITE_PID_FILE="$LOG_DIR/dev-vite.pid"
API_PORT="${MBTI_API_PORT:-8787}"
ADMIN_PORT="${MBTI_ADMIN_PORT:-5173}"
ADMIN_PORT_DESIRED="${MBTI_ADMIN_PORT:-5173}"
die() { echo "ERROR: $*" >&2; exit 1; }
@@ -48,6 +48,30 @@ done
[[ -f "$API_PUBLIC/router.php" ]] || die "Missing router.php"
mkdir -p "$LOG_DIR"
_port_listen_pids() {
lsof -tiTCP:"$1" -sTCP:LISTEN 2>/dev/null || true
}
_pick_next_free_admin_port_from() {
local start="$1"
local max_jump=80
local p="$start"
local end=$((start + max_jump))
while [[ "$p" -le "$end" ]]; do
if [[ -z "$(_port_listen_pids "$p")" ]]; then
echo "$p"
return 0
fi
p=$((p + 1))
done
die "无法在端口 ${start}-${end} 内找到空闲端口(请先关闭多余前端或设置 MBTI_ADMIN_PORT"
}
ADMIN_PORT="$(_pick_next_free_admin_port_from "$ADMIN_PORT_DESIRED")"
if [[ "$ADMIN_PORT" != "$ADMIN_PORT_DESIRED" ]]; then
echo "WARN: 端口 ${ADMIN_PORT_DESIRED} 已被占用,前端改用 ${ADMIN_PORT}"
fi
free_port() {
local port="$1"
local pids
@@ -61,7 +85,6 @@ free_port() {
}
free_port "$API_PORT"
free_port "$ADMIN_PORT"
rm -f "$PHP_PID_FILE" "$VITE_PID_FILE"
echo "Starting PHP API on 127.0.0.1:${API_PORT} ..."