feat: 完善访谈与vlog模块页面,重组设备与图片公共资源,新增音频管理与一班对接工具

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-05 15:41:41 +08:00
1 parent c4c9be9d35
commit fc9633f371
159 files changed
+4773 -2485

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ dist-ssr
# 忽略所有 .mp4 文件
*.mp4
public/video/*
public/audio/*
# Editor directories and files
.vscode/*
+4 -1
View File
@@ -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
}
+6 -6
View File
@@ -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
}
+40 -2
View File
@@ -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': [
+2
View File
@@ -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>
+3 -2
View File
@@ -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",
+544 -51
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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
+407
View File
@@ -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));
+1
View File
@@ -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
+33 -4
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

+5
View File
@@ -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>
+34 -21
View File
@@ -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
+35 -2
View File
@@ -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<{
+111 -304
View File
@@ -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>
+22
View File
@@ -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类型
+25 -4
View File
@@ -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)
}
+28 -5
View File
@@ -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"
>
+87 -2
View File
@@ -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;
+95 -2
View File
@@ -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 }
+145
View File
@@ -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
}
}
+41
View File
@@ -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
}
+37 -2
View File
@@ -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
+56
View File
@@ -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',
+81 -49
View File
@@ -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',
+75
View File
@@ -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
+107 -2
View File
@@ -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,
+111 -2
View File
@@ -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
+10 -7
View File
@@ -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
}
},
{
+172 -5
View File
@@ -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
}
},
{
+9
View File
@@ -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;
}
+226
View File
@@ -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>
+22 -6
View File
@@ -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="步骤导航"
>
+44 -2
View File
@@ -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">
+27 -10
View File
@@ -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>
+2 -2
View File
@@ -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">
+6 -9
View File
@@ -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>
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more