Просмотр исходного кода

看职业适配微信小程序

shmily1213 2 недель назад
Родитель
Сommit
106b762b88

+ 27 - 0
src/api/modules/career.ts

@@ -9,4 +9,31 @@ import { Career } from "@/types";
  */
 export function getCareerTree(params: Career.CareerTreeQueryDTO) {
   return flyio.get('/front/vocational/getAllVocation', params) as Promise<ApiResponse<Career.CareerItem[]>>;
+}
+
+/**
+ * 获取职业概览
+ * @param code 职业编码
+ * @returns 
+ */
+export function getCareerOverview(code: string) {
+  return flyio.get('/front/vocational/getVocationalOverview', { code }) as Promise<ApiResponse<Career.CareerOverview>>;
+}
+
+/**
+ * 获取职业岗位
+ * @param code 职业编码
+ * @returns 
+ */
+export function getCareerJob(code: string) {
+  return flyio.get('/front/vocational/getVocationalPosts', { code }) as Promise<ApiResponse<Career.CareerJob[]>>;
+}
+
+/**
+ * 获取职业岗位详情
+ * @param code 职业编码
+ * @returns 
+ */
+export function getCareerJobDetail(name: string) {
+  return flyio.get('/front/vocational/getVocationalPostDetailByPostName', { postName: name }) as Promise<ApiResponse<Career.CareerJobDetail>>;
 }

+ 399 - 0
src/pagesOther/components/ie-echart/canvas.js

@@ -0,0 +1,399 @@
+import {getDeviceInfo} from './utils';
+
+const cacheChart = {}
+const fontSizeReg = /([\d\.]+)px/;
+class EventEmit {
+	constructor() {
+		this.__events = {};
+	}
+	on(type, listener) {
+		if (!type || !listener) {
+			return;
+		}
+		const events = this.__events[type] || [];
+		events.push(listener);
+		this.__events[type] = events;
+	}
+	emit(type, e) {
+		if (type.constructor === Object) {
+			e = type;
+			type = e && e.type;
+		}
+		if (!type) {
+			return;
+		}
+		const events = this.__events[type];
+		if (!events || !events.length) {
+			return;
+		}
+		events.forEach((listener) => {
+			listener.call(this, e);
+		});
+	}
+	off(type, listener) {
+		const __events = this.__events;
+		const events = __events[type];
+		if (!events || !events.length) {
+			return;
+		}
+		if (!listener) {
+			delete __events[type];
+			return;
+		}
+		for (let i = 0, len = events.length; i < len; i++) {
+			if (events[i] === listener) {
+				events.splice(i, 1);
+				i--;
+			}
+		}
+	}
+}
+class Image {
+	constructor() {
+		this.currentSrc = null
+		this.naturalHeight = 0
+		this.naturalWidth = 0
+		this.width = 0
+		this.height = 0
+		this.tagName = 'IMG'
+	}
+	set src(src) {
+		this.currentSrc = src
+		uni.getImageInfo({
+			src,
+			success: (res) => {
+				this.naturalWidth = this.width = res.width
+				this.naturalHeight = this.height = res.height
+				this.onload()
+			},
+			fail: () => {
+				this.onerror()
+			}
+		})
+	}
+	get src() {
+		return this.currentSrc
+	}
+}
+class OffscreenCanvas {
+	constructor(ctx, com, canvasId) {
+		this.tagName = 'canvas'
+		this.com = com
+		this.canvasId = canvasId
+		this.ctx = ctx
+	}
+	set width(w) {
+		this.com.offscreenWidth = w
+	}
+	set height(h) {
+		this.com.offscreenHeight = h
+	}
+	get width() {
+		return this.com.offscreenWidth || 0
+	}
+	get height() {
+		return this.com.offscreenHeight || 0
+	}
+	getContext(type) {
+		return this.ctx
+	}
+	getImageData() {
+		return new Promise((resolve, reject) => {
+			this.com.$nextTick(() => {
+				uni.canvasGetImageData({
+					x:0,
+					y:0,
+					width: this.com.offscreenWidth,
+					height: this.com.offscreenHeight,
+					canvasId: this.canvasId,
+					success: (res) => {
+						resolve(res)
+					},
+					fail: (err) => {
+						reject(err)
+					},
+				}, this.com)
+			})
+		})
+	}
+}
+export class Canvas {
+	constructor(ctx, com, isNew, canvasNode={}) {
+		cacheChart[com.canvasId] = {ctx}
+		this.canvasId = com.canvasId;
+		this.chart = null;
+		this.isNew = isNew
+		this.tagName = 'canvas'
+		this.canvasNode = canvasNode;
+		this.com = com;
+		if (!isNew) {
+			this._initStyle(ctx)
+		}
+		this._initEvent();
+		this._ee = new EventEmit()
+	}
+	getContext(type) {
+		if (type === '2d') {
+			return this.ctx;
+		}
+	}
+	setAttribute(key, value) {
+		if(key === 'aria-label') {
+			this.com['ariaLabel'] = value
+		}
+	}
+	setChart(chart) {
+		this.chart = chart;
+	}
+	createOffscreenCanvas(param){
+		if(!this.children) {
+			this.com.isOffscreenCanvas = true
+			this.com.offscreenWidth = param.width||300
+			this.com.offscreenHeight = param.height||300
+			const com = this.com
+			const canvasId = this.com.offscreenCanvasId
+			const context = uni.createCanvasContext(canvasId, this.com)
+			this._initStyle(context)
+			this.children = new OffscreenCanvas(context, com, canvasId)
+		} 
+		return this.children
+	}
+	appendChild(child) {
+		console.log('child', child)
+	}
+	dispatchEvent(type, e) {
+		if(typeof type == 'object') {
+			this._ee.emit(type.type, type);
+		} else {
+			this._ee.emit(type, e);
+		}
+		return true
+	}
+	attachEvent() {
+	}
+	detachEvent() {
+	}
+	addEventListener(type, listener) {
+		this._ee.on(type, listener)
+	}
+	removeEventListener(type, listener) {
+		this._ee.off(type, listener)
+	}
+	_initCanvas(zrender, ctx) {
+		// zrender.util.getContext = function() {
+		// 	return ctx;
+		// };
+		// zrender.util.$override('measureText', function(text, font) {
+		// 	ctx.font = font || '12px sans-serif';
+		// 	return ctx.measureText(text, font);
+		// });
+	}
+	_initStyle(ctx, child) {
+		const styles = [
+			'fillStyle',
+			'strokeStyle',
+			'fontSize',
+			'globalAlpha',
+			'opacity',
+			'textAlign',
+			'textBaseline',
+			'shadow',
+			'lineWidth',
+			'lineCap',
+			'lineJoin',
+			'lineDash',
+			'miterLimit',
+			// #ifdef H5 || APP
+			'font',
+			// #endif
+		];
+		const colorReg = /#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])\b/g;
+		styles.forEach(style => {
+			Object.defineProperty(ctx, style, {
+				set: value => {
+					// #ifdef H5 || APP
+					if (style === 'font' && fontSizeReg.test(value)) {
+						const match = fontSizeReg.exec(value);
+						ctx.setFontSize(match[1]);
+						return;
+					}
+					// #endif
+					
+					if (style === 'opacity') {
+						ctx.setGlobalAlpha(value)
+						return;
+					}
+					if (style !== 'fillStyle' && style !== 'strokeStyle' || value !== 'none' && value !== null) {
+						// #ifdef H5 || APP-PLUS || MP-BAIDU
+						if(typeof value == 'object') {
+							if (value.hasOwnProperty('colorStop') || value.hasOwnProperty('colors')) {
+								ctx['set' + style.charAt(0).toUpperCase() + style.slice(1)](value);
+							}
+							return
+						} 
+						// #endif
+						// #ifdef MP-TOUTIAO
+						if(colorReg.test(value)) {
+							value = value.replace(colorReg, '#$1$1$2$2$3$3')
+						}
+						// #endif
+						ctx['set' + style.charAt(0).toUpperCase() + style.slice(1)](value);
+					}
+				}
+			});
+		});
+		if(!this.isNew && !child) {
+			ctx.uniDrawImage = ctx.drawImage
+			ctx.drawImage = (...a) => {
+				a[0] = a[0].src
+				ctx.uniDrawImage(...a)
+			}
+		}
+		if(!ctx.createRadialGradient) {
+			ctx.createRadialGradient = function() {
+				return ctx.createCircularGradient(...[...arguments].slice(-3))
+			};
+		}
+		// 字节不支持
+		if (!ctx.strokeText) {
+			ctx.strokeText = (...a) => {
+				ctx.fillText(...a)
+			}
+		}
+		
+		// 钉钉不支持 , 鸿蒙是异步
+		if (!ctx.measureText || getDeviceInfo().osName == 'harmonyos') {
+			ctx._measureText = ctx.measureText
+			const strLen = (str) => {
+				let len = 0;
+				for (let i = 0; i < str.length; i++) {
+					if (str.charCodeAt(i) > 0 && str.charCodeAt(i) < 128) {
+						len++;
+					} else {
+						len += 2;
+					}
+				}
+				return len;
+			}
+			ctx.measureText = (text, font) => {
+				let fontSize = ctx?.state?.fontSize || 12;
+				if (font) {
+					fontSize = parseInt(font.match(/([\d\.]+)px/)[1])
+				}
+				fontSize /= 2;
+				let isBold = fontSize >= 16;
+				const widthFactor = isBold ? 1.3 : 1;
+				// ctx._measureText(text, (res) => {})
+				return {
+					width: strLen(text) * fontSize * widthFactor
+				};
+			}
+		}
+	}
+
+	_initEvent(e) {
+		this.event = {};
+		const eventNames = [{
+			wxName: 'touchStart',
+			ecName: 'mousedown'
+		}, {
+			wxName: 'touchMove',
+			ecName: 'mousemove'
+		}, {
+			wxName: 'touchEnd',
+			ecName: 'mouseup'
+		}, {
+			wxName: 'touchEnd',
+			ecName: 'click'
+		}];
+
+		eventNames.forEach(name => {
+			this.event[name.wxName] = e => {
+				const touch = e.touches[0];
+				this.chart.getZr().handler.dispatch(name.ecName, {
+					zrX: name.wxName === 'tap' ? touch.clientX : touch.x,
+					zrY: name.wxName === 'tap' ? touch.clientY : touch.y
+				});
+			};
+		});
+	}
+
+	set width(w) {
+		this.canvasNode.width = w
+	}
+	set height(h) {
+		this.canvasNode.height = h
+	}
+
+	get width() {
+		return this.canvasNode.width || 0
+	}
+	get height() {
+		return this.canvasNode.height || 0
+	}
+	get ctx() {
+		return cacheChart[this.canvasId]['ctx'] || null
+	}
+	set chart(chart) {
+		cacheChart[this.canvasId]['chart'] = chart
+	}
+	get chart() {
+		return cacheChart[this.canvasId]['chart'] || null
+	}
+}
+
+export function dispatch(name, {x,y, wheelDelta}) {
+	this.dispatch(name, {
+		zrX: x,
+		zrY: y,
+		zrDelta: wheelDelta,
+		preventDefault: () => {},
+		stopPropagation: () =>{}
+	});
+}
+export function setCanvasCreator(echarts, {canvas, node}) {
+	if(echarts && !echarts.registerPreprocessor) {
+		return console.warn('echarts 版本不对或未传入echarts,vue3请使用esm格式')
+	}
+	echarts.registerPreprocessor(option => {
+		if (option && option.series) {
+			if (option.series.length > 0) {
+				option.series.forEach(series => {
+					series.progressive = 0;
+				});
+			} else if (typeof option.series === 'object') {
+				option.series.progressive = 0;
+			}
+		}
+	});
+	function loadImage(src, onload, onerror) {
+		let img = null
+		if(node && node.createImage) {
+			img = node.createImage()
+			img.onload = onload.bind(img);
+			img.onerror = onerror.bind(img);
+			img.src = src;
+			return img
+		} else {
+			img = new Image()
+			img.onload = onload.bind(img)
+			img.onerror = onerror.bind(img);
+			img.src = src
+			return img
+		}
+	}
+	if(echarts.setPlatformAPI) {
+		echarts.setPlatformAPI({
+			loadImage: canvas.setChart ? loadImage : null,
+			createCanvas(){
+				const key = 'createOffscreenCanvas'
+				return uni.canIUse(key) && uni[key] ? uni[key]({type: '2d'}) : canvas
+			}
+		})
+	} else if(echarts.setCanvasCreator) {
+		echarts.setCanvasCreator(() => {
+		    return canvas;
+		});
+	}
+	
+}

+ 511 - 0
src/pagesOther/components/ie-echart/echart.vue

@@ -0,0 +1,511 @@
+<template>
+	<view class="lime-echart" :style="[customStyle]" v-if="canvasId" ref="limeEchart" :aria-label="ariaLabel">
+		<!-- #ifndef APP-NVUE -->
+		<canvas
+			class="lime-echart__canvas"
+			v-if="use2dCanvas"
+			type="2d"
+			:id="canvasId"
+			:style="canvasStyle"
+			:disable-scroll="isDisableScroll"
+			@touchstart="touchStart"
+			@touchmove="touchMove"
+			@touchend="touchEnd"
+		/>
+		<canvas
+			class="lime-echart__canvas"
+			v-else
+			:width="nodeWidth"
+			:height="nodeHeight"
+			:style="canvasStyle"
+			:canvas-id="canvasId"
+			:id="canvasId"
+			:disable-scroll="isDisableScroll"
+			@touchstart="touchStart"
+			@touchmove="touchMove"
+			@touchend="touchEnd"
+		/>
+		<view class="lime-echart__mask"
+			v-if="isPC"
+			@mousedown="touchStart"
+			@mousemove="touchMove"
+			@mouseup="touchEnd"
+			@touchstart="touchStart"
+			@touchmove="touchMove"
+			@touchend="touchEnd">
+		</view>
+		<canvas v-if="isOffscreenCanvas" :style="offscreenStyle" :canvas-id="offscreenCanvasId"></canvas>
+		<!-- #endif -->
+		<!-- #ifdef APP-NVUE -->
+		<web-view
+			class="lime-echart__canvas"
+			:id="canvasId"
+			:style="canvasStyle"
+			:webview-styles="webviewStyles"
+			ref="webview"
+			src="/uni_modules/lime-echart/static/uvue.html?v=1"
+			@pagefinish="finished = true"
+			@onPostMessage="onMessage"
+		></web-view>
+		<!-- #endif -->
+	</view>
+</template>
+
+<script>
+// @ts-nocheck
+// #ifndef APP-NVUE
+import {Canvas, setCanvasCreator, dispatch} from './canvas';
+import {wrapTouch, convertTouchesToArray, devicePixelRatio ,sleep, canIUseCanvas2d, getRect, getDeviceInfo} from './utils';
+// #endif
+
+/**
+ * LimeChart 图表
+ * @description 全端兼容的eCharts
+ * @tutorial https://ext.dcloud.net.cn/plugin?id=4899
+
+ * @property {String} customStyle 自定义样式
+ * @property {String} type 指定 canvas 类型
+ * @value 2d 使用canvas 2d,部分小程序支持
+ * @value '' 使用原生canvas,会有层级问题
+ * @value bottom right	不缩放图片,只显示图片的右下边区域
+ * @property {Boolean} isDisableScroll	 
+ * @property {number} beforeDelay = [30]  延迟初始化 (毫秒)
+ * @property {Boolean} enableHover PC端使用鼠标悬浮
+
+ * @event {Function} finished 加载完成触发
+ */
+export default {
+	name: 'lime-echart',
+	props: {
+		// #ifdef MP-WEIXIN || MP-TOUTIAO
+		type: {
+			type: String,
+			default: '2d'
+		},
+		// #endif
+		// #ifdef APP-NVUE
+		webviewStyles: Object,
+		// hybrid: Boolean,
+		// #endif
+		customStyle: String,
+		isDisableScroll: Boolean,
+		isClickable: {
+			type: Boolean,
+			default: true
+		},
+		enableHover: Boolean,
+		beforeDelay: {
+			type: Number,
+			default: 30
+		},
+		landscape: Boolean
+	},
+	data() {
+		return {
+			// #ifdef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
+			use2dCanvas: true,
+			// #endif
+			// #ifndef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
+			use2dCanvas: false,
+			// #endif
+			ariaLabel: '图表',
+			width: null,
+			height: null,
+			nodeWidth: null,
+			nodeHeight: null,
+			// canvasNode: null,
+			config: {},
+			inited: false,
+			finished: false,
+			file: '',
+			platform: '',
+			isPC: false,
+			isDown: false,
+			isOffscreenCanvas: false,
+			offscreenWidth: 0,
+			offscreenHeight: 0,
+		};
+	},
+	computed: {
+		rootStyle() {
+			if(this.landscape) {
+				return `transform: translate(-50%,-50%) rotate(90deg); top:50%; left:50%;`
+			}
+		},
+		canvasId() {
+			return `lime-echart${this._ && this._.uid || this._uid}`
+		},
+		offscreenCanvasId() {
+			return `${this.canvasId}_offscreen`
+		},
+		offscreenStyle() {
+			return `width:${this.offscreenWidth}px;height: ${this.offscreenHeight}px; position: fixed; left: 99999px; background: red`
+		},
+		canvasStyle() {
+			return this.rootStyle + (this.width && this.height ? ('width:' + this.width + 'px;height:' + this.height + 'px') : '')
+		}
+	},
+	// #ifndef VUE3
+	beforeDestroy() {
+		this.clear()
+		this.dispose()
+		// #ifdef H5
+		if(this.isPC) {
+			document.removeEventListener('mousewheel', this.mousewheel)
+		}
+		// #endif
+	},
+	// #endif
+	// #ifdef VUE3
+	beforeUnmount() {
+		this.clear()
+		this.dispose()
+		// #ifdef H5
+		if(this.isPC) {
+			document.removeEventListener('mousewheel', this.mousewheel)
+		}
+		// #endif
+	},
+	// #endif
+	created() {
+		// #ifdef H5
+		if(!('ontouchstart' in window)) {
+			this.isPC = true
+			document.addEventListener('mousewheel', this.mousewheel)
+		}
+		// #endif
+		// #ifdef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
+		const { platform } = getDeviceInfo();
+		this.isPC = /windows/i.test(platform)
+		// #endif
+		this.use2dCanvas = this.type === '2d' && canIUseCanvas2d()
+	},
+	mounted() {
+		this.$nextTick(() => {
+			this.$emit('finished')
+		})
+	},
+	methods: {
+		// #ifdef APP-NVUE
+		onMessage(e) {
+			const detail = e?.detail?.data[0] || null;
+			const data = detail?.data
+			const key = detail?.event
+			const options = data?.options
+			const event = data?.event
+			const file = detail?.file
+			if (key == 'log' && data) {
+				console.log(data)
+			}
+			if(event) {
+				this.chart.dispatchAction(event.replace(/"/g,''), options)
+			}
+			if(file) {
+				thie.file = file
+			}
+		},
+		// #endif
+		setChart(callback) {
+			if(!this.chart) {
+				console.warn(`组件还未初始化,请先使用 init`)
+				return
+			}
+			if(typeof callback === 'function' && this.chart) {
+				callback(this.chart);
+			}
+			// #ifdef APP-NVUE
+			if(typeof callback === 'function') {
+				this.$refs.webview.evalJs(`setChart(${JSON.stringify(callback.toString())}, ${JSON.stringify(this.chart.options)})`);
+			}
+			// #endif
+		},
+		setOption() {
+			if (!this.chart || !this.chart.setOption) {
+				console.warn(`组件还未初始化,请先使用 init`)
+				return
+			}
+			this.chart.setOption(...arguments);
+		},
+		showLoading() {
+			if(this.chart) {
+				this.chart.showLoading(...arguments)
+			}
+		},
+		hideLoading() {
+			if(this.chart) {
+				this.chart.hideLoading()
+			}
+		},
+		clear() {
+			if(this.chart && !this.chart.isDisposed()) {
+				this.chart.clear()
+			}
+		},
+		dispose() {
+			if(this.chart && !this.chart.isDisposed()) {
+				this.chart.dispose()
+			}
+		},
+		resize(size) {
+			if(size && size.width && size.height) {
+				this.height = size.height
+				this.width = size.width
+				if(this.chart) {this.chart.resize(size)}
+			} else {
+				this.$nextTick(() => {
+					getRect('.lime-echart', this).then(res =>{
+						if (res) {
+							let { width, height } = res;
+							this.width = width = width || 300;
+							this.height = height = height || 300;
+							this.chart.resize({width, height})
+						}
+					})
+				})
+			}
+			
+		},
+		canvasToTempFilePath(args = {}) {
+			// #ifndef APP-NVUE
+			const { use2dCanvas, canvasId } = this;
+			return new Promise((resolve, reject) => {
+				const copyArgs = Object.assign({
+					canvasId,
+					success: resolve,
+					fail: reject
+				}, args);
+				if (use2dCanvas) {
+					delete copyArgs.canvasId;
+					copyArgs.canvas = this.canvasNode;
+				}
+				uni.canvasToTempFilePath(copyArgs, this);
+			});
+			// #endif
+			// #ifdef APP-NVUE
+			this.file = ''
+			this.$refs.webview.evalJs(`canvasToTempFilePath()`);
+			return new Promise((resolve, reject) => {
+				this.$watch('file', async (file) => {
+					if(file) {
+						const tempFilePath = await base64ToPath(file)
+						resolve(args.success({tempFilePath}))
+					} else {
+						reject(args.fail({error: ``}))
+					}
+				})
+			})
+			// #endif
+		},
+		async init(echarts, ...args) {
+			// #ifndef APP-NVUE
+			if(args && args.length == 0 && !echarts) {
+				console.error('缺少参数:init(echarts, theme?:string, opts?: object, callback?: function)')
+				return
+			}
+			// #endif
+			let theme=null,opts={},callback;
+			// Array.from(arguments)
+			args.forEach(item => {
+				if(typeof item === 'function') {
+					callback = item
+				}
+				if(['string'].includes(typeof item)) {
+					theme = item
+				}
+				if(typeof item === 'object') {
+					opts = item
+				}
+			})
+			if(this.beforeDelay) {
+				await sleep(this.beforeDelay)
+			}
+			let config = await this.getContext();
+			// #ifndef APP-NVUE
+			setCanvasCreator(echarts, config)
+			try {
+				this.chart = echarts.init(config.canvas, theme, Object.assign({}, config, opts || {}))
+				
+				callback?.(this.chart)
+				return this.chart
+			} catch(e) {
+				console.error("【lime-echarts】:", e)
+				return null
+			}
+			// #endif
+			// #ifdef APP-NVUE
+			this.chart = new Echarts(this.$refs.webview)
+			this.$refs.webview.evalJs(`init(null, null, ${JSON.stringify(opts)}, ${theme})`)
+			callback?.(this.chart)
+			return this.chart
+			// #endif
+		},
+		getContext() {
+			// #ifdef APP-NVUE
+			if(this.finished) {
+				return Promise.resolve(this.finished)
+			}
+			return new Promise(resolve => {
+				this.$watch('finished', (val) => {
+					if(val) {
+						resolve(this.finished)
+					}
+				})
+			})
+			// #endif
+			// #ifndef APP-NVUE
+			return getRect(`#${this.canvasId}`, this, this.use2dCanvas).then(res => {
+				if(res) {
+					let dpr = devicePixelRatio
+					let {width, height, node} = res
+					let canvas;
+					this.width = width = width || 300;
+					this.height = height = height || 300;
+					if(node) {
+						const ctx = node.getContext('2d');
+						canvas = new Canvas(ctx, this, true, node);
+						this.canvasNode = node
+					} else {
+						// #ifdef MP-TOUTIAO
+						dpr = !this.isPC ? devicePixelRatio : 1// 1.25
+						// #endif
+						// #ifndef MP-ALIPAY || MP-TOUTIAO
+						dpr = this.isPC ? devicePixelRatio : 1
+						// #endif
+						// #ifdef MP-ALIPAY || MP-LARK
+						dpr = devicePixelRatio
+						// #endif
+						// #ifdef WEB
+						dpr = 1
+						// #endif
+						this.rect = res
+						this.nodeWidth = width * dpr;
+						this.nodeHeight = height * dpr;
+						const ctx = uni.createCanvasContext(this.canvasId, this);
+						canvas =  new Canvas(ctx, this, false);
+					}
+					
+					return { canvas, width, height, devicePixelRatio: dpr, node };
+				} else {
+					return {}
+				}
+			})
+			// #endif
+		},
+		// #ifndef APP-NVUE
+		getRelative(e, touches) {
+			let { clientX, clientY } = e
+			if(!(clientX && clientY) && touches && touches[0]) {
+				clientX = touches[0].clientX
+				clientY = touches[0].clientY
+			}
+			return {x: clientX - this.rect.left, y: clientY - this.rect.top, wheelDelta: e.wheelDelta || 0}
+		},
+		getTouch(e, touches) {
+			const {x} = touches && touches[0] || {}
+			const touch = x ? touches[0] : this.getRelative(e, touches);
+			if(this.landscape) {
+				[touch.x, touch.y] = [touch.y, this.height - touch.x]
+			}
+			return touch;
+		},
+		touchStart(e) {
+			this.isDown = true
+			const next = () => {
+				const touches = convertTouchesToArray(e.touches)
+				if(this.chart) {
+					const touch = this.getTouch(e, touches)
+					this.startX = touch.x
+					this.startY = touch.y
+					this.startT = new Date()
+					const handler = this.chart.getZr().handler;
+					dispatch.call(handler, 'mousedown', touch)
+					dispatch.call(handler, 'mousemove', touch)
+					handler.processGesture(wrapTouch(e), 'start');
+					clearTimeout(this.endTimer);
+				}
+				
+			}
+			if(this.isPC) {
+				getRect(`#${this.canvasId}`, {context: this}).then(res => {
+					this.rect = res
+					next()
+				})
+				return
+			}
+			next()
+		},
+		touchMove(e) {
+			if(this.isPC && this.enableHover && !this.isDown) {this.isDown = true}
+			const touches = convertTouchesToArray(e.touches)
+			if (this.chart && this.isDown) {
+				const handler = this.chart.getZr().handler;
+				dispatch.call(handler, 'mousemove', this.getTouch(e, touches))
+				handler.processGesture(wrapTouch(e), 'change');
+			}
+			
+		},
+		touchEnd(e) {
+			this.isDown = false
+			if (this.chart) {
+				const touches = convertTouchesToArray(e.changedTouches)
+				const {x} = touches && touches[0] || {}
+				const touch = (x ? touches[0] : this.getRelative(e, touches)) || {};
+				if(this.landscape) {
+					[touch.x, touch.y] = [touch.y,  this.height - touch.x]
+				}
+				const handler = this.chart.getZr().handler;
+				const isClick = Math.abs(touch.x - this.startX) < 10 && new Date() - this.startT < 200;
+				dispatch.call(handler, 'mouseup', touch)
+				handler.processGesture(wrapTouch(e), 'end');
+				if(isClick) {
+					dispatch.call(handler, 'click', touch)
+				} else {
+					this.endTimer = setTimeout(() => {
+						dispatch.call(handler, 'mousemove', {x: 999999999,y: 999999999});
+						dispatch.call(handler, 'mouseup', {x: 999999999,y: 999999999});
+					},50)
+				}
+			}
+		},
+		// #endif
+		// #ifdef H5
+		mousewheel(e){
+			if(this.chart) {
+				dispatch.call(this.chart.getZr().handler, 'mousewheel', this.getTouch(e))
+			}
+		}
+		// #endif
+	}
+};
+</script>
+<style>	
+.lime-echart {
+	position: relative;
+	/* #ifndef APP-NVUE */
+	width: 100%;
+	height: 100%;
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	flex: 1;
+	/* #endif */
+}
+.lime-echart__canvas {
+	/* #ifndef APP-NVUE */
+	width: 100%;
+	height: 100%;
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	flex: 1;
+	/* #endif */
+}
+/* #ifndef APP-NVUE */
+.lime-echart__mask {
+	position: absolute;
+	width: 100%;
+	height: 100%;
+	left: 0;
+	top: 0;
+	z-index: 1;
+}
+/* #endif */
+</style>

+ 66 - 0
src/pagesOther/components/ie-echart/ie-echart.vue

@@ -0,0 +1,66 @@
+<template>
+  <view class="w-full h-full">
+    <echart ref="chartRef"></echart>
+  </view>
+</template>
+
+<script setup>
+import echart from './echart.vue';
+// #ifdef MP-WEIXIN
+const echarts = require('../../static/echarts.min.js')
+// #endif
+// #ifdef H5
+import * as echarts from 'echarts'
+// #endif
+const props = defineProps({
+  option: {
+    type: Object,
+    default: () => ({}),
+  },
+})
+watch(() => props.option, (newVal) => {
+  init();
+}, {
+  deep: true,
+})
+const chartRef = ref();
+const isInit = ref(false);
+const init = () => {
+  if (isInit.value) {
+    chartRef.value.chart.dispatchAction({
+      type: 'hideTip'
+    });
+    chartRef.value.chart.setOption(props.option)
+    return;
+  }
+  chartRef.value.init(echarts, (chart) => {
+    chart.setOption(props.option)
+    isInit.value = true;
+    // 监听tooltip显示事件
+    chart.on('showTip', (params) => { })
+    chart.on('hideTip', (params) => { })
+  })
+}
+
+const save = () => {
+  chartRef.value.chart.canvasToTempFilePath({
+    success(res) {
+      console.log('res::::', res)
+    },
+  })
+}
+
+onMounted(() => {
+  setTimeout(() => {
+    init();
+  }, 100);
+});
+
+</script>
+<style>
+.customTooltips {
+  position: absolute;
+  background-color: rgba(255, 255, 255, 0.8);
+  padding: 20rpx;
+}
+</style>

+ 185 - 0
src/pagesOther/components/ie-echart/utils.js

@@ -0,0 +1,185 @@
+// @ts-nocheck
+/**
+ * 获取设备基础信息
+ *
+ * @see [uni.getDeviceInfo](https://uniapp.dcloud.net.cn/api/system/getDeviceInfo.html)
+ */
+export function getDeviceInfo() {
+	if (uni.getDeviceInfo || uni.canIUse('getDeviceInfo')) {
+		return uni.getDeviceInfo();
+	} else {
+		return uni.getSystemInfoSync();
+	}
+}
+
+/**
+ * 获取窗口信息
+ *
+ * @see [uni.getWindowInfo](https://uniapp.dcloud.net.cn/api/system/getWindowInfo.html)
+ */
+export function getWindowInfo() {
+	if (uni.getWindowInfo || uni.canIUse('getWindowInfo')) {
+		return uni.getWindowInfo();
+	} else {
+		return uni.getSystemInfoSync();
+	}
+}
+
+/**
+ * 获取APP基础信息
+ *
+ * @see [uni.getAppBaseInfo](https://uniapp.dcloud.net.cn/api/system/getAppBaseInfo.html)
+ */
+export function getAppBaseInfo() {
+	if (uni.getAppBaseInfo || uni.canIUse('getAppBaseInfo')) {
+		return uni.getAppBaseInfo();
+	} else {
+		return uni.getSystemInfoSync();
+	}
+}
+
+
+// #ifndef APP-NVUE
+// 计算版本
+export function compareVersion(v1, v2) {
+	v1 = v1.split('.')
+	v2 = v2.split('.')
+	const len = Math.max(v1.length, v2.length)
+	while (v1.length < len) {
+		v1.push('0')
+	}
+	while (v2.length < len) {
+		v2.push('0')
+	}
+	for (let i = 0; i < len; i++) {
+		const num1 = parseInt(v1[i], 10)
+		const num2 = parseInt(v2[i], 10)
+
+		if (num1 > num2) {
+			return 1
+		} else if (num1 < num2) {
+			return -1
+		}
+	}
+	return 0
+}
+// const systemInfo = uni.getSystemInfoSync();
+
+function gte(version) {
+	// 截止 2023-03-22 mac pc小程序不支持 canvas 2d
+	// let {
+	// 	SDKVersion,
+	// 	platform
+	// } = systemInfo;
+	const { platform } = getDeviceInfo();
+	let { SDKVersion } = getAppBaseInfo();
+	// #ifdef MP-ALIPAY
+	SDKVersion = my.SDKVersion
+	// #endif
+	// #ifdef MP-WEIXIN
+	return platform !== 'mac' && compareVersion(SDKVersion, version) >= 0;
+	// #endif
+	return compareVersion(SDKVersion, version) >= 0;
+}
+
+
+export function canIUseCanvas2d() {
+	// #ifdef MP-WEIXIN
+	return gte('2.9.0');
+	// #endif
+	// #ifdef MP-ALIPAY
+	return gte('2.7.0');
+	// #endif
+	// #ifdef MP-TOUTIAO
+	return gte('1.78.0');
+	// #endif
+	return false
+}
+
+export function convertTouchesToArray(touches) {
+	// 如果 touches 是一个数组,则直接返回它
+	if (Array.isArray(touches)) {
+		return touches;
+	}
+	// 如果touches是一个对象,则转换为数组
+	if (typeof touches === 'object' && touches !== null) {
+		return Object.values(touches);
+	}
+	// 对于其他类型,直接返回它
+	return touches;
+}
+
+export function wrapTouch(event) {
+	event.touches = convertTouchesToArray(event.touches)
+	for (let i = 0; i < event.touches.length; ++i) {
+		const touch = event.touches[i];
+		touch.offsetX = touch.x;
+		touch.offsetY = touch.y;
+	}
+	return event;
+}
+// export const devicePixelRatio = uni.getSystemInfoSync().pixelRatio
+export const devicePixelRatio = getWindowInfo().pixelRatio;
+// #endif
+// #ifdef APP-NVUE
+export function base64ToPath(base64) {
+	return new Promise((resolve, reject) => {
+		const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64) || [];
+		const bitmap = new plus.nativeObj.Bitmap('bitmap' + Date.now())
+		bitmap.loadBase64Data(base64, () => {
+			if (!format) {
+				reject(new Error('ERROR_BASE64SRC_PARSE'))
+			}
+			const time = new Date().getTime();
+			const filePath = `_doc/uniapp_temp/${time}.${format}`
+
+			bitmap.save(filePath, {},
+				() => {
+					bitmap.clear()
+					resolve(filePath)
+				},
+				(error) => {
+					bitmap.clear()
+					console.error(`${JSON.stringify(error)}`)
+					reject(error)
+				})
+		}, (error) => {
+			bitmap.clear()
+			console.error(`${JSON.stringify(error)}`)
+			reject(error)
+		})
+	})
+}
+// #endif
+
+
+export function sleep(time) {
+	return new Promise((resolve) => {
+		setTimeout(() => {
+			resolve(true)
+		}, time)
+	})
+}
+
+
+export function getRect(selector, context, node) {
+	return new Promise((resolve, reject) => {
+		const dom = uni.createSelectorQuery().in(context).select(selector);
+		const result = (rect) => {
+			if (rect) {
+				resolve(rect)
+			} else {
+				reject()
+			}
+		}
+		if (!node) {
+			dom.boundingClientRect(result).exec()
+		} else {
+			dom.fields({
+				node: true,
+				size: true,
+				rect: true
+			}, result).exec()
+		}
+	});
+};

+ 132 - 0
src/pagesOther/pages/career/detail/components/career-overview.vue

@@ -0,0 +1,132 @@
+<template>
+  <scroll-view v-if="overview" class=" h-full overflow-auto" scroll-y>
+    <view class="mt-20 bg-white p-30">
+      <view class="text-36 font-bold">{{ overview.name }}</view>
+      <view class="mt-10 text-26 text-fore-content">
+        <uv-parse :content="overview.summary" />
+      </view>
+      <view class="p-30 text-28 text-fore-content bg-back rounded-12 mt-10 whitespace-pre-line">
+        <uv-parse :content="overview.description" />
+      </view>
+    </view>
+    <view class="mt-20 bg-white">
+      <view class="text-30 px-30 py-20">
+        <span>相关岗位</span>
+        <span class="text-danger mx-10">{{ overview.postJobs.length }}</span>
+        <span>个</span>
+      </view>
+      <view>
+        <uv-cell-group :border="false">
+          <uv-cell v-for="(job, index) in overview.postJobs" :key="index" :title="job.name" :isLink="true"
+            :border="true" :value="`${job.salaryMin}-${job.salaryMax}/月`" arrow-direction="right" @click="handleClickJob(job.name)">
+            <template #title>
+              <view class="flex items-center gap-10">
+                <view class="text-28 text-fore-title">{{ job.name }}</view>
+              </view>
+            </template>
+          </uv-cell>
+        </uv-cell-group>
+      </view>
+    </view>
+    <view class="mt-20 bg-white">
+      <view class="text-30 px-30 py-20">
+        <span>相关职业</span>
+        <span class="text-danger mx-10">{{ overview.jobs.length }}</span>
+        <span>个</span>
+      </view>
+      <view>
+        <uv-cell-group :border="false">
+          <uv-cell v-for="(job, index) in overview.jobs" :key="index" :title="job.name" :isLink="true" :border="true"
+            arrow-direction="right" @click="handleClickCareer(job.code)">
+            <template #title>
+              <view class="flex items-center gap-10">
+                <view class="text-28 text-fore-title">{{ job.name }}</view>
+              </view>
+            </template>
+          </uv-cell>
+        </uv-cell-group>
+      </view>
+    </view>
+    <view class="mt-20 bg-white">
+      <view class="text-30 px-30 py-20">
+        <span>相关专业</span>
+        <span class="text-danger mx-10">{{ overview.postMajors.length }}</span>
+        <span>个</span>
+      </view>
+      <view>
+        <uv-cell-group :border="false">
+          <uv-cell v-for="(major, index) in overview.postMajors" :key="index" :title="major.name" :isLink="true"
+            :border="index < overview.postMajors.length - 1" arrow-direction="right"
+            @click="handleClickMajor(major.code)">
+            <template #title>
+              <view class="text-28 text-fore-title">{{ major.name }}</view>
+              <view class="mt-10 flex items-center gap-10 flex-1">
+                <view class="flex-1 flex flex-col items-center justify-center">
+                  <view class="text-28 text-fore-title">{{ major.code }}</view>
+                  <view class="text-22 text-fore-light">国标代码</view>
+                </view>
+                <view class="flex-1 flex flex-col items-center justify-center">
+                  <view class="text-28 text-fore-title">{{ major.learnYear }}</view>
+                  <view class="text-22 text-fore-light">学制</view>
+                </view>
+                <view class="flex-1 flex flex-col items-center justify-center">
+                  <view class="text-28 text-fore-title">{{ major.mfRatioView }}</view>
+                  <view class="text-22 text-fore-light">男女比例</view>
+                </view>
+              </view>
+            </template>
+          </uv-cell>
+        </uv-cell-group>
+      </view>
+    </view>
+    <ie-safe-area-bottom bg-color="#FFFFFF" />
+  </scroll-view>
+</template>
+<script lang="ts" setup>
+import { getCareerOverview } from '@/api/modules/career';
+import { Career } from '@/types';
+import { useTransferPage } from '@/hooks/useTransferPage';
+const { transferTo, routes } = useTransferPage();
+const props = defineProps<{
+  code: string;
+}>();
+
+const overview = ref<Career.CareerOverview | null>(null);
+
+const handleClickCareer = (code: string) => {
+  transferTo(routes.careerDetail, {
+    data: {
+      code: code,
+    },
+    type: 'redirectTo'
+  });
+}
+
+const emit = defineEmits<{
+  (e: 'change-job', code: string): void;
+}>();
+const handleClickJob = (name: string) => {
+  emit('change-job', name);
+}
+
+const handleClickMajor = (code: string) => {
+  transferTo(routes.majorDetail, {
+    data: {
+      code: code,
+    }
+  });
+}
+const loadData = () => {
+  uni.$ie.showLoading();
+  getCareerOverview(props.code).then(res => {
+    overview.value = res.data;
+  }).finally(() => {
+    uni.$ie.hideLoading();
+  });
+};
+
+onMounted(() => {
+  loadData();
+});
+</script>
+<style lang="scss" scoped></style>

+ 97 - 0
src/pagesOther/pages/career/detail/components/employment-chart.vue

@@ -0,0 +1,97 @@
+<template>
+  <view class="w-full h-[200px]">
+    <ie-echart :option="options" />
+  </view>
+</template>
+<script lang="ts" setup>
+import IeEchart from '@/pagesOther/components/ie-echart/ie-echart.vue';
+import { Career } from '@/types';
+
+const props = defineProps({
+  data: {
+    type: Object as PropType<Career.CareerJobDetail>,
+    default: () => ({})
+  },
+  type: {
+    type: String as PropType<'education' | 'experience'>,
+    default: 'education'
+  }
+});
+const educationColors = [
+  '#4A90E2', // 科技蓝(主色)
+  '#FF6B6B', // 活力红(主色)
+  '#6AB04C', // 成长绿(辅助色)
+  '#F4A261', // 温暖橙(辅助色)
+  '#8E5DAA', // 智慧紫(辅助色)
+  '#E0E0E0', // 浅灰(点缀色)
+  '#FFD166'  // 亮黄(点缀色)
+];
+const options = computed(() => {
+  const data = props.type === 'education' ? props.data.edu : props.data.exp;
+  const seriesData = data.map(item => {
+    return {
+      name: props.type === 'education' ? (item as Career.CareerJobEdu).edu : (item as Career.CareerJobExp).exp,
+      value: props.type === 'education' ? (item as Career.CareerJobEdu).ratio : (item as Career.CareerJobExp).ratio
+    }
+  });
+  return {
+    color: educationColors,
+    title: {
+      show: false
+    },
+    tooltip: {
+      trigger: 'item',
+      confine: true,
+      formatter: '{b}: {c}%'
+    },
+    legend: {
+      show: false,
+      right: 'right',
+      top: 'center',
+      itemWidth: 18,
+      itemHeight: 10,
+      itemGap: 10,
+      itemStyle: {
+        borderRadius: 0
+      },
+      formatter: (name: string) => {
+        const value = seriesData.find(item => item.name === name)?.value;
+        return `${name}: ${value ? value : 0}%`;
+      },
+      textStyle: {
+        color: '#333',
+        fontSize: 10
+      }
+    },
+    grid: {
+      top: '10%',
+      left: '1%',
+      right: '2%',
+      bottom: '2%',
+      containLabel: true
+    },
+    series: [
+      {
+        name: '就业情况',
+        type: 'pie',
+        radius: ['46%', '70%'],
+        center: ['50%', '50%'],
+        data: seriesData,
+        label: {
+          show: true,
+          formatter: '{b}: \n{c}%',
+          textStyle: {
+            color: '#333',
+            fontSize: 12
+          }
+        },
+        labelLine: {
+          show: true
+        }
+      }
+    ]
+  };
+
+});
+</script>
+<style lang="scss" scoped></style>

+ 182 - 0
src/pagesOther/pages/career/detail/components/related-jobs.vue

@@ -0,0 +1,182 @@
+<template>
+  <scroll-view class=" h-full overflow-auto" scroll-y>
+    <view class="py-20">
+      <uv-tabs :list="list" :current="current" :itemStyle="itemStyle" :customStyle="customStyle" lineColor="transparent"
+        @change="handleChange">
+        <template #default="{ data: { item, index } }">
+          <view class="px-20 py-10 flex items-center flex-col justify-center transition-all duration-300"
+            :class="[index === current ? 'bg-primary text-white' : 'bg-white']">
+            <view class=" text-26">{{ item.name }}</view>
+            <view class=" text-22">{{ item.salaryMin }}-{{ item.salaryMax }}{{ item.salaryUnit }}</view>
+          </view>
+        </template>
+      </uv-tabs>
+    </view>
+    <view class="bg-white p-30">
+      <view class="flex items-center justify-between">
+        <span class="text-30 font-bold text-fore-title">薪资情况</span>
+        <view class="w-240">
+          <uv-subsection :list="section1" keyName="name" :current="currentSection1" @change="changeSection1" />
+        </view>
+      </view>
+      <view class="min-h-[200px]">
+        <salary-chart v-if="detail" :data="detail" :type="salaryType" />
+      </view>
+    </view>
+    <view class="mt-20 bg-white p-30">
+      <view class="flex items-center justify-between">
+        <span class="text-30 font-bold text-fore-title">收入情况</span>
+        <view class="w-240">
+          <uv-subsection :list="section2" keyName="name" :current="currentSection2" @change="changeSection2" />
+        </view>
+      </view>
+      <view class="min-h-[200px] mt-30">
+        <view v-for="item in income" :key="item.name" class="flex items-center text-28 py-10">
+          <text>{{ item.name }}</text>
+          <view class="flex-1 mx-20">
+            <uv-line dashed />
+          </view>
+          <text class="text-fore-content">¥{{ item.value }}</text>
+        </view>
+      </view>
+    </view>
+    <view class="mt-20 bg-white p-30">
+      <view class="flex items-center justify-between">
+        <span class="text-30 font-bold text-fore-title">就业情况</span>
+        <view class="w-240">
+          <uv-subsection :list="section3" keyName="name" :current="currentSection3" @change="changeSection3" />
+        </view>
+      </view>
+      <view class="min-h-[200px]">
+        <employment-chart v-if="detail" :data="detail" :type="employmentType" />
+      </view>
+    </view>
+    <view class="mt-20 bg-white p-30">
+      <view class="flex items-center gap-8">
+        <uv-icon name="info-circle" size="16" color="#888" />
+        <text class="text-28 text-fore-content">数据来源说明</text>
+      </view>
+      <view class="mt-20 text-24 text-fore-light">{{ detail?.salarySource }}</view>
+      <view class="text-24 text-fore-light">{{ detail?.vocationalSource }}</view>
+    </view>
+    <ie-safe-area-bottom bg-color="#FFFFFF" />
+  </scroll-view>
+</template>
+<script lang="ts" setup>
+import SalaryChart from './salary-chart.vue';
+import EmploymentChart from './employment-chart.vue';
+import { Career } from '@/types';
+import { getCareerJob, getCareerJobDetail } from '@/api/modules/career';
+
+const props = defineProps<{
+  code: string;
+  name: string;
+}>();
+const itemStyle = {
+  height: 'fit-content',
+  padding: '0 10rpx !important',
+}
+const customStyle = {
+  padding: '0 10rpx',
+}
+
+watch(() => props.name, (newVal) => {
+  if (newVal) {
+    const index = list.value.findIndex(item => item.name === newVal);
+    getJobDetail(index);
+    setTimeout(() => {
+      nextTick(() => {
+        current.value = index;
+      });
+    }, 350);
+  }
+});
+
+const section1 = [
+  {
+    name: '按趋势',
+    type: 'trend'
+  },
+  {
+    name: '按分布',
+    type: 'distribution'
+  }
+]
+const section2 = [
+  {
+    name: '按行业',
+    type: 'industry'
+  },
+  {
+    name: '按城市',
+    type: 'city'
+  }
+]
+const section3 = [
+  {
+    name: '按学历',
+    type: 'education'
+  },
+  {
+    name: '按经验',
+    type: 'experience'
+  }
+]
+const current = ref(-1);
+const currentSection1 = ref(0);
+const currentSection2 = ref(0);
+const currentSection3 = ref(0);
+const list = ref<Career.CareerJob[]>([]);
+const detail = ref<Career.CareerJobDetail | null>(null);
+const salaryType = computed(() => section1[currentSection1.value].type as 'trend' | 'distribution');
+const industryType = computed(() => section2[currentSection2.value].type as 'industry' | 'city');
+const employmentType = computed(() => section3[currentSection3.value].type as 'education' | 'experience');
+const income = computed(() => {
+  if (industryType.value === 'industry') {
+    return detail.value?.industrySalary.map(item => {
+      return {
+        name: item.name,
+        value: item.salary
+      }
+    });
+  } else {
+    return detail.value?.citySalary.map(item => {
+      return {
+        name: item.city,
+        value: item.salary
+      }
+    });
+  }
+})
+const handleChange = (e: any) => {
+  current.value = e.index;
+  getJobDetail(e.index);
+}
+const changeSection1 = (index: number) => {
+  currentSection1.value = index;
+}
+const changeSection2 = (index: number) => {
+  currentSection2.value = index;
+}
+const changeSection3 = (index: number) => {
+  currentSection3.value = index;
+}
+const getJobDetail = (index: number) => {
+  uni.$ie.showLoading();
+  const name = list.value[index].name;
+  getCareerJobDetail(name).then(res => {
+    detail.value = res.data;
+  }).finally(() => uni.$ie.hideLoading());
+}
+const loadData = () => {
+  getCareerJob(props.code).then(res => {
+    list.value = res.data;
+    current.value = 0;
+    getJobDetail(0);
+  });
+}
+onMounted(() => {
+  loadData();
+});
+</script>
+<style lang="scss" scoped></style>

+ 123 - 0
src/pagesOther/pages/career/detail/components/salary-chart.vue

@@ -0,0 +1,123 @@
+<template>
+  <view class="w-full h-[200px]">
+    <ie-echart :option="options" />
+  </view>
+</template>
+<script lang="ts" setup>
+import IeEchart from '@/pagesOther/components/ie-echart/ie-echart.vue';
+import { Career } from '@/types';
+
+const props = defineProps({
+  data: {
+    type: Object as PropType<Career.CareerJobDetail>,
+    default: () => ({})
+  },
+  type: {
+    type: String as PropType<'trend' | 'distribution'>,
+    default: 'trend'
+  }
+});
+const options = computed(() => {
+  const data = props.type === 'trend' ? props.data.experience : props.data.citySalary;
+  const xAxisData = data.map(item => {
+    return props.type === 'trend' ? (item as Career.CareerJobExperience).year : (item as Career.CareerJobSalary).city;
+  });
+  const seriesData = data.map(item => {
+    return props.type === 'trend' ? (item as Career.CareerJobExperience).salary : (item as Career.CareerJobSalary).salary;
+  });
+  return {
+    title: {
+      show: false
+    },
+    tooltip: {
+      trigger: 'axis',
+      confine: true,
+    },
+    legend: {
+      show: false
+    },
+    grid: {
+      top: '10%',
+      left: '1%',
+      right: '2%',
+      bottom: '2%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData,
+      boundaryGap: false,
+      axisLine: {
+        show: true,
+        lineStyle: {
+          color: '#333'
+        }
+      },
+      axisTick: {
+        show: true,
+        lineStyle: {
+          color: '#333'
+        }
+      },
+      axisLabel: {
+        interval: 0,
+        rotate: 25,
+        textStyle: {
+          color: '#333',
+          fontSize: 10
+        }
+      }
+    },
+    yAxis: {
+      type: 'value',
+      title: {
+        show: false
+      },
+      axisLine: {
+        show: false
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        textStyle: {
+          color: '#333',
+          fontSize: 10
+        }
+      },
+      splitLine: {
+        show: true
+      }
+    },
+    series: [
+      {
+        name: '薪资情况',
+        type: 'line',
+        smooth: true,
+        title: {
+          show: false
+        },
+        data: seriesData,
+        itemStyle: {
+          color: '#65dc79'
+        },
+        areaStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: '#65dc79' },
+              { offset: 1, color: '#fff' }
+            ]
+          }
+        }
+      }
+    ]
+  };
+
+});
+</script>
+<style lang="scss" scoped></style>

+ 48 - 3
src/pagesOther/pages/career/detail/detail.vue

@@ -1,9 +1,54 @@
 <template>
-<ie-page>
-  <ie-navbar title="职业详情" />
-</ie-page>
+  <ie-page :fix-height="true" bg-color="#F6F8FA" :safe-area-inset-bottom="false">
+    <ie-navbar :title="pageTitle" />
+    <ie-auto-resizer>
+      <ie-tabs-swiper v-model="current" :list="tabs" :scrollable="false">
+        <swiper class="swiper h-full" :current="current" @change="handleChangeSwiper">
+          <swiper-item v-for="(item, index) in tabs" :key="index" class="h-full">
+            <view class="h-full" v-if="Math.abs(index - current) <= 2">
+              <career-overview v-if="item.slot === 'career-overview' && code" :code="code" @change-job="handleChangeJob" />
+              <related-jobs v-if="item.slot === 'related-jobs' && code" :code="code" :name="jobName" />
+            </view>
+          </swiper-item>
+        </swiper>
+      </ie-tabs-swiper>
+    </ie-auto-resizer>
+  </ie-page>
 </template>
 <script lang="ts" setup>
+import { SwiperTabItem } from '@/types';
+import { useTransferPage } from '@/hooks/useTransferPage';
+import CareerOverview from './components/career-overview.vue';
+import RelatedJobs from './components/related-jobs.vue';
 
+const current = ref(0);
+const tabs = ref<SwiperTabItem[]>([
+  {
+    name: '职业介绍',
+    slot: 'career-overview'
+  },
+  {
+    name: '就业岗位',
+    slot: 'related-jobs'
+  },
+]);
+const { prevData } = useTransferPage();
+const code = ref('');
+const jobName = ref('');
+const pageTitle = computed(() => {
+  return prevData.value.name || '职业详情';
+});
+
+const handleChangeSwiper = (e: any) => {
+  current.value = e.detail.current;
+}
+const handleChangeJob = (name: string) => {
+  current.value = 1;
+  jobName.value = name;
+}
+
+onLoad(() => {
+  code.value = prevData.value.code;
+});
 </script>
 <style lang="scss" scoped></style>

+ 22 - 10
src/pagesOther/pages/career/index/components/career-list.vue

@@ -18,19 +18,29 @@
                     :class="{ 'rotate-90': expanded }" />
                   <view>{{ child.name }}</view>
                 </view>
-                <view class="text-24 text-gray-500">{{ child.children?.length }}个专业</view>
+                <view class="text-26 text-fore-light">{{ child.children?.length }}个专业</view>
               </view>
             </template>
             <template #right-icon>
               <view></view>
             </template>
-            <view class="bg-back-light">
-              <uv-cell-group :border="false">
-                <uv-cell v-for="(grandchild, index) in child.children" :key="grandchild.id" icon=""
-                  :title="grandchild.name" :isLink="true" :border="false" arrow-direction="right"
-                  @click="handleNodeClick(child)"></uv-cell>
-              </uv-cell-group>
-            </view>
+            <!-- 使用 v-if 延迟渲染,只在展开时渲染内容,提升初始加载性能 -->
+            <!-- 一旦渲染过就保持显示,避免收起时立即消失 -->
+            <template #default="{ expanded, hasRendered }">
+              <view v-if="expanded || hasRendered" class="bg-back-light">
+                <uv-cell-group :border="false">
+                  <uv-cell v-for="(grandchild, index) in child.children" :key="grandchild.id" icon=""
+                    :title="grandchild.name" :isLink="true" :border="false" arrow-direction="right"
+                    @click="handleNodeClick(grandchild)">
+                    <template #title>
+                      <view class="flex items-center gap-10 pl-40">
+                        <view class="text-30 text-fore-title">{{ grandchild.name }}</view>
+                      </view>
+                    </template>
+                  </uv-cell>
+                </uv-cell-group>
+              </view>
+            </template>
           </uv-collapse-item>
         </uv-collapse>
       </view>
@@ -40,15 +50,17 @@
 </template>
 <script lang="ts" setup>
 import { Career } from '@/types';
-import { TreeProps } from '@/types/tree';
 
 const props = defineProps<{
   data: Career.CareerItem[];
   searchMode: boolean;
 }>();
 
+const emit = defineEmits<{
+  (e: 'node-click', item: Career.CareerItem): void;
+}>();
 const handleNodeClick = (item: Career.CareerItem) => {
-  console.log(item);
+  emit('node-click', item);
 }
 </script>
 <style lang="scss" scoped></style>

+ 12 - 1
src/pagesOther/pages/career/index/index.vue

@@ -6,7 +6,7 @@
         <ie-search v-model="keyword" placeholder="输入职业名称" @search="handleSearch" @clear="handleSearch" />
       </template>
       <view class="">
-        <career-list :data="list" :search-mode="searchMode" />
+        <career-list :data="list" :search-mode="searchMode" @node-click="handleNodeClick" />
       </view>
     </z-paging>
   </ie-page>
@@ -16,6 +16,9 @@
 import { Career } from '@/types';
 import CareerList from './components/career-list.vue';
 import { getCareerTree } from '@/api/modules/career';
+import { useTransferPage } from '@/hooks/useTransferPage';
+
+const { transferTo, routes } = useTransferPage();
 
 const list = ref<Career.CareerItem[]>([]);
 const keyword = ref('');
@@ -32,6 +35,14 @@ const loadData = (page: number, size: number) => {
 const handleSearch = () => {
   paging.value?.reload();
 }
+const handleNodeClick = (item: Career.CareerItem) => {
+  transferTo(routes.careerDetail, {
+    data: {
+      code: item.code,
+      name: item.name,
+    }
+  });
+}
 </script>
 
 <style lang="scss" scoped></style>

+ 5 - 5
src/pagesOther/pages/major/detail/detail.vue

@@ -33,14 +33,15 @@ const tabs = ref<SwiperTabItem[]>([
   },
 ]);
 const { prevData } = useTransferPage();
-const majorData = ref<Major.MajorOverview>({} as Major.MajorOverview);
-const overviewData = ref<Major.MajorOverview>({} as Major.MajorOverview);
+const overviewData = ref<Major.MajorOverview | null>(null);
 const loadData = async () => {
   uni.$ie.showLoading();
   try {
     const res = await getMajorOverviewByCode(prevData.value.code);
-    overviewData.value = res.data;
-    
+    overviewData.value = {
+      ...res.data,
+      code: prevData.value.code,
+    } as Major.MajorOverview;
   } finally {
     uni.$ie.hideLoading();
   }
@@ -49,7 +50,6 @@ const handleChangeSwiper = (e: any) => {
   current.value = e.detail.current;
 }
 onLoad(() => {
-  majorData.value = prevData.value;
   loadData();
 });
 </script>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/pagesOther/static/echarts.min.js


+ 4 - 2
src/pagesStudy/pages/study-plan/components/page-header.vue

@@ -1,9 +1,9 @@
 <template>
   <view class="relative">
     <ie-image :is-oss="true" src="/study-bg5.png" customClass="w-full h-[555rpx] absolute top-0 left-0 z-0" />
-    <ie-image :is-oss="true" src="/study-bg6.png" customClass="w-220 h-205 absolute top-83 right-38 z-1" />
+    <ie-image :is-oss="true" src="/study-bg6.png" :customClass="`w-220 h-205 absolute right-38 z-1 ${appStore.isH5 ? 'top-83' : 'top-153'}`" />
     <view class="relative z-2">
-      <view class="pt-122 ml-43">
+      <view :class="`ml-43 ${appStore.isH5 ? 'pt-122' : 'pt-192'}`">
         <ie-image :is-oss="true" src="/study-title2.png" customClass="w-240 h-52" />
         <view class="mt-22 text-24 text-fore-light"> 每天按计划学习,让进步看得见~</view>
       </view>
@@ -21,7 +21,9 @@
   </view>
 </template>
 <script lang="ts" setup>
+import { useAppStore } from '@/store/appStore';
 import { STUDY_PLAN_STATS, STUDY_PLAN } from '@/types/injectionSymbols';
+const appStore = useAppStore();
 const studyPlan = inject(STUDY_PLAN);
 const studyPlanStatsRef = inject(STUDY_PLAN_STATS);
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/pagesStudy/static/echarts.min.js


+ 86 - 98
src/types/career.ts

@@ -1,3 +1,5 @@
+import { Entity } from ".";
+
 /**
  * 职业项接口
  */
@@ -22,107 +24,93 @@ export interface CareerTreeQueryDTO {
 }
 
 /**
- * 业详情接口
+ * 业详情接口
  */
-export interface MajorOverview {
-  /** 接续高职本科专业举例 */
-  benMajors: string;
-  /** 大类名称 */
-  bigName: string;
-  /** 子级数量 */
-  childCount: number;
-  /** 专业代码 */
+export interface CareerOverview extends Entity {
   code: string;
-  /** 代码列表 */
-  codes: string[] | null;
-  /** 创建人 */
-  createBy: string | null;
-  /** 创建时间 */
-  createTime: string | null;
-  /** 学位 */
-  degree: string;
-  /** 教育层次:zhuan-专科,ben-本科 */
-  eduLevel: string;
-  /** 培养目标 */
-  eduObjective: string;
-  /** 培养要求 */
-  eduRequirement: string;
-  /** 就业热度 */
-  employmentHeat: number;
-  /** 知名学者 */
-  famousScholar: string;
-  /** 女性比例 */
-  femaleRatio: number;
-  /** 女性比例文本 */
-  femaleRatioText: string;
-  /** 点击量 */
-  hits: number;
-  /** 专业ID */
-  id: number;
-  /** 实习描述 */
-  internshipDesc: string;
-  /** 专业介绍 */
-  introduction: string;
-  /** 是否收藏 */
-  isCollect: boolean;
-  /** 就业方向 */
-  jobDirection: string;
-  /** 就业文本 */
-  jobText: string | null;
-  /** 学制(数字) */
-  learnYear: string;
-  /** 学制(阿拉伯数字文本) */
-  learnYearArab: string;
-  /** 学制(中文文本) */
-  learnYearZh: string;
-  /** 层级 */
+  description: string;
+  hits: string;
+  jobs: {
+    code: string;
+    name: string;
+    hits: string;
+  }[];
   level: number;
-  /** 理科比例 */
-  lkRatio: number;
-  /** 理科比例文本 */
-  lkRatiotext: string;
-  /** 知识与能力 */
-  loreAndAbility: string;
-  /** 主要课程 */
-  mainCourse: string;
-  /** 男性比例 */
-  maleRatio: number;
-  /** 男性比例文本 */
-  maleRatioText: string;
-  /** 专业ID(另一个字段) */
-  marjorId: number;
-  /** 中类名称 */
-  middleName: string;
-  /** 专业名称 */
-  name: string;
-  /** 开设院校数量 */
-  openCollegeCount: number;
-  /** 资格证书 */
-  qualification: string;
-  /** 相关专业 */
-  relationMajors: string;
-  /** 备注 */
-  remark: string | null;
-  /** 薪资 */
-  salary: number | null;
-  /** 学习方向 */
-  studyDirection: string | null;
-  /** 选科要求 */
-  subjectRequirement: string;
-  /** 摘要 */
-  summary: string | null;
-  /** 更新人 */
-  updateBy: string | null;
-  /** 更新时间 */
-  updateTime: string | null;
-  /** 文科比例 */
-  wkRatio: number;
-  /** 文科比例文本 */
-  wkRatioText: string;
-  /** 接续中职专业 */
-  zhongzhiMajors: string;
-  /** 专升本方向 */
-  zhuanToBenOrient: string;
+  levelName: string;
+  levels: {
+    code: string;
+    name: string;
+  }[];
+  postJobs: {
+    name: string;
+    hotCity: string;
+    salaryMax: number;
+    salaryMin: number;
+    hotIndustry: string;
+  }[];
+  postMajors: {
+    code: string;
+    femaleRatio: number;
+    learnYear: string;
+    maleRatio: number;
+    mfRatioView: string;
+    name: string;
+  }[];
+  status: number;
+  summary: string;
+  tags: string[];
+}
+export interface CareerJob extends Entity {
+  code: string;
+  salaryMax: string;
+  salaryMin: string;
+  salaryUnit: string;
+}
+
+export interface CareerJobSalary {
+  city: string;
+  salary: number;
+  sample: number;
+}
+
+export interface CareerJobExperience {
+  year: string;
+  salary: number;
+  sampleCount: number;
+}
+
+export interface CareerJobEdu {
+  edu: string;
+  ratio: number;
+}
+
+export interface CareerJobExp {
+  exp: string;
+  ratio: number;
+}
+export interface CareerJobDetail extends Entity {
+  citySalary: CareerJobSalary[];
+  demand: {
+    city: string;
+    count: number;
+  }[];
+  edu: CareerJobEdu[];
+  exp: CareerJobExp[];
+  experience: CareerJobExperience[];
+  industrySalary: {
+    name: string;
+    salary: number;
+    sampleCount: number;
+  }[];
+  salary: {
+    max: number;
+    min: number;
+    ratio: number;
+  }[];
+  salaryAvg: string;
+  salarySource: string;
+  sampleDesc: string | null;
+  vocationalSource: string;
 }
 
 export interface UniversityQueryDTO {

+ 10 - 0
src/types/index.ts

@@ -129,6 +129,16 @@ export interface SwiperTabItem {
   params?: any;
 }
 
+export interface Entity {
+  id: number;
+  name: string;
+  remark: string | null;
+  createBy: string | null;
+  craeteTime: string | null;
+  updateBy: string | null;
+  updateTime: string | null;
+}
+
 
 
 export { Study, User, News, Transfer, System, Major, Career, Tree, Voluntary };

+ 34 - 9
src/uni_modules/uv-collapse/components/uv-collapse-item/uv-collapse-item.vue

@@ -38,7 +38,7 @@
         :style="{padding}"
 				:id="elId"
 				:ref="elId"
-			><slot /></view>
+			><slot :expanded="expanded" :hasRendered="hasRendered" /></view>
 		</view>
 		<uv-line v-if="border"></uv-line>
 	</view>
@@ -83,6 +83,10 @@
 				showBorder: false,
 				// 是否动画中,如果是则不允许继续触发点击
 				animating: false,
+				// 是否已经初始化过(用于延迟初始化优化)
+				inited: false,
+				// 内容是否已经渲染过(一旦渲染过就保持显示,避免收起时立即消失)
+				hasRendered: false,
 				// 父组件uv-collapse的参数
 				parentData: {
 					accordion: false,
@@ -98,22 +102,30 @@
 				this.timer = setTimeout(() => {
 					this.showBorder = n
 				}, n ? 10 : 290)
+				// 一旦展开过,标记为已渲染,后续即使收起也保持显示
+				if (n) {
+					this.hasRendered = true
+				}
 			}
 		},
 		created() {
 			this.elId = this.$uv.guid();
 		},
 		mounted() {
-			this.init()
+			// this.init()
 		},
 		methods: {
-			// 异步获取内容,或者动态修改了内容时,需要重新初始化
-			init() {
-				// 初始化数据
+			// 初始化父组件数据(不设置状态,不执行动画)
+			initParentData() {
 				this.updateParentData()
 				if (!this.parent) {
 					return this.$uv.error('uv-collapse-item必须要搭配uv-collapse组件使用')
 				}
+			},
+			// 异步获取内容,或者动态修改了内容时,需要重新初始化
+			init() {
+				// 初始化数据
+				this.initParentData()
 				const {
 					value,
 					accordion,
@@ -143,6 +155,10 @@
 			async setContentAnimate() {
 				// 每次面板打开或者收起时,都查询元素尺寸
 				// 好处是,父组件从服务端获取内容后,变更折叠面板后可以获得最新的高度
+				// 展开时需要等待 DOM 更新,确保内容已渲染
+				if (this.expanded) {
+					await new Promise(resolve => this.$nextTick(resolve))
+				}
 				const rect = await this.queryRect()
 				const height = this.expanded ? rect.height : 0
 				this.animating = true
@@ -180,10 +196,19 @@
 				// #endif
 			},
 			// 点击collapsehead头部
-			clickHandler() {
-				if (this.disabled && this.animating) return
-				// 设置本组件为相反的状态
-				this.parent && this.parent.onChange(this)
+			async clickHandler() {
+				if (this.disabled || this.animating) return
+				
+				// 首次点击时,需要先初始化父组件数据
+				if (!this.inited) {
+					this.initParentData()
+					this.inited = true
+				}
+				
+				// 先通知父组件切换状态(onChange 会设置 expanded 并调用 setContentAnimate)
+				if (this.parent) {
+					this.parent.onChange(this)
+				}
 			},
 			// 查询内容高度
 			queryRect() {

+ 3 - 1
src/uni_modules/uv-collapse/components/uv-collapse/uv-collapse.vue

@@ -24,7 +24,9 @@
 		mixins: [mpMixin, mixin, props],
 		watch: {
 			needInit() {
-				this.init()
+				// 延迟初始化,避免在数据加载时立即触发所有子组件的初始化
+				// 子组件现在使用延迟初始化策略,只在点击时才初始化
+				// this.init()
 			},
 			// 当父组件需要子组件需要共享的参数发生了变化,手动通知子组件
 			parentData() {

+ 1 - 1
src/uni_modules/uv-subsection/components/uv-subsection/uv-subsection.vue

@@ -31,7 +31,7 @@
             <view
                 class="uv-subsection__item__text"
                 :style="[textStyle(index)]">
-                <slot v-bind="{item,index,style:textStyle(index)}">
+                <slot :data="{item,index,style:textStyle(index)}">
                     {{ getText(item) }}
                 </slot>
             </view>

+ 3 - 1
src/uni_modules/uv-tabs/components/uv-tabs/uv-tabs.vue

@@ -289,7 +289,9 @@
 					})
 					// 获取了tabs的尺寸之后,设置滑块的位置
 					this.setLineLeft()
-					this.setScrollLeft()
+					if(this.innerCurrent !== 0 || this.innerCurrent === 0 && !this.firstTime) {
+						this.setScrollLeft()
+					}
 				})
 			},
 			// 获取导航菜单的尺寸

Некоторые файлы не были показаны из-за большого количества измененных файлов