feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)
Made-with: Cursor
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,12 +11,15 @@ build/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.local.merge
|
||||
.env.bak.*
|
||||
|
||||
# 日志与运行时
|
||||
*.log
|
||||
api/runtime/log/
|
||||
api/runtime/cache/
|
||||
api/runtime/temp/
|
||||
api/runtime/dev-*.pid
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
44
README.md
44
README.md
@@ -31,14 +31,44 @@ git push origin main
|
||||
|
||||
## 本地开发(管理后台 + API)
|
||||
|
||||
1. 启动 API(需 PHP 8+,示例用 Homebrew 的 8.4):
|
||||
### 推荐 A:后台常驻(关终端 / 关 Cursor 仍可用)
|
||||
|
||||
在仓库根目录执行(自动释放 **8787**、**5173**,起 PHP + Vite,**nohup 后台**,并做接口自检):
|
||||
|
||||
```bash
|
||||
bash scripts/dev_stop.sh # 可选:先停旧进程
|
||||
bash scripts/dev_start_daemon.sh
|
||||
# 或 admin 目录:npm run dev:daemon
|
||||
```
|
||||
|
||||
浏览器:**http://127.0.0.1:5173/admin/login**
|
||||
日志:`api/runtime/php-dev-server.log`、`api/runtime/vite-dev-server.log`
|
||||
自检:`bash scripts/dev_health.sh`(或 `npm run dev:health`)
|
||||
停止:`bash scripts/dev_stop.sh`
|
||||
|
||||
### 推荐 B:前台一键(调试用,Ctrl+C 即停)
|
||||
|
||||
```bash
|
||||
bash scripts/dev_start.sh
|
||||
# 或 admin 目录:npm run dev:all
|
||||
```
|
||||
|
||||
(请保持 `admin/.env.development` 里 `VITE_API_BASE_URL` **留空**,走 Vite 代理。)
|
||||
|
||||
可选环境变量:`MBTI_PHP_BIN`、`MBTI_API_PORT`、`MBTI_ADMIN_PORT`、`MBTI_API_CURL_TIMEOUT`(守护进程等 API 的 curl 秒数,默认 120)。
|
||||
前端开发环境 axios 默认 **180s** 超时(云库慢);可在 `admin/.env.development` 设 `VITE_REQUEST_TIMEOUT_MS`。
|
||||
Vite 代理默认 **180s**(`VITE_PROXY_TIMEOUT_MS` 可改)。
|
||||
|
||||
### 手动分步(与旧流程一致)
|
||||
|
||||
1. 启动 API(需 PHP 8+):
|
||||
|
||||
```bash
|
||||
cd api/public
|
||||
/usr/local/opt/php@8.4/bin/php -S 127.0.0.1:8787 router.php
|
||||
```
|
||||
|
||||
2. 启动管理后台(`admin/.env.development` 已默认走代理,无需改 hosts):
|
||||
2. 启动管理后台:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
@@ -46,5 +76,13 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
浏览器打开 `http://localhost:5173` ,接口会经 Vite 转发到 `http://127.0.0.1:8787`。若 API 端口不是 8787,可执行
|
||||
接口经 Vite 转发到 `http://127.0.0.1:8787`。若 API 端口不是 8787,可执行
|
||||
`VITE_DEV_API_PROXY=http://127.0.0.1:端口 npm run dev`。
|
||||
|
||||
### 从 kr 宝塔同步 `.env`
|
||||
|
||||
1. 在宝塔站点目录下载或复制线上 `api/.env` 到本机(任意路径)。
|
||||
2. 执行:`python3 scripts/apply_bt_download_env.py /你下载的路径/.env`
|
||||
- 会自动备份当前 `api/.env` 为 `api/.env.bak.<时间戳>`。
|
||||
3. 若宝塔里数据库是 `127.0.0.1`(指服务器本机),本机需隧道或改连云库:可复制 `api/.env.local.merge.example` 为 `api/.env.local.merge`,按注释写好覆盖项,再重新执行第 2 步(会追加合并)。
|
||||
4. **管理端**请保持 `admin/.env.development` 里 `VITE_API_BASE_URL` **留空**,否则浏览器会直连 `mbti.com` 等域名,易出现 **Network Error**。
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
{
|
||||
"name": "mbti-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.6.2",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-echarts": "^6.6.9",
|
||||
"vue-router": "^4.3.0",
|
||||
"vue3-draggable-resizable": "^1.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"sass": "^1.71.1",
|
||||
"typescript": "~5.4.0",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.1.6",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "mbti-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:all": "bash ../scripts/dev_start.sh",
|
||||
"dev:daemon": "bash ../scripts/dev_start_daemon.sh",
|
||||
"dev:health": "bash ../scripts/dev_health.sh",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.6.2",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-echarts": "^6.6.9",
|
||||
"vue-router": "^4.3.0",
|
||||
"vue3-draggable-resizable": "^1.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"sass": "^1.71.1",
|
||||
"typescript": "~5.4.0",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.1.6",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
1207
admin/src/assets/admin-theme.css
Normal file
1207
admin/src/assets/admin-theme.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -209,6 +209,42 @@
|
||||
</div>
|
||||
<div v-else class="ud-empty-hint">暂无人脸分析照片</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用户旅程" name="journey">
|
||||
<div class="ud-journey">
|
||||
<div class="ud-journey__toolbar">
|
||||
<span class="ud-journey__label">统计范围</span>
|
||||
<el-select v-model="journeyDays" style="width: 120px" @change="loadJourney">
|
||||
<el-option :value="7" label="近 7 天" />
|
||||
<el-option :value="30" label="近 30 天" />
|
||||
<el-option :value="90" label="近 90 天" />
|
||||
</el-select>
|
||||
<el-button size="small" :loading="journeyLoading" @click="loadJourney">刷新</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="journeyLoading"
|
||||
:data="journeyRows"
|
||||
stripe
|
||||
size="small"
|
||||
max-height="420"
|
||||
empty-text="暂无行为记录"
|
||||
>
|
||||
<el-table-column prop="createdAt" label="时间" width="160" />
|
||||
<el-table-column label="事件" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.eventNameCn || row.eventName }}</strong>
|
||||
<div class="ud-journey__subname">{{ row.eventName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pagePath" label="页面" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="附加" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="ud-journey__props">{{ formatJourneyProps(row.props) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</main>
|
||||
</template>
|
||||
@@ -249,6 +285,7 @@ import VChart from 'vue-echarts'
|
||||
import { discTopTwoLabel, discCompactLabel } from '@/utils/discDisplay'
|
||||
import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail'
|
||||
import { SBTI_RADAR_DIMENSION_ORDER, buildSbtiRadarValues, formatSbtiSummary } from '@/utils/sbtiDisplay'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
use([CanvasRenderer, RadarChart, GridComponent, TooltipComponent, LegendComponent, RadarComponent])
|
||||
|
||||
@@ -271,12 +308,17 @@ const udTab = ref('analysis')
|
||||
const testPage = ref(1)
|
||||
const testPageSize = 8
|
||||
|
||||
const journeyDays = ref(30)
|
||||
const journeyLoading = ref(false)
|
||||
const journeyRows = ref<any[]>([])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
v => {
|
||||
if (v) {
|
||||
udTab.value = 'analysis'
|
||||
testPage.value = 1
|
||||
journeyRows.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -285,9 +327,45 @@ watch(
|
||||
() => props.user?.id,
|
||||
() => {
|
||||
testPage.value = 1
|
||||
journeyRows.value = []
|
||||
}
|
||||
)
|
||||
|
||||
watch(udTab, (t) => {
|
||||
if (t === 'journey' && journeyRows.value.length === 0) {
|
||||
void loadJourney()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadJourney() {
|
||||
const uid = props.user?.id
|
||||
if (!uid) {
|
||||
journeyRows.value = []
|
||||
return
|
||||
}
|
||||
journeyLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/user-journey', {
|
||||
params: { userId: uid, days: journeyDays.value }
|
||||
})
|
||||
journeyRows.value = res.data?.list || []
|
||||
} catch {
|
||||
journeyRows.value = []
|
||||
} finally {
|
||||
journeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatJourneyProps(p: unknown): string {
|
||||
if (p == null) return '—'
|
||||
try {
|
||||
const s = JSON.stringify(p)
|
||||
return s.length > 160 ? s.slice(0, 160) + '…' : s
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
const avatarLetter = computed(() => {
|
||||
const n = (props.user?.username || props.user?.nickname || '?').trim()
|
||||
return (n.charAt(0) || '?').toUpperCase()
|
||||
@@ -1096,6 +1174,35 @@ function openMail(email: string) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ud-journey {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ud-journey__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ud-journey__label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ud-journey__subname {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ud-journey__props {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ud-layout {
|
||||
flex-direction: column;
|
||||
|
||||
24
admin/src/components/superadmin/SaPageHeader.vue
Normal file
24
admin/src/components/superadmin/SaPageHeader.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="sa-page-head" :class="{ 'sa-page-head-row': !!$slots.actions }">
|
||||
<div class="sa-head-main">
|
||||
<h2 class="sa-title">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
<span v-if="badge" class="sa-title-badge">{{ badge }}</span>
|
||||
</h2>
|
||||
<p v-if="subtitle || $slots.subtitle" class="sa-subtitle">
|
||||
<slot name="subtitle">{{ subtitle }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="sa-head-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
badge?: string
|
||||
}>()
|
||||
</script>
|
||||
58
admin/src/components/superadmin/SaTabs.vue
Normal file
58
admin/src/components/superadmin/SaTabs.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="sa-tabs" role="tablist">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="String(item.value)"
|
||||
role="tab"
|
||||
:class="['sa-tab-item', { 'is-active': modelValue === item.value }]"
|
||||
@click="onSelect(item.value)"
|
||||
>
|
||||
<span v-if="withDot" class="sa-tab-dot" />
|
||||
{{ item.label }}
|
||||
<span v-if="item.badge != null" class="sa-tab-badge">{{ item.badge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string | number">
|
||||
interface SaTabItem<V> {
|
||||
label: string
|
||||
value: V
|
||||
badge?: string | number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: T
|
||||
items: SaTabItem<T>[]
|
||||
withDot?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: T): void
|
||||
(e: 'change', v: T): void
|
||||
}>()
|
||||
|
||||
function onSelect(v: T) {
|
||||
if (v === props.modelValue) return
|
||||
emit('update:modelValue', v)
|
||||
emit('change', v)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sa-tab-badge {
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-radius: 999px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sa-tab-item.is-active .sa-tab-badge {
|
||||
background: var(--sa-primary-soft, #eef2ff);
|
||||
color: var(--sa-primary, #6366f1);
|
||||
}
|
||||
</style>
|
||||
@@ -86,11 +86,11 @@ const authStore = useAuthStore()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin/dashboard', icon: DataLine, label: '概览' },
|
||||
{ path: '/admin/dashboard', icon: DataLine, label: '企业概览' },
|
||||
{ path: '/admin/users', icon: User, label: '用户运营' },
|
||||
{ path: '/admin/orders', icon: ShoppingCart, label: '订单运营' },
|
||||
{ path: '/admin/distribution', icon: Share, label: '分销推广' },
|
||||
{ path: '/admin/settings', icon: Setting, label: '系统设置' },
|
||||
{ path: '/admin/settings', icon: Setting, label: '企业设置' },
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
|
||||
@@ -35,21 +35,43 @@
|
||||
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-main">
|
||||
<el-button
|
||||
v-for="item in navMainItems"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: isActive(item.path) }]"
|
||||
text
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<el-icon class="nav-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</el-button>
|
||||
<div class="nav-group-label">运营总控</div>
|
||||
<template v-for="item in navMainItems" :key="item.path">
|
||||
<!-- 一级条目 -->
|
||||
<el-button
|
||||
:class="['nav-item', { active: isActive(item.path) || hasActiveChild(item) }]"
|
||||
text
|
||||
@click="onNavClick(item)"
|
||||
>
|
||||
<el-icon class="nav-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<el-icon v-if="item.children && item.children.length" class="nav-arrow">
|
||||
<component :is="isExpanded(item.path) ? ArrowDown : ArrowRight" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<!-- 二级子菜单 -->
|
||||
<div
|
||||
v-if="item.children && item.children.length && (isExpanded(item.path) || hasActiveChild(item))"
|
||||
class="nav-sub"
|
||||
>
|
||||
<el-button
|
||||
v-for="sub in item.children"
|
||||
:key="sub.path"
|
||||
:class="['nav-item nav-item--sub', { active: isActive(sub.path) }]"
|
||||
text
|
||||
@click="navigateTo(sub.path)"
|
||||
>
|
||||
<span class="nav-sub-dot"></span>
|
||||
<span class="nav-label">{{ sub.label }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer-block">
|
||||
<div class="nav-group-label">系统</div>
|
||||
<el-button
|
||||
v-for="item in navFooterItems"
|
||||
:key="item.path"
|
||||
@@ -91,7 +113,9 @@ import {
|
||||
Setting,
|
||||
OfficeBuilding,
|
||||
Share,
|
||||
Monitor
|
||||
Monitor,
|
||||
ArrowDown,
|
||||
ArrowRight
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -100,22 +124,65 @@ const authStore = useAuthStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const navMainItems: { path: string; icon: typeof TrendCharts; label: string }[] = [
|
||||
interface NavItem {
|
||||
path: string
|
||||
icon: any
|
||||
label: string
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
const navMainItems: NavItem[] = [
|
||||
{ path: '/superadmin/ops', icon: TrendCharts, label: '总览' },
|
||||
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
|
||||
{
|
||||
path: '/superadmin/enterprises',
|
||||
icon: OfficeBuilding,
|
||||
label: '企业管理',
|
||||
children: [
|
||||
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业列表' },
|
||||
{ path: '/superadmin/soul-articles', icon: Share, label: 'Soul 引流文章' },
|
||||
{ path: '/superadmin/mp-tabbar', icon: Setting, label: '小程序底部菜单' },
|
||||
{ path: '/superadmin/profit-rules', icon: Share, label: '分账规则' }
|
||||
]
|
||||
},
|
||||
{ path: '/superadmin/commerce', icon: ShoppingCart, label: '订单和财务' },
|
||||
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' },
|
||||
{ path: '/superadmin/ai-config', icon: Cpu, label: '智能算力' }
|
||||
]
|
||||
|
||||
const navFooterItems: { path: string; icon: typeof Setting; label: string }[] = [
|
||||
const navFooterItems: NavItem[] = [
|
||||
{ path: '/superadmin/settings', icon: Setting, label: '系统设置' }
|
||||
]
|
||||
|
||||
const expandedPaths = ref<Set<string>>(new Set())
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path || route.path.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
const hasActiveChild = (item: NavItem): boolean => {
|
||||
if (!item.children) return false
|
||||
return item.children.some(c => isActive(c.path))
|
||||
}
|
||||
|
||||
const isExpanded = (p: string) => expandedPaths.value.has(p)
|
||||
|
||||
const onNavClick = (item: NavItem) => {
|
||||
if (item.children && item.children.length) {
|
||||
// 有子菜单 → 切换展开
|
||||
if (expandedPaths.value.has(item.path)) {
|
||||
expandedPaths.value.delete(item.path)
|
||||
} else {
|
||||
expandedPaths.value.add(item.path)
|
||||
}
|
||||
// 首次展开时,若当前没选中任何子项,就默认跳第一个子项
|
||||
if (expandedPaths.value.has(item.path) && !hasActiveChild(item)) {
|
||||
navigateTo(item.children[0].path)
|
||||
}
|
||||
} else {
|
||||
navigateTo(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
const goAdminConsole = () => {
|
||||
const url = router.resolve({ path: '/admin/dashboard' }).href
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
@@ -146,7 +213,7 @@ const handleLogout = async () => {
|
||||
<style scoped lang="scss">
|
||||
.superadmin-layout {
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
@@ -187,12 +254,13 @@ const handleLogout = async () => {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: #ef4444;
|
||||
background-image: linear-gradient(135deg, #1E40AF 0%, #3730A3 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.3);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
@@ -227,12 +295,12 @@ const handleLogout = async () => {
|
||||
}
|
||||
|
||||
&.admin-entry {
|
||||
color: #b91c1c;
|
||||
color: #1E40AF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.logout {
|
||||
color: #ef4444;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
@@ -300,8 +368,8 @@ const handleLogout = async () => {
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fef2f2;
|
||||
color: #ef4444;
|
||||
background-color: #E0E7FF;
|
||||
color: #1E40AF;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
@@ -311,7 +379,11 @@ const handleLogout = async () => {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: #ef4444;
|
||||
background-color: #1E40AF;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: #1E40AF;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +395,42 @@ const handleLogout = async () => {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
font-size: 12px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f9fafb;
|
||||
border-left: 2px solid #e5e7eb;
|
||||
margin: 0 0 4px 24px;
|
||||
}
|
||||
|
||||
.nav-item--sub {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
font-size: 13px;
|
||||
color: #6B7280;
|
||||
|
||||
.nav-sub-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #D1D5DB;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #EEF2FF;
|
||||
color: #1E40AF;
|
||||
|
||||
.nav-sub-dot {
|
||||
background: #1E40AF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.scss'
|
||||
import { migrateLegacyAuthStorage } from '@/utils/authStorage'
|
||||
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.scss'
|
||||
import './assets/admin-theme.css'
|
||||
import { migrateLegacyAuthStorage } from '@/utils/authStorage'
|
||||
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/Dashboard.vue'),
|
||||
meta: { title: '概览' }
|
||||
meta: { title: '企业概览' }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
@@ -63,7 +63,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'settings',
|
||||
name: 'AdminSettings',
|
||||
component: () => import('@/views/admin/Settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
meta: { title: '企业设置' }
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -138,6 +138,30 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/superadmin/AIConfig.vue'),
|
||||
meta: { title: '智能算力' }
|
||||
},
|
||||
{
|
||||
path: 'soul-articles',
|
||||
name: 'SuperAdminSoulArticles',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/enterprises',
|
||||
query: { ...to.query, tab: 'soulArticles' }
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'mp-tabbar',
|
||||
name: 'SuperAdminMpTabBar',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/enterprises',
|
||||
query: { ...to.query, tab: 'mpTabbar' }
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'profit-rules',
|
||||
name: 'SuperAdminProfitRules',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/enterprises',
|
||||
query: { ...to.query, tab: 'profitRules' }
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'SuperAdminSettings',
|
||||
|
||||
@@ -28,10 +28,18 @@ const getBaseURL = (): string => {
|
||||
return '/api/v1'
|
||||
}
|
||||
|
||||
// 本地连云库时接口可能较慢:开发环境默认放宽;可用 VITE_REQUEST_TIMEOUT_MS 覆盖
|
||||
const requestTimeoutMs = (() => {
|
||||
const raw = import.meta.env.VITE_REQUEST_TIMEOUT_MS
|
||||
const n = typeof raw === 'string' ? Number(raw.trim()) : Number(raw)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
return import.meta.env.DEV ? 180000 : 15000
|
||||
})()
|
||||
|
||||
// 创建 axios 实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: getBaseURL(),
|
||||
timeout: 15000,
|
||||
timeout: requestTimeoutMs,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
@@ -1,10 +1,49 @@
|
||||
<template>
|
||||
<div class="dashboard-viewport" v-loading="loading">
|
||||
<header class="dash-head">
|
||||
<h1 class="dash-title">数据概览</h1>
|
||||
<p class="dash-tagline"></p>
|
||||
<h1 class="dash-title">企业概览</h1>
|
||||
<p class="dash-tagline">实时掌握余额、扣款、冻结佣金与补充建议</p>
|
||||
</header>
|
||||
|
||||
<div class="wallet-strip">
|
||||
<div class="wallet-card wallet-card--primary">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">当前余额</div>
|
||||
<div class="wallet-value">¥{{ fenToYuan(finance.balanceFen) }}</div>
|
||||
<div class="wallet-foot">历史充值 ¥{{ fenToYuan(finance.manualRechargeFen) }}</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><Wallet /></el-icon></div>
|
||||
<el-button type="primary" size="small" class="wallet-action" @click="openRechargeDialog">立即充值</el-button>
|
||||
</div>
|
||||
<div class="wallet-card wallet-card--consume">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">本月已扣款</div>
|
||||
<div class="wallet-value">¥{{ fenToYuan(finance.monthConsumeFen) }}</div>
|
||||
<div class="wallet-foot">日均 ¥{{ fenToYuan(finance.avgDailyConsumeFen) }} · 今日 ¥{{ fenToYuan(finance.todayConsumeFen) }}</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><TrendCharts /></el-icon></div>
|
||||
</div>
|
||||
<div class="wallet-card wallet-card--frozen">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">冻结佣金</div>
|
||||
<div class="wallet-value">¥{{ fenToYuan(finance.frozenCommissionFen) }}</div>
|
||||
<div class="wallet-foot">余额补足后自动解冻</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><Lock /></el-icon></div>
|
||||
</div>
|
||||
<div class="wallet-card wallet-card--suggest">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">建议充值</div>
|
||||
<div class="wallet-value">
|
||||
<template v-if="finance.suggestRechargeFen > 0">¥{{ fenToYuan(finance.suggestRechargeFen) }}</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
<div class="wallet-foot">{{ suggestHint }}</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><WarningFilled /></el-icon></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-kpis">
|
||||
<div v-for="(card, i) in kpiCards" :key="card.key" class="stat-card" :style="{ animationDelay: `${i * 45}ms` }">
|
||||
<div class="stat-info">
|
||||
@@ -15,21 +54,43 @@
|
||||
<el-icon><component :is="card.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-recharge" :style="{ animationDelay: `${kpiCards.length * 45}ms` }">
|
||||
<div class="stat-card-recharge-inner">
|
||||
<div class="stat-info stat-info--recharge">
|
||||
<div class="stat-label">企业余额</div>
|
||||
<div class="stat-value-row">
|
||||
<span class="stat-value">{{ enterpriseBalanceDisplay }}</span>
|
||||
<el-button link type="primary" size="small" class="recharge-btn-inline" @click="goEnterpriseRecharge">充值</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="rechargeVisible" title="企业余额充值" width="420px" :close-on-click-modal="false" destroy-on-close>
|
||||
<div class="recharge-dialog">
|
||||
<el-form :model="rechargeForm" label-width="80px" size="default">
|
||||
<el-form-item label="金额(元)">
|
||||
<el-input-number
|
||||
v-model="rechargeForm.amountYuan"
|
||||
:min="1"
|
||||
:max="100000"
|
||||
:step="100"
|
||||
controls-position="right"
|
||||
style="width:100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="quickAmounts.length" label="快速">
|
||||
<div class="recharge-quick">
|
||||
<el-button
|
||||
v-for="a in quickAmounts"
|
||||
:key="a"
|
||||
size="small"
|
||||
:type="rechargeForm.amountYuan === a ? 'primary' : 'default'"
|
||||
@click="rechargeForm.amountYuan = a"
|
||||
>¥{{ a }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="recharge-action">
|
||||
<el-button type="primary" :loading="rechargeLoading" @click="generateRechargeQr">生成充值码</el-button>
|
||||
<span class="recharge-tip">用微信扫码,在小程序完成支付</span>
|
||||
</div>
|
||||
<div class="stat-icon amber">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<div v-if="rechargeQrcode" class="recharge-qr">
|
||||
<img :src="rechargeQrcode" alt="充值二维码" />
|
||||
<div class="recharge-qr-text">扫码支付 ¥{{ rechargeAmountYuanDisplay }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<div class="dash-catalog" v-if="testCatalog.length">
|
||||
<div
|
||||
@@ -158,7 +219,9 @@ import {
|
||||
Reading,
|
||||
Histogram,
|
||||
Medal,
|
||||
Wallet
|
||||
Wallet,
|
||||
Lock,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -192,12 +255,65 @@ const stats = reactive({
|
||||
activeToday: 0
|
||||
})
|
||||
|
||||
/** 企业余额(分),来自 /admin/finance/overview,与财务页一致 */
|
||||
const balanceFen = ref(0)
|
||||
/** 企业钱包概览(分),来自 /admin/finance/overview */
|
||||
const finance = reactive({
|
||||
balanceFen: 0,
|
||||
manualRechargeFen: 0,
|
||||
frozenCommissionFen: 0,
|
||||
monthConsumeFen: 0,
|
||||
todayConsumeFen: 0,
|
||||
avgDailyConsumeFen: 0,
|
||||
suggestRechargeFen: 0,
|
||||
})
|
||||
|
||||
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
|
||||
|
||||
const enterpriseBalanceDisplay = computed(() => `¥${fenToYuan(balanceFen.value)}`)
|
||||
const suggestHint = computed(() => {
|
||||
if (finance.avgDailyConsumeFen <= 0) return '暂无消耗,按需充值即可'
|
||||
if (finance.suggestRechargeFen <= 0) return '余额可支撑 > 14 日'
|
||||
return '≥ 2 周使用量'
|
||||
})
|
||||
|
||||
const rechargeVisible = ref(false)
|
||||
const rechargeLoading = ref(false)
|
||||
const rechargeForm = reactive({ amountYuan: 500 })
|
||||
const rechargeQrcode = ref('')
|
||||
const quickAmounts = [100, 500, 1000, 3000, 5000]
|
||||
const rechargeAmountYuanDisplay = computed(() => Number(rechargeForm.amountYuan || 0).toFixed(2))
|
||||
|
||||
function openRechargeDialog() {
|
||||
rechargeQrcode.value = ''
|
||||
if (finance.suggestRechargeFen > 0) {
|
||||
rechargeForm.amountYuan = Math.max(100, Math.round(finance.suggestRechargeFen / 100))
|
||||
} else if (!rechargeForm.amountYuan) {
|
||||
rechargeForm.amountYuan = 500
|
||||
}
|
||||
rechargeVisible.value = true
|
||||
}
|
||||
|
||||
async function generateRechargeQr() {
|
||||
if (rechargeLoading.value) return
|
||||
const amountFen = Math.round(Number(rechargeForm.amountYuan || 0) * 100)
|
||||
if (amountFen <= 0) {
|
||||
ElMessage.warning('请先输入充值金额')
|
||||
return
|
||||
}
|
||||
rechargeLoading.value = true
|
||||
rechargeQrcode.value = ''
|
||||
try {
|
||||
const res: any = await request.post('/admin/finance/recharge-qrcode', { amountFen })
|
||||
const qr = res?.data?.qrcode || res?.qrcode
|
||||
if (typeof qr === 'string' && qr) {
|
||||
rechargeQrcode.value = qr
|
||||
} else {
|
||||
ElMessage.error(res?.message || '二维码生成失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '二维码生成失败')
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testTrends = ref<
|
||||
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
|
||||
@@ -244,7 +360,7 @@ const kpiCards = computed(() => [
|
||||
])
|
||||
|
||||
function goEnterpriseRecharge() {
|
||||
void router.push({ path: '/admin/settings', query: { tab: 'finance' } })
|
||||
openRechargeDialog()
|
||||
}
|
||||
|
||||
const catalogIconMap: Record<string, { icon: typeof Camera; tone: string }> = {
|
||||
@@ -389,9 +505,22 @@ function summarizeTypes(row: TopUserRow) {
|
||||
async function loadFinanceBalance() {
|
||||
try {
|
||||
const res: any = await request.get('/admin/finance/overview')
|
||||
balanceFen.value = Number(res?.data?.balanceFen ?? 0)
|
||||
const d = res?.data || {}
|
||||
finance.balanceFen = Number(d.balanceFen ?? 0)
|
||||
finance.manualRechargeFen = Number(d.manualRechargeFen ?? 0)
|
||||
finance.frozenCommissionFen = Number(d.frozenCommissionFen ?? 0)
|
||||
finance.monthConsumeFen = Number(d.monthConsumeFen ?? 0)
|
||||
finance.todayConsumeFen = Number(d.todayConsumeFen ?? 0)
|
||||
finance.avgDailyConsumeFen = Number(d.avgDailyConsumeFen ?? 0)
|
||||
finance.suggestRechargeFen = Number(d.suggestRechargeFen ?? 0)
|
||||
} catch {
|
||||
balanceFen.value = 0
|
||||
finance.balanceFen = 0
|
||||
finance.manualRechargeFen = 0
|
||||
finance.frozenCommissionFen = 0
|
||||
finance.monthConsumeFen = 0
|
||||
finance.todayConsumeFen = 0
|
||||
finance.avgDailyConsumeFen = 0
|
||||
finance.suggestRechargeFen = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,11 +655,73 @@ const loadInviteQrcode = async () => {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.dash-kpis {
|
||||
.wallet-strip {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wallet-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px 16px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
animation: dashFadeUp 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.wallet-info { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.wallet-label { font-size: 12px; color: #64748b; font-weight: 500; }
|
||||
.wallet-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.wallet-foot { font-size: 11px; color: #94a3b8; }
|
||||
.wallet-icon {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wallet-card--primary { background: linear-gradient(135deg, #eef2ff 0%, #ffffff 60%); }
|
||||
.wallet-card--primary .wallet-icon { background: #eef2ff; color: #4f46e5; }
|
||||
.wallet-card--primary .wallet-value { color: #3730a3; }
|
||||
.wallet-card--consume .wallet-icon { background: #ecfdf5; color: #10b981; }
|
||||
.wallet-card--frozen .wallet-icon { background: #fffbeb; color: #b45309; }
|
||||
.wallet-card--suggest .wallet-icon { background: #fef2f2; color: #ef4444; }
|
||||
.wallet-action {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
padding: 4px 12px;
|
||||
font-weight: 600;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.recharge-dialog { display: flex; flex-direction: column; gap: 12px; }
|
||||
.recharge-quick { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.recharge-action { display: flex; align-items: center; gap: 10px; }
|
||||
.recharge-tip { font-size: 12px; color: #94a3b8; }
|
||||
.recharge-qr { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.recharge-qr img { width: 200px; height: 200px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; padding: 8px; }
|
||||
.recharge-qr-text { font-size: 13px; color: #475569; }
|
||||
|
||||
.dash-kpis {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -983,11 +1174,17 @@ const loadInviteQrcode = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.wallet-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-kpis {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.wallet-strip { grid-template-columns: 1fr; }
|
||||
|
||||
.dash-catalog {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@@ -86,27 +86,29 @@
|
||||
<VChart v-if="hasProductSeries" class="trend-chart-echarts" :option="productSeriesOption" autoresize />
|
||||
<div v-else class="empty-chart">暂无产品数据</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card top-ranking-card">
|
||||
<div class="card-header">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<span>分销商收益排行</span>
|
||||
<span>分销商 Top 10 排行榜</span>
|
||||
<span class="head-hint">点击「查看订单」定位该分销商带来的订单</span>
|
||||
</div>
|
||||
<div v-if="overviewTopDistributors.length" class="overview-list">
|
||||
<div v-for="item in overviewTopDistributors" :key="item.id" class="overview-list-item">
|
||||
<div v-for="(item, idx) in overviewTopDistributors" :key="item.id" class="overview-list-item">
|
||||
<div class="overview-main">
|
||||
<span :class="['rank-badge', idx < 3 ? 'rank-top' : '']">{{ idx + 1 }}</span>
|
||||
<div class="agent-cell">
|
||||
<el-avatar :size="28" :src="item.avatar" class="agent-avatar">
|
||||
<el-avatar :size="30" :src="item.avatar" class="agent-avatar">
|
||||
{{ item.agentName ? item.agentName[0] : '?' }}
|
||||
</el-avatar>
|
||||
<div class="overview-meta">
|
||||
<strong>{{ item.agentName || '-' }}</strong>
|
||||
<span>团队人数:{{ item.teamCount || 0 }}人</span>
|
||||
<span>团队 {{ item.teamCount || 0 }} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-side">
|
||||
<strong>{{ formatCurrency(parseFloat(item.totalCommission || 0)) }}</strong>
|
||||
<span>累计收益</span>
|
||||
<el-button link type="primary" size="small" @click="gotoInviterOrders(item)">查看订单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +171,11 @@
|
||||
{{ row.createdAt ? new Date(row.createdAt * 1000).toLocaleString() : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="gotoInviterOrders(row)">该分销商订单</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="distributors.length === 0 && !loading" class="empty-placeholder">暂无分销商数据</div>
|
||||
</div>
|
||||
@@ -389,6 +396,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Refresh, User, Money, Clock, Connection, Search,
|
||||
CaretTop, TrendCharts, PieChart, UserFilled, List
|
||||
@@ -398,11 +406,13 @@ import { ElMessage } from 'element-plus'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, PieChart as EchartsPieChart } from 'echarts/charts'
|
||||
import { LineChart, PieChart as EchartsPieChart, BarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, LineChart, EchartsPieChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
use([CanvasRenderer, LineChart, BarChart, EchartsPieChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref('overview')
|
||||
const distSearch = ref('')
|
||||
@@ -445,35 +455,59 @@ const hasProductSeries = computed(() =>
|
||||
productCommissionSeries.value.some(item => Number(item.value || 0) > 0)
|
||||
)
|
||||
|
||||
const cumulativeTrend = computed(() => {
|
||||
let acc = 0
|
||||
return commissionTrend.value.map(item => {
|
||||
acc += Number(item.amount || 0)
|
||||
return Number(acc.toFixed(2))
|
||||
})
|
||||
})
|
||||
|
||||
const commissionTrendOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 24, bottom: 30 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { data: ['每日佣金', '累计佣金'], top: 0, textStyle: { color: '#64748b', fontSize: 11 } },
|
||||
grid: { left: 44, right: 50, top: 30, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: commissionTrend.value.map(item => item.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: {
|
||||
color: '#6b7280',
|
||||
formatter: (value: number) => `¥${value}`
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '每日',
|
||||
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 10, formatter: (v: number) => `¥${v}` }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '累计',
|
||||
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 10, formatter: (v: number) => `¥${v}` }
|
||||
}
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '佣金',
|
||||
name: '每日佣金',
|
||||
type: 'bar',
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: '#4F46E5', borderRadius: [4, 4, 0, 0] },
|
||||
data: commissionTrend.value.map(item => Number(item.amount || 0))
|
||||
},
|
||||
{
|
||||
name: '累计佣金',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 6,
|
||||
itemStyle: { color: '#7c3aed' },
|
||||
areaStyle: { color: 'rgba(124, 58, 237, 0.08)' },
|
||||
data: commissionTrend.value.map(item => Number(item.amount || 0))
|
||||
yAxisIndex: 1,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#10B981', width: 2 },
|
||||
itemStyle: { color: '#10B981' },
|
||||
data: cumulativeTrend.value
|
||||
}
|
||||
]
|
||||
}))
|
||||
@@ -576,7 +610,7 @@ const loadOverview = async () => {
|
||||
if (distributorsRes.code === 200 && distributorsRes.data) {
|
||||
overviewTopDistributors.value = [...(distributorsRes.data.list || [])]
|
||||
.sort((a: any, b: any) => parseFloat(b.totalCommission || 0) - parseFloat(a.totalCommission || 0))
|
||||
.slice(0, 5)
|
||||
.slice(0, 10)
|
||||
} else {
|
||||
overviewTopDistributors.value = []
|
||||
}
|
||||
@@ -726,6 +760,18 @@ watch(commFilter, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function gotoInviterOrders(row: any) {
|
||||
const id = row?.id ?? row?.userId ?? row?.inviterId
|
||||
if (!id) {
|
||||
ElMessage.warning('分销商 ID 缺失')
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: '/admin/orders',
|
||||
query: { inviterId: String(id), inviterName: row?.agentName || '' },
|
||||
})
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (activeTab.value === 'overview') {
|
||||
await loadOverview()
|
||||
@@ -929,41 +975,74 @@ onMounted(() => {
|
||||
.overview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 180px;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.overview-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #f3f4f6;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background-color: #eef2ff; }
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.rank-top { background: #fef3c7; color: #b45309; }
|
||||
}
|
||||
|
||||
.top-ranking-card {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
.card-header { justify-content: space-between; }
|
||||
|
||||
.head-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overview-side {
|
||||
min-width: 120px;
|
||||
min-width: 150px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
329
admin/src/views/admin/OrderTimelineDrawer.vue
Normal file
329
admin/src/views/admin/OrderTimelineDrawer.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
direction="rtl"
|
||||
size="480px"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<div v-if="order" class="timeline-drawer">
|
||||
<div class="summary-card">
|
||||
<div class="summary-row">
|
||||
<span class="label">订单号</span>
|
||||
<span class="value monospaced">{{ order.orderNo || '—' }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">用户</span>
|
||||
<span class="value">{{ order.userName || '—' }} <em>{{ formatPhone(order.userPhone) }}</em></span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">产品</span>
|
||||
<span class="value">{{ order.productTitle || productTypeLabel(order.productType) }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">金额</span>
|
||||
<span class="value amount">{{ formatAmount(order.amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">状态</span>
|
||||
<span :class="['status-badge', 'status-' + (order.status || 'pending')]">{{ statusLabel(order.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="section-title">订单时间线</h4>
|
||||
<ul class="timeline">
|
||||
<li v-for="step in timelineSteps" :key="step.key" :class="['timeline-step', step.active ? 'active' : 'inactive']">
|
||||
<span class="dot" />
|
||||
<div class="step-body">
|
||||
<div class="step-title">{{ step.title }}</div>
|
||||
<div class="step-time">{{ step.active ? formatDateTime(step.time) : '—' }}</div>
|
||||
<div class="step-desc" v-if="step.desc">{{ step.desc }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="commissionList.length">
|
||||
<h4 class="section-title">分销分润</h4>
|
||||
<div class="commission-list">
|
||||
<div v-for="cr in commissionList" :key="cr.id" class="commission-card">
|
||||
<div class="commission-top">
|
||||
<span class="inviter">{{ cr.inviterName || cr.inviterNickname || ('#' + cr.inviterId) }}</span>
|
||||
<span :class="['status-badge', 'status-' + mapCommissionStatus(cr.status)]">{{ commissionStatusLabel(cr.status) }}</span>
|
||||
</div>
|
||||
<div class="commission-meta">
|
||||
<span>佣金 <strong>{{ formatAmount(cr.commissionFen) }}</strong></span>
|
||||
<span v-if="cr.rate != null">比例 {{ Number(cr.rate * 100).toFixed(1) }}%</span>
|
||||
<span v-if="cr.paidAt">结算 {{ formatDateTime(cr.paidAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-commission">无分销分润记录</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface OrderInput {
|
||||
orderNo?: string
|
||||
userName?: string
|
||||
userPhone?: string
|
||||
userId?: number
|
||||
productType?: string
|
||||
productTitle?: string
|
||||
amount?: number
|
||||
status?: string
|
||||
createdAt?: number | null
|
||||
payTime?: number | null
|
||||
refundTime?: number | null
|
||||
cancelTime?: number | null
|
||||
commissions?: any[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
order: OrderInput | null
|
||||
commissions?: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const drawerTitle = computed(() => `订单详情 · ${props.order?.orderNo || ''}`)
|
||||
|
||||
const commissionList = computed(() => {
|
||||
const inline = Array.isArray(props.order?.commissions) ? props.order!.commissions! : []
|
||||
const extra = Array.isArray(props.commissions) ? props.commissions : []
|
||||
return [...inline, ...extra]
|
||||
})
|
||||
|
||||
const timelineSteps = computed(() => {
|
||||
const order = props.order
|
||||
if (!order) return []
|
||||
const status = order.status || 'pending'
|
||||
const commissionPaidAt = commissionList.value.find(c => c.paidAt)?.paidAt ?? null
|
||||
const createdActive = !!order.createdAt
|
||||
const paidActive = !!order.payTime || ['paid', 'completed', 'refunded'].includes(status)
|
||||
const settledActive = !!commissionPaidAt || status === 'completed'
|
||||
const refundActive = status === 'refunded' && !!(order.refundTime || order.payTime)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'created',
|
||||
title: '下单',
|
||||
time: order.createdAt,
|
||||
active: createdActive,
|
||||
desc: '用户发起订单',
|
||||
},
|
||||
{
|
||||
key: 'paid',
|
||||
title: '付款',
|
||||
time: order.payTime,
|
||||
active: paidActive,
|
||||
desc: paidActive ? '支付已到账' : '等待支付',
|
||||
},
|
||||
{
|
||||
key: 'settled',
|
||||
title: '分销结算',
|
||||
time: commissionPaidAt,
|
||||
active: settledActive,
|
||||
desc: settledActive
|
||||
? (commissionList.value.length ? '佣金按规则结算' : '无分销,直接完成')
|
||||
: '尚未结算',
|
||||
},
|
||||
...(refundActive
|
||||
? [{ key: 'refunded', title: '退款', time: order.refundTime || order.payTime, active: true, desc: '已发起退款' }]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
function formatPhone(phone?: string) {
|
||||
if (!phone) return ''
|
||||
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
|
||||
return phone
|
||||
}
|
||||
|
||||
function formatDateTime(ts?: number | null) {
|
||||
if (ts == null) return '—'
|
||||
const d = new Date(Number(ts) * 1000)
|
||||
return (
|
||||
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
|
||||
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
|
||||
)
|
||||
}
|
||||
|
||||
function formatAmount(amountFen?: number | string | null) {
|
||||
if (amountFen == null) return '¥0.00'
|
||||
const n = typeof amountFen === 'number' ? amountFen : Number(amountFen)
|
||||
if (!Number.isFinite(n)) return '¥0.00'
|
||||
return '¥' + (n / 100).toFixed(2)
|
||||
}
|
||||
|
||||
function productTypeLabel(type?: string) {
|
||||
const map: Record<string, string> = {
|
||||
face: 'AI人脸分析',
|
||||
mbti: 'MBTI',
|
||||
disc: 'DISC',
|
||||
pdp: 'PDP',
|
||||
sbti: 'SBTI',
|
||||
report: '完整报告',
|
||||
}
|
||||
return map[type || ''] || type || '—'
|
||||
}
|
||||
|
||||
function statusLabel(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款',
|
||||
failed: '失败',
|
||||
}
|
||||
return map[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
function mapCommissionStatus(s: string) {
|
||||
if (s === 'paid' || s === 'settled') return 'completed'
|
||||
if (s === 'frozen') return 'frozen'
|
||||
if (s === 'cancelled') return 'cancelled'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function commissionStatusLabel(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
frozen: '冻结中',
|
||||
pending: '待结算',
|
||||
paid: '已结算',
|
||||
settled: '已结算',
|
||||
cancelled: '已撤销',
|
||||
}
|
||||
return map[s] || s || '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.timeline-drawer { padding: 8px 4px; }
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
.label { color: #64748b; flex-shrink: 0; width: 56px; }
|
||||
.value { color: #0f172a; font-weight: 500; }
|
||||
.value.amount { color: #4f46e5; font-weight: 700; }
|
||||
.value.monospaced { font-family: 'SF Mono', Menlo, monospace; font-weight: 500; }
|
||||
em { color: #94a3b8; font-style: normal; margin-left: 6px; font-size: 12px; }
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
position: relative;
|
||||
padding-left: 22px;
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 14px;
|
||||
bottom: -10px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&:last-child::before { display: none; }
|
||||
|
||||
&.active .dot { background: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
|
||||
|
||||
.step-title { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||
.step-time { font-size: 12px; color: #64748b; margin-top: 2px; }
|
||||
.step-desc { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
|
||||
&.inactive {
|
||||
.step-title, .step-time, .step-desc { color: #94a3b8; }
|
||||
}
|
||||
}
|
||||
|
||||
.commission-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.commission-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
|
||||
.commission-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.inviter { font-size: 13px; color: #0f172a; font-weight: 600; }
|
||||
}
|
||||
|
||||
.commission-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
flex-wrap: wrap;
|
||||
|
||||
strong { color: #4f46e5; font-weight: 600; }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-commission {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -91,7 +91,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table">
|
||||
<div v-if="inviterId" class="inviter-banner">
|
||||
<span>筛选:分销商 #{{ inviterId }}<span v-if="inviterName"> · {{ inviterName }}</span></span>
|
||||
<el-button link type="primary" size="small" @click="clearInviterFilter">清除筛选</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table" row-key="id">
|
||||
<el-table-column label="订单号" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="order-id">{{ row.orderNo }}</span>
|
||||
@@ -105,13 +110,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<el-table-column label="产品" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<div class="product-main">{{ row.productTitle || productTypeLabel(row.productType) }}</div>
|
||||
<div class="product-sub" v-if="row.productTitle">
|
||||
{{ productTypeLabel(row.productType) }}
|
||||
</div>
|
||||
<div class="product-sub" v-if="row.productTitle">{{ productTypeLabel(row.productType) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -120,45 +123,36 @@
|
||||
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付方式" width="100" align="center">
|
||||
<el-table-column label="支付方式" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ payMethodLabel(row.payMethod) }}
|
||||
<span class="pay-method">{{ payMethodLabel(row.payMethod) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
<span :class="['status-badge', 'status-' + (row.status || 'pending')]">{{ statusLabel(row.status) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联测试" min-width="200">
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="test-data-cell">
|
||||
<template v-if="row.testData && row.testData.length">
|
||||
<div
|
||||
v-for="(t, i) in row.testData"
|
||||
:key="t.id || i"
|
||||
class="test-item"
|
||||
>
|
||||
<span class="test-type">{{ testTypeLabel(t.testType) }}</span>
|
||||
<span class="test-summary">{{ t.resultSummary || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<div class="time-cell">
|
||||
<div class="time-row"><em>创建</em>{{ formatDate(row.createdAt) }}</div>
|
||||
<div class="time-row"><em>支付</em>{{ row.payTime ? formatDate(row.payTime) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.payTime ? formatDate(row.payTime) : '—' }}
|
||||
<el-button link type="primary" @click="openTimeline(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
<span>详情</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<OrderTimelineDrawer v-model="timelineVisible" :order="currentOrder" :commissions="currentCommissions" />
|
||||
|
||||
<div class="empty-state" v-if="orders.length === 0 && !loading">
|
||||
<span>暂无订单数据</span>
|
||||
</div>
|
||||
@@ -181,15 +175,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Refresh, ShoppingCart, Box, Money, Search } from '@element-plus/icons-vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Refresh, ShoppingCart, Box, Money, Search, View } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import OrderTimelineDrawer from './OrderTimelineDrawer.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ embedded?: boolean; ordersApiPath?: string }>(),
|
||||
{ embedded: false, ordersApiPath: '/admin/orders' }
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('')
|
||||
@@ -201,6 +200,44 @@ const totalRevenueFen = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
const timelineVisible = ref(false)
|
||||
const currentOrder = ref<any | null>(null)
|
||||
const currentCommissions = ref<any[]>([])
|
||||
|
||||
const inviterId = ref<string | number>('')
|
||||
const inviterName = ref<string>('')
|
||||
|
||||
function readInviterFromRoute() {
|
||||
const q = route?.query || {}
|
||||
const id = Array.isArray(q.inviterId) ? q.inviterId[0] : q.inviterId
|
||||
const name = Array.isArray(q.inviterName) ? q.inviterName[0] : q.inviterName
|
||||
inviterId.value = (id ?? '') as any
|
||||
inviterName.value = (name ?? '') as string
|
||||
}
|
||||
|
||||
function clearInviterFilter() {
|
||||
inviterId.value = ''
|
||||
inviterName.value = ''
|
||||
const q = { ...route.query }
|
||||
delete (q as any).inviterId
|
||||
delete (q as any).inviterName
|
||||
router.replace({ path: route.path, query: q })
|
||||
currentPage.value = 1
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
watch(() => route.query, () => {
|
||||
readInviterFromRoute()
|
||||
currentPage.value = 1
|
||||
loadOrders()
|
||||
})
|
||||
|
||||
function openTimeline(row: any) {
|
||||
currentOrder.value = row
|
||||
currentCommissions.value = Array.isArray(row?.commissions) ? row.commissions : []
|
||||
timelineVisible.value = true
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待支付', value: 'pending' },
|
||||
@@ -303,15 +340,17 @@ function statusTagType(
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get(props.ordersApiPath, {
|
||||
params: {
|
||||
page: currentPage.value,
|
||||
pageSize,
|
||||
keyword: searchQuery.value,
|
||||
status: statusFilter.value,
|
||||
productType: productFilter.value
|
||||
}
|
||||
})
|
||||
const params: Record<string, any> = {
|
||||
page: currentPage.value,
|
||||
pageSize,
|
||||
keyword: searchQuery.value,
|
||||
status: statusFilter.value,
|
||||
productType: productFilter.value
|
||||
}
|
||||
if (inviterId.value) {
|
||||
params.inviterId = inviterId.value
|
||||
}
|
||||
const res: any = await request.get(props.ordersApiPath, { params })
|
||||
const list = res.data?.list ?? res?.list ?? []
|
||||
orders.value = list
|
||||
total.value = res.data?.total ?? res?.total ?? 0
|
||||
@@ -327,7 +366,10 @@ async function loadOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadOrders())
|
||||
onMounted(() => {
|
||||
readInviterFromRoute()
|
||||
loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -531,8 +573,34 @@ onMounted(() => loadOrders())
|
||||
|
||||
.amount-cell {
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pay-method {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.product-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
em {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
color: #94a3b8;
|
||||
font-style: normal;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.time-row + .time-row { margin-top: 2px; }
|
||||
}
|
||||
|
||||
.test-data-cell {
|
||||
@@ -619,4 +687,17 @@ onMounted(() => loadOrders())
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.inviter-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
margin: 0 20px 0;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>用户数据</h2>
|
||||
<p class="subtitle">按企业用户池分类查看和管理用户数据</p>
|
||||
<h2>用户测试画像</h2>
|
||||
<p class="subtitle">以用户为核心 · 一行看清面容分析、MBTI / DISC / PDP 结果</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button variant="outline" size="small" @click="exportData">
|
||||
@@ -12,8 +12,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 画像汇总条 -->
|
||||
<div class="profile-summary">
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-blue">👥</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">总用户数</div>
|
||||
<div class="summary-value">{{ total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-teal">🪞</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">已面容分析</div>
|
||||
<div class="summary-value">
|
||||
{{ profileStats.faceCount }}
|
||||
<span class="summary-sub">/ 本页 {{ users.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-indigo">🧠</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">已完成 MBTI</div>
|
||||
<div class="summary-value">
|
||||
{{ profileStats.mbtiCount }}
|
||||
<span class="summary-sub">/ 本页 {{ users.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-amber">📊</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">至少一项测试</div>
|
||||
<div class="summary-value">
|
||||
{{ profileStats.anyTestCount }}
|
||||
<span class="summary-sub">/ 本页 {{ users.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-section">
|
||||
<div class="search-section admin-filter-bar">
|
||||
<el-input
|
||||
v-model="searchTerm"
|
||||
placeholder="搜索昵称、手机号、地区、MBTI..."
|
||||
@@ -26,133 +67,93 @@
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="coldFaceFilter"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="冷脸分析"
|
||||
class="coldface-select"
|
||||
@change="() => { currentPage = 1; loadUsers() }"
|
||||
>
|
||||
<el-option label="暖" value="warm" />
|
||||
<el-option label="中" value="neutral" />
|
||||
<el-option label="冷" value="cold" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadUsers">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表表格 -->
|
||||
<div class="table-card">
|
||||
<el-table :data="users" style="width: 100%" v-loading="loading" class="user-table">
|
||||
<el-table-column label="用户信息" min-width="180">
|
||||
<el-table-column label="用户" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info-cell">
|
||||
<img
|
||||
v-if="displayAvatarUrl(row.avatar)"
|
||||
:src="displayAvatarUrl(row.avatar)"
|
||||
class="user-avatar user-avatar-img"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="user-avatar user-avatar-letter"
|
||||
:style="{ backgroundColor: avatarBgColor(row) }"
|
||||
>
|
||||
{{ avatarLetter(row) }}
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar">
|
||||
<img
|
||||
v-if="displayAvatarUrl(row.avatar)"
|
||||
:src="displayAvatarUrl(row.avatar)"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=""
|
||||
/>
|
||||
<span v-else :style="{ backgroundColor: avatarBgColor(row), width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }">
|
||||
{{ avatarLetter(row) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="username">{{ row.username || '未设置昵称' }}</div>
|
||||
<div class="openid-line">{{ row.openid || '—' }}</div>
|
||||
<div class="phone-line">{{ row.phone}}</div>
|
||||
<div class="user-meta">
|
||||
<div class="user-name">{{ row.username || '未设置昵称' }}</div>
|
||||
<div class="user-sub">
|
||||
<span
|
||||
v-if="row.coldFaceLevel"
|
||||
:class="['coldface-tag', 'coldface-' + row.coldFaceLevel]"
|
||||
:title="coldFaceTooltip(row)"
|
||||
>{{ coldFaceLabel(row.coldFaceLevel) }}<template v-if="row.coldFaceScore != null"> · {{ row.coldFaceScore }}</template></span>
|
||||
<span v-else class="empty-hint">未测面相</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
|
||||
|
||||
<el-table-column label="人脸分析" width="180" align="center">
|
||||
<el-table-column label="联系方式" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.faceMbtiType || row.faceDiscType || row.facePdpType">
|
||||
<el-tag
|
||||
v-if="row.faceMbtiType"
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="success"
|
||||
@click.stop="handleClickTestTag(row, 'face')"
|
||||
>
|
||||
{{ row.faceMbtiType }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.faceDiscType"
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="info"
|
||||
@click.stop="handleClickTestTag(row, 'face')"
|
||||
>
|
||||
{{ row.faceDiscType }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.facePdpType"
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="warning"
|
||||
@click.stop="handleClickTestTag(row, 'face')"
|
||||
>
|
||||
{{ row.facePdpType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<div class="contact-cell">
|
||||
<div class="phone">{{ row.phone || '—' }}</div>
|
||||
<div class="openid-line" :title="row.openid || ''">{{ row.openid ? (String(row.openid).length > 16 ? String(row.openid).slice(0,16) + '…' : row.openid) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="MBTI" width="120" align="center">
|
||||
<el-table-column label="测评标签" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.mbtiType">
|
||||
<el-tag size="small" class="result-tag" @click.stop="handleClickTestTag(row, 'mbti')">
|
||||
{{ row.mbtiType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<el-tag v-if="row.mbtiType" size="small" class="result-tag tag-mbti" @click.stop="handleClickTestTag(row, 'mbti')">MBTI · {{ row.mbtiType }}</el-tag>
|
||||
<el-tag v-if="row.discType" size="small" class="result-tag tag-disc" type="info" @click.stop="handleClickTestTag(row, 'disc')">DISC · {{ row.discType }}</el-tag>
|
||||
<el-tag v-if="row.pdpType" size="small" class="result-tag tag-pdp" type="warning" @click.stop="handleClickTestTag(row, 'pdp')">PDP · {{ row.pdpType }}</el-tag>
|
||||
<el-tag v-if="row.faceMbtiType" size="small" class="result-tag tag-face" type="success" @click.stop="handleClickTestTag(row, 'face')">面·{{ row.faceMbtiType }}</el-tag>
|
||||
<el-tag v-if="row.faceDiscType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">面·{{ row.faceDiscType }}</el-tag>
|
||||
<el-tag v-if="row.facePdpType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">面·{{ row.facePdpType }}</el-tag>
|
||||
<span v-if="!row.mbtiType && !row.discType && !row.pdpType && !row.faceMbtiType && !row.faceDiscType && !row.facePdpType" class="no-test">暂无测评</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="PDP" width="120" align="center">
|
||||
<el-table-column label="测评次数 · 最近活跃" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.pdpType">
|
||||
<el-tag
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="warning"
|
||||
@click.stop="handleClickTestTag(row, 'pdp')"
|
||||
>
|
||||
{{ row.pdpType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<div class="activity-cell">
|
||||
<div class="activity-row">测评 <strong>{{ row.testCount || 0 }}</strong> 次</div>
|
||||
<div class="activity-row sub">{{ row.lastTestAt ? formatDate(row.lastTestAt) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="DISC" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.discType">
|
||||
<el-tag
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="info"
|
||||
@click.stop="handleClickTestTag(row, 'disc')"
|
||||
>
|
||||
{{ row.discType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="注册时间" width="150">
|
||||
<el-table-column label="注册时间" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="time-cell">{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link @click="handleView(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
@@ -713,6 +714,7 @@ const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 10
|
||||
const searchTerm = ref('')
|
||||
const coldFaceFilter = ref<string[]>([])
|
||||
const showDetailDialog = ref(false)
|
||||
const detailUser = ref<Record<string, any> | null>(null)
|
||||
|
||||
@@ -729,6 +731,24 @@ const currentTestType = computed(() => (currentTest.value?.testType || '').toLow
|
||||
|
||||
const users = ref<any[]>([])
|
||||
|
||||
/** 页内画像汇总 */
|
||||
const profileStats = computed(() => {
|
||||
const list = users.value || []
|
||||
let faceCount = 0
|
||||
let mbtiCount = 0
|
||||
let anyTestCount = 0
|
||||
for (const u of list) {
|
||||
const hasFace = !!(u.faceMbtiType || u.faceDiscType || u.facePdpType)
|
||||
const hasMbti = !!u.mbtiType
|
||||
const hasDisc = !!u.discType
|
||||
const hasPdp = !!u.pdpType
|
||||
if (hasFace) faceCount++
|
||||
if (hasMbti) mbtiCount++
|
||||
if (hasFace || hasMbti || hasDisc || hasPdp) anyTestCount++
|
||||
}
|
||||
return { faceCount, mbtiCount, anyTestCount }
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -737,6 +757,9 @@ async function loadUsers() {
|
||||
pageSize,
|
||||
keyword: searchTerm.value
|
||||
}
|
||||
if (coldFaceFilter.value.length > 0) {
|
||||
params.coldFaceLevel = coldFaceFilter.value.join(',')
|
||||
}
|
||||
const res: any = await request.get('/admin/app-users', { params })
|
||||
const list = res.data?.list ?? res?.list ?? []
|
||||
users.value = list.map((row: any) => ({
|
||||
@@ -773,6 +796,20 @@ function avatarBgColor(row: { username?: string; nickname?: string }) {
|
||||
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]
|
||||
}
|
||||
|
||||
function coldFaceLabel(level: string | null | undefined) {
|
||||
if (level === 'cold') return '冷'
|
||||
if (level === 'warm') return '暖'
|
||||
if (level === 'neutral') return '中'
|
||||
return ''
|
||||
}
|
||||
|
||||
function coldFaceTooltip(row: any) {
|
||||
const parts: string[] = []
|
||||
if (row?.coldFaceScore != null) parts.push(`冷脸分:${row.coldFaceScore}`)
|
||||
if (row?.coldFaceUpdatedAt) parts.push(`更新于 ${formatDate(row.coldFaceUpdatedAt)}`)
|
||||
return parts.join(' · ') || '尚未完成面相分析'
|
||||
}
|
||||
|
||||
function formatPhone(phone: string) {
|
||||
if (!phone) return '-'
|
||||
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
|
||||
@@ -1169,11 +1206,85 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.ic-blue { background: #eff6ff; }
|
||||
&.ic-teal { background: #f0fdfa; }
|
||||
&.ic-indigo { background: #eef2ff; }
|
||||
&.ic-amber { background: #fffbeb; }
|
||||
}
|
||||
|
||||
.summary-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.summary-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.coldface-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
@@ -1294,6 +1405,7 @@ onMounted(() => {
|
||||
|
||||
.result-tag {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-test {
|
||||
@@ -1302,6 +1414,29 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.contact-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.phone { color: #0f172a; font-size: 13px; font-weight: 500; }
|
||||
.openid-line { color: #94a3b8; font-size: 11px; font-family: 'SF Mono', Menlo, monospace; }
|
||||
}
|
||||
|
||||
.activity-cell {
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
|
||||
strong {
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.activity-row.sub { color: #94a3b8; font-size: 12px; margin-top: 2px; }
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<template>
|
||||
<div class="commerce-hub">
|
||||
<div class="hub-header">
|
||||
<h2>订单和财务</h2>
|
||||
<p class="hub-subtitle">
|
||||
监督全平台订单与资金流水;全局定价影响各企业在管理后台侧的采购与结算。企业日常接单请在「管理后台」查看。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page commerce-hub">
|
||||
<SaPageHeader
|
||||
title="订单和财务"
|
||||
subtitle="监督全平台订单与资金流水;全局定价影响各企业在管理后台侧的采购与结算。企业日常接单请在「管理后台」查看。"
|
||||
/>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
|
||||
|
||||
<div class="hub-body">
|
||||
<Orders
|
||||
@@ -27,7 +14,6 @@
|
||||
orders-api-path="/superadmin/orders"
|
||||
/>
|
||||
<Finance v-if="activeTab === 'finance'" embedded />
|
||||
<!-- 与独立「全局定价」一致:含个人版价格 + 深度服务左右栏(个人/企业) -->
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded />
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,6 +25,8 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Orders from '@/views/admin/Orders.vue'
|
||||
import Finance from './Finance.vue'
|
||||
import Pricing from './Pricing.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['orders', 'finance', 'pricing'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
@@ -66,99 +54,21 @@ const applyRouteTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const onTabChange = (tab: TabId) => {
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'orders') {
|
||||
q.tab = tab
|
||||
}
|
||||
if (tab !== 'orders') q.tab = tab
|
||||
router.replace({ path: '/superadmin/commerce', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
watch(() => route.query.tab, () => applyRouteTab())
|
||||
onMounted(() => applyRouteTab())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.commerce-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -79,6 +79,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分销排行榜:TOP 分销员 + 邀请与分润 -->
|
||||
<div class="content-card top-agents-card">
|
||||
<div class="card-title">
|
||||
分销排行榜 · TOP {{ topAgents.length || 0 }}
|
||||
<span class="card-title-sub">按累计分润排序 · 近 {{ topAgentsDays }} 天</span>
|
||||
</div>
|
||||
<div class="filter-group top-days-group">
|
||||
<div
|
||||
v-for="d in [7, 30, 90]"
|
||||
:key="d"
|
||||
:class="['filter-item', { active: topAgentsDays === d }]"
|
||||
@click="changeTopAgentsDays(d)"
|
||||
>近 {{ d }} 天</div>
|
||||
</div>
|
||||
<el-table
|
||||
:data="topAgents"
|
||||
class="custom-table"
|
||||
v-loading="loadingTopAgents"
|
||||
empty-text="暂无分销记录"
|
||||
>
|
||||
<el-table-column label="名次" width="72">
|
||||
<template #default="{ $index }">
|
||||
<span
|
||||
class="rank-badge"
|
||||
:class="{ 'rank-badge--gold': $index === 0, 'rank-badge--silver': $index === 1, 'rank-badge--bronze': $index === 2 }"
|
||||
>{{ $index + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分销员" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="agent-cell">
|
||||
<el-avatar :src="row.avatar" :size="28" v-if="row.avatar" />
|
||||
<div>
|
||||
<div class="agent-name">{{ row.nickname || '未命名' }}</div>
|
||||
<div class="agent-meta">ID {{ row.userId }} · {{ row.phone || '无手机号' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分享次数" width="110" align="center" prop="shareCount" />
|
||||
<el-table-column label="邀请绑定" width="110" align="center" prop="inviteBound" />
|
||||
<el-table-column label="累计分润(元)" width="150" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong class="amount-positive">¥{{ (Number(row.totalCommissionFen || 0) / 100).toFixed(2) }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提现管理 -->
|
||||
@@ -331,6 +380,9 @@ const overview = reactive({
|
||||
|
||||
const withdrawals = ref<any[]>([])
|
||||
const commissions = ref<any[]>([])
|
||||
const topAgents = ref<any[]>([])
|
||||
const topAgentsDays = ref(30)
|
||||
const loadingTopAgents = ref(false)
|
||||
|
||||
const distEnabled = ref(true)
|
||||
const promoCenterTitle = ref('推广中心')
|
||||
@@ -394,6 +446,26 @@ const loadOverview = async () => {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── 分销 TOP 榜(依赖 /superadmin/analytics/share-stats)
|
||||
const loadTopAgents = async () => {
|
||||
loadingTopAgents.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/share-stats', {
|
||||
params: { days: topAgentsDays.value, page: 1, pageSize: 20 }
|
||||
})
|
||||
topAgents.value = res?.data?.list || []
|
||||
} catch (e) {
|
||||
topAgents.value = []
|
||||
} finally {
|
||||
loadingTopAgents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const changeTopAgentsDays = (d: number) => {
|
||||
topAgentsDays.value = d
|
||||
void loadTopAgents()
|
||||
}
|
||||
|
||||
// ── 提现列表
|
||||
const loadWithdrawals = async () => {
|
||||
loading.value = true
|
||||
@@ -508,7 +580,7 @@ const saveSettings = async () => {
|
||||
}
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'overview') loadOverview()
|
||||
if (t === 'overview') { loadOverview(); loadTopAgents() }
|
||||
else if (t === 'withdrawals') loadWithdrawals()
|
||||
else if (t === 'commissions') loadCommissions()
|
||||
else if (t === 'settings') loadSettings()
|
||||
@@ -518,13 +590,13 @@ watch(withFilter, () => { if (activeTab.value === 'withdrawals') loadWithdrawa
|
||||
watch(commFilter, () => { if (activeTab.value === 'commissions') loadCommissions() })
|
||||
|
||||
const refresh = async () => {
|
||||
if (activeTab.value === 'overview') await loadOverview()
|
||||
if (activeTab.value === 'overview') { await loadOverview(); await loadTopAgents() }
|
||||
else if (activeTab.value === 'withdrawals') await loadWithdrawals()
|
||||
else if (activeTab.value === 'commissions') await loadCommissions()
|
||||
ElMessage.success('数据已刷新')
|
||||
}
|
||||
|
||||
onMounted(() => { loadOverview() })
|
||||
onMounted(() => { loadOverview(); loadTopAgents() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -699,4 +771,69 @@ onMounted(() => { loadOverview() })
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 分销 TOP 榜卡 */
|
||||
.top-agents-card {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
.card-title-sub {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
.top-days-group {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
margin: 8px 0 12px;
|
||||
background: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
|
||||
.filter-item {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-item.active {
|
||||
background: #fff;
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rank-badge--gold { background: linear-gradient(135deg, #fde68a, #f59e0b); color: #78350f; }
|
||||
.rank-badge--silver { background: linear-gradient(135deg, #e5e7eb, #9ca3af); color: #1f2937; }
|
||||
.rank-badge--bronze { background: linear-gradient(135deg, #fecaca, #b45309); color: #fff; }
|
||||
|
||||
.agent-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.agent-name { font-weight: 600; color: #111827; font-size: 13px; }
|
||||
.agent-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||
}
|
||||
|
||||
.amount-positive { color: #d97706; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<template>
|
||||
<div class="distribution-hub">
|
||||
<div class="hub-header">
|
||||
<h2>分销管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级分销规则、提现与佣金;小程序埋点用于监督各端行为数据。企业管理员日常操作在「管理后台」侧完成。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page distribution-hub">
|
||||
<SaPageHeader
|
||||
title="分销管理"
|
||||
subtitle="平台级分销规则、提现与佣金;小程序埋点用于监督各端行为数据。企业管理员日常操作在「管理后台」侧完成。"
|
||||
/>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
|
||||
|
||||
<div class="hub-body">
|
||||
<Distribution v-if="activeTab === 'distribution'" embedded />
|
||||
@@ -32,6 +19,8 @@ import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Distribution from './Distribution.vue'
|
||||
import MpAnalytics from './MpAnalytics.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['distribution', 'analytics'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
@@ -58,103 +47,24 @@ const applyRouteTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const onTabChange = (tab: TabId) => {
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'distribution') {
|
||||
q.tab = tab
|
||||
}
|
||||
if (tab !== 'distribution') q.tab = tab
|
||||
router.replace({
|
||||
path: '/superadmin/distribution',
|
||||
query: Object.keys(q).length ? q : {}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
watch(() => route.query.tab, () => applyRouteTab())
|
||||
onMounted(() => applyRouteTab())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.distribution-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<template>
|
||||
<div class="enterprise-hub">
|
||||
<div class="hub-header">
|
||||
<h2>企业管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
维护企业档案与平台用户池,对应各企业管理员在「普通管理后台」可见的数据范围。个人侧无企业归属的测试用户在统计与列表中并入「存客宝」。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page enterprise-hub">
|
||||
<SaPageHeader
|
||||
title="企业管理"
|
||||
subtitle="维护企业档案与平台用户池,对应各企业管理员在「普通管理后台」可见的数据范围。个人侧无企业归属的测试用户在统计与列表中并入「存客宝」。"
|
||||
/>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
|
||||
|
||||
<el-alert
|
||||
v-if="activeTab === 'users'"
|
||||
@@ -39,6 +26,9 @@
|
||||
<div class="hub-body">
|
||||
<Enterprises v-if="activeTab === 'companies'" embedded />
|
||||
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
|
||||
<SoulArticles v-if="activeTab === 'soulArticles'" />
|
||||
<MpTabBar v-if="activeTab === 'mpTabbar'" />
|
||||
<ProfitRules v-if="activeTab === 'profitRules'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,8 +40,13 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import Enterprises from './Enterprises.vue'
|
||||
import Users from './Users.vue'
|
||||
import SoulArticles from './SoulArticles.vue'
|
||||
import MpTabBar from './MpTabBar.vue'
|
||||
import ProfitRules from './ProfitRules.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['companies', 'users'] as const
|
||||
const TAB_IDS = ['companies', 'users', 'soulArticles', 'mpTabbar', 'profitRules'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
@@ -101,7 +96,10 @@ async function runOrphanMigrate() {
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '企业列表', value: 'companies' },
|
||||
{ label: '用户总览', value: 'users' }
|
||||
{ label: '用户总览', value: 'users' },
|
||||
{ label: '引流文章', value: 'soulArticles' },
|
||||
{ label: '底部菜单', value: 'mpTabbar' },
|
||||
{ label: '分账规则', value: 'profitRules' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
@@ -113,108 +111,25 @@ const applyRouteTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const onTabChange = (tab: TabId) => {
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'companies') {
|
||||
q.tab = tab
|
||||
}
|
||||
if (tab !== 'companies') q.tab = tab
|
||||
router.replace({ path: '/superadmin/enterprises', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
watch(() => route.query.tab, () => applyRouteTab())
|
||||
onMounted(() => applyRouteTab())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.enterprise-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
.migrate-alert {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.migrate-text {
|
||||
margin: 0 0 10px;
|
||||
|
||||
@@ -35,21 +35,114 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>分享漏斗(结果页→分享→登录→付费→分润)</span>
|
||||
</template>
|
||||
<div class="funnel-wrap" v-loading="loadingFunnel">
|
||||
<VChart v-if="funnelData.length" class="funnel-chart" :option="funnelOption" autoresize />
|
||||
<div v-else class="empty-hint">暂无漏斗数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>事件汇总(共 {{ summaryTotal }} 条记录)</span>
|
||||
</template>
|
||||
<div class="filter-row">
|
||||
<el-select
|
||||
v-model="filterEvent"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="按事件筛选(中文名)"
|
||||
style="width: 320px"
|
||||
@change="onFilterChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in labelOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="summaryList"
|
||||
stripe
|
||||
:empty-text="tableMissing ? '表未创建,见上方说明' : '暂无数据(小程序有访问后会出现统计)'"
|
||||
>
|
||||
<el-table-column prop="eventName" label="事件名" min-width="200" />
|
||||
<el-table-column prop="eventNameCn" label="事件(中文)" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.eventNameCn || row.eventName }}</strong>
|
||||
<div class="props-preview">{{ row.eventName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cnt" label="次数" width="120" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>分享 & 邀请 · 用户榜 TOP {{ shareRows.length }}</span>
|
||||
</template>
|
||||
<el-table v-loading="loadingShare" :data="shareRows" stripe empty-text="暂无分享记录(需小程序分享或分销绑定后)">
|
||||
<el-table-column label="#" width="60">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<el-avatar :src="row.avatar" :size="28" v-if="row.avatar" />
|
||||
<div>
|
||||
<div style="font-weight:600;">{{ row.nickname || '未命名' }}</div>
|
||||
<div style="font-size:12px;color:#94a3b8;">ID {{ row.userId }} · {{ row.phone || '无手机号' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="shareCount" label="分享次数" width="110" align="center" />
|
||||
<el-table-column prop="inviteBound" label="邀请人数" width="110" align="center" />
|
||||
<el-table-column label="累计分润(元)" width="130" align="center">
|
||||
<template #default="{ row }">¥{{ (Number(row.totalCommissionFen || 0) / 100).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openUserJourney(row.userId, row.nickname)">用户旅程</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="sharePage"
|
||||
v-model:page-size="sharePageSize"
|
||||
:total="shareTotal"
|
||||
:page-sizes="[20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadShareStats"
|
||||
@size-change="loadShareStats"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="journeyVisible" :title="journeyTitle" width="720px">
|
||||
<el-table :data="journeyRows" v-loading="journeyLoading" stripe empty-text="暂无记录" max-height="520">
|
||||
<el-table-column prop="createdAt" label="时间" width="170" />
|
||||
<el-table-column label="事件" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.eventNameCn || row.eventName }}</strong>
|
||||
<div class="props-preview">{{ row.eventName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pagePath" label="页面" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="附加" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="props-preview">{{ formatProps(row.props) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>最近明细</span>
|
||||
@@ -57,7 +150,12 @@
|
||||
<el-table v-loading="loadingEvents" :data="eventRows" stripe empty-text="暂无明细">
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="createdAt" label="时间" width="170" />
|
||||
<el-table-column prop="eventName" label="事件" min-width="140" />
|
||||
<el-table-column label="事件" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.eventNameCn || row.eventName }}</strong>
|
||||
<div class="props-preview">{{ row.eventName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pagePath" label="页面" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column label="附加" min-width="200">
|
||||
@@ -82,8 +180,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { FunnelChart } from 'echarts/charts'
|
||||
import { TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, FunnelChart, TooltipComponent, LegendComponent, TitleComponent])
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
@@ -91,12 +196,66 @@ const days = ref(7)
|
||||
const tableMissing = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadingEvents = ref(false)
|
||||
const summaryList = ref<{ eventName: string; cnt: number }[]>([])
|
||||
const summaryList = ref<{ eventName: string; eventNameCn?: string; cnt: number }[]>([])
|
||||
const summaryTotal = ref(0)
|
||||
const eventRows = ref<any[]>([])
|
||||
const eventTotal = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const filterEvent = ref('')
|
||||
const labelOptions = ref<{ value: string; label: string }[]>([])
|
||||
|
||||
const loadingShare = ref(false)
|
||||
const shareRows = ref<any[]>([])
|
||||
const shareTotal = ref(0)
|
||||
const sharePage = ref(1)
|
||||
const sharePageSize = ref(30)
|
||||
|
||||
const journeyVisible = ref(false)
|
||||
const journeyLoading = ref(false)
|
||||
const journeyRows = ref<any[]>([])
|
||||
const journeyTitle = ref('')
|
||||
|
||||
const loadingFunnel = ref(false)
|
||||
const funnelData = ref<{ stage: string; value: number }[]>([])
|
||||
|
||||
const funnelOption = computed(() => {
|
||||
const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f97316', '#14b8a6']
|
||||
return {
|
||||
tooltip: { trigger: 'item' as const },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
name: '分享漏斗',
|
||||
type: 'funnel',
|
||||
left: '6%',
|
||||
right: '6%',
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
width: '88%',
|
||||
sort: 'none',
|
||||
gap: 4,
|
||||
labelLine: { length: 18 },
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
formatter: (p: any) => `${p.name}\n${p.value}`
|
||||
},
|
||||
data: funnelData.value.map((item, i) => ({
|
||||
name: item.stage,
|
||||
value: Number(item.value || 0),
|
||||
itemStyle: { color: palette[i % palette.length] }
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
function formatProps(p: unknown): string {
|
||||
if (p == null) return '—'
|
||||
@@ -111,12 +270,20 @@ function formatProps(p: unknown): string {
|
||||
async function loadSummary() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/summary', {
|
||||
params: { days: days.value }
|
||||
})
|
||||
const params: Record<string, string | number> = { days: days.value }
|
||||
if (filterEvent.value) params.eventName = filterEvent.value
|
||||
const res: any = await request.get('/superadmin/analytics/summary', { params })
|
||||
summaryList.value = res.data?.list || []
|
||||
summaryTotal.value = res.data?.total ?? 0
|
||||
tableMissing.value = !!res.data?.tableMissing
|
||||
|
||||
const labels = res.data?.labels
|
||||
if (labels && typeof labels === 'object') {
|
||||
labelOptions.value = Object.entries(labels).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: `${v as string}(${k})`
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
summaryList.value = []
|
||||
summaryTotal.value = 0
|
||||
@@ -129,13 +296,13 @@ async function loadSummary() {
|
||||
async function loadEvents() {
|
||||
loadingEvents.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/events', {
|
||||
params: {
|
||||
days: days.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
})
|
||||
const params: Record<string, string | number> = {
|
||||
days: days.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
if (filterEvent.value) params.eventName = filterEvent.value
|
||||
const res: any = await request.get('/superadmin/analytics/events', { params })
|
||||
eventRows.value = res.data?.list || []
|
||||
eventTotal.value = res.data?.total ?? 0
|
||||
if (res.data?.tableMissing) {
|
||||
@@ -150,10 +317,64 @@ async function loadEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShareStats() {
|
||||
loadingShare.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/share-stats', {
|
||||
params: { days: days.value, page: sharePage.value, pageSize: sharePageSize.value }
|
||||
})
|
||||
shareRows.value = res.data?.list || []
|
||||
shareTotal.value = res.data?.total ?? 0
|
||||
} catch {
|
||||
shareRows.value = []
|
||||
shareTotal.value = 0
|
||||
} finally {
|
||||
loadingShare.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openUserJourney(userId: number, nickname: string) {
|
||||
journeyTitle.value = `用户旅程 · ${nickname || '未命名'}(ID ${userId})`
|
||||
journeyVisible.value = true
|
||||
journeyLoading.value = true
|
||||
journeyRows.value = []
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/user-journey', {
|
||||
params: { userId, days: days.value }
|
||||
})
|
||||
journeyRows.value = res.data?.list || []
|
||||
} finally {
|
||||
journeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShareFunnel() {
|
||||
loadingFunnel.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/share-funnel', {
|
||||
params: { days: days.value }
|
||||
})
|
||||
funnelData.value = res.data?.funnel || []
|
||||
} catch {
|
||||
funnelData.value = []
|
||||
} finally {
|
||||
loadingFunnel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
page.value = 1
|
||||
sharePage.value = 1
|
||||
await loadSummary()
|
||||
await loadEvents()
|
||||
await loadShareStats()
|
||||
await loadShareFunnel()
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
void loadSummary()
|
||||
void loadEvents()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -165,6 +386,31 @@ onMounted(() => {
|
||||
.setup-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.props-preview {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.funnel-wrap {
|
||||
width: 100%;
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.funnel-chart {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
.empty-hint {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.mp-analytics {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
|
||||
351
admin/src/views/superadmin/MpTabBar.vue
Normal file
351
admin/src/views/superadmin/MpTabBar.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>小程序底部菜单</h2>
|
||||
<p class="subtitle">
|
||||
拖拽排序、开关显隐、修改文案/图标,保存后小程序 60 秒内自动生效。
|
||||
至少保留 2 个可见 Tab,最多 5 个。
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="fetchList">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
<el-button type="primary" @click="onSave" :loading="saving">
|
||||
<el-icon><Check /></el-icon>保存
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Tab 列表(拖拽行首"☰"排序)</span>
|
||||
<el-button size="small" @click="onAdd" :disabled="list.length >= 5">
|
||||
<el-icon><Plus /></el-icon>新增 Tab
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" row-key="tempId" border stripe>
|
||||
<el-table-column width="60" align="center" label="排序">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="drag-handle">
|
||||
<el-icon><Operation /></el-icon>
|
||||
<span style="margin-left: 4px">{{ $index + 1 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="SVG图标" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.iconKey" size="small" style="width: 100%">
|
||||
<el-option label="首页" value="home" />
|
||||
<el-option label="拍摄" value="camera" />
|
||||
<el-option label="AI" value="ai" />
|
||||
<el-option label="我" value="profile" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="图片URL(优先)" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.iconUrl"
|
||||
size="small"
|
||||
placeholder="/images/xxx.png 或 https://..."
|
||||
clearable
|
||||
>
|
||||
<template #append v-if="row.iconUrl">
|
||||
<img :src="row.iconUrl" style="width: 22px; height: 22px; border-radius: 4px;" />
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="文案" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.text" size="small" maxlength="6" show-word-limit />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="页面路径" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.pagePath" size="small" placeholder="pages/xxx/xxx">
|
||||
<template #prepend>/</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="中间浮钮" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.highlight" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="显示" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.visible" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button size="small" link @click="onMove($index, -1)" :disabled="$index === 0">↑</el-button>
|
||||
<el-button size="small" link @click="onMove($index, 1)" :disabled="$index === list.length - 1">↓</el-button>
|
||||
<el-button size="small" type="danger" link @click="onRemove($index)" :disabled="list.length <= 2">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="preview">
|
||||
<div class="preview-title">📱 小程序底部预览</div>
|
||||
<div class="mp-bar">
|
||||
<template v-for="(it, idx) in visibleItems" :key="idx">
|
||||
<div v-if="it.highlight" class="mp-item mp-item--fab">
|
||||
<div class="mp-fab" :class="'mp-icon--' + (it.iconKey || 'home')"></div>
|
||||
<div class="mp-text">{{ it.text }}</div>
|
||||
</div>
|
||||
<div v-else class="mp-item">
|
||||
<div class="mp-icon" :class="'mp-icon--' + (it.iconKey || 'home')"></div>
|
||||
<div class="mp-text">{{ it.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="preview-hint">仅为示意,实际样式以小程序端为准。</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Check, Plus, Operation } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
interface TabItem {
|
||||
id?: number
|
||||
tempId?: string
|
||||
sortOrder: number
|
||||
pagePath: string
|
||||
text: string
|
||||
iconKey: string
|
||||
iconUrl?: string | null
|
||||
visible: number
|
||||
highlight: number
|
||||
badgeKey?: string | null
|
||||
}
|
||||
|
||||
const list = ref<TabItem[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
const visibleItems = computed(() => list.value.filter(x => x.visible === 1))
|
||||
|
||||
const tmpId = () => 't' + Date.now() + '-' + Math.floor(Math.random() * 1000)
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/tabbar')
|
||||
if (res && res.code === 200) {
|
||||
const raw = (res.data && res.data.list) || []
|
||||
list.value = raw.map((r: any) => ({
|
||||
id: r.id,
|
||||
tempId: 'db-' + r.id,
|
||||
sortOrder: r.sortOrder,
|
||||
pagePath: r.pagePath,
|
||||
text: r.text,
|
||||
iconKey: r.iconKey || 'home',
|
||||
iconUrl: r.iconUrl || '',
|
||||
visible: Number(r.visible ?? 1),
|
||||
highlight: Number(r.highlight ?? 0),
|
||||
badgeKey: r.badgeKey || null,
|
||||
}))
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onAdd = () => {
|
||||
if (list.value.length >= 5) return
|
||||
list.value.push({
|
||||
tempId: tmpId(),
|
||||
sortOrder: (list.value.length + 1) * 10,
|
||||
pagePath: 'pages/',
|
||||
text: '新菜单',
|
||||
iconKey: 'home',
|
||||
iconUrl: '',
|
||||
visible: 1,
|
||||
highlight: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const onRemove = async (idx: number) => {
|
||||
const it = list.value[idx]
|
||||
const r = await ElMessageBox.confirm(`删除「${it.text}」?`, '确认', { type: 'warning' }).catch(() => 'cancel')
|
||||
if (r === 'cancel') return
|
||||
list.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
const onMove = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= list.value.length) return
|
||||
const arr = list.value.slice()
|
||||
const [moved] = arr.splice(idx, 1)
|
||||
arr.splice(target, 0, moved)
|
||||
list.value = arr
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (list.value.length < 2) return ElMessage.warning('至少 2 个 Tab')
|
||||
if (list.value.length > 5) return ElMessage.warning('最多 5 个 Tab')
|
||||
const v = list.value.filter(x => x.visible === 1).length
|
||||
if (v < 2) return ElMessage.warning('至少 2 个可见 Tab')
|
||||
|
||||
for (const [idx, it] of list.value.entries()) {
|
||||
if (!it.text || !it.text.trim()) return ElMessage.warning(`第 ${idx + 1} 行文案不能为空`)
|
||||
if (!it.pagePath || !it.pagePath.trim()) return ElMessage.warning(`第 ${idx + 1} 行页面路径不能为空`)
|
||||
it.sortOrder = (idx + 1) * 10
|
||||
it.pagePath = it.pagePath.replace(/^\//, '')
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/tabbar/save', {
|
||||
items: list.value.map(it => ({
|
||||
id: it.id,
|
||||
sortOrder: it.sortOrder,
|
||||
pagePath: it.pagePath,
|
||||
text: it.text,
|
||||
iconKey: it.iconKey,
|
||||
iconUrl: it.iconUrl,
|
||||
visible: it.visible,
|
||||
highlight: it.highlight,
|
||||
badgeKey: it.badgeKey,
|
||||
}))
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success(res.message || '保存成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-left h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 22px;
|
||||
color: #1F1B4D;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6B6894;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 720px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-top: 24px;
|
||||
border: 1px dashed #E5E7EB;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #FAFAFA;
|
||||
}
|
||||
.preview-title {
|
||||
font-size: 13px;
|
||||
color: #6B6894;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mp-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.mp-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.mp-item--fab {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.mp-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-size: 22px 22px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.mp-fab {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
box-shadow: 0 4px 12px rgba(124,58,237,0.4);
|
||||
background-size: 24px 24px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mp-text {
|
||||
font-size: 11px;
|
||||
color: #6B6894;
|
||||
}
|
||||
.mp-icon--home { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><path d='M3 12l9-9 9 9M5 10v10h14V10'/></svg>"); }
|
||||
.mp-icon--camera { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>"); }
|
||||
.mp-icon--ai { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linecap='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3'/><circle cx='12' cy='12' r='3'/></svg>"); }
|
||||
.mp-icon--profile{ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><circle cx='12' cy='8' r='4'/><path d='M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8'/></svg>"); }
|
||||
.mp-fab.mp-icon--camera,
|
||||
.mp-fab.mp-icon--ai,
|
||||
.mp-fab.mp-icon--home,
|
||||
.mp-fab.mp-icon--profile {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>"),
|
||||
linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
}
|
||||
.preview-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div class="ops-hub">
|
||||
<div class="hub-header">
|
||||
<h2>总览</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级数据总览,用于对照各企业在「普通管理后台」的经营情况。分销与埋点在侧栏「分销管理」;订单与定价在「订单和财务」;模型与算力在「智能算力」。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page ops-hub">
|
||||
<SaPageHeader
|
||||
title="平台总览"
|
||||
subtitle="平台级数据对照各企业在「普通管理后台」的经营情况。分销与埋点在侧栏「分销管理」;订单与定价在「订单和财务」;模型与算力在「智能算力」。"
|
||||
badge="SuperAdmin"
|
||||
/>
|
||||
<div class="hub-body">
|
||||
<Overview />
|
||||
</div>
|
||||
@@ -14,33 +13,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Overview from './Overview.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ops-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -655,18 +655,18 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.purple {
|
||||
background-color: #f3e8ff;
|
||||
color: #a855f7;
|
||||
background-color: #E0E7FF;
|
||||
color: #1E40AF;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background-color: #dcfce7;
|
||||
color: #22c55e;
|
||||
background-color: #D1FAE5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background-color: #fed7aa;
|
||||
color: #f97316;
|
||||
background-color: #FEF3C7;
|
||||
color: #D97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,23 +719,23 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
&.blue .action-icon {
|
||||
background-color: #3b82f6;
|
||||
background-image: linear-gradient(135deg, #1E40AF 0%, #3730A3 100%);
|
||||
}
|
||||
|
||||
&.green .action-icon {
|
||||
background-color: #22c55e;
|
||||
background-image: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||||
}
|
||||
|
||||
&.orange .action-icon {
|
||||
background-color: #f97316;
|
||||
background-image: linear-gradient(135deg, #D97706 0%, #B45309 100%);
|
||||
}
|
||||
|
||||
&.purple .action-icon {
|
||||
background-color: #a855f7;
|
||||
background-image: linear-gradient(135deg, #4338CA 0%, #312E81 100%);
|
||||
}
|
||||
|
||||
&.gray .action-icon {
|
||||
background-color: #6b7280;
|
||||
background-color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,7 +848,7 @@ onMounted(async () => {
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
color: #1E40AF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
199
admin/src/views/superadmin/ProfitRules.vue
Normal file
199
admin/src/views/superadmin/ProfitRules.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>分账规则</h2>
|
||||
<p class="subtitle">按产品类型配置分账接收人与比例,支付成功后自动执行。比例合计须等于 100%。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openEdit()">
|
||||
<el-icon><Plus /></el-icon>新增规则
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="productType" label="产品类型" width="200" />
|
||||
<el-table-column prop="name" label="名称" width="240" />
|
||||
<el-table-column label="分账明细" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-for="(r, i) in row.receivers" :key="i" class="receiver-line">
|
||||
<el-tag size="small">{{ labelOf(r.type) }}</el-tag>
|
||||
<span style="margin-left: 6px;">{{ r.name }}</span>
|
||||
<span class="ratio">{{ (Number(r.ratio) * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
|
||||
{{ row.status === 'active' ? '生效中' : '已禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" link @click="onToggle(row)">
|
||||
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showDialog" :title="form.id ? '编辑规则' : '新增规则'" width="680px">
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item label="产品类型">
|
||||
<el-input v-model="form.productType" :disabled="!!form.id" placeholder="如 ai_deep_report" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" placeholder="如 AI 深度画像报告" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" active-value="active" inactive-value="disabled" active-text="生效" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分账接收人">
|
||||
<div>
|
||||
<div v-for="(r, i) in form.receivers" :key="i" class="receiver-row">
|
||||
<el-select v-model="r.type" style="width: 160px">
|
||||
<el-option label="平台" value="platform" />
|
||||
<el-option label="一级分销" value="distributor_l1" />
|
||||
<el-option label="二级分销" value="distributor_l2" />
|
||||
<el-option label="顾问" value="consultant" />
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
<el-input v-model="r.name" placeholder="显示名" style="width: 180px; margin-left: 8px" />
|
||||
<el-input-number v-model="r.ratio" :min="0" :max="1" :step="0.05" :precision="3" style="margin-left: 8px" />
|
||||
<span style="margin-left: 8px">比例(0~1)</span>
|
||||
<el-button link type="danger" @click="form.receivers.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<div style="margin-top: 10px">
|
||||
<el-button size="small" @click="form.receivers.push({ type: 'platform', name: '平台', ratio: 0 })">
|
||||
<el-icon><Plus /></el-icon>添加接收人
|
||||
</el-button>
|
||||
<span class="sum-tip" :class="{ err: !sumOk }">
|
||||
当前合计:{{ sumPercent.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!sumOk" @click="onSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
interface Receiver { type: string; name: string; ratio: number }
|
||||
interface Rule {
|
||||
id?: number
|
||||
productType: string
|
||||
name: string
|
||||
receivers: Receiver[]
|
||||
status: 'active' | 'disabled'
|
||||
}
|
||||
|
||||
const list = ref<Rule[]>([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const emptyForm = (): Rule => ({
|
||||
productType: '', name: '', receivers: [{ type: 'platform', name: '平台', ratio: 1 }], status: 'active'
|
||||
})
|
||||
const form = ref<Rule>(emptyForm())
|
||||
|
||||
const sumPercent = computed(() => {
|
||||
return form.value.receivers.reduce((s, r) => s + (Number(r.ratio) || 0), 0) * 100
|
||||
})
|
||||
const sumOk = computed(() => Math.abs(sumPercent.value - 100) < 0.5)
|
||||
|
||||
const labelOf = (t: string) => ({
|
||||
platform: '平台',
|
||||
distributor_l1: '一级分销',
|
||||
distributor_l2: '二级分销',
|
||||
consultant: '顾问',
|
||||
custom: '自定义',
|
||||
}[t] || t)
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/profit-rules')
|
||||
if (res && res.code === 200) {
|
||||
list.value = (res.data?.list || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
productType: r.productType,
|
||||
name: r.name,
|
||||
receivers: Array.isArray(r.receivers) ? r.receivers : [],
|
||||
status: r.status
|
||||
}))
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (row?: Rule) => {
|
||||
if (row) {
|
||||
form.value = JSON.parse(JSON.stringify(row))
|
||||
} else {
|
||||
form.value = emptyForm()
|
||||
}
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (!form.value.productType || !form.value.name) return ElMessage.warning('请填写产品类型与名称')
|
||||
if (!sumOk.value) return ElMessage.warning('分账比例合计必须 = 100%')
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/profit-rules/save', form.value)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('已保存')
|
||||
showDialog.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.message || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onToggle = async (row: Rule) => {
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/profit-rules/${row.id}/toggle`)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('已切换')
|
||||
fetchList()
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
|
||||
.page-header h2 { margin: 0 0 6px; color: #1F1B4D; }
|
||||
.subtitle { color: #6B6894; font-size: 13px; max-width: 800px; }
|
||||
.receiver-line { display: flex; align-items: center; margin-bottom: 4px; }
|
||||
.ratio { margin-left: auto; color: #7c3aed; font-weight: 600; }
|
||||
.receiver-row { display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.sum-tip { margin-left: 16px; color: #059669; font-weight: 600; }
|
||||
.sum-tip.err { color: #EF4444; }
|
||||
</style>
|
||||
740
admin/src/views/superadmin/SoulArticles.vue
Normal file
740
admin/src/views/superadmin/SoulArticles.vue
Normal file
@@ -0,0 +1,740 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>Soul 引流文章</h2>
|
||||
<p class="subtitle">
|
||||
采集由服务端<strong>仅通过 HTTPS</strong>请求「一场 soul 创业实验」开放接口,<strong>不使用 SSH</strong>。
|
||||
「当前推荐」至多 3 篇;需在下方开启<strong>神仙 AI 页</strong>或<strong>我的页推荐条</strong>后,小程序对应位置才会展示;排序第 1 篇用于「我的」底部灰字链接。
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索一场创业实验内容关键词"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="onSearchImport"
|
||||
/>
|
||||
<el-button @click="onSearchImport" :loading="searching">
|
||||
搜索并添加
|
||||
</el-button>
|
||||
<el-button type="primary" @click="onSync" :loading="syncing">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
采集最新 10 篇
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 神仙 AI 健康小条 -->
|
||||
<el-card v-if="health.loaded" class="health-card" shadow="never" :class="{ 'health-card--warn': health.hasAlert }">
|
||||
<div class="health-bar">
|
||||
<div class="health-title">
|
||||
<span class="dot" :class="health.hasAlert ? 'dot-warn' : 'dot-ok'"></span>
|
||||
神仙 AI 服务状态
|
||||
<el-tag v-if="health.hasAlert" type="danger" size="small">有告警</el-tag>
|
||||
<el-tag v-else type="success" size="small">正常</el-tag>
|
||||
</div>
|
||||
<div class="health-providers">
|
||||
<span v-for="p in health.providers" :key="p.providerId" class="provider-chip" :class="{ 'chip-warn': p.status === 'low-balance' || p.status === 'no-key' || p.status === 'disabled' }">
|
||||
{{ p.name }} · {{ p.currency === 'USD' ? '$' : '¥' }}{{ p.balance == null ? '—' : Number(p.balance).toFixed(2) }}
|
||||
<template v-if="p.status === 'low-balance'">(欠费)</template>
|
||||
<template v-else-if="p.status === 'disabled'">(已停用)</template>
|
||||
<template v-else-if="p.status === 'no-key'">(缺 key)</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-actions">
|
||||
<el-button size="small" :loading="checking" @click="onBalanceCheck">立即扫描余额</el-button>
|
||||
<el-button size="small" type="primary" plain @click="fetchHealth">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="health.lastAlert" class="health-alert">
|
||||
最近告警:{{ health.lastAlert.providerId }} · 余额 {{ health.lastAlert.balance }} / 阈值 {{ health.lastAlert.threshold }}({{ health.lastAlert.dateStr }})
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 小程序 · 推荐文章展示(神仙 AI / 我的) -->
|
||||
<el-card class="ai-chat-display-card" shadow="never">
|
||||
<template #header>
|
||||
<span>小程序 · 推荐文章展示</span>
|
||||
</template>
|
||||
<el-form label-width="160px" class="ai-chat-display-form">
|
||||
<el-form-item label="神仙 AI 页展示">
|
||||
<el-switch v-model="aiChatDisplay.enabled" />
|
||||
<span class="form-hint-inline">开启后神仙 AI 页显示「精选推荐」列表</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="aiChatDisplay.enabled" label="最多展示条数">
|
||||
<el-radio-group v-model="aiChatDisplay.maxShow">
|
||||
<el-radio :label="1">1 篇</el-radio>
|
||||
<el-radio :label="2">2 篇</el-radio>
|
||||
<el-radio :label="3">3 篇</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="aiChatDisplay.enabled" label="默认展开列表">
|
||||
<el-switch v-model="aiChatDisplay.sectionExpandedDefault" />
|
||||
<span class="form-hint-inline">关闭则默认收起,用户点击「精选推荐」后展开</span>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">我的 · 底部推荐条</el-divider>
|
||||
<el-form-item label="我的页展示首条">
|
||||
<el-switch v-model="aiChatDisplay.profileRecoEnabled" />
|
||||
<span class="form-hint-inline">在「我的」快捷入口下方展示<strong>当前推荐排序第 1 篇</strong>(灰色字可点)</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="aiChatDisplay.profileRecoEnabled" label="区块标题 / 标签">
|
||||
<el-input
|
||||
v-model="aiChatDisplay.profileSectionLabel"
|
||||
maxlength="32"
|
||||
show-word-limit
|
||||
placeholder="如:一场创业实验、精选阅读"
|
||||
style="max-width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="savingAcDisplay" @click="saveAiChatDisplay">
|
||||
保存展示设置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 推荐位概览 -->
|
||||
<el-card class="reco-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>当前推荐(至多 3 篇 · 需在上方开启「小程序展示」后按条数展示)</span>
|
||||
<div class="reco-head-actions">
|
||||
<span class="reco-count">{{ recommended.length }} / 3</span>
|
||||
<el-button size="small" @click="onNormalizeOrder">一键归一排序</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="recommended.length === 0" description="还没有推荐文章,去下方列表勾选一篇吧" />
|
||||
<div v-else class="reco-list">
|
||||
<div v-for="(a, idx) in recommended" :key="a.id" class="reco-item">
|
||||
<div class="reco-rank">{{ idx + 1 }}</div>
|
||||
<img v-if="a.cover" :src="a.cover" class="reco-cover" />
|
||||
<div v-else class="reco-cover reco-cover--placeholder">MBTI</div>
|
||||
<div class="reco-body">
|
||||
<div class="reco-title">{{ a.title }}</div>
|
||||
<div class="reco-meta">
|
||||
<el-tag size="small" type="primary">{{ a.tag || 'MBTI' }}</el-tag>
|
||||
<span class="meta-text">{{ formatDate(a.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button size="small" @click="onToggleRecommend(a)">取消推荐</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 候选文章列表 -->
|
||||
<el-card class="list-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header card-header-col">
|
||||
<span>候选文章池</span>
|
||||
<div class="filter-row">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="按标题关键词筛选"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="tagFilter"
|
||||
placeholder="标签(如 MBTI)"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 320px"
|
||||
/>
|
||||
<el-radio-group v-model="filter" size="small" @change="fetchList">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="0">未推荐</el-radio-button>
|
||||
<el-radio-button label="1">已推荐</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" plain @click="onSearch">筛选</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column label="封面" width="100">
|
||||
<template #default="{ row }">
|
||||
<img v-if="row.cover" :src="row.cover" style="width: 80px; height: 50px; object-fit: cover; border-radius: 6px;" />
|
||||
<div v-else style="width: 80px; height: 50px; background: #a78bfa; color: white; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px;">MBTI</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="tag" label="标签" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.tag || 'MBTI' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="author" label="作者" width="120" />
|
||||
<el-table-column label="发布时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.publishedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="阅读" width="80">
|
||||
<template #default="{ row }">{{ row.viewCount || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isRecommended" type="success" size="small">推荐中</el-tag>
|
||||
<el-tag v-else type="info" size="small">未推荐</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="置顶权重" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isRecommended" class="order-cell">
|
||||
<el-input-number
|
||||
v-model="row.recommendedOrder"
|
||||
:min="0"
|
||||
:max="999"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 72px"
|
||||
/>
|
||||
<el-button size="small" type="primary" link @click="onUpdateOrder(row)">保存</el-button>
|
||||
</div>
|
||||
<span v-else class="order-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="preview(row)">预览</el-button>
|
||||
<el-button size="small" :type="row.isRecommended ? 'warning' : 'success'" @click="onToggleRecommend(row)">
|
||||
{{ row.isRecommended ? '取消' : '推荐' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchList"
|
||||
@size-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const searching = ref(false)
|
||||
const checking = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filter = ref<string>('')
|
||||
const searchKeyword = ref('')
|
||||
const keyword = ref('')
|
||||
const tagFilter = ref('')
|
||||
const dateRange = ref<string[]>([])
|
||||
const health = ref<any>({ loaded: false, providers: [], lastAlert: null, hasAlert: false })
|
||||
|
||||
const aiChatDisplay = ref({
|
||||
enabled: false,
|
||||
maxShow: 1,
|
||||
sectionExpandedDefault: false,
|
||||
profileRecoEnabled: false,
|
||||
profileSectionLabel: '推荐阅读'
|
||||
})
|
||||
const savingAcDisplay = ref(false)
|
||||
|
||||
const recommended = computed(() => list.value.filter(x => x.isRecommended))
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(ts * 1000)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
const pad = (n: number) => n < 10 ? '0' + n : '' + n
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/soul-articles', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
...(filter.value !== '' ? { isRecommended: filter.value } : {}),
|
||||
...(keyword.value ? { keyword: keyword.value } : {}),
|
||||
...(tagFilter.value ? { tag: tagFilter.value } : {}),
|
||||
...(dateRange.value && dateRange.value.length === 2 ? { dateRange: dateRange.value } : {})
|
||||
}
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
list.value = (res.data && res.data.list) || []
|
||||
total.value = (res.data && res.data.total) || 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
keyword.value = ''
|
||||
tagFilter.value = ''
|
||||
dateRange.value = []
|
||||
filter.value = ''
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const onSync = async () => {
|
||||
syncing.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/sync', { limit: 10, tag: 'MBTI' })
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`采集完成:新增 ${d.created || 0} 篇,更新 ${d.updated || 0} 篇`)
|
||||
page.value = 1
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '采集失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '采集失败,请稍后再试')
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSearchImport = async () => {
|
||||
const keyword = (searchKeyword.value || '').trim()
|
||||
if (!keyword) {
|
||||
ElMessage.warning('请先输入关键词')
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/sync', {
|
||||
limit: 10,
|
||||
tag: 'MBTI',
|
||||
keyword
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`已搜索并添加:新增 ${d.created || 0} 篇,更新 ${d.updated || 0} 篇`)
|
||||
page.value = 1
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '搜索添加失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '搜索添加失败')
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleRecommend = async (row: any) => {
|
||||
const current = list.value.filter(x => x.isRecommended).length
|
||||
if (!row.isRecommended && current >= 3) {
|
||||
const r = await ElMessageBox.confirm(
|
||||
'当前已有 3 篇推荐,继续推荐将自动顶掉最老的一篇,确认继续?',
|
||||
'推荐已满',
|
||||
{ type: 'warning' }
|
||||
).catch(() => 'cancel')
|
||||
if (r === 'cancel') return
|
||||
}
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/recommend`)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success(res.message || '操作成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (row: any) => {
|
||||
const r = await ElMessageBox.confirm(`确认删除「${row.title}」?`, '确认', { type: 'warning' }).catch(() => 'cancel')
|
||||
if (r === 'cancel') return
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/delete`)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('已删除')
|
||||
fetchList()
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateOrder = async (row: any) => {
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/order`, {
|
||||
recommendedOrder: Number(row.recommendedOrder || 0),
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('置顶权重已更新')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '更新失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onNormalizeOrder = async () => {
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/reorder-normalize')
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('推荐权重已归一')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '归一失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '归一失败')
|
||||
}
|
||||
}
|
||||
|
||||
const preview = (row: any) => {
|
||||
if (!row.url) {
|
||||
ElMessage.warning('该文章无 URL')
|
||||
return
|
||||
}
|
||||
window.open(row.url, '_blank')
|
||||
}
|
||||
|
||||
const fetchAiChatDisplay = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/soul-articles/ai-chat-display')
|
||||
if (res && res.code === 200 && res.data) {
|
||||
const d = res.data
|
||||
aiChatDisplay.value = {
|
||||
enabled: !!d.enabled,
|
||||
maxShow: Math.max(1, Math.min(3, Number(d.maxShow) || 1)),
|
||||
sectionExpandedDefault: !!d.sectionExpandedDefault,
|
||||
profileRecoEnabled: !!d.profileRecoEnabled,
|
||||
profileSectionLabel: String(d.profileSectionLabel || '推荐阅读').slice(0, 32)
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const saveAiChatDisplay = async () => {
|
||||
savingAcDisplay.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/ai-chat-display', {
|
||||
enabled: aiChatDisplay.value.enabled,
|
||||
maxShow: aiChatDisplay.value.maxShow,
|
||||
sectionExpandedDefault: aiChatDisplay.value.sectionExpandedDefault,
|
||||
profileRecoEnabled: aiChatDisplay.value.profileRecoEnabled,
|
||||
profileSectionLabel: aiChatDisplay.value.profileSectionLabel
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('展示设置已保存')
|
||||
await fetchAiChatDisplay()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingAcDisplay.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchHealth = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/ai/health')
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
const providers = Array.isArray(d.providers) ? d.providers : []
|
||||
const hasAlert = providers.some((p: any) => p.status === 'low-balance' || p.status === 'no-key') || !!d.lastAlert
|
||||
health.value = { loaded: true, providers, lastAlert: d.lastAlert || null, hasAlert }
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const onBalanceCheck = async () => {
|
||||
checking.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/ai/balance-check')
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`扫描完成:推送 ${d.alerted || 0} 条告警,跳过 ${d.skipped || 0} 条(当日去重)`)
|
||||
fetchHealth()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '扫描失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '扫描失败')
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
fetchHealth()
|
||||
fetchAiChatDisplay()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 22px;
|
||||
color: #1F1B4D;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6B6894;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.reco-card,
|
||||
.list-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-display-card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #E9D5FF;
|
||||
background: #FAF5FF;
|
||||
}
|
||||
|
||||
.ai-chat-display-form {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.form-hint-inline {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: #6B6894;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
}
|
||||
|
||||
.health-card--warn {
|
||||
background: #FEF2F2;
|
||||
border-color: #FECACA;
|
||||
}
|
||||
|
||||
.health-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.health-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: #1F1B4D;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot-ok { background: #10B981; }
|
||||
.dot-warn { background: #EF4444; }
|
||||
|
||||
.health-providers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.provider-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #EEF2FF;
|
||||
color: #4338CA;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-warn {
|
||||
background: #FEE2E2;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.health-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.health-alert {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-header-col {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.order-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.order-empty {
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.reco-count {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
.reco-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reco-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reco-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #F5F3FF;
|
||||
border: 1px solid #EDE9FE;
|
||||
}
|
||||
|
||||
.reco-rank {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reco-cover {
|
||||
width: 120px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reco-cover--placeholder {
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reco-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reco-title {
|
||||
font-size: 15px;
|
||||
color: #1F1B4D;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reco-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #6B6894;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -34,14 +34,17 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 5173, // 首选端口
|
||||
strictPort: false, // 如果端口被占用,自动尝试下一个可用端口
|
||||
port: Number(process.env.MBTI_ADMIN_PORT) || 5173,
|
||||
strictPort: true, // 端口被占用则直接失败,避免静默改端口导致「打不开」
|
||||
proxy: {
|
||||
'/api': {
|
||||
// 与 .env.development 配合:VITE_API_BASE_URL 留空时,浏览器请求 /api/* 由这里转发到本机 ThinkPHP
|
||||
target: process.env.VITE_DEV_API_PROXY ?? 'http://127.0.0.1:8787',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path,
|
||||
// 云库首次连库较慢时避免代理提前断开(毫秒)
|
||||
timeout: Number(process.env.VITE_PROXY_TIMEOUT_MS) || 180000,
|
||||
proxyTimeout: Number(process.env.VITE_PROXY_TIMEOUT_MS) || 180000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
10
api/.env.local.merge.example
Normal file
10
api/.env.local.merge.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# 复制本文件为 api/.env.local.merge(与 .env 同级),再执行:
|
||||
# python3 scripts/apply_bt_download_env.py <宝塔下载的文件路径>
|
||||
# 脚本会把宝塔 .env 写入 api/.env,并把下面内容追加在末尾(后面的键覆盖前面的)。
|
||||
|
||||
# 示例:宝塔里数据库写的是 127.0.0.1(指服务器本机),你在本机用 SSH 隧道映射到 13306 时:
|
||||
# DATABASE_HOSTNAME = 127.0.0.1
|
||||
# DATABASE_HOSTPORT = 13306
|
||||
|
||||
# 示例:强制本机调试
|
||||
# APP_DEBUG = true
|
||||
132
api/app/common/AnalyticsEventLabels.php
Normal file
132
api/app/common/AnalyticsEventLabels.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 埋点事件中文名字典
|
||||
* 配合 analytics_events.eventName 展示
|
||||
*/
|
||||
class AnalyticsEventLabels
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private static $MAP = [
|
||||
// 基础
|
||||
'page_view' => '页面浏览',
|
||||
'app_launch' => '小程序启动',
|
||||
'share' => '分享',
|
||||
'pay_success' => '支付成功',
|
||||
'pay_fail' => '支付失败',
|
||||
'pay_success_attribution' => '支付成功·归因',
|
||||
|
||||
// 首页 / 拍照入口
|
||||
'tap_start_camera' => '点击·开始拍照测试',
|
||||
'tap_questionnaire_home' => '点击·首页·去做问卷',
|
||||
'tap_enterprise_entry' => '点击·切换企业版',
|
||||
'tap_test_select' => '点击·选择测评类型',
|
||||
|
||||
// 问卷类
|
||||
'test_start' => '开始答题',
|
||||
'test_submit' => '提交答题',
|
||||
'test_complete' => '答题完成',
|
||||
'test_next' => '下一题',
|
||||
'test_prev' => '上一题',
|
||||
'tap_upload_photo_home' => '点击·首页·上传照片',
|
||||
|
||||
// 付费墙
|
||||
'paywall_view' => '付费墙·曝光',
|
||||
|
||||
// 结果页通用
|
||||
'tap_read_full' => '点击·看全文',
|
||||
'tap_share_moment' => '点击·分享到朋友圈',
|
||||
'tap_share_friend' => '点击·分享给好友',
|
||||
'tap_face_camera' => '点击·去拍照',
|
||||
'tap_complete_profile' => '点击·去完善资料',
|
||||
'tap_unlock_full' => '点击·解锁完整报告',
|
||||
'tap_retake_test' => '点击·重新测试',
|
||||
|
||||
// 深度解读 / 推广(带测评类型后缀)
|
||||
'tap_deep_service_from_mbti' => '点击·MBTI·深度解读方案',
|
||||
'tap_deep_service_from_disc' => '点击·DISC·深度解读方案',
|
||||
'tap_deep_service_from_pdp' => '点击·PDP·深度解读方案',
|
||||
'tap_deep_service_from_sbti' => '点击·SBTI·深度解读方案',
|
||||
'tap_deep_service_face_result' => '点击·面相·深度解读方案',
|
||||
|
||||
'tap_promo_from_mbti' => '点击·MBTI·推广中心',
|
||||
'tap_promo_from_disc' => '点击·DISC·推广中心',
|
||||
'tap_promo_from_pdp' => '点击·PDP·推广中心',
|
||||
'tap_promo_from_sbti' => '点击·SBTI·推广中心',
|
||||
'tap_promo_face_result' => '点击·面相·推广中心',
|
||||
|
||||
// 面相成交链路
|
||||
'tap_questionnaire_face_result' => '点击·面相页·补做问卷',
|
||||
|
||||
// 我的 / 推广中心
|
||||
'tap_deep_service' => '点击·我的·深度解读方案',
|
||||
'tap_my_orders' => '点击·我的·我的订单',
|
||||
'tap_promo_center' => '点击·进入推广中心',
|
||||
'tap_promo_poster' => '点击·生成推广海报',
|
||||
'tap_promo_withdraw' => '点击·提现',
|
||||
'tap_promo_share' => '点击·推广分享',
|
||||
|
||||
// 面相 / AI 结果页
|
||||
'face_analyze_success' => '面相分析成功',
|
||||
'face_analyze_fail' => '面相分析失败',
|
||||
|
||||
// 企业 / 简历
|
||||
'tap_resume_upload' => '点击·上传简历',
|
||||
'tap_resume_analyze' => '点击·简历分析',
|
||||
|
||||
// 登录 / 绑手机
|
||||
'login_silent_success' => '静默登录成功',
|
||||
'login_silent_fail' => '静默登录失败',
|
||||
'bind_phone_success' => '绑定手机成功',
|
||||
'bind_phone_fail' => '绑定手机失败',
|
||||
|
||||
// 神仙 AI(功能六)
|
||||
'tap_tab_ai_chat' => '点击·底部Tab·神仙AI',
|
||||
'ai_chat_send' => '神仙AI·发送消息',
|
||||
'ai_chat_receive' => '神仙AI·收到回复',
|
||||
'ai_chat_degrade' => '神仙AI·降级兜底',
|
||||
'ai_quick_question_click' => '神仙AI·快捷问题点击',
|
||||
'tap_ai_article' => '神仙AI·点击推荐文章',
|
||||
'ai_article_read' => '神仙AI·文章阅读完成',
|
||||
'ai_chat_share' => '神仙AI·分享对话',
|
||||
'ai_chat_share_invite_tap' => '神仙AI·点击邀请赚佣金',
|
||||
'ai_report_cta_tap' => '神仙AI·点击深度报告CTA',
|
||||
'ai_report_pay_tap' => '神仙AI·深度报告点击支付',
|
||||
'ai_report_pay_success' => '神仙AI·深度报告支付成功',
|
||||
'ai_report_share' => '神仙AI·分享深度报告',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取事件中文名。未命中时回退到原 eventName。
|
||||
*/
|
||||
public static function cn(string $eventName): string
|
||||
{
|
||||
$name = trim($eventName);
|
||||
if ($name === '') return '';
|
||||
return self::$MAP[$name] ?? $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量翻译:在数组每行补 eventNameCn 字段
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
*/
|
||||
public static function withCn(array $rows, string $field = 'eventName', string $cnField = 'eventNameCn'): array
|
||||
{
|
||||
foreach ($rows as &$r) {
|
||||
$name = isset($r[$field]) ? (string) $r[$field] : '';
|
||||
$r[$cnField] = self::cn($name);
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回整张字典(供管理端下拉筛选)
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return self::$MAP;
|
||||
}
|
||||
}
|
||||
128
api/app/common/service/AiBalanceAlertService.php
Normal file
128
api/app/common/service/AiBalanceAlertService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* AI 服务商欠费预警
|
||||
*
|
||||
* 复用 `feishu_lead_webhook` 的 webhookUrl,但触发文案独立(不用走 lead 模板);
|
||||
* 每个服务商·每天·同阈值只推一次(ai_balance_alerts 表)。
|
||||
*
|
||||
* 触发建议:
|
||||
* 1) 超管页面手动点击「余额检查」
|
||||
* 2) 宝塔/Linux crontab 每天 9:00 + 21:00 POST 调用
|
||||
* curl -X POST -H "Authorization: Bearer {SUPERADMIN_TOKEN}" https://.../api/v1/superadmin/ai/balance-check
|
||||
*/
|
||||
class AiBalanceAlertService
|
||||
{
|
||||
/**
|
||||
* 扫描所有启用且开启预警的服务商,低于阈值则推飞书
|
||||
* @return array{alerted:int,skipped:int,items:array<int,array<string,mixed>>}
|
||||
*/
|
||||
public static function scanAndAlert(): array
|
||||
{
|
||||
$rows = AiProviderModel::where('enabled', 1)
|
||||
->where('balanceAlertEnabled', 1)
|
||||
->whereNotNull('lastBalance')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$alerted = 0;
|
||||
$skipped = 0;
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$threshold = (float) ($row['balanceAlertThreshold'] ?? 0);
|
||||
$balance = (float) $row['lastBalance'];
|
||||
if ($threshold <= 0 || $balance > $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateStr = date('Y-m-d');
|
||||
$dup = Db::name('ai_balance_alerts')
|
||||
->where('providerId', $row['providerId'])
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
if ($dup) {
|
||||
$skipped++;
|
||||
$items[] = ['providerId' => $row['providerId'], 'status' => 'dedup'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = self::pushFeishu($row, $balance, $threshold);
|
||||
if ($ok) {
|
||||
try {
|
||||
Db::name('ai_balance_alerts')->insert([
|
||||
'providerId' => $row['providerId'],
|
||||
'balance' => $balance,
|
||||
'threshold' => $threshold,
|
||||
'currency' => $row['lastBalanceCurrency'] ?? 'CNY',
|
||||
'alertedAt' => time(),
|
||||
'dateStr' => $dateStr,
|
||||
]);
|
||||
} catch (\Throwable $e) {}
|
||||
$alerted++;
|
||||
$items[] = ['providerId' => $row['providerId'], 'status' => 'alerted', 'balance' => $balance, 'threshold' => $threshold];
|
||||
} else {
|
||||
$items[] = ['providerId' => $row['providerId'], 'status' => 'send-failed'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['alerted' => $alerted, 'skipped' => $skipped, 'items' => $items];
|
||||
}
|
||||
|
||||
private static function pushFeishu(array $row, float $balance, float $threshold): bool
|
||||
{
|
||||
$cfg = FeishuLeadWebhookService::getConfig();
|
||||
$url = trim((string) ($cfg['webhookUrl'] ?? ''));
|
||||
if ($url === '' || stripos($url, 'http') !== 0) {
|
||||
Log::warning('AiBalanceAlertService: feishu webhookUrl 未配置');
|
||||
return false;
|
||||
}
|
||||
|
||||
$currency = ($row['lastBalanceCurrency'] ?? 'CNY') === 'USD' ? '$' : '¥';
|
||||
$now = date('Y-m-d H:i');
|
||||
$text = "⚠️ AI 服务余额预警\n"
|
||||
. "服务商: {$row['name']} ({$row['providerId']})\n"
|
||||
. "当前余额: {$currency}" . number_format($balance, 2) . "\n"
|
||||
. "告警阈值: {$currency}" . number_format($threshold, 2) . "\n"
|
||||
. "━━━━━━━━━━\n"
|
||||
. "神仙 AI 对话 / 面相分析可能受影响,已自动把该服务商降权到故障切换队尾。\n"
|
||||
. "请尽快到后台充值:\n"
|
||||
. "时间: {$now}";
|
||||
|
||||
$payload = stripos($url, 'qyapi.weixin.qq.com') !== false
|
||||
? ['msgtype' => 'text', 'text' => ['content' => $text]]
|
||||
: ['msg_type' => 'text', 'content' => ['text' => $text]];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code >= 400) {
|
||||
Log::warning("AiBalanceAlertService feishu push http={$code}");
|
||||
return false;
|
||||
}
|
||||
if ($body !== false && $body !== '') {
|
||||
$resp = json_decode($body, true);
|
||||
if (is_array($resp)) {
|
||||
if (isset($resp['code']) && (int) $resp['code'] !== 0) return false;
|
||||
if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
623
api/app/common/service/AiCallService.php
Normal file
623
api/app/common/service/AiCallService.php
Normal file
@@ -0,0 +1,623 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 神仙 AI · 统一对话调用服务
|
||||
*
|
||||
* 能力:
|
||||
* 1. 自动按 sortWeight / id 升序挑选可用服务商
|
||||
* 2. 余额低于阈值的服务商自动降权沉底
|
||||
* 3. 故障自动切换(429/401/402/5xx/解析错误)
|
||||
* 4. 支持 OpenAI 兼容 / Anthropic 两类协议
|
||||
* 5. 全部失败返回降级兜底回答,**不抛给前端**
|
||||
*
|
||||
* 典型用法:
|
||||
* $r = AiCallService::chat([
|
||||
* ['role'=>'system','content'=>'你是…'],
|
||||
* ['role'=>'user','content'=>'我是 INFP,今天压力大'],
|
||||
* ]);
|
||||
* // => ['content'=>'...', 'providerId'=>'deepseek', 'isDegraded'=>false]
|
||||
*/
|
||||
class AiCallService
|
||||
{
|
||||
/**
|
||||
* 对话入口
|
||||
*
|
||||
* @param array $messages OpenAI messages 格式
|
||||
* @param array $options ['temperature'=>0.7, 'maxTokens'=>2048, 'stream'=>false]
|
||||
* @return array ['content'=>string, 'providerId'=>string, 'isDegraded'=>bool, 'tokensIn'=>int, 'tokensOut'=>int]
|
||||
*/
|
||||
public static function chat(array $messages, array $options = []): array
|
||||
{
|
||||
$maxTokens = (int) ($options['maxTokens'] ?? 2048);
|
||||
$temperature = isset($options['temperature']) ? (float) $options['temperature'] : 0.7;
|
||||
|
||||
$providers = self::resolveProviders();
|
||||
if (empty($providers)) {
|
||||
return self::degrade('no-provider', '小神仙在喝茶呢,超管还没配置好法力,稍后再来找我呀~');
|
||||
}
|
||||
|
||||
$lastError = '';
|
||||
foreach ($providers as $provider) {
|
||||
$providerId = strtolower((string) ($provider->providerId ?? ''));
|
||||
$apiKey = $provider->getRawApiKey();
|
||||
if ($apiKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$endpoint = self::resolveEndpoint($provider);
|
||||
$model = !empty($provider->model) ? $provider->model : self::defaultModel($providerId);
|
||||
|
||||
try {
|
||||
if ($providerId === 'anthropic') {
|
||||
$content = self::callAnthropic($endpoint, $apiKey, $model, $messages, $maxTokens);
|
||||
} else {
|
||||
// openai / deepseek / moonshot / qwen / zhipu / zhizengzeng / groq 等
|
||||
$content = self::callOpenAICompatible($endpoint, $apiKey, $model, $messages, $maxTokens, $temperature);
|
||||
}
|
||||
|
||||
if ($content === '') {
|
||||
$lastError = "provider={$providerId} empty-content";
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'providerId' => $providerId,
|
||||
'model' => $model,
|
||||
'isDegraded' => false,
|
||||
'tokensIn' => 0, // 各家返回字段不一,留空(可二期补 usage)
|
||||
'tokensOut' => 0,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$lastError = "provider={$providerId} error=" . $e->getMessage();
|
||||
Log::warning('AiCallService chat fallback: ' . $lastError);
|
||||
// 遇到明显欠费/鉴权类错误,给该服务商的余额打低权重沉底
|
||||
if (self::looksLikeBillingError($e->getMessage())) {
|
||||
try {
|
||||
$provider->sortWeight = 999;
|
||||
$provider->save();
|
||||
} catch (\Throwable $ignore) {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return self::degrade('all-failed', '小神仙暂时联系不上天界了(' . $lastError . '),工程师已收到告警,请稍后再试 🌿');
|
||||
}
|
||||
|
||||
/**
|
||||
* 选出可用服务商列表(按 sortWeight 升序、余额不足的沉底)
|
||||
* @return AiProviderModel[]
|
||||
*/
|
||||
private static function resolveProviders(): array
|
||||
{
|
||||
$all = AiProviderModel::where('enabled', 1)
|
||||
->whereRaw('(visible IS NULL OR visible = 1)')
|
||||
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
|
||||
->order('sortWeight', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($all)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 把「余额低于阈值」的服务商排到队尾
|
||||
$healthy = [];
|
||||
$depleted = [];
|
||||
foreach ($all as $row) {
|
||||
$provider = AiProviderModel::where('id', $row['id'])->find();
|
||||
if (!$provider) continue;
|
||||
|
||||
$isLowBalance = $provider->balanceAlertEnabled == 1
|
||||
&& $provider->lastBalance !== null
|
||||
&& $provider->lastBalance <= (float) ($provider->balanceAlertThreshold ?? 0);
|
||||
|
||||
if ($isLowBalance) {
|
||||
$depleted[] = $provider;
|
||||
} else {
|
||||
$healthy[] = $provider;
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($healthy, $depleted);
|
||||
}
|
||||
|
||||
private static function resolveEndpoint($provider): string
|
||||
{
|
||||
$endpoint = !empty($provider->apiEndpoint) ? rtrim($provider->apiEndpoint, '/') : '';
|
||||
if ($endpoint !== '') return $endpoint;
|
||||
return self::defaultEndpoint(strtolower((string) $provider->providerId));
|
||||
}
|
||||
|
||||
private static function defaultEndpoint(string $providerId): string
|
||||
{
|
||||
switch ($providerId) {
|
||||
case 'openai': return 'https://api.openai.com/v1';
|
||||
case 'groq': return 'https://api.groq.com/openai/v1';
|
||||
case 'deepseek': return 'https://api.deepseek.com/v1';
|
||||
case 'moonshot': return 'https://api.moonshot.ai/v1';
|
||||
case 'qwen': return 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
case 'anthropic': return 'https://api.anthropic.com/v1';
|
||||
case 'zhipu': return 'https://api.z.ai/api/paas/v4';
|
||||
case 'zhizengzeng': return 'https://api.zhizengzeng.com/v1';
|
||||
default: return 'https://api.openai.com/v1';
|
||||
}
|
||||
}
|
||||
|
||||
private static function defaultModel(string $providerId): string
|
||||
{
|
||||
switch ($providerId) {
|
||||
case 'deepseek': return 'deepseek-chat';
|
||||
case 'moonshot': return 'moonshot-v1-8k';
|
||||
case 'qwen': return 'qwen-turbo';
|
||||
case 'zhipu': return 'glm-4-flash';
|
||||
case 'anthropic': return 'claude-3-5-haiku-latest';
|
||||
case 'groq': return 'llama-3.1-70b-versatile';
|
||||
default: return 'gpt-4o-mini';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI 兼容协议(deepseek / moonshot / qwen / zhipu / zhizengzeng / groq / openai…)
|
||||
*/
|
||||
private static function callOpenAICompatible(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens, float $temperature): string
|
||||
{
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => $temperature,
|
||||
];
|
||||
|
||||
if (strpos($endpoint, 'api.zhizengzeng.com') !== false) {
|
||||
$body['max_completion_tokens'] = $maxTokens;
|
||||
} else {
|
||||
$body['max_tokens'] = $maxTokens;
|
||||
}
|
||||
|
||||
$url = rtrim($endpoint, '/') . '/chat/completions';
|
||||
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErrNo = curl_errno($ch);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '') {
|
||||
throw new \RuntimeException("curl-failed code={$curlErrNo} msg={$curlError}");
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
throw new \RuntimeException('invalid-json');
|
||||
}
|
||||
|
||||
if (!empty($data['error'])) {
|
||||
$errMsg = is_array($data['error']) ? ($data['error']['message'] ?? json_encode($data['error'])) : (string) $data['error'];
|
||||
throw new \RuntimeException('api-error: ' . $errMsg);
|
||||
}
|
||||
|
||||
$text = $data['choices'][0]['message']['content'] ?? '';
|
||||
return trim((string) $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic Messages 协议
|
||||
*/
|
||||
private static function callAnthropic(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens): string
|
||||
{
|
||||
// Anthropic 需要单独传 system,messages 只接受 user/assistant
|
||||
$system = '';
|
||||
$userMessages = [];
|
||||
foreach ($messages as $m) {
|
||||
if (($m['role'] ?? '') === 'system') {
|
||||
$system .= "\n" . ($m['content'] ?? '');
|
||||
} else {
|
||||
$userMessages[] = [
|
||||
'role' => $m['role'] ?? 'user',
|
||||
'content' => [['type' => 'text', 'text' => (string) ($m['content'] ?? '')]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'max_tokens' => $maxTokens,
|
||||
'messages' => $userMessages,
|
||||
];
|
||||
if (trim($system) !== '') {
|
||||
$body['system'] = trim($system);
|
||||
}
|
||||
|
||||
$url = rtrim($endpoint, '/') . '/messages';
|
||||
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '') {
|
||||
throw new \RuntimeException('anthropic-no-response');
|
||||
}
|
||||
if ($httpCode >= 400) {
|
||||
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$text = '';
|
||||
if (!empty($data['content']) && is_array($data['content'])) {
|
||||
foreach ($data['content'] as $block) {
|
||||
if (($block['type'] ?? '') === 'text') {
|
||||
$text .= (string) ($block['text'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 常见欠费/鉴权类错误关键字 → 触发降权
|
||||
*/
|
||||
private static function looksLikeBillingError(string $msg): bool
|
||||
{
|
||||
$lower = strtolower($msg);
|
||||
foreach (['insufficient', 'balance', 'quota', 'rate_limit', '402', '429', 'unauthorized', '401'] as $kw) {
|
||||
if (strpos($lower, $kw) !== false) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function degrade(string $reason, string $content): array
|
||||
{
|
||||
Log::warning('AiCallService degraded: ' . $reason);
|
||||
return [
|
||||
'content' => $content,
|
||||
'providerId' => 'degrade',
|
||||
'model' => '',
|
||||
'isDegraded' => true,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造针对某用户的 system prompt(结合 MBTI 类型 + 画像)
|
||||
*/
|
||||
public static function buildSystemPrompt(array $userContext): string
|
||||
{
|
||||
$mbtiType = trim((string) ($userContext['mbtiType'] ?? ''));
|
||||
$summary = trim((string) ($userContext['summary'] ?? ''));
|
||||
$nickname = trim((string) ($userContext['nickname'] ?? ''));
|
||||
|
||||
$prompt = "你是「神仙 AI」,神仙团队 MBTI 性格小程序的专属人格伙伴。\n";
|
||||
$prompt .= "说话风格:像朋友一样亲切、简短(每次回答 150 字以内,必要时可延展),不堆砌术语;适度鼓励用户完成其他测评(DISC / PDP / SBTI / 面相)或去「一场 soul 创业实验」公众号阅读相关文章。\n";
|
||||
$prompt .= "禁止:做医疗诊断 / 政治议题 / 具体投资建议。\n";
|
||||
if ($nickname !== '') {
|
||||
$prompt .= "称呼:可以偶尔叫用户「{$nickname}」。\n";
|
||||
}
|
||||
if ($mbtiType !== '') {
|
||||
$prompt .= "用户 MBTI:{$mbtiType}。请结合此类型做个性化回答。\n";
|
||||
} else {
|
||||
$prompt .= "用户尚未完成 MBTI 测试。回答时可友好建议先去做一下测评。\n";
|
||||
}
|
||||
if ($summary !== '') {
|
||||
$prompt .= "用户性格画像摘要:{$summary}\n";
|
||||
}
|
||||
|
||||
$appendix = trim((string) ($userContext['testAppendix'] ?? ''));
|
||||
if ($appendix !== '') {
|
||||
$prompt .= "\n【用户测评客观记录(含问卷选项,供你结合其当前提问做针对性回答;勿机械罗列题号套话)】\n";
|
||||
$prompt .= $appendix . "\n";
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取用户 MBTI 画像(最近一次 test_results 结果)
|
||||
*/
|
||||
public static function fetchUserContext(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return ['mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => ''];
|
||||
}
|
||||
|
||||
$nickname = (string) Db::name('wechat_users')->where('id', $userId)->value('nickname');
|
||||
|
||||
$mbtiType = '';
|
||||
$summary = '';
|
||||
$row = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->where('testType', 'mbti')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($row && !empty($row['resultData'])) {
|
||||
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
|
||||
if (is_array($data)) {
|
||||
$mbtiType = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
|
||||
if (!empty($data['description']['summary'])) {
|
||||
$summary = (string) $data['description']['summary'];
|
||||
} elseif (!empty($data['description']['overview'])) {
|
||||
$summary = (string) $data['description']['overview'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 若无 MBTI,尝试回落到面相分析给出的 mbti
|
||||
if ($mbtiType === '') {
|
||||
$faceRow = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->where('testType', 'face')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($faceRow && !empty($faceRow['resultData'])) {
|
||||
$data = is_string($faceRow['resultData']) ? json_decode($faceRow['resultData'], true) : $faceRow['resultData'];
|
||||
if (is_array($data) && isset($data['mbti']['type'])) {
|
||||
$mbtiType = (string) $data['mbti']['type'];
|
||||
$summary = (string) ($data['personalitySummary'] ?? ($data['overview'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$testAppendix = self::buildLatestTestsAppendix($userId);
|
||||
|
||||
return [
|
||||
'mbtiType' => $mbtiType,
|
||||
'summary' => $summary,
|
||||
'nickname' => $nickname,
|
||||
'testAppendix' => $testAppendix,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总用户最近一次各类型测评的答题与结果,供 system prompt 使用(有长度上限)
|
||||
*/
|
||||
private static function buildLatestTestsAppendix(int $userId): string
|
||||
{
|
||||
$types = ['mbti', 'sbti', 'disc', 'pdp'];
|
||||
$blocks = [];
|
||||
foreach ($types as $type) {
|
||||
$row = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->where('testType', $type)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$row || empty($row['resultData'])) {
|
||||
continue;
|
||||
}
|
||||
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
$block = self::formatTestBlockForPrompt($type, $data);
|
||||
if ($block !== '') {
|
||||
$blocks[] = $block;
|
||||
}
|
||||
}
|
||||
$text = implode("\n——\n", $blocks);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
$maxLen = 3800;
|
||||
if (mb_strlen($text, 'UTF-8') > $maxLen) {
|
||||
$text = mb_substr($text, 0, $maxLen, 'UTF-8') . '…(档案已截断)';
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatTestBlockForPrompt(string $testType, array $data): string
|
||||
{
|
||||
switch ($testType) {
|
||||
case 'mbti':
|
||||
return self::formatMbtiBlockForPrompt($data);
|
||||
case 'sbti':
|
||||
return self::formatSbtiBlockForPrompt($data);
|
||||
case 'disc':
|
||||
return self::formatDiscBlockForPrompt($data);
|
||||
case 'pdp':
|
||||
return self::formatPdpBlockForPrompt($data);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $answers
|
||||
*/
|
||||
private static function formatAnswersCompact(?array $answers): string
|
||||
{
|
||||
if (!is_array($answers) || $answers === []) {
|
||||
return '';
|
||||
}
|
||||
$keys = array_keys($answers);
|
||||
sort($keys, SORT_NATURAL);
|
||||
$parts = [];
|
||||
foreach ($keys as $k) {
|
||||
$v = $answers[$k];
|
||||
if (is_array($v) || is_object($v)) {
|
||||
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$parts[] = (string) $k . '→' . (string) $v;
|
||||
if (count($parts) >= 100) {
|
||||
$parts[] = '…共' . count($keys) . '题';
|
||||
break;
|
||||
}
|
||||
}
|
||||
return implode(',', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatMbtiBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【MBTI·最近一次】'];
|
||||
$type = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
|
||||
if ($type !== '') {
|
||||
$lines[] = '类型:' . $type;
|
||||
}
|
||||
if (isset($data['confidence'])) {
|
||||
$lines[] = '置信度:' . (int) $data['confidence'] . '%';
|
||||
}
|
||||
if (!empty($data['dimensionScores']) && is_array($data['dimensionScores'])) {
|
||||
$lines[] = '四轴:' . json_encode($data['dimensionScores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (!empty($data['scores']) && is_array($data['scores'])) {
|
||||
$lines[] = '字母计数:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项(题号/ID→所选):' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatSbtiBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【SBTI·最近一次】'];
|
||||
$final = $data['finalType'] ?? null;
|
||||
$fcode = is_array($final) ? (string) ($final['code'] ?? '') : '';
|
||||
$code = (string) ($data['sbtiType'] ?? $fcode);
|
||||
if ($code !== '') {
|
||||
$lines[] = '类型代码:' . $code;
|
||||
}
|
||||
if (!empty($data['special'])) {
|
||||
$lines[] = '特殊标记:' . json_encode($data['special'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatDiscBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【DISC·最近一次】'];
|
||||
if (!empty($data['dominantType'])) {
|
||||
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
|
||||
}
|
||||
if (!empty($data['scores']) && is_array($data['scores'])) {
|
||||
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatPdpBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【PDP·最近一次】'];
|
||||
if (!empty($data['dominantType'])) {
|
||||
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
|
||||
}
|
||||
if (!empty($data['scores']) && is_array($data['scores'])) {
|
||||
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤不在小程序展示的快捷问句(与产品配置一致)
|
||||
*
|
||||
* @param string[] $questions
|
||||
* @return string[]
|
||||
*/
|
||||
public static function filterQuickQuestions(array $questions): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($questions as $q) {
|
||||
$s = trim((string) $q);
|
||||
if ($s === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/记\s*一下\s*我的\s*MBTI/ui', $s)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = $s;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 MBTI 类型返回 3 条快捷问句
|
||||
*/
|
||||
public static function quickQuestions(string $mbtiType): array
|
||||
{
|
||||
$mbtiType = strtoupper(trim($mbtiType));
|
||||
$generic = [
|
||||
'我应该找什么样的工作?',
|
||||
'我适合什么样的伴侣?',
|
||||
'我的职业发展方向是什么?',
|
||||
'我最近有点迷茫,有什么建议?',
|
||||
'帮我做一个简短的自我介绍',
|
||||
'我有哪些需要警惕的盲点?',
|
||||
];
|
||||
if ($mbtiType === '') return $generic;
|
||||
|
||||
return [
|
||||
"作为 {$mbtiType},我最大的优势和盲点是什么?",
|
||||
"{$mbtiType} 适合什么样的工作?",
|
||||
"{$mbtiType} 适合找什么样的伴侣?",
|
||||
"{$mbtiType} 的职业发展路径推荐?",
|
||||
"{$mbtiType} 如何处理职场人际关系?",
|
||||
"{$mbtiType} 最容易掉进什么心理陷阱?",
|
||||
];
|
||||
}
|
||||
}
|
||||
116
api/app/common/service/AiChatArticleDisplayService.php
Normal file
116
api/app/common/service/AiChatArticleDisplayService.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 神仙 AI 页 · 推荐文章区块展示(超管可配)
|
||||
*
|
||||
* system_config.key = ai_chat_articles, enterprise_id = 0
|
||||
* JSON: { enabled, maxShow(1-3), sectionExpandedDefault, profileRecoEnabled, profileSectionLabel }
|
||||
*/
|
||||
class AiChatArticleDisplayService
|
||||
{
|
||||
public const CONFIG_KEY = 'ai_chat_articles';
|
||||
|
||||
/** 我的页推荐条默认标题(后台可改) */
|
||||
public const DEFAULT_PROFILE_SECTION_LABEL = '推荐阅读';
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
|
||||
*/
|
||||
public static function getSettings(): array
|
||||
{
|
||||
$defaults = [
|
||||
'enabled' => false,
|
||||
'maxShow' => 1,
|
||||
'sectionExpandedDefault' => false,
|
||||
'profileRecoEnabled' => false,
|
||||
'profileSectionLabel' => self::DEFAULT_PROFILE_SECTION_LABEL,
|
||||
];
|
||||
$row = Db::name('system_config')
|
||||
->where('key', self::CONFIG_KEY)
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if (!$row || empty($row['value'])) {
|
||||
return $defaults;
|
||||
}
|
||||
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (!is_array($v)) {
|
||||
return $defaults;
|
||||
}
|
||||
$maxShow = (int) ($v['maxShow'] ?? 1);
|
||||
if ($maxShow < 1) {
|
||||
$maxShow = 1;
|
||||
}
|
||||
if ($maxShow > 3) {
|
||||
$maxShow = 3;
|
||||
}
|
||||
|
||||
$profileLabel = isset($v['profileSectionLabel']) ? trim((string) $v['profileSectionLabel']) : '';
|
||||
if ($profileLabel === '') {
|
||||
$profileLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
|
||||
}
|
||||
if (function_exists('mb_substr')) {
|
||||
$profileLabel = mb_substr($profileLabel, 0, 32, 'UTF-8');
|
||||
} else {
|
||||
$profileLabel = substr($profileLabel, 0, 32);
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !empty($v['enabled']),
|
||||
'maxShow' => $maxShow,
|
||||
'sectionExpandedDefault' => !empty($v['sectionExpandedDefault']),
|
||||
'profileRecoEnabled' => !empty($v['profileRecoEnabled']),
|
||||
'profileSectionLabel' => $profileLabel,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
|
||||
*/
|
||||
public static function saveSettings(array $input): array
|
||||
{
|
||||
$rawLabel = isset($input['profileSectionLabel']) ? trim((string) $input['profileSectionLabel']) : '';
|
||||
if ($rawLabel === '') {
|
||||
$rawLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
|
||||
}
|
||||
if (function_exists('mb_substr')) {
|
||||
$rawLabel = mb_substr($rawLabel, 0, 32, 'UTF-8');
|
||||
} else {
|
||||
$rawLabel = substr($rawLabel, 0, 32);
|
||||
}
|
||||
|
||||
$settings = [
|
||||
'enabled' => !empty($input['enabled']),
|
||||
'maxShow' => max(1, min(3, (int) ($input['maxShow'] ?? 1))),
|
||||
'sectionExpandedDefault' => !empty($input['sectionExpandedDefault']),
|
||||
'profileRecoEnabled' => !empty($input['profileRecoEnabled']),
|
||||
'profileSectionLabel' => $rawLabel,
|
||||
];
|
||||
$json = json_encode($settings, JSON_UNESCAPED_UNICODE);
|
||||
$now = time();
|
||||
$exists = Db::name('system_config')
|
||||
->where('key', self::CONFIG_KEY)
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if ($exists) {
|
||||
Db::name('system_config')
|
||||
->where('key', self::CONFIG_KEY)
|
||||
->where('enterprise_id', 0)
|
||||
->update(['value' => $json, 'updatedAt' => $now]);
|
||||
} else {
|
||||
Db::name('system_config')->insert([
|
||||
'key' => self::CONFIG_KEY,
|
||||
'enterprise_id' => 0,
|
||||
'value' => $json,
|
||||
'description' => '神仙 AI / 我的页:推荐文章展示配置',
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
206
api/app/common/service/AiReportService.php
Normal file
206
api/app/common/service/AiReportService.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\AiReport as AiReportModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* AI 深度画像报告服务
|
||||
*
|
||||
* 生命周期:
|
||||
* pending(创建)→ paid(支付成功 / dev:markPaidDev)
|
||||
* → generating(后台开始调 AI) → done(生成完成)
|
||||
* 失败时置 failed + 自动重试(retryCount<=3)
|
||||
*/
|
||||
class AiReportService
|
||||
{
|
||||
const PRICE_FEN = 990; // 9.9 元
|
||||
|
||||
public static function createOrGetPending(int $userId, int $conversationId = 0, string $mbtiType = ''): array
|
||||
{
|
||||
// 已有 pending/paid/generating/done → 返回最新那条
|
||||
$existing = AiReportModel::where('userId', $userId)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($existing && in_array($existing->status, ['pending', 'paid', 'generating', 'done'])) {
|
||||
// 已完成的报告允许用户再做一份?按默认:只有 done 才允许再发起新的
|
||||
if ($existing->status !== 'done') {
|
||||
return self::toArray($existing);
|
||||
}
|
||||
}
|
||||
|
||||
$orderSn = 'AIR' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
$model = AiReportModel::create([
|
||||
'userId' => $userId,
|
||||
'conversationId' => $conversationId ?: null,
|
||||
'mbtiType' => $mbtiType ?: null,
|
||||
'orderSn' => $orderSn,
|
||||
'priceFen' => self::PRICE_FEN,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
return self::toArray($model);
|
||||
}
|
||||
|
||||
public static function myLatest(int $userId): ?array
|
||||
{
|
||||
$m = AiReportModel::where('userId', $userId)->order('id', 'desc')->find();
|
||||
return $m ? self::toArray($m) : null;
|
||||
}
|
||||
|
||||
public static function get(int $id, int $userId): ?array
|
||||
{
|
||||
$m = AiReportModel::where('id', $id)->where('userId', $userId)->find();
|
||||
return $m ? self::toArray($m) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功回调:把 report 置为 paid,并触发异步生成
|
||||
* 同时执行订单分账(走 ProfitSharingService)
|
||||
*/
|
||||
public static function markPaid(string $orderSn): array
|
||||
{
|
||||
$m = AiReportModel::where('orderSn', $orderSn)->find();
|
||||
if (!$m) return ['status' => 'not-found'];
|
||||
if ($m->status !== 'pending') {
|
||||
// 已处理过
|
||||
return ['status' => 'already', 'reportStatus' => $m->status];
|
||||
}
|
||||
|
||||
$m->status = 'paid';
|
||||
$m->paidAt = time();
|
||||
$m->save();
|
||||
|
||||
// 分账(idempotent)
|
||||
try {
|
||||
ProfitSharingService::executeSharing(
|
||||
$m->orderSn,
|
||||
'ai_deep_report',
|
||||
(int) $m->priceFen,
|
||||
['userId' => (int) $m->userId, 'orderId' => (int) $m->id]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiReportService profit-sharing failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 同步生成报告(若耗时 >30s 可改异步队列,此处 PHP fpm 环境先同步,失败自动降级)
|
||||
self::generate((int) $m->id);
|
||||
|
||||
$fresh = AiReportModel::find($m->id);
|
||||
return ['status' => 'ok', 'report' => self::toArray($fresh)];
|
||||
}
|
||||
|
||||
/** 调试/内部:跳过支付直接 markPaid */
|
||||
public static function markPaidDev(string $orderSn): array
|
||||
{
|
||||
return self::markPaid($orderSn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成报告正文(调 AI)
|
||||
*/
|
||||
public static function generate(int $reportId): void
|
||||
{
|
||||
$m = AiReportModel::find($reportId);
|
||||
if (!$m) return;
|
||||
if (!in_array($m->status, ['paid', 'failed', 'generating'])) return;
|
||||
|
||||
$m->status = 'generating';
|
||||
$m->retryCount = (int) $m->retryCount + 1;
|
||||
$m->save();
|
||||
|
||||
try {
|
||||
$user = Db::name('user')->where('id', (int) $m->userId)->find();
|
||||
$mbti = $m->mbtiType ?: ($user['mbti_type'] ?? '未知');
|
||||
$nickname = $user['nickname'] ?? '朋友';
|
||||
|
||||
// 拉取最近对话作为输入素材
|
||||
$history = [];
|
||||
if (!empty($m->conversationId)) {
|
||||
$msgs = Db::name('ai_messages')
|
||||
->where('conversationId', (int) $m->conversationId)
|
||||
->order('id', 'asc')
|
||||
->limit(40)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($msgs as $msg) {
|
||||
if ($msg['role'] === 'system') continue;
|
||||
$history[] = ($msg['role'] === 'user' ? '【你】' : '【神仙AI】') . ":" . $msg['content'];
|
||||
}
|
||||
}
|
||||
|
||||
$system = "你是「神仙 AI」,要基于用户的 MBTI 类型与最近的对话,输出一份具个性化的深度画像报告。\n"
|
||||
. "报告要求:\n"
|
||||
. "1) 长度 1200-1800 字,分 5 个章节(性格内核 / 优势雷达 / 潜在盲点 / 亲密关系建议 / 下一步成长路径)\n"
|
||||
. "2) 每章 2-3 段,语气温暖不说教;禁止玄学与迷信\n"
|
||||
. "3) 首段总结一句,用一条「超短金句」概括这个人\n"
|
||||
. "4) 使用第二人称「你」,称呼:{$nickname}\n"
|
||||
. "5) Markdown 纯文本,章节用 「## 章节标题」 "
|
||||
. "";
|
||||
|
||||
$prompt = "【用户 MBTI】{$mbti}\n【最近对话】\n" . (empty($history) ? '(无历史对话)' : implode("\n", $history));
|
||||
|
||||
$r = AiCallService::chat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
], ['maxTokens' => 3000, 'temperature' => 0.75]);
|
||||
|
||||
$content = (string) ($r['content'] ?? '');
|
||||
if ($content === '' || !empty($r['isDegraded'])) {
|
||||
throw new \RuntimeException('AI 生成失败或降级:' . (string) ($r['content'] ?? ''));
|
||||
}
|
||||
|
||||
$summary = self::extractSummary($content);
|
||||
$title = self::extractTitle($content, $mbti);
|
||||
|
||||
$m->title = $title;
|
||||
$m->summary = $summary;
|
||||
$m->content = $content;
|
||||
$m->status = 'done';
|
||||
$m->generatedAt = time();
|
||||
$m->lastError = null;
|
||||
$m->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiReportService generate fail: ' . $e->getMessage());
|
||||
$m->status = $m->retryCount >= 3 ? 'failed' : 'paid';
|
||||
$m->lastError = mb_substr($e->getMessage(), 0, 480);
|
||||
$m->save();
|
||||
}
|
||||
}
|
||||
|
||||
private static function extractSummary(string $md): string
|
||||
{
|
||||
$plain = preg_replace('/[#>*_`\[\]]+/', '', $md);
|
||||
$plain = trim((string) $plain);
|
||||
return mb_substr($plain, 0, 180);
|
||||
}
|
||||
|
||||
private static function extractTitle(string $md, string $mbti): string
|
||||
{
|
||||
if (preg_match('/^\s*#+\s*(.+)$/m', $md, $mch)) {
|
||||
return mb_substr(trim($mch[1]), 0, 80);
|
||||
}
|
||||
return "{$mbti} · 神仙 AI 深度画像";
|
||||
}
|
||||
|
||||
private static function toArray(AiReportModel $m): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $m->id,
|
||||
'userId' => (int) $m->userId,
|
||||
'conversationId' => (int) $m->conversationId,
|
||||
'mbtiType' => $m->mbtiType,
|
||||
'orderSn' => $m->orderSn,
|
||||
'priceFen' => (int) $m->priceFen,
|
||||
'priceYuan' => round($m->priceFen / 100, 2),
|
||||
'status' => $m->status,
|
||||
'title' => $m->title,
|
||||
'summary' => $m->summary,
|
||||
'content' => $m->content,
|
||||
'posterUrl' => $m->posterUrl,
|
||||
'paidAt' => (int) $m->paidAt,
|
||||
'generatedAt' => (int) $m->generatedAt,
|
||||
'createdAt' => (int) $m->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,26 @@ class FeishuLeadWebhookService
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序「了解自己」页提交咨询申请(与 CrmReport 存客宝上报并行;飞书机器人需 feishu_lead_webhook.enabled)
|
||||
*/
|
||||
public static function onDeepServiceConsultApply(int $userId, string $source, string $categoryTag): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$src = trim($source) !== '' ? trim($source) : '深度服务·申请咨询';
|
||||
$tag = trim($categoryTag);
|
||||
$dedup = 'deep_consult_apply:' . $userId . ':' . md5($src . '|' . $tag) . ':' . date('YmdH');
|
||||
$extra = $tag !== '' ? ('套餐: ' . self::oneLine($tag, 60)) : '';
|
||||
self::pushLead([
|
||||
'dedupKey' => $dedup,
|
||||
'userId' => $userId,
|
||||
'source' => $src,
|
||||
'extraLine' => $extra,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{dedupKey:string,userId:int,source:string,phone?:string,extraLine?:string} $p
|
||||
*/
|
||||
@@ -127,7 +147,7 @@ class FeishuLeadWebhookService
|
||||
$text .= "\n" . $p['extraLine'];
|
||||
}
|
||||
|
||||
$lines = self::recentBehaviorLines($userId, 8);
|
||||
$lines = UserJourneyService::recentBehaviorLines($userId, 8);
|
||||
if (count($lines) > 0) {
|
||||
$text .= "\n━━━━━━━━━━\n最近行为:";
|
||||
$i = 1;
|
||||
@@ -184,85 +204,6 @@ class FeishuLeadWebhookService
|
||||
return $s;
|
||||
}
|
||||
|
||||
private static function recentBehaviorLines(int $userId, int $limit): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = self::formatAnalyticsLine($r);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function formatAnalyticsLine(array $r): string
|
||||
{
|
||||
$name = (string) ($r['eventName'] ?? '');
|
||||
$path = trim((string) ($r['pagePath'] ?? ''));
|
||||
$props = [];
|
||||
if (!empty($r['propsJson'])) {
|
||||
$decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
|
||||
$props = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
$labelMap = [
|
||||
'page_view' => '浏览页面',
|
||||
'button_click' => '按钮点击',
|
||||
'click_pay' => '发起支付',
|
||||
'click_recharge'=> '点击充值',
|
||||
];
|
||||
$label = $labelMap[$name] ?? $name;
|
||||
$detail = '';
|
||||
if ($name === 'page_view' && $path !== '') {
|
||||
$detail = $path;
|
||||
}
|
||||
if (isset($props['action']) && (string) $props['action'] !== '') {
|
||||
$detail = (string) $props['action'];
|
||||
if (!empty($props['productType'])) {
|
||||
$detail .= ' · ' . (string) $props['productType'];
|
||||
}
|
||||
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
|
||||
$detail = (string) $props['label'];
|
||||
} elseif ($path !== '' && $detail === '') {
|
||||
$detail = $path;
|
||||
}
|
||||
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
|
||||
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
|
||||
if (!$ts && !empty($r['createdAt'])) {
|
||||
$ts = strtotime((string) $r['createdAt']) * 1000;
|
||||
}
|
||||
if ($ts) {
|
||||
$line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
|
||||
}
|
||||
return $line;
|
||||
}
|
||||
|
||||
private static function humanTimeAgoCn(int $clientTsMs): string
|
||||
{
|
||||
$now = (int) (microtime(true) * 1000);
|
||||
$sec = max(0, (int) (($now - $clientTsMs) / 1000));
|
||||
if ($sec < 60) {
|
||||
return '刚刚';
|
||||
}
|
||||
if ($sec < 3600) {
|
||||
return (int) floor($sec / 60) . '分钟前';
|
||||
}
|
||||
if ($sec < 86400) {
|
||||
return (int) floor($sec / 3600) . '小时前';
|
||||
}
|
||||
return (int) floor($sec / 86400) . '天前';
|
||||
}
|
||||
|
||||
private static function beginDedup(string $dedupKey): bool
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -243,8 +243,29 @@ class OutboundPushHookService
|
||||
$lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n";
|
||||
}
|
||||
$lines[] = '测试时间: ' . $testTime;
|
||||
$body = implode('', $lines);
|
||||
$mgmtSummary = trim((string) ($payload['managementSummary'] ?? ''));
|
||||
if ($mgmtSummary !== '') {
|
||||
$body .= "\n━━━━━━━━━━\n用户管理:\n" . $mgmtSummary;
|
||||
}
|
||||
$um = $payload['userManagement'] ?? null;
|
||||
if (is_array($um) && !empty($um['openidTail6'])) {
|
||||
$body .= "\nOpenID尾号(脱敏): " . (string) $um['openidTail6'];
|
||||
}
|
||||
$beh = $payload['recentBehaviors'] ?? [];
|
||||
if (is_array($beh) && count($beh) > 0) {
|
||||
$body .= "\n━━━━━━━━━━\n最近行为:";
|
||||
$i = 1;
|
||||
foreach ($beh as $bl) {
|
||||
if (!is_string($bl) || $bl === '') {
|
||||
continue;
|
||||
}
|
||||
$body .= "\n {$i}. {$bl}";
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
|
||||
return implode('', $lines);
|
||||
return $body;
|
||||
default:
|
||||
return $head . "\n事件: " . $event;
|
||||
}
|
||||
@@ -590,6 +611,50 @@ class OutboundPushHookService
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测评完成 JSON / 飞书文本:附加用户管理字段 + analytics 用户旅程(与 FeishuLeadWebhookService 获客卡片「最近行为」同源)
|
||||
*
|
||||
* @param array<string,mixed> $payload 引用
|
||||
* @param array<string,mixed> $testResultRow test_results 一行
|
||||
* @param array<string,mixed>|null $wuArr wechat_users 行(可含 openid)
|
||||
*/
|
||||
private static function mergeTestResultUserJourneyPayload(array &$payload, int $userId, array $testResultRow, ?array $wuArr): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
$payload['recentBehaviors'] = [];
|
||||
$payload['userManagement'] = [
|
||||
'wechatUserId' => 0,
|
||||
'enterpriseId' => 0,
|
||||
'enterpriseName' => null,
|
||||
'openidTail6' => null,
|
||||
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
|
||||
];
|
||||
$payload['managementSummary'] = '';
|
||||
|
||||
return;
|
||||
}
|
||||
$eidResolved = self::resolveEnterpriseIdForTestResult($testResultRow, $wuArr);
|
||||
$tenant = self::tenantPayload($eidResolved);
|
||||
$openid = '';
|
||||
if ($wuArr !== null && isset($wuArr['openid'])) {
|
||||
$openid = trim((string) $wuArr['openid']);
|
||||
}
|
||||
$tail = '';
|
||||
if ($openid !== '') {
|
||||
$tail = strlen($openid) >= 6 ? substr($openid, -6) : $openid;
|
||||
}
|
||||
$payload['userManagement'] = [
|
||||
'wechatUserId' => $userId,
|
||||
'enterpriseId' => $eidResolved,
|
||||
'enterpriseName' => $tenant['enterpriseName'],
|
||||
'openidTail6' => $tail !== '' ? $tail : null,
|
||||
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
|
||||
];
|
||||
$payload['recentBehaviors'] = UserJourneyService::recentBehaviorLines($userId, 12);
|
||||
$trEid = (int) ($testResultRow['enterpriseId'] ?? 0);
|
||||
$payload['managementSummary'] = UserJourneyService::managementSummaryLine($userId, $trEid);
|
||||
}
|
||||
|
||||
public static function isEventEnabled(string $event, array $cfg): bool
|
||||
{
|
||||
if (empty($cfg['enabled'])) {
|
||||
@@ -712,7 +777,7 @@ class OutboundPushHookService
|
||||
$completedAt = date('Y-m-d H:i:s', $createdAt);
|
||||
|
||||
$wu = $userId > 0
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
|
||||
: null;
|
||||
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
|
||||
if ($userName === '') {
|
||||
@@ -765,6 +830,7 @@ class OutboundPushHookService
|
||||
$payload['resultDisc'] = $dims['disc'];
|
||||
}
|
||||
}
|
||||
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
|
||||
|
||||
self::dispatch('test.result_completed', [
|
||||
'event' => 'test.result_completed',
|
||||
@@ -807,7 +873,7 @@ class OutboundPushHookService
|
||||
$completedAt = date('Y-m-d H:i:s', $createdAt);
|
||||
|
||||
$wu = $userId > 0
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
|
||||
: null;
|
||||
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
|
||||
if ($userName === '') {
|
||||
@@ -860,6 +926,7 @@ class OutboundPushHookService
|
||||
$payload['resultDisc'] = $dims['disc'];
|
||||
}
|
||||
}
|
||||
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
|
||||
|
||||
return self::dispatchDetailed('test.result_completed', [
|
||||
'event' => 'test.result_completed',
|
||||
|
||||
259
api/app/common/service/ProfitSharingService.php
Normal file
259
api/app/common/service/ProfitSharingService.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 订单分账服务(参考知己 profit-sharing 思路)
|
||||
*
|
||||
* 用法:订单支付成功后调用 ::executeSharing($orderSn, $productType, $totalFen, $extra)
|
||||
* - 按 mbti_profit_sharing_rules 配置把 totalFen 拆给多个 receiver
|
||||
* - 对 distributor_l1 / distributor_l2:会联动现有 mbti_commission_records( 复用项目已有分销系统 )
|
||||
* - 最终写入 mbti_profit_sharing_records 一行(orderSn 唯一),details JSON 里记所有明细
|
||||
*
|
||||
* 该服务 **idempotent**:同一 orderSn 重复调用不会重复分账。
|
||||
*/
|
||||
class ProfitSharingService
|
||||
{
|
||||
/**
|
||||
* @param string $orderSn 订单号(唯一)
|
||||
* @param string $productType 产品类型,对应 mbti_profit_sharing_rules.productType,找不到则走 default
|
||||
* @param int $totalFen 订单总金额(分)
|
||||
* @param array $extra 扩展 ['userId'=>购买者id, 'orderId'=>订单表id, 'consultantId'=>?]
|
||||
* @return array 分账明细
|
||||
*/
|
||||
public static function executeSharing(string $orderSn, string $productType, int $totalFen, array $extra = []): array
|
||||
{
|
||||
if ($totalFen <= 0 || $orderSn === '') {
|
||||
return ['status' => 'skipped', 'reason' => 'invalid-input'];
|
||||
}
|
||||
|
||||
$dup = Db::name('profit_sharing_records')->where('orderSn', $orderSn)->find();
|
||||
if ($dup) {
|
||||
return ['status' => 'dedup', 'recordId' => $dup['id']];
|
||||
}
|
||||
|
||||
$rule = self::resolveRule($productType);
|
||||
$receivers = $rule['receivers'] ?? [];
|
||||
if (!is_array($receivers) || count($receivers) === 0) {
|
||||
$receivers = [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]];
|
||||
}
|
||||
|
||||
$totalRatio = 0.0;
|
||||
foreach ($receivers as $r) { $totalRatio += (float) ($r['ratio'] ?? 0); }
|
||||
if (abs($totalRatio - 1.0) > 0.001) {
|
||||
Log::warning("ProfitSharingService: rule {$productType} ratio sum={$totalRatio}, will renormalize");
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$details = [];
|
||||
$buyerUserId = (int) ($extra['userId'] ?? 0);
|
||||
|
||||
// 一级 / 二级分销人解析(复用现有 distribution_bindings)
|
||||
list($inviterL1, $inviterL2) = self::resolveInviters($buyerUserId);
|
||||
|
||||
$allocated = 0;
|
||||
foreach ($receivers as $idx => $r) {
|
||||
$ratio = (float) ($r['ratio'] ?? 0);
|
||||
$amount = (int) floor($totalFen * $ratio);
|
||||
$type = (string) ($r['type'] ?? 'platform');
|
||||
$account = null;
|
||||
$status = 'success';
|
||||
|
||||
if ($type === 'distributor_l1') {
|
||||
$account = $inviterL1 ? (string) $inviterL1 : null;
|
||||
if (!$inviterL1) {
|
||||
// 没有一级分销 → 金额归还平台
|
||||
$status = 'no-inviter';
|
||||
$amount = 0;
|
||||
}
|
||||
} elseif ($type === 'distributor_l2') {
|
||||
$account = $inviterL2 ? (string) $inviterL2 : null;
|
||||
if (!$inviterL2) {
|
||||
$status = 'no-inviter';
|
||||
$amount = 0;
|
||||
}
|
||||
} elseif ($type === 'consultant') {
|
||||
$account = isset($extra['consultantId']) ? (string) $extra['consultantId'] : null;
|
||||
}
|
||||
|
||||
$details[] = [
|
||||
'receiverType' => $type,
|
||||
'receiverName' => (string) ($r['name'] ?? $type),
|
||||
'amount' => $amount,
|
||||
'ratio' => $ratio,
|
||||
'account' => $account,
|
||||
'status' => $status,
|
||||
];
|
||||
$allocated += $amount;
|
||||
}
|
||||
|
||||
// 尾差归平台
|
||||
if ($allocated < $totalFen) {
|
||||
$diff = $totalFen - $allocated;
|
||||
foreach ($details as &$d) {
|
||||
if ($d['receiverType'] === 'platform') { $d['amount'] += $diff; break; }
|
||||
}
|
||||
unset($d);
|
||||
}
|
||||
|
||||
// 写主记录
|
||||
$recordId = Db::name('profit_sharing_records')->insertGetId([
|
||||
'orderSn' => $orderSn,
|
||||
'orderId' => (int) ($extra['orderId'] ?? 0) ?: null,
|
||||
'productType' => $productType,
|
||||
'totalAmount' => $totalFen,
|
||||
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
|
||||
'status' => 'processing',
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
|
||||
// 联动现有分销系统:把 distributor_l1/l2 的金额写进 mbti_commission_records
|
||||
try {
|
||||
foreach ($details as $d) {
|
||||
if (in_array($d['receiverType'], ['distributor_l1', 'distributor_l2'], true)
|
||||
&& (int) $d['amount'] > 0 && !empty($d['account'])) {
|
||||
self::createCommissionRecord(
|
||||
(int) $d['account'],
|
||||
$buyerUserId,
|
||||
$d['receiverType'] === 'distributor_l1' ? 1 : 2,
|
||||
(int) $d['amount'],
|
||||
(float) $d['ratio'],
|
||||
$totalFen,
|
||||
$orderSn,
|
||||
(int) ($extra['orderId'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ProfitSharingService commission hook failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
Db::name('profit_sharing_records')->where('id', $recordId)->update([
|
||||
'status' => 'completed',
|
||||
'processedAt' => time(),
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'completed',
|
||||
'recordId' => $recordId,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveRule(string $productType): array
|
||||
{
|
||||
$row = Db::name('profit_sharing_rules')
|
||||
->where('productType', $productType)
|
||||
->where('status', 'active')
|
||||
->find();
|
||||
if (!$row) {
|
||||
$row = Db::name('profit_sharing_rules')
|
||||
->where('productType', 'default')
|
||||
->where('status', 'active')
|
||||
->find();
|
||||
}
|
||||
if (!$row) {
|
||||
return ['receivers' => [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]]];
|
||||
}
|
||||
$receivers = json_decode($row['receivers'] ?? '[]', true) ?: [];
|
||||
return ['receivers' => $receivers];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一级/二级分销人(复用 mbti_distribution_bindings)
|
||||
* @return array [inviterL1_userId|null, inviterL2_userId|null]
|
||||
*/
|
||||
private static function resolveInviters(int $buyerUserId): array
|
||||
{
|
||||
if ($buyerUserId <= 0) return [null, null];
|
||||
try {
|
||||
$now = time();
|
||||
$bind1 = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $buyerUserId)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', $now)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$bind1) return [null, null];
|
||||
$l1 = (int) $bind1['inviterId'];
|
||||
$bind2 = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $l1)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', $now)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$l2 = $bind2 ? (int) $bind2['inviterId'] : null;
|
||||
return [$l1, $l2];
|
||||
} catch (\Throwable $e) {
|
||||
return [null, null];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 mbti_commission_records(复用项目已有的分销佣金表结构)
|
||||
* 若该表结构/字段命名不同,请在本方法内做适配;当前按 mbti_data.sql 中看到的字段写入
|
||||
*/
|
||||
private static function createCommissionRecord(
|
||||
int $inviterId,
|
||||
int $inviteeId,
|
||||
int $level,
|
||||
int $amountFen,
|
||||
float $rate,
|
||||
int $orderTotalFen,
|
||||
string $orderSn,
|
||||
int $orderId
|
||||
): void {
|
||||
$now = time();
|
||||
// 防重:相同 orderSn + inviterId + level 只插一次
|
||||
$dup = Db::name('commission_records')
|
||||
->where('orderSn', $orderSn)
|
||||
->where('inviterId', $inviterId)
|
||||
->where('level', $level)
|
||||
->find();
|
||||
if ($dup) return;
|
||||
|
||||
// 字段按 mbti_commission_records 实际结构写;amountFen 存分、amount 存元(容错)
|
||||
$data = [
|
||||
'inviterId' => $inviterId,
|
||||
'inviteeId' => $inviteeId,
|
||||
'level' => $level,
|
||||
'rate' => $rate,
|
||||
'amount' => round($amountFen / 100, 2),
|
||||
'amountFen' => $amountFen,
|
||||
'orderId' => $orderId ?: null,
|
||||
'orderSn' => $orderSn,
|
||||
'orderAmountFen'=> $orderTotalFen,
|
||||
'status' => 'pending',
|
||||
'scope' => 'personal',
|
||||
'source' => 'ai_deep_report',
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
|
||||
try {
|
||||
Db::name('commission_records')->insert($data);
|
||||
} catch (\Throwable $e) {
|
||||
// 字段不匹配时退化:只写关键字段
|
||||
Log::warning('commission_records insert fallback: ' . $e->getMessage());
|
||||
try {
|
||||
Db::name('commission_records')->insert([
|
||||
'inviterId' => $inviterId,
|
||||
'inviteeId' => $inviteeId,
|
||||
'level' => $level,
|
||||
'rate' => $rate,
|
||||
'amount' => round($amountFen / 100, 2),
|
||||
'status' => 'pending',
|
||||
'orderId' => $orderId ?: null,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
} catch (\Throwable $e2) {
|
||||
Log::error('commission_records insert failed: ' . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
441
api/app/common/service/SoulArticleService.php
Normal file
441
api/app/common/service/SoulArticleService.php
Normal file
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\SoulArticle as SoulArticleModel;
|
||||
use app\model\SystemConfig as SystemConfigModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* Soul 文章采集与推荐服务
|
||||
*
|
||||
* 数据源:一场 soul 创业实验 对外 API(默认 base 见 resolveConfig)
|
||||
* 采集方式:仅本服务内 curl HTTP(S) 请求上述接口;不写磁盘拉取、不经 SSH、不连对方服务器 shell
|
||||
* 鉴权:system_config.key=`soul_api` 的 {baseUrl, token};环境变量 SOUL_API_BASE / SOUL_API_TOKEN 兜底
|
||||
*/
|
||||
class SoulArticleService
|
||||
{
|
||||
/**
|
||||
* 按关键词搜索 Soul 内容并写入本地文章池
|
||||
*/
|
||||
public static function syncByKeyword(string $keyword, int $limit = 10, string $tag = 'MBTI'): array
|
||||
{
|
||||
$keyword = trim($keyword);
|
||||
if ($keyword === '') {
|
||||
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => '关键词不能为空'];
|
||||
}
|
||||
|
||||
$cfg = self::resolveConfig();
|
||||
if ($cfg['baseUrl'] === '') {
|
||||
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
|
||||
}
|
||||
$base = rtrim($cfg['baseUrl'], '/');
|
||||
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($cfg['token'] !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
|
||||
}
|
||||
|
||||
// 优先尝试通用文章接口(若支持 keyword)
|
||||
$url = $base . '/api/articles?' . http_build_query([
|
||||
'keyword' => $keyword,
|
||||
'limit' => $limit,
|
||||
'sort' => 'publishedAt:desc',
|
||||
]);
|
||||
[$response] = self::httpGet($url, $headers);
|
||||
$data = is_string($response) ? json_decode($response, true) : null;
|
||||
$list = [];
|
||||
if (is_array($data)) {
|
||||
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
|
||||
$list = $data['data']['list'];
|
||||
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
|
||||
$list = $data['data'];
|
||||
} elseif (isset($data['items']) && is_array($data['items'])) {
|
||||
$list = $data['items'];
|
||||
} elseif (isset($data[0])) {
|
||||
$list = $data;
|
||||
}
|
||||
}
|
||||
|
||||
// 若无结果,回退到 book API 全量抓取后关键词过滤
|
||||
if (empty($list)) {
|
||||
$list = self::fetchFromSoulBookApi($base, max($limit * 3, 30), $tag, $keyword);
|
||||
}
|
||||
|
||||
return self::upsertArticles($list, $tag, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Soul 后台拉取最新 N 篇 MBTI 主题文章并写入本地 soul_articles 表
|
||||
*
|
||||
* @return array ['fetched'=>int, 'created'=>int, 'updated'=>int, 'items'=>[…]]
|
||||
*/
|
||||
public static function syncLatest(int $limit = 10, string $tag = 'MBTI'): array
|
||||
{
|
||||
$cfg = self::resolveConfig();
|
||||
if ($cfg['baseUrl'] === '') {
|
||||
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
|
||||
}
|
||||
|
||||
$base = rtrim($cfg['baseUrl'], '/');
|
||||
$url = $base . '/api/articles';
|
||||
$params = [
|
||||
'tag' => $tag,
|
||||
'limit' => $limit,
|
||||
'sort' => 'publishedAt:desc',
|
||||
];
|
||||
$url .= '?' . http_build_query($params);
|
||||
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($cfg['token'] !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
|
||||
}
|
||||
|
||||
[$response, $httpCode, $err] = self::httpGet($url, $headers);
|
||||
|
||||
if ($response === false || $response === '') {
|
||||
Log::warning("SoulArticleService sync failed: http={$httpCode} err={$err}");
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
// 兼容常见返回结构:{data:{list:[]}} / {data:[]} / {items:[]}
|
||||
$list = [];
|
||||
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
|
||||
$list = $data['data']['list'];
|
||||
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
|
||||
$list = $data['data'];
|
||||
} elseif (isset($data['items']) && is_array($data['items'])) {
|
||||
$list = $data['items'];
|
||||
} elseif (isset($data[0])) {
|
||||
$list = $data;
|
||||
}
|
||||
|
||||
// 兼容「一场 soul 创业实验」新接口:/api/miniprogram/book/*
|
||||
if (empty($list)) {
|
||||
$list = self::fetchFromSoulBookApi($base, $limit, $tag);
|
||||
}
|
||||
|
||||
return self::upsertArticles($list, $tag, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动推送(傻瓜模式):检查 last_sync_at,超过 intervalSec 则自动执行一次采集
|
||||
* - 非阻塞:调用方可忽略返回值
|
||||
* - 配置:system_config.key='soul_article_auto_sync' {enabled, intervalSec, limit}
|
||||
* @return array ['didSync'=>bool, ...syncResult]
|
||||
*/
|
||||
public static function autoSyncIfStale(): array
|
||||
{
|
||||
try {
|
||||
$cfgRow = SystemConfigModel::where('key', 'soul_article_auto_sync')->find();
|
||||
$enabled = true;
|
||||
$intervalSec = 3600;
|
||||
$limit = 10;
|
||||
if ($cfgRow && !empty($cfgRow->value)) {
|
||||
$v = $cfgRow->value;
|
||||
if (is_string($v)) {
|
||||
$d = json_decode($v, true);
|
||||
if (is_array($d)) $v = $d;
|
||||
}
|
||||
if (is_array($v)) {
|
||||
$enabled = !isset($v['enabled']) ? true : (bool) $v['enabled'];
|
||||
$intervalSec = (int) ($v['intervalSec'] ?? 3600);
|
||||
$limit = (int) ($v['limit'] ?? 10);
|
||||
}
|
||||
}
|
||||
if (!$enabled) return ['didSync' => false, 'reason' => 'disabled'];
|
||||
|
||||
$lastRow = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
|
||||
$last = 0;
|
||||
if ($lastRow && !empty($lastRow->value)) {
|
||||
$last = is_numeric($lastRow->value) ? (int) $lastRow->value : (int) strtotime($lastRow->value);
|
||||
}
|
||||
$now = time();
|
||||
if ($last > 0 && ($now - $last) < $intervalSec) {
|
||||
return ['didSync' => false, 'lastAt' => $last, 'reason' => 'fresh'];
|
||||
}
|
||||
|
||||
$r = self::syncLatest($limit, 'MBTI');
|
||||
|
||||
// 写回 last sync(即使采集 0 篇也写回,避免高频重试)
|
||||
$val = (string) $now;
|
||||
$exist = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
|
||||
if ($exist) {
|
||||
$exist->value = $val;
|
||||
$exist->save();
|
||||
} else {
|
||||
SystemConfigModel::create([
|
||||
'key' => 'soul_article_last_sync_at',
|
||||
'value' => $val,
|
||||
'description' => 'Soul 文章最近一次自动采集时间戳',
|
||||
]);
|
||||
}
|
||||
return array_merge(['didSync' => true, 'lastAt' => $now], $r);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SoulArticleService autoSync failed: ' . $e->getMessage());
|
||||
return ['didSync' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前已推荐(最多 3 篇)
|
||||
*/
|
||||
public static function getRecommended(int $limit = 3): array
|
||||
{
|
||||
// 仅返回后台「推荐位」文章,不用候选池补齐(与超管「当前推荐」严格一致)
|
||||
$rows = SoulArticleModel::where('isRecommended', 1)
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return array_map(function ($r) {
|
||||
$pub = (int) ($r['publishedAt'] ?? 0);
|
||||
$upd = (int) ($r['updatedAt'] ?? 0);
|
||||
// 避免 0 / 无效时间戳被格式化成 1970-01-01
|
||||
$dateStr = '';
|
||||
if ($pub > 946684800) {
|
||||
$dateStr = date('Y-m-d', $pub);
|
||||
} elseif ($upd > 946684800) {
|
||||
$dateStr = date('Y-m-d', $upd);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $r['id'],
|
||||
'sourceId' => $r['sourceId'],
|
||||
'title' => $r['title'],
|
||||
'cover' => $r['cover'],
|
||||
'url' => $r['url'],
|
||||
'summary' => $r['summary'],
|
||||
'author' => $r['author'],
|
||||
'tag' => $r['tag'],
|
||||
'publishedAt' => $dateStr,
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某篇文章设为推荐(至多 3 篇;超出时自动顶掉最老的一篇)
|
||||
*/
|
||||
public static function recommend(int $articleId): array
|
||||
{
|
||||
$article = SoulArticleModel::find($articleId);
|
||||
if (!$article) {
|
||||
return ['ok' => false, 'message' => '文章不存在'];
|
||||
}
|
||||
if ($article->isRecommended) {
|
||||
$article->isRecommended = 0;
|
||||
$article->save();
|
||||
return ['ok' => true, 'message' => '已取消推荐', 'isRecommended' => false];
|
||||
}
|
||||
|
||||
$current = SoulArticleModel::where('isRecommended', 1)->order('recommendedOrder', 'asc')->select()->toArray();
|
||||
if (count($current) >= 3) {
|
||||
// 顶掉最老的一篇
|
||||
$oldest = end($current);
|
||||
$old = SoulArticleModel::find($oldest['id']);
|
||||
if ($old) {
|
||||
$old->isRecommended = 0;
|
||||
$old->save();
|
||||
}
|
||||
}
|
||||
|
||||
$maxOrder = (int) SoulArticleModel::where('isRecommended', 1)->max('recommendedOrder');
|
||||
$article->isRecommended = 1;
|
||||
$article->recommendedOrder = $maxOrder + 1;
|
||||
$article->save();
|
||||
|
||||
return ['ok' => true, 'message' => '已设为推荐', 'isRecommended' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* soul_api 配置:system_config.key='soul_api' / env 兜底
|
||||
*/
|
||||
private static function resolveConfig(): array
|
||||
{
|
||||
$baseUrl = trim((string) getenv('SOUL_API_BASE'));
|
||||
$token = trim((string) getenv('SOUL_API_TOKEN'));
|
||||
|
||||
$row = SystemConfigModel::where('key', 'soul_api')->find();
|
||||
if ($row && !empty($row->value)) {
|
||||
$v = $row->value;
|
||||
if (is_string($v)) {
|
||||
$decoded = json_decode($v, true);
|
||||
if (is_array($decoded)) $v = $decoded;
|
||||
}
|
||||
if (is_array($v)) {
|
||||
if (!empty($v['baseUrl'])) $baseUrl = (string) $v['baseUrl'];
|
||||
if (!empty($v['token'])) $token = (string) $v['token'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = 'https://soulapi.quwanzhi.com';
|
||||
}
|
||||
|
||||
return ['baseUrl' => $baseUrl, 'token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Soul 小程序图书接口拉取最新章节,映射成文章池结构(用于 /api/articles 不可用时兜底)
|
||||
*/
|
||||
private static function fetchFromSoulBookApi(string $baseUrl, int $limit, string $tag, string $keyword = ''): array
|
||||
{
|
||||
$partsUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/parts';
|
||||
[$rawParts] = self::httpGet($partsUrl, ['Accept: application/json']);
|
||||
$partsData = is_string($rawParts) ? json_decode($rawParts, true) : null;
|
||||
if (!is_array($partsData)) return [];
|
||||
$parts = $partsData['parts'] ?? $partsData['data'] ?? [];
|
||||
if (!is_array($parts) || empty($parts)) return [];
|
||||
|
||||
$chapters = [];
|
||||
foreach ($parts as $p) {
|
||||
$partId = (string) ($p['id'] ?? '');
|
||||
if ($partId === '') continue;
|
||||
$chaptersUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/chapters-by-part?partId=' . rawurlencode($partId);
|
||||
[$rawRows] = self::httpGet($chaptersUrl, ['Accept: application/json']);
|
||||
$rowsData = is_string($rawRows) ? json_decode($rawRows, true) : null;
|
||||
if (!is_array($rowsData)) continue;
|
||||
$rows = $rowsData['data'] ?? $rowsData['list'] ?? [];
|
||||
if (!is_array($rows)) continue;
|
||||
foreach ($rows as $row) {
|
||||
$id = (string) ($row['id'] ?? '');
|
||||
$title = trim((string) ($row['sectionTitle'] ?? $row['title'] ?? ''));
|
||||
if ($id === '' || $title === '') continue;
|
||||
// 只抓 MBTI 相关,减少噪音;标题无 MBTI 时保留“第x场”高频经营内容
|
||||
$lc = mb_strtolower($title, 'UTF-8');
|
||||
if (mb_strpos($lc, 'mbti') === false && mb_strpos($title, '第') === false) {
|
||||
continue;
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$kw = mb_strtolower($keyword, 'UTF-8');
|
||||
$summary = trim((string) ($row['summary'] ?? ''));
|
||||
$haystack = mb_strtolower($title . ' ' . $summary, 'UTF-8');
|
||||
if (mb_strpos($haystack, $kw) === false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$publishedAt = 0;
|
||||
$pubRaw = $row['updatedAt'] ?? ($row['createdAt'] ?? 0);
|
||||
if (is_numeric($pubRaw)) {
|
||||
$publishedAt = (int) $pubRaw;
|
||||
} elseif (is_string($pubRaw) && trim($pubRaw) !== '') {
|
||||
$t = strtotime((string) $pubRaw);
|
||||
if ($t) $publishedAt = (int) $t;
|
||||
}
|
||||
// 异常时间戳(如 0 / 1970)兜底当前时间
|
||||
if ($publishedAt < 946684800) {
|
||||
$publishedAt = time();
|
||||
}
|
||||
$cover = (string) ($row['coverUrl'] ?? $row['cover'] ?? '');
|
||||
$summary = trim((string) ($row['summary'] ?? ''));
|
||||
$chapters[] = [
|
||||
'id' => $id,
|
||||
'title' => $title,
|
||||
'url' => rtrim($baseUrl, '/') . '/read/' . rawurlencode($id),
|
||||
'cover' => $cover,
|
||||
'summary' => $summary,
|
||||
'author' => '一场 soul 创业实验',
|
||||
'tag' => $tag,
|
||||
'publishedAt' => $publishedAt ?: time(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($chapters, function ($a, $b) {
|
||||
return (int)($b['publishedAt'] ?? 0) <=> (int)($a['publishedAt'] ?? 0);
|
||||
});
|
||||
if (count($chapters) > $limit) {
|
||||
$chapters = array_slice($chapters, 0, $limit);
|
||||
}
|
||||
return $chapters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一写入文章池(幂等按 sourceId)
|
||||
*/
|
||||
private static function upsertArticles(array $list, string $tag, int $limit): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$items = [];
|
||||
$now = time();
|
||||
|
||||
if (!empty($list) && count($list) > $limit) {
|
||||
$list = array_slice($list, 0, $limit);
|
||||
}
|
||||
|
||||
foreach ($list as $raw) {
|
||||
$sourceId = (string) ($raw['id'] ?? $raw['sourceId'] ?? $raw['articleId'] ?? '');
|
||||
$title = trim((string) ($raw['title'] ?? ''));
|
||||
$url2 = trim((string) ($raw['url'] ?? $raw['link'] ?? $raw['shareUrl'] ?? ''));
|
||||
if ($sourceId === '' || $title === '' || $url2 === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cover = trim((string) ($raw['cover'] ?? $raw['coverUrl'] ?? $raw['thumbnail'] ?? ''));
|
||||
$summary = trim((string) ($raw['summary'] ?? $raw['excerpt'] ?? $raw['description'] ?? ''));
|
||||
$author = trim((string) ($raw['author'] ?? ($raw['authorName'] ?? '')));
|
||||
$publishedAt = 0;
|
||||
$pub = $raw['publishedAt'] ?? ($raw['createdAt'] ?? null);
|
||||
if (is_numeric($pub)) {
|
||||
$publishedAt = (int) $pub > 2000000000 ? (int)($pub / 1000) : (int) $pub;
|
||||
} elseif (is_string($pub) && $pub !== '') {
|
||||
$t = strtotime($pub);
|
||||
if ($t) $publishedAt = $t;
|
||||
}
|
||||
|
||||
if ($publishedAt < 946684800) {
|
||||
$publishedAt = $now;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'title' => mb_substr($title, 0, 250, 'UTF-8'),
|
||||
'cover' => $cover,
|
||||
'url' => $url2,
|
||||
'summary' => mb_substr($summary, 0, 500, 'UTF-8'),
|
||||
'author' => mb_substr($author, 0, 60, 'UTF-8'),
|
||||
'tag' => $tag,
|
||||
'publishedAt' => $publishedAt ?: $now,
|
||||
];
|
||||
|
||||
$existed = SoulArticleModel::where('sourceId', $sourceId)->find();
|
||||
if ($existed) {
|
||||
$existed->save($payload);
|
||||
$updated++;
|
||||
} else {
|
||||
$payload['sourceId'] = $sourceId;
|
||||
SoulArticleModel::create($payload);
|
||||
$created++;
|
||||
}
|
||||
$items[] = array_merge(['sourceId' => $sourceId], $payload);
|
||||
}
|
||||
|
||||
return ['fetched' => count($list), 'created' => $created, 'updated' => $updated, 'items' => $items];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 HTTP GET(返回 body/httpCode/error)
|
||||
*/
|
||||
private static function httpGet(string $url, array $headers = []): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return [$response, $httpCode, $err];
|
||||
}
|
||||
}
|
||||
152
api/app/common/service/UserJourneyService.php
Normal file
152
api/app/common/service/UserJourneyService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 用户旅程:从 analytics_events 聚合最近行为,供飞书获客、出站 Hook、存客宝备注等复用。
|
||||
*/
|
||||
class UserJourneyService
|
||||
{
|
||||
/**
|
||||
* @return string[] 每条一行展示文案(与 FeishuLeadWebhookService 历史格式一致)
|
||||
*/
|
||||
public static function recentBehaviorLines(int $userId, int $limit = 8): array
|
||||
{
|
||||
if ($userId <= 0 || $limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
if (is_array($r)) {
|
||||
$out[] = self::formatAnalyticsLine($r);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户管理摘要一行(平台用户 ID、归属企业),用于 CRM 备注与推送文案。
|
||||
*/
|
||||
public static function managementSummaryLine(int $userId, int $testResultEnterpriseId = 0): string
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return '';
|
||||
}
|
||||
$parts = ['平台用户ID:' . $userId];
|
||||
$eid = $testResultEnterpriseId > 0 ? $testResultEnterpriseId : 0;
|
||||
if ($eid <= 0) {
|
||||
try {
|
||||
$bound = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
|
||||
$eid = (int) ($bound ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
$eid = 0;
|
||||
}
|
||||
}
|
||||
if ($eid > 0) {
|
||||
try {
|
||||
$name = Db::name('enterprises')->where('id', $eid)->value('name');
|
||||
} catch (\Throwable $e) {
|
||||
$name = null;
|
||||
}
|
||||
$label = $name !== null && (string) $name !== '' ? (string) $name : '企业';
|
||||
$parts[] = '归属企业:' . $label . '(ID' . $eid . ')';
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $r analytics_events 一行
|
||||
*/
|
||||
public static function formatAnalyticsLine(array $r): string
|
||||
{
|
||||
$name = (string) ($r['eventName'] ?? '');
|
||||
$path = trim((string) ($r['pagePath'] ?? ''));
|
||||
$props = [];
|
||||
if (!empty($r['propsJson'])) {
|
||||
$decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
|
||||
$props = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
$labelMap = [
|
||||
'page_view' => '浏览页面',
|
||||
'button_click' => '按钮点击',
|
||||
'click_pay' => '发起支付',
|
||||
'click_recharge' => '点击充值',
|
||||
];
|
||||
$label = $labelMap[$name] ?? $name;
|
||||
$detail = '';
|
||||
if ($name === 'page_view' && $path !== '') {
|
||||
$detail = $path;
|
||||
}
|
||||
if (isset($props['action']) && (string) $props['action'] !== '') {
|
||||
$detail = (string) $props['action'];
|
||||
if (!empty($props['productType'])) {
|
||||
$detail .= ' · ' . (string) $props['productType'];
|
||||
}
|
||||
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
|
||||
$detail = (string) $props['label'];
|
||||
} elseif ($path !== '' && $detail === '') {
|
||||
$detail = $path;
|
||||
}
|
||||
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
|
||||
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
|
||||
if (!$ts && !empty($r['createdAt'])) {
|
||||
$ts = strtotime((string) $r['createdAt']) * 1000;
|
||||
}
|
||||
if ($ts) {
|
||||
$line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
public static function humanTimeAgoCn(int $clientTsMs): string
|
||||
{
|
||||
$now = (int) (microtime(true) * 1000);
|
||||
$sec = max(0, (int) (($now - $clientTsMs) / 1000));
|
||||
if ($sec < 60) {
|
||||
return '刚刚';
|
||||
}
|
||||
if ($sec < 3600) {
|
||||
return (int) floor($sec / 60) . '分钟前';
|
||||
}
|
||||
if ($sec < 86400) {
|
||||
return (int) floor($sec / 3600) . '小时前';
|
||||
}
|
||||
|
||||
return (int) floor($sec / 86400) . '天前';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将旅程行压成一段备注(用于存客宝 remark 等),超长截断。
|
||||
*
|
||||
* @param string[] $lines
|
||||
*/
|
||||
public static function journeyLinesToRemarkBlock(array $lines, int $maxChars = 600): string
|
||||
{
|
||||
if (count($lines) === 0) {
|
||||
return '';
|
||||
}
|
||||
$body = '用户旅程:' . implode(';', $lines);
|
||||
if (function_exists('mb_strlen') && mb_strlen($body) > $maxChars) {
|
||||
return mb_substr($body, 0, $maxChars) . '…';
|
||||
}
|
||||
if (!function_exists('mb_strlen') && strlen($body) > $maxChars) {
|
||||
return substr($body, 0, $maxChars) . '…';
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ class AppUser extends BaseController
|
||||
$pageSize = (int) Request::param('pageSize', 20);
|
||||
$pageSize = min(max($pageSize, 1), 100);
|
||||
$keyword = trim(Request::param('keyword', ''));
|
||||
$coldFaceLevelRaw = Request::param('coldFaceLevel', '');
|
||||
$coldFaceLevels = [];
|
||||
if (is_array($coldFaceLevelRaw)) {
|
||||
$coldFaceLevels = array_values(array_filter(array_map('strval', $coldFaceLevelRaw)));
|
||||
} elseif (is_string($coldFaceLevelRaw) && $coldFaceLevelRaw !== '') {
|
||||
$coldFaceLevels = array_values(array_filter(array_map('trim', explode(',', $coldFaceLevelRaw))));
|
||||
}
|
||||
$coldFaceLevels = array_values(array_intersect($coldFaceLevels, ['cold', 'neutral', 'warm']));
|
||||
|
||||
$where = [];
|
||||
if ($keyword !== '') {
|
||||
@@ -72,6 +80,19 @@ class AppUser extends BaseController
|
||||
->join([$poolSql => 'p'], 'w.id = p.userId')
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid');
|
||||
|
||||
if (!empty($coldFaceLevels)) {
|
||||
try {
|
||||
$cfSql = Db::name('user_profile')
|
||||
->whereRaw('enterpriseId = ' . $eid)
|
||||
->whereIn('coldFaceLevel', $coldFaceLevels)
|
||||
->field('userId')
|
||||
->buildSql(true);
|
||||
$baseQuery->join([$cfSql => 'cf'], 'w.id = cf.userId');
|
||||
} catch (\Throwable $e) {
|
||||
// coldFace 字段不存在时忽略筛选
|
||||
}
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . addcslashes($keyword, '%_\\') . '%';
|
||||
$baseQuery->whereRaw(
|
||||
@@ -215,6 +236,31 @@ class AppUser extends BaseController
|
||||
} catch (\Throwable $e) {
|
||||
$payStats = [];
|
||||
}
|
||||
|
||||
// 冷脸分字段(容错:迁移未执行时 coldFace* 字段缺失,查询异常则视为全空)
|
||||
$coldFaceMap = [];
|
||||
try {
|
||||
$cfQuery = Db::name('user_profile')->where('userId', 'in', $ids);
|
||||
if ($enterpriseId) {
|
||||
$cfQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$cfRows = $cfQuery
|
||||
->field('userId, coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($cfRows as $r) {
|
||||
$uid = (int) ($r['userId'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$coldFaceMap[$uid] = [
|
||||
'score' => isset($r['coldFaceScore']) && $r['coldFaceScore'] !== null ? (int) $r['coldFaceScore'] : null,
|
||||
'level' => $r['coldFaceLevel'] ?? null,
|
||||
'updatedAt' => isset($r['coldFaceUpdatedAt']) ? (int) $r['coldFaceUpdatedAt'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$coldFaceMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
@@ -236,6 +282,18 @@ class AppUser extends BaseController
|
||||
$pay = $payStats[$id] ?? null;
|
||||
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
|
||||
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
|
||||
|
||||
$cf = $coldFaceMap[$id] ?? null;
|
||||
if ((!$cf || $cf['score'] === null) && !empty($testsForUser)) {
|
||||
$calc = $this->calcColdFace($testsForUser);
|
||||
if ($calc) {
|
||||
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
|
||||
$cf = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
|
||||
}
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
@@ -322,6 +380,35 @@ class AppUser extends BaseController
|
||||
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
|
||||
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
|
||||
|
||||
// 冷脸分字段(详情)
|
||||
$coldFace = null;
|
||||
try {
|
||||
$cfQuery = Db::name('user_profile')->where('userId', $id);
|
||||
if ($enterpriseId) {
|
||||
$cfQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$cfRow = $cfQuery->field('coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')->find();
|
||||
if ($cfRow) {
|
||||
$coldFace = [
|
||||
'score' => $cfRow['coldFaceScore'] !== null ? (int) $cfRow['coldFaceScore'] : null,
|
||||
'level' => $cfRow['coldFaceLevel'] ?? null,
|
||||
'updatedAt' => isset($cfRow['coldFaceUpdatedAt']) ? (int) $cfRow['coldFaceUpdatedAt'] : null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$coldFace = null;
|
||||
}
|
||||
if ((!$coldFace || $coldFace['score'] === null) && !empty($tests)) {
|
||||
$calc = $this->calcColdFace($tests);
|
||||
if ($calc) {
|
||||
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
|
||||
$coldFace = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
|
||||
}
|
||||
}
|
||||
$data['coldFaceScore'] = $coldFace['score'] ?? null;
|
||||
$data['coldFaceLevel'] = $coldFace['level'] ?? null;
|
||||
$data['coldFaceUpdatedAt'] = $coldFace['updatedAt'] ?? null;
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class Finance extends BaseController
|
||||
$baseOrderQuery = Db::name('orders')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->whereIn('status', ['paid', 'completed'])
|
||||
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp']);
|
||||
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp', 'ai_deep_report']);
|
||||
|
||||
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
|
||||
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
|
||||
@@ -54,16 +54,44 @@ class Finance extends BaseController
|
||||
->where('status', 'frozen')
|
||||
->sum('commissionFen') ?? 0);
|
||||
|
||||
// 本月累计扣款(平台扣费 + 佣金扣减)
|
||||
$monthConsumeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'consume')
|
||||
->where('createdAt', '>=', $monthStart)
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
// 今日扣款
|
||||
$todayConsumeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'consume')
|
||||
->where('createdAt', '>=', $todayStart)
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
// 建议充值:按本月实际天数日均 × 14 天,低于 500 元按 500 元兜底;余额足够则 0
|
||||
$balanceFen = (int) ($enterprise['balance'] ?? 0);
|
||||
$daysElapsed = max(1, (int) date('j'));
|
||||
$avgDaily = $monthConsumeFen > 0 ? intval($monthConsumeFen / $daysElapsed) : 0;
|
||||
$suggestThreshold = $avgDaily * 14;
|
||||
$suggestRechargeFen = 0;
|
||||
if ($avgDaily > 0 && $balanceFen < $suggestThreshold) {
|
||||
$suggestRechargeFen = max($suggestThreshold - $balanceFen, 50000); // ≥ 500 元
|
||||
}
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => $enterprise['name'] ?? '',
|
||||
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
|
||||
'balanceFen' => $balanceFen,
|
||||
'totalIncomeFen' => $totalIncomeFen,
|
||||
'todayIncomeFen' => $todayIncomeFen,
|
||||
'monthIncomeFen' => $monthIncomeFen,
|
||||
'manualRechargeFen' => $manualRechargeFen,
|
||||
'frozenCommissionFen' => $frozenCommissionFen,
|
||||
'paidOrderCount' => $paidOrderCount,
|
||||
'monthConsumeFen' => $monthConsumeFen,
|
||||
'todayConsumeFen' => $todayConsumeFen,
|
||||
'avgDailyConsumeFen' => $avgDaily,
|
||||
'suggestRechargeFen' => $suggestRechargeFen,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取企业财务概览失败:' . $e->getMessage(), 500);
|
||||
|
||||
@@ -31,6 +31,7 @@ class Order extends BaseController
|
||||
$keyword = trim(Request::param('keyword', ''));
|
||||
$status = trim(Request::param('status', ''));
|
||||
$productType = trim(Request::param('productType', ''));
|
||||
$inviterId = (int) Request::param('inviterId', 0);
|
||||
|
||||
// 超管:全平台订单;其余管理员仅本企业
|
||||
$enterpriseId = null;
|
||||
@@ -53,6 +54,24 @@ class Order extends BaseController
|
||||
if ($productType !== '') {
|
||||
$query->where('productType', $productType);
|
||||
}
|
||||
if ($inviterId > 0) {
|
||||
// 通过分销绑定表过滤「该分销商带来的订单」:orders.userId = distribution_bindings.inviteeId,inviter=inviterId
|
||||
try {
|
||||
$inviteeIds = Db::name('distribution_bindings')
|
||||
->where('inviterId', $inviterId)
|
||||
->column('inviteeId');
|
||||
$inviteeIds = array_values(array_filter(array_map('intval', $inviteeIds ?: [])));
|
||||
if (empty($inviteeIds)) {
|
||||
return success([
|
||||
'list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize,
|
||||
'hasMore' => false, 'paidCompletedCount' => 0, 'totalRevenueFen' => 0,
|
||||
]);
|
||||
}
|
||||
$query->whereIn('userId', $inviteeIds);
|
||||
} catch (\Throwable $e) {
|
||||
// 表不存在或字段差异:忽略筛选
|
||||
}
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
if (is_numeric($keyword)) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
@@ -119,12 +138,58 @@ class Order extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 当前页订单的分销分润记录
|
||||
$commissionsByOrder = [];
|
||||
if (!empty($orderIds)) {
|
||||
try {
|
||||
$crRows = Db::name('commission_records')
|
||||
->where('orderId', 'in', $orderIds)
|
||||
->field('id, orderId, inviterId, commissionFen, rate, status, paidAt, createdAt')
|
||||
->order('createdAt', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$inviterIds = array_values(array_unique(array_filter(array_column($crRows, 'inviterId'))));
|
||||
$inviterMap = [];
|
||||
if (!empty($inviterIds)) {
|
||||
$inviters = Db::name('wechat_users')
|
||||
->where('id', 'in', $inviterIds)
|
||||
->field('id, nickname, phone')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($inviters as $iv) {
|
||||
$inviterMap[(int) $iv['id']] = $iv;
|
||||
}
|
||||
}
|
||||
foreach ($crRows as $cr) {
|
||||
$oid = (int) ($cr['orderId'] ?? 0);
|
||||
if ($oid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$iv = $inviterMap[(int) ($cr['inviterId'] ?? 0)] ?? null;
|
||||
$commissionsByOrder[$oid][] = [
|
||||
'id' => (int) $cr['id'],
|
||||
'inviterId' => (int) ($cr['inviterId'] ?? 0),
|
||||
'inviterName' => $iv['nickname'] ?? null,
|
||||
'inviterPhone' => $iv['phone'] ?? null,
|
||||
'commissionFen' => (int) ($cr['commissionFen'] ?? 0),
|
||||
'rate' => isset($cr['rate']) ? (float) $cr['rate'] : null,
|
||||
'status' => (string) ($cr['status'] ?? ''),
|
||||
'paidAt' => isset($cr['paidAt']) ? (int) $cr['paidAt'] : null,
|
||||
'createdAt' => isset($cr['createdAt']) ? (int) $cr['createdAt'] : null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$commissionsByOrder = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
$u = $usersMap[$uid] ?? null;
|
||||
$row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
|
||||
$row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
|
||||
$row['testData'] = $testsByOrder[$row['id']] ?? [];
|
||||
$row['commissions'] = $commissionsByOrder[$row['id']] ?? [];
|
||||
}
|
||||
|
||||
return success([
|
||||
|
||||
@@ -2,12 +2,115 @@
|
||||
namespace app\controller\admin\concern;
|
||||
|
||||
use app\common\PdpDiscResultText;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 从测试记录数组中解析 MBTI / DISC / PDP / 人脸子类型(与 AppUser 逻辑一致)
|
||||
*/
|
||||
trait ExtractsTestResults
|
||||
{
|
||||
/**
|
||||
* 基于测试记录推算冷脸分值(0-100)
|
||||
* - 面相 emotionScore/emotionNeutrality/microExpression 作为主输入
|
||||
* - MBTI I/E 作为补充(I 更冷,E 更暖)
|
||||
* 返回 ['score'=>int, 'level'=>'cold|neutral|warm'];如无任何线索返回 null
|
||||
*/
|
||||
public function calcColdFace(array $tests): ?array
|
||||
{
|
||||
$score = null; // 0-100
|
||||
$mbtiType = '';
|
||||
|
||||
foreach ($tests as $t) {
|
||||
$type = strtolower($t['testType'] ?? '');
|
||||
$raw = $t['result'] ?? ($t['resultData'] ?? '');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
continue;
|
||||
}
|
||||
$dec = json_decode($raw, true);
|
||||
if (!is_array($dec)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'face' && $score === null) {
|
||||
$face = is_array($dec['face'] ?? null) ? $dec['face'] : $dec;
|
||||
$emotion = null;
|
||||
foreach (['coldFaceScore', 'coldScore', 'emotionNeutrality', 'neutrality'] as $k) {
|
||||
if (isset($face[$k]) && is_numeric($face[$k])) {
|
||||
$emotion = (float) $face[$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($emotion === null && isset($face['emotionScore']) && is_numeric($face['emotionScore'])) {
|
||||
// emotionScore 越高越暖 → 冷脸分取反
|
||||
$emotion = 100 - (float) $face['emotionScore'];
|
||||
}
|
||||
if ($emotion === null && isset($face['microExpression']) && is_array($face['microExpression'])) {
|
||||
$mx = $face['microExpression'];
|
||||
$happy = is_numeric($mx['happy'] ?? null) ? (float) $mx['happy'] : 0;
|
||||
$neutral = is_numeric($mx['neutral'] ?? null) ? (float) $mx['neutral'] : 0;
|
||||
$total = $happy + $neutral + 1;
|
||||
$emotion = ($neutral / $total) * 100;
|
||||
}
|
||||
if ($emotion !== null) {
|
||||
if ($emotion <= 1) {
|
||||
$emotion = $emotion * 100;
|
||||
}
|
||||
$score = max(0, min(100, (int) round($emotion)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'mbti' && $mbtiType === '') {
|
||||
foreach (['mbtiType', 'type', 'result'] as $k) {
|
||||
$s = $this->coerceResultLabel($dec[$k] ?? null);
|
||||
if ($s !== '') {
|
||||
$mbtiType = strtoupper($s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($score === null && $mbtiType !== '') {
|
||||
$score = strpos($mbtiType, 'I') === 0 ? 62 : 42;
|
||||
}
|
||||
if ($score === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$level = 'neutral';
|
||||
if ($score > 65) {
|
||||
$level = 'cold';
|
||||
} elseif ($score < 35) {
|
||||
$level = 'warm';
|
||||
}
|
||||
|
||||
return ['score' => (int) $score, 'level' => $level];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写回 user_profile 的冷脸字段(存在才更新,否则跳过;失败静默)
|
||||
*/
|
||||
protected function writeColdFace(int $userId, ?int $enterpriseId, array $cold): void
|
||||
{
|
||||
if (!isset($cold['score'], $cold['level'])) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$query = Db::name('user_profile')->where('userId', $userId);
|
||||
if ($enterpriseId) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$query->update([
|
||||
'coldFaceScore' => (int) $cold['score'],
|
||||
'coldFaceLevel' => (string) $cold['level'],
|
||||
'coldFaceUpdatedAt' => time(),
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 字段不存在(未执行迁移)时直接忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果字段可能为数组/对象,禁止直接 (string) 强转导致 Array to string conversion
|
||||
*/
|
||||
|
||||
503
api/app/controller/api/AiChat.php
Normal file
503
api/app/controller/api/AiChat.php
Normal file
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiCallService;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\SoulArticleService;
|
||||
use app\model\AiConversation as AiConversationModel;
|
||||
use app\model\AiMessage as AiMessageModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 神仙 AI 聊天接口(小程序侧)
|
||||
*
|
||||
* 路由:/api/ai/*
|
||||
* 鉴权:auth(微信登录后 token)
|
||||
*/
|
||||
class AiChat extends BaseController
|
||||
{
|
||||
/** 每用户每日发送上限(超出则返回 429) */
|
||||
private const DAILY_LIMIT = 20;
|
||||
|
||||
/** 单次对话最多携带的历史上下文(user+assistant 轮数) */
|
||||
private const CONTEXT_TURNS = 8;
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat
|
||||
* body: { conversationId?: int, message: string }
|
||||
* 返回 { conversationId, message: {role, content, providerId, isDegraded, createdAt}, usageToday, dailyLimit }
|
||||
*/
|
||||
public function chat()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$message = trim((string) Request::post('message', ''));
|
||||
if ($message === '') return error('消息不能为空', 400);
|
||||
if (mb_strlen($message, 'UTF-8') > 800) {
|
||||
return error('消息过长,请精简到 800 字以内', 400);
|
||||
}
|
||||
|
||||
$conversationId = (int) Request::post('conversationId', 0);
|
||||
|
||||
// 每日限流(必须在 try 外先取 used,失败时返回 200+降级,避免未捕获异常导致 HTTP 500)
|
||||
$used = 0;
|
||||
try {
|
||||
$used = $this->incrementAndCheckDaily($userId, self::DAILY_LIMIT);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat incrementAndCheckDaily: ' . $e->getMessage());
|
||||
|
||||
return $this->chatDegradeResponse(
|
||||
$conversationId,
|
||||
0,
|
||||
'小神仙这边计数服务抖了一下,请稍后再发一条消息~'
|
||||
);
|
||||
}
|
||||
if ($used === -1) {
|
||||
return error('今日对话次数已用完,明天再来找我呀~', 429);
|
||||
}
|
||||
|
||||
$userMsgSaved = false;
|
||||
$conversation = null;
|
||||
try {
|
||||
// 拉取/新建对话(测评档案组装失败时不阻断聊天)
|
||||
$userContext = [
|
||||
'mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => '',
|
||||
];
|
||||
try {
|
||||
$userContext = AiCallService::fetchUserContext($userId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat fetchUserContext: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$conversation = $conversationId > 0
|
||||
? AiConversationModel::where('userId', $userId)->where('id', $conversationId)->find()
|
||||
: null;
|
||||
|
||||
$now = time();
|
||||
if (!$conversation) {
|
||||
$title = mb_substr($message, 0, 24, 'UTF-8');
|
||||
if ($title === false) {
|
||||
$title = mb_substr($message, 0, 24);
|
||||
}
|
||||
if (!is_string($title) || $title === '') {
|
||||
$title = '新对话';
|
||||
}
|
||||
$conversation = AiConversationModel::create([
|
||||
'userId' => $userId,
|
||||
'title' => $title,
|
||||
'mbtiType' => $userContext['mbtiType'] ?: '',
|
||||
'providerId' => '',
|
||||
'lastMessageAt' => $now,
|
||||
'messageCount' => 0,
|
||||
]);
|
||||
$conversationId = (int) $conversation->id;
|
||||
}
|
||||
|
||||
// 写入 user 消息
|
||||
AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'user',
|
||||
'content' => $message,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
'providerId' => '',
|
||||
'isDegraded' => 0,
|
||||
'createdAt' => $now,
|
||||
]);
|
||||
$userMsgSaved = true;
|
||||
|
||||
// 组装 messages:system + 最近 N 轮历史 + 本次 user
|
||||
try {
|
||||
$systemPrompt = AiCallService::buildSystemPrompt($userContext);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat buildSystemPrompt: ' . $e->getMessage());
|
||||
$systemPrompt = "你是「神仙 AI」,神仙团队 MBTI 小程序的专属伙伴。请亲切、简短地回答用户;禁止医疗诊断与投资建议。\n";
|
||||
}
|
||||
$history = AiMessageModel::where('conversationId', $conversationId)
|
||||
->order('id', 'desc')
|
||||
->limit(self::CONTEXT_TURNS * 2)
|
||||
->select()
|
||||
->toArray();
|
||||
$history = array_reverse($history);
|
||||
|
||||
$messages = [['role' => 'system', 'content' => $systemPrompt]];
|
||||
foreach ($history as $row) {
|
||||
if (!in_array($row['role'], ['user', 'assistant'], true)) {
|
||||
continue;
|
||||
}
|
||||
$messages[] = ['role' => $row['role'], 'content' => (string) $row['content']];
|
||||
}
|
||||
|
||||
$r = AiCallService::chat($messages, ['temperature' => 0.75, 'maxTokens' => 1024]);
|
||||
$assistantContent = $r['content'] !== '' ? $r['content'] : '(小神仙被问住了,换个问法试试?)';
|
||||
|
||||
// 写入 assistant 消息
|
||||
$msgRow = AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'tokensIn' => (int) ($r['tokensIn'] ?? 0),
|
||||
'tokensOut' => (int) ($r['tokensOut'] ?? 0),
|
||||
'providerId' => (string) ($r['providerId'] ?? ''),
|
||||
'isDegraded' => !empty($r['isDegraded']) ? 1 : 0,
|
||||
'createdAt' => time(),
|
||||
]);
|
||||
|
||||
// 更新对话元数据
|
||||
$conversation->lastMessageAt = time();
|
||||
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
|
||||
$conversation->providerId = (string) ($r['providerId'] ?? '');
|
||||
$conversation->save();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => (int) $msgRow->id,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'providerId' => $r['providerId'] ?? '',
|
||||
'isDegraded' => (bool) ($r['isDegraded'] ?? false),
|
||||
'createdAt' => (int) $msgRow->createdAt,
|
||||
],
|
||||
'usageToday' => $used,
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AiChat::chat 异常: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
|
||||
// 用户消息已落库时:降级返回一条助手回复,避免前端只看到 500
|
||||
if ($userMsgSaved && $conversationId > 0 && $conversation) {
|
||||
try {
|
||||
$assistantContent = '小神仙这边刚刚抖了一下(已自动记录)。你可以把问题缩短一点重发,或稍后再试~';
|
||||
$msgRow = AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => 1,
|
||||
'createdAt' => time(),
|
||||
]);
|
||||
$conversation->lastMessageAt = time();
|
||||
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
|
||||
$conversation->providerId = 'degrade';
|
||||
$conversation->save();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => (int) $msgRow->id,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => true,
|
||||
'createdAt' => (int) $msgRow->createdAt,
|
||||
],
|
||||
'usageToday' => $used,
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
} catch (\Throwable $e2) {
|
||||
Log::error('AiChat::chat 降级回复写入失败: ' . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 统一不再返回业务 code=500,前端只认 code=200;避免用户看到「服务异常(500)」
|
||||
return $this->chatDegradeResponse(
|
||||
$conversationId,
|
||||
$used,
|
||||
'小神仙服务暂时不可用,请稍后再试。如反复出现可在「我的」联系客服~'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天失败时仍返回 HTTP JSON code=200,由 message.isDegraded 标记,小程序可正常展示气泡
|
||||
*
|
||||
* @param int $conversationId 当前会话 id(未知则 0)
|
||||
* @param int $usageToday 今日已计次数(失败时可能为 0)
|
||||
* @param string $assistantText 助手可见文案
|
||||
*/
|
||||
private function chatDegradeResponse(int $conversationId, int $usageToday, string $assistantText)
|
||||
{
|
||||
$now = time();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => 0,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantText,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => true,
|
||||
'createdAt' => $now,
|
||||
],
|
||||
'usageToday' => max(0, $usageToday),
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/articles/recommended
|
||||
* 无需登录(也可以;这里先要求登录保持一致)
|
||||
*/
|
||||
public function recommendedArticles()
|
||||
{
|
||||
$display = AiChatArticleDisplayService::getSettings();
|
||||
$rows = [];
|
||||
if ($display['enabled']) {
|
||||
try { SoulArticleService::autoSyncIfStale(); } catch (\Throwable $e) {}
|
||||
$limit = (int) $display['maxShow'];
|
||||
$rows = SoulArticleService::getRecommended($limit);
|
||||
if (empty($rows)) {
|
||||
try { SoulArticleService::syncLatest(10, 'MBTI'); } catch (\Throwable $e) {}
|
||||
$rows = SoulArticleService::getRecommended($limit);
|
||||
}
|
||||
}
|
||||
|
||||
$resp = success([
|
||||
'list' => $rows,
|
||||
'recoCount' => count($rows),
|
||||
'display' => [
|
||||
'enabled' => $display['enabled'],
|
||||
'maxShow' => $display['maxShow'],
|
||||
'sectionExpandedDefault' => $display['sectionExpandedDefault'],
|
||||
],
|
||||
]);
|
||||
if (method_exists($resp, 'header')) {
|
||||
$resp->header([
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/articles/profile-teaser
|
||||
* 我的页底部:展示后台「当前推荐」排序第一的一篇 + 可配区块标题(无需登录)
|
||||
*/
|
||||
public function profileArticleTeaser()
|
||||
{
|
||||
$display = AiChatArticleDisplayService::getSettings();
|
||||
if (empty($display['profileRecoEnabled'])) {
|
||||
$resp = success([
|
||||
'enabled' => false,
|
||||
'sectionLabel' => $display['profileSectionLabel'] ?? '',
|
||||
'article' => null,
|
||||
]);
|
||||
} else {
|
||||
try {
|
||||
SoulArticleService::autoSyncIfStale();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$rows = SoulArticleService::getRecommended(1);
|
||||
if (empty($rows)) {
|
||||
try {
|
||||
SoulArticleService::syncLatest(10, 'MBTI');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$rows = SoulArticleService::getRecommended(1);
|
||||
}
|
||||
$article = !empty($rows[0]) ? $rows[0] : null;
|
||||
$resp = success([
|
||||
'enabled' => true,
|
||||
'sectionLabel' => $display['profileSectionLabel'] ?? '',
|
||||
'article' => $article,
|
||||
]);
|
||||
}
|
||||
if (method_exists($resp, 'header')) {
|
||||
$resp->header([
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/quick-questions
|
||||
* 基于当前用户 MBTI 类型返回快捷提问
|
||||
*/
|
||||
public function quickQuestions()
|
||||
{
|
||||
// 公开接口:无中间件时从 Authorization 解析微信用户(可选)
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) {
|
||||
$u = $this->resolveUser();
|
||||
if ($u && ($u['source'] ?? '') === 'wechat') {
|
||||
$userId = (int) ($u['user_id'] ?? $u['userId'] ?? 0);
|
||||
}
|
||||
}
|
||||
$ctx = AiCallService::fetchUserContext($userId);
|
||||
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions($ctx['mbtiType']));
|
||||
return success([
|
||||
'mbtiType' => $ctx['mbtiType'],
|
||||
'nickname' => $ctx['nickname'],
|
||||
'questions' => $qs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/conversations 分页取当前用户会话列表
|
||||
* query: page, pageSize
|
||||
*/
|
||||
public function conversations()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$page = max(1, (int) Request::get('page', 1));
|
||||
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
|
||||
|
||||
$query = AiConversationModel::where('userId', $userId);
|
||||
$total = $query->count();
|
||||
$list = $query
|
||||
->order('lastMessageAt', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/conversations/:id/messages
|
||||
*/
|
||||
public function messages()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$cid = (int) Request::param('id', 0);
|
||||
$conversation = AiConversationModel::where('userId', $userId)->where('id', $cid)->find();
|
||||
if (!$conversation) return error('会话不存在', 404);
|
||||
|
||||
$rows = AiMessageModel::where('conversationId', $cid)
|
||||
->order('id', 'asc')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function ($r) {
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
'role' => $r['role'],
|
||||
'content' => $r['content'],
|
||||
'providerId' => $r['providerId'],
|
||||
'isDegraded' => (bool) $r['isDegraded'],
|
||||
'createdAt' => (int) $r['createdAt'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return success([
|
||||
'conversation' => [
|
||||
'id' => (int) $conversation->id,
|
||||
'title' => (string) $conversation->title,
|
||||
'mbtiType' => (string) $conversation->mbtiType,
|
||||
'messageCount' => (int) $conversation->messageCount,
|
||||
'lastMessageAt' => (int) $conversation->lastMessageAt,
|
||||
],
|
||||
'messages' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/transcribe
|
||||
* 语音转文字:接受前端 audio 文件上传。
|
||||
* 当前占位:若未配置语音识别服务(ai_transcribe_provider),返回 501 + 降级提示,
|
||||
* 前端会降级到"请用键盘麦克风"。接入 ASR 后在此填充 provider 调用即可。
|
||||
*/
|
||||
public function transcribe()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$file = Request::file('audio');
|
||||
if (!$file) return error('未收到音频文件', 400);
|
||||
|
||||
// 读取 system_config.ai_transcribe_provider(未配置则降级)
|
||||
$cfgRow = Db::name('system_config')->where('key', 'ai_transcribe_provider')->find();
|
||||
if (!$cfgRow || empty($cfgRow['value'])) {
|
||||
return error('语音识别未开通,请使用键盘麦克风', 501);
|
||||
}
|
||||
|
||||
// TODO: 二期对接 ASR(如通义听悟/讯飞/whisper API)
|
||||
// 当前阶段:保持降级,避免伪造返回值骗用户
|
||||
return error('语音识别通道建设中,请使用键盘麦克风', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/articles/:id/click
|
||||
* 轻量记录点击,用于分析(失败不影响用户)
|
||||
*/
|
||||
public function articleClick()
|
||||
{
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id > 0) {
|
||||
try {
|
||||
Db::name('soul_articles')->where('id', $id)->inc('viewCount')->update();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
return success(null);
|
||||
}
|
||||
|
||||
/** 从 auth 中间件挂好的 request->user 中取出当前微信用户 id */
|
||||
private function currentUserId(): int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['source'] ?? '') !== 'wechat') return 0;
|
||||
return (int) ($user['user_id'] ?? $user['userId'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增今日用量,若已达上限返回 -1
|
||||
*/
|
||||
private function incrementAndCheckDaily(int $userId, int $limit): int
|
||||
{
|
||||
$dateStr = date('Y-m-d');
|
||||
$now = time();
|
||||
|
||||
$row = Db::name('ai_usage_daily')
|
||||
->where('userId', $userId)
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
|
||||
if (!$row) {
|
||||
try {
|
||||
Db::name('ai_usage_daily')->insert([
|
||||
'userId' => $userId,
|
||||
'dateStr' => $dateStr,
|
||||
'messageCount' => 1,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
return 1;
|
||||
} catch (\Throwable $e) {
|
||||
// 并发首条:另一请求已插入,回读后再自增
|
||||
$row = Db::name('ai_usage_daily')
|
||||
->where('userId', $userId)
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
if (!$row) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$used = (int) $row['messageCount'];
|
||||
if ($used >= $limit) return -1;
|
||||
|
||||
Db::name('ai_usage_daily')
|
||||
->where('id', $row['id'])
|
||||
->update(['messageCount' => $used + 1, 'updatedAt' => $now]);
|
||||
|
||||
return $used + 1;
|
||||
}
|
||||
}
|
||||
84
api/app/controller/api/AiReport.php
Normal file
84
api/app/controller/api/AiReport.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiReportService;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* AI 深度画像报告 · 小程序 API
|
||||
*
|
||||
* POST /api/ai/report/create 创建或返回现有 pending 报告
|
||||
* GET /api/ai/report/my-latest 我最近的一份报告(判断是否已买)
|
||||
* GET /api/ai/report/:id 获取报告正文(必须属于当前用户)
|
||||
* POST /api/ai/report/:id/mark-paid-dev 调试用:跳过支付直接置已付
|
||||
* POST /api/ai/report/:id/regenerate 失败后重试生成(仅管理员/作者)
|
||||
*/
|
||||
class AiReport extends BaseController
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$conversationId = (int) Request::param('conversationId', 0);
|
||||
$mbtiType = trim((string) Request::param('mbtiType', ''));
|
||||
|
||||
$r = AiReportService::createOrGetPending((int) $user['id'], $conversationId, $mbtiType);
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function myLatest()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return success(['status' => '']);
|
||||
$r = AiReportService::myLatest((int) $user['id']);
|
||||
if (!$r) return success(['status' => '']);
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
|
||||
// 未付费时,content 不下发,只给 summary + 解锁引导
|
||||
if ($r['status'] !== 'done') {
|
||||
unset($r['content']);
|
||||
}
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function markPaidDev($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
|
||||
// 仅超管或本地 debug 开关允许
|
||||
$role = $user['role'] ?? '';
|
||||
$isSuper = $role === 'superadmin';
|
||||
$isDebug = function_exists('env') ? (bool) env('app.ai_report_paid_dev', false) : false;
|
||||
if (!$isSuper && !$isDebug) {
|
||||
return error('仅测试模式可用', 403);
|
||||
}
|
||||
$ret = AiReportService::markPaidDev((string) $r['orderSn']);
|
||||
return success($ret);
|
||||
}
|
||||
|
||||
public function regenerate($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
if (!in_array($r['status'], ['failed', 'paid'])) {
|
||||
return error('当前状态不支持重试:' . $r['status']);
|
||||
}
|
||||
AiReportService::generate((int) $r['id']);
|
||||
$r2 = AiReportService::get((int) $id, (int) $user['id']);
|
||||
return success($r2);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\PdpDiscResultText;
|
||||
use app\common\service\FeishuLeadWebhookService;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\UserJourneyService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
@@ -22,6 +24,7 @@ class CrmReport extends BaseController
|
||||
* @param string remark 备注,如"申请咨询"/"完成付款"
|
||||
* @param string tags 可选,逗号分隔的微信标签
|
||||
* @param string siteTags 可选,逗号分隔的站内标签
|
||||
* @param bool deepConsult 为 true 时:「了解自己」申请咨询;apiKey 可空,后端按用户归属企业从 cunkebao_keys 解析;并推送飞书获客(若已配置)
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
@@ -49,6 +52,8 @@ class CrmReport extends BaseController
|
||||
$tags = trim((string) ($this->request->param('tags', '') ?? ''));
|
||||
$siteTags = trim((string) ($this->request->param('siteTags', '') ?? ''));
|
||||
|
||||
$deepConsult = self::isTruthyParam($this->request->param('deepConsult', false));
|
||||
|
||||
// 测评类付费(人脸/MBTI/PDP/DISC):未传 apiKey 时从企业后台配置 cunkebao_keys 解析(与深度服务「完成付款」上报一致)
|
||||
$testType = trim((string) ($this->request->param('testType', '') ?? ''));
|
||||
$testResultId = (int) ($this->request->param('testResultId', 0) ?? 0);
|
||||
@@ -70,6 +75,15 @@ class CrmReport extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 「了解自己」申请咨询:类目未配 consultWechat 时,按用户归属企业回落 cunkebao_keys
|
||||
if ($apiKey === '' && $deepConsult && $userId > 0) {
|
||||
$eidDeep = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
|
||||
$fk = self::readSingleCunkebaoApiKeyWithFallback($eidDeep);
|
||||
if ($fk !== '') {
|
||||
$apiKey = $fk;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用企业 cunkebao_keys 的「测评付费」上报:须校验已支付(防止未支付伪造「完成付款」)
|
||||
if ($resolvedFromEnterpriseKeys && !empty($apiKey)) {
|
||||
$deny = self::verifyTestPaidForCrmReport($userId, $testType, $testResultId);
|
||||
@@ -78,19 +92,58 @@ class CrmReport extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// apiKey 为空则跳过,不影响主流程
|
||||
if (empty($apiKey)) {
|
||||
if (empty($apiKey) && !$deepConsult) {
|
||||
return success(['reported' => false, 'reason' => 'no_api_key']);
|
||||
}
|
||||
|
||||
// 测评结果:备注仅摘要;站内/微信标签含「测评名,结果」
|
||||
if ($testResultId > 0 && $userId > 0) {
|
||||
self::applyTestResultSummaryToReportPayload($userId, $testType, $testResultId, $remark, $tags, $siteTags);
|
||||
$eidForMgmt = $contextEnterpriseId;
|
||||
if ($eidForMgmt <= 0) {
|
||||
$eidForMgmt = (int) (Db::name('test_results')->where('id', $testResultId)->where('userId', $userId)->value('enterpriseId') ?? 0);
|
||||
}
|
||||
self::appendCrmRemarkUserJourney($userId, $eidForMgmt, $remark);
|
||||
}
|
||||
|
||||
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
// 申请咨询:备注中附带用户管理摘要与旅程(与飞书卡片「最近行为」同源)
|
||||
if ($deepConsult && $userId > 0 && !empty($apiKey)) {
|
||||
$eidJ = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
|
||||
self::appendCrmRemarkUserJourney($userId, $eidJ, $remark);
|
||||
}
|
||||
|
||||
return success(['reported' => $ok, 'reason' => $ok ? '' : 'api_error']);
|
||||
$ok = false;
|
||||
if (!empty($apiKey)) {
|
||||
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
}
|
||||
|
||||
if ($deepConsult && $userId > 0) {
|
||||
$feishuSource = $source !== '' ? $source : '深度服务·申请咨询';
|
||||
FeishuLeadWebhookService::onDeepServiceConsultApply($userId, $feishuSource, $siteTags);
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
return success(['reported' => true, 'reason' => '']);
|
||||
}
|
||||
|
||||
return success([
|
||||
'reported' => false,
|
||||
'reason' => empty($apiKey) ? 'no_api_key' : 'api_error',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param mixed $v */
|
||||
private static function isTruthyParam($v): bool
|
||||
{
|
||||
if ($v === true || $v === 1) {
|
||||
return true;
|
||||
}
|
||||
if ($v === false || $v === 0 || $v === null || $v === '') {
|
||||
return false;
|
||||
}
|
||||
$s = strtolower(trim((string) $v));
|
||||
|
||||
return in_array($s, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,6 +451,8 @@ class CrmReport extends BaseController
|
||||
$tags = $merged;
|
||||
$siteTags = $merged;
|
||||
|
||||
self::appendCrmRemarkUserJourney($userId, $enterpriseId, $remark);
|
||||
|
||||
self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[CrmReport] reportTestCompletion 异常 userId=' . $userId . ' err=' . $e->getMessage());
|
||||
@@ -460,6 +515,36 @@ class CrmReport extends BaseController
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 存客宝 remark 追加:用户管理一行 + analytics 用户旅程(与飞书/出站 Hook 字段同源)
|
||||
*/
|
||||
private static function appendCrmRemarkUserJourney(int $userId, int $enterpriseIdForMgmt, string &$remark): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$blocks = [];
|
||||
$m = UserJourneyService::managementSummaryLine($userId, $enterpriseIdForMgmt);
|
||||
if ($m !== '') {
|
||||
$blocks[] = '【用户管理】' . $m;
|
||||
}
|
||||
$jb = UserJourneyService::journeyLinesToRemarkBlock(UserJourneyService::recentBehaviorLines($userId, 10), 720);
|
||||
if ($jb !== '') {
|
||||
$blocks[] = $jb;
|
||||
}
|
||||
if (count($blocks) === 0) {
|
||||
return;
|
||||
}
|
||||
$append = implode("\n", $blocks);
|
||||
$remark = trim($remark === '' ? $append : ($remark . "\n" . $append));
|
||||
$cap = 900;
|
||||
if (function_exists('mb_strlen') && mb_strlen($remark) > $cap) {
|
||||
$remark = mb_substr($remark, 0, $cap) . '…';
|
||||
} elseif (!function_exists('mb_strlen') && strlen($remark) > $cap) {
|
||||
$remark = substr($remark, 0, $cap) . '…';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部通用上报(供 report() 接口和 reportTestCompletion 共用)
|
||||
*/
|
||||
|
||||
58
api/app/controller/api/MpConfig.php
Normal file
58
api/app/controller/api/MpConfig.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 小程序公开运行配置
|
||||
* - GET /api/mp/tabbar : 返回底部 TabBar 动态布局
|
||||
*
|
||||
* 不强制登录,结果做 60s 软缓存(客户端也会做 localStorage 缓存)。
|
||||
*/
|
||||
class MpConfig extends BaseController
|
||||
{
|
||||
public function tabbar()
|
||||
{
|
||||
$items = Db::name('mp_tabbar_items')
|
||||
->where('visible', 1)
|
||||
->order('sortOrder', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = [];
|
||||
foreach ($items as $row) {
|
||||
$iconKey = $row['iconKey'] ?? 'home';
|
||||
$iconUrl = $row['iconUrl'] ?? null;
|
||||
if ($iconKey === 'ai') {
|
||||
// 神仙AI:纯圆老头像(无放射线装饰),避免后台误配导致小图标异常
|
||||
$iconUrl = '/images/shenxian-oldman-circle.png';
|
||||
}
|
||||
$list[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'pagePath' => $row['pagePath'],
|
||||
'text' => $row['text'],
|
||||
'iconKey' => $iconKey,
|
||||
'iconUrl' => $iconUrl,
|
||||
'highlight' => (int) ($row['highlight'] ?? 0) === 1,
|
||||
'badgeKey' => $row['badgeKey'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// 兜底:后台还没配/表空,返回硬编码 4 项,保证小程序永远可用
|
||||
if (empty($list)) {
|
||||
$list = [
|
||||
['id' => 0, 'pagePath' => 'pages/index/index', 'text' => '首页', 'iconKey' => 'home', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/index/camera', 'text' => '拍摄', 'iconKey' => 'camera', 'iconUrl' => null, 'highlight' => true, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/ai-chat/index', 'text' => '神仙AI', 'iconKey' => 'ai', 'iconUrl' => '/images/shenxian-oldman-circle.png', 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/profile/index', 'text' => '我', 'iconKey' => 'profile', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
];
|
||||
}
|
||||
|
||||
return success([
|
||||
'items' => $list,
|
||||
'version' => (int) Db::name('mp_tabbar_items')->max('updatedAt'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\FeishuLeadWebhookService;
|
||||
use app\common\service\AiReportService;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -364,6 +365,14 @@ class Payment extends BaseController
|
||||
// 企业四项测试支付后,订单金额进入企业余额
|
||||
$this->creditEnterpriseBalanceForOrder($order, $paidAmountFen, $now);
|
||||
|
||||
// AI 深度报告:支付成功后置 paid 并触发报告生成(幂等)
|
||||
if (($order['productType'] ?? '') === 'ai_deep_report' && !empty($order['orderNo'])) {
|
||||
try {
|
||||
AiReportService::markPaid((string) $order['orderNo']);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (($order['productType'] ?? '') !== 'recharge') {
|
||||
// 触发分销佣金结算
|
||||
try {
|
||||
@@ -372,6 +381,43 @@ class Payment extends BaseController
|
||||
// 佣金结算失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 成交归因:写入一条 analytics_events,便于漏斗统计
|
||||
try {
|
||||
$userId = (int) ($order['userId'] ?? 0);
|
||||
$inviterRow = null;
|
||||
if ($userId > 0) {
|
||||
$inviterRow = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $userId)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', time())
|
||||
->order('id', 'desc')
|
||||
->field('inviterId')
|
||||
->find();
|
||||
}
|
||||
$amountFenForEvent = (int) ($order['amount'] ?? 0);
|
||||
$props = [
|
||||
'orderId' => (int) $order['id'],
|
||||
'orderNo' => $order['orderNo'] ?? '',
|
||||
'amountFen' => $amountFenForEvent,
|
||||
'amountYuan'=> $amountFenForEvent / 100,
|
||||
'productType' => $order['productType'] ?? '',
|
||||
'testType' => $order['testType'] ?? '',
|
||||
'inviterId' => $inviterRow ? (int) ($inviterRow['inviterId'] ?? 0) : 0,
|
||||
];
|
||||
Db::name('analytics_events')->insert([
|
||||
'userId' => $userId ?: null,
|
||||
'eventName' => 'pay_success_attribution',
|
||||
'pagePath' => 'server/payment/notify',
|
||||
'propsJson' => json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
'clientTs' => (int) ($now * 1000),
|
||||
'platform' => 'server',
|
||||
'sessionId' => null,
|
||||
'createdAt' => date('Y-m-d H:i:s', $now),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 埋点失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
@@ -461,6 +507,14 @@ class Payment extends BaseController
|
||||
// 企业四项测试支付后,订单金额进入企业余额
|
||||
$this->creditEnterpriseBalanceForOrder($localOrder, $finalAmount, $now);
|
||||
|
||||
// AI 深度报告:查询确认支付时补偿置 paid(幂等)
|
||||
if (($localOrder['productType'] ?? '') === 'ai_deep_report' && !empty($localOrder['orderNo'])) {
|
||||
try {
|
||||
AiReportService::markPaid((string) $localOrder['orderNo']);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (($localOrder['productType'] ?? '') !== 'recharge') {
|
||||
// 触发分销佣金结算
|
||||
try {
|
||||
|
||||
29
api/app/controller/superadmin/AiMonitor.php
Normal file
29
api/app/controller/superadmin/AiMonitor.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiBalanceAlertService;
|
||||
|
||||
/**
|
||||
* 超管 · 神仙 AI 监控面板
|
||||
*
|
||||
* - POST /api/v1/superadmin/ai/balance-check 手动触发余额预警扫描
|
||||
* 可直接由宝塔 cron 每 12 小时调用一次
|
||||
*/
|
||||
class AiMonitor extends BaseController
|
||||
{
|
||||
public function balanceCheck()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$r = AiBalanceAlertService::scanAndAlert();
|
||||
return success($r, "扫描完成:推送 {$r['alerted']} 条,跳过 {$r['skipped']} 条(当日去重)");
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\AnalyticsEventLabels;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
@@ -11,30 +12,42 @@ use think\facade\Request;
|
||||
class Analytics extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/summary?days=7
|
||||
* GET /api/v1/superadmin/analytics/summary?days=7&eventName=可选
|
||||
* 返回字段附加 eventNameCn(中文名)
|
||||
*/
|
||||
public function summary()
|
||||
{
|
||||
$days = min(90, max(1, (int) Request::param('days', 7)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$eventFilter = trim((string) Request::param('eventName', ''));
|
||||
|
||||
try {
|
||||
$list = Db::name('analytics_events')
|
||||
$q = Db::name('analytics_events')
|
||||
->field('eventName, COUNT(*) AS cnt')
|
||||
->where('createdAt', '>=', $since)
|
||||
->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$q->where('eventName', $eventFilter);
|
||||
}
|
||||
$list = $q
|
||||
->group('eventName')
|
||||
->order('cnt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$total = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
$list = AnalyticsEventLabels::withCn($list);
|
||||
|
||||
$cntQuery = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$cntQuery->where('eventName', $eventFilter);
|
||||
}
|
||||
$total = $cntQuery->count();
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'total' => (int) $total,
|
||||
'list' => $list,
|
||||
'labels' => AnalyticsEventLabels::all(),
|
||||
'tableMissing' => false,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -42,13 +55,14 @@ class Analytics extends BaseController
|
||||
'days' => $days,
|
||||
'total' => 0,
|
||||
'list' => [],
|
||||
'labels' => AnalyticsEventLabels::all(),
|
||||
'tableMissing' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50
|
||||
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50&eventName=&userId=
|
||||
*/
|
||||
public function events()
|
||||
{
|
||||
@@ -56,14 +70,21 @@ class Analytics extends BaseController
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(10, (int) Request::param('pageSize', 50)));
|
||||
$eventFilter = trim((string) Request::param('eventName', ''));
|
||||
$userIdFilter = (int) Request::param('userId', 0);
|
||||
|
||||
try {
|
||||
$total = (int) Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
$baseQ = Db::name('analytics_events')->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$baseQ->where('eventName', $eventFilter);
|
||||
}
|
||||
if ($userIdFilter > 0) {
|
||||
$baseQ->where('userId', $userIdFilter);
|
||||
}
|
||||
|
||||
$total = (int) (clone $baseQ)->count();
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
$rows = $baseQ
|
||||
->order('id', 'desc')
|
||||
->limit($offset, $pageSize)
|
||||
->select()
|
||||
@@ -80,6 +101,8 @@ class Analytics extends BaseController
|
||||
}
|
||||
unset($r);
|
||||
|
||||
$rows = AnalyticsEventLabels::withCn($rows);
|
||||
|
||||
return success([
|
||||
'list' => $rows,
|
||||
'total' => $total,
|
||||
@@ -97,4 +120,260 @@ class Analytics extends BaseController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/user-journey?userId=&days=30
|
||||
* 单个用户旅程:按时间倒序展示最近 200 条事件(中文名)
|
||||
*/
|
||||
public function userJourney()
|
||||
{
|
||||
$userId = (int) Request::param('userId', 0);
|
||||
if ($userId <= 0) {
|
||||
return error('userId 不能为空', 400);
|
||||
}
|
||||
$days = min(180, max(1, (int) Request::param('days', 30)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
|
||||
try {
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->where('createdAt', '>=', $since)
|
||||
->order('id', 'desc')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as &$r) {
|
||||
if (!empty($r['propsJson'])) {
|
||||
$decoded = json_decode($r['propsJson'], true);
|
||||
$r['props'] = is_array($decoded) ? $decoded : null;
|
||||
} else {
|
||||
$r['props'] = null;
|
||||
}
|
||||
unset($r['propsJson']);
|
||||
}
|
||||
unset($r);
|
||||
$rows = AnalyticsEventLabels::withCn($rows);
|
||||
|
||||
return success([
|
||||
'userId' => $userId,
|
||||
'days' => $days,
|
||||
'list' => $rows,
|
||||
'total' => count($rows),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return success([
|
||||
'userId' => $userId,
|
||||
'days' => $days,
|
||||
'list' => [],
|
||||
'total' => 0,
|
||||
'tableMissing' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/share-stats?days=30
|
||||
* 分享与邀请统计:
|
||||
* - 每位用户累计分享次数
|
||||
* - 每位用户邀请绑定人数(distribution_bindings)
|
||||
* - 每位用户累计产生分润(distribution_commissions)
|
||||
*/
|
||||
public function shareStats()
|
||||
{
|
||||
$days = min(180, max(1, (int) Request::param('days', 30)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(10, (int) Request::param('pageSize', 30)));
|
||||
|
||||
$out = [];
|
||||
$total = 0;
|
||||
$tableMissing = false;
|
||||
|
||||
try {
|
||||
// 分享次数:share / tap_share_moment / tap_share_friend 都算
|
||||
$shareEvents = ['share', 'tap_share_moment', 'tap_share_friend'];
|
||||
$shareRows = Db::name('analytics_events')
|
||||
->field('userId, COUNT(*) AS shareCount')
|
||||
->whereIn('eventName', $shareEvents)
|
||||
->where('createdAt', '>=', $since)
|
||||
->where('userId', '>', 0)
|
||||
->group('userId')
|
||||
->order('shareCount', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($shareRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => (int) ($row['shareCount'] ?? 0),
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$tableMissing = true;
|
||||
}
|
||||
|
||||
// 邀请绑定人数(按 inviteeId 去重,只统计当前 active)
|
||||
try {
|
||||
$bindRows = Db::name('distribution_bindings')
|
||||
->field('inviterId AS userId, COUNT(DISTINCT inviteeId) AS cnt')
|
||||
->where('status', 'active')
|
||||
->group('inviterId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($bindRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
if (!isset($out[$uid])) {
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => 0,
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
$out[$uid]['inviteBound'] = (int) ($row['cnt'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 分润金额(从 commission_records 聚合)
|
||||
try {
|
||||
$commRows = Db::name('commission_records')
|
||||
->field('inviterId AS userId, SUM(commissionFen) AS sumFen')
|
||||
->where('commissionFen', '>', 0)
|
||||
->group('inviterId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($commRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
if (!isset($out[$uid])) {
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => 0,
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
$out[$uid]['totalCommissionFen'] = (int) ($row['sumFen'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 合并后按邀请人数、分润、分享次数排序
|
||||
$list = array_values($out);
|
||||
usort($list, function ($a, $b) {
|
||||
if ($a['totalCommissionFen'] !== $b['totalCommissionFen']) {
|
||||
return $b['totalCommissionFen'] <=> $a['totalCommissionFen'];
|
||||
}
|
||||
if ($a['inviteBound'] !== $b['inviteBound']) {
|
||||
return $b['inviteBound'] <=> $a['inviteBound'];
|
||||
}
|
||||
return $b['shareCount'] <=> $a['shareCount'];
|
||||
});
|
||||
|
||||
$total = count($list);
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$pageList = array_slice($list, $offset, $pageSize);
|
||||
|
||||
// 补用户资料
|
||||
$uids = array_column($pageList, 'userId');
|
||||
if (!empty($uids)) {
|
||||
try {
|
||||
$users = Db::name('wechat_users')
|
||||
->whereIn('id', $uids)
|
||||
->field('id, nickname, avatar, phone, enterpriseId, createdAt')
|
||||
->select()
|
||||
->toArray();
|
||||
$byId = [];
|
||||
foreach ($users as $u) { $byId[(int) $u['id']] = $u; }
|
||||
foreach ($pageList as &$row) {
|
||||
$u = $byId[$row['userId']] ?? null;
|
||||
$row['nickname'] = $u['nickname'] ?? '';
|
||||
$row['avatar'] = $u['avatar'] ?? '';
|
||||
$row['phone'] = $u['phone'] ?? '';
|
||||
$row['enterpriseId'] = $u['enterpriseId'] ?? null;
|
||||
$row['createdAt'] = $u['createdAt'] ?? null;
|
||||
}
|
||||
unset($row);
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'total' => $total,
|
||||
'list' => $pageList,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'tableMissing' => $tableMissing,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/share-funnel?days=14
|
||||
* 分享漏斗:
|
||||
* 1) 结果页访问(pages/result/* page_view)
|
||||
* 2) 分享动作(share / tap_share_moment / tap_share_friend)
|
||||
* 3) 好友登录(login_silent_success)
|
||||
* 4) 好友付费(pay_success_attribution 或 pay_success)
|
||||
* 5) 累计分润(distribution_commissions)
|
||||
*/
|
||||
public function shareFunnel()
|
||||
{
|
||||
$days = min(180, max(1, (int) Request::param('days', 14)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
|
||||
$out = [
|
||||
['stage' => '结果页访问', 'value' => 0],
|
||||
['stage' => '分享动作', 'value' => 0],
|
||||
['stage' => '好友登录', 'value' => 0],
|
||||
['stage' => '好友付费', 'value' => 0],
|
||||
['stage' => '累计分润(元)', 'value' => 0],
|
||||
];
|
||||
$tableMissing = false;
|
||||
|
||||
try {
|
||||
$out[0]['value'] = (int) Db::name('analytics_events')
|
||||
->where('eventName', 'page_view')
|
||||
->where('pagePath', 'like', 'pages/result/%')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[1]['value'] = (int) Db::name('analytics_events')
|
||||
->whereIn('eventName', ['share', 'tap_share_moment', 'tap_share_friend'])
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[2]['value'] = (int) Db::name('analytics_events')
|
||||
->where('eventName', 'login_silent_success')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[3]['value'] = (int) Db::name('analytics_events')
|
||||
->whereIn('eventName', ['pay_success_attribution', 'pay_success'])
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
} catch (\Throwable $e) {
|
||||
$tableMissing = true;
|
||||
}
|
||||
|
||||
// 分润金额(元)— commission_records.commissionFen
|
||||
// createdAt 在该表为整数秒时间戳,而不是 datetime;需要改成整数比较
|
||||
try {
|
||||
$sinceTs = time() - $days * 86400;
|
||||
$totalFen = (int) Db::name('commission_records')
|
||||
->where('createdAt', '>=', $sinceTs)
|
||||
->where('commissionFen', '>', 0)
|
||||
->sum('commissionFen');
|
||||
$out[4]['value'] = round($totalFen / 100, 2);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'funnel' => $out,
|
||||
'tableMissing' => $tableMissing,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
127
api/app/controller/superadmin/MpTabBar.php
Normal file
127
api/app/controller/superadmin/MpTabBar.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 超管 · 小程序 TabBar 配置管理
|
||||
*/
|
||||
class MpTabBar extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$list = Db::name('mp_tabbar_items')
|
||||
->order('sortOrder', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
return success(['list' => $list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /superadmin/tabbar/save 整体覆盖保存
|
||||
* body: { items: [{id?, sortOrder, pagePath, text, iconKey, highlight, visible, badgeKey}] }
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$items = Request::param('items/a', []);
|
||||
if (!is_array($items) || count($items) === 0) {
|
||||
return error('items 不能为空');
|
||||
}
|
||||
if (count($items) > 5) {
|
||||
return error('最多支持 5 个 Tab');
|
||||
}
|
||||
|
||||
$visibleCount = 0;
|
||||
foreach ($items as $it) {
|
||||
if ((int) ($it['visible'] ?? 1) === 1) $visibleCount++;
|
||||
}
|
||||
if ($visibleCount < 2) {
|
||||
return error('至少保留 2 个可见 Tab');
|
||||
}
|
||||
if ($visibleCount > 5) {
|
||||
return error('可见 Tab 不得超过 5 个');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$keepIds = [];
|
||||
foreach ($items as $idx => $it) {
|
||||
$data = [
|
||||
'sortOrder' => (int) ($it['sortOrder'] ?? ($idx + 1) * 10),
|
||||
'pagePath' => trim((string) ($it['pagePath'] ?? '')),
|
||||
'text' => trim((string) ($it['text'] ?? '')),
|
||||
'iconKey' => trim((string) ($it['iconKey'] ?? 'home')),
|
||||
'iconUrl' => isset($it['iconUrl']) && $it['iconUrl'] !== '' ? (string) $it['iconUrl'] : null,
|
||||
'visible' => (int) ($it['visible'] ?? 1) === 1 ? 1 : 0,
|
||||
'highlight' => (int) ($it['highlight'] ?? 0) === 1 ? 1 : 0,
|
||||
'badgeKey' => isset($it['badgeKey']) ? (string) $it['badgeKey'] : null,
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
if ($data['pagePath'] === '' || $data['text'] === '') {
|
||||
throw new \Exception('pagePath / text 不能为空');
|
||||
}
|
||||
|
||||
if (!empty($it['id'])) {
|
||||
Db::name('mp_tabbar_items')->where('id', (int) $it['id'])->update($data);
|
||||
$keepIds[] = (int) $it['id'];
|
||||
} else {
|
||||
$data['createdAt'] = $now;
|
||||
$newId = Db::name('mp_tabbar_items')->insertGetId($data);
|
||||
$keepIds[] = (int) $newId;
|
||||
}
|
||||
}
|
||||
// 删除不在本次保存列表中的旧行
|
||||
Db::name('mp_tabbar_items')->whereNotIn('id', $keepIds)->delete();
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return error('保存失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return success(null, '已保存 ' . count($items) . ' 个 Tab 项');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /superadmin/tabbar/reorder 仅排序
|
||||
* body: { ids: [id1,id2,id3,...] }
|
||||
*/
|
||||
public function reorder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$ids = Request::param('ids/a', []);
|
||||
if (!is_array($ids) || empty($ids)) return error('ids 不能为空');
|
||||
$now = time();
|
||||
foreach ($ids as $idx => $id) {
|
||||
Db::name('mp_tabbar_items')->where('id', (int) $id)->update([
|
||||
'sortOrder' => ($idx + 1) * 10,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
return success(null, '排序已更新');
|
||||
}
|
||||
|
||||
public function remove()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('id 非法');
|
||||
$left = Db::name('mp_tabbar_items')->where('visible', 1)->where('id', '<>', $id)->count();
|
||||
if ($left < 2) return error('至少保留 2 个可见 Tab,不能再删');
|
||||
Db::name('mp_tabbar_items')->where('id', $id)->delete();
|
||||
return success(null, '已删除');
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
79
api/app/controller/superadmin/ProfitRule.php
Normal file
79
api/app/controller/superadmin/ProfitRule.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
class ProfitRule extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$list = Db::name('profit_sharing_rules')->order('id', 'asc')->select()->toArray();
|
||||
foreach ($list as &$row) {
|
||||
$row['receivers'] = json_decode($row['receivers'] ?? '[]', true) ?: [];
|
||||
}
|
||||
unset($row);
|
||||
return success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
$productType = trim((string) Request::param('productType', ''));
|
||||
$name = trim((string) Request::param('name', ''));
|
||||
$receivers = Request::param('receivers/a', []);
|
||||
$status = Request::param('status', 'active');
|
||||
if ($productType === '' || $name === '') return error('productType / name 必填');
|
||||
if (!is_array($receivers) || count($receivers) === 0) return error('至少一个收款人');
|
||||
|
||||
// 校验比例合计 = 1
|
||||
$sum = 0.0;
|
||||
foreach ($receivers as $r) { $sum += (float) ($r['ratio'] ?? 0); }
|
||||
if (abs($sum - 1.0) > 0.005) {
|
||||
return error('分账比例合计必须等于 100%(当前 ' . round($sum * 100, 2) . '%)');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$data = [
|
||||
'productType' => $productType,
|
||||
'name' => $name,
|
||||
'receivers' => json_encode($receivers, JSON_UNESCAPED_UNICODE),
|
||||
'status' => $status === 'disabled' ? 'disabled' : 'active',
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
|
||||
if ($id > 0) {
|
||||
Db::name('profit_sharing_rules')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$dup = Db::name('profit_sharing_rules')->where('productType', $productType)->find();
|
||||
if ($dup) return error('该产品类型已存在规则,请编辑现有规则');
|
||||
$data['createdAt'] = $now;
|
||||
Db::name('profit_sharing_rules')->insert($data);
|
||||
}
|
||||
return success(null, '已保存');
|
||||
}
|
||||
|
||||
public function toggle($id)
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$row = Db::name('profit_sharing_rules')->where('id', (int) $id)->find();
|
||||
if (!$row) return error('规则不存在');
|
||||
$next = $row['status'] === 'active' ? 'disabled' : 'active';
|
||||
Db::name('profit_sharing_rules')->where('id', (int) $id)->update([
|
||||
'status' => $next,
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
return success(['status' => $next]);
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
232
api/app/controller/superadmin/SoulArticle.php
Normal file
232
api/app/controller/superadmin/SoulArticle.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\SoulArticleService;
|
||||
use app\model\SoulArticle as SoulArticleModel;
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 超管 · Soul 文章管理(采集 / 推荐 / AI 健康概览)
|
||||
*/
|
||||
class SoulArticle extends BaseController
|
||||
{
|
||||
/** POST /api/v1/superadmin/soul-articles/sync */
|
||||
public function sync()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$limit = (int) Request::post('limit', 10);
|
||||
$limit = max(1, min($limit, 30));
|
||||
$tag = trim((string) Request::post('tag', 'MBTI'));
|
||||
$keyword = trim((string) Request::post('keyword', ''));
|
||||
if ($tag === '') $tag = 'MBTI';
|
||||
|
||||
$r = $keyword !== ''
|
||||
? SoulArticleService::syncByKeyword($keyword, $limit, $tag)
|
||||
: SoulArticleService::syncLatest($limit, $tag);
|
||||
if (!empty($r['error'])) {
|
||||
return error($r['error'], 500);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
return success($r, "搜索并添加完成:新增 {$r['created']} 篇,更新 {$r['updated']} 篇");
|
||||
}
|
||||
return success($r, "采集完成:新增 {$r['created']} 篇,更新 {$r['updated']} 篇");
|
||||
}
|
||||
|
||||
/** GET /api/v1/superadmin/soul-articles/ai-chat-display */
|
||||
public function aiChatDisplayGet()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
return success(AiChatArticleDisplayService::getSettings());
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/ai-chat-display */
|
||||
public function aiChatDisplaySave()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$input = Request::post();
|
||||
$settings = AiChatArticleDisplayService::saveSettings(is_array($input) ? $input : []);
|
||||
return success($settings, '已保存');
|
||||
}
|
||||
|
||||
/** GET /api/v1/superadmin/soul-articles */
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$page = max(1, (int) Request::get('page', 1));
|
||||
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
|
||||
$isReco = Request::get('isRecommended', '');
|
||||
$keyword = trim((string) Request::get('keyword', ''));
|
||||
$tag = trim((string) Request::get('tag', ''));
|
||||
$dateRange = Request::get('dateRange', '');
|
||||
|
||||
$query = SoulArticleModel::order('isRecommended', 'desc')
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc');
|
||||
if ($isReco === '1' || $isReco === 1) {
|
||||
$query = $query->where('isRecommended', 1);
|
||||
} elseif ($isReco === '0' || $isReco === 0) {
|
||||
$query = $query->where('isRecommended', 0);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query = $query->whereLike('title', "%{$keyword}%");
|
||||
}
|
||||
if ($tag !== '') {
|
||||
$query = $query->where('tag', $tag);
|
||||
}
|
||||
if (is_array($dateRange) && count($dateRange) === 2) {
|
||||
$start = (int) strtotime((string) $dateRange[0] . ' 00:00:00');
|
||||
$end = (int) strtotime((string) $dateRange[1] . ' 23:59:59');
|
||||
if ($start > 0 && $end > 0) {
|
||||
$query = $query->whereBetween('publishedAt', [$start, $end]);
|
||||
}
|
||||
}
|
||||
|
||||
$total = $query->count();
|
||||
$rows = $query->page($page, $pageSize)->select()->toArray();
|
||||
|
||||
return paginate_response($rows, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/order */
|
||||
public function setOrder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
$order = (int) Request::post('recommendedOrder', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
if ($order < 0) return error('排序值不合法', 400);
|
||||
$article = SoulArticleModel::find($id);
|
||||
if (!$article) return error('文章不存在', 404);
|
||||
if ((int) $article->isRecommended !== 1) {
|
||||
return error('仅推荐中的文章可调整排序', 400);
|
||||
}
|
||||
$article->recommendedOrder = $order;
|
||||
$article->save();
|
||||
return success(['id' => $id, 'recommendedOrder' => $order], '排序已更新');
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/reorder-normalize */
|
||||
public function normalizeOrder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$rows = SoulArticleModel::where('isRecommended', 1)
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
$idx = 1;
|
||||
foreach ($rows as $row) {
|
||||
$m = SoulArticleModel::find((int) $row['id']);
|
||||
if (!$m) continue;
|
||||
$m->recommendedOrder = $idx;
|
||||
$m->save();
|
||||
$idx++;
|
||||
}
|
||||
return success(['count' => count($rows)], '推荐权重已归一化');
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/recommend */
|
||||
public function recommend()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
|
||||
$r = SoulArticleService::recommend($id);
|
||||
if (empty($r['ok'])) {
|
||||
return error($r['message'] ?? '操作失败', 400);
|
||||
}
|
||||
return success($r, $r['message']);
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/delete */
|
||||
public function remove()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
$article = SoulArticleModel::find($id);
|
||||
if (!$article) return error('文章不存在', 404);
|
||||
$article->delete();
|
||||
return success(null, '已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/ai/health
|
||||
* AI 健康小条:各服务商可用 / 余额 / 最后检查时间
|
||||
*/
|
||||
public function health()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$rows = AiProviderModel::whereRaw('(visible IS NULL OR visible = 1)')
|
||||
->order('sortWeight', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$balance = isset($r['lastBalance']) ? (float) $r['lastBalance'] : null;
|
||||
$threshold = isset($r['balanceAlertThreshold']) ? (float) $r['balanceAlertThreshold'] : 0.0;
|
||||
$alertOn = (int) ($r['balanceAlertEnabled'] ?? 0) === 1;
|
||||
$hasKey = !empty($r['apiKey']);
|
||||
$enabled = (int) ($r['enabled'] ?? 0) === 1;
|
||||
|
||||
$status = 'unknown';
|
||||
if (!$enabled) {
|
||||
$status = 'disabled';
|
||||
} elseif (!$hasKey) {
|
||||
$status = 'no-key';
|
||||
} elseif ($alertOn && $balance !== null && $balance <= $threshold) {
|
||||
$status = 'low-balance';
|
||||
} elseif ($balance !== null) {
|
||||
$status = 'healthy';
|
||||
} else {
|
||||
$status = 'pending-check';
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'providerId' => $r['providerId'],
|
||||
'name' => $r['name'],
|
||||
'enabled' => $enabled,
|
||||
'hasKey' => $hasKey,
|
||||
'balance' => $balance,
|
||||
'currency' => $r['lastBalanceCurrency'] ?? 'CNY',
|
||||
'threshold' => $threshold,
|
||||
'balanceAlertEnabled' => $alertOn,
|
||||
'lastBalanceCheckedAt' => !empty($r['lastBalanceCheckedAt'])
|
||||
? date('Y-m-d H:i:s', (int) $r['lastBalanceCheckedAt'])
|
||||
: null,
|
||||
'sortWeight' => (int) ($r['sortWeight'] ?? 100),
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
// 最近一次告警
|
||||
$lastAlert = Db::name('ai_balance_alerts')->order('alertedAt', 'desc')->find();
|
||||
return success([
|
||||
'providers' => $list,
|
||||
'lastAlert' => $lastAlert ? [
|
||||
'providerId' => $lastAlert['providerId'],
|
||||
'balance' => (float) $lastAlert['balance'],
|
||||
'threshold' => (float) $lastAlert['threshold'],
|
||||
'alertedAt' => date('Y-m-d H:i:s', (int) $lastAlert['alertedAt']),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
29
api/app/model/AiConversation.php
Normal file
29
api/app/model/AiConversation.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 神仙 AI 对话
|
||||
*/
|
||||
class AiConversation extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
protected $strict = false;
|
||||
|
||||
protected $name = 'ai_conversations';
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
protected $type = [
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'lastMessageAt' => 'integer',
|
||||
];
|
||||
}
|
||||
23
api/app/model/AiMessage.php
Normal file
23
api/app/model/AiMessage.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 神仙 AI 消息流
|
||||
*/
|
||||
class AiMessage extends Model
|
||||
{
|
||||
protected $name = 'ai_messages';
|
||||
|
||||
/** 避免因库表字段与模型缓存不一致导致写入抛错 */
|
||||
protected $strict = false;
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
protected $type = [
|
||||
'createdAt' => 'integer',
|
||||
'tokensIn' => 'integer',
|
||||
'tokensOut' => 'integer',
|
||||
];
|
||||
}
|
||||
@@ -1,109 +1,110 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* AI服务商配置模型
|
||||
*/
|
||||
class AiProvider extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'ai_providers';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'providerId' => 'string',
|
||||
'name' => 'string',
|
||||
'enabled' => 'int',
|
||||
'visible' => 'int',
|
||||
'apiKey' => 'string',
|
||||
'apiEndpoint' => 'string',
|
||||
'model' => 'string',
|
||||
'organizationId' => 'string',
|
||||
'maxTokens' => 'int',
|
||||
'balanceAlertEnabled' => 'int',
|
||||
'balanceAlertThreshold' => 'float',
|
||||
'notes' => 'string',
|
||||
'docUrl' => 'string',
|
||||
'isFree' => 'int',
|
||||
'supportsBalance' => 'int',
|
||||
'lastBalance' => 'float',
|
||||
'lastBalanceCurrency' => 'string',
|
||||
'lastBalanceCheckedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'extraConfig' => 'string',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'lastBalanceCheckedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'extraConfig' => 'json',
|
||||
];
|
||||
|
||||
// 注意:API Key需要可逆读取用于API调用,所以不隐藏,但在获取器中脱敏
|
||||
|
||||
/**
|
||||
* API Key 修改器(存储原始值,用于API调用)
|
||||
*/
|
||||
public function setApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
// 如果输入的是脱敏格式(包含****),不更新
|
||||
if (strpos($value, '****') !== false) {
|
||||
return null; // 返回null表示不更新此字段
|
||||
}
|
||||
// 直接存储原始值(实际生产环境建议使用AES加密)
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 获取器(返回脱敏后的密钥)
|
||||
* 注意:如果需要原始密钥用于API调用,使用 getRawApiKey() 方法
|
||||
*/
|
||||
public function getApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
// 返回脱敏后的密钥(显示前6位和后4位)
|
||||
if (strlen($value) > 10) {
|
||||
return substr($value, 0, 6) . '****' . substr($value, -4);
|
||||
}
|
||||
return '****';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始API Key(用于API调用)
|
||||
* @return string
|
||||
*/
|
||||
public function getRawApiKey()
|
||||
{
|
||||
// 直接从数据库读取原始值,绕过获取器
|
||||
return \think\facade\Db::name('ai_providers')
|
||||
->where('id', $this->id)
|
||||
->value('apiKey') ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* AI服务商配置模型
|
||||
*/
|
||||
class AiProvider extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'ai_providers';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'providerId' => 'string',
|
||||
'name' => 'string',
|
||||
'enabled' => 'int',
|
||||
'visible' => 'int',
|
||||
'apiKey' => 'string',
|
||||
'apiEndpoint' => 'string',
|
||||
'model' => 'string',
|
||||
'organizationId' => 'string',
|
||||
'maxTokens' => 'int',
|
||||
'balanceAlertEnabled' => 'int',
|
||||
'balanceAlertThreshold' => 'float',
|
||||
'notes' => 'string',
|
||||
'docUrl' => 'string',
|
||||
'isFree' => 'int',
|
||||
'supportsBalance' => 'int',
|
||||
'lastBalance' => 'float',
|
||||
'lastBalanceCurrency' => 'string',
|
||||
'lastBalanceCheckedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'extraConfig' => 'string',
|
||||
'sortWeight' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'lastBalanceCheckedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'extraConfig' => 'json',
|
||||
];
|
||||
|
||||
// 注意:API Key需要可逆读取用于API调用,所以不隐藏,但在获取器中脱敏
|
||||
|
||||
/**
|
||||
* API Key 修改器(存储原始值,用于API调用)
|
||||
*/
|
||||
public function setApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
// 如果输入的是脱敏格式(包含****),不更新
|
||||
if (strpos($value, '****') !== false) {
|
||||
return null; // 返回null表示不更新此字段
|
||||
}
|
||||
// 直接存储原始值(实际生产环境建议使用AES加密)
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 获取器(返回脱敏后的密钥)
|
||||
* 注意:如果需要原始密钥用于API调用,使用 getRawApiKey() 方法
|
||||
*/
|
||||
public function getApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
// 返回脱敏后的密钥(显示前6位和后4位)
|
||||
if (strlen($value) > 10) {
|
||||
return substr($value, 0, 6) . '****' . substr($value, -4);
|
||||
}
|
||||
return '****';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始API Key(用于API调用)
|
||||
* @return string
|
||||
*/
|
||||
public function getRawApiKey()
|
||||
{
|
||||
// 直接从数据库读取原始值,绕过获取器
|
||||
return \think\facade\Db::name('ai_providers')
|
||||
->where('id', $this->id)
|
||||
->value('apiKey') ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
api/app/model/AiReport.php
Normal file
12
api/app/model/AiReport.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class AiReport extends Model
|
||||
{
|
||||
protected $name = 'ai_reports';
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
}
|
||||
@@ -61,6 +61,8 @@ class Enterprise extends Model
|
||||
'sbti' => true,
|
||||
'pdp' => true,
|
||||
'disc' => true,
|
||||
/** 详细测试列表中的「AI 性格测试」聚合入口 */
|
||||
'aiHub' => true,
|
||||
'distribution' => true,
|
||||
];
|
||||
}
|
||||
|
||||
16
api/app/model/ProfitSharingRule.php
Normal file
16
api/app/model/ProfitSharingRule.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ProfitSharingRule extends Model
|
||||
{
|
||||
protected $name = 'profit_sharing_rules';
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
protected $type = [
|
||||
'receivers' => 'json',
|
||||
];
|
||||
}
|
||||
30
api/app/model/SoulArticle.php
Normal file
30
api/app/model/SoulArticle.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* Soul 采集文章(MBTI 主题 · 为一场 soul 创业实验引流)
|
||||
*/
|
||||
class SoulArticle extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
protected $name = 'soul_articles';
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
protected $type = [
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'publishedAt' => 'integer',
|
||||
'isRecommended' => 'integer',
|
||||
'recommendedOrder' => 'integer',
|
||||
'viewCount' => 'integer',
|
||||
];
|
||||
}
|
||||
80
api/database/migrations/add_ai_chat_and_soul_articles.sql
Normal file
80
api/database/migrations/add_ai_chat_and_soul_articles.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- AI 聊天 + Soul 文章推荐 + 欠费预警字段
|
||||
-- 对应需求:ai功能202600417 -6.md
|
||||
-- 建议部署时用 source 一次性执行;前缀 mbti_ 按环境 .env DATABASE_PREFIX 调整
|
||||
|
||||
-- 1) ai_providers 新增排序权重(故障切换时可手动调优先级)
|
||||
ALTER TABLE `mbti_ai_providers`
|
||||
ADD COLUMN `sortWeight` INT NOT NULL DEFAULT 100 COMMENT '优先级,升序;欠费自动降到999' AFTER `visible`;
|
||||
|
||||
-- 2) AI 对话表
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_conversations` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`userId` INT UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '',
|
||||
`mbtiType` VARCHAR(16) NOT NULL DEFAULT '',
|
||||
`providerId` VARCHAR(32) NOT NULL DEFAULT '',
|
||||
`lastMessageAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`messageCount` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`deletedAt` INT UNSIGNED NULL,
|
||||
INDEX `idx_user` (`userId`, `updatedAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 对话';
|
||||
|
||||
-- 3) AI 消息表
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_messages` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`conversationId` INT UNSIGNED NOT NULL,
|
||||
`role` ENUM('system','user','assistant') NOT NULL,
|
||||
`content` MEDIUMTEXT NOT NULL,
|
||||
`tokensIn` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`tokensOut` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`providerId` VARCHAR(32) NOT NULL DEFAULT '',
|
||||
`isDegraded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为降级兜底回答',
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
INDEX `idx_conv` (`conversationId`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 消息流';
|
||||
|
||||
-- 4) Soul 采集文章表(一场 soul 创业实验 引流内容池)
|
||||
CREATE TABLE IF NOT EXISTS `mbti_soul_articles` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`sourceId` VARCHAR(64) NOT NULL COMMENT 'soul 侧文章 id',
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`cover` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`url` VARCHAR(512) NOT NULL,
|
||||
`summary` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`author` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`tag` VARCHAR(32) NOT NULL DEFAULT 'MBTI',
|
||||
`publishedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`isRecommended` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`recommendedOrder` INT NOT NULL DEFAULT 0,
|
||||
`viewCount` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`deletedAt` INT UNSIGNED NULL,
|
||||
UNIQUE KEY `uk_source` (`sourceId`),
|
||||
INDEX `idx_reco` (`isRecommended`, `recommendedOrder`, `publishedAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Soul 采集文章(MBTI 主题)';
|
||||
|
||||
-- 5) AI 使用限额(每日)—— 用户层面的限流记录
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_usage_daily` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`userId` INT UNSIGNED NOT NULL,
|
||||
`dateStr` CHAR(10) NOT NULL COMMENT '格式 YYYY-MM-DD',
|
||||
`messageCount` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY `uk_user_date` (`userId`, `dateStr`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 每日限流计数';
|
||||
|
||||
-- 6) AI 余额预警日志(去重同一阈值每天只发 1 次)
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_balance_alerts` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`providerId` VARCHAR(32) NOT NULL,
|
||||
`balance` DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
`threshold` DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY',
|
||||
`alertedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`dateStr` CHAR(10) NOT NULL,
|
||||
UNIQUE KEY `uk_provider_date` (`providerId`, `dateStr`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 欠费预警去重日志';
|
||||
12
api/database/migrations/add_cold_face.sql
Normal file
12
api/database/migrations/add_cold_face.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 冷脸分析字段:扩展 user_profile,用于列表/详情展示
|
||||
-- 说明:
|
||||
-- coldFaceScore 0-100;<35=暖(warm), 35-65=中(neutral), >65=冷(cold)
|
||||
-- coldFaceLevel 字符串枚举 cold/neutral/warm
|
||||
-- coldFaceUpdatedAt 最近一次测评更新时间戳(秒)
|
||||
-- 执行: 在 phpMyAdmin 或 CLI 运行
|
||||
|
||||
ALTER TABLE `mbti_user_profile`
|
||||
ADD COLUMN `coldFaceScore` tinyint(3) UNSIGNED NULL DEFAULT NULL COMMENT '冷脸分(0-100)' AFTER `lastFaceResultId`,
|
||||
ADD COLUMN `coldFaceLevel` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '冷脸等级: cold/neutral/warm' AFTER `coldFaceScore`,
|
||||
ADD COLUMN `coldFaceUpdatedAt` int(11) NULL DEFAULT NULL COMMENT '冷脸更新时间(时间戳)' AFTER `coldFaceLevel`,
|
||||
ADD INDEX `idx_coldFaceLevel` (`coldFaceLevel`) USING BTREE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 埋点表补充:userId + createdAt 复合索引(用于用户旅程、分享统计)
|
||||
-- 幂等写法:若索引已存在会报错,可忽略(或手工 SHOW INDEX 检查后再执行)
|
||||
ALTER TABLE `mbti_analytics_events`
|
||||
ADD INDEX `idx_user_created` (`userId`, `createdAt`);
|
||||
95
api/database/migrations/add_tabbar_and_ai_profit_sharing.sql
Normal file
95
api/database/migrations/add_tabbar_and_ai_profit_sharing.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- =====================================================
|
||||
-- 神仙 AI v2 · TabBar 后台可配 + AI 深度报告 + 分账规则
|
||||
-- Date: 2026-04-17
|
||||
-- 生产库表前缀若为 mbti_,则下方表名以 mbti_ 为准
|
||||
-- =====================================================
|
||||
|
||||
-- ---------- 1) 小程序 TabBar 配置 ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_mp_tabbar_items` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`sortOrder` INT NOT NULL DEFAULT 100 COMMENT '排序,小的在前',
|
||||
`pagePath` VARCHAR(128) NOT NULL COMMENT '目标页面 pages/xxx/xxx',
|
||||
`text` VARCHAR(32) NOT NULL COMMENT '底部文案',
|
||||
`iconKey` VARCHAR(32) NOT NULL DEFAULT 'home' COMMENT '图标key: home/camera/ai/profile',
|
||||
`visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=显示 0=隐藏(审核模式)',
|
||||
`highlight` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=中间突出按钮(圆形)',
|
||||
`badgeKey` VARCHAR(32) NULL COMMENT '红点角标来源key(预留)',
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sort` (`sortOrder`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序 TabBar 后台可配';
|
||||
|
||||
-- 初始 4 项(默认顺序:首页·拍摄·神仙AI·我)
|
||||
INSERT INTO `mbti_mp_tabbar_items` (sortOrder, pagePath, text, iconKey, highlight, visible, createdAt, updatedAt) VALUES
|
||||
(10, 'pages/index/index', '首页', 'home', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(20, 'pages/index/camera', '拍摄', 'camera', 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(30, 'pages/ai-chat/index', '神仙AI', 'ai', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(40, 'pages/profile/index', '我', 'profile', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
|
||||
-- ---------- 2) 分账规则(知己 profit-sharing 思路) ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_profit_sharing_rules` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`productType` VARCHAR(64) NOT NULL COMMENT 'ai_deep_report / mbti_report / face_analysis / default',
|
||||
`name` VARCHAR(128) NOT NULL,
|
||||
`receivers` JSON NOT NULL COMMENT '[{type,name,ratio,account?}]',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_product` (`productType`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单分账规则';
|
||||
|
||||
INSERT INTO `mbti_profit_sharing_rules` (productType, name, receivers, status, createdAt, updatedAt) VALUES
|
||||
('ai_deep_report', 'AI 深度画像报告',
|
||||
'[{"type":"platform","name":"平台","ratio":0.70},{"type":"distributor_l1","name":"一级分销","ratio":0.20},{"type":"distributor_l2","name":"二级分销","ratio":0.10}]',
|
||||
'active', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
('default', '默认规则 · 平台 100%',
|
||||
'[{"type":"platform","name":"平台","ratio":1.00}]',
|
||||
'active', UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
|
||||
-- ---------- 3) 分账记录 ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_profit_sharing_records` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`orderSn` VARCHAR(64) NOT NULL COMMENT '订单号',
|
||||
`orderId` INT UNSIGNED NULL COMMENT '订单表id',
|
||||
`productType` VARCHAR(64) NOT NULL,
|
||||
`totalAmount` INT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)',
|
||||
`details` JSON NOT NULL COMMENT '分账明细 [{receiverType,receiverName,amount,ratio,account,status}]',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/processing/completed/failed',
|
||||
`processedAt` INT UNSIGNED NULL,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_order_sn` (`orderSn`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单分账记录';
|
||||
|
||||
|
||||
-- ---------- 4) AI 深度报告(付费解锁阅读) ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_reports` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT UNSIGNED NOT NULL,
|
||||
`conversationId` INT UNSIGNED NULL COMMENT '来源对话',
|
||||
`mbtiType` VARCHAR(8) NULL,
|
||||
`orderSn` VARCHAR(64) NULL COMMENT '对应订单号',
|
||||
`orderId` INT UNSIGNED NULL,
|
||||
`priceFen` INT NOT NULL DEFAULT 990 COMMENT '单价(分)',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending=未支付 paid=已支付未生成 generating=生成中 done=已完成 failed=失败',
|
||||
`title` VARCHAR(255) NULL,
|
||||
`summary` TEXT NULL COMMENT '免费预览的摘要片段',
|
||||
`content` MEDIUMTEXT NULL COMMENT '完整报告正文',
|
||||
`posterUrl` VARCHAR(512) NULL COMMENT '分享海报',
|
||||
`retryCount` INT NOT NULL DEFAULT 0,
|
||||
`lastError` VARCHAR(512) NULL,
|
||||
`paidAt` INT UNSIGNED NULL,
|
||||
`generatedAt` INT UNSIGNED NULL,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`userId`),
|
||||
KEY `idx_order_sn` (`orderSn`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 深度画像报告';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =====================================================
|
||||
-- plan7 补丁:给 tabBar 配置加 iconUrl(可传图片 URL 覆盖 SVG)
|
||||
-- 给 soul_articles 自动推送时间戳
|
||||
-- Date: 2026-04-17
|
||||
-- =====================================================
|
||||
|
||||
ALTER TABLE `mbti_mp_tabbar_items`
|
||||
ADD COLUMN `iconUrl` VARCHAR(512) NULL AFTER `iconKey`
|
||||
COMMENT '图片图标 URL,若有则优先覆盖 iconKey 的 SVG';
|
||||
|
||||
-- 给"神仙AI"tab 默认用团队 logo 图
|
||||
UPDATE `mbti_mp_tabbar_items`
|
||||
SET `iconUrl` = '/images/mbti-team-image.png'
|
||||
WHERE `iconKey` = 'ai' AND (`iconUrl` IS NULL OR `iconUrl` = '');
|
||||
|
||||
-- Soul 自动采集:last sync 时间戳记录到 system_config
|
||||
INSERT INTO `mbti_system_config` (`key`, `value`, `description`, `createdAt`, `updatedAt`)
|
||||
SELECT 'soul_article_auto_sync',
|
||||
JSON_OBJECT('enabled', TRUE, 'intervalSec', 3600, 'limit', 10),
|
||||
'Soul 文章自动采集配置',
|
||||
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `mbti_system_config` WHERE `key` = 'soul_article_auto_sync'
|
||||
);
|
||||
@@ -27,6 +27,14 @@ Route::group('api', function () {
|
||||
Route::post('analytics/events', 'api.Analytics/batch');
|
||||
// 好友打开分享卡片:凭 st 校验查看他人测试结果(无需登录)
|
||||
Route::get('test/share-detail', 'api.Test/shareDetail');
|
||||
// 神仙 AI · 推荐文章(无需登录也可拉取,首屏展示)
|
||||
Route::get('ai/articles/recommended', 'api.AiChat/recommendedArticles');
|
||||
Route::get('ai/articles/profile-teaser', 'api.AiChat/profileArticleTeaser');
|
||||
Route::post('ai/articles/:id/click', 'api.AiChat/articleClick');
|
||||
// 快捷问句:公开接口;若请求头带有效微信 token 则按用户 MBTI 个性化
|
||||
Route::get('ai/quick-questions', 'api.AiChat/quickQuestions');
|
||||
// 小程序 TabBar 动态配置(无需登录,启动拉取)
|
||||
Route::get('mp/tabbar', 'api.MpConfig/tabbar');
|
||||
})->middleware('cors');
|
||||
|
||||
// 前端需要认证的API路由
|
||||
@@ -81,6 +89,19 @@ Route::group('api', function () {
|
||||
Route::post('wechat/transfer/notify', 'api.WechatTransferNotify/notify')->middleware('cors');
|
||||
// 存客宝获客线索上报
|
||||
Route::post('crm/report', 'api.CrmReport/report');
|
||||
|
||||
// ==================== 神仙 AI(需要微信登录)====================
|
||||
Route::post('ai/chat', 'api.AiChat/chat');
|
||||
Route::get('ai/conversations', 'api.AiChat/conversations');
|
||||
Route::get('ai/conversations/:id/messages', 'api.AiChat/messages');
|
||||
Route::post('ai/transcribe', 'api.AiChat/transcribe');
|
||||
|
||||
// AI 深度画像报告
|
||||
Route::post('ai/report/create', 'api.AiReport/create');
|
||||
Route::get('ai/report/my-latest', 'api.AiReport/myLatest');
|
||||
Route::get('ai/report/:id', 'api.AiReport/show');
|
||||
Route::post('ai/report/:id/mark-paid-dev', 'api.AiReport/markPaidDev');
|
||||
Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate');
|
||||
})->middleware(['cors', 'auth']);
|
||||
|
||||
// ==================== 小程序API路由(匹配前端 /api 路径)====================
|
||||
@@ -295,6 +316,34 @@ Route::group('api/v1/superadmin', function () {
|
||||
// 小程序埋点统计(仅超管)
|
||||
Route::get('analytics/summary', 'superadmin.Analytics/summary');
|
||||
Route::get('analytics/events', 'superadmin.Analytics/events');
|
||||
Route::get('analytics/user-journey', 'superadmin.Analytics/userJourney');
|
||||
Route::get('analytics/share-stats', 'superadmin.Analytics/shareStats');
|
||||
Route::get('analytics/share-funnel', 'superadmin.Analytics/shareFunnel');
|
||||
|
||||
// Soul 文章采集与推荐(一场 soul 创业实验引流)
|
||||
Route::get('soul-articles/ai-chat-display', 'superadmin.SoulArticle/aiChatDisplayGet');
|
||||
Route::post('soul-articles/ai-chat-display', 'superadmin.SoulArticle/aiChatDisplaySave');
|
||||
Route::post('soul-articles/sync', 'superadmin.SoulArticle/sync');
|
||||
Route::post('soul-articles/:id/recommend', 'superadmin.SoulArticle/recommend');
|
||||
Route::post('soul-articles/:id/order', 'superadmin.SoulArticle/setOrder');
|
||||
Route::post('soul-articles/reorder-normalize', 'superadmin.SoulArticle/normalizeOrder');
|
||||
Route::post('soul-articles/:id/delete', 'superadmin.SoulArticle/remove');
|
||||
Route::get('soul-articles', 'superadmin.SoulArticle/index');
|
||||
|
||||
// 神仙 AI 健康与欠费监控
|
||||
Route::get('ai/health', 'superadmin.SoulArticle/health');
|
||||
Route::post('ai/balance-check', 'superadmin.AiMonitor/balanceCheck');
|
||||
|
||||
// 小程序 TabBar 后台可配
|
||||
Route::get('tabbar', 'superadmin.MpTabBar/index');
|
||||
Route::post('tabbar/save', 'superadmin.MpTabBar/save');
|
||||
Route::post('tabbar/reorder', 'superadmin.MpTabBar/reorder');
|
||||
Route::post('tabbar/:id/delete', 'superadmin.MpTabBar/remove');
|
||||
|
||||
// 分账规则
|
||||
Route::get('profit-rules', 'superadmin.ProfitRule/index');
|
||||
Route::post('profit-rules/save', 'superadmin.ProfitRule/save');
|
||||
Route::post('profit-rules/:id/toggle', 'superadmin.ProfitRule/toggle');
|
||||
})->middleware(['cors', 'auth', 'superadmin']);
|
||||
|
||||
// ==================== 兼容旧版路由(保留,逐步废弃)====================
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 99 KiB |
@@ -627,6 +627,19 @@ Page({
|
||||
tt.navigateTo({ url: '/pages/test-select/index' })
|
||||
},
|
||||
|
||||
goToQuestionnaireFromFace() {
|
||||
tt.navigateTo({ url: '/pages/test-select/index' })
|
||||
},
|
||||
|
||||
goToDeepServiceFromFace() {
|
||||
tt.navigateTo({ url: '/pages/purchase/index' })
|
||||
},
|
||||
|
||||
goToPromoFromFace() {
|
||||
tt.showShareMenu && tt.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage'] })
|
||||
tt.navigateTo({ url: '/pages/promo/index' })
|
||||
},
|
||||
|
||||
retake() {
|
||||
// camera 是 tabBar 页面,必须用 switchTab
|
||||
tt.switchTab({ url: '/pages/index/camera' })
|
||||
|
||||
@@ -116,18 +116,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="result-cta-questionnaire"
|
||||
hover-class="result-cta-questionnaire--pressed"
|
||||
hover-stay-time="120"
|
||||
bindtap="goToQuestionnaireTest"
|
||||
>
|
||||
<text class="result-cta-questionnaire-text">去做详细性格测试</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card" tt:if="{{!hasError && result.summary}}">
|
||||
<text class="card-title">性格概述</text>
|
||||
<text class="card-title">📝 性格概述</text>
|
||||
<text class="card-text">{{result.summary}}</text>
|
||||
</view>
|
||||
|
||||
@@ -337,4 +329,23 @@
|
||||
</block>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 固定底部操作栏:三按钮常驻(抖音) -->
|
||||
<view class="result-action-bar" tt:if="{{!isAnalyzing && showResult && !hasError}}">
|
||||
<view class="result-action-bar__btn result-action-bar__btn--test" bindtap="goToQuestionnaireFromFace">
|
||||
<text class="result-action-bar__ic">🧠</text>
|
||||
<text class="result-action-bar__label">详细性格测试</text>
|
||||
</view>
|
||||
<view class="result-action-bar__btn result-action-bar__btn--deep" bindtap="goToDeepServiceFromFace">
|
||||
<text class="result-action-bar__ic">💎</text>
|
||||
<text class="result-action-bar__label">找专家·深度解读</text>
|
||||
</view>
|
||||
<view class="result-action-bar__btn result-action-bar__btn--share" bindtap="goToPromoFromFace">
|
||||
<text class="result-action-bar__ic">📣</text>
|
||||
<view class="result-action-bar__share-body">
|
||||
<text class="result-action-bar__label">分享赚</text>
|
||||
<text class="result-action-bar__label-hl">90%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0 24rpx 60rpx;
|
||||
padding: 0 24rpx calc(200rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ========== 分析中模态框 ========== */
|
||||
@@ -1248,3 +1248,99 @@
|
||||
.boss-metric-value.low {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* ========== 底部固定操作栏(三按钮常驻) ========== */
|
||||
.result-action-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 20rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-top: 1rpx solid #eceff4;
|
||||
box-shadow: 0 -6rpx 24rpx rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.result-action-bar__btn {
|
||||
flex: 1;
|
||||
min-height: 96rpx;
|
||||
margin: 0;
|
||||
padding: 10rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.result-action-bar__ic {
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.result-action-bar__label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.result-action-bar__btn--test {
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||
}
|
||||
|
||||
.result-action-bar__btn--test .result-action-bar__label {
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.result-action-bar__btn--deep {
|
||||
background: linear-gradient(135deg, #fdf4ff 0%, #fae8ff 100%);
|
||||
}
|
||||
|
||||
.result-action-bar__btn--deep .result-action-bar__label {
|
||||
color: #a21caf;
|
||||
}
|
||||
|
||||
.result-action-bar__btn--share {
|
||||
background: linear-gradient(135deg, #ff6b9d 0%, #e63946 100%);
|
||||
box-shadow: 0 6rpx 20rpx rgba(230, 57, 70, 0.35);
|
||||
flex-direction: row;
|
||||
gap: 10rpx;
|
||||
padding: 10rpx 14rpx;
|
||||
}
|
||||
|
||||
.result-action-bar__btn--share .result-action-bar__ic {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.result-action-bar__share-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.result-action-bar__btn--share .result-action-bar__label {
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.result-action-bar__label-hl {
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1rpx;
|
||||
text-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ Page({
|
||||
goToPurchaseEnterprise() { tt.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
|
||||
goToEnterprise() { tt.navigateTo({ url: '/pages/enterprise/index' }) },
|
||||
goToPromo() { tt.navigateTo({ url: '/pages/promo/index' }) },
|
||||
goToPromoWithdrawals() { tt.navigateTo({ url: '/pages/promo/withdrawals' }) },
|
||||
goToMyResume() { tt.navigateTo({ url: '/pages/enterprise/resume-history' }) },
|
||||
goToSettings() {
|
||||
tt.showToast({ title: '开发中', icon: 'none' })
|
||||
|
||||
@@ -177,29 +177,23 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 推广中心(根据管理端开关显示/隐藏,标题可配置) -->
|
||||
<!-- 推广数据卡:可提现格子拆成两区(上=分销中心 / 下=提现记录) -->
|
||||
<view class="section px-section" tt:if="{{hasLogin && promoDistributionEnabled && !reviewMode && permDistribution}}">
|
||||
<view class="promo-card" bindtap="goToPromo">
|
||||
<view class="promo-header">
|
||||
<view class="promo-title-wrap">
|
||||
<text class="promo-icon">📈</text>
|
||||
<text class="promo-title">{{promoCenterTitle}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="promo-card">
|
||||
<view class="promo-stats">
|
||||
<view class="promo-stat-item">
|
||||
<view class="promo-stat-item" catchtap="goToPromo">
|
||||
<text class="promo-stat-label">邀请好友</text>
|
||||
<text class="promo-stat-value">{{promoTotalInvite}}</text>
|
||||
</view>
|
||||
<view class="promo-stat-divider"></view>
|
||||
<view class="promo-stat-item">
|
||||
<text class="promo-stat-label">累计收益</text>
|
||||
<text class="promo-stat-value">¥{{promoTotalEarned}}</text>
|
||||
<view class="promo-stat-item" catchtap="goToPromo">
|
||||
<text class="promo-stat-label">可提现 ›</text>
|
||||
<text class="promo-stat-value promo-highlight">¥{{promoWithdrawable}}</text>
|
||||
</view>
|
||||
<view class="promo-stat-divider"></view>
|
||||
<view class="promo-stat-item">
|
||||
<text class="promo-stat-label">可提现</text>
|
||||
<text class="promo-stat-value promo-highlight">¥{{promoWithdrawable}}</text>
|
||||
<view class="promo-stat-item promo-stat-item--sub" catchtap="goToPromoWithdrawals">
|
||||
<text class="promo-stat-label">累计 ›</text>
|
||||
<text class="promo-stat-value promo-stat-value--sub">¥{{promoTotalEarned}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -559,6 +559,21 @@ page {
|
||||
color: #F43F5E;
|
||||
}
|
||||
|
||||
/* "累计"辅格:紫色较小字号 */
|
||||
.promo-stat-item--sub .promo-stat-value {
|
||||
color: #a78bfa;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.promo-stat-item--sub .promo-stat-label {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.promo-stat-value--sub {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
/* ===== 服务菜单 ===== */
|
||||
.menu-card {
|
||||
background: #FFFFFF;
|
||||
|
||||
@@ -14,8 +14,7 @@ Page({
|
||||
successModal: {
|
||||
visible: false,
|
||||
title: '',
|
||||
content: '',
|
||||
wechat: ''
|
||||
content: ''
|
||||
}
|
||||
},
|
||||
|
||||
@@ -148,8 +147,7 @@ Page({
|
||||
this.setData({ purchasing: false })
|
||||
this._reportCrmLead(category, 'buy')
|
||||
const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
|
||||
const wechat = (category.serviceWechat || '').trim()
|
||||
this._showSuccessModal('购买成功', successMsg, wechat)
|
||||
this._showSuccessModal('购买成功', successMsg)
|
||||
},
|
||||
fail: () => {
|
||||
tt.hideLoading()
|
||||
@@ -159,27 +157,21 @@ Page({
|
||||
},
|
||||
|
||||
applyConsult(category) {
|
||||
// serviceWechat 展示给用户,consultWechat 是存客宝 API key
|
||||
const wechat = (category.serviceWechat || '').trim()
|
||||
const apiKey = (category.consultWechat || '').trim()
|
||||
const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
|
||||
tt.showLoading({ title: '提交中...', mask: true })
|
||||
if (apiKey) {
|
||||
this._reportCrmLead(category, 'consult')
|
||||
}
|
||||
setTimeout(() => {
|
||||
const done = () => {
|
||||
tt.hideLoading()
|
||||
this._showSuccessModal('申请成功', successMsg, wechat)
|
||||
}, 600)
|
||||
this._showSuccessModal('申请成功', successMsg)
|
||||
}
|
||||
this._reportCrmLead(category, 'consult', done)
|
||||
},
|
||||
|
||||
_showSuccessModal(title, content, wechat) {
|
||||
_showSuccessModal(title, content) {
|
||||
this.setData({
|
||||
successModal: {
|
||||
visible: true,
|
||||
title: title || '成功',
|
||||
content: content || '',
|
||||
wechat: wechat || ''
|
||||
content: content || ''
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -190,25 +182,23 @@ Page({
|
||||
this.setData({ 'successModal.visible': false })
|
||||
},
|
||||
|
||||
copyWechat() {
|
||||
const wechat = this.data.successModal.wechat
|
||||
if (!wechat) return
|
||||
tt.setClipboardData({
|
||||
data: wechat,
|
||||
success: () => tt.showToast({ title: '已复制微信号', icon: 'success' })
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 向后端上报存客宝线索,后端负责签名和调用存客宝 API
|
||||
* @param {Object} category 深度服务类目对象(需含 consultWechat / title)
|
||||
* @param {Object} category 深度服务类目对象(consultWechat 可空,申请咨询时由后端按企业配置回落)
|
||||
* @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询)
|
||||
* @param {Function} [onDone]
|
||||
*/
|
||||
_reportCrmLead(category, actionType) {
|
||||
const apiKey = category.consultWechat || ''
|
||||
if (!apiKey) return
|
||||
_reportCrmLead(category, actionType, onDone) {
|
||||
const apiKey = (category.consultWechat || '').trim()
|
||||
const apiBase = app.globalData.apiBase || ''
|
||||
if (!apiBase) return
|
||||
if (actionType === 'buy' && !apiKey) {
|
||||
if (typeof onDone === 'function') onDone()
|
||||
return
|
||||
}
|
||||
if (!apiBase) {
|
||||
if (typeof onDone === 'function') onDone()
|
||||
return
|
||||
}
|
||||
|
||||
const isEnterprise = this.data.activeTab === 'enterprise'
|
||||
const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
|
||||
@@ -226,6 +216,7 @@ Page({
|
||||
source,
|
||||
remark,
|
||||
siteTags: category.title || '',
|
||||
deepConsult: actionType === 'consult',
|
||||
},
|
||||
success(res) {
|
||||
console.log('[CRM] 线索上报结果', res.data)
|
||||
@@ -233,6 +224,9 @@ Page({
|
||||
fail(err) {
|
||||
console.warn('[CRM] 线索上报请求失败', err)
|
||||
},
|
||||
complete() {
|
||||
if (typeof onDone === 'function') onDone()
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -10,12 +10,7 @@
|
||||
</view>
|
||||
<text class="success-dialog-title">{{successModal.title}}</text>
|
||||
<text class="success-dialog-content">{{successModal.content}}</text>
|
||||
<view tt:if="{{successModal.wechat}}" class="success-wechat-wrap">
|
||||
<text class="success-wechat-label">客服微信</text>
|
||||
<text class="success-wechat-val">{{successModal.wechat}}</text>
|
||||
</view>
|
||||
<view class="success-dialog-btns">
|
||||
<button tt:if="{{successModal.wechat}}" class="success-btn-copy" bindtap="copyWechat">复制微信号</button>
|
||||
<button class="success-btn-close" bindtap="closeSuccessModal">我知道了</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -59,6 +59,21 @@ function applyDrawCountAfterShuffle(questions) {
|
||||
return questions.slice(0, n)
|
||||
}
|
||||
|
||||
function parseJsonBody(raw) {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) return raw
|
||||
if (typeof raw === 'string') {
|
||||
const s = raw.trim()
|
||||
if (!s) return {}
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch (e) {
|
||||
return { _parseError: true }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function fetchQuestionBank(type, enterpriseId) {
|
||||
const q = [`type=${encodeURIComponent(type)}`]
|
||||
if (enterpriseId != null && Number(enterpriseId) > 0) {
|
||||
@@ -69,9 +84,14 @@ function fetchQuestionBank(type, enterpriseId) {
|
||||
method: 'GET',
|
||||
needAuth: true
|
||||
}).then((res) => {
|
||||
const body = res.data || {}
|
||||
const body = parseJsonBody(res.data)
|
||||
if (body._parseError) {
|
||||
throw new Error('接口返回非 JSON,请检查 apiBase 与服务器域名')
|
||||
}
|
||||
if (body.code !== 200 || body.data == null) {
|
||||
throw new Error(body.message || '拉取题库失败')
|
||||
const msg = body.message || '拉取题库失败'
|
||||
const hint = body.code === 401 || body.code === 403 ? `${msg}(请重新进入小程序)` : msg
|
||||
throw new Error(hint)
|
||||
}
|
||||
const list = body.data.list
|
||||
if (!Array.isArray(list)) {
|
||||
|
||||
4
miniprogram/.env.example
Normal file
4
miniprogram/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# 复制本文件为同目录下的 .env 并填写 Secret
|
||||
# cp .env.example .env
|
||||
WX_MINIPROGRAM_APPID=wx3d15ed02e98b04e3
|
||||
WX_MINIPROGRAM_APPSECRET=
|
||||
@@ -46,9 +46,9 @@ App({
|
||||
enterpriseIdFromScene: null,
|
||||
// 超管配置的默认企业 ID(无 scene/eid 等入口参数时回落)
|
||||
defaultEnterpriseId: null,
|
||||
// API基础地址(开发时用本地,生产环境替换为实际域名)
|
||||
//apiBase: 'https://mbtiapi.quwanzhi.com',
|
||||
apiBase: 'http://mbti.com',
|
||||
// API 基础地址:默认走线上;本机/内网调试可在开发者工具执行
|
||||
// wx.setStorageSync('apiBaseOverride', 'https://你的调试域名') 后重启小程序
|
||||
apiBase: 'https://mbtiapi.quwanzhi.com',
|
||||
// VIP信息
|
||||
vipInfo: null,
|
||||
// 测试次数
|
||||
@@ -69,6 +69,13 @@ App({
|
||||
// 记录场景值供埋点使用
|
||||
this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : ''
|
||||
|
||||
try {
|
||||
const ov = wx.getStorageSync('apiBaseOverride')
|
||||
if (ov && typeof ov === 'string' && ov.trim()) {
|
||||
this.globalData.apiBase = ov.trim().replace(/\/$/, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const { mergeThirdPartyFromQuery } = require('./utils/thirdPartyContext.js')
|
||||
mergeThirdPartyFromQuery((launchOptions && launchOptions.query) || {})
|
||||
@@ -83,6 +90,14 @@ App({
|
||||
analytics.reportAppLaunch(launchOptions)
|
||||
} catch (e) {}
|
||||
|
||||
// 先读取缓存的 TabBar 配置,避免首屏 flash(后续拉接口再刷新)
|
||||
try {
|
||||
const cachedTabBar = wx.getStorageSync('tabBarConfig')
|
||||
if (cachedTabBar && Array.isArray(cachedTabBar.items) && cachedTabBar.items.length >= 2) {
|
||||
this.globalData.tabBar = cachedTabBar
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 必须先完成静默登录再拉 runtime:否则无 token 时 enterprisePermissions 永远 null,清除缓存后必现「要二次刷新」
|
||||
this.silentLogin()
|
||||
.catch(() => {})
|
||||
@@ -91,6 +106,46 @@ App({
|
||||
this._afterRuntimeSynced()
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
// 并行拉取 TabBar 动态配置(不阻塞主流程)
|
||||
this.loadTabBarConfig().catch(() => {})
|
||||
},
|
||||
|
||||
/**
|
||||
* 拉取小程序 TabBar 动态配置(后台可调整顺序/显示)
|
||||
* 写入 globalData.tabBar 并缓存到本地
|
||||
*/
|
||||
loadTabBarConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const base = (this.globalData.apiBase || '').replace(/\/$/, '')
|
||||
if (!base) { resolve(null); return }
|
||||
wx.request({
|
||||
url: base + '/api/mp/tabbar',
|
||||
method: 'GET',
|
||||
timeout: 6000,
|
||||
success: (res) => {
|
||||
if (res && res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data) {
|
||||
const cfg = res.data.data
|
||||
if (Array.isArray(cfg.items) && cfg.items.length >= 2) {
|
||||
this.globalData.tabBar = cfg
|
||||
try { wx.setStorageSync('tabBarConfig', cfg) } catch (e) {}
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
if (pages && pages.length > 0) {
|
||||
const top = pages[pages.length - 1]
|
||||
if (top && typeof top.getTabBar === 'function') {
|
||||
const tb = top.getTabBar()
|
||||
if (tb && typeof tb.refreshFromConfig === 'function') tb.refreshFromConfig()
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
resolve(null)
|
||||
},
|
||||
fail: () => resolve(null)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -254,21 +309,28 @@ App({
|
||||
}
|
||||
if (user) {
|
||||
this.globalData.userInfo = user
|
||||
// 使用微信真实 openid,而不是 wechat_users 表的 id
|
||||
this.globalData.openId = user.openid || user.openId || null
|
||||
wx.setStorageSync('userInfo', user)
|
||||
}
|
||||
// 登录成功后处理分销绑定(若进入时携带了推荐人参数)
|
||||
if (token) {
|
||||
this._tryDistributionBind()
|
||||
try {
|
||||
require('./utils/analytics.js').track('login_silent_success', {
|
||||
userId: user && user.id,
|
||||
hasEnterprise: !!(user && user.enterpriseId),
|
||||
invited: !!(this.globalData._pendingInviterId)
|
||||
})
|
||||
} catch (e) {}
|
||||
}
|
||||
resolve(!!token)
|
||||
return
|
||||
}
|
||||
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'non_200' }) } catch (e) {}
|
||||
resolve(false)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('登录请求失败:', err)
|
||||
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'network' }) } catch (e) {}
|
||||
const storedToken = wx.getStorageSync('token')
|
||||
const storedUser = wx.getStorageSync('userInfo')
|
||||
if (storedToken) {
|
||||
@@ -282,18 +344,43 @@ App({
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => resolve(false)
|
||||
fail: () => {
|
||||
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'wx_login' }) } catch (e) {}
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 确保已登录:有 token 直接 resolve,否则先执行静默登录
|
||||
* 加 15s 超时兜底:若 wx.login / wx.request 卡住,强制 resolve(false),避免调用方 promise 悬挂
|
||||
* @returns {Promise<boolean>} 当前是否有有效登录态
|
||||
*/
|
||||
ensureLogin() {
|
||||
if (this.globalData.token) return Promise.resolve(true)
|
||||
return this.silentLogin()
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'timeout' }) } catch (e) {}
|
||||
resolve(false)
|
||||
}, 15000)
|
||||
this.silentLogin()
|
||||
.then((ok) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(!!ok)
|
||||
})
|
||||
.catch(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"pages/index/upload",
|
||||
"pages/index/result",
|
||||
"pages/test-select/index",
|
||||
"pages/ai-test/index",
|
||||
"pages/test/mbti",
|
||||
"pages/test/sbti",
|
||||
"pages/test/disc",
|
||||
@@ -25,7 +26,12 @@
|
||||
"pages/phone-auth/index",
|
||||
"pages/promo/index",
|
||||
"pages/promo/poster",
|
||||
"pages/promo/withdrawals"
|
||||
"pages/promo/withdrawals",
|
||||
"pages/match-job/index",
|
||||
"pages/ai-chat/index",
|
||||
"pages/ai-chat/report",
|
||||
"pages/ai-chat/history",
|
||||
"pages/webview/index"
|
||||
],
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
@@ -46,6 +52,12 @@
|
||||
"iconPath": "images/camera.png",
|
||||
"selectedIconPath": "images/camera-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/ai-chat/index",
|
||||
"text": "神仙AI",
|
||||
"iconPath": "images/home.png",
|
||||
"selectedIconPath": "images/home-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的",
|
||||
|
||||
@@ -1,176 +1,193 @@
|
||||
/* app.wxss - 全局样式 */
|
||||
|
||||
/* 主题颜色 */
|
||||
page {
|
||||
--primary-color: #FF6B8A;
|
||||
--primary-light: #FFE4E9;
|
||||
--secondary-color: #8B5CF6;
|
||||
--secondary-light: #EDE9FE;
|
||||
--success-color: #10B981;
|
||||
--warning-color: #F59E0B;
|
||||
--danger-color: #EF4444;
|
||||
--text-primary: #1F2937;
|
||||
--text-secondary: #6B7280;
|
||||
--text-muted: #9CA3AF;
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-secondary: #F3F4F6;
|
||||
--border-color: #E5E7EB;
|
||||
|
||||
background-color: var(--bg-secondary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 玻璃卡片效果 */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.08);
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* 渐变背景 */
|
||||
.gradient-personal {
|
||||
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
|
||||
}
|
||||
|
||||
.gradient-enterprise {
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #3B82F6 100%);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #FFFFFF;
|
||||
color: var(--primary-color);
|
||||
border: 2rpx solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 2rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 12rpx;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 6rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 24rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-primary {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tag-secondary {
|
||||
background: var(--secondary-light);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 间距 */
|
||||
.mt-2 { margin-top: 16rpx; }
|
||||
.mt-4 { margin-top: 32rpx; }
|
||||
.mb-2 { margin-bottom: 16rpx; }
|
||||
.mb-4 { margin-bottom: 32rpx; }
|
||||
.p-4 { padding: 32rpx; }
|
||||
|
||||
/* 弹性布局 */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 安全区域 */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: calc(constant(safe-area-inset-bottom) + 16rpx);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 16rpx);
|
||||
}
|
||||
/* app.wxss - 全局样式 */
|
||||
|
||||
/* 主题颜色 */
|
||||
page {
|
||||
--primary-color: #FF6B8A;
|
||||
--primary-light: #FFE4E9;
|
||||
--secondary-color: #8B5CF6;
|
||||
--secondary-light: #EDE9FE;
|
||||
--success-color: #10B981;
|
||||
--warning-color: #F59E0B;
|
||||
--danger-color: #EF4444;
|
||||
--text-primary: #1F2937;
|
||||
--text-secondary: #6B7280;
|
||||
--text-muted: #9CA3AF;
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-secondary: #F3F4F6;
|
||||
--border-color: #E5E7EB;
|
||||
|
||||
background-color: var(--bg-secondary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 玻璃卡片效果 */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.08);
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* 渐变背景 */
|
||||
.gradient-personal {
|
||||
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
|
||||
}
|
||||
|
||||
.gradient-enterprise {
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #3B82F6 100%);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #FFFFFF;
|
||||
color: var(--primary-color);
|
||||
border: 2rpx solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 2rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 12rpx;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 6rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 24rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-primary {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tag-secondary {
|
||||
background: var(--secondary-light);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 间距 */
|
||||
.mt-2 { margin-top: 16rpx; }
|
||||
.mt-4 { margin-top: 32rpx; }
|
||||
.mb-2 { margin-bottom: 16rpx; }
|
||||
.mb-4 { margin-bottom: 32rpx; }
|
||||
.p-4 { padding: 32rpx; }
|
||||
|
||||
/* 弹性布局 */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 安全区域 */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: calc(constant(safe-area-inset-bottom) + 16rpx);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 16rpx);
|
||||
}
|
||||
|
||||
/* ============ 全局 TabBar 底部避让 ============ */
|
||||
/* 自定义 TabBar 高度约 140rpx + 底部安全区。
|
||||
在 tab 页外层 view 加 .tabbar-pad 即可避免被遮挡;
|
||||
也可直接写 style="padding-bottom: var(--tabbar-pad)" */
|
||||
page {
|
||||
--tabbar-height: 140rpx;
|
||||
--tabbar-pad: calc(var(--tabbar-height) + env(safe-area-inset-bottom) + 20rpx);
|
||||
}
|
||||
|
||||
.tabbar-pad {
|
||||
padding-bottom: var(--tabbar-pad) !important;
|
||||
}
|
||||
|
||||
.tabbar-pad-fixed {
|
||||
bottom: var(--tabbar-pad) !important;
|
||||
}
|
||||
|
||||
23
miniprogram/components/result-section-nav/index.js
Normal file
23
miniprogram/components/result-section-nav/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// components/result-section-nav/index.js
|
||||
// 结果页顶部分类锚点导航:横向 chip 条,点击触发 navtap 事件,由页面 scroll-into-view 接管跳转
|
||||
Component({
|
||||
options: {
|
||||
addGlobalClass: true,
|
||||
multipleSlots: false
|
||||
},
|
||||
properties: {
|
||||
// [{ id: 'sec-insight', label: '洞察', emoji: '🧠' }, ...]
|
||||
sections: { type: Array, value: [] },
|
||||
// 当前高亮的 section id(可选;暂未接滚动联动)
|
||||
active: { type: String, value: '' },
|
||||
// 色系锚点:purple / blue / orange / teal(对应 MBTI/DISC/PDP/SBTI)
|
||||
theme: { type: String, value: 'purple' }
|
||||
},
|
||||
methods: {
|
||||
onTap(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
if (!id) return
|
||||
this.triggerEvent('navtap', { id })
|
||||
}
|
||||
}
|
||||
})
|
||||
4
miniprogram/components/result-section-nav/index.json
Normal file
4
miniprogram/components/result-section-nav/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
17
miniprogram/components/result-section-nav/index.wxml
Normal file
17
miniprogram/components/result-section-nav/index.wxml
Normal file
@@ -0,0 +1,17 @@
|
||||
<!--components/result-section-nav/index.wxml-->
|
||||
<view class="rsn rsn--{{theme}}" wx:if="{{sections && sections.length}}">
|
||||
<scroll-view class="rsn__scroll" scroll-x enhanced show-scrollbar="{{false}}">
|
||||
<view class="rsn__row">
|
||||
<view
|
||||
class="rsn__chip {{active === item.id ? 'rsn__chip--active' : ''}}"
|
||||
wx:for="{{sections}}"
|
||||
wx:key="id"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="onTap"
|
||||
>
|
||||
<text wx:if="{{item.emoji}}" class="rsn__emoji">{{item.emoji}}</text>
|
||||
<text class="rsn__label">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
67
miniprogram/components/result-section-nav/index.wxss
Normal file
67
miniprogram/components/result-section-nav/index.wxss
Normal file
@@ -0,0 +1,67 @@
|
||||
/* components/result-section-nav/index.wxss */
|
||||
.rsn {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0.94) 100%);
|
||||
backdrop-filter: saturate(140%) blur(8rpx);
|
||||
border-bottom: 1rpx solid #f1f5f9;
|
||||
padding: 14rpx 8rpx 12rpx;
|
||||
}
|
||||
|
||||
.rsn__scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rsn__row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.rsn__chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 22rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #f1f5f9;
|
||||
border: 1rpx solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rsn__emoji {
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rsn__label {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rsn__chip--active {
|
||||
background: #fff;
|
||||
border-color: currentColor;
|
||||
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
/* 色系:与四测 hero 色对齐 */
|
||||
.rsn--purple .rsn__chip--active { color: #6366f1; }
|
||||
.rsn--purple .rsn__chip--active .rsn__label { color: #4f46e5; font-weight: 600; }
|
||||
|
||||
.rsn--blue .rsn__chip--active { color: #0ea5e9; }
|
||||
.rsn--blue .rsn__chip--active .rsn__label { color: #0284c7; font-weight: 600; }
|
||||
|
||||
.rsn--orange .rsn__chip--active { color: #f97316; }
|
||||
.rsn--orange .rsn__chip--active .rsn__label { color: #ea580c; font-weight: 600; }
|
||||
|
||||
.rsn--teal .rsn__chip--active { color: #14b8a6; }
|
||||
.rsn--teal .rsn__chip--active .rsn__label { color: #0d9488; font-weight: 600; }
|
||||
@@ -1,28 +1,65 @@
|
||||
// 自定义 tabBar(不使用微信内置):灰线 + 三栏,中间为浮起圆钮
|
||||
// 自定义 TabBar:完全由后台配置驱动
|
||||
// 数据来源:app.globalData.tabBar.items(GET /api/mp/tabbar)
|
||||
// 兜底:若配置缺失,使用默认 4 项(首页·拍摄·神仙AI·我)
|
||||
|
||||
const DEFAULT_ITEMS = [
|
||||
{ pagePath: 'pages/index/index', text: '首页', iconKey: 'home', iconUrl: '', highlight: false },
|
||||
{ pagePath: 'pages/index/camera', text: '拍摄', iconKey: 'camera', iconUrl: '', highlight: true },
|
||||
{ pagePath: 'pages/ai-chat/index', text: '神仙AI', iconKey: 'ai', iconUrl: '/images/shenxian-oldman-circle.png', highlight: false },
|
||||
{ pagePath: 'pages/profile/index', text: '我', iconKey: 'profile', iconUrl: '', highlight: false }
|
||||
]
|
||||
|
||||
Component({
|
||||
data: {
|
||||
selected: 0,
|
||||
/** 审核模式 */
|
||||
reviewMode: false,
|
||||
/** 为 true 时隐藏中间「拍摄」:审核模式 或 企业权限关闭 face */
|
||||
hideMiddleFab: false,
|
||||
list: [
|
||||
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
|
||||
{ pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
|
||||
{ pagePath: '/pages/profile/index', text: '我的', textKey: 'profile', icon: 'user' }
|
||||
]
|
||||
items: [],
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
this.updateSelected()
|
||||
this.refreshFromConfig()
|
||||
}
|
||||
},
|
||||
|
||||
pageLifetimes: {
|
||||
show() {
|
||||
this.updateSelected()
|
||||
this.refreshFromConfig()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshFromConfig() {
|
||||
try {
|
||||
const app = getApp() || {}
|
||||
const gd = app.globalData || {}
|
||||
const cfg = gd.tabBar && Array.isArray(gd.tabBar.items) ? gd.tabBar.items : null
|
||||
const rawItems = (cfg && cfg.length >= 2) ? cfg.slice() : DEFAULT_ITEMS.slice()
|
||||
const items = rawItems.map((it) => ({
|
||||
...it,
|
||||
// 仅神仙AI使用指定 logo;其他图标保持后台配置
|
||||
iconUrl: it.iconKey === 'ai' ? '/images/shenxian-oldman-circle.png' : (it.iconUrl || '')
|
||||
}))
|
||||
|
||||
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
|
||||
const ep = gd.enterprisePermissions
|
||||
const faceOff = !!(ep && ep.face === false)
|
||||
const hideMiddleFab = reviewMode || faceOff
|
||||
|
||||
// 审核模式 / face 关闭时:隐藏所有 highlight=true 的 Tab
|
||||
const effectiveItems = items.filter(it => !(hideMiddleFab && it.highlight))
|
||||
|
||||
this.setData({
|
||||
items: effectiveItems,
|
||||
reviewMode,
|
||||
hideMiddleFab,
|
||||
}, () => this.updateSelected())
|
||||
} catch (e) {
|
||||
this.setData({ items: DEFAULT_ITEMS, reviewMode: false, hideMiddleFab: false }, () => this.updateSelected())
|
||||
}
|
||||
},
|
||||
|
||||
updateSelected() {
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
@@ -30,35 +67,41 @@ Component({
|
||||
const currentPage = pages[pages.length - 1]
|
||||
if (!currentPage || !currentPage.route) return
|
||||
const url = currentPage.route
|
||||
const gd = getApp().globalData || {}
|
||||
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
|
||||
const ep = gd.enterprisePermissions
|
||||
const hideMiddleFab = reviewMode || !!(ep && ep.face === false)
|
||||
const items = this.data.items || []
|
||||
|
||||
let selected = 0
|
||||
if (url === 'pages/index/index' || url === 'pages/enterprise/index') {
|
||||
selected = 0
|
||||
} else if (url === 'pages/index/camera') {
|
||||
selected = hideMiddleFab ? 0 : 1
|
||||
} else if (url === 'pages/profile/index') {
|
||||
selected = 2
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const path = (items[i].pagePath || '').replace(/^\//, '')
|
||||
if (url === path) { selected = i; break }
|
||||
// 企业首页归一到"首页"Tab
|
||||
if (url === 'pages/enterprise/index' && path === 'pages/index/index') { selected = i; break }
|
||||
if (url === 'pages/index/camera' && items[i].highlight) { selected = i; break }
|
||||
}
|
||||
this.setData({ selected, reviewMode, hideMiddleFab })
|
||||
} catch (error) {
|
||||
console.error('updateSelected error:', error)
|
||||
this.setData({ selected: 0, reviewMode: false, hideMiddleFab: false })
|
||||
this.setData({ selected })
|
||||
} catch (err) {
|
||||
this.setData({ selected: 0 })
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(e) {
|
||||
const index = parseInt(e.currentTarget.dataset.index, 10)
|
||||
let url = e.currentTarget.dataset.path
|
||||
const it = (this.data.items || [])[index]
|
||||
if (!it) return
|
||||
|
||||
const gd = getApp().globalData || {}
|
||||
const faceOff = !!(gd.enterprisePermissions && gd.enterprisePermissions.face === false)
|
||||
if (index === 1 && (!!(gd.reviewMode || gd.maintenanceMode) || faceOff)) {
|
||||
return
|
||||
// 中间浮钮在审核模式下被 refreshFromConfig 过滤掉了,不会到这里
|
||||
|
||||
// 神仙 AI Tab 的轻量埋点
|
||||
if ((it.iconKey === 'ai' || /ai-chat/.test(it.pagePath))) {
|
||||
try {
|
||||
const analytics = require('../utils/analytics.js')
|
||||
if (analytics && typeof analytics.track === 'function') {
|
||||
analytics.track('tap_tab_ai_chat', {})
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
// 首页点击:企业作用域走企业首页(保留旧行为)
|
||||
if (it.pagePath === 'pages/index/index' || it.iconKey === 'home') {
|
||||
try {
|
||||
const app = getApp()
|
||||
const scope = (app && app.globalData && app.globalData.appScope) || 'personal'
|
||||
@@ -70,7 +113,14 @@ Component({
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
wx.switchTab({ url })
|
||||
const url = '/' + it.pagePath.replace(/^\//, '')
|
||||
wx.switchTab({
|
||||
url,
|
||||
fail: () => {
|
||||
// 非 tabBar 页面 fallback 到 navigateTo
|
||||
wx.navigateTo({ url })
|
||||
}
|
||||
})
|
||||
this.setData({ selected: index })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
<!-- 自定义 tabBar:顶部分割线 + 三栏;中间单独一列避免浮钮与左右叠层 -->
|
||||
<!-- 自定义 TabBar:由 globalData.tabBar 动态渲染;支持 iconUrl 图片覆盖 SVG;highlight=中间浮钮 -->
|
||||
<view class="tab-bar">
|
||||
<view class="tab-bar-line"></view>
|
||||
<view class="tab-bar-inner {{hideMiddleFab ? 'tab-bar-inner--audit' : ''}}">
|
||||
<view
|
||||
class="tab-item {{selected === 0 ? 'active' : ''}}"
|
||||
data-index="0"
|
||||
data-path="/pages/index/index"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
<image class="tab-icon" src="{{selected === 0 ? '/images/home-active.png' : '/images/home.png'}}" mode="aspectFit" />
|
||||
<text class="tab-text">首页</text>
|
||||
</view>
|
||||
<view class="tab-bar-inner tab-bar-count-{{items.length}}">
|
||||
<block wx:for="{{items}}" wx:key="pagePath" wx:for-item="it">
|
||||
<!-- 中间浮钮(highlight=true) -->
|
||||
<view wx:if="{{it.highlight}}" class="tab-slot-middle">
|
||||
<view
|
||||
class="middle-fab {{selected === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
<view class="center-circle">
|
||||
<image wx:if="{{it.iconUrl}}" class="center-img" src="{{it.iconUrl}}" mode="aspectFit" />
|
||||
<view wx:else class="center-svg center-svg--{{it.iconKey || 'camera'}}"></view>
|
||||
</view>
|
||||
<text class="tab-text tab-text-fab">{{it.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 中间列:非审核模式显示「拍摄」 -->
|
||||
<view class="tab-slot-middle" wx:if="{{!hideMiddleFab}}">
|
||||
<!-- 普通 Tab item -->
|
||||
<view
|
||||
class="middle-fab {{selected === 1 ? 'active' : ''}}"
|
||||
data-index="1"
|
||||
data-path="/pages/index/camera"
|
||||
wx:else
|
||||
class="tab-item {{selected === index ? 'active' : ''}} tab-item--{{it.iconKey || 'home'}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
<view class="center-circle">
|
||||
<image class="center-icon" src="{{selected === 1 ? '/images/camera-active.png' : '/images/camera.png'}}" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="tab-text tab-text-fab">拍摄</text>
|
||||
<image
|
||||
wx:if="{{it.iconUrl}}"
|
||||
class="tab-icon-img {{selected === index ? 'tab-icon-img--active' : ''}}"
|
||||
src="{{it.iconUrl}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view
|
||||
wx:else
|
||||
class="tab-icon tab-icon--{{it.iconKey || 'home'}} {{selected === index ? 'tab-icon--active' : ''}}"
|
||||
></view>
|
||||
<text class="tab-text">{{it.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="tab-item {{selected === 2 ? 'active' : ''}}"
|
||||
data-index="2"
|
||||
data-path="/pages/profile/index"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
<image class="tab-icon" src="{{selected === 2 ? '/images/user-active.png' : '/images/user.png'}}" mode="aspectFit" />
|
||||
<text class="tab-text">我的</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 自定义 tabBar:白底、顶部分割线、中间浮起圆钮 */
|
||||
/* 自定义 TabBar:白底、顶部分割线、支持 highlight 圆钮 · 动态数量 */
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -18,19 +18,16 @@
|
||||
.tab-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: space-around;
|
||||
min-height: 120rpx;
|
||||
padding: 12rpx 12rpx 10rpx;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 审核模式仅「首页」「我的」两栏时,左右均匀留白 */
|
||||
.tab-bar-inner--audit {
|
||||
justify-content: space-between;
|
||||
padding-left: 56rpx;
|
||||
padding-right: 56rpx;
|
||||
}
|
||||
.tab-bar-count-3 { padding-left: 40rpx; padding-right: 40rpx; }
|
||||
.tab-bar-count-4,
|
||||
.tab-bar-count-5 { padding-left: 12rpx; padding-right: 12rpx; }
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
@@ -47,6 +44,32 @@
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin-bottom: 4rpx;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 44rpx 44rpx;
|
||||
}
|
||||
|
||||
/* 图片形态的图标(iconUrl 传图片时使用,保留圆角便于 logo 类图片显示) */
|
||||
.tab-icon-img {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
margin-bottom: 4rpx;
|
||||
border-radius: 12rpx;
|
||||
filter: grayscale(60%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.tab-icon-img--active {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4rpx 10rpx rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
|
||||
/* 中间浮钮的图片形态 */
|
||||
.center-img {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
@@ -58,7 +81,43 @@
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* 中间列:独立层叠上下文,浮钮不盖住左右 tab */
|
||||
/* ===== 图标:home ===== */
|
||||
.tab-icon--home {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 12l9-9 9 9'/><path d='M5 10v10h14V10'/><path d='M10 20v-6h4v6'/></svg>");
|
||||
}
|
||||
.tab-icon--home.tab-icon--active,
|
||||
.tab-item.active .tab-icon--home {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><path d='M3 12l9-9 9 9'/><path d='M5 10v10h14V10'/><path d='M10 20v-6h4v6' fill='none' stroke='%23ffffff'/></svg>");
|
||||
}
|
||||
|
||||
/* ===== 图标:ai (sparkle) ===== */
|
||||
.tab-icon--ai {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1'/><circle cx='12' cy='12' r='3'/></svg>");
|
||||
}
|
||||
.tab-icon--ai.tab-icon--active,
|
||||
.tab-item.active .tab-icon--ai {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1' stroke='%237c3aed' fill='none' stroke-linecap='round'/><circle cx='12' cy='12' r='3.5' fill='%237c3aed'/></svg>");
|
||||
}
|
||||
|
||||
/* ===== 图标:profile ===== */
|
||||
.tab-icon--profile {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='8' r='4'/><path d='M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8'/></svg>");
|
||||
}
|
||||
.tab-icon--profile.tab-icon--active,
|
||||
.tab-item.active .tab-icon--profile {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><circle cx='12' cy='8' r='4'/><path d='M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8' fill='%237c3aed'/></svg>");
|
||||
}
|
||||
|
||||
/* ===== 图标:camera(作为普通 Tab 时用) ===== */
|
||||
.tab-icon--camera {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>");
|
||||
}
|
||||
.tab-icon--camera.tab-icon--active,
|
||||
.tab-item.active .tab-icon--camera {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4' fill='%23ffffff' stroke='%237c3aed'/></svg>");
|
||||
}
|
||||
|
||||
/* ===== 中间浮钮 ===== */
|
||||
.tab-slot-middle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -71,7 +130,6 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* 浮钮:整体上移,更突出圆形快门、减少与底栏顶边重叠感 */
|
||||
.middle-fab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -104,9 +162,19 @@
|
||||
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.6);
|
||||
}
|
||||
|
||||
.center-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
.center-svg {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 42rpx 42rpx;
|
||||
}
|
||||
|
||||
.center-svg--camera {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>");
|
||||
}
|
||||
.center-svg--ai {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1'/><circle cx='12' cy='12' r='3.5' fill='%23ffffff'/></svg>");
|
||||
}
|
||||
|
||||
.tab-text-fab {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 99 KiB |
BIN
miniprogram/images/shenxian-ai-logo.png
Normal file
BIN
miniprogram/images/shenxian-ai-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
BIN
miniprogram/images/shenxian-oldman-circle.png
Normal file
BIN
miniprogram/images/shenxian-oldman-circle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
59
miniprogram/pages/ai-chat/history.js
Normal file
59
miniprogram/pages/ai-chat/history.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const { request } = require('../../utils/request.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true
|
||||
},
|
||||
onShow() {
|
||||
this.setData({ list: [], page: 1, hasMore: true }, () => this.fetchPage())
|
||||
},
|
||||
fetchPage() {
|
||||
const { page, pageSize, list, hasMore } = this.data
|
||||
if (!hasMore) return
|
||||
request({
|
||||
url: '/api/ai/conversations',
|
||||
method: 'GET',
|
||||
data: { page, pageSize },
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code !== 200) return
|
||||
const d = body.data || {}
|
||||
const items = (d.list || []).map(x => ({
|
||||
id: x.id,
|
||||
title: x.title,
|
||||
mbtiType: x.mbtiType,
|
||||
messageCount: x.messageCount,
|
||||
lastMessageAt: x.lastMessageAt,
|
||||
lastMessageAtStr: formatTime(x.lastMessageAt)
|
||||
}))
|
||||
this.setData({
|
||||
list: list.concat(items),
|
||||
hasMore: !!d.hasMore,
|
||||
page: page + 1
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
onReachBottom() {
|
||||
this.fetchPage()
|
||||
},
|
||||
onOpen(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
wx.navigateTo({ url: `/pages/ai-chat/index?cid=${id}` })
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
const d = new Date(t * 1000)
|
||||
const now = Date.now()
|
||||
const diff = now - t * 1000
|
||||
if (diff < 86400000) {
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
return `${d.getMonth() + 1}-${d.getDate()}`
|
||||
}
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n }
|
||||
7
miniprogram/pages/ai-chat/history.json
Normal file
7
miniprogram/pages/ai-chat/history.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "神仙 AI · 历史对话",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F3FF",
|
||||
"usingComponents": {}
|
||||
}
|
||||
14
miniprogram/pages/ai-chat/history.wxml
Normal file
14
miniprogram/pages/ai-chat/history.wxml
Normal file
@@ -0,0 +1,14 @@
|
||||
<view class="history-page">
|
||||
<block wx:if="{{list.length > 0}}">
|
||||
<view class="item" wx:for="{{list}}" wx:key="id" bindtap="onOpen" data-id="{{item.id}}">
|
||||
<view class="item-title">{{item.title || '未命名对话'}}</view>
|
||||
<view class="item-meta">
|
||||
<text class="mbti-tag" wx:if="{{item.mbtiType}}">{{item.mbtiType}}</text>
|
||||
<text class="meta">{{item.messageCount || 0}} 条消息</text>
|
||||
<text class="meta">{{item.lastMessageAtStr}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty" wx:if="{{!hasMore && list.length > 0}}">— 到底啦 —</view>
|
||||
</block>
|
||||
<view class="empty" wx:else>还没有历史对话,快去和神仙 AI 聊聊吧~</view>
|
||||
</view>
|
||||
39
miniprogram/pages/ai-chat/history.wxss
Normal file
39
miniprogram/pages/ai-chat/history.wxss
Normal file
@@ -0,0 +1,39 @@
|
||||
.history-page {
|
||||
min-height: 100vh;
|
||||
background: #F5F3FF;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.item {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.06);
|
||||
}
|
||||
.item-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1F1B4D;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: #6B6894;
|
||||
}
|
||||
.mbti-tag {
|
||||
background: #EDE9FE;
|
||||
color: #7c3aed;
|
||||
border-radius: 100rpx;
|
||||
padding: 2rpx 16rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9CA3AF;
|
||||
padding: 96rpx 0 40rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
396
miniprogram/pages/ai-chat/index.js
Normal file
396
miniprogram/pages/ai-chat/index.js
Normal file
@@ -0,0 +1,396 @@
|
||||
// 神仙 AI · 对话页
|
||||
const { request } = require('../../utils/request.js')
|
||||
const analytics = require('../../utils/analytics.js')
|
||||
|
||||
// 兜底封面(base64 SVG,柔和渐变 + 圆角感)
|
||||
const DEFAULT_COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120">' +
|
||||
'<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">' +
|
||||
'<stop offset="0%" stop-color="#c4b5fd"/><stop offset="100%" stop-color="#7c3aed"/>' +
|
||||
'</linearGradient></defs>' +
|
||||
'<rect width="200" height="120" rx="16" fill="url(%23g)"/>' +
|
||||
'<text x="100" y="56" font-size="28" fill="white" text-anchor="middle" font-family="PingFang SC,sans-serif" font-weight="700">读</text>' +
|
||||
'<text x="100" y="82" font-size="11" fill="rgba(255,255,255,0.9)" text-anchor="middle" font-family="PingFang SC,sans-serif">好文</text>' +
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
Page({
|
||||
data: {
|
||||
articles: [],
|
||||
defaultCover: DEFAULT_COVER,
|
||||
mbtiType: '',
|
||||
nickname: '',
|
||||
quickQuestions: [
|
||||
'帮我了解并分析一下我现在的状态',
|
||||
'我适合什么样的伴侣关系?',
|
||||
'我下一步的职业发展方向是什么?',
|
||||
'我最适合的工作类型是什么?'
|
||||
],
|
||||
messages: [],
|
||||
conversationId: 0,
|
||||
draft: '',
|
||||
sending: false,
|
||||
scrollTarget: '',
|
||||
usageToday: 0,
|
||||
dailyLimit: 20,
|
||||
showReportCta: false,
|
||||
myReportStatus: '',
|
||||
/** 推荐文章区块(与超管 ai_chat_articles 同步) */
|
||||
articlesDisplayEnabled: false,
|
||||
articlesBlockExpanded: false,
|
||||
articlesLoaded: false,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const cid = parseInt(options && options.cid, 10)
|
||||
if (cid > 0) {
|
||||
this.setData({ conversationId: cid })
|
||||
this.loadHistory(cid)
|
||||
}
|
||||
this.loadArticles()
|
||||
this.loadQuickQuestions()
|
||||
this.tryBindReferral(options)
|
||||
this.loadMyReportStatus()
|
||||
analytics.track('page_view', { pagePath: 'pages/ai-chat/index' })
|
||||
},
|
||||
|
||||
/** 分享链接里带 inviterId 时,绑定分销关系(复用现有分销系统) */
|
||||
tryBindReferral(options) {
|
||||
const inviterId = parseInt(options && options.inviterId, 10)
|
||||
if (!inviterId || inviterId <= 0) return
|
||||
request({
|
||||
url: '/api/distribution/bind',
|
||||
method: 'POST',
|
||||
data: { inviterId },
|
||||
success() {}, fail() {}
|
||||
})
|
||||
},
|
||||
|
||||
/** 查询我最近的 AI 报告状态(决定是否展示 CTA) */
|
||||
loadMyReportStatus() {
|
||||
request({
|
||||
url: '/api/ai/report/my-latest',
|
||||
method: 'GET',
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code !== 200) return
|
||||
const d = body.data || {}
|
||||
this.setData({ myReportStatus: d.status || '' }, () => this._refreshReportCta())
|
||||
},
|
||||
fail() {}
|
||||
})
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const tabBar = typeof this.getTabBar === 'function' ? this.getTabBar() : null
|
||||
if (tabBar && typeof tabBar.refreshFromConfig === 'function') {
|
||||
tabBar.refreshFromConfig()
|
||||
}
|
||||
this.loadArticles()
|
||||
},
|
||||
|
||||
onInput(e) {
|
||||
this.setData({ draft: e.detail.value })
|
||||
},
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
|
||||
/** 与后台推荐位一致:去重、条数由 maxShow 限制 */
|
||||
normalizeRecoArticles(raw, maxShow) {
|
||||
const cap = Math.max(1, Math.min(3, parseInt(maxShow, 10) || 1))
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const it = list[i]
|
||||
if (!it || it.id == null) continue
|
||||
const id = Number(it.id)
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push(it)
|
||||
if (out.length >= cap) break
|
||||
}
|
||||
return out
|
||||
},
|
||||
|
||||
loadArticles() {
|
||||
const t = Date.now()
|
||||
request({
|
||||
url: `/api/ai/articles/recommended?_t=${t}`,
|
||||
method: 'GET',
|
||||
needAuth: false,
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code !== 200) return
|
||||
const d = body.data || {}
|
||||
const disp = d.display || {}
|
||||
const enabled = !!disp.enabled
|
||||
const maxShow = Math.max(1, Math.min(3, parseInt(disp.maxShow, 10) || 1))
|
||||
const expandedDef = !!disp.sectionExpandedDefault
|
||||
const list = enabled ? this.normalizeRecoArticles(d.list || [], maxShow) : []
|
||||
this.setData({
|
||||
articles: list,
|
||||
articlesDisplayEnabled: enabled,
|
||||
articlesBlockExpanded: expandedDef,
|
||||
articlesLoaded: true
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onToggleArticlesFold() {
|
||||
this.setData({ articlesBlockExpanded: !this.data.articlesBlockExpanded })
|
||||
},
|
||||
|
||||
/** 去测 MBTI:进入「详细性格测试」列表(含 MBTI 问卷等),不直跳拍摄页 */
|
||||
onGoMbtiTest() {
|
||||
try {
|
||||
analytics.track('ai_chat_go_test_select', {})
|
||||
} catch (e) {}
|
||||
wx.navigateTo({
|
||||
url: '/pages/test-select/index',
|
||||
fail: () => {
|
||||
wx.showToast({ title: '暂时无法打开测试列表', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
loadQuickQuestions() {
|
||||
request({
|
||||
url: '/api/ai/quick-questions',
|
||||
method: 'GET',
|
||||
// 接口公开:无 token 也可访问;有 token 时自动带上以匹配 MBTI 快捷问句
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code !== 200) {
|
||||
this.setData({ quickQuestions: this.buildQuickQuestions([]) })
|
||||
return
|
||||
}
|
||||
const d = body.data || {}
|
||||
this.setData({
|
||||
mbtiType: d.mbtiType || '',
|
||||
nickname: d.nickname || '',
|
||||
quickQuestions: this.buildQuickQuestions(d.questions || [])
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
// 小程序未登录/接口偶发失败时,仍展示本地快捷提问,避免底部空白
|
||||
this.setData({ quickQuestions: this.buildQuickQuestions([]) })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
buildQuickQuestions(serverList) {
|
||||
const preferred = [
|
||||
'帮我了解并分析一下我现在的状态',
|
||||
'我适合什么样的伴侣关系?',
|
||||
'我下一步的职业发展方向是什么?',
|
||||
'我最适合的工作类型是什么?',
|
||||
'我现在最该改掉的一个习惯是什么?'
|
||||
]
|
||||
const ban = /记\s*一下\s*我的\s*MBTI/i
|
||||
const all = []
|
||||
;(Array.isArray(serverList) ? serverList : []).forEach((q) => {
|
||||
if (typeof q === 'string' && q.trim()) {
|
||||
const t = q.trim()
|
||||
if (!ban.test(t)) all.push(t)
|
||||
}
|
||||
})
|
||||
preferred.forEach((q) => all.push(q))
|
||||
const uniq = []
|
||||
const seen = {}
|
||||
all.forEach((q) => {
|
||||
if (!seen[q]) {
|
||||
seen[q] = true
|
||||
uniq.push(q)
|
||||
}
|
||||
})
|
||||
return uniq.slice(0, 8)
|
||||
},
|
||||
|
||||
loadHistory(cid) {
|
||||
request({
|
||||
url: `/api/ai/conversations/${cid}/messages`,
|
||||
method: 'GET',
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code !== 200) return
|
||||
const d = body.data || {}
|
||||
const msgs = (d.messages || []).filter(m => m.role !== 'system')
|
||||
this.setData({ messages: msgs }, () => {
|
||||
this.scrollToBottom()
|
||||
this._refreshReportCta()
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// ---------- 交互 ----------
|
||||
|
||||
onTapQuick(e) {
|
||||
const q = (e.currentTarget.dataset.q || '').trim()
|
||||
if (!q || this.data.sending) return
|
||||
analytics.track('ai_quick_question_click', { question: q })
|
||||
this.setData({ draft: q }, () => this.onSend())
|
||||
},
|
||||
|
||||
onTapArticle(e) {
|
||||
const { id, url, title } = e.currentTarget.dataset
|
||||
analytics.track('tap_ai_article', { articleId: id, url, title })
|
||||
// 轻量回传点击(异步)
|
||||
if (id) {
|
||||
request({
|
||||
url: `/api/ai/articles/${id}/click`,
|
||||
method: 'POST',
|
||||
needAuth: false,
|
||||
success() {}, fail() {}
|
||||
})
|
||||
}
|
||||
if (!url) return
|
||||
// 小程序内嵌 webview 打开外链
|
||||
const enc = encodeURIComponent(url)
|
||||
wx.navigateTo({
|
||||
url: `/pages/webview/index?url=${enc}`,
|
||||
fail: () => {
|
||||
// 如无 webview 中转页,回退到复制链接
|
||||
wx.setClipboardData({
|
||||
data: url,
|
||||
success: () => wx.showToast({ title: '已复制链接', icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onSend() {
|
||||
const content = (this.data.draft || '').trim()
|
||||
if (!content || this.data.sending) return
|
||||
|
||||
if (this.data.usageToday >= this.data.dailyLimit && this.data.dailyLimit > 0) {
|
||||
wx.showToast({ title: '今日对话次数已用完', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const userMsg = {
|
||||
id: 'u_' + now,
|
||||
role: 'user',
|
||||
content,
|
||||
createdAt: Math.floor(now / 1000)
|
||||
}
|
||||
const messages = this.data.messages.concat([userMsg])
|
||||
this.setData({ messages, draft: '', sending: true }, () => this.scrollToBottom())
|
||||
|
||||
analytics.track('ai_chat_send', { length: content.length })
|
||||
|
||||
request({
|
||||
url: '/api/ai/chat',
|
||||
method: 'POST',
|
||||
data: {
|
||||
conversationId: this.data.conversationId || 0,
|
||||
message: content
|
||||
},
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (res.statusCode === 429) {
|
||||
wx.showToast({ title: body.message || '今日次数用完', icon: 'none' })
|
||||
this.setData({ sending: false })
|
||||
return
|
||||
}
|
||||
if (body.code !== 200) {
|
||||
const hint = body.message || (res.statusCode ? `服务异常(${res.statusCode})` : 'AI 失联了')
|
||||
wx.showToast({ title: hint, icon: 'none', duration: 2800 })
|
||||
this.setData({ sending: false })
|
||||
return
|
||||
}
|
||||
|
||||
const d = body.data || {}
|
||||
const aiMsg = d.message || {}
|
||||
const next = this.data.messages.concat([{
|
||||
id: aiMsg.id || ('a_' + Date.now()),
|
||||
role: 'assistant',
|
||||
content: aiMsg.content || '',
|
||||
isDegraded: !!aiMsg.isDegraded,
|
||||
providerId: aiMsg.providerId || '',
|
||||
createdAt: aiMsg.createdAt || Math.floor(Date.now() / 1000)
|
||||
}])
|
||||
|
||||
this.setData({
|
||||
messages: next,
|
||||
conversationId: d.conversationId || this.data.conversationId,
|
||||
usageToday: d.usageToday || this.data.usageToday,
|
||||
dailyLimit: d.dailyLimit || this.data.dailyLimit,
|
||||
sending: false
|
||||
}, () => {
|
||||
this.scrollToBottom()
|
||||
this._refreshReportCta()
|
||||
})
|
||||
|
||||
analytics.track('ai_chat_receive', {
|
||||
providerId: aiMsg.providerId || '',
|
||||
length: (aiMsg.content || '').length
|
||||
})
|
||||
|
||||
if (aiMsg.isDegraded) {
|
||||
analytics.track('ai_chat_degrade', { providerId: aiMsg.providerId || '' })
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
wx.showToast({ title: '网络异常,稍后再试', icon: 'none' })
|
||||
this.setData({ sending: false })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
const msgs = this.data.messages
|
||||
const last = msgs.length ? msgs[msgs.length - 1] : null
|
||||
this.setData({ scrollTarget: last ? ('msg-' + last.id) : 'bottom' })
|
||||
},
|
||||
|
||||
/** 点击"邀请赚佣金"快捷方式:触发原生分享 */
|
||||
onTapShare() {
|
||||
analytics.track('ai_chat_share_invite_tap', {})
|
||||
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
|
||||
wx.showToast({ title: '点右上角 ··· 分享给好友', icon: 'none' })
|
||||
},
|
||||
|
||||
/** 点击"生成深度报告 CTA" */
|
||||
onTapReportCta() {
|
||||
analytics.track('ai_report_cta_tap', { messageCount: this.data.messages.length })
|
||||
wx.navigateTo({
|
||||
url: `/pages/ai-chat/report?cid=${this.data.conversationId || 0}`,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '报告功能即将开放', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** 根据消息数刷新 CTA 显隐 */
|
||||
_refreshReportCta() {
|
||||
const aiCount = (this.data.messages || []).filter(m => m.role === 'assistant').length
|
||||
const show = aiCount >= 5 && this.data.myReportStatus !== 'done' && this.data.myReportStatus !== 'paid'
|
||||
if (show !== this.data.showReportCta) {
|
||||
this.setData({ showReportCta: show })
|
||||
}
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const app = getApp() || {}
|
||||
const gd = app.globalData || {}
|
||||
const inviterId = (gd.userInfo && (gd.userInfo.id || gd.userInfo.user_id)) || 0
|
||||
const mbti = this.data.mbtiType || ''
|
||||
const title = mbti
|
||||
? `神仙 AI 帮我看懂了 ${mbti},你也来试试?`
|
||||
: '和神仙 AI 聊聊你的 MBTI,超准!'
|
||||
const path = `/pages/ai-chat/index?inviterId=${inviterId}&src=ai_chat`
|
||||
analytics.track('ai_chat_share', { mbti })
|
||||
return { title, path }
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '神仙 AI · 帮你看懂自己的 MBTI',
|
||||
query: 'src=ai_chat_timeline'
|
||||
}
|
||||
}
|
||||
})
|
||||
7
miniprogram/pages/ai-chat/index.json
Normal file
7
miniprogram/pages/ai-chat/index.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "神仙 AI",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F3FF",
|
||||
"usingComponents": {}
|
||||
}
|
||||
113
miniprogram/pages/ai-chat/index.wxml
Normal file
113
miniprogram/pages/ai-chat/index.wxml
Normal file
@@ -0,0 +1,113 @@
|
||||
<!-- 神仙 AI · 对话页 -->
|
||||
<view class="ai-page">
|
||||
<!-- 推荐文章:超管开启后显示;默认折叠(由后台 sectionExpandedDefault 控制首次是否展开) -->
|
||||
<view class="soul-articles-wrap" wx:if="{{articlesDisplayEnabled}}">
|
||||
<view class="articles-fold-bar" bindtap="onToggleArticlesFold">
|
||||
<view class="articles-fold-left">
|
||||
<text class="articles-fold-title">精选推荐</text>
|
||||
<text class="articles-fold-pill">Soul</text>
|
||||
<text class="articles-fold-count" wx:if="{{!articlesBlockExpanded}}">· {{articles.length}} 篇</text>
|
||||
</view>
|
||||
<text class="articles-fold-chevron">{{articlesBlockExpanded ? '▼' : '▶'}}</text>
|
||||
</view>
|
||||
<view class="soul-articles" wx:if="{{articlesBlockExpanded && articles.length > 0}}">
|
||||
<view class="articles-list">
|
||||
<view class="article-card" wx:for="{{articles}}" wx:key="id" bindtap="onTapArticle" data-id="{{item.id}}" data-url="{{item.url}}" data-title="{{item.title}}">
|
||||
<view class="article-cover-wrap">
|
||||
<image class="article-cover" src="{{item.cover || defaultCover}}" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="article-body">
|
||||
<text class="article-title">{{item.title}}</text>
|
||||
<view class="article-meta">
|
||||
<text class="article-pill">{{item.tag || 'MBTI'}}</text>
|
||||
<text class="article-time" wx:if="{{item.publishedAt}}">{{item.publishedAt}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- MBTI 提醒:去测 → 详细性格测试列表 -->
|
||||
<view class="mbti-remind-card">
|
||||
<view class="mbti-remind-row">
|
||||
<image class="mbti-remind-avatar" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
|
||||
<view class="mbti-remind-texts">
|
||||
<view class="mbti-remind-title">我是神仙 AI</view>
|
||||
<view class="mbti-remind-sub">{{mbtiType ? ('已了解你的 ' + mbtiType + ',继续聊~') : '先做一下 MBTI 测评,我能帮你聊得更懂你哦~'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mbti-remind-actions">
|
||||
<text class="mbti-remind-tag">MBTI</text>
|
||||
<view class="mbti-remind-btn" hover-class="mbti-remind-btn--press" bindtap="onGoMbtiTest">去测 MBTI</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 聊天区 -->
|
||||
<scroll-view class="chat-scroll" scroll-y="true" scroll-into-view="{{scrollTarget}}" scroll-with-animation="true" enhanced show-scrollbar="{{false}}">
|
||||
<!-- 消息气泡 -->
|
||||
<view class="msg-wrap" wx:for="{{messages}}" wx:key="id" id="msg-{{item.id}}">
|
||||
<view wx:if="{{item.role === 'assistant'}}" class="msg msg-ai">
|
||||
<image class="avatar-ai" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
|
||||
<view class="bubble bubble-ai {{item.isDegraded ? 'bubble-degraded' : ''}}">
|
||||
<text selectable="true">{{item.content}}</text>
|
||||
<view class="bubble-meta" wx:if="{{item.isDegraded}}">(降级兜底)</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="msg msg-me">
|
||||
<view class="bubble bubble-me">
|
||||
<text selectable="true">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="typing" wx:if="{{sending}}" id="msg-typing">
|
||||
<image class="avatar-ai" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
|
||||
<view class="bubble bubble-ai"><text>神仙在思考…</text></view>
|
||||
</view>
|
||||
|
||||
<!-- 深度报告 CTA:对话 >=10 条且还没付费时显示 -->
|
||||
<view class="report-cta" wx:if="{{showReportCta}}" bindtap="onTapReportCta">
|
||||
<view class="report-cta-text">✨ 聊了这么多,来份专属 AI 深度画像报告?¥9.9</view>
|
||||
<view class="report-cta-btn">立即生成</view>
|
||||
</view>
|
||||
|
||||
<view class="scroll-bottom" id="bottom"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部快捷问法(常驻) -->
|
||||
<scroll-view class="quick-bottom-scroll" scroll-x="true" show-scrollbar="{{false}}" wx:if="{{quickQuestions.length > 0}}">
|
||||
<view class="quick-bottom-list">
|
||||
<view class="quick-bottom-item" wx:for="{{quickQuestions}}" wx:for-item="q" wx:key="*this" bindtap="onTapQuick" data-q="{{q}}">
|
||||
{{q}}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入区:输入框 + 发送 -->
|
||||
<view class="input-bar">
|
||||
<input
|
||||
class="input"
|
||||
placeholder="问我你想了解的自己"
|
||||
placeholder-class="input-ph"
|
||||
value="{{draft}}"
|
||||
bindinput="onInput"
|
||||
bindconfirm="onSend"
|
||||
confirm-type="send"
|
||||
confirm-hold="true"
|
||||
adjust-position="{{true}}"
|
||||
maxlength="800"
|
||||
disabled="{{sending}}"
|
||||
/>
|
||||
|
||||
<view
|
||||
class="send-btn {{draft && !sending ? 'send-btn--active' : ''}} {{sending || !draft ? 'send-btn--disabled' : ''}}"
|
||||
hover-class="{{draft && !sending ? 'send-btn--press' : ''}}"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
bindtap="onSend"
|
||||
>
|
||||
<text class="send-btn__label">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
507
miniprogram/pages/ai-chat/index.wxss
Normal file
507
miniprogram/pages/ai-chat/index.wxss
Normal file
@@ -0,0 +1,507 @@
|
||||
/* 神仙 AI · 对话页样式 */
|
||||
page {
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #F5F3FF 0%, #FFFFFF 40%);
|
||||
}
|
||||
|
||||
.ai-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 给底部 tabBar + 输入栏预留空间;使用全局变量避免不同机型遮挡 */
|
||||
padding-bottom: calc(var(--tabbar-pad, 160rpx) + 150rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 推荐区块:折叠条 */
|
||||
.soul-articles-wrap {
|
||||
margin: 0 24rpx 12rpx;
|
||||
}
|
||||
|
||||
.articles-fold-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18rpx 20rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #e9d5ff;
|
||||
box-shadow: 0 2rpx 10rpx rgba(124, 58, 237, 0.06);
|
||||
}
|
||||
|
||||
.articles-fold-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.articles-fold-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #4c1d95;
|
||||
}
|
||||
|
||||
.articles-fold-pill {
|
||||
font-size: 20rpx;
|
||||
color: #6d28d9;
|
||||
background: #ede9fe;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 100rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.articles-fold-count {
|
||||
font-size: 22rpx;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.articles-fold-chevron {
|
||||
font-size: 22rpx;
|
||||
color: #7c3aed;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
/* MBTI 测评提醒卡 */
|
||||
.mbti-remind-card {
|
||||
margin: 0 24rpx 14rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #e9d5ff;
|
||||
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
.mbti-remind-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.mbti-remind-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #e9d5ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mbti-remind-texts {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mbti-remind-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1f1b4d;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.mbti-remind-sub {
|
||||
font-size: 24rpx;
|
||||
color: #6b5f8a;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mbti-remind-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mbti-remind-tag {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #5b21b6;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 100rpx;
|
||||
border: 1rpx solid #ddd6fe;
|
||||
}
|
||||
|
||||
.mbti-remind-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 14rpx 36rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
border-radius: 100rpx;
|
||||
background: linear-gradient(135deg, #9333ea 0%, #7c3aed 100%);
|
||||
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.35);
|
||||
}
|
||||
|
||||
.mbti-remind-btn--press {
|
||||
opacity: 0.88;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 顶部推荐文章列表 */
|
||||
.soul-articles {
|
||||
padding: 12rpx 0 0;
|
||||
}
|
||||
|
||||
.articles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 18rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(124, 58, 237, 0.1);
|
||||
border: 1rpx solid rgba(124, 58, 237, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-cover-wrap {
|
||||
width: 200rpx;
|
||||
min-width: 200rpx;
|
||||
padding: 10rpx;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #faf5ff 0%, #f3e8ff 100%);
|
||||
}
|
||||
|
||||
.article-cover {
|
||||
width: 100%;
|
||||
height: 120rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #ede9fe;
|
||||
box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
.article-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10rpx 12rpx;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 22rpx;
|
||||
color: #1F1B4D;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
margin-top: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.article-pill {
|
||||
font-size: 20rpx;
|
||||
color: #5b21b6;
|
||||
background: linear-gradient(135deg, #ede9fe 0%, #f5f3ff 100%);
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 100rpx;
|
||||
font-weight: 600;
|
||||
border: 1rpx solid #ddd6fe;
|
||||
}
|
||||
|
||||
.article-time {
|
||||
font-size: 18rpx;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
/* 聊天区:flex:1 撑开,不再用 100vh 硬算 */
|
||||
.chat-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8rpx 24rpx 160rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 欢迎卡:紧凑化,不再占整屏 */
|
||||
.welcome {
|
||||
margin: 8rpx 0 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
background: linear-gradient(135deg, #F5F3FF 0%, #EDE9FE 100%);
|
||||
border: 1rpx solid #EDE9FE;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.welcome-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.welcome-logo {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #E9D5FF;
|
||||
box-shadow: 0 2rpx 10rpx rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1F1B4D;
|
||||
}
|
||||
|
||||
.welcome-sub {
|
||||
font-size: 24rpx;
|
||||
color: #6B6894;
|
||||
margin-bottom: 16rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mbti-badge {
|
||||
display: inline-block;
|
||||
margin: 0 4rpx;
|
||||
padding: 2rpx 14rpx;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
color: #ffffff;
|
||||
border-radius: 100rpx;
|
||||
font-weight: 600;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.quick-title {
|
||||
font-size: 24rpx;
|
||||
color: #4C1D95;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #ffffff;
|
||||
border-radius: 100rpx;
|
||||
padding: 14rpx 22rpx;
|
||||
font-size: 24rpx;
|
||||
color: #4C1D95;
|
||||
border: 1rpx solid #DDD6FE;
|
||||
}
|
||||
|
||||
.quick-item:active {
|
||||
background: #EDE9FE;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 消息气泡 */
|
||||
.msg-wrap {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.msg-ai {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.msg-me {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.avatar-ai {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #E9D5FF;
|
||||
background: #fff;
|
||||
margin-right: 14rpx;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 74%;
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bubble-ai {
|
||||
background: #ffffff;
|
||||
color: #1F1B4D;
|
||||
border-top-left-radius: 4rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(31, 27, 77, 0.06);
|
||||
}
|
||||
|
||||
.bubble-me {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
color: #ffffff;
|
||||
border-top-right-radius: 4rpx;
|
||||
}
|
||||
|
||||
.bubble-degraded {
|
||||
background: #FFF7ED;
|
||||
color: #C2410C;
|
||||
border: 1rpx solid #FED7AA;
|
||||
}
|
||||
|
||||
.bubble-meta {
|
||||
margin-top: 8rpx;
|
||||
font-size: 20rpx;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.scroll-bottom {
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
/* ==== 深度报告 CTA ==== */
|
||||
.report-cta {
|
||||
margin: 16rpx 0 8rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
background: linear-gradient(135deg, #FDE68A 0%, #F9A8D4 100%);
|
||||
color: #78350F;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 6rpx 18rpx rgba(249, 168, 212, 0.3);
|
||||
}
|
||||
.report-cta-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
padding-right: 12rpx;
|
||||
}
|
||||
.report-cta-btn {
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: 100rpx;
|
||||
background: #78350F;
|
||||
color: #FEF3C7;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.quick-bottom-scroll {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--tabbar-height, 132rpx) + env(safe-area-inset-bottom) + 84rpx);
|
||||
padding: 0 16rpx 4rpx;
|
||||
z-index: 21;
|
||||
}
|
||||
|
||||
.quick-bottom-list {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-bottom-item {
|
||||
display: inline-block;
|
||||
margin-right: 10rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #F5F3FF;
|
||||
color: #5B21B6;
|
||||
font-size: 22rpx;
|
||||
border: 1rpx solid #DDD6FE;
|
||||
}
|
||||
|
||||
/* ==== 输入区:紧贴自定义 tabBar 上方 ==== */
|
||||
.input-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--tabbar-height, 132rpx) + env(safe-area-inset-bottom));
|
||||
background: #ffffff;
|
||||
padding: 12rpx 16rpx 12rpx 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
border-top: 1rpx solid #F3F4F6;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
background: #F3F4F6;
|
||||
border-radius: 100rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
font-size: 27rpx;
|
||||
min-height: 62rpx;
|
||||
color: #1F1B4D;
|
||||
}
|
||||
|
||||
.input-ph {
|
||||
color: #9CA3AF;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
/* 发送:独立按钮块(非原生 button,样式可控) */
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 28rpx;
|
||||
min-width: 108rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-left: 16rpx;
|
||||
margin-right: 4rpx;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #f3f4f6 0%, #e5e7eb 100%);
|
||||
border: 2rpx solid #d1d5db;
|
||||
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.send-btn__label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.send-btn--active {
|
||||
background: linear-gradient(180deg, #9333ea 0%, #7c3aed 100%);
|
||||
border-color: #6d28d9;
|
||||
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.35);
|
||||
}
|
||||
|
||||
.send-btn--active .send-btn__label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.send-btn--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.send-btn--press {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
194
miniprogram/pages/ai-chat/report.js
Normal file
194
miniprogram/pages/ai-chat/report.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// 神仙 AI · 深度画像报告页
|
||||
const { request } = require('../../utils/request.js')
|
||||
const analytics = require('../../utils/analytics.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
cid: 0, // 来源对话 id
|
||||
reportId: 0, // 当前报告 id
|
||||
report: {}, // 报告对象
|
||||
paying: false,
|
||||
pollTimer: null
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const cid = parseInt(options && options.cid, 10) || 0
|
||||
const rid = parseInt(options && options.rid, 10) || 0
|
||||
this.setData({ cid, reportId: rid })
|
||||
analytics.track('page_view', { pagePath: 'pages/ai-chat/report' })
|
||||
this.initReport()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this.data.pollTimer) clearTimeout(this.data.pollTimer)
|
||||
},
|
||||
|
||||
/** 初始化:有 rid 直接拉;否则先 createOrGetPending */
|
||||
initReport() {
|
||||
if (this.data.reportId > 0) {
|
||||
this.loadReport(this.data.reportId)
|
||||
return
|
||||
}
|
||||
request({
|
||||
url: '/api/ai/report/create',
|
||||
method: 'POST',
|
||||
data: { conversationId: this.data.cid },
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code !== 200) {
|
||||
wx.showToast({ title: body.message || '创建失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const r = body.data || {}
|
||||
this.setData({ reportId: r.id, report: r }, () => {
|
||||
if (r.status === 'generating' || r.status === 'paid') this._schedulePoll()
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
loadReport(id) {
|
||||
request({
|
||||
url: `/api/ai/report/${id}`,
|
||||
method: 'GET',
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code !== 200) {
|
||||
wx.showToast({ title: body.message || '加载失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ report: body.data || {} }, () => {
|
||||
const s = this.data.report.status
|
||||
if (s === 'generating' || s === 'paid') this._schedulePoll()
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
_schedulePoll() {
|
||||
if (this.data.pollTimer) clearTimeout(this.data.pollTimer)
|
||||
const t = setTimeout(() => {
|
||||
this.loadReport(this.data.reportId)
|
||||
}, 3500)
|
||||
this.setData({ pollTimer: t })
|
||||
},
|
||||
|
||||
reload() {
|
||||
this.loadReport(this.data.reportId)
|
||||
},
|
||||
|
||||
/** 付费解锁:先尝试 dev 模式,生产环境走 payment/create 下单 */
|
||||
onTapPay() {
|
||||
if (this.data.paying) return
|
||||
this.setData({ paying: true })
|
||||
analytics.track('ai_report_pay_tap', { reportId: this.data.reportId })
|
||||
|
||||
const report = this.data.report || {}
|
||||
const orderSn = report.orderSn || ''
|
||||
|
||||
// 1) 尝试调用现有 /api/payment/create 下单
|
||||
request({
|
||||
url: '/api/payment/create',
|
||||
method: 'POST',
|
||||
data: {
|
||||
productType: 'ai_deep_report',
|
||||
productId: this.data.reportId,
|
||||
amount: report.priceFen || 990,
|
||||
orderSn
|
||||
},
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code === 200 && body.data && body.data.timeStamp) {
|
||||
const p = body.data
|
||||
wx.requestPayment({
|
||||
timeStamp: p.timeStamp,
|
||||
nonceStr: p.nonceStr,
|
||||
package: p.package,
|
||||
signType: p.signType || 'RSA',
|
||||
paySign: p.paySign,
|
||||
success: () => {
|
||||
wx.showToast({ title: '支付成功', icon: 'success' })
|
||||
analytics.track('ai_report_pay_success', { reportId: this.data.reportId })
|
||||
setTimeout(() => this.loadReport(this.data.reportId), 1500)
|
||||
this.setData({ paying: false })
|
||||
},
|
||||
fail: () => {
|
||||
wx.showToast({ title: '已取消支付', icon: 'none' })
|
||||
this.setData({ paying: false })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 2) 下单失败(比如接口不支持该 productType)→ 兜底 dev markPaid(仅超管可用)
|
||||
this._fallbackDevMarkPaid()
|
||||
}
|
||||
},
|
||||
fail: () => this._fallbackDevMarkPaid()
|
||||
})
|
||||
},
|
||||
|
||||
_fallbackDevMarkPaid() {
|
||||
// 仅在配置了 dev 模式或超管账号时生效
|
||||
request({
|
||||
url: `/api/ai/report/${this.data.reportId}/mark-paid-dev`,
|
||||
method: 'POST',
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code === 200) {
|
||||
wx.showToast({ title: '解锁成功', icon: 'success' })
|
||||
this.setData({ paying: false })
|
||||
setTimeout(() => this.loadReport(this.data.reportId), 800)
|
||||
} else {
|
||||
wx.showToast({ title: body.message || '支付接口未开通', icon: 'none' })
|
||||
this.setData({ paying: false })
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
wx.showToast({ title: '支付通道异常,稍后再试', icon: 'none' })
|
||||
this.setData({ paying: false })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
if (!this.data.reportId) return
|
||||
request({
|
||||
url: `/api/ai/report/${this.data.reportId}/regenerate`,
|
||||
method: 'POST',
|
||||
success: (res) => {
|
||||
const body = (res && res.data) || {}
|
||||
if (body.code === 200) {
|
||||
this.setData({ report: body.data || {} })
|
||||
const s = body.data && body.data.status
|
||||
if (s === 'generating' || s === 'paid') this._schedulePoll()
|
||||
} else {
|
||||
wx.showToast({ title: body.message || '重试失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onCopy() {
|
||||
const c = (this.data.report && this.data.report.content) || ''
|
||||
if (!c) return
|
||||
wx.setClipboardData({ data: c })
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const app = getApp() || {}
|
||||
const gd = app.globalData || {}
|
||||
const inviterId = (gd.userInfo && (gd.userInfo.id || gd.userInfo.user_id)) || 0
|
||||
const mbti = (this.data.report && this.data.report.mbtiType) || ''
|
||||
const title = mbti
|
||||
? `神仙 AI 帮我看懂了 ${mbti},推荐你也来做一份专属报告`
|
||||
: '神仙 AI · 深度画像报告'
|
||||
analytics.track('ai_report_share', { reportId: this.data.reportId, mbti })
|
||||
return {
|
||||
title,
|
||||
path: `/pages/ai-chat/index?inviterId=${inviterId}&src=ai_report_share`,
|
||||
}
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return { title: '神仙 AI 帮我看懂了自己的 MBTI', query: 'src=ai_report_timeline' }
|
||||
}
|
||||
})
|
||||
7
miniprogram/pages/ai-chat/report.json
Normal file
7
miniprogram/pages/ai-chat/report.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "AI 深度画像报告",
|
||||
"navigationBarBackgroundColor": "#F5F3FF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F3FF",
|
||||
"usingComponents": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user