From 830e3611d748980243799231223068b1cd33dc41 Mon Sep 17 00:00:00 2001
From: Ghost <106998207@qq.com>
Date: Thu, 23 Apr 2026 16:08:13 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=81=E4=B8=9A=E7=89=88=E5=90=88?=
=?UTF-8?q?=E4=BD=9C=E6=A8=A1=E5=BC=8F=EF=BC=88=E5=90=8E=E7=AB=AF=E3=80=81?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=E3=80=81=E5=B0=8F=E7=A8=8B=E5=BA=8F?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- API:合作模式配置、用户选择、企业版入口与迁移 SQL
- 管理端:合作方式管理、企业/用户侧设置与 CSV 导出等
- 小程序:合作模式弹窗与结果/简历等页对接;开发脚本小调整
Made-with: Cursor
---
admin/src/components/UserDetailDialog.vue | 49 ++
admin/src/layouts/AdminLayout.vue | 7 +-
admin/src/router/index.ts | 6 +
admin/src/utils/downloadCsv.ts | 45 ++
admin/src/utils/request.ts | 4 +-
admin/src/views/admin/CooperationChoices.vue | 166 +++++++
admin/src/views/admin/Settings.vue | 188 ++++++++
admin/src/views/admin/Users.vue | 64 ++-
.../superadmin/CooperationChoicesPanel.vue | 193 ++++++++
admin/src/views/superadmin/EnterpriseHub.vue | 7 +-
admin/src/views/superadmin/Enterprises.vue | 176 ++++++-
admin/vite.config.ts | 3 +-
.../service/EnterpriseCooperationService.php | 450 ++++++++++++++++++
api/app/controller/admin/AppUser.php | 64 +++
.../admin/EnterpriseCooperation.php | 146 ++++++
api/app/controller/api/Cooperation.php | 133 ++++++
.../superadmin/EnterpriseCooperation.php | 127 +++++
api/app/model/EnterpriseCooperationMode.php | 26 +
api/app/model/UserCooperationChoice.php | 20 +
api/database/add_enterprise_cooperation.sql | 29 ++
api/docs/企业版合作模式需求.md | 184 +++++++
api/route/api.php | 16 +
miniprogram/app.js | 4 +-
.../cooperation-mode-dialog.js | 77 +++
.../cooperation-mode-dialog.json | 4 +
.../cooperation-mode-dialog.wxml | 20 +
.../cooperation-mode-dialog.wxss | 104 ++++
miniprogram/pages/index/result.js | 10 +-
miniprogram/pages/index/result.json | 3 +-
miniprogram/pages/index/result.wxml | 5 +
miniprogram/pages/promo/index.json | 14 +-
miniprogram/pages/result/mbti.js | 10 +-
miniprogram/pages/result/mbti.json | 3 +-
miniprogram/pages/result/mbti.wxml | 5 +
miniprogram/pages/result/resume.js | 10 +-
miniprogram/pages/result/resume.json | 15 +-
miniprogram/pages/result/resume.wxml | 5 +
miniprogram/utils/enterpriseCooperation.js | 105 ++++
scripts/dev_start.sh | 27 +-
scripts/dev_start_daemon.sh | 27 +-
40 files changed, 2518 insertions(+), 33 deletions(-)
create mode 100644 admin/src/utils/downloadCsv.ts
create mode 100644 admin/src/views/admin/CooperationChoices.vue
create mode 100644 admin/src/views/superadmin/CooperationChoicesPanel.vue
create mode 100644 api/app/common/service/EnterpriseCooperationService.php
create mode 100644 api/app/controller/admin/EnterpriseCooperation.php
create mode 100644 api/app/controller/api/Cooperation.php
create mode 100644 api/app/controller/superadmin/EnterpriseCooperation.php
create mode 100644 api/app/model/EnterpriseCooperationMode.php
create mode 100644 api/app/model/UserCooperationChoice.php
create mode 100644 api/database/add_enterprise_cooperation.sql
create mode 100644 api/docs/企业版合作模式需求.md
create mode 100644 miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.js
create mode 100644 miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.json
create mode 100644 miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxml
create mode 100644 miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxss
create mode 100644 miniprogram/utils/enterpriseCooperation.js
diff --git a/admin/src/components/UserDetailDialog.vue b/admin/src/components/UserDetailDialog.vue
index b1352bc..38ba4a6 100644
--- a/admin/src/components/UserDetailDialog.vue
+++ b/admin/src/components/UserDetailDialog.vue
@@ -51,6 +51,17 @@
+
+
合作意向
+
{{ user.cooperationModeTitle || user.cooperationModeCode }}
+
+ {{ user.cooperationModeCode }}
+
+
选择时间 {{ formatDate(user.cooperationChosenAt) }}
+
维度标签
{{ t }}
@@ -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;
diff --git a/admin/src/layouts/AdminLayout.vue b/admin/src/layouts/AdminLayout.vue
index b6de7c6..b6c6f4d 100644
--- a/admin/src/layouts/AdminLayout.vue
+++ b/admin/src/layouts/AdminLayout.vue
@@ -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
}
diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts
index 9bc8004..1835039 100644
--- a/admin/src/router/index.ts
+++ b/admin/src/router/index.ts
@@ -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' } }
diff --git a/admin/src/utils/downloadCsv.ts b/admin/src/utils/downloadCsv.ts
new file mode 100644
index 0000000..0607964
--- /dev/null
+++ b/admin/src/utils/downloadCsv.ts
@@ -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
) {
+ 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)
+}
diff --git a/admin/src/utils/request.ts b/admin/src/utils/request.ts
index 709d5ae..ecce784 100644
--- a/admin/src/utils/request.ts
+++ b/admin/src/utils/request.ts
@@ -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
diff --git a/admin/src/views/admin/CooperationChoices.vue b/admin/src/views/admin/CooperationChoices.vue
new file mode 100644
index 0000000..40760a3
--- /dev/null
+++ b/admin/src/views/admin/CooperationChoices.vue
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+ 查询
+ 导出 CSV
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/src/views/admin/Settings.vue b/admin/src/views/admin/Settings.vue
index 91d5405..6b1e060 100644
--- a/admin/src/views/admin/Settings.vue
+++ b/admin/src/views/admin/Settings.vue
@@ -62,6 +62,71 @@
+
+
+
+
+ + 新增合作模式
+
+
+
+
+ normalizeSettingsCoopCode(row)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 删除
+
+
+
+
暂无配置,可点击「新增合作模式」添加。
+
+
+ 保存合作模式
+
+
+
+
-
+
+
+
🤝
+
+
已选合作意向
+
+ {{ profileStats.cooperationCount }}
+ / {{ pageUserCount }}
+
+
+
@@ -149,6 +159,20 @@
+
+
+
+
{{ row.cooperationModeTitle || row.cooperationModeCode || '—' }}
+
{{ row.cooperationModeCode }}
+
{{ formatDate(row.cooperationChosenAt) }}
+
+ —
+
+
+
@@ -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;
diff --git a/admin/src/views/superadmin/CooperationChoicesPanel.vue b/admin/src/views/superadmin/CooperationChoicesPanel.vue
new file mode 100644
index 0000000..c01467e
--- /dev/null
+++ b/admin/src/views/superadmin/CooperationChoicesPanel.vue
@@ -0,0 +1,193 @@
+
+
+
+ 先选择要查看的企业,再加载或导出该企业在小程序中记录的用户合作意向(与表 mbti_user_cooperation_choices 一致)。
+
+
+
+
+
+
+
+ 查询
+ 导出 CSV
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/src/views/superadmin/EnterpriseHub.vue b/admin/src/views/superadmin/EnterpriseHub.vue
index 8588b57..5c15563 100644
--- a/admin/src/views/superadmin/EnterpriseHub.vue
+++ b/admin/src/views/superadmin/EnterpriseHub.vue
@@ -27,6 +27,7 @@
+
@@ -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 {
diff --git a/admin/src/views/superadmin/Enterprises.vue b/admin/src/views/superadmin/Enterprises.vue
index 3aebb32..001a206 100644
--- a/admin/src/views/superadmin/Enterprises.vue
+++ b/admin/src/views/superadmin/Enterprises.vue
@@ -265,7 +265,7 @@
+
+
+
+
+ 用户在该企业完成简历、面相与 MBTI 后将弹出选择;关闭项不会对用户展示。代码为小写英文、数字、下划线(1–32 位),保存后不可改。
+
+
+ + 新增合作模式
+
+
+
+
+ normalizeCoopCode(row)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 删除
+
+
+
+
暂无配置,可点击「新增合作模式」添加;保存后未包含的旧记录将从数据库删除。
+
+
+ 保存合作模式
+
+
+
+
@@ -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([])
+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()
+ const modes: Record[] = []
+ 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;
diff --git a/admin/vite.config.ts b/admin/vite.config.ts
index 12a59f1..ab25c27 100644
--- a/admin/vite.config.ts
+++ b/admin/vite.config.ts
@@ -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
diff --git a/api/app/common/service/EnterpriseCooperationService.php b/api/app/common/service/EnterpriseCooperationService.php
new file mode 100644
index 0000000..ce7ed93
--- /dev/null
+++ b/api/app/common/service/EnterpriseCooperationService.php
@@ -0,0 +1,450 @@
+ */
+ 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
+ */
+ 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 $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, 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
+ */
+ 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);
+ }
+}
diff --git a/api/app/controller/admin/AppUser.php b/api/app/controller/admin/AppUser.php
index b8e89c3..57cacd4 100644
--- a/api/app/controller/admin/AppUser.php
+++ b/api/app/controller/admin/AppUser.php
@@ -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);
}
diff --git a/api/app/controller/admin/EnterpriseCooperation.php b/api/app/controller/admin/EnterpriseCooperation.php
new file mode 100644
index 0000000..f00baef
--- /dev/null
+++ b/api/app/controller/admin/EnterpriseCooperation.php
@@ -0,0 +1,146 @@
+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;
+ }
+}
diff --git a/api/app/controller/api/Cooperation.php b/api/app/controller/api/Cooperation.php
new file mode 100644
index 0000000..0dcf0d5
--- /dev/null
+++ b/api/app/controller/api/Cooperation.php
@@ -0,0 +1,133 @@
+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,
+ ], '已保存');
+ }
+}
diff --git a/api/app/controller/superadmin/EnterpriseCooperation.php b/api/app/controller/superadmin/EnterpriseCooperation.php
new file mode 100644
index 0000000..f4c87c4
--- /dev/null
+++ b/api/app/controller/superadmin/EnterpriseCooperation.php
@@ -0,0 +1,127 @@
+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 . '"']);
+ }
+}
diff --git a/api/app/model/EnterpriseCooperationMode.php b/api/app/model/EnterpriseCooperationMode.php
new file mode 100644
index 0000000..4d75387
--- /dev/null
+++ b/api/app/model/EnterpriseCooperationMode.php
@@ -0,0 +1,26 @@
+ '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';
+}
diff --git a/api/app/model/UserCooperationChoice.php b/api/app/model/UserCooperationChoice.php
new file mode 100644
index 0000000..a56bacf
--- /dev/null
+++ b/api/app/model/UserCooperationChoice.php
@@ -0,0 +1,20 @@
+ 'int',
+ 'userId' => 'int',
+ 'enterpriseId' => 'int',
+ 'modeCode' => 'string',
+ 'chosenAt' => 'int',
+ 'updatedAt' => 'int',
+ ];
+
+}
diff --git a/api/database/add_enterprise_cooperation.sql b/api/database/add_enterprise_cooperation.sql
new file mode 100644
index 0000000..f011ef5
--- /dev/null
+++ b/api/database/add_enterprise_cooperation.sql
@@ -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='用户在企业下的合作模式选择';
diff --git a/api/docs/企业版合作模式需求.md b/api/docs/企业版合作模式需求.md
new file mode 100644
index 0000000..135ed6c
--- /dev/null
+++ b/api/docs/企业版合作模式需求.md
@@ -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`、小程序接口与弹框、企业后台与超管配置页 |
diff --git a/api/route/api.php b/api/route/api.php
index 00a5c42..063a1d2 100644
--- a/api/route/api.php
+++ b/api/route/api.php
@@ -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');
diff --git a/miniprogram/app.js b/miniprogram/app.js
index 77e1cb4..67c3e23 100644
--- a/miniprogram/app.js
+++ b/miniprogram/app.js
@@ -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,
// 测试次数
diff --git a/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.js b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.js
new file mode 100644
index 0000000..2f207f6
--- /dev/null
+++ b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.js
@@ -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 })
+ }
+ })
+ }
+ }
+})
diff --git a/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.json b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.json
new file mode 100644
index 0000000..a89ef4d
--- /dev/null
+++ b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.json
@@ -0,0 +1,4 @@
+{
+ "component": true,
+ "usingComponents": {}
+}
diff --git a/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxml b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxml
new file mode 100644
index 0000000..417446b
--- /dev/null
+++ b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxml
@@ -0,0 +1,20 @@
+
+
+ 请选择合作模式
+ 三项测评已完成,请选择与您更契合的合作方式(提交后可在企业侧活动中使用)。
+
+
+
+
+
+
+
+
+
+
diff --git a/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxss b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxss
new file mode 100644
index 0000000..e6c75df
--- /dev/null
+++ b/miniprogram/components/cooperation-mode-dialog/cooperation-mode-dialog.wxss
@@ -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;
+}
diff --git a/miniprogram/pages/index/result.js b/miniprogram/pages/index/result.js
index 6a56b55..53c928e 100644
--- a/miniprogram/pages/index/result.js
+++ b/miniprogram/pages/index/result.js
@@ -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 || {}
diff --git a/miniprogram/pages/index/result.json b/miniprogram/pages/index/result.json
index 2f548a1..310d793 100644
--- a/miniprogram/pages/index/result.json
+++ b/miniprogram/pages/index/result.json
@@ -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"
}
}
diff --git a/miniprogram/pages/index/result.wxml b/miniprogram/pages/index/result.wxml
index 730fb63..c6e49ec 100644
--- a/miniprogram/pages/index/result.wxml
+++ b/miniprogram/pages/index/result.wxml
@@ -465,4 +465,9 @@
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
+
diff --git a/miniprogram/pages/promo/index.json b/miniprogram/pages/promo/index.json
index 8abdf4c..ff55d09 100644
--- a/miniprogram/pages/promo/index.json
+++ b/miniprogram/pages/promo/index.json
@@ -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"
+ }
}
\ No newline at end of file
diff --git a/miniprogram/pages/result/mbti.js b/miniprogram/pages/result/mbti.js
index 210d84c..ceec489 100644
--- a/miniprogram/pages/result/mbti.js
+++ b/miniprogram/pages/result/mbti.js
@@ -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 || {}
diff --git a/miniprogram/pages/result/mbti.json b/miniprogram/pages/result/mbti.json
index 4653c82..50799a1 100644
--- a/miniprogram/pages/result/mbti.json
+++ b/miniprogram/pages/result/mbti.json
@@ -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"
}
}
diff --git a/miniprogram/pages/result/mbti.wxml b/miniprogram/pages/result/mbti.wxml
index 2de681d..5b38605 100644
--- a/miniprogram/pages/result/mbti.wxml
+++ b/miniprogram/pages/result/mbti.wxml
@@ -336,4 +336,9 @@
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
+
diff --git a/miniprogram/pages/result/resume.js b/miniprogram/pages/result/resume.js
index e5650f4..26e203d 100644
--- a/miniprogram/pages/result/resume.js
+++ b/miniprogram/pages/result/resume.js
@@ -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') {
diff --git a/miniprogram/pages/result/resume.json b/miniprogram/pages/result/resume.json
index 75882a2..b6fb0b6 100644
--- a/miniprogram/pages/result/resume.json
+++ b/miniprogram/pages/result/resume.json
@@ -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"
+ }
+}
+
diff --git a/miniprogram/pages/result/resume.wxml b/miniprogram/pages/result/resume.wxml
index 48dcc79..cfd26ec 100644
--- a/miniprogram/pages/result/resume.wxml
+++ b/miniprogram/pages/result/resume.wxml
@@ -107,5 +107,10 @@
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
+
diff --git a/miniprogram/utils/enterpriseCooperation.js b/miniprogram/utils/enterpriseCooperation.js
new file mode 100644
index 0000000..8b759ea
--- /dev/null
+++ b/miniprogram/utils/enterpriseCooperation.js
@@ -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
+}
diff --git a/scripts/dev_start.sh b/scripts/dev_start.sh
index 76f176b..218bd94 100755
--- a/scripts/dev_start.sh
+++ b/scripts/dev_start.sh
@@ -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)"
diff --git a/scripts/dev_start_daemon.sh b/scripts/dev_start_daemon.sh
index 1661362..a9b5790 100644
--- a/scripts/dev_start_daemon.sh
+++ b/scripts/dev_start_daemon.sh
@@ -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} ..."