feat: 完善访谈与vlog模块页面,重组设备与图片公共资源,新增音频管理与一班对接工具
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No files matched your search
@@ -15,6 +15,7 @@ dist-ssr
|
||||
# 忽略所有 .mp4 文件
|
||||
*.mp4
|
||||
public/video/*
|
||||
public/audio/*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "lf",
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"vueIndentScriptAndStyle": false
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "lf",
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"endOfLine": "auto",
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
@@ -2,11 +2,37 @@ import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// 直接使用 tsconfig 的绝对路径,避免中文路径在 URL/路径互转时被编码导致读取失败
|
||||
const tsconfigEslintPath = fileURLToPath(new URL('./tsconfig.eslint.json', import.meta.url))
|
||||
|
||||
export default defineConfig([
|
||||
{ files: ['**/*.{js,mjs,cjs,ts,mts,cts,vue}'], languageOptions: { globals: globals.browser } },
|
||||
// 全局忽略:自身配置文件、产物、第三方静态资源等不参与 lint
|
||||
{
|
||||
ignores: [
|
||||
'eslint.config.ts',
|
||||
'eslint.config.*',
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'public/**',
|
||||
'vite.config.*',
|
||||
'postcss.config.*',
|
||||
'tailwind.config.*'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,mts,cts,tsx,vue}'],
|
||||
languageOptions: { globals: globals.browser }
|
||||
},
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
// 让 typescript-eslint 使用专用 tsconfig(显式 include .vue)
|
||||
project: [tsconfigEslintPath]
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
@@ -14,7 +40,19 @@ export default defineConfig([
|
||||
pluginVue.configs['flat/essential'],
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
languageOptions: { parserOptions: { parser: tseslint.parser } },
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: {
|
||||
ts: tseslint.parser,
|
||||
tsx: tseslint.parser
|
||||
},
|
||||
project: [tsconfigEslintPath],
|
||||
extraFileExtensions: ['.vue'],
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-undef-components': [
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"
|
||||
/>
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<script type="text/javascript" src="./videoajax/jquery-3.7.1.min.js"></script>
|
||||
<title>短视频达人</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script src="./videoajax/videoajax.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,9 +27,10 @@
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@types/node": "^24.3.3",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.8.28",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"baseline-browser-mapping": "^2.10.23",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */
|
||||
!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0<t&&t-1 in e)}function fe(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}ce.fn=ce.prototype={jquery:t,constructor:ce,length:0,toArray:function(){return ae.call(this)},get:function(e){return null==e?ae.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=ce.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return ce.each(this,e)},map:function(n){return this.pushStack(ce.map(this,function(e,t){return n.call(e,t,e)}))},slice:function(){return this.pushStack(ae.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(ce.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(ce.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(0<=n&&n<t?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:s,sort:oe.sort,splice:oe.splice},ce.extend=ce.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||v(a)||(a={}),s===u&&(a=this,s--);s<u;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(ce.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||ce.isPlainObject(n)?n:{},i=!1,a[t]=ce.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},ce.extend({expando:"jQuery"+(t+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==i.call(e))&&(!(t=r(e))||"function"==typeof(n=ue.call(t,"constructor")&&t.constructor)&&o.call(n)===a)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t,n){m(e,{nonce:t&&t.nonce},n)},each:function(e,t){var n,r=0;if(c(e)){for(n=e.length;r<n;r++)if(!1===t.call(e[r],r,e[r]))break}else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},text:function(e){var t,n="",r=0,i=e.nodeType;if(!i)while(t=e[r++])n+=ce.text(t);return 1===i||11===i?e.textContent:9===i?e.documentElement.textContent:3===i||4===i?e.nodeValue:n},makeArray:function(e,t){var n=t||[];return null!=e&&(c(Object(e))?ce.merge(n,"string"==typeof e?[e]:e):s.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:se.call(t,e,n)},isXMLDoc:function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!l.test(t||n&&n.nodeName||"HTML")},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;r<n;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;i<o;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,o=0,a=[];if(c(e))for(r=e.length;o<r;o++)null!=(i=t(e[o],o,n))&&a.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&a.push(i);return g(a)},guid:1,support:le}),"function"==typeof Symbol&&(ce.fn[Symbol.iterator]=oe[Symbol.iterator]),ce.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){n["[object "+t+"]"]=t.toLowerCase()});var pe=oe.pop,de=oe.sort,he=oe.splice,ge="[\\x20\\t\\r\\n\\f]",ve=new RegExp("^"+ge+"+|((?:^|[^\\\\])(?:\\\\.)*)"+ge+"+$","g");ce.contains=function(e,t){var n=t&&t.parentNode;return e===n||!(!n||1!==n.nodeType||!(e.contains?e.contains(n):e.compareDocumentPosition&&16&e.compareDocumentPosition(n)))};var f=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\x80-\uFFFF\w-]/g;function p(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e}ce.escapeSelector=function(e){return(e+"").replace(f,p)};var ye=C,me=s;!function(){var e,b,w,o,a,T,r,C,d,i,k=me,S=ce.expando,E=0,n=0,s=W(),c=W(),u=W(),h=W(),l=function(e,t){return e===t&&(a=!0),0},f="checked|selected|async|autofocus|autoplay|conLine truncated
|
||||
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* $Id: base64.js,v 2.15 2014/04/05 12:58:57 dankogai Exp dankogai $
|
||||
*
|
||||
* Licensed under the MIT license.
|
||||
* http://opensource.org/licenses/mit-license
|
||||
*
|
||||
* References:
|
||||
* http://en.wikipedia.org/wiki/Base64
|
||||
*/
|
||||
|
||||
(function(global) {
|
||||
'use strict';
|
||||
// existing version for noConflict()
|
||||
var _Base64 = global.Base64;
|
||||
var version = "2.1.5";
|
||||
// if node.js, we use Buffer
|
||||
var buffer;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buffer = require('buffer').Buffer;
|
||||
}
|
||||
// constants
|
||||
var b64chars
|
||||
= 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
var b64tab = function(bin) {
|
||||
var t = {};
|
||||
for (var i = 0, l = bin.length; i < l; i++) t[bin.charAt(i)] = i;
|
||||
return t;
|
||||
}(b64chars);
|
||||
var fromCharCode = String.fromCharCode;
|
||||
// encoder stuff
|
||||
var cb_utob = function(c) {
|
||||
if (c.length < 2) {
|
||||
var cc = c.charCodeAt(0);
|
||||
return cc < 0x80 ? c
|
||||
: cc < 0x800 ? (fromCharCode(0xc0 | (cc >>> 6))
|
||||
+ fromCharCode(0x80 | (cc & 0x3f)))
|
||||
: (fromCharCode(0xe0 | ((cc >>> 12) & 0x0f))
|
||||
+ fromCharCode(0x80 | ((cc >>> 6) & 0x3f))
|
||||
+ fromCharCode(0x80 | ( cc & 0x3f)));
|
||||
} else {
|
||||
var cc = 0x10000
|
||||
+ (c.charCodeAt(0) - 0xD800) * 0x400
|
||||
+ (c.charCodeAt(1) - 0xDC00);
|
||||
return (fromCharCode(0xf0 | ((cc >>> 18) & 0x07))
|
||||
+ fromCharCode(0x80 | ((cc >>> 12) & 0x3f))
|
||||
+ fromCharCode(0x80 | ((cc >>> 6) & 0x3f))
|
||||
+ fromCharCode(0x80 | ( cc & 0x3f)));
|
||||
}
|
||||
};
|
||||
var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g;
|
||||
var utob = function(u) {
|
||||
return u.replace(re_utob, cb_utob);
|
||||
};
|
||||
var cb_encode = function(ccc) {
|
||||
var padlen = [0, 2, 1][ccc.length % 3],
|
||||
ord = ccc.charCodeAt(0) << 16
|
||||
| ((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8)
|
||||
| ((ccc.length > 2 ? ccc.charCodeAt(2) : 0)),
|
||||
chars = [
|
||||
b64chars.charAt( ord >>> 18),
|
||||
b64chars.charAt((ord >>> 12) & 63),
|
||||
padlen >= 2 ? '=' : b64chars.charAt((ord >>> 6) & 63),
|
||||
padlen >= 1 ? '=' : b64chars.charAt(ord & 63)
|
||||
];
|
||||
return chars.join('');
|
||||
};
|
||||
var btoa = global.btoa ? function(b) {
|
||||
return global.btoa(b);
|
||||
} : function(b) {
|
||||
return b.replace(/[\s\S]{1,3}/g, cb_encode);
|
||||
};
|
||||
var _encode = buffer
|
||||
? function (u) { return (new buffer(u)).toString('base64') }
|
||||
: function (u) { return btoa(utob(u)) }
|
||||
;
|
||||
var encode = function(u, urisafe) {
|
||||
return !urisafe
|
||||
? _encode(u)
|
||||
: _encode(u).replace(/[+\/]/g, function(m0) {
|
||||
return m0 == '+' ? '-' : '_';
|
||||
}).replace(/=/g, '');
|
||||
};
|
||||
var encodeURI = function(u) { return encode(u, true) };
|
||||
// decoder stuff
|
||||
var re_btou = new RegExp([
|
||||
'[\xC0-\xDF][\x80-\xBF]',
|
||||
'[\xE0-\xEF][\x80-\xBF]{2}',
|
||||
'[\xF0-\xF7][\x80-\xBF]{3}'
|
||||
].join('|'), 'g');
|
||||
var cb_btou = function(cccc) {
|
||||
switch(cccc.length) {
|
||||
case 4:
|
||||
var cp = ((0x07 & cccc.charCodeAt(0)) << 18)
|
||||
| ((0x3f & cccc.charCodeAt(1)) << 12)
|
||||
| ((0x3f & cccc.charCodeAt(2)) << 6)
|
||||
| (0x3f & cccc.charCodeAt(3)),
|
||||
offset = cp - 0x10000;
|
||||
return (fromCharCode((offset >>> 10) + 0xD800)
|
||||
+ fromCharCode((offset & 0x3FF) + 0xDC00));
|
||||
case 3:
|
||||
return fromCharCode(
|
||||
((0x0f & cccc.charCodeAt(0)) << 12)
|
||||
| ((0x3f & cccc.charCodeAt(1)) << 6)
|
||||
| (0x3f & cccc.charCodeAt(2))
|
||||
);
|
||||
default:
|
||||
return fromCharCode(
|
||||
((0x1f & cccc.charCodeAt(0)) << 6)
|
||||
| (0x3f & cccc.charCodeAt(1))
|
||||
);
|
||||
}
|
||||
};
|
||||
var btou = function(b) {
|
||||
return b.replace(re_btou, cb_btou);
|
||||
};
|
||||
var cb_decode = function(cccc) {
|
||||
var len = cccc.length,
|
||||
padlen = len % 4,
|
||||
n = (len > 0 ? b64tab[cccc.charAt(0)] << 18 : 0)
|
||||
| (len > 1 ? b64tab[cccc.charAt(1)] << 12 : 0)
|
||||
| (len > 2 ? b64tab[cccc.charAt(2)] << 6 : 0)
|
||||
| (len > 3 ? b64tab[cccc.charAt(3)] : 0),
|
||||
chars = [
|
||||
fromCharCode( n >>> 16),
|
||||
fromCharCode((n >>> 8) & 0xff),
|
||||
fromCharCode( n & 0xff)
|
||||
];
|
||||
chars.length -= [0, 0, 2, 1][padlen];
|
||||
return chars.join('');
|
||||
};
|
||||
var atob = global.atob ? function(a) {
|
||||
return global.atob(a);
|
||||
} : function(a){
|
||||
return a.replace(/[\s\S]{1,4}/g, cb_decode);
|
||||
};
|
||||
var _decode = buffer
|
||||
? function(a) { return (new buffer(a, 'base64')).toString() }
|
||||
: function(a) { return btou(atob(a)) };
|
||||
var decode = function(a){
|
||||
return _decode(
|
||||
a.replace(/[-_]/g, function(m0) { return m0 == '-' ? '+' : '/' })
|
||||
.replace(/[^A-Za-z0-9\+\/]/g, '')
|
||||
);
|
||||
};
|
||||
var noConflict = function() {
|
||||
var Base64 = global.Base64;
|
||||
global.Base64 = _Base64;
|
||||
return Base64;
|
||||
};
|
||||
// export Base64
|
||||
global.Base64 = {
|
||||
VERSION: version,
|
||||
atob: atob,
|
||||
btoa: btoa,
|
||||
fromBase64: decode,
|
||||
toBase64: encode,
|
||||
utob: utob,
|
||||
encode: encode,
|
||||
encodeURI: encodeURI,
|
||||
btou: btou,
|
||||
decode: decode,
|
||||
noConflict: noConflict
|
||||
};
|
||||
// if ES5 is available, make Base64.extendString() available
|
||||
if (typeof Object.defineProperty === 'function') {
|
||||
var noEnum = function(v){
|
||||
return {value:v,enumerable:false,writable:true,configurable:true};
|
||||
};
|
||||
global.Base64.extendString = function () {
|
||||
Object.defineProperty(
|
||||
String.prototype, 'fromBase64', noEnum(function () {
|
||||
return decode(this)
|
||||
}));
|
||||
Object.defineProperty(
|
||||
String.prototype, 'toBase64', noEnum(function (urisafe) {
|
||||
return encode(this, urisafe)
|
||||
}));
|
||||
Object.defineProperty(
|
||||
String.prototype, 'toBase64URI', noEnum(function () {
|
||||
return encode(this, true)
|
||||
}));
|
||||
};
|
||||
}
|
||||
// that's it!
|
||||
})(this);
|
||||
|
||||
if (this['Meteor']) {
|
||||
Base64 = global.Base64; // for normal export in Meteor.js
|
||||
}
|
||||
|
||||
; (function (root) {
|
||||
// body...
|
||||
|
||||
var videoajax = function (argument) {
|
||||
// body...
|
||||
var host = '/';
|
||||
|
||||
this._setHost=function(hoststr) {
|
||||
host = hoststr;
|
||||
};
|
||||
this._getHost = function(){
|
||||
return host;
|
||||
}
|
||||
};
|
||||
|
||||
$.extend(videoajax.prototype,{
|
||||
/**
|
||||
* 设置主域名
|
||||
* @param hosts {String} default: '/' 主域名字符串
|
||||
*/
|
||||
setHost :function(hosts){
|
||||
this._setHost(hosts);
|
||||
},
|
||||
//user_id,course_id,lesson_id,break_point
|
||||
/**
|
||||
* 播放退出时间点
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* user_id {Number} 用户ID
|
||||
* course_id {Number} 课程ID
|
||||
* lesson_id {Number} 课时ID
|
||||
* break_point {Number} 断点
|
||||
*@param callback {Function} 回调方法
|
||||
*/
|
||||
quitBreakPoint:function(data,callback){
|
||||
var paramfiled = ["user_id","course_id","lesson_id","break_point"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addBreakPoint',{
|
||||
userViewPoint:dataStr
|
||||
},function(data){
|
||||
isFunction(callback) ? callback(data.code == 200,data) : '';
|
||||
}
|
||||
);
|
||||
},
|
||||
/**
|
||||
* user_id:123,course_id:23,lesson_id:3
|
||||
*播放历史记录
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* user_id {Number} 用户ID
|
||||
* course_id {Number} 课程ID
|
||||
* lesson_id {Number} 课时ID
|
||||
*@param callback {Function} 回调方法
|
||||
*/
|
||||
getBreakPoint:function(data,callback){
|
||||
var paramfiled = ["user_id","course_id","lesson_id"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=userbreakpoint',{
|
||||
userCourseInfo:dataStr
|
||||
},function(data){
|
||||
isFunction(callback) ? callback(data.code == 200,data) : '';
|
||||
// isFunction(callback) ? callback(data) : '';
|
||||
}
|
||||
);
|
||||
},
|
||||
/**
|
||||
*用户答题答案
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* user_id {Number} 用户ID
|
||||
* course_id {Number} 课程ID
|
||||
* lesson_id {Number} 课时ID
|
||||
* question_order {Number} 问题序号
|
||||
* answer {String} 问题
|
||||
*@param callback {Function} 回调方法
|
||||
*/
|
||||
getUserAnswer:function(data,callback){
|
||||
var paramfiled = ["user_id","lesson_id","course_id","question_order","answer"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addUserAnswer',{
|
||||
userAnswerInfo:dataStr
|
||||
},function(data){
|
||||
isFunction(callback) ? callback(data.code == 200,data) : '';
|
||||
}
|
||||
);
|
||||
|
||||
},
|
||||
/**
|
||||
*播放结束
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* order {Number} 排序ID
|
||||
* co {Number} 课时ID
|
||||
*@param callback {Function} 回调方法
|
||||
*/
|
||||
finishCourse:function(data,callback){
|
||||
var paramfiled = ["order","co","process"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=user&c=learnrecord&a=playcount',data,function(data){
|
||||
isFunction(callback) ? callback(data.code == 200,data) : '';
|
||||
// isFunction(callback) ? callback(data) : '';
|
||||
}
|
||||
);
|
||||
},
|
||||
/**
|
||||
*课程进度获取
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* order {Number} 排序ID
|
||||
* co {Number} 课时ID
|
||||
*@param callback {Function} 回调方法
|
||||
* 注回调使用的参数,值类似为: {"status":true,"data":{"process":"91"}}
|
||||
* 或: {"status":false,"data":'参数丢失'}
|
||||
* 其中status为true为调用正常, 如果有值则data返回json形式的进度, 没有值则返回[]
|
||||
* status为false时, data保存的是错误提示
|
||||
*/
|
||||
getfinishCourse:function(data,callback){
|
||||
var paramfiled = ["order","co"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=user&c=learnrecord&a=getuserlessionprocess',data,function(data){
|
||||
isFunction(callback) ? callback(data) : '';
|
||||
// isFunction(callback) ? callback(data) : '';
|
||||
}
|
||||
);
|
||||
},
|
||||
/**
|
||||
*解码加密的breakpoint
|
||||
* @pparam basecode {String} 要解码的字符串
|
||||
*/
|
||||
fromBaseCode : function(basecode){
|
||||
return getBreakPoint(basecode);
|
||||
},
|
||||
/**
|
||||
*通过key值和课时值获取自定的数据
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* lessonId {Number} 课时ID
|
||||
* order {Number} 排序ID
|
||||
*@param callback {Function} 回调方法
|
||||
*/
|
||||
getUserDefineInfo :function(data,callback){
|
||||
var paramfiled = ["lessonId","order"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=getUserDefineInfo',{
|
||||
defineDate:dataStr
|
||||
},function(data){
|
||||
isFunction(callback) ? callback(data) : '';
|
||||
});
|
||||
},
|
||||
/**
|
||||
*通过key值和课时值及用户ID获取自定的数据
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* lessonId {Number} 课时ID
|
||||
* order {Number} 排序ID
|
||||
*@param callback {Function} 回调方法
|
||||
*/
|
||||
getUserDefineInfoByUser :function(data,callback){
|
||||
var paramfiled = ["lessonId","order"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=getUserDefineInfoByUser',{
|
||||
defineUserDate:dataStr
|
||||
},function(data){
|
||||
isFunction(callback) ? callback(data) : '';
|
||||
});
|
||||
},
|
||||
/**
|
||||
*增加自定数据
|
||||
* @param data {dataObject} 向后台传递的数据对象,应该包含
|
||||
* lessonId {Number} 课时ID
|
||||
* key {string} 键值ID
|
||||
* content {string} 字内容
|
||||
*@param callback {Function} 回调方法
|
||||
*/
|
||||
addUserDefine:function(data,callback){
|
||||
var paramfiled = ["lessonId","key","content"];
|
||||
var dataStr = verfiyData(paramfiled,data);
|
||||
getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addUserDefine',{
|
||||
addDefineDate:dataStr
|
||||
},function(data){
|
||||
isFunction(callback) ? callback(data) : '';
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
var getDate = function(url,data,callback){
|
||||
$.get(url,data,function(data){
|
||||
callback(data);
|
||||
},'json')
|
||||
};
|
||||
|
||||
var isFunction = function(fn){
|
||||
if(typeof fn === 'function')return true;
|
||||
return false;
|
||||
}
|
||||
var verfiyData = function(field,data){
|
||||
var arr=[];
|
||||
console.log(field);
|
||||
console.log('<br/>');
|
||||
console.log(data);
|
||||
for(var i in field){
|
||||
|
||||
if(!data[field[i]]){
|
||||
throw new Error('data is error');
|
||||
}
|
||||
arr.push(field[i]+':'+data[field[i]]);
|
||||
}
|
||||
if(root.JSON){
|
||||
return JSON.stringify(data);
|
||||
}else{
|
||||
return '{'+arr.join(',')+'}';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var getBreakPoint = function (base64point) {
|
||||
var newbc = base64point.split('').reverse().join('');
|
||||
return Base64.fromBase64(newbc);
|
||||
}
|
||||
|
||||
root.videoAjax = function(){
|
||||
return new videoajax();
|
||||
}
|
||||
}(this));
|
||||
@@ -0,0 +1 @@
|
||||
(function(global){'use strict';var _Base64=global.Base64;var version="2.1.5";var buffer;if(typeof module!=='undefined'&&module.exports){buffer=require('buffer').Buffer}var b64chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';var b64tab=function(bin){var t={};for(var i=0,l=bin.length;i<l;i++)t[bin.charAt(i)]=i;return t}(b64chars);var fromCharCode=String.fromCharCode;var cb_utob=function(c){if(c.length<2){var cc=c.charCodeAt(0);return cc<0x80?c:cc<0x800?(fromCharCode(0xc0|(cc>>>6))+fromCharCode(0x80|(cc&0x3f))):(fromCharCode(0xe0|((cc>>>12)&0x0f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}else{var cc=0x10000+(c.charCodeAt(0)-0xD800)*0x400+(c.charCodeAt(1)-0xDC00);return(fromCharCode(0xf0|((cc>>>18)&0x07))+fromCharCode(0x80|((cc>>>12)&0x3f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}};var re_utob=/[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g;var utob=function(u){return u.replace(re_utob,cb_utob)};var cb_encode=function(ccc){var padlen=[0,2,1][ccc.length%3],ord=ccc.charCodeAt(0)<<16|((ccc.length>1?ccc.charCodeAt(1):0)<<8)|((ccc.length>2?ccc.charCodeAt(2):0)),chars=[b64chars.charAt(ord>>>18),b64chars.charAt((ord>>>12)&63),padlen>=2?'=':b64chars.charAt((ord>>>6)&63),padlen>=1?'=':b64chars.charAt(ord&63)];return chars.join('')};var btoa=global.btoa?function(b){return global.btoa(b)}:function(b){return b.replace(/[\s\S]{1,3}/g,cb_encode)};var _encode=buffer?function(u){return(new buffer(u)).toString('base64')}:function(u){return btoa(utob(u))};var encode=function(u,urisafe){return!urisafe?_encode(u):_encode(u).replace(/[+\/]/g,function(m0){return m0=='+'?'-':'_'}).replace(/=/g,'')};var encodeURI=function(u){return encode(u,true)};var re_btou=new RegExp(['[\xC0-\xDF][\x80-\xBF]','[\xE0-\xEF][\x80-\xBF]{2}','[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),'g');var cb_btou=function(cccc){switch(cccc.length){case 4:var cp=((0x07&cccc.charCodeAt(0))<<18)|((0x3f&cccc.charCodeAt(1))<<12)|((0x3f&cccc.charCodeAt(2))<<6)|(0x3f&cccc.charCodeAt(3)),offset=cp-0x10000;return(fromCharCode((offset>>>10)+0xD800)+fromCharCode((offset&0x3FF)+0xDC00));case 3:return fromCharCode(((0x0f&cccc.charCodeAt(0))<<12)|((0x3f&cccc.charCodeAt(1))<<6)|(0x3f&cccc.charCodeAt(2)));default:return fromCharCode(((0x1f&cccc.charCodeAt(0))<<6)|(0x3f&cccc.charCodeAt(1)))}};var btou=function(b){return b.replace(re_btou,cb_btou)};var cb_decode=function(cccc){var len=cccc.length,padlen=len%4,n=(len>0?b64tab[cccc.charAt(0)]<<18:0)|(len>1?b64tab[cccc.charAt(1)]<<12:0)|(len>2?b64tab[cccc.charAt(2)]<<6:0)|(len>3?b64tab[cccc.charAt(3)]:0),chars=[fromCharCode(n>>>16),fromCharCode((n>>>8)&0xff),fromCharCode(n&0xff)];chars.length-=[0,0,2,1][padlen];return chars.join('')};var atob=global.atob?function(a){return global.atob(a)}:function(a){return a.replace(/[\s\S]{1,4}/g,cb_decode)};var _decode=buffer?function(a){return(new buffer(a,'base64')).toString()}:function(a){return btou(atob(a))};var decode=function(a){return _decode(a.replace(/[-_]/g,function(m0){return m0=='-'?'+':'/'}).replace(/[^A-Za-z0-9\+\/]/g,''))};var noConflict=function(){var Base64=global.Base64;global.Base64=_Base64;return Base64};global.Base64={VERSION:version,atob:atob,btoa:btoa,fromBase64:decode,toBase64:encode,utob:utob,encode:encode,encodeURI:encodeURI,btou:btou,decode:decode,noConflict:noConflict};if(typeof Object.defineProperty==='function'){var noEnum=function(v){return{value:v,enumerable:false,writable:true,configurable:true}};global.Base64.extendString=function(){Object.defineProperty(String.prototype,'fromBase64',noEnum(function(){return decode(this)}));Object.defineProperty(String.prototype,'toBase64',noEnum(function(urisafe){return encode(this,urisafe)}));Object.defineProperty(String.prototype,'toBase64URI',noEnum(function(){return encode(this,true)}))}}})(this);if(this['Meteor']){Base64=global.Base64};(function(root){var videoajax=function(argument){var host='/';this._setHost=function(hoststr){host=hoststr};this._getHost=function(){return host}};$.extend(videoajax.prototype,{setHost:function(hosts){this._setHost(hosts)},quitBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id","break_point"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addBreakPoint',{userViewPoint:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=userbreakpoint',{userCourseInfo:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getUserAnswer:function(data,callback){var paramfiled=["user_id","lesson_id","course_id","question_order","answer"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addUserAnswer',{userAnswerInfo:dataStr},function(data){isFunction(callback)?callbacLine truncated
|
||||
@@ -12,10 +12,42 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScale } from '@/composables/useScale'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
useUserStore,
|
||||
useVlogStore,
|
||||
useFilmStore,
|
||||
useInterviewStore,
|
||||
useBasicElementsStore
|
||||
} from '@/stores'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
// 使用等比例缩放hook
|
||||
const { scaleStyle } = useScale()
|
||||
const userStore = useUserStore()
|
||||
const vlogStore = useVlogStore()
|
||||
const filmStore = useFilmStore()
|
||||
const interviewStore = useInterviewStore()
|
||||
const basicElementsStore = useBasicElementsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const courseId = params.get('courseid')
|
||||
const uid = params.get('uid')
|
||||
|
||||
if (courseId || uid) {
|
||||
userStore.setCourseInfo({
|
||||
courseId: courseId ?? null,
|
||||
uid: uid ?? null
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
vlogStore.loadVlogStore(),
|
||||
filmStore.loadFilmStore(),
|
||||
interviewStore.loadInterviewStore(),
|
||||
basicElementsStore.loadBasicElementsStore()
|
||||
])
|
||||
})
|
||||
|
||||
// 容器样式
|
||||
const containerStyle = {
|
||||
@@ -25,9 +57,6 @@ const containerStyle = {
|
||||
position: 'relative' as const,
|
||||
background: '#000'
|
||||
}
|
||||
|
||||
// 只缓存技能树页
|
||||
const cachedNames = computed(() => ['SkillPage', 'Work'])
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 683 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 917 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 6.8 MiB |
@@ -36,11 +36,16 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (): void => {
|
||||
if (props.disabled) return
|
||||
if (props.path) {
|
||||
router.push(props.path)
|
||||
}
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
@@ -2,18 +2,18 @@
|
||||
<teleport to="body">
|
||||
<div class="guide-steps-container" v-if="show">
|
||||
<SpotlightOverlay
|
||||
:visible="Boolean(highlightSelector)"
|
||||
:target-selector="highlightSelector"
|
||||
:padding="highlightPadding"
|
||||
:border-radius="highlightRadius"
|
||||
:highlight-mode="highlightMode"
|
||||
:visible="Boolean(currentHighlightSelector)"
|
||||
:target-selector="currentHighlightSelector"
|
||||
:padding="currentHighlightPadding"
|
||||
:border-radius="currentHighlightRadius"
|
||||
:highlight-mode="currentHighlightMode"
|
||||
/>
|
||||
<div class="guide-align" :style="alignStyle">
|
||||
<GuideDragon
|
||||
:show-skip="currentStep < totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
:position-offset-y="props.positionOffsetY"
|
||||
:position-offset-y="currentPositionOffsetY"
|
||||
@next="handleNext"
|
||||
@skip="handleSkip"
|
||||
@complete="handleComplete"
|
||||
@@ -23,6 +23,7 @@
|
||||
<div v-if="useSlotMode">
|
||||
<component
|
||||
:is="currentStepComponent"
|
||||
:key="currentStepIndex"
|
||||
@continue="handleNext"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
@@ -32,9 +33,14 @@
|
||||
<!-- 兼容原有的配置形式:也包裹 GuideStepsItem,获得继续按钮 -->
|
||||
<GuideStepsItem
|
||||
v-else
|
||||
:key="currentStepIndex"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
:audio-src="currentStepData?.audioSrc"
|
||||
:audio-mode="currentStepData?.audioMode"
|
||||
:audio-channel-id="currentStepData?.audioChannelId"
|
||||
:audio-autoplay="currentStepData?.audioAutoplay"
|
||||
@continue="handleNext"
|
||||
>
|
||||
<!-- 支持组件形式的 content -->
|
||||
@@ -51,18 +57,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useSlots, onMounted, onUnmounted, reactive } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import type { GuideStep } from './types'
|
||||
import SpotlightOverlay from './SpotlightOverlay.vue'
|
||||
import GuideDragon from './GuideDragon.vue'
|
||||
import GuideStepsItem from './GuideStepsItem.vue'
|
||||
|
||||
interface GuideStep {
|
||||
id: string
|
||||
title?: string
|
||||
content: string | Component
|
||||
nextText?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 引导步骤配置 - 当使用插槽模式时可以为空 */
|
||||
steps?: GuideStep[]
|
||||
@@ -129,14 +128,11 @@ const alignStyle = reactive<{
|
||||
width: '1920px',
|
||||
height: '1080px'
|
||||
})
|
||||
// 验证并清理选择器,空字符串或只包含空格的选择器视为无效
|
||||
const highlightSelector = computed(() => {
|
||||
if (!props.highlightSelector) return null
|
||||
const trimmed = props.highlightSelector.trim()
|
||||
const normalizeSelector = (selector?: string | null) => {
|
||||
if (!selector) return null
|
||||
const trimmed = selector.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
})
|
||||
const highlightPadding = computed(() => props.highlightPadding || 0)
|
||||
const highlightRadius = computed(() => props.highlightRadius || 0)
|
||||
}
|
||||
|
||||
const useSlotMode = computed(() => {
|
||||
return slots.default && slots.default().length > 0
|
||||
@@ -161,6 +157,23 @@ const currentStepData = computed(() => {
|
||||
return props.steps[currentStepIndex.value] || null
|
||||
})
|
||||
|
||||
const currentStepConfig = computed(() => props.steps[currentStepIndex.value] || null)
|
||||
const currentHighlightSelector = computed(() => {
|
||||
return normalizeSelector(currentStepConfig.value?.highlightSelector ?? props.highlightSelector)
|
||||
})
|
||||
const currentHighlightPadding = computed(
|
||||
() => currentStepConfig.value?.highlightPadding ?? props.highlightPadding
|
||||
)
|
||||
const currentHighlightRadius = computed(
|
||||
() => currentStepConfig.value?.highlightRadius ?? props.highlightRadius
|
||||
)
|
||||
const currentHighlightMode = computed(
|
||||
() => currentStepConfig.value?.highlightMode ?? props.highlightMode
|
||||
)
|
||||
const currentPositionOffsetY = computed(
|
||||
() => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY
|
||||
)
|
||||
|
||||
const currentStepComponent = computed(() => {
|
||||
if (!useSlotMode.value) return null
|
||||
return slotSteps.value[currentStepIndex.value] || null
|
||||
|
||||
@@ -5,20 +5,53 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
import { useAudioManager } from '@/composables/useAudioManager'
|
||||
import { IMAGE_PATHS } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
// 步骤项容器组件,通过插槽传递内容
|
||||
|
||||
type AudioMode = 'global' | 'channel'
|
||||
|
||||
interface Props {
|
||||
isLastStep?: boolean
|
||||
currentStep?: number
|
||||
totalSteps?: number
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLastStep: false,
|
||||
currentStep: 1,
|
||||
totalSteps: 1
|
||||
totalSteps: 1,
|
||||
audioSrc: undefined,
|
||||
audioMode: 'global',
|
||||
audioChannelId: undefined,
|
||||
audioAutoplay: true
|
||||
})
|
||||
|
||||
const { play, stop } = useAudioManager({
|
||||
mode: computed(() => props.audioMode),
|
||||
channelId: computed(() => props.audioChannelId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.audioSrc, props.audioMode, props.audioChannelId, props.audioAutoplay] as const,
|
||||
([audioSrc]) => {
|
||||
stop()
|
||||
|
||||
if (props.audioAutoplay && audioSrc) {
|
||||
play(audioSrc)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
<template>
|
||||
<Transition name="step-navigation" appear>
|
||||
<!-- 抽屉遮罩层 -->
|
||||
<div v-if="visible" class="drawer-overlay" @click="handleClose">
|
||||
<div v-if="visible" class="fixed inset-0 z-50 bg-black/30 backdrop-blur-md">
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-[10]"
|
||||
image="components/close"
|
||||
@click="handleClose"
|
||||
/>
|
||||
|
||||
<!-- 抽屉内容 -->
|
||||
<div
|
||||
class="drawer-container"
|
||||
:class="{
|
||||
'drawer-container--visible': visible && !isClosing,
|
||||
'drawer-container--closing': isClosing
|
||||
}"
|
||||
@click.stop
|
||||
class="step-navigation-drawer relative flex h-full w-full justify-end"
|
||||
@click="handleClose"
|
||||
>
|
||||
<!-- 右侧步骤面板 -->
|
||||
<div class="steps-panel">
|
||||
<!-- 关闭按钮 -->
|
||||
<!-- <button class="close-button" @click="handleClose">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12 4L4 12M4 4L12 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button> -->
|
||||
|
||||
<!-- 顶部导航箭头 -->
|
||||
<div class="panel-header">
|
||||
<button class="nav-arrow nav-arrow-up" @click="scrollUp" :disabled="!canScrollUp">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 9L3 6L6 3"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<div
|
||||
class="relative flex h-full w-[648px] flex-col py-[100px]"
|
||||
:class="'steps-panel-bg'"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 顶部分页箭头(上一页) -->
|
||||
<div class="relative z-10 flex items-center justify-center pointer-events-none">
|
||||
<button
|
||||
class="pointer-events-auto flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
|
||||
@click="goPrevPage"
|
||||
:disabled="!canPrevPage"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M5 30L35 30L20 6Z" fill="white" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤列表 -->
|
||||
<div class="steps-list" ref="stepsListRef">
|
||||
<!-- 步骤列表(分页,每页最多5条) -->
|
||||
<div class="scrollbar-hide z-10 h-[800px] flex-1 px-6 py-4">
|
||||
<div class="h-auto space-y-4">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
v-for="(step, index) in pagedSteps"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
:class="{ 'step-item--active': index === currentStepIndex }"
|
||||
class="step-item-bg relative flex h-[122px] w-[831px] cursor-pointer items-center px-8 py-4 transition-all duration-300 hover:-translate-x-[23%]"
|
||||
:class="{
|
||||
'step-item-active-bg h-[230px] w-[1034px] pl-[180px] -translate-x-[23%]':
|
||||
currentPageStartIndex + index === currentStepIndex
|
||||
}"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<!-- 播放图标(仅当前步骤显示) -->
|
||||
<!-- <div v-if="index === currentStepIndex" class="play-icon">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M1.5 0.5L6.5 4L1.5 7.5V0.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div> -->
|
||||
|
||||
<!-- 步骤标签 -->
|
||||
<div class="step-label" :class="getTagColor(step.label)">
|
||||
<div
|
||||
class="mr-4 min-w-[5rem] rounded-full px-16 py-4 text-center text-2xl font-semibold shadow-md"
|
||||
:class="getTagColor(step.label)"
|
||||
>
|
||||
{{ step.label }}
|
||||
</div>
|
||||
|
||||
<!-- 步骤内容 -->
|
||||
<div class="step-content">
|
||||
<div class="mr-4 flex-1 text-3xl font-bold leading-[1.5]">
|
||||
{{ step.content }}
|
||||
</div>
|
||||
|
||||
<!-- 步骤数字 -->
|
||||
<div class="step-number" :class="{ 'step-number--active': index === currentStepIndex }">
|
||||
{{ index + 1 }}
|
||||
<!-- 步骤数字(全局序号) -->
|
||||
<div
|
||||
class="absolute right-[15%] z-[1] flex items-center justify-center text-6xl font-semibold text-white/30 transition-all duration-300"
|
||||
:class="{
|
||||
'mb-20 text-[150px] text-white drop-shadow-[5px_10px_5px_rgba(0,0,0,0.1)]':
|
||||
currentPageStartIndex + index === currentStepIndex
|
||||
}"
|
||||
>
|
||||
{{ currentPageStartIndex + index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<div class="panel-footer">
|
||||
<button class="nav-arrow nav-arrow-down" @click="scrollDown" :disabled="!canScrollDown">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 3L9 6L6 9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- 底部分页箭头(下一页) -->
|
||||
<div class="relative z-10 flex flex-col items-center justify-center">
|
||||
<button
|
||||
class="flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
|
||||
@click="goNextPage"
|
||||
:disabled="!canNextPage"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M5 10L35 10L20 34Z" fill="white" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { ImgButton } from '@/components'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getTagColor } from '@/utils'
|
||||
|
||||
export interface StepItem {
|
||||
@@ -127,294 +127,101 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const stepsListRef = ref<HTMLElement>()
|
||||
const scrollPosition = ref(0)
|
||||
const maxScroll = ref(0)
|
||||
const isClosing = ref(false)
|
||||
// 分页:每页最多 5 条
|
||||
const pageSize = 5
|
||||
const currentPage = ref(0)
|
||||
|
||||
// 计算是否可以滚动
|
||||
const canScrollUp = computed(() => scrollPosition.value > 0)
|
||||
const canScrollDown = computed(() => scrollPosition.value < maxScroll.value)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((props.steps?.length ?? 0) / pageSize)))
|
||||
const currentPageStartIndex = computed(() => currentPage.value * pageSize)
|
||||
const pagedSteps = computed(() =>
|
||||
props.steps.slice(currentPageStartIndex.value, currentPageStartIndex.value + pageSize)
|
||||
)
|
||||
|
||||
// 滚动到指定位置
|
||||
const scrollTo = (position: number) => {
|
||||
if (!stepsListRef.value) return
|
||||
stepsListRef.value.scrollTop = position
|
||||
scrollPosition.value = position
|
||||
const canPrevPage = computed(() => currentPage.value > 0)
|
||||
const canNextPage = computed(() => currentPage.value < totalPages.value - 1)
|
||||
|
||||
const goPrevPage = () => {
|
||||
if (!canPrevPage.value) return
|
||||
currentPage.value -= 1
|
||||
}
|
||||
|
||||
// 向上滚动
|
||||
const scrollUp = () => {
|
||||
if (!canScrollUp.value) return
|
||||
const newPosition = Math.max(0, scrollPosition.value - 100)
|
||||
scrollTo(newPosition)
|
||||
}
|
||||
|
||||
// 向下滚动
|
||||
const scrollDown = () => {
|
||||
if (!canScrollDown.value) return
|
||||
const newPosition = Math.min(maxScroll.value, scrollPosition.value + 100)
|
||||
scrollTo(newPosition)
|
||||
const goNextPage = () => {
|
||||
if (!canNextPage.value) return
|
||||
currentPage.value += 1
|
||||
}
|
||||
|
||||
// 处理步骤点击
|
||||
const handleStepClick = (index: number) => {
|
||||
emit('step-click', index, props.steps[index])
|
||||
const globalIndex = currentPageStartIndex.value + index
|
||||
emit('step-click', globalIndex, props.steps[globalIndex])
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
isClosing.value = true
|
||||
// 延迟关闭,让动画完成
|
||||
setTimeout(() => {
|
||||
emit('close')
|
||||
isClosing.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 监听visible变化,重置关闭状态
|
||||
// 当外部当前步骤改变时,自动定位到对应页
|
||||
watch(
|
||||
() => props.visible,
|
||||
() => props.currentStepIndex,
|
||||
newVal => {
|
||||
if (newVal) {
|
||||
isClosing.value = false
|
||||
}
|
||||
const idx = newVal ?? 0
|
||||
const targetPage = Math.floor(idx / pageSize)
|
||||
if (targetPage !== currentPage.value) {
|
||||
currentPage.value = Math.min(Math.max(targetPage, 0), totalPages.value - 1)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 更新滚动状态
|
||||
const updateScrollState = () => {
|
||||
if (!stepsListRef.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = stepsListRef.value
|
||||
scrollPosition.value = scrollTop
|
||||
maxScroll.value = scrollHeight - clientHeight
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
updateScrollState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
updateScrollState()
|
||||
if (stepsListRef.value) {
|
||||
stepsListRef.value.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
/* padding: 100px 0; */
|
||||
.step-navigation-enter-active,
|
||||
.step-navigation-leave-active {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.drawer-container {
|
||||
@apply absolute right-0 top-0 w-1/2 h-full flex justify-end;
|
||||
.step-navigation-enter-from,
|
||||
.step-navigation-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.step-navigation-enter-active .step-navigation-drawer,
|
||||
.step-navigation-leave-active .step-navigation-drawer {
|
||||
transition: transform 300ms ease-out;
|
||||
}
|
||||
|
||||
.step-navigation-enter-from .step-navigation-drawer,
|
||||
.step-navigation-leave-to .step-navigation-drawer {
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.drawer-container--visible {
|
||||
.step-navigation-enter-to .step-navigation-drawer,
|
||||
.step-navigation-leave-from .step-navigation-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawer-container--closing {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.steps-panel {
|
||||
@apply h-full relative;
|
||||
/* background: linear-gradient(
|
||||
180deg,
|
||||
rgba(20, 30, 45, 0.85) 0%,
|
||||
rgba(10, 15, 25, 0.9) 30%,
|
||||
rgba(15, 20, 30, 0.85) 70%,
|
||||
rgba(20, 30, 45, 0.85) 100%
|
||||
); */
|
||||
width: 648px;
|
||||
height: 1080px;
|
||||
.steps-panel-bg {
|
||||
background-image: url('@/assets/images/vlog/专业剖析侧边.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* border-left: 1px solid rgba(255, 255, 255, 0.1); */
|
||||
/* border-radius: 12px 0 0 12px;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3); */
|
||||
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply absolute top-4 right-4 w-8 h-8 rounded-full flex items-center justify-center;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.6);
|
||||
.step-item-bg {
|
||||
background-image: url('@/assets/images/vlog/侧边-底板未选中.png');
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(59, 130, 246, 1);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 25px rgba(59, 130, 246, 0.8);
|
||||
.step-item-active-bg {
|
||||
background-image: url('@/assets/images/vlog/侧边-选中底板.png');
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex justify-center items-center;
|
||||
padding: 3rem 0 1rem 0;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
@apply w-6 h-6 flex items-center justify-center;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-arrow:hover:not(:disabled) {
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
@apply flex-1 px-6 py-4;
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 60%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.steps-list::-webkit-scrollbar {
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
@apply flex items-center py-4 px-8 mb-4 cursor-pointer transition-all duration-300;
|
||||
position: relative;
|
||||
width: 831px;
|
||||
height: 122px;
|
||||
/* min-height: 4rem; */
|
||||
/* background: rgba(255, 255, 255, 0.05); */
|
||||
background-image: url('@/assets/images/vlog/侧边-底板未选中.png');
|
||||
/* border: 1px solid rgba(255, 255, 255, 0.1); */
|
||||
/* border-radius: 20px; */
|
||||
/* backdrop-filter: blur(10px); */
|
||||
}
|
||||
|
||||
.step-item:hover {
|
||||
/* background: rgba(255, 255, 255, 0.08); */
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.step-item--active {
|
||||
width: 1034px;
|
||||
height: 230px;
|
||||
padding-left: 180px;
|
||||
/* background: linear-gradient(90deg, rgba(59, 130, 246, 0.3) 0%, rgba(59, 130, 246, 0.1) 100%); */
|
||||
/* border-color: rgba(59, 130, 246, 0.6);
|
||||
box-shadow:
|
||||
0 0 30px rgba(59, 130, 246, 0.5),
|
||||
inset 0 0 20px rgba(59, 130, 246, 0.1); */
|
||||
background-image: url('@/assets/images/vlog/侧边-选中底板.png');
|
||||
transform: translateX(-23%);
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
@apply w-5 h-5 flex items-center justify-center mr-3;
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
@apply px-16 py-4 rounded-full text-2xl font-semibold mr-4 whitespace-nowrap;
|
||||
min-width: 5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
@apply flex-1 text-3xl font-bold;
|
||||
line-height: 1.5;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
@apply flex items-center justify-center text-6xl font-semibold;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
right: 15%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-number--active {
|
||||
margin-bottom: 80px;
|
||||
font-size: 150px;
|
||||
color: white;
|
||||
text-shadow: 5px 10px 5px rgba(0, 0, 0, 0.1);
|
||||
/* transform: scale(1.2);
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4); */
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
padding: 1rem 0 2rem 0;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.guide-button {
|
||||
@apply px-8 py-3 rounded-full text-sm font-semibold;
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
|
||||
}
|
||||
|
||||
.guide-button:hover {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,24 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type AudioMode = 'global' | 'channel'
|
||||
export type HighlightMode = 'border' | 'gradient'
|
||||
|
||||
// 引导步骤类型定义
|
||||
export interface GuideStep {
|
||||
id: string
|
||||
title?: string
|
||||
content: string | Component
|
||||
nextText?: string
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
highlightSelector?: string | null
|
||||
highlightPadding?: number
|
||||
highlightRadius?: number
|
||||
highlightMode?: HighlightMode
|
||||
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
||||
positionOffsetY?: number | string
|
||||
}
|
||||
|
||||
// 引导组件Props类型
|
||||
@@ -23,8 +36,13 @@ export interface GuideStepsProps {
|
||||
autoStart?: boolean
|
||||
keyboardNavigation?: boolean
|
||||
autoPlayInterval?: number
|
||||
highlightSelector?: string | null
|
||||
highlightPadding?: number
|
||||
highlightRadius?: number
|
||||
highlightMode?: HighlightMode
|
||||
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
||||
positionOffsetY?: number | string
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
// 引导组件事件类型
|
||||
@@ -45,6 +63,10 @@ export interface GuideStepsItemProps {
|
||||
isLastStep?: boolean
|
||||
currentStep?: number
|
||||
totalSteps?: number
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
}
|
||||
|
||||
// GuideStepsItem Emits类型
|
||||
|
||||
@@ -51,14 +51,27 @@
|
||||
:src="currentStep === index ? steps[index].nameSelect : steps[index].nameselectNot"
|
||||
alt=""
|
||||
/> -->
|
||||
<div class="flex items-center cursor-pointer" @click="handleStepClick(index + 1)">
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center',
|
||||
isStepClickable(index + 1)
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-45 grayscale'
|
||||
]"
|
||||
@click="handleStepClick(index + 1)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'font-bold text-4xl ',
|
||||
currentStep === index + 1 ? 'text-[#fbbf24]' : 'text-white'
|
||||
!isStepClickable(index + 1)
|
||||
? 'text-white/60'
|
||||
: currentStep === index + 1
|
||||
? 'text-[#fbbf24]'
|
||||
: 'text-white'
|
||||
]"
|
||||
>{{ step.name }}</span
|
||||
>
|
||||
{{ step.name }}
|
||||
</span>
|
||||
<!-- 图标 -->
|
||||
<img v-if="isStepCompleted(index)" src="@/assets/images/midterm/完成.png" alt="" />
|
||||
<img
|
||||
@@ -79,7 +92,7 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="step-ellipsis"
|
||||
:class="['step-ellipsis', !isStepClickable(index + 2) && 'opacity-45 grayscale']"
|
||||
src="@/assets/images/midterm/进度装饰(未完成.png"
|
||||
alt=""
|
||||
/>
|
||||
@@ -174,7 +187,15 @@ const handleComplete = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isStepClickable = (index: number) => index <= props.completedStep + 1
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
// 自由切换规则:
|
||||
// - 已完成的步骤(index <= completedStep)可自由切换
|
||||
// - 下一步(index === completedStep + 1)允许进入学习
|
||||
// - 其他情况(跳关)忽略
|
||||
if (!isStepClickable(index)) return
|
||||
|
||||
currentStep.value = index
|
||||
emit('stepChange', index)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const currentIndex = ref(0)
|
||||
const selectedOptionId = ref<string | null>(null) // 单选题选中的选项
|
||||
const selectedOptionIds = ref<string[]>([]) // 多选题选中的选项数组
|
||||
const showErrorExplanation = ref(false) // 是否显示错误解析
|
||||
const showCorrectFeedback = ref(false)
|
||||
|
||||
const total = computed(() => props.questions.length)
|
||||
const currentQuestion = computed(() => props.questions[currentIndex.value])
|
||||
@@ -63,6 +64,7 @@ watch(currentIndex, () => {
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = false
|
||||
})
|
||||
|
||||
// 通过文件后缀判断媒体类型
|
||||
@@ -79,6 +81,8 @@ const isImage = (src: string): boolean => {
|
||||
}
|
||||
|
||||
const handleOptionClick = (optionId: string) => {
|
||||
if (showCorrectFeedback.value) return
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选题:切换选中状态
|
||||
const index = selectedOptionIds.value.indexOf(optionId)
|
||||
@@ -93,13 +97,16 @@ const handleOptionClick = (optionId: string) => {
|
||||
}
|
||||
// 选择新选项时隐藏错误解析
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = false
|
||||
}
|
||||
|
||||
// 交互规则:选对进入下一步,选错显示解析;最后一题选对触发 complete 事件
|
||||
// 如果 correctOptionId 为空字符串,表示引导题,任意选择都可进入下一题
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
const q = currentQuestion.value
|
||||
|
||||
if (showCorrectFeedback.value) return
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选题:必须选择至少一个选项
|
||||
if (selectedOptionIds.value.length === 0) return
|
||||
@@ -138,12 +145,15 @@ const handleSubmit = () => {
|
||||
|
||||
// 答对时重置错误解析状态
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
if (currentIndex.value < total.value - 1) {
|
||||
currentIndex.value += 1
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
} else {
|
||||
showCorrectFeedback.value = false
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
@@ -158,9 +168,9 @@ const handleSubmit = () => {
|
||||
<div v-if="showQuestionNumber" class="flex items-center justify-between mb-2">
|
||||
<div class="text-3xl font-semibold">
|
||||
第
|
||||
<span class="text-4xl font-extrabold mx-1 text-emerald-300">{{
|
||||
currentQuestion.id
|
||||
}}</span>
|
||||
<span class="text-4xl font-extrabold mx-1 text-emerald-300">
|
||||
{{ currentQuestion.id }}
|
||||
</span>
|
||||
页
|
||||
<span class="text-2xl opacity-70 ml-2">/ 共 {{ total }} 页</span>
|
||||
</div>
|
||||
@@ -222,6 +232,16 @@ const handleSubmit = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 正确反馈提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showCorrectFeedback"
|
||||
class="mt-4 p-6 rounded-xl bg-emerald-500/20 border border-emerald-400/30 text-2xl font-semibold text-emerald-100"
|
||||
>
|
||||
回答正确
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 错误解析提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@@ -255,7 +275,10 @@ const handleSubmit = () => {
|
||||
<div :class="!hasMedia && 'flex justify-center'">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isMultiple ? selectedOptionIds.length === 0 : !selectedOptionId"
|
||||
:disabled="
|
||||
showCorrectFeedback ||
|
||||
(isMultiple ? selectedOptionIds.length === 0 : !selectedOptionId)
|
||||
"
|
||||
class="inline-flex items-center justify-center px-10 py-3 rounded-full bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-white text-2xl font-bold shadow-lg shadow-emerald-500/40 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-emerald-500 hover:shadow-xl hover:shadow-emerald-500/50 hover:scale-105 active:scale-100"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
|
||||
@@ -71,6 +71,30 @@ const emit = defineEmits<{
|
||||
(e: 'seeked', currentTime: TimeUnit): void
|
||||
}>()
|
||||
|
||||
interface VideoSlotProps {
|
||||
video: HTMLVideoElement | undefined
|
||||
playing: boolean
|
||||
currentTime: TimeUnit
|
||||
duration: TimeUnit
|
||||
progressPercent: number
|
||||
bufferedPercent: number
|
||||
volume: number
|
||||
rate: number
|
||||
togglePlay: () => Promise<void>
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
setRate: (rate: number) => void
|
||||
}
|
||||
|
||||
defineSlots<{
|
||||
default?: () => any
|
||||
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any
|
||||
overlay?: (props: VideoSlotProps) => any
|
||||
controls?: (props: VideoSlotProps) => any
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
'controls-right'?: (props: VideoSlotProps) => any
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
|
||||
// 本地状态
|
||||
@@ -174,6 +198,43 @@ const togglePlay = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// src 切换时的稳健自动播放:
|
||||
// - 如果外部 modelValue 为 true(或当前正在播放),切换资源后会尝试继续播放
|
||||
// - 某些浏览器在切换 src 后立即 play 可能失败(还未 ready / load 过程中),这里做轻量重试
|
||||
const playAfterSourceChange = (el: HTMLVideoElement) => {
|
||||
const shouldPlay = !!props.modelValue || isPlaying.value
|
||||
if (!shouldPlay) return
|
||||
|
||||
// 尝试在下一帧播放;失败则再延迟重试一次
|
||||
requestAnimationFrame(() => {
|
||||
el.play().catch(err1 => {
|
||||
// 再给一次机会:资源 load/seek 状态可能还没就绪
|
||||
window.setTimeout(() => {
|
||||
el.play().catch(err2 => emit('error', err2 ?? err1))
|
||||
}, 50)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.src, props.sources],
|
||||
() => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
|
||||
// 切源:重置并重新加载
|
||||
try {
|
||||
el.pause()
|
||||
el.currentTime = 0
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.load()
|
||||
playAfterSourceChange(el)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const handleTimeInput = (e: Event) => {
|
||||
if (!videoRef.value) return
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -466,8 +527,30 @@ const videoStyle = computed(() => ({
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-white/10"
|
||||
@click.stop="togglePlay"
|
||||
>
|
||||
<span v-if="!isPlaying">▶</span>
|
||||
<span v-else>⏸</span>
|
||||
<!-- 播放图标 -->
|
||||
<svg
|
||||
v-if="!isPlaying"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 4.75v14.5c0 .7.76 1.14 1.36.78l9.25-5.5a.9.9 0 0 0 0-1.56l-9.25-5.5A.9.9 0 0 0 7 4.75z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- 暂停图标 -->
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 5.25c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1h2.5c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1H7zm7.5 0c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1H17c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1h-2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 左侧扩展位 -->
|
||||
@@ -594,6 +677,7 @@ const videoStyle = computed(() => ({
|
||||
transition: opacity 0.2s var(--sv-ease);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.sv-fade-fast-enter-from,
|
||||
.sv-fade-fast-leave-to {
|
||||
opacity: 0;
|
||||
@@ -607,6 +691,7 @@ const videoStyle = computed(() => ({
|
||||
transform 0.25s var(--sv-ease);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.sv-slide-up-enter-from,
|
||||
.sv-slide-up-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,2 +1,95 @@
|
||||
export { default as Video } from './Video.vue'
|
||||
export type {} from './Video.vue'
|
||||
import type { ComponentOptionsMixin, DefineComponent, PublicProps, SlotsType } from 'vue'
|
||||
import VideoSfc from './Video.vue'
|
||||
|
||||
type TimeUnit = number
|
||||
|
||||
interface VideoSource {
|
||||
src: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface VideoAnchor {
|
||||
id: string
|
||||
time: number
|
||||
enabled?: boolean
|
||||
afterResume?: 'hide' | 'remove' | 'keep'
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
interface VideoProps {
|
||||
src?: string
|
||||
sources?: VideoSource[]
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
loop?: boolean
|
||||
preload?: 'auto' | 'metadata' | 'none'
|
||||
poster?: string
|
||||
controls?: boolean
|
||||
hideNativeControls?: boolean
|
||||
autoHideControls?: boolean
|
||||
autoHideDelay?: number
|
||||
showCenterPlayIcon?: boolean
|
||||
modelValue?: boolean
|
||||
volume?: number
|
||||
playbackRate?: number
|
||||
fit?: 'contain' | 'cover' | 'fill'
|
||||
anchors?: VideoAnchor[]
|
||||
}
|
||||
|
||||
interface VideoSlotProps {
|
||||
video: HTMLVideoElement | undefined
|
||||
playing: boolean
|
||||
currentTime: TimeUnit
|
||||
duration: TimeUnit
|
||||
progressPercent: number
|
||||
bufferedPercent: number
|
||||
volume: number
|
||||
rate: number
|
||||
togglePlay: () => Promise<void>
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
setRate: (rate: number) => void
|
||||
}
|
||||
|
||||
type VideoSlots = SlotsType<{
|
||||
default?: () => any
|
||||
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any
|
||||
overlay?: (props: VideoSlotProps) => any
|
||||
controls?: (props: VideoSlotProps) => any
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
'controls-right'?: (props: VideoSlotProps) => any
|
||||
}>
|
||||
|
||||
type VideoComponent = DefineComponent<
|
||||
VideoProps,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
ComponentOptionsMixin,
|
||||
ComponentOptionsMixin,
|
||||
{
|
||||
'update:modelValue': (playing: boolean) => void
|
||||
play: () => void
|
||||
pause: () => void
|
||||
ended: () => void
|
||||
timeupdate: (currentTime: TimeUnit, duration: TimeUnit) => void
|
||||
error: (error: unknown) => void
|
||||
loadedmetadata: (duration: TimeUnit) => void
|
||||
ratechange: (rate: number) => void
|
||||
volumechange: (volume: number, muted: boolean) => void
|
||||
seeking: (currentTime: TimeUnit) => void
|
||||
seeked: (currentTime: TimeUnit) => void
|
||||
},
|
||||
string,
|
||||
PublicProps,
|
||||
Readonly<VideoProps>,
|
||||
Record<never, never>,
|
||||
VideoSlots
|
||||
>
|
||||
|
||||
const Video = VideoSfc as VideoComponent
|
||||
|
||||
export { Video }
|
||||
export default Video
|
||||
export type { VideoAnchor, VideoProps, VideoSlotProps }
|
||||
@@ -0,0 +1,145 @@
|
||||
import { computed, onBeforeUnmount, ref, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
|
||||
type AudioMode = 'global' | 'channel'
|
||||
|
||||
interface UseAudioManagerOptions {
|
||||
mode?: MaybeRefOrGetter<AudioMode | undefined>
|
||||
channelId?: MaybeRefOrGetter<string | undefined>
|
||||
}
|
||||
|
||||
interface AudioState {
|
||||
currentSrc: string | null
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
const audioPool = new Map<string, HTMLAudioElement>()
|
||||
const statePool = new Map<string, AudioState>()
|
||||
const playTokens = new Map<string, number>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function resolveKey(options?: UseAudioManagerOptions): string {
|
||||
if ((toValue(options?.mode) ?? 'global') === 'global') {
|
||||
return '__GLOBAL__'
|
||||
}
|
||||
|
||||
return `channel:${toValue(options?.channelId) ?? '__DEFAULT__'}`
|
||||
}
|
||||
|
||||
function getState(key: string): AudioState {
|
||||
return (
|
||||
statePool.get(key) ?? {
|
||||
currentSrc: null,
|
||||
isPlaying: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function emit(key: string, state: AudioState) {
|
||||
statePool.set(key, state)
|
||||
listeners.forEach(listener => listener())
|
||||
}
|
||||
|
||||
function nextToken(key: string) {
|
||||
const token = (playTokens.get(key) ?? 0) + 1
|
||||
playTokens.set(key, token)
|
||||
return token
|
||||
}
|
||||
|
||||
function isLatestToken(key: string, token: number) {
|
||||
return playTokens.get(key) === token
|
||||
}
|
||||
|
||||
function getOrCreateAudio(key: string): HTMLAudioElement {
|
||||
let audio = audioPool.get(key)
|
||||
|
||||
if (!audio) {
|
||||
audio = new Audio()
|
||||
audioPool.set(key, audio)
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
const state = getState(key)
|
||||
|
||||
emit(key, {
|
||||
currentSrc: state.currentSrc,
|
||||
isPlaying: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
|
||||
export function useAudioManager(options?: UseAudioManagerOptions) {
|
||||
const key = computed(() => resolveKey(options))
|
||||
const state = ref<AudioState>(getState(key.value))
|
||||
|
||||
const syncState = () => {
|
||||
state.value = getState(key.value)
|
||||
}
|
||||
|
||||
listeners.add(syncState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
listeners.delete(syncState)
|
||||
})
|
||||
|
||||
const play = async (src: string) => {
|
||||
const currentKey = key.value
|
||||
const audio = getOrCreateAudio(currentKey)
|
||||
const token = nextToken(currentKey)
|
||||
|
||||
audio.pause()
|
||||
|
||||
if (getState(currentKey).currentSrc !== src) {
|
||||
audio.src = src
|
||||
}
|
||||
|
||||
audio.currentTime = 0
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: src,
|
||||
isPlaying: false
|
||||
})
|
||||
|
||||
try {
|
||||
await audio.play()
|
||||
|
||||
if (!isLatestToken(currentKey, token)) return
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: src,
|
||||
isPlaying: true
|
||||
})
|
||||
} catch {
|
||||
if (!isLatestToken(currentKey, token)) return
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: src,
|
||||
isPlaying: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
const currentKey = key.value
|
||||
const audio = audioPool.get(currentKey)
|
||||
|
||||
nextToken(currentKey)
|
||||
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: getState(currentKey).currentSrc,
|
||||
isPlaying: false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
play,
|
||||
stop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ModuleName = 'film' | 'vlog' | 'interview'
|
||||
export type StepIndex = 1 | 2 | 3
|
||||
|
||||
/**
|
||||
* 模块步骤 -> 子页面 route.name 列表
|
||||
*
|
||||
* 说明:
|
||||
* - 用于“停留≥阈值后自动计为已查看(viewed)”以及计算 completedStep。
|
||||
* - 按需求:菜单中 disabled=true 的子项不计入完成条件,因此这里不包含 disabled 子项。
|
||||
*/
|
||||
export const moduleStepMap: Record<ModuleName, Record<StepIndex, readonly string[]>> = {
|
||||
film: {
|
||||
1: ['FilmResource', 'FilmCreative', 'FilmScriptWriting', 'FilmStoryboard'],
|
||||
2: ['FilmMidtermEquipment', 'FilmMidtermShooting', 'FilmOnsiteShooting'],
|
||||
3: ['FilmEditing', 'FilmColorPractice', 'FilmSoundEffect', 'FilmExportPublish']
|
||||
},
|
||||
vlog: {
|
||||
1: ['VlogResource', 'VlogCreative', 'VlogScriptWriting', 'VlogStoryboard'],
|
||||
2: ['VlogMidtermEquipment', 'VlogMidtermShooting', 'VlogShooting'],
|
||||
3: ['VlogEditing', 'VlogColor', 'VlogExportPublish']
|
||||
},
|
||||
interview: {
|
||||
1: ['InterviewResource', 'InterviewCreative', 'InterviewScriptWriting', 'InterviewStoryboard'],
|
||||
2: ['InterviewEquipment', 'InterviewShooting', 'InterviewOnsiteShooting'],
|
||||
3: ['InterviewEditing', 'InterviewColorPractice', 'InterviewAudio', 'InterviewExportPublish']
|
||||
}
|
||||
} as const
|
||||
|
||||
export const allTrackedRouteNames = new Set<string>(
|
||||
Object.values(moduleStepMap).flatMap(stepMap => Object.values(stepMap).flatMap(names => names))
|
||||
)
|
||||
|
||||
export function getModuleNameByRouteName(routeName: string): ModuleName | null {
|
||||
for (const moduleName of Object.keys(moduleStepMap) as ModuleName[]) {
|
||||
const stepMap = moduleStepMap[moduleName]
|
||||
for (const step of [1, 2, 3] as const) {
|
||||
if (stepMap[step].includes(routeName)) return moduleName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { BasicElements, Film, Interview, Vlog } from './modules'
|
||||
import { allTrackedRouteNames, getModuleNameByRouteName } from '@/config/moduleStepMap'
|
||||
import { useFilmStore, useInterviewStore, useVlogStore } from '@/stores'
|
||||
|
||||
const NotFound = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
@@ -15,7 +17,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
component: () => import('@/views/home/index.tsx'),
|
||||
meta: {
|
||||
title: '首页'
|
||||
}
|
||||
@@ -61,13 +63,46 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
const VIEW_THRESHOLD_MS = 3000
|
||||
let currentTrackedRouteName: string | null = null
|
||||
let currentEnterAt = 0
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta?.title) {
|
||||
document.title = `${to.meta.title} - 短视频达人`
|
||||
}
|
||||
|
||||
// 结算离开:from 是上一个页面
|
||||
const fromName = typeof from.name === 'string' ? from.name : null
|
||||
if (
|
||||
fromName &&
|
||||
currentTrackedRouteName === fromName &&
|
||||
allTrackedRouteNames.has(fromName) &&
|
||||
currentEnterAt > 0
|
||||
) {
|
||||
const stayedMs = Date.now() - currentEnterAt
|
||||
if (stayedMs >= VIEW_THRESHOLD_MS) {
|
||||
const moduleName = getModuleNameByRouteName(fromName)
|
||||
if (moduleName === 'film') useFilmStore().markViewed(fromName)
|
||||
if (moduleName === 'vlog') useVlogStore().markViewed(fromName)
|
||||
if (moduleName === 'interview') useInterviewStore().markViewed(fromName)
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(to => {
|
||||
// 记录进入:to 是当前页面
|
||||
const toName = typeof to.name === 'string' ? to.name : null
|
||||
if (toName && allTrackedRouteNames.has(toName)) {
|
||||
currentTrackedRouteName = toName
|
||||
currentEnterAt = Date.now()
|
||||
} else {
|
||||
currentTrackedRouteName = null
|
||||
currentEnterAt = 0
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -29,6 +29,22 @@ const Interview: RouteRecordRaw[] = [
|
||||
}
|
||||
},
|
||||
//前期准备
|
||||
{
|
||||
path: '/interview/resource',
|
||||
name: 'InterviewResource',
|
||||
component: () => import('@/views/interview/resource/index.vue'),
|
||||
meta: {
|
||||
title: '资源筹备'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/creative',
|
||||
name: 'InterviewCreative',
|
||||
component: () => import('@/views/interview/creative/index.vue'),
|
||||
meta: {
|
||||
title: '创意构想'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/script-writing',
|
||||
name: 'InterviewScriptWriting',
|
||||
@@ -37,6 +53,14 @@ const Interview: RouteRecordRaw[] = [
|
||||
title: '脚本撰写'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/storyboard',
|
||||
name: 'InterviewStoryboard',
|
||||
component: () => import('@/views/interview/storyboard/index.vue'),
|
||||
meta: {
|
||||
title: '分镜制作'
|
||||
}
|
||||
},
|
||||
//中期拍摄
|
||||
{
|
||||
path: '/interview/equipment',
|
||||
@@ -54,6 +78,14 @@ const Interview: RouteRecordRaw[] = [
|
||||
title: '实地取景'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/on-site-shooting',
|
||||
name: 'InterviewOnsiteShooting',
|
||||
component: () => import('@/views/interview/midterm-shooting/on-site/index.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/filmingLocation-a',
|
||||
name: 'InterviewFilmingLocationA',
|
||||
@@ -89,6 +121,14 @@ const Interview: RouteRecordRaw[] = [
|
||||
// title: '调色'
|
||||
// }
|
||||
// },
|
||||
{
|
||||
path: '/interview/color-practice',
|
||||
name: 'InterviewColorPractice',
|
||||
component: () => import('@/views/interview/after-editing/color-practice.vue'),
|
||||
meta: {
|
||||
title: '后期剪辑-调色实践'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/color',
|
||||
name: 'InterviewColor',
|
||||
@@ -97,6 +137,22 @@ const Interview: RouteRecordRaw[] = [
|
||||
title: '调色'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/audio',
|
||||
name: 'InterviewAudio',
|
||||
component: () => import('@/views/interview/after-editing/audio/index.vue'),
|
||||
meta: {
|
||||
title: '音频'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/export-publish',
|
||||
name: 'InterviewExportPublish',
|
||||
component: () => import('@/views/interview/after-editing/export-publish/index.vue'),
|
||||
meta: {
|
||||
title: '导出发布'
|
||||
}
|
||||
},
|
||||
// 实战操作台
|
||||
{
|
||||
path: '/interview/practice',
|
||||
|
||||
@@ -28,7 +28,23 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '发现与记录-视频'
|
||||
}
|
||||
},
|
||||
//前期准备-脚本撰写
|
||||
//前期准备
|
||||
{
|
||||
path: '/vlog/resource',
|
||||
name: 'VlogResource',
|
||||
component: () => import('@/views/vlog/resource/index.vue'),
|
||||
meta: {
|
||||
title: '资源筹备'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/creative',
|
||||
name: 'VlogCreative',
|
||||
component: () => import('@/views/vlog/creative/index.vue'),
|
||||
meta: {
|
||||
title: '创意构想'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/script-writing',
|
||||
name: 'VlogScriptWriting',
|
||||
@@ -37,6 +53,14 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '脚本撰写'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/storyboard',
|
||||
name: 'VlogStoryboard',
|
||||
component: () => import('@/views/vlog/storyboard/index.vue'),
|
||||
meta: {
|
||||
title: '分镜制作'
|
||||
}
|
||||
},
|
||||
//中期拍摄
|
||||
{
|
||||
path: '/vlog/equipment',
|
||||
@@ -62,54 +86,54 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '现场拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/time-lapse',
|
||||
name: 'VlogOnsiteTimeLapse',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/time-lapse.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-延时拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/slider',
|
||||
name: 'VlogOnsiteSlider',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/slider.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-滑轨拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/slow-shutter-day',
|
||||
name: 'VlogOnsiteSlowShutterDay',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-day.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-慢门拍摄(白天)'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/slow-shutter-night',
|
||||
name: 'VlogOnsiteSlowShutterNight',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-night.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-慢门拍摄(夜晚)'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/low-angle',
|
||||
name: 'VlogOnsiteLowAngle',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/low-angle.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-低角度拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/random',
|
||||
name: 'VlogOnsiteRandom',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/random.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-创作随机性'
|
||||
}
|
||||
},
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/time-lapse',
|
||||
// name: 'VlogOnsiteTimeLapse',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/time-lapse.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-延时拍摄'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/slider',
|
||||
// name: 'VlogOnsiteSlider',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/slider.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-滑轨拍摄'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/slow-shutter-day',
|
||||
// name: 'VlogOnsiteSlowShutterDay',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-day.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-慢门拍摄(白天)'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/slow-shutter-night',
|
||||
// name: 'VlogOnsiteSlowShutterNight',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-night.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-慢门拍摄(夜晚)'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/low-angle',
|
||||
// name: 'VlogOnsiteLowAngle',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/low-angle.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-低角度拍摄'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/random',
|
||||
// name: 'VlogOnsiteRandom',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/random.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-创作随机性'
|
||||
// }
|
||||
// },
|
||||
{
|
||||
path: '/vlog/filmingLocation-a',
|
||||
name: 'VlogFilmingLocationA',
|
||||
@@ -151,6 +175,14 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '后期剪辑-调色'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/export-publish',
|
||||
name: 'VlogExportPublish',
|
||||
component: () => import('@/views/vlog/after-editing/export-publish/index.vue'),
|
||||
meta: {
|
||||
title: '后期剪辑-导出发布'
|
||||
}
|
||||
},
|
||||
// 实战操作台
|
||||
{
|
||||
path: '/vlog/practice',
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const BASIC_ELEMENTS_LESSON_ID = 'xsdh_sdjcys'
|
||||
export const BASIC_ELEMENTS_KEY = 1
|
||||
|
||||
interface BasicElementsSavePayload {
|
||||
isGuideNewUser?: boolean
|
||||
isGuideBasicElementsTask?: boolean
|
||||
isBasicElementsUnlock?: boolean
|
||||
basicElementsUnlockProgress?: number
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic Elements store - manages Basic Elements information
|
||||
*/
|
||||
@@ -9,6 +28,8 @@ export const useBasicElementsStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
//action-新手导航(是否引导新手导航)
|
||||
const isGuideNewUser = ref<boolean>(true)
|
||||
//basic-elements-任务取景框引导
|
||||
const isGuideBasicElementsTask = ref<boolean>(true)
|
||||
//basic-elements-是否解锁完成前置(观看视频)
|
||||
@@ -17,24 +38,78 @@ export const useBasicElementsStore = defineStore(
|
||||
const basicElementsUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveBasicElementsStore(): void {
|
||||
const payload: BasicElementsSavePayload = {
|
||||
isGuideNewUser: isGuideNewUser.value,
|
||||
isGuideBasicElementsTask: isGuideBasicElementsTask.value,
|
||||
isBasicElementsUnlock: isBasicElementsUnlock.value,
|
||||
basicElementsUnlockProgress: basicElementsUnlockProgress.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api =>
|
||||
api?.setUserDefine(BASIC_ELEMENTS_LESSON_ID, BASIC_ELEMENTS_KEY, JSON.stringify(payload))
|
||||
)
|
||||
.catch(error => console.error('Failed to save Basic Elements store', error))
|
||||
}
|
||||
|
||||
async function loadBasicElementsStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(BASIC_ELEMENTS_LESSON_ID, BASIC_ELEMENTS_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as BasicElementsSavePayload
|
||||
isGuideNewUser.value = data.isGuideNewUser ?? true
|
||||
isGuideBasicElementsTask.value = data.isGuideBasicElementsTask ?? true
|
||||
isBasicElementsUnlock.value = data.isBasicElementsUnlock ?? false
|
||||
basicElementsUnlockProgress.value = data.basicElementsUnlockProgress ?? 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load Basic Elements store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function setIsGuideNewUser(value: boolean): void {
|
||||
isGuideNewUser.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function setIsGuideBasicElementsTask(value: boolean): void {
|
||||
isGuideBasicElementsTask.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function setIsBasicElementsUnlock(value: boolean): void {
|
||||
isBasicElementsUnlock.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function setBasicElementsUnlockProgress(value: number): void {
|
||||
basicElementsUnlockProgress.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function resetBasicElementsState(): void {
|
||||
isGuideNewUser.value = true
|
||||
isGuideBasicElementsTask.value = true
|
||||
isBasicElementsUnlock.value = false
|
||||
basicElementsUnlockProgress.value = 0
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
setIsGuideNewUser,
|
||||
setIsGuideBasicElementsTask,
|
||||
setIsBasicElementsUnlock,
|
||||
setBasicElementsUnlockProgress,
|
||||
saveBasicElementsStore,
|
||||
loadBasicElementsStore,
|
||||
resetBasicElementsState,
|
||||
//Actions
|
||||
isGuideNewUser,
|
||||
isGuideBasicElementsTask,
|
||||
isBasicElementsUnlock,
|
||||
basicElementsUnlockProgress
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { moduleStepMap } from '@/config/moduleStepMap'
|
||||
|
||||
export const FILM_LESSON_ID = 'film_rwqjk'
|
||||
export const FILM_KEY = 4
|
||||
|
||||
interface FilmSavePayload {
|
||||
viewed?: Record<string, boolean>
|
||||
isGuideFilmTask?: boolean
|
||||
hasShownGuideFilmTask?: boolean
|
||||
isFilmUnlock?: boolean
|
||||
FilmUnlockProgress?: number
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Film store - manages Film information
|
||||
@@ -9,6 +30,12 @@ export const useFilmStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
/**
|
||||
* 已查看(停留≥阈值)记录
|
||||
* key 推荐用 route.name
|
||||
*/
|
||||
const viewed = ref<Record<string, boolean>>({})
|
||||
|
||||
//Film-任务取景框引导
|
||||
const isGuideFilmTask = ref<boolean>(true)
|
||||
const hasShownGuideFilmTask = ref<boolean>(false)
|
||||
@@ -18,38 +45,116 @@ export const useFilmStore = defineStore(
|
||||
const FilmUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveFilmStore(): void {
|
||||
const payload: FilmSavePayload = {
|
||||
viewed: viewed.value,
|
||||
isGuideFilmTask: isGuideFilmTask.value,
|
||||
hasShownGuideFilmTask: hasShownGuideFilmTask.value,
|
||||
isFilmUnlock: isFilmUnlock.value,
|
||||
FilmUnlockProgress: FilmUnlockProgress.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api => api?.setUserDefine(FILM_LESSON_ID, FILM_KEY, JSON.stringify(payload)))
|
||||
.catch(error => console.error('Failed to save Film store', error))
|
||||
}
|
||||
|
||||
async function loadFilmStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(FILM_LESSON_ID, FILM_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as FilmSavePayload
|
||||
viewed.value = data.viewed ?? {}
|
||||
isGuideFilmTask.value = data.isGuideFilmTask ?? true
|
||||
hasShownGuideFilmTask.value = data.hasShownGuideFilmTask ?? false
|
||||
isFilmUnlock.value = data.isFilmUnlock ?? false
|
||||
FilmUnlockProgress.value = data.FilmUnlockProgress ?? 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load Film store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function markViewed(key: string): void {
|
||||
if (!key) return
|
||||
viewed.value[key] = true
|
||||
FilmUnlockProgress.value = Math.max(
|
||||
FilmUnlockProgress.value,
|
||||
Math.min(completedStep.value + 1, 4)
|
||||
)
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setIsGuideFilmTask(value: boolean): void {
|
||||
isGuideFilmTask.value = value
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setIsFilmUnlock(value: boolean): void {
|
||||
isFilmUnlock.value = value
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setFilmUnlockProgress(value: number): void {
|
||||
FilmUnlockProgress.value = value
|
||||
const maxUnlockedStep = Math.min(completedStep.value + 1, 4)
|
||||
FilmUnlockProgress.value = Math.min(value, maxUnlockedStep)
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setHasShownGuideFilmTask(value: boolean): void {
|
||||
hasShownGuideFilmTask.value = value
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
// 基于“子页面是否已查看”的大步骤完成度(1..3)
|
||||
// disabled 子项不计入(在这里直接不放进列表)
|
||||
const stepRouteNames = moduleStepMap.film
|
||||
|
||||
const completedStep = computed<number>(() => {
|
||||
const isDone = (step: 1 | 2 | 3) =>
|
||||
stepRouteNames[step].every(name => viewed.value[name] === true)
|
||||
|
||||
if (isDone(1) && isDone(2) && isDone(3)) return 3
|
||||
if (isDone(1) && isDone(2)) return 2
|
||||
if (isDone(1)) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
// 按子页面粒度的学习进度百分比
|
||||
const progressPercent = computed<number>(() => {
|
||||
const allRoutes = (Object.values(stepRouteNames) as string[][]).flat()
|
||||
if (allRoutes.length === 0) return 0
|
||||
const viewedCount = allRoutes.filter(name => viewed.value[name]).length
|
||||
return Math.round((viewedCount / allRoutes.length) * 100)
|
||||
})
|
||||
|
||||
// Reset all Film-related persistent state
|
||||
function resetFilmState(): void {
|
||||
viewed.value = {}
|
||||
isGuideFilmTask.value = true
|
||||
hasShownGuideFilmTask.value = false
|
||||
isFilmUnlock.value = false
|
||||
FilmUnlockProgress.value = 0
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
markViewed,
|
||||
setIsGuideFilmTask,
|
||||
setIsFilmUnlock,
|
||||
setFilmUnlockProgress,
|
||||
setHasShownGuideFilmTask,
|
||||
saveFilmStore,
|
||||
loadFilmStore,
|
||||
resetFilmState,
|
||||
//Actions
|
||||
completedStep,
|
||||
progressPercent,
|
||||
viewed,
|
||||
isGuideFilmTask,
|
||||
isFilmUnlock,
|
||||
FilmUnlockProgress,
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { moduleStepMap } from '@/config/moduleStepMap'
|
||||
|
||||
export const INTERVIEW_LESSON_ID = 'fangtan_rwqjk'
|
||||
export const INTERVIEW_KEY = 3
|
||||
|
||||
interface InterviewSavePayload {
|
||||
viewed?: Record<string, boolean>
|
||||
isGuideInterviewTask?: boolean
|
||||
isInterviewUnlock?: boolean
|
||||
InterviewUnlockProgress?: number
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* I store - manages Interview information
|
||||
@@ -9,6 +29,12 @@ export const useInterviewStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
/**
|
||||
* 已查看(停留≥阈值)记录
|
||||
* key 推荐用 route.name
|
||||
*/
|
||||
const viewed = ref<Record<string, boolean>>({})
|
||||
|
||||
//Interview-任务取景框引导
|
||||
const isGuideInterviewTask = ref<boolean>(true)
|
||||
//Interview-是否解锁完成前置(观看视频)
|
||||
@@ -17,24 +43,107 @@ export const useInterviewStore = defineStore(
|
||||
const InterviewUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveInterviewStore(): void {
|
||||
const payload: InterviewSavePayload = {
|
||||
viewed: viewed.value,
|
||||
isGuideInterviewTask: isGuideInterviewTask.value,
|
||||
isInterviewUnlock: isInterviewUnlock.value,
|
||||
InterviewUnlockProgress: InterviewUnlockProgress.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api =>
|
||||
api?.setUserDefine(INTERVIEW_LESSON_ID, INTERVIEW_KEY, JSON.stringify(payload))
|
||||
)
|
||||
.catch(error => console.error('Failed to save Interview store', error))
|
||||
}
|
||||
|
||||
async function loadInterviewStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(INTERVIEW_LESSON_ID, INTERVIEW_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as InterviewSavePayload
|
||||
viewed.value = data.viewed ?? {}
|
||||
isGuideInterviewTask.value = data.isGuideInterviewTask ?? true
|
||||
isInterviewUnlock.value = data.isInterviewUnlock ?? false
|
||||
InterviewUnlockProgress.value = data.InterviewUnlockProgress ?? 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load Interview store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function markViewed(key: string): void {
|
||||
if (!key) return
|
||||
viewed.value[key] = true
|
||||
InterviewUnlockProgress.value = Math.max(
|
||||
InterviewUnlockProgress.value,
|
||||
Math.min(completedStep.value + 1, 4)
|
||||
)
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
// 基于“子页面是否已查看”的大步骤完成度(1..3)
|
||||
// disabled 子项不计入(在这里直接不放进列表)
|
||||
const stepRouteNames = moduleStepMap.interview
|
||||
|
||||
const completedStep = computed<number>(() => {
|
||||
const isDone = (step: 1 | 2 | 3) =>
|
||||
stepRouteNames[step].every(name => viewed.value[name] === true)
|
||||
|
||||
if (isDone(1) && isDone(2) && isDone(3)) return 3
|
||||
if (isDone(1) && isDone(2)) return 2
|
||||
if (isDone(1)) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const progressPercent = computed<number>(() => {
|
||||
const allRoutes = (Object.values(stepRouteNames) as string[][]).flat()
|
||||
if (allRoutes.length === 0) return 0
|
||||
const viewedCount = allRoutes.filter(name => viewed.value[name]).length
|
||||
return Math.round((viewedCount / allRoutes.length) * 100)
|
||||
})
|
||||
|
||||
function setIsGuideInterviewTask(value: boolean): void {
|
||||
isGuideInterviewTask.value = value
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
function setIsInterviewUnlock(value: boolean): void {
|
||||
isInterviewUnlock.value = value
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
function setInterviewUnlockProgress(value: number): void {
|
||||
InterviewUnlockProgress.value = value
|
||||
const maxUnlockedStep = Math.min(completedStep.value + 1, 4)
|
||||
InterviewUnlockProgress.value = Math.min(value, maxUnlockedStep)
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
function resetInterviewState(): void {
|
||||
viewed.value = {}
|
||||
isGuideInterviewTask.value = true
|
||||
isInterviewUnlock.value = false
|
||||
InterviewUnlockProgress.value = 0
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
markViewed,
|
||||
setIsGuideInterviewTask,
|
||||
setIsInterviewUnlock,
|
||||
setInterviewUnlockProgress,
|
||||
resetInterviewState,
|
||||
loadInterviewStore,
|
||||
saveInterviewStore,
|
||||
//Actions
|
||||
completedStep,
|
||||
progressPercent,
|
||||
viewed,
|
||||
isGuideInterviewTask,
|
||||
isInterviewUnlock,
|
||||
InterviewUnlockProgress
|
||||
|
||||
@@ -7,22 +7,25 @@ import { ref } from 'vue'
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
() => {
|
||||
//action-新手导航(是否引导新手导航)
|
||||
const isGuideNewUser = ref<boolean>(true)
|
||||
const courseId = ref<string | null>(null)
|
||||
const uid = ref<string | null>(null)
|
||||
|
||||
function setIsGuideNewUser(value: boolean): void {
|
||||
isGuideNewUser.value = value
|
||||
function setCourseInfo(payload: { courseId: string | null; uid: string | null }): void {
|
||||
courseId.value = payload.courseId
|
||||
uid.value = payload.uid
|
||||
}
|
||||
|
||||
// Reset all User-related persistent state
|
||||
function resetUserState(): void {
|
||||
isGuideNewUser.value = true
|
||||
courseId.value = null
|
||||
uid.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
setIsGuideNewUser,
|
||||
setCourseInfo,
|
||||
resetUserState,
|
||||
isGuideNewUser
|
||||
courseId,
|
||||
uid
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { moduleStepMap } from '@/config/moduleStepMap'
|
||||
|
||||
export const VLOG_LESSON_ID = 'vlog_rwqjk'
|
||||
export const VLOG_KEY = 2
|
||||
|
||||
interface VlogSavePayload {
|
||||
viewed?: Record<string, boolean>
|
||||
isGuideVlogTask?: boolean
|
||||
isGuideVlogReturn?: boolean
|
||||
isGuideVlogStage1?: boolean
|
||||
isGuideVlogStage2?: boolean
|
||||
isGuideVlogStage3?: boolean
|
||||
isVlogUnlock?: boolean
|
||||
vlogUnlockProgress?: number
|
||||
vlogMenuVisible?: boolean
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vlog store - manages Vlog information
|
||||
@@ -9,43 +34,185 @@ export const useVlogStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
/**
|
||||
* 已查看(停留≥阈值)记录
|
||||
* key 推荐用 route.name
|
||||
*/
|
||||
const viewed = ref<Record<string, boolean>>({})
|
||||
|
||||
//vlog-任务取景框引导
|
||||
const isGuideVlogTask = ref<boolean>(true)
|
||||
//vlog-返回 VlogWork 时的一次性引导
|
||||
const isGuideVlogReturn = ref<boolean>(true)
|
||||
//vlog-前期准备 菜单一次性引导
|
||||
const isGuideVlogStage1 = ref<boolean>(true)
|
||||
//vlog-中期拍摄 菜单一次性引导
|
||||
const isGuideVlogStage2 = ref<boolean>(true)
|
||||
//vlog-后期剪辑 菜单一次性引导
|
||||
const isGuideVlogStage3 = ref<boolean>(true)
|
||||
//vlog-是否解锁完成前置(观看视频)
|
||||
const isVlogUnlock = ref<boolean>(false)
|
||||
|
||||
//vlog-菜单(Overlay)展开状态
|
||||
const vlogMenuVisible = ref<boolean>(false)
|
||||
|
||||
//vlog-发现与记录解锁进度
|
||||
const vlogUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveVlogStore(): void {
|
||||
const payload: VlogSavePayload = {
|
||||
viewed: viewed.value,
|
||||
isGuideVlogTask: isGuideVlogTask.value,
|
||||
isGuideVlogReturn: isGuideVlogReturn.value,
|
||||
isGuideVlogStage1: isGuideVlogStage1.value,
|
||||
isGuideVlogStage2: isGuideVlogStage2.value,
|
||||
isGuideVlogStage3: isGuideVlogStage3.value,
|
||||
isVlogUnlock: isVlogUnlock.value,
|
||||
vlogUnlockProgress: vlogUnlockProgress.value,
|
||||
vlogMenuVisible: vlogMenuVisible.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api => api?.setUserDefine(VLOG_LESSON_ID, VLOG_KEY, JSON.stringify(payload)))
|
||||
.catch(error => console.error('Failed to save Vlog store', error))
|
||||
}
|
||||
|
||||
async function loadVlogStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(VLOG_LESSON_ID, VLOG_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as VlogSavePayload
|
||||
viewed.value = data.viewed ?? {}
|
||||
isGuideVlogTask.value = data.isGuideVlogTask ?? true
|
||||
isGuideVlogReturn.value = data.isGuideVlogReturn ?? true
|
||||
isGuideVlogStage1.value = data.isGuideVlogStage1 ?? true
|
||||
isGuideVlogStage2.value = data.isGuideVlogStage2 ?? true
|
||||
isGuideVlogStage3.value = data.isGuideVlogStage3 ?? true
|
||||
isVlogUnlock.value = data.isVlogUnlock ?? false
|
||||
vlogUnlockProgress.value = data.vlogUnlockProgress ?? 0
|
||||
vlogMenuVisible.value = data.vlogMenuVisible ?? false
|
||||
} catch (error) {
|
||||
console.error('Failed to load Vlog store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function markViewed(key: string): void {
|
||||
if (!key) return
|
||||
viewed.value[key] = true
|
||||
vlogUnlockProgress.value = Math.max(
|
||||
vlogUnlockProgress.value,
|
||||
Math.min(completedStep.value + 1, 4)
|
||||
)
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogTask(value: boolean): void {
|
||||
isGuideVlogTask.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogReturn(value: boolean): void {
|
||||
isGuideVlogReturn.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogStage1(value: boolean): void {
|
||||
isGuideVlogStage1.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogStage2(value: boolean): void {
|
||||
isGuideVlogStage2.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogStage3(value: boolean): void {
|
||||
isGuideVlogStage3.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setVlogMenuVisible(value: boolean): void {
|
||||
vlogMenuVisible.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsVlogUnlock(value: boolean): void {
|
||||
isVlogUnlock.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setVlogUnlockProgress(value: number): void {
|
||||
vlogUnlockProgress.value = value
|
||||
const maxUnlockedStep = Math.min(completedStep.value + 1, 4)
|
||||
vlogUnlockProgress.value = Math.min(value, maxUnlockedStep)
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
// 基于“子页面是否已查看”的大步骤完成度(1..3)
|
||||
// disabled 子项不计入(在这里直接不放进列表)
|
||||
const stepRouteNames = moduleStepMap.vlog
|
||||
|
||||
const completedStep = computed<number>(() => {
|
||||
const isDone = (step: 1 | 2 | 3) =>
|
||||
stepRouteNames[step].every(name => viewed.value[name] === true)
|
||||
|
||||
if (isDone(1) && isDone(2) && isDone(3)) return 3
|
||||
if (isDone(1) && isDone(2)) return 2
|
||||
if (isDone(1)) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
// 按子页面粒度的学习进度百分比
|
||||
const progressPercent = computed<number>(() => {
|
||||
const allRoutes = (Object.values(stepRouteNames) as string[][]).flat()
|
||||
if (allRoutes.length === 0) return 0
|
||||
const viewedCount = allRoutes.filter(name => viewed.value[name]).length
|
||||
return Math.round((viewedCount / allRoutes.length) * 100)
|
||||
})
|
||||
|
||||
// Reset all Vlog-related persistent state
|
||||
function resetVlogState(): void {
|
||||
viewed.value = {}
|
||||
isGuideVlogTask.value = true
|
||||
isGuideVlogReturn.value = true
|
||||
isGuideVlogStage1.value = true
|
||||
isGuideVlogStage2.value = true
|
||||
isGuideVlogStage3.value = true
|
||||
isVlogUnlock.value = false
|
||||
vlogUnlockProgress.value = 0
|
||||
vlogMenuVisible.value = false
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
markViewed,
|
||||
setIsGuideVlogTask,
|
||||
setIsGuideVlogReturn,
|
||||
setIsGuideVlogStage1,
|
||||
setIsGuideVlogStage2,
|
||||
setIsGuideVlogStage3,
|
||||
setIsVlogUnlock,
|
||||
setVlogUnlockProgress,
|
||||
setVlogMenuVisible,
|
||||
saveVlogStore,
|
||||
loadVlogStore,
|
||||
resetVlogState,
|
||||
setVlogUnlockProgress,
|
||||
//Actions
|
||||
completedStep,
|
||||
progressPercent,
|
||||
viewed,
|
||||
isGuideVlogTask,
|
||||
isGuideVlogReturn,
|
||||
isGuideVlogStage1,
|
||||
isGuideVlogStage2,
|
||||
isGuideVlogStage3,
|
||||
isVlogUnlock,
|
||||
vlogUnlockProgress
|
||||
vlogUnlockProgress,
|
||||
vlogMenuVisible
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,3 +56,12 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
/* 让背景色变化的过渡时间变得无限长,从而锁定透明状态 */
|
||||
transition: background-color 99999s ease-in-out 0s;
|
||||
/* 记得设置文字颜色,否则可能会变白看不见 */
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
declare const $: {
|
||||
extend(value: Record<string, unknown>): void
|
||||
toJSON(value: unknown): string
|
||||
parseJSON(value: string): unknown
|
||||
getUrlVars?: () => string[] & Record<string, string | undefined>
|
||||
getUrlVar?: (name: string) => string | undefined
|
||||
}
|
||||
declare const iscallyb: boolean
|
||||
declare const emykey: string
|
||||
declare function videoAjax(): VideoAjax
|
||||
declare function getFutureDa(days: number): string
|
||||
declare function getCurrentDa(str: string): void
|
||||
declare function formatDateFromMilliseconds(date: Date): string
|
||||
|
||||
interface UserDefineParam {
|
||||
lessonId: string
|
||||
key?: string | number
|
||||
order?: string | number
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface VideoAjaxResponseItem {
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface VideoAjaxResponse {
|
||||
code: number
|
||||
data: {
|
||||
value?: VideoAjaxResponseItem[] | string
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoAjax {
|
||||
setHost(host: string): void
|
||||
addUserDefine(param: UserDefineParam): void
|
||||
getUserDefineInfoByUser(
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
): void
|
||||
getUserDefineInfo(
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
): void
|
||||
finishCourse(param: { order: number; co: string; process: number }): void
|
||||
}
|
||||
|
||||
$.extend({
|
||||
getUrlVars(): string[] & Record<string, string | undefined> {
|
||||
const vars = [] as unknown as string[] & Record<string, string | undefined>
|
||||
const queryIndex = window.location.href.indexOf('?')
|
||||
if (queryIndex === -1) return vars
|
||||
|
||||
const hashes = window.location.href
|
||||
.slice(queryIndex + 1)
|
||||
.split('&')
|
||||
.filter(Boolean)
|
||||
|
||||
for (const item of hashes) {
|
||||
const [keyRaw = '', valueRaw = ''] = item.split('=')
|
||||
const key = decodeURIComponent(keyRaw)
|
||||
const value = decodeURIComponent(valueRaw)
|
||||
vars.push(key)
|
||||
vars[key] = value
|
||||
}
|
||||
|
||||
return vars
|
||||
},
|
||||
getUrlVar(name: string): string | undefined {
|
||||
return $.getUrlVars?.()[name]
|
||||
}
|
||||
})
|
||||
|
||||
const LESSON_PREFIX = 'dsp'
|
||||
|
||||
const videoajax: VideoAjax = videoAjax()
|
||||
videoajax.setHost('/')
|
||||
|
||||
function normalizeLessonId(lessonId: string | number): string {
|
||||
const raw = String(lessonId)
|
||||
return raw.startsWith(`${LESSON_PREFIX}_`) ? raw : `${LESSON_PREFIX}_${raw}`
|
||||
}
|
||||
|
||||
function setLtDefine(key: string | number, value: string): void {
|
||||
if (iscallyb) {
|
||||
setUserDefine(1, key, value)
|
||||
} else {
|
||||
sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
let mycstr9 = ''
|
||||
|
||||
async function getLtDefine(key: string | number): Promise<string> {
|
||||
if (iscallyb) {
|
||||
if (mycstr9 === '') {
|
||||
const all = await getUsersDefine(1, emykey)
|
||||
if (all === undefined || all === null || all === 'null' || all === '') {
|
||||
mycstr9 = getFutureDa(10)
|
||||
setLtDefine(emykey, mycstr9)
|
||||
} else {
|
||||
mycstr9 = all as string
|
||||
}
|
||||
}
|
||||
|
||||
const str = mycstr9 === undefined || mycstr9 === null || mycstr9 === 'null' ? '' : mycstr9
|
||||
getCurrentDa(str)
|
||||
const objitem = await getUserDefine(1, key)
|
||||
return objitem === undefined || objitem === null || objitem === 'null' ? '' : objitem
|
||||
}
|
||||
|
||||
const itemvalue = sessionStorage.getItem(`${LESSON_PREFIX}_${key}`)
|
||||
return itemvalue === undefined || itemvalue === null || itemvalue === 'null' ? '' : itemvalue
|
||||
}
|
||||
|
||||
export function setCourseNotepad(collect: string[]): void {
|
||||
setLtDefine(10002, $.toJSON(collect))
|
||||
}
|
||||
|
||||
export async function getCourseNotepad(): Promise<string[]> {
|
||||
const raw = await getLtDefine(10002)
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return ['', '', '', '', '', '', '', '', '']
|
||||
}
|
||||
|
||||
return $.parseJSON(raw) as string[]
|
||||
}
|
||||
|
||||
export function setUserDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number,
|
||||
value: string
|
||||
): void {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: normalizeLessonId(lessonId),
|
||||
key,
|
||||
content: value
|
||||
}
|
||||
videoajax.addUserDefine(param)
|
||||
}
|
||||
|
||||
function getUserDefineAsyncInternal(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string> {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: normalizeLessonId(lessonId),
|
||||
order: key
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let content = ''
|
||||
videoajax.getUserDefineInfoByUser(param, res => {
|
||||
console.log(res)
|
||||
if (res && res.code === 200) {
|
||||
const json = (res.data.value || []) as VideoAjaxResponseItem[]
|
||||
content = json[0]?.content || ''
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
resolve(content)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string> {
|
||||
return getUserDefineAsyncInternal(lessonId, key)
|
||||
}
|
||||
|
||||
function getUsersDefineAsyncInternal(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string | VideoAjaxResponseItem[]> {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: normalizeLessonId(lessonId),
|
||||
order: key
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let content: string | VideoAjaxResponseItem[] = ''
|
||||
videoajax.getUserDefineInfo(param, data => {
|
||||
if (data && data.code === 200) {
|
||||
const json = (data.data.value || []) as VideoAjaxResponseItem[]
|
||||
if (json.length > 1) {
|
||||
if (key === emykey) {
|
||||
const mdate = new Date(
|
||||
Math.max.apply(
|
||||
null,
|
||||
json.map(item => new Date(item.content || '').getTime())
|
||||
)
|
||||
)
|
||||
content = formatDateFromMilliseconds(mdate)
|
||||
} else {
|
||||
content = json
|
||||
}
|
||||
} else {
|
||||
content = json[0]?.content || ''
|
||||
}
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
resolve(content)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUsersDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string | VideoAjaxResponseItem[]> {
|
||||
return getUsersDefineAsyncInternal(lessonId, key)
|
||||
}
|
||||
|
||||
export async function setCourseTimeProgress(order: number): Promise<void> {
|
||||
const courseid = useUserStore().courseId
|
||||
console.info(`courseid:${courseid} order:${order}`)
|
||||
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') {
|
||||
if (order > 0) {
|
||||
videoajax.finishCourse({ order, co: courseid, process: 100 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { Video, QuestionQuiz, type Question } from '@/components'
|
||||
import { getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
// 视频导入
|
||||
// 手机摄像头介绍
|
||||
import video1 from '/video/new/xinshou_shoujishexiangtoujieshao.mp4'
|
||||
@@ -146,7 +146,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
|
||||
return [
|
||||
{
|
||||
id: 3,
|
||||
title: '互动题 4',
|
||||
title: '',
|
||||
description: '(单选)你觉得下面哪句话,更接近“新手拍视频的正确思路”?',
|
||||
options: [
|
||||
{
|
||||
@@ -223,6 +223,12 @@ const handleStepNavigationClose = () => {
|
||||
showStepNavigation.value = false
|
||||
}
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 答题完成后的处理
|
||||
const handleQuestionsComplete = () => {
|
||||
showQuiz.value = false
|
||||
@@ -291,12 +297,16 @@ const handleQuestionsComplete = () => {
|
||||
@close="handleStepNavigationClose"
|
||||
/>
|
||||
|
||||
<GuideSteps :visible="true" position-offset-y="85%">
|
||||
<GuideStepsItem>欢迎来到基础拍摄训练区!</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
>接下来,我会带你依次认识手机摄像头、看懂景别、用好慢动作与延时技法。</GuideStepsItem
|
||||
>
|
||||
<GuideStepsItem>还会教你如何拍出更稳的微距和更亮的夜景。</GuideStepsItem>
|
||||
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_018.MP3')">
|
||||
欢迎来到基础拍摄训练区!
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_019.MP3')">
|
||||
我会带你依次认识手机摄像头、看懂景别、用好慢动作与延时。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_020.MP3')">
|
||||
还会教你拍出更稳的微距和更亮的夜景。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { Video } from '@/components'
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
// 运镜初识 - 暂时用运镜的基本方式代替
|
||||
import video1 from '/video/new/xinshou_yunjingdejibenfangshi.mp4'
|
||||
import video2 from '/video/new/xinshou_zhengquedeshoujipaishe.mp4'
|
||||
@@ -20,6 +20,23 @@ const props = defineProps<{
|
||||
const playing = ref(false)
|
||||
const status = ref(false)
|
||||
|
||||
const showCameraMovementGuide = ref(false)
|
||||
const cameraMovementGuideShown = ref(false)
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleCameraMovementGuideComplete = () => {
|
||||
showCameraMovementGuide.value = false
|
||||
cameraMovementGuideShown.value = true
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 步骤导航数据
|
||||
const navigationSteps = ref([
|
||||
// {
|
||||
@@ -60,6 +77,14 @@ const handleVideoChange = () => {
|
||||
status.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 切到“运镜的基本方式”(index 1)时,先暂停并展示引导对话
|
||||
if (currentVideoIndex.value === 1 && !cameraMovementGuideShown.value) {
|
||||
playing.value = false
|
||||
showCameraMovementGuide.value = true
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
// 使用受控播放来触发下一个视频自动播放
|
||||
playing.value = true
|
||||
@@ -145,9 +170,26 @@ const handleStepNavigationClose = () => {
|
||||
@close="handleStepNavigationClose"
|
||||
/>
|
||||
|
||||
<GuideSteps :visible="true" position-offset-y="85%">
|
||||
<GuideStepsItem>想拍出好的视频效果,画面稳定性是最基本的要求。</GuideStepsItem>
|
||||
<GuideStepsItem>接下来,我们来学习正确的持机方式和拍摄姿势。</GuideStepsItem>
|
||||
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_014.MP3')">
|
||||
想拍出好的视频效果,画面稳定性是最基本的要求。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_015.MP3')">
|
||||
接下来,我们来学习正确的持机方式和拍摄姿势。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
<GuideSteps
|
||||
v-if="showCameraMovementGuide"
|
||||
position-offset-y="85%"
|
||||
@complete="handleCameraMovementGuideComplete"
|
||||
>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_016.MP3')">
|
||||
当我们将画面稳定之后,就来到了镜头语言。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_017.MP3')">
|
||||
同一个场景,用不同运镜,表达就不一样。跟着我,去了解5种基础运镜吧~
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { Video } from '@/components'
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import video1 from '/video/new/xsdh_rhxzgt_batch.mp4'
|
||||
import video2 from '/video/new/xsdh_jzgtf_batch.mp4'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
@@ -75,6 +75,8 @@ const onEnded = () => {
|
||||
const handleStepClick = (index: number) => {
|
||||
currentVideoIndex.value = index
|
||||
currentNavigationStep.value = index
|
||||
// 先重置播放状态,确保 v-model 触发 watcher
|
||||
playing.value = false
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
@@ -84,6 +86,12 @@ const handleStepNavigationClose = () => {
|
||||
showStepNavigation.value = false
|
||||
}
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {})
|
||||
</script>
|
||||
|
||||
@@ -145,15 +153,23 @@ onMounted(() => {})
|
||||
:highlight-selector="guideStepIndex === 3 ? '.step-navigation-btn' : ''"
|
||||
position-offset-y="85%"
|
||||
@step-change="(_: any, index: number) => (guideStepIndex = index)"
|
||||
@complete="handleIntroGuideComplete"
|
||||
>
|
||||
<GuideStepsItem>你有没有过这样的感觉?画面里什么都有,却不知道重点在哪里?</GuideStepsItem>
|
||||
<GuideStepsItem>很多时候,不是内容不够好,而是需要有构图思维。</GuideStepsItem>
|
||||
<GuideStepsItem>所谓构图,就是在画面中对元素进行选择、取舍和安排</GuideStepsItem>
|
||||
<GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_005.MP3')">
|
||||
你有没有过这样的感觉?画面里什么都有,却不知道重点在哪里?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_006.MP3')">
|
||||
很多时候,不是内容不够好,而是需要有构图思维。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_007.MP3')">
|
||||
所谓构图,就是在画面中对元素进行选择、取舍和安排
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_008.MP3')">
|
||||
<div class="flex flex-wrap items-center justify-start">
|
||||
<div>一起来看看吧~</div>
|
||||
<div class="text-yellow-400">
|
||||
点击<button
|
||||
点击
|
||||
<button
|
||||
class="w-10 h-10 bg-blue-500/80 hover:bg-blue-500 rounded-full inline-flex items-center justify-center text-white transition-all duration-200"
|
||||
title="步骤导航"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { Video, QuestionQuiz, type Question } from '@/components'
|
||||
import { getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { GuideSteps } from '@/components/Guide'
|
||||
// 光初识 - 暂时用光的基础认知代替
|
||||
import video1 from '/video/new/xinshou_guangdejichurenzhi.mp4'
|
||||
@@ -21,12 +21,25 @@ const showQuiz = ref(false) // 控制是否显示答题窗口
|
||||
|
||||
const isGuide = ref(true)
|
||||
const currentGuideStep = ref(0)
|
||||
const showGoldenHourGuide = ref(false)
|
||||
const goldenHourGuideShown = ref(false)
|
||||
const handleSkip = (index: number) => {
|
||||
currentGuideStep.value = index
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
isGuide.value = false
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleGoldenHourGuideComplete = () => {
|
||||
showGoldenHourGuide.value = false
|
||||
goldenHourGuideShown.value = true
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 步骤导航数据
|
||||
@@ -103,6 +116,14 @@ const handleVideoChange = () => {
|
||||
status.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 切到“拍摄的黄金时间”(index 2)时,先暂停并展示引导对话
|
||||
if (currentVideoIndex.value === 2 && !goldenHourGuideShown.value) {
|
||||
playing.value = false
|
||||
showGoldenHourGuide.value = true
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
// 使用受控播放来触发下一个视频自动播放
|
||||
playing.value = true
|
||||
@@ -215,7 +236,28 @@ const handleQuestionsComplete = () => {
|
||||
<!-- 引导与高亮 -->
|
||||
|
||||
<GuideSteps v-if="isGuide" position-offset-y="85%" @complete="handleComplete">
|
||||
<GuideStepsItem>光是摄影的灵魂,摄影高手一定是用光的高手</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_009.MP3')">
|
||||
光是摄影的灵魂,摄影高手一定是用光的高手
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_010.MP3')">
|
||||
现在, 让我们一起去认识光、读懂光。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
<GuideSteps
|
||||
v-if="showGoldenHourGuide"
|
||||
position-offset-y="85%"
|
||||
@complete="handleGoldenHourGuideComplete"
|
||||
>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_011.MP3')">
|
||||
同一个地点,不同季节拍出来的感觉为什么完全不一样?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_012.MP3')">
|
||||
同一条街,早上、下午、傍晚各拍一张,你会发现世界仿佛在悄悄换装。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_013.MP3')">
|
||||
接下来,我们一起揭开四季与时间的秘密,找一找一年365天中最容易出片的时刻。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { Video } from '@/components'
|
||||
import { getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import video from '/video/new/xsdh_CapCut_Tutorial.mp4'
|
||||
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -18,55 +17,71 @@ const rawSteps = [
|
||||
{
|
||||
id: 'step2',
|
||||
label: '剪映基础操作',
|
||||
content: '认识剪映剪辑界面',
|
||||
content: '初识剪映专业版界面和功能',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: '剪映基础操作',
|
||||
content: '素材导入及调整',
|
||||
content: '时间线的用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
label: '剪映基础操作',
|
||||
content: '比例及背景设置',
|
||||
content: '转场特效贴纸功能详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step5',
|
||||
label: '剪映基础操作',
|
||||
content: '内容裁剪及组合',
|
||||
content: '如何给视频快速上字幕',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step6',
|
||||
label: '剪映基础操作',
|
||||
content: '音频添加及调整',
|
||||
content: '音频的功能应用和处理',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step7',
|
||||
label: '剪映基础操作',
|
||||
content: '添加字幕和贴纸',
|
||||
content: '关键帧功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step8',
|
||||
label: '剪映基础操作',
|
||||
content: '添加特效和滤镜',
|
||||
content: '蒙版功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step9',
|
||||
label: '剪映基础操作',
|
||||
content: '添加转场和动画',
|
||||
content: '混合模式功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step10',
|
||||
label: '剪映基础操作',
|
||||
content: '封面和视频导出',
|
||||
content: '曲线变速功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step11',
|
||||
label: '剪映基础操作',
|
||||
content: '如何给画面调色',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
}
|
||||
]
|
||||
@@ -78,13 +93,13 @@ const showStepNavigation = ref(false)
|
||||
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
|
||||
|
||||
// 视频列表
|
||||
const videoList = [video, video, video, video, video, video, video, video, video]
|
||||
const currentVideoIndex = ref(0)
|
||||
|
||||
//按顺序播放
|
||||
const currentVideo = computed(() => videoList[currentVideoIndex.value])
|
||||
const currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl)
|
||||
|
||||
const handleVideoChange = () => {
|
||||
if (currentVideoIndex.value < videoList.length - 1) {
|
||||
if (currentVideoIndex.value < rawSteps.length - 1) {
|
||||
currentVideoIndex.value++
|
||||
currentNavigationStep.value = currentVideoIndex.value
|
||||
} else {
|
||||
@@ -119,6 +134,12 @@ const handleStepClick = (index: number) => {
|
||||
const handleStepNavigationClose = () => {
|
||||
showStepNavigation.value = false
|
||||
}
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -174,6 +195,21 @@ const handleStepNavigationClose = () => {
|
||||
@step-click="handleStepClick"
|
||||
@close="handleStepNavigationClose"
|
||||
/>
|
||||
|
||||
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_021.MP3')">
|
||||
拍摄工作完成以后还需要剪辑来赋予节奏与表达。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_022.MP3')">
|
||||
接下来,我们一起进入剪映专业版,开启剪映软件的操作学习。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_023.MP3')">
|
||||
从界面认识到时间线操作,从字幕添加到音频处理,再到调色与特效制作,你将逐步掌握剪映软件的基础技能。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_024.MP3')">
|
||||
准备好了吗?让零散的素材在你的手中拼接成精彩作品!
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ const back = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/调色-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/调色-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { Video, GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
|
||||
import { useFilmStore } from '@/stores'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const film = useFilmStore()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
@@ -25,10 +22,6 @@ const showGuide = ref(true)
|
||||
const handleComplete = () => {
|
||||
showGuide.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
film.setFilmUnlockProgress(4)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -21,10 +21,10 @@ const back = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/音效-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/音效-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getStaticImage } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getBackgroundImage } from '@/utils'
|
||||
import { useFilmStore } from '@/stores'
|
||||
defineOptions({ name: 'FilmMenu' })
|
||||
|
||||
/**
|
||||
@@ -71,12 +72,12 @@ const menuItems = ref([
|
||||
parentValue: 2,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
label: '素材管理',
|
||||
routeName: 'Film素材管理',
|
||||
parentValue: 3,
|
||||
disabled: true
|
||||
},
|
||||
// {
|
||||
// label: '素材管理',
|
||||
// routeName: 'Film素材管理',
|
||||
// parentValue: 3,
|
||||
// disabled: true
|
||||
// },
|
||||
{
|
||||
label: '剪辑',
|
||||
routeName: 'FilmEditing',
|
||||
@@ -109,6 +110,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const film = useFilmStore()
|
||||
|
||||
const isViewed = (routeName: string) => {
|
||||
return film.viewed?.[routeName] === true
|
||||
}
|
||||
|
||||
const currentParentValue = computed<number>(() => {
|
||||
return props.currentStep
|
||||
@@ -131,9 +137,9 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
|
||||
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep">
|
||||
<!-- 分类名称 -->
|
||||
<div class="absolute top-3 left-24 flex items-center space-x-2">
|
||||
<span class="text-4xl text-[#fbbf24] font-black">{{
|
||||
category[currentParentValue - 1].label
|
||||
}}</span>
|
||||
<span class="text-4xl text-[#fbbf24] font-black">
|
||||
{{ category[currentParentValue - 1].label }}
|
||||
</span>
|
||||
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" />
|
||||
</div>
|
||||
<!-- 背景图片 -->
|
||||
@@ -157,7 +163,18 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
:style="{ backgroundImage: getBackgroundImage('components/menu-hover.png') }"
|
||||
></div>
|
||||
<span :class="[!item.disabled ? 'menu-glow-text' : '']">{{ item.label }}</span>
|
||||
<span class="flex items-center gap-2" :class="[!item.disabled ? 'menu-glow-text' : '']">
|
||||
<span>{{ item.label }}</span>
|
||||
<img
|
||||
class="w-10"
|
||||
:src="
|
||||
isViewed(item.routeName)
|
||||
? '/src/assets/images/midterm/完成.png'
|
||||
: '/src/assets/images/midterm/未完成.png'
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -32,10 +32,10 @@ const handleOpenImages = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/创意构想-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/创意构想-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -17,9 +17,7 @@ const status = ref(true)
|
||||
|
||||
const currentStep = computed(() => film.FilmUnlockProgress)
|
||||
|
||||
const progress = computed(() => {
|
||||
return Math.round((currentStep.value / 4) * 100)
|
||||
})
|
||||
const progress = computed(() => film.progressPercent)
|
||||
|
||||
const guideIndex = ref<number>(0)
|
||||
const showTaskGuide = computed(
|
||||
@@ -167,7 +165,7 @@ watch(
|
||||
<ProgressBar
|
||||
module-name="film"
|
||||
:current-step="currentStep"
|
||||
:completed-step="2"
|
||||
:completed-step="film.completedStep"
|
||||
@step-change="handleStepChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -177,6 +175,7 @@ watch(
|
||||
<ProgressBar
|
||||
module-name="film"
|
||||
:current-step="currentStep"
|
||||
:completed-step="film.completedStep"
|
||||
@complete="handleComplete"
|
||||
@step-change="handleStepChange"
|
||||
/>
|
||||
@@ -191,11 +190,9 @@ watch(
|
||||
>
|
||||
<GuideStepsItem>
|
||||
<div class="flex items-center justify-start">
|
||||
请点击<img
|
||||
class="w-20"
|
||||
:src="getStaticImage('film/icon')"
|
||||
alt=""
|
||||
/>进入任务取景框开始学习!
|
||||
请点击
|
||||
<img class="w-20" :src="getStaticImage('film/icon')" alt="" />
|
||||
进入任务取景框开始学习!
|
||||
</div>
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="tsx" setup>
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { GuideSteps } from '@/components/Guide'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
@@ -28,42 +28,100 @@ const steps = ref([
|
||||
highlightSelector: '.plate'
|
||||
}
|
||||
])
|
||||
const stepIndex = ref<number>(0)
|
||||
const hiddenbar = ref(false)
|
||||
const currentIndex = ref(0) // 当前显示的图片索引
|
||||
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
|
||||
|
||||
const currentHighlight = computed(() => {
|
||||
return steps.value[stepIndex.value ?? 0]?.highlightSelector
|
||||
})
|
||||
type EquipmentCategoryId = 'camera' | 'slider' | 'tripod' | 'stabilizer' | 'light' | 'filter'
|
||||
interface EquipmentProduct {
|
||||
img: string
|
||||
}
|
||||
interface EquipmentCategory {
|
||||
id: EquipmentCategoryId
|
||||
name: string
|
||||
disabled: boolean
|
||||
titleImg: string
|
||||
contentImg: string
|
||||
products: EquipmentProduct[]
|
||||
}
|
||||
|
||||
// 菜单项数据
|
||||
const menuItems = ref([
|
||||
{ id: 'phone', name: '相机/摄像机/微单', disabled: false },
|
||||
{ id: 'phone-stand', name: '滑轨', disabled: true },
|
||||
{ id: 'bluetooth-remote', name: '三脚架', disabled: true },
|
||||
{ id: 'stabilizer', name: '稳定器', disabled: true },
|
||||
{ id: 'props', name: '灯光', disabled: true },
|
||||
{ id: 'phone-lens', name: '滤镜', disabled: true }
|
||||
// 菜单项数据 + 对应资源(资源都在 equipment)
|
||||
const categories = ref<EquipmentCategory[]>([
|
||||
{
|
||||
id: 'camera',
|
||||
name: '相机/摄像机/微单',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/相机标题.png'),
|
||||
contentImg: getStaticImage('equipment/相机内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/相机产品.png') },
|
||||
{ img: getStaticImage('equipment/摄像机产品.png') },
|
||||
{ img: getStaticImage('equipment/微单产品.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'slider',
|
||||
name: '滑轨',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/滑轨标题.png'),
|
||||
contentImg: getStaticImage('equipment/滑轨内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/便携滑轨产品.png') },
|
||||
{ img: getStaticImage('equipment/脚架式长滑轨产品.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tripod',
|
||||
name: '三脚架',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/三脚架标题.png'),
|
||||
contentImg: getStaticImage('equipment/三脚架内容.png'),
|
||||
products: [{ img: getStaticImage('equipment/三角架产品.png') }]
|
||||
},
|
||||
{
|
||||
id: 'stabilizer',
|
||||
name: '稳定器',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/稳定器标题.png'),
|
||||
contentImg: getStaticImage('equipment/稳定器内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/手机稳定器.png') },
|
||||
{ img: getStaticImage('equipment/相机稳定器.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'light',
|
||||
name: '灯光',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/灯标题.png'),
|
||||
contentImg: getStaticImage('equipment/灯内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/led补光灯.png') },
|
||||
{ img: getStaticImage('equipment/环形灯产品.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
name: '滤镜',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/滤镜标题.png'),
|
||||
contentImg: getStaticImage('equipment/滤镜内容.png'),
|
||||
products: [{ img: getStaticImage('equipment/滤镜产品.png') }]
|
||||
}
|
||||
])
|
||||
|
||||
const phoneList = ref([
|
||||
{ phoneImg: getStaticImage('film/equipment/相机产品.png') },
|
||||
{ phoneImg: getStaticImage('film/equipment/摄像机产品.png') },
|
||||
{ phoneImg: getStaticImage('film/equipment/微单产品.png') }
|
||||
])
|
||||
// 有资源的都解锁:如果某个类目没有任何产品资源,则自动禁用
|
||||
categories.value = categories.value.map(c => ({
|
||||
...c,
|
||||
disabled: (c.products?.length ?? 0) === 0
|
||||
}))
|
||||
|
||||
const platetitleList = ref([
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') },
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') },
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') }
|
||||
])
|
||||
|
||||
const platetextList = ref([
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') },
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') },
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') }
|
||||
])
|
||||
const currentCategory = computed(
|
||||
() => categories.value[selectedMenuIndex.value] ?? categories.value[0]
|
||||
)
|
||||
const currentProducts = computed(() => currentCategory.value?.products ?? [])
|
||||
const currentTitleImg = computed(() => currentCategory.value?.titleImg ?? '')
|
||||
const currentContentImg = computed(() => currentCategory.value?.contentImg ?? '')
|
||||
const hiddenbarclick = () => {
|
||||
hiddenbar.value = !hiddenbar.value
|
||||
}
|
||||
@@ -71,15 +129,15 @@ const hiddenbarclick = () => {
|
||||
// 选择菜单项
|
||||
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => {
|
||||
if (item.disabled) {
|
||||
console.log(`菜单项 "${item.name}" 已被禁用`)
|
||||
return
|
||||
}
|
||||
selectedMenuIndex.value = index
|
||||
currentIndex.value = 0
|
||||
}
|
||||
|
||||
// 下一张图片
|
||||
const nextPhone = () => {
|
||||
if (currentIndex.value < phoneList.value.length - 1) {
|
||||
if (currentIndex.value < currentProducts.value.length - 1) {
|
||||
currentIndex.value++
|
||||
}
|
||||
}
|
||||
@@ -90,13 +148,9 @@ const prevPhone = () => {
|
||||
currentIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepChange = (_: any, index: number) => {
|
||||
stepIndex.value = index
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="equipment-index">
|
||||
<div class="equipment-index overflow-hidden">
|
||||
<!-- 背景图片 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat equipment-bg"
|
||||
@@ -109,7 +163,7 @@ const handleStepChange = (_: any, index: number) => {
|
||||
<div class="equipment-item" :hidden="hiddenbar">
|
||||
<div class="menu-list">
|
||||
<div
|
||||
v-for="(item, index) in menuItems"
|
||||
v-for="(item, index) in categories"
|
||||
:key="item.id"
|
||||
class="menu-item"
|
||||
:class="{
|
||||
@@ -155,12 +209,12 @@ const handleStepChange = (_: any, index: number) => {
|
||||
<img src="@/assets/images/equipment/按钮-左滑.png" alt="" />
|
||||
</div>
|
||||
<div class="equipment-phoneImg">
|
||||
<img :src="phoneList[currentIndex].phoneImg" alt="" />
|
||||
<img :src="currentProducts[currentIndex]?.img" alt="" />
|
||||
</div>
|
||||
<div
|
||||
class="equipment-rightbutton"
|
||||
@click="nextPhone"
|
||||
:style="{ visibility: currentIndex < phoneList.length - 1 ? 'visible' : 'hidden' }"
|
||||
:style="{ visibility: currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<img src="@/assets/images/equipment/按钮-右滑.png" alt="" />
|
||||
</div>
|
||||
@@ -171,11 +225,11 @@ const handleStepChange = (_: any, index: number) => {
|
||||
</div>
|
||||
|
||||
<div class="title">
|
||||
<img :src="platetitleList[currentIndex].title" alt="" />
|
||||
<img :src="currentTitleImg" alt="" />
|
||||
</div>
|
||||
<div class="plate-content">
|
||||
<div class="text">
|
||||
<img :src="platetextList[currentIndex].text" alt="" />
|
||||
<img :src="currentContentImg" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,10 +247,8 @@ const handleStepChange = (_: any, index: number) => {
|
||||
<GuideSteps
|
||||
:visible="true"
|
||||
:steps="steps"
|
||||
:highlight-selector="currentHighlight"
|
||||
:highlight-padding="12"
|
||||
:highlight-radius="20"
|
||||
@step-change="handleStepChange"
|
||||
position-offset-y="85%"
|
||||
/>
|
||||
</div>
|
||||
|
||||