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