feat: AI岗位专区 — 5个AI岗位置顶 + 首页分组展示
- schema: HotPosition 新增 category 字段 (ai/traditional)
- positions: 5 AI岗位 (AI算法/大模型应用/Prompt/AI产品/AI运维) + 7传统岗位
- frontend: 首页拆分 "🔥 AI热门岗位" 置顶高亮 + "更多岗位" 折叠
- ai服务: 新增 primaryFallbackModel (sensenova-6.7-flash-lite) 降级链路
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_130b21833ffe0epQvGBjpGtogZ",
|
||||||
|
"updatedAt": "2026-06-17T05:54:06.381Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "active",
|
||||||
|
"reason": "2 background task(s) active",
|
||||||
|
"updatedAt": "2026-06-17T05:54:06.381Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common'
|
import { Injectable, Logger } from "@nestjs/common"
|
||||||
import axios from 'axios'
|
import axios from "axios"
|
||||||
import https from 'https'
|
import https from "https"
|
||||||
|
|
||||||
interface AiCallOptions {
|
interface AiCallOptions {
|
||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
@@ -15,26 +15,35 @@ const httpAgent = new https.Agent({ rejectUnauthorized: true, keepAlive: true })
|
|||||||
export class AiService {
|
export class AiService {
|
||||||
private readonly logger = new Logger(AiService.name)
|
private readonly logger = new Logger(AiService.name)
|
||||||
|
|
||||||
private readonly primaryUrl = process.env.AI_PRIMARY_URL || 'https://token.sensenova.cn/v1'
|
private readonly primaryUrl = process.env.AI_PRIMARY_URL || "https://token.sensenova.cn/v1"
|
||||||
private readonly primaryKey = process.env.AI_PRIMARY_KEY || ''
|
private readonly primaryKey = process.env.AI_PRIMARY_KEY || ""
|
||||||
private readonly primaryModel = process.env.AI_PRIMARY_MODEL || 'deepseek-v4-flash'
|
private readonly primaryModel = process.env.AI_PRIMARY_MODEL || "deepseek-v4-flash"
|
||||||
|
private readonly primaryFallbackModel = process.env.AI_PRIMARY_FALLBACK_MODEL || "sensenova-6.7-flash-lite"
|
||||||
|
|
||||||
private readonly backupUrl = process.env.AI_BACKUP_URL || 'https://integrate.api.nvidia.com/v1'
|
private readonly backupUrl = process.env.AI_BACKUP_URL || "https://integrate.api.nvidia.com/v1"
|
||||||
private readonly backupKey = process.env.AI_BACKUP_KEY || ''
|
private readonly backupKey = process.env.AI_BACKUP_KEY || ""
|
||||||
private readonly backupModel = process.env.AI_BACKUP_MODEL || 'stepfun-ai/step-3.5-flash'
|
private readonly backupModel = process.env.AI_BACKUP_MODEL || "stepfun-ai/step-3.5-flash"
|
||||||
|
|
||||||
async call(options: AiCallOptions): Promise<string> {
|
async call(options: AiCallOptions): Promise<string> {
|
||||||
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
|
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
|
||||||
|
|
||||||
// Try primary AI
|
// Try primary AI (deepseek-v4-flash on sensenova)
|
||||||
try {
|
try {
|
||||||
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens)
|
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens)
|
||||||
if (result) return result
|
if (result) return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying backup...`)
|
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying primary fallback...`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try backup AI
|
// Try primary fallback model (sensenova-6.7-flash-lite, same provider)
|
||||||
|
try {
|
||||||
|
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryFallbackModel, systemPrompt, userMessage, temperature, maxTokens)
|
||||||
|
if (result) return result
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Primary fallback AI also failed: ${(e as Error).message}, trying backup...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try backup AI (NVIDIA)
|
||||||
try {
|
try {
|
||||||
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, maxTokens)
|
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, maxTokens)
|
||||||
if (result) return result
|
if (result) return result
|
||||||
@@ -43,7 +52,7 @@ export class AiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Final fallback
|
// Final fallback
|
||||||
throw new Error('AI 服务暂时不可用,请稍后重试')
|
throw new Error("AI \u670d\u52a1\u6682\u65f6\u4e0d\u53ef\u7528\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5")
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callApi(
|
private async callApi(
|
||||||
@@ -56,16 +65,16 @@ export class AiService {
|
|||||||
{
|
{
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: 'user', content: userMessage },
|
{ role: "user", content: userMessage },
|
||||||
],
|
],
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
"Authorization": `Bearer ${apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
httpsAgent: httpAgent,
|
httpsAgent: httpAgent,
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"
|
||||||
import { Document } from 'mongoose'
|
import { Document } from "mongoose"
|
||||||
|
|
||||||
export type HotPositionDocument = HotPosition & Document
|
export type HotPositionDocument = HotPosition & Document
|
||||||
|
|
||||||
|
export type PositionCategory = "ai" | "traditional"
|
||||||
|
|
||||||
@Schema({ timestamps: true })
|
@Schema({ timestamps: true })
|
||||||
export class HotPosition {
|
export class HotPosition {
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
name: string
|
name: string
|
||||||
|
|
||||||
@Prop({ default: '' })
|
@Prop({ default: "" })
|
||||||
salary?: string
|
salary?: string
|
||||||
|
|
||||||
@Prop({ default: '' })
|
@Prop({ default: "" })
|
||||||
company?: string
|
company?: string
|
||||||
|
|
||||||
@Prop({ default: '' })
|
@Prop({ default: "" })
|
||||||
icon?: string
|
icon?: string
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
@Prop({ default: 0 })
|
||||||
@@ -22,7 +24,11 @@ export class HotPosition {
|
|||||||
|
|
||||||
@Prop({ default: true })
|
@Prop({ default: true })
|
||||||
active: boolean
|
active: boolean
|
||||||
|
|
||||||
|
@Prop({ type: String, enum: ["ai", "traditional"], default: "traditional" })
|
||||||
|
category: PositionCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HotPositionSchema = SchemaFactory.createForClass(HotPosition)
|
export const HotPositionSchema = SchemaFactory.createForClass(HotPosition)
|
||||||
HotPositionSchema.index({ sort: 1 })
|
HotPositionSchema.index({ sort: 1 })
|
||||||
|
HotPositionSchema.index({ category: 1, active: 1 })
|
||||||
|
|||||||
@@ -107,18 +107,49 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 热门岗位 -->
|
<!-- 热门岗位 - AI 专区 -->
|
||||||
<view class="section">
|
<view class="section">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<view class="section-title-row">
|
<view class="section-title-row">
|
||||||
<text class="section-title">热门岗位</text>
|
<text class="section-title">🔥 AI 热门岗位</text>
|
||||||
|
<text class="section-badge">NEW</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="section-desc">点击直接面试</text>
|
<text class="section-desc">AI 时代最热方向,点击直接面试</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="position-list card" v-if="!positionsLoading">
|
<view class="ai-banner card" @click="goInterview">
|
||||||
<view class="pos-item" v-for="(pos, idx) in hotPositions" :key="idx" @click="startInterview(pos)">
|
<text class="ai-banner-title">🚀 AI 正在重塑整个行业</text>
|
||||||
|
<text class="ai-banner-desc">大模型应用 / Agent 开发 / Prompt 工程 — 顶尖人才缺口巨大,现在上车正当时</text>
|
||||||
|
</view>
|
||||||
|
<view class="position-list card" v-if="!positionsLoading && aiPositions.length > 0">
|
||||||
|
<view class="pos-item" v-for="(pos, idx) in aiPositions" :key="'ai-' + idx" @click="startInterview(pos)">
|
||||||
<view class="pos-left">
|
<view class="pos-left">
|
||||||
<text class="pos-icon">{{ pos.icon || posIcons[idx % posIcons.length] || '💼' }}</text>
|
<text class="pos-icon pos-icon-ai">{{ pos.icon || posIcons[idx % posIcons.length] || '🤖' }}</text>
|
||||||
|
<view class="pos-body">
|
||||||
|
<text class="pos-name">{{ pos.name }}</text>
|
||||||
|
<view class="pos-meta-row" v-if="pos.company || pos.salary">
|
||||||
|
<text class="pos-company">{{ pos.company }}</text>
|
||||||
|
<text class="pos-salary">{{ pos.salary }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="pos-action">
|
||||||
|
<text class="pos-action-text pos-action-ai">立即模拟</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 更多岗位 -->
|
||||||
|
<view class="more-header" @click="showMore = !showMore">
|
||||||
|
<view class="more-header-left">
|
||||||
|
<text class="more-icon">🧑💻</text>
|
||||||
|
<text class="more-title">更多岗位({{ traditionalPositions.length }})</text>
|
||||||
|
</view>
|
||||||
|
<text class="more-arrow">{{ showMore ? '收起 ▲' : '展开 ▼' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="position-list card" v-if="showMore && !positionsLoading && traditionalPositions.length > 0">
|
||||||
|
<view class="pos-item" v-for="(pos, idx) in traditionalPositions" :key="'tr-' + idx" @click="startInterview(pos)">
|
||||||
|
<view class="pos-left">
|
||||||
|
<text class="pos-icon">{{ pos.icon || posIcons[(aiPositions.length + idx) % posIcons.length] || '💼' }}</text>
|
||||||
<view class="pos-body">
|
<view class="pos-body">
|
||||||
<text class="pos-name">{{ pos.name }}</text>
|
<text class="pos-name">{{ pos.name }}</text>
|
||||||
<view class="pos-meta-row" v-if="pos.company || pos.salary">
|
<view class="pos-meta-row" v-if="pos.company || pos.salary">
|
||||||
@@ -140,7 +171,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { api } from '../../config'
|
import { api } from '../../config'
|
||||||
|
|
||||||
@@ -151,6 +182,10 @@ const posIcons = ['💻', '⚙️', '🤖', '📊', '🎨', '🧪', '📱', '
|
|||||||
const positionsLoading = ref(true)
|
const positionsLoading = ref(true)
|
||||||
const dailyQuestion = ref(null)
|
const dailyQuestion = ref(null)
|
||||||
const showAnswer = ref(false)
|
const showAnswer = ref(false)
|
||||||
|
const showMore = ref(false)
|
||||||
|
|
||||||
|
const aiPositions = computed(() => hotPositions.value.filter(p => p.category === 'ai'))
|
||||||
|
const traditionalPositions = computed(() => hotPositions.value.filter(p => p.category !== 'ai'))
|
||||||
|
|
||||||
const loadUserInfo = () => {
|
const loadUserInfo = () => {
|
||||||
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s); else userInfo.value = null } catch (e) { userInfo.value = null }
|
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s); else userInfo.value = null } catch (e) { userInfo.value = null }
|
||||||
@@ -253,6 +288,7 @@ const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/intervie
|
|||||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||||
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||||
.section-title-row { display: flex; align-items: center; gap: 12rpx; }
|
.section-title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||||
|
.section-badge { font-size: 18rpx; color: #fff; background: var(--color-primary); padding: 2rpx 12rpx; border-radius: 20rpx; font-weight: 500; }
|
||||||
.section-desc { font-size: 22rpx; color: var(--color-primary); }
|
.section-desc { font-size: 22rpx; color: var(--color-primary); }
|
||||||
|
|
||||||
.feature-list { display: flex; flex-direction: column; gap: 16rpx; }
|
.feature-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
@@ -292,12 +328,23 @@ const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/intervie
|
|||||||
.daily-action { font-size: 24rpx; color: var(--color-text-secondary); }
|
.daily-action { font-size: 24rpx; color: var(--color-text-secondary); }
|
||||||
.daily-action.primary { color: var(--color-primary); font-weight: 600; }
|
.daily-action.primary { color: var(--color-primary); font-weight: 600; }
|
||||||
|
|
||||||
|
/* AI 岗位专区 */
|
||||||
|
.ai-banner {
|
||||||
|
background: linear-gradient(135deg, #FEF3C7, #FDE68A);
|
||||||
|
padding: 20rpx 24rpx; border-radius: var(--radius-lg); margin-bottom: 16rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ai-banner:active { transform: scale(0.98); }
|
||||||
|
.ai-banner-title { font-size: 26rpx; font-weight: 700; color: #92400E; display: block; margin-bottom: 6rpx; }
|
||||||
|
.ai-banner-desc { font-size: 20rpx; color: #A16207; line-height: 1.5; display: block; }
|
||||||
|
|
||||||
.position-list { border-radius: var(--radius-lg); overflow: hidden; }
|
.position-list { border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
.pos-item { padding: 24rpx 28rpx; border-bottom: 1rpx solid var(--color-border); display: flex; justify-content: space-between; align-items: center; }
|
.pos-item { padding: 24rpx 28rpx; border-bottom: 1rpx solid var(--color-border); display: flex; justify-content: space-between; align-items: center; }
|
||||||
.pos-item:last-child { border-bottom: none; }
|
.pos-item:last-child { border-bottom: none; }
|
||||||
.pos-item:active { background: var(--color-bg); }
|
.pos-item:active { background: var(--color-bg); }
|
||||||
.pos-left { display: flex; align-items: center; gap: 16rpx; flex: 1; min-width: 0; }
|
.pos-left { display: flex; align-items: center; gap: 16rpx; flex: 1; min-width: 0; }
|
||||||
.pos-icon { font-size: 36rpx; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; background: #F3F4F6; border-radius: 14rpx; flex-shrink: 0; }
|
.pos-icon { font-size: 36rpx; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; background: #F3F4F6; border-radius: 14rpx; flex-shrink: 0; }
|
||||||
|
.pos-icon-ai { background: #FEF3C7; }
|
||||||
.pos-body { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
.pos-body { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||||
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||||
.pos-meta-row { display: flex; align-items: center; gap: 10rpx; margin-top: 4rpx; }
|
.pos-meta-row { display: flex; align-items: center; gap: 10rpx; margin-top: 4rpx; }
|
||||||
@@ -305,6 +352,19 @@ const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/intervie
|
|||||||
.pos-salary { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 10rpx; border-radius: 6rpx; }
|
.pos-salary { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 10rpx; border-radius: 6rpx; }
|
||||||
.pos-action { flex-shrink: 0; margin-left: 16rpx; }
|
.pos-action { flex-shrink: 0; margin-left: 16rpx; }
|
||||||
.pos-action-text { font-size: 22rpx; color: var(--color-primary); font-weight: 600; }
|
.pos-action-text { font-size: 22rpx; color: var(--color-primary); font-weight: 600; }
|
||||||
|
.pos-action-ai { color: #D97706; }
|
||||||
|
|
||||||
|
/* 更多岗位折叠 */
|
||||||
|
.more-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 20rpx 4rpx; margin-top: 8rpx; cursor: pointer;
|
||||||
|
}
|
||||||
|
.more-header:active { opacity: 0.7; }
|
||||||
|
.more-header-left { display: flex; align-items: center; gap: 10rpx; }
|
||||||
|
.more-icon { font-size: 28rpx; }
|
||||||
|
.more-title { font-size: 26rpx; font-weight: 600; color: var(--color-text-secondary); }
|
||||||
|
.more-arrow { font-size: 22rpx; color: var(--color-primary); font-weight: 500; }
|
||||||
|
|
||||||
.loading-tip { text-align: center; padding: 40rpx; font-size: 24rpx; color: var(--color-text-tertiary); background: #FFF; border-radius: var(--radius-lg); }
|
.loading-tip { text-align: center; padding: 40rpx; font-size: 24rpx; color: var(--color-text-tertiary); background: #FFF; border-radius: var(--radius-lg); }
|
||||||
.bottom-spacer { height: 40rpx; }
|
.bottom-spacer { height: 40rpx; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user