feat: update residence learning flows

Commit non-media application and asset changes while leaving audio, video, and environment files out of version control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-13 10:15:26 +08:00
1 parent 68ba22a33f
commit 6d40e19346
184 files changed
+7661 -2201

No files matched your search

+6 -1
View File
@@ -53,7 +53,12 @@
"Bash(awk 'NR>=216 && NR<=380' src/config/puzzleData.ts)", "Bash(awk 'NR>=216 && NR<=380' src/config/puzzleData.ts)",
"Bash(awk 'NR>=380 && NR<=540' src/config/puzzleData.ts)", "Bash(awk 'NR>=380 && NR<=540' src/config/puzzleData.ts)",
"Bash(awk '/^worktree / {wt=$2} /^branch / {br=$2; if \\(wt ~ /\\\\.claude\\\\/worktrees\\\\/agent-/\\) print wt\"\\\\t\"br}')", "Bash(awk '/^worktree / {wt=$2} /^branch / {br=$2; if \\(wt ~ /\\\\.claude\\\\/worktrees\\\\/agent-/\\) print wt\"\\\\t\"br}')",
"Read(//tmp/**)" "Read(//tmp/**)",
"Bash(git check-ignore *)",
"Bash(git config *)",
"WebSearch",
"Bash(grep -v \"^--$\")",
"Bash(grep -E \"^\\\\S+.*>.*</GuideStepsItem>$\")"
], ],
"deny": [ "deny": [
"Read(public/**)", "Read(public/**)",
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
+4 -2
View File
@@ -8,8 +8,10 @@
"bun": ">=1.0.0" "bun": ">=1.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --mode development",
"build": "tsc && vite build", "dev:test": "vite --mode test",
"build": "tsc && vite build --mode production",
"build:test": "tsc && vite build --mode test",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
+4901 -1
View File
File diff suppressed because it is too large. Load diff
+431 -381
View File
@@ -8,400 +8,450 @@
* http://en.wikipedia.org/wiki/Base64 * http://en.wikipedia.org/wiki/Base64
*/ */
(function(global) { ;(function (global) {
'use strict'; 'use strict'
// existing version for noConflict() // existing version for noConflict()
var _Base64 = global.Base64; var _Base64 = global.Base64
var version = "2.1.5"; var version = '2.1.5'
// if node.js, we use Buffer // if node.js, we use Buffer
var buffer; var buffer
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
buffer = require('buffer').Buffer; 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))
)
} }
// constants }
var b64chars var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g
= 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; var utob = function (u) {
var b64tab = function(bin) { return u.replace(re_utob, cb_utob)
var t = {}; }
for (var i = 0, l = bin.length; i < l; i++) t[bin.charAt(i)] = i; var cb_encode = function (ccc) {
return t; var padlen = [0, 2, 1][ccc.length % 3],
}(b64chars); ord =
var fromCharCode = String.fromCharCode; (ccc.charCodeAt(0) << 16) |
// encoder stuff ((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8) |
var cb_utob = function(c) { (ccc.length > 2 ? ccc.charCodeAt(2) : 0),
if (c.length < 2) { chars = [
var cc = c.charCodeAt(0); b64chars.charAt(ord >>> 18),
return cc < 0x80 ? c b64chars.charAt((ord >>> 12) & 63),
: cc < 0x800 ? (fromCharCode(0xc0 | (cc >>> 6)) padlen >= 2 ? '=' : b64chars.charAt((ord >>> 6) & 63),
+ fromCharCode(0x80 | (cc & 0x3f))) padlen >= 1 ? '=' : b64chars.charAt(ord & 63),
: (fromCharCode(0xe0 | ((cc >>> 12) & 0x0f)) ]
+ fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) return chars.join('')
+ fromCharCode(0x80 | ( cc & 0x3f))); }
} else { var btoa = global.btoa
var cc = 0x10000 ? function (b) {
+ (c.charCodeAt(0) - 0xD800) * 0x400 return global.btoa(b)
+ (c.charCodeAt(1) - 0xDC00); }
return (fromCharCode(0xf0 | ((cc >>> 18) & 0x07)) : function (b) {
+ fromCharCode(0x80 | ((cc >>> 12) & 0x3f)) return b.replace(/[\s\S]{1,3}/g, cb_encode)
+ fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) }
+ fromCharCode(0x80 | ( cc & 0x3f))); var _encode = buffer
} ? function (u) {
}; return new buffer(u).toString('base64')
var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g; }
var utob = function(u) { : function (u) {
return u.replace(re_utob, cb_utob); return btoa(utob(u))
}; }
var cb_encode = function(ccc) { var encode = function (u, urisafe) {
var padlen = [0, 2, 1][ccc.length % 3], return !urisafe
ord = ccc.charCodeAt(0) << 16 ? _encode(u)
| ((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8) : _encode(u)
| ((ccc.length > 2 ? ccc.charCodeAt(2) : 0)), .replace(/[+\/]/g, function (m0) {
chars = [ return m0 == '+' ? '-' : '_'
b64chars.charAt( ord >>> 18), })
b64chars.charAt((ord >>> 12) & 63), .replace(/=/g, '')
padlen >= 2 ? '=' : b64chars.charAt((ord >>> 6) & 63), }
padlen >= 1 ? '=' : b64chars.charAt(ord & 63) var encodeURI = function (u) {
]; return encode(u, true)
return chars.join(''); }
}; // decoder stuff
var btoa = global.btoa ? function(b) { var re_btou = new RegExp(
return global.btoa(b); ['[\xC0-\xDF][\x80-\xBF]', '[\xE0-\xEF][\x80-\xBF]{2}', '[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),
} : function(b) { 'g'
return b.replace(/[\s\S]{1,3}/g, cb_encode); )
}; var cb_btou = function (cccc) {
var _encode = buffer switch (cccc.length) {
? function (u) { return (new buffer(u)).toString('base64') } case 4:
: function (u) { return btoa(utob(u)) } var cp =
; ((0x07 & cccc.charCodeAt(0)) << 18) |
var encode = function(u, urisafe) { ((0x3f & cccc.charCodeAt(1)) << 12) |
return !urisafe ((0x3f & cccc.charCodeAt(2)) << 6) |
? _encode(u) (0x3f & cccc.charCodeAt(3)),
: _encode(u).replace(/[+\/]/g, function(m0) { offset = cp - 0x10000
return m0 == '+' ? '-' : '_'; return fromCharCode((offset >>> 10) + 0xd800) + fromCharCode((offset & 0x3ff) + 0xdc00)
}).replace(/=/g, ''); case 3:
}; return fromCharCode(
var encodeURI = function(u) { return encode(u, true) }; ((0x0f & cccc.charCodeAt(0)) << 12) |
// decoder stuff ((0x3f & cccc.charCodeAt(1)) << 6) |
var re_btou = new RegExp([ (0x3f & cccc.charCodeAt(2))
'[\xC0-\xDF][\x80-\xBF]', )
'[\xE0-\xEF][\x80-\xBF]{2}', default:
'[\xF0-\xF7][\x80-\xBF]{3}' return fromCharCode(((0x1f & cccc.charCodeAt(0)) << 6) | (0x3f & cccc.charCodeAt(1)))
].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); 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']) { if (this['Meteor']) {
Base64 = global.Base64; // for normal export in Meteor.js Base64 = global.Base64 // for normal export in Meteor.js
} }
; (function (root) { ;(function (root) {
// body...
var videoajax = function (argument) {
// body... // body...
var host = '/'
var videoajax = function (argument) { this._setHost = function (hoststr) {
// body... host = hoststr
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){ this._getHost = function () {
var arr=[]; return host
console.log(field); }
console.log('<br/>'); }
console.log(data);
for(var i in field){
if(!data[field[i]]){ $.extend(videoajax.prototype, {
throw new Error('data is error'); /**
} * 设置主域名
arr.push(field[i]+':'+data[field[i]]); * @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) : ''
} }
if(root.JSON){ )
return JSON.stringify(data); },
}else{ /**
return '{'+arr.join(',')+'}'; * 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 getBreakPoint = function (base64point) { var isFunction = function (fn) {
var newbc = base64point.split('').reverse().join(''); if (typeof fn === 'function') return true
return Base64.fromBase64(newbc); 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(',') + '}'
}
}
root.videoAjax = function(){ var getBreakPoint = function (base64point) {
return new videoajax(); var newbc = base64point.split('').reverse().join('')
} return Base64.fromBase64(newbc)
}(this)); }
root.videoAjax = function () {
return new videoajax()
}
})(this)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

+8 -1
View File
@@ -7,9 +7,11 @@
## 需要的图片文件列表 ## 需要的图片文件列表
### 1. 首页 ### 1. 首页
- `鲁迅-首页.png` - 首页背景图(放在 luxun 根目录) - `鲁迅-首页.png` - 首页背景图(放在 luxun 根目录)
### 2. place1 - 风雨前夕的思想淬炼(探索鲁迅故居一楼客厅) ### 2. place1 - 风雨前夕的思想淬炼(探索鲁迅故居一楼客厅)
- `背景.png` - 场景背景图 - `背景.png` - 场景背景图
- `展品1.png` - 展品1图片 - `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片 - `展品2.png` - 展品2图片
@@ -18,6 +20,7 @@
- `展品5.png` - 展品5图片 - `展品5.png` - 展品5图片
### 3. place2 - 杂文集里的匕首与投枪(探索卧室兼书房) ### 3. place2 - 杂文集里的匕首与投枪(探索卧室兼书房)
- `背景.png` - 场景背景图 - `背景.png` - 场景背景图
- `展品1.png` - 展品1图片 - `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片 - `展品2.png` - 展品2图片
@@ -25,6 +28,7 @@
- `展品4.png` - 展品4图片 - `展品4.png` - 展品4图片
### 4. place3 - 历史新编中的现实呐喊(探索二楼卧室兼书房) ### 4. place3 - 历史新编中的现实呐喊(探索二楼卧室兼书房)
- `背景.png` - 场景背景图 - `背景.png` - 场景背景图
- `展品1.png` - 展品1图片 - `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片 - `展品2.png` - 展品2图片
@@ -32,21 +36,25 @@
- `展品4.png` - 展品4图片 - `展品4.png` - 展品4图片
### 5. place4 - 为亡友编纂的精神丰碑(探索二楼卧室兼书房的书桌) ### 5. place4 - 为亡友编纂的精神丰碑(探索二楼卧室兼书房的书桌)
- `背景.png` - 场景背景图 - `背景.png` - 场景背景图
- `展品1.png` - 展品1图片 - `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片 - `展品2.png` - 展品2图片
- `展品3.png` - 展品3图片 - `展品3.png` - 展品3图片
### 6. place5 - 6平方米的红色庇护所(探索鲁迅故居的客房) ### 6. place5 - 6平方米的红色庇护所(探索鲁迅故居的客房)
- `背景.png` - 场景背景图 - `背景.png` - 场景背景图
- `展品1.png` - 展品1图片 - `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片 - `展品2.png` - 展品2图片
- `展品3.png` - 展品3图片 - `展品3.png` - 展品3图片
### 7. reflection - 体悟模块 ### 7. reflection - 体悟模块
- `底图.png` - 体悟模块背景图 - `底图.png` - 体悟模块背景图
### 8. internalization - 内化模块 ### 8. internalization - 内化模块
- `底图.png` - 内化模块背景图 - `底图.png` - 内化模块背景图
## 注意事项 ## 注意事项
@@ -54,4 +62,3 @@
1. 所有图片文件请使用 PNG 格式 1. 所有图片文件请使用 PNG 格式
2. 文件名请严格按照上述命名,区分大小写 2. 文件名请严格按照上述命名,区分大小写
3. 入口页的鲁迅图标文件已存在于 `entrance/鲁迅.png` 3. 入口页的鲁迅图标文件已存在于 `entrance/鲁迅.png`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 KiB

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+17 -22
View File
@@ -47,30 +47,25 @@ src/components/
## 组件模板 ## 组件模板
````tsx ````tsx
import React, { import React, { forwardRef, useRef, useState, useImperativeHandle } from 'react'
forwardRef, import { cn } from '@/lib/utils'
useRef,
useState,
useImperativeHandle,
} from "react";
import { cn } from "@/lib/utils";
/** 组件配置项 */ /** 组件配置项 */
export interface ComponentNameProps { export interface ComponentNameProps {
/** 必填属性说明 */ /** 必填属性说明 */
requiredProp: string; requiredProp: string
/** 可选属性说明 */ /** 可选属性说明 */
optionalProp?: boolean; optionalProp?: boolean
/** 子元素 */ /** 子元素 */
children?: React.ReactNode; children?: React.ReactNode
/** 自定义类名 */ /** 自定义类名 */
className?: string; className?: string
} }
/** 组件 Ref 暴露的方法 */ /** 组件 Ref 暴露的方法 */
export interface ComponentNameRef { export interface ComponentNameRef {
/** 方法说明 */ /** 方法说明 */
methodName: () => void; methodName: () => void
} }
/** /**
@@ -84,33 +79,33 @@ export interface ComponentNameRef {
const ComponentName = forwardRef<ComponentNameRef, ComponentNameProps>( const ComponentName = forwardRef<ComponentNameRef, ComponentNameProps>(
({ requiredProp, optionalProp = false, className, children }, ref) => { ({ requiredProp, optionalProp = false, className, children }, ref) => {
// 状态定义 // 状态定义
const [state, setState] = useState<string>(""); const [state, setState] = useState<string>('')
// Ref 定义 // Ref 定义
const innerRef = useRef<HTMLDivElement>(null); const innerRef = useRef<HTMLDivElement>(null)
// 暴露方法给父组件 // 暴露方法给父组件
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
methodName: () => { methodName: () => {
// 实现 // 实现
}, },
})); }))
return <div className={cn("base-class", className)}>{children}</div>; return <div className={cn('base-class', className)}>{children}</div>
} }
); )
ComponentName.displayName = "ComponentName"; ComponentName.displayName = 'ComponentName'
export default ComponentName; export default ComponentName
```` ````
## index.ts 导出模板 ## index.ts 导出模板
```ts ```ts
export { default } from "./component-name"; export { default } from './component-name'
export { default as ComponentName } from "./component-name"; export { default as ComponentName } from './component-name'
export type { ComponentNameProps, ComponentNameRef } from "./component-name"; export type { ComponentNameProps, ComponentNameRef } from './component-name'
``` ```
## 类型规范 ## 类型规范
+21 -3
View File
@@ -79,6 +79,9 @@ const ExhibitOverlay = ({
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const viewElRef = useRef<HTMLDivElement | null>(null) const viewElRef = useRef<HTMLDivElement | null>(null)
const frameRef = useRef<number | null>(null) const frameRef = useRef<number | null>(null)
const openTimerRef = useRef<number | null>(null)
const wasVisibleRef = useRef(false)
const onOpenRef = useRef(onOpen)
const pointers = useRef<Map<number, Point>>(new Map()) const pointers = useRef<Map<number, Point>>(new Map())
const lastDistance = useRef<number | null>(null) const lastDistance = useRef<number | null>(null)
// 拖拽初始状态:记录 pointerDown 时的状态 // 拖拽初始状态:记录 pointerDown 时的状态
@@ -137,10 +140,25 @@ const ExhibitOverlay = ({
}, [applyTransformStyle]) }, [applyTransformStyle])
useEffect(() => { useEffect(() => {
if (visible) { onOpenRef.current = onOpen
onOpen?.() }, [onOpen])
useEffect(() => {
if (visible && !wasVisibleRef.current) {
openTimerRef.current = window.setTimeout(() => {
onOpenRef.current?.()
openTimerRef.current = null
}, 0)
} }
}, [visible, onOpen]) wasVisibleRef.current = visible
return () => {
if (openTimerRef.current) {
window.clearTimeout(openTimerRef.current)
openTimerRef.current = null
}
}
}, [visible])
// 关闭或切换时重置 // 关闭或切换时重置
useEffect(() => { useEffect(() => {
+8 -1
View File
@@ -149,6 +149,13 @@ export const StudyGuideModal = ({
// } // }
} }
const handleContinue = () => {
if (audioSrc && currentSrc === audioSrc) {
stop()
}
onContinue?.()
}
const contentChildren = renderIndentedContent(children, firstLineIndent) const contentChildren = renderIndentedContent(children, firstLineIndent)
const content = ( const content = (
@@ -204,7 +211,7 @@ export const StudyGuideModal = ({
src={getStaticImage('components/guide/继续')} src={getStaticImage('components/guide/继续')}
alt="继续" alt="继续"
button button
onClick={onContinue} onClick={handleContinue}
/> />
</div> </div>
</div> </div>
+41 -36
View File
@@ -23,6 +23,7 @@ const RESIDENCES_WITH_PUZZLE = [
'maozedong', 'maozedong',
'nieer', 'nieer',
'zhangleping', 'zhangleping',
'rongdesheng',
] as const ] as const
/** /**
@@ -186,48 +187,52 @@ const ResidenceLayoutContent = () => {
// 处理碎片动画触发(仅支持拼图功能的故居) // 处理碎片动画触发(仅支持拼图功能的故居)
const handleTriggerPuzzleAnimation = useCallback( const handleTriggerPuzzleAnimation = useCallback(
(pieceId: number, options?: { onComplete?: () => void }) => { (pieceId: number, options?: { onComplete?: () => void }) => {
if (!residence || !hasPuzzleFeature) {
options?.onComplete?.()
return Promise.resolve()
}
// 防止重复执行动画
if (animationInProgressRef.current) {
options?.onComplete?.()
return Promise.resolve()
}
// 检查碎片是否已解锁
if (isPuzzleUnlocked(residence, pieceId)) {
options?.onComplete?.()
return Promise.resolve()
}
// 标记动画正在进行
animationInProgressRef.current = true
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
animationCompletionRef.current = resolve window.setTimeout(() => {
animationCompletionCallbackRef.current = options?.onComplete ?? null if (!residence || !hasPuzzleFeature) {
options?.onComplete?.()
resolve()
return
}
// 开始动画流程:禁用所有操作 // 防止重复执行动画
setIsAnimationPlaying(true) if (animationInProgressRef.current) {
setPuzzlePieceId(pieceId) options?.onComplete?.()
setShowPuzzlePiece(true) resolve()
return
}
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成 // 检查碎片是否已解锁
requestAnimationFrame(() => { if (isPuzzleUnlocked(residence, pieceId)) {
requestAnimationFrame(async () => { options?.onComplete?.()
// 等待初始出现动画(旋转+放大)完成 resolve()
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)) return
}
// 等待发光动画显示一段时间,让用户看到碎片 // 标记动画正在进行
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)) animationInProgressRef.current = true
animationCompletionRef.current = resolve
animationCompletionCallbackRef.current = options?.onComplete ?? null
// 等待元素准备就绪并执行飞行动画 // 开始动画流程:禁用所有操作
await waitForElementsAndAnimate(pieceId) setIsAnimationPlaying(true)
setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true)
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// 等待初始出现动画(旋转+放大)完成
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
// 等待发光动画显示一段时间,让用户看到碎片
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
// 等待元素准备就绪并执行飞行动画
await waitForElementsAndAnimate(pieceId)
})
}) })
}) }, 0)
}) })
}, },
[ [
+9 -1
View File
@@ -4,6 +4,8 @@ import { createPortal } from 'react-dom'
import { useScale } from '@/contexts/ScaleContext' import { useScale } from '@/contexts/ScaleContext'
export interface ScaledPortalProps { export interface ScaledPortalProps {
/** 是否显示 */
show?: boolean
/** 是否启用(不启用则直接渲染 children) */ /** 是否启用(不启用则直接渲染 children) */
enabled?: boolean enabled?: boolean
/** z-index 层级(建议 > ExhibitOverlay 的 9999) */ /** z-index 层级(建议 > ExhibitOverlay 的 9999) */
@@ -17,7 +19,12 @@ export interface ScaledPortalProps {
* - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住 * - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住
* - 使用 useScale 的 offset/scale 做视觉对齐(与 ExhibitOverlay 一致) * - 使用 useScale 的 offset/scale 做视觉对齐(与 ExhibitOverlay 一致)
*/ */
const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortalProps) => { const ScaledPortal = ({
enabled = true,
zIndex = 10050,
show = true,
children,
}: ScaledPortalProps) => {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale() const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
@@ -25,6 +32,7 @@ const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortal
setMounted(true) setMounted(true)
}, []) }, [])
if (!show) return null
if (!enabled) return <>{children}</> if (!enabled) return <>{children}</>
if (!mounted) return null if (!mounted) return null
+1 -1
View File
@@ -42,7 +42,7 @@ const ErrorAlert = ({
{/* 标题和副标题容器 - 带深色背景条 */} {/* 标题和副标题容器 - 带深色背景条 */}
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4"> <div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
{/* 副标题 */} {/* 副标题 */}
<div className="text-white text-5xl font-medium tracking-widest">{text}</div> <div className="text-white text-5xl font-medium tracking-widest leading-18">{text}</div>
</div> </div>
{/* 继续按钮 */} {/* 继续按钮 */}
@@ -29,7 +29,7 @@ export interface SuccessAlertProps {
* 使用 components/正确版 作为背景 * 使用 components/正确版 作为背景
*/ */
const SuccessAlert = ({ const SuccessAlert = ({
title = '恭喜你, 选择正确!', title = '恭喜你,选择正确!',
content, content,
visible = true, visible = true,
audioSrc, audioSrc,
@@ -108,16 +108,12 @@ const SuccessAlert = ({
if (audioSrc && currentSrc === audioSrc) { if (audioSrc && currentSrc === audioSrc) {
stop() stop()
} }
// 只调用 onContinue,它会处理关闭逻辑 onContinue?.()
if (onContinue) { onClose?.()
onContinue()
} else if (onClose) {
onClose()
}
} }
return ( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center"> <div className="fixed inset-0 z-10000 flex items-center justify-center">
{/* 蒙版背景 */} {/* 蒙版背景 */}
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
+3 -3
View File
@@ -55,7 +55,7 @@ const DragQuizPanel = ({
'inline-flex items-center justify-center min-w-[140px] h-[56px] px-6 mx-3', 'inline-flex items-center justify-center min-w-[140px] h-[56px] px-6 mx-3',
'rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]', 'rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]',
filledValue filledValue
? 'border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm' ? 'border-[#f0be75] bg-linear-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm'
: 'border-[#d4c5a9]/60 bg-white/10 backdrop-blur-sm hover:border-[#d4c5a9]' : 'border-[#d4c5a9]/60 bg-white/10 backdrop-blur-sm hover:border-[#d4c5a9]'
)} )}
> >
@@ -111,11 +111,11 @@ const DragQuizPanel = ({
'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]', 'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
isUsed isUsed
? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50' ? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
: 'border-[#d4c5a9] bg-gradient-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]' : 'border-[#d4c5a9] bg-linear-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]'
)} )}
> >
{!isUsed && ( {!isUsed && (
<div className="absolute inset-0 rounded-[32px] bg-gradient-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" /> <div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
)} )}
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]"> <span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">
{option.label} {option.label}
+83 -83
View File
@@ -22,57 +22,55 @@
### 使用示例 ### 使用示例
```tsx ```tsx
import { SelectFillQuizPanel } from "@/components"; import { SelectFillQuizPanel } from '@/components'
function MyComponent() { function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false); const [showQuiz, setShowQuiz] = useState(false)
return ( return (
<SelectFillQuizPanel <SelectFillQuizPanel
visible={showQuiz} visible={showQuiz}
titlePrefix="【填空】毛泽东主席认为,到了" titlePrefix="【填空】毛泽东主席认为,到了"
titleSuffix="年夏天,在理论上,而且在某种程度的行动上,他已成为一个马克思主义者。" titleSuffix="年夏天,在理论上,而且在某种程度的行动上,他已成为一个马克思主义者。"
blanks={[ blanks={[{ id: 'year', prefix: '____' }]}
{ id: "year", prefix: "____" },
]}
options={[ options={[
{ value: "1919", label: "1919" }, { value: '1919', label: '1919' },
{ value: "1920", label: "1920" }, { value: '1920', label: '1920' },
{ value: "1921", label: "1921" }, { value: '1921', label: '1921' },
]} ]}
correctAnswers={{ year: "1920" }} correctAnswers={{ year: '1920' }}
errorMessage="回想一下 1920 年 5 月到 7 月在上海居住的这段经历~" errorMessage="回想一下 1920 年 5 月到 7 月在上海居住的这段经历~"
onSubmit={(results, isCorrect) => { onSubmit={(results, isCorrect) => {
console.log("选择结果:", results, "是否正确:", isCorrect); console.log('选择结果:', results, '是否正确:', isCorrect)
if (isCorrect) { if (isCorrect) {
setShowQuiz(false); setShowQuiz(false)
} }
}} }}
onError={(error) => { onError={(error) => {
console.log("错误信息:", error); console.log('错误信息:', error)
}} }}
submitText="确定" submitText="确定"
/> />
); )
} }
``` ```
### API ### API
| 属性 | 类型 | 默认值 | 说明 | | 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------| | -------------- | --------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------- |
| visible | boolean | - | 是否可见(必填) | | visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 | | zIndex | number | 10000 | z-index 层级 |
| titlePrefix | string | - | 整体题干前缀文本(可选) | | titlePrefix | string | - | 整体题干前缀文本(可选) |
| titleSuffix | string | - | 整体题干后缀文本(可选) | | titleSuffix | string | - | 整体题干后缀文本(可选) |
| blanks | SelectFillBlank[] | - | 每个填空位的配置(必填,包含 id / prefix / suffix) | | blanks | SelectFillBlank[] | - | 每个填空位的配置(必填,包含 id / prefix / suffix) |
| options | { value: string; label: string }[] | - | 候选项列表(必填) | | options | { value: string; label: string }[] | - | 候选项列表(必填) |
| correctAnswers | Record<string, string> | - | 正确答案映射(可选,不传则不做校验,直接判定为正确) | | correctAnswers | Record<string, string> | - | 正确答案映射(可选,不传则不做校验,直接判定为正确) |
| errorMessage | string | `"还不太对,再仔细对照题干和提示想一想吧~"` | 自定义错误提示信息(答案错误时显示) | | errorMessage | string | `"还不太对,再仔细对照题干和提示想一想吧~"` | 自定义错误提示信息(答案错误时显示) |
| onSubmit | (results: SelectFillResult[], isCorrect: boolean) => void | - | 提交回调 | | onSubmit | (results: SelectFillResult[], isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 | | onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 | | disabled | boolean | false | 是否禁用 |
| submitText | string | "提交" | 提交按钮文本 | | submitText | string | "提交" | 提交按钮文本 |
### 行为说明 ### 行为说明
@@ -98,53 +96,53 @@ function MyComponent() {
### 使用方法 ### 使用方法
```tsx ```tsx
import { SingleChoiceQuizPanel } from "@/components"; import { SingleChoiceQuizPanel } from '@/components'
function MyComponent() { function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false); const [showQuiz, setShowQuiz] = useState(false)
return ( return (
<SingleChoiceQuizPanel <SingleChoiceQuizPanel
visible={showQuiz} visible={showQuiz}
title="这是一道单选题:以下哪个选项是正确的?" title="这是一道单选题:以下哪个选项是正确的?"
options={[ options={[
{ value: "A", label: "选项A" }, { value: 'A', label: '选项A' },
{ value: "B", label: "选项B" }, { value: 'B', label: '选项B' },
{ value: "C", label: "选项C" }, { value: 'C', label: '选项C' },
]} ]}
correctAnswer="B" correctAnswer="B"
errorMessage="再想一想,注意题干中的时间和提示信息哦~" errorMessage="再想一想,注意题干中的时间和提示信息哦~"
onSubmit={(value, isCorrect) => { onSubmit={(value, isCorrect) => {
console.log("用户选择:", value); console.log('用户选择:', value)
console.log("是否正确:", isCorrect); console.log('是否正确:', isCorrect)
if (isCorrect) { if (isCorrect) {
// 选择正确,可以关闭面板或进入下一步 // 选择正确,可以关闭面板或进入下一步
setShowQuiz(false); setShowQuiz(false)
} }
}} }}
onError={(error) => { onError={(error) => {
console.log("错误信息:", error); console.log('错误信息:', error)
}} }}
/> />
); )
} }
``` ```
### API ### API
| 属性 | 类型 | 默认值 | 说明 | | 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------| | ------------- | ------------------------------------------- | -------------------------------------- | ------------------------------------ |
| 属性 | 类型 | 默认值 | 说明 | | 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------| | ------ | ------ | -------- | ------ |
| visible | boolean | - | 是否可见(必填) | | visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 | | zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) | | title | string | - | 题目(必填) |
| options | SingleChoiceOption[] | - | 选项列表(必填) | | options | SingleChoiceOption[] | - | 选项列表(必填) |
| correctAnswer | string | - | 正确答案(可选,用于验证) | | correctAnswer | string | - | 正确答案(可选,用于验证) |
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) | | errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
| onSubmit | (value: string, isCorrect: boolean) => void | - | 提交回调 | | onSubmit | (value: string, isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 | | onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 | | disabled | boolean | false | 是否禁用 |
### 行为说明 ### 行为说明
@@ -168,61 +166,61 @@ function MyComponent() {
### 使用方法 ### 使用方法
```tsx ```tsx
import { MultipleChoiceQuizPanel } from "@/components"; import { MultipleChoiceQuizPanel } from '@/components'
function MyComponent() { function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false); const [showQuiz, setShowQuiz] = useState(false)
return ( return (
<MultipleChoiceQuizPanel <MultipleChoiceQuizPanel
visible={showQuiz} visible={showQuiz}
title="这是一道多选题:以下哪些选项是正确的?(可多选)" title="这是一道多选题:以下哪些选项是正确的?(可多选)"
options={[ options={[
{ value: "A", label: "选项A" }, { value: 'A', label: '选项A' },
{ value: "B", label: "选项B" }, { value: 'B', label: '选项B' },
{ value: "C", label: "选项C" }, { value: 'C', label: '选项C' },
{ value: "D", label: "选项D" }, { value: 'D', label: '选项D' },
]} ]}
correctAnswer={["A", "B"]} correctAnswer={['A', 'B']}
errorMessage="很抱歉,你选错了!请你再思考一下吧!" errorMessage="很抱歉,你选错了!请你再思考一下吧!"
minSelections={1} minSelections={1}
maxSelections={3} maxSelections={3}
minSelectionsErrorMessage="请至少选择 1 项才能提交" minSelectionsErrorMessage="请至少选择 1 项才能提交"
submitText="确认提交" submitText="确认提交"
onSubmit={(values, isCorrect) => { onSubmit={(values, isCorrect) => {
console.log("用户选择:", values); console.log('用户选择:', values)
console.log("是否正确:", isCorrect); console.log('是否正确:', isCorrect)
if (isCorrect) { if (isCorrect) {
setShowQuiz(false); setShowQuiz(false)
} }
}} }}
onError={(error) => { onError={(error) => {
console.log("错误信息:", error); console.log('错误信息:', error)
}} }}
/> />
); )
} }
``` ```
### API ### API
| 属性 | 类型 | 默认值 | 说明 | | 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------| | ------------------------- | ---------------------------------------------- | -------------------------------------- | ------------------------------------ |
| 属性 | 类型 | 默认值 | 说明 | | 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------| | ------ | ------ | -------- | ------ |
| visible | boolean | - | 是否可见(必填) | | visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 | | zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) | | title | string | - | 题目(必填) |
| options | MultipleChoiceOption[] | - | 选项列表(必填) | | options | MultipleChoiceOption[] | - | 选项列表(必填) |
| correctAnswer | string[] | - | 正确答案数组(可选,用于验证) | | correctAnswer | string[] | - | 正确答案数组(可选,用于验证) |
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) | | errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
| minSelectionsErrorMessage | string | `请至少选择 ${minSelections} 项` | 自定义最小选择数量不足时的提示信息 | | minSelectionsErrorMessage | string | `请至少选择 ${minSelections} 项` | 自定义最小选择数量不足时的提示信息 |
| onSubmit | (values: string[], isCorrect: boolean) => void | - | 提交回调 | | onSubmit | (values: string[], isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 | | onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 | | disabled | boolean | false | 是否禁用 |
| minSelections | number | - | 最小选择数量 | | minSelections | number | - | 最小选择数量 |
| maxSelections | number | - | 最大选择数量 | | maxSelections | number | - | 最大选择数量 |
| submitText | string | "提交" | 提交按钮文本 | | submitText | string | "提交" | 提交按钮文本 |
### 行为说明 ### 行为说明
@@ -237,6 +235,7 @@ function MyComponent() {
### 从旧版 QuizPanel 迁移 ### 从旧版 QuizPanel 迁移
**旧版用法:** **旧版用法:**
```tsx ```tsx
<QuizPanel <QuizPanel
visible={showQuiz} visible={showQuiz}
@@ -245,17 +244,18 @@ function MyComponent() {
value={quizAnswer} value={quizAnswer}
error={quizError} error={quizError}
onChange={(val) => { onChange={(val) => {
setQuizAnswer(val); setQuizAnswer(val)
if (val === "B") { if (val === 'B') {
setQuizError(""); setQuizError('')
} else { } else {
setQuizError("很抱歉,你选错了!"); setQuizError('很抱歉,你选错了!')
} }
}} }}
/> />
``` ```
**新版用法:** **新版用法:**
```tsx ```tsx
<SingleChoiceQuizPanel <SingleChoiceQuizPanel
visible={showQuiz} visible={showQuiz}
@@ -159,7 +159,6 @@ const SingleChoiceQuizPanel = ({
<SuccessAlert <SuccessAlert
{...successAlertProps} {...successAlertProps}
visible={successAlertVisible} visible={successAlertVisible}
onContinue={handleSuccessContinue}
onClose={handleSuccessContinue} onClose={handleSuccessContinue}
/> />
)} )}
+7 -5
View File
@@ -8,9 +8,9 @@ import { showHint } from '@/components/hint'
const formatCurrentDate = () => { const formatCurrentDate = () => {
const now = new Date() const now = new Date()
const year = now.getFullYear() const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0') const month = now.getMonth() + 1
const day = `${now.getDate()}`.padStart(2, '0') const day = now.getDate()
return `${year}.${month}.${day}` return `${year}年${month}月${day}日`
} }
export type LetterFieldType = 'text' | 'input' | 'select' export type LetterFieldType = 'text' | 'input' | 'select'
@@ -305,12 +305,14 @@ const LetterTemplate = ({
} }
return ( return (
<div className="absolute right-[190px] bottom-[120px] z-99 flex flex-col items-end gap-2 text-[#4e402e]"> <div className="absolute right-[190px] bottom-[120px] z-100 flex flex-col items-end gap-2 text-[#4e402e]">
<style>{`.signature-name-input::placeholder { color: #999999; font-size: 26px; }`}</style>
<input <input
name="signature-name" name="signature-name"
className={`h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[120px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`} className={`signature-name-input h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[240px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`}
style={{ fontSize: '26px' }} style={{ fontSize: '26px' }}
autoComplete="off" autoComplete="off"
placeholder="请郑重签下你的姓名"
value={signatureName} value={signatureName}
maxLength={12} maxLength={12}
onChange={(e) => handleSignatureNameChange(e.target.value)} onChange={(e) => handleSignatureNameChange(e.target.value)}
@@ -82,7 +82,7 @@ const LetterToHuangyanpei = ({
onClose={onClose} onClose={onClose}
onSubmit={onSubmit} onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd} onAnimationEnd={onAnimationEnd}
title="寄给黄炎培的信" title="写给黄炎培先生"
letterContent={letterContent} letterContent={letterContent}
fields={fields} fields={fields}
contentClassName="!text-[25px]" contentClassName="!text-[25px]"
+2 -2
View File
@@ -63,7 +63,7 @@ const fields: Record<number, LetterFieldConfig> = {
correctValue: '横眉冷对', correctValue: '横眉冷对',
readOnlyFallback: '横眉冷对', readOnlyFallback: '横眉冷对',
hints: { hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。', error: '“横眉冷对千夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
}, },
}, },
4: { 4: {
@@ -73,7 +73,7 @@ const fields: Record<number, LetterFieldConfig> = {
correctValue: '孺子牛', correctValue: '孺子牛',
readOnlyFallback: '孺子牛', readOnlyFallback: '孺子牛',
hints: { hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。', error: '“横眉冷对千夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
}, },
}, },
5: { 5: {
@@ -19,7 +19,7 @@ const commonSelectField: LetterFieldConfig = {
className: '!w-[120px] !min-w-[120px] shrink-0 px-0', className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [ options: [
{ value: '天下为公', label: '天下为公' }, { value: '天下为公', label: '天下为公' },
{ value: '敢为天下先', label: '敢为天下先' }, { value: '与时俱进', label: '与时俱进' },
{ value: '振兴中华', label: '振兴中华' }, { value: '振兴中华', label: '振兴中华' },
], ],
} }
@@ -27,7 +27,7 @@ const commonSelectField: LetterFieldConfig = {
const letterContent = `敬爱的孙中山先生: const letterContent = `敬爱的孙中山先生:
您好! 您好!
今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在李教授的解读中,我终于明白:您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。 今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在这场跨越时空的对话中,我终于明白,您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。 我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
@@ -52,11 +52,11 @@ const fields: Record<number, LetterFieldConfig> = {
}, },
2: { 2: {
...commonSelectField, ...commonSelectField,
correctValue: '敢为天下先', correctValue: '与时俱进',
hints: { hints: {
error: '再想想哦,这里对应的是孙中山先生敢于探索新道路的胆识。', error: '再想想哦,这里对应的是孙中山先生敢于探索新道路的胆识。',
}, },
readOnlyFallback: '敢为天下先', readOnlyFallback: '与时俱进',
}, },
3: { 3: {
type: 'input', type: 'input',
+4
View File
@@ -21,6 +21,8 @@ export interface NarrationProps {
autoPlayAudio?: boolean autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string audioChannelId?: string
/** 署名/出处,主文本动画结束后显示在右下方 */
attribution?: string
} }
/** /**
@@ -41,6 +43,7 @@ const Narration = ({
animationSpeed = 100, animationSpeed = 100,
autoPlayAudio = true, autoPlayAudio = true,
audioChannelId, audioChannelId,
attribution,
}: NarrationProps) => { }: NarrationProps) => {
const [displayedText, setDisplayedText] = useState('') const [displayedText, setDisplayedText] = useState('')
const [isAnimating, setIsAnimating] = useState(true) const [isAnimating, setIsAnimating] = useState(true)
@@ -146,6 +149,7 @@ const Narration = ({
<div className="text-white text-3xl leading-relaxed space-y-8"> <div className="text-white text-3xl leading-relaxed space-y-8">
<p className="whitespace-pre-wrap">{displayedText}</p> <p className="whitespace-pre-wrap">{displayedText}</p>
{attribution && !isAnimating && <p className="text-right">{attribution}</p>}
</div> </div>
{/* 关闭按钮 - 只在动画完成后显示 */} {/* 关闭按钮 - 只在动画完成后显示 */}
-253
View File
@@ -1,253 +0,0 @@
import { useMemo, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { usePuzzleStore } from '@/store/puzzleStore'
import { Image } from '@/components'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
/**
* 六边形进度条组件
* 根据进度值显示不同颜色:100%绿色,50%-99%橙色,0%-49%灰色
*/
interface HexagonProgressBarProps {
progress: number // 0-100
}
const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
const progressPercent = Math.min(100, Math.max(0, progress))
const progressText =
progressPercent.toFixed(2) === '100.00' ? '进度100%' : `进度${Math.round(progressPercent)}%`
// 根据进度值确定填充颜色
const getProgressColor = () => {
if (progressPercent >= 100) {
return '#90B86B' // 浅橄榄绿色(100%完成)
} else if (progressPercent >= 50) {
return '#EEB362' // 橙色(50%-99%)
} else {
return '#E0E0E0' // 浅灰色(0%-49%)
}
}
const fillColor = getProgressColor()
// 六边形路径,细长的水平六边形,左右两端是尖角
// 正确的六边形路径:从左顶点开始,顺时针绘制
// 左顶点(0%, 50%) -> 左上角(3%, 0%) -> 右上角(97%, 0%) -> 右顶点(100%, 50%) -> 右下角(97%, 100%) -> 左下角(3%, 100%)
// 外层路径(包含边框)
const outerHexagonPath = 'polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)'
// 内层路径(边框内部)
const innerHexagonPath = 'polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)'
const borderWidth = 4 // 增加边框宽度从2px到3px
return (
<div className="relative" style={{ width: '260px', height: '50px' }}>
{/* 外层:白色边框(使用 clip-path 裁剪形状) */}
<div
className="absolute inset-0"
style={{
clipPath: outerHexagonPath,
backgroundColor: 'white',
}}
/>
{/* 内容容器(边框内部区域) */}
<div
className="absolute"
style={{
left: `${borderWidth}px`,
top: `${borderWidth}px`,
right: `${borderWidth}px`,
bottom: `${borderWidth}px`,
clipPath: innerHexagonPath,
}}
>
{/* 背景层:深灰色背景(未填充部分) */}
<div
className="absolute inset-0"
style={{
backgroundColor: '#757575',
}}
/>
{/* 填充层(根据进度百分比从左侧填充) */}
{progressPercent > 0 && (
<div
className="absolute left-0 top-0 bottom-0 overflow-hidden"
style={{
width: `${progressPercent}%`,
}}
>
<div
className="absolute inset-0"
style={{
backgroundColor: fillColor,
transition: 'width 0.3s ease, background-color 0.3s ease',
}}
/>
</div>
)}
</div>
{/* 文字(白色,居中,始终显示在最上层,带阴影效果) */}
<div
className="absolute inset-0 flex items-center justify-center text-white text-2xl font-bold pointer-events-none"
style={{
textShadow: '0 2px 4px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.8)',
zIndex: 10,
}}
>
{progressText}
</div>
</div>
)
}
export interface MenuPuzzleCenterProps {
/** 是否可见 */
visible: boolean
/** 关闭回调 */
onClose: () => void
/** 选择某个故居后的回调(进入对应的碎片系统) */
onSelectResidence: (residence: string) => void
/** 是否允许从管理中心进入具体拼图 */
allowPuzzleEntry?: boolean
}
const MenuPuzzleCenter = ({
visible,
onClose,
onSelectResidence,
allowPuzzleEntry = true,
}: MenuPuzzleCenterProps) => {
const { getPuzzleCollectProgress } = usePuzzleStore()
const [activeMapImage, setActiveMapImage] = useState<string | null>(null)
const residenceProgress = useMemo(
() =>
PUZZLE_RESIDENCES.map((item) => ({
...item,
// 只有已开放拼图的故居才计算收集进度
progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0,
})),
[getPuzzleCollectProgress]
)
if (!visible) return null
return (
<div
className="fixed inset-0 z-90 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.8)' }}
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}
>
<div
className="relative w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('puzzle/home/底图') }}
>
{/* 箭头指示底图 */}
<img
className="absolute top-84 left-55"
src={getStaticImage('puzzle/home/箭头指引')}
alt=""
/>
{/* 关闭按钮 */}
<Image
className="absolute right-8 top-8 z-10"
src={getStaticImage('components/关闭')}
alt="关闭"
button
onClick={onClose}
/>
{/* 故居列表:按照设计图进行绝对定位 */}
<div className="absolute inset-0">
{residenceProgress.map(
({
residence,
displayName,
left,
top,
image,
hasPuzzle,
progress,
hasMap,
mapImage,
}) => (
<div
key={residence}
className={`absolute -translate-x-1/2 -translate-y-1/2`}
style={{ left, top }}
>
{/* 故居图片(资源在 puzzle/home 下,可根据实际名称调整) */}
<Image src={getStaticImage(`puzzle/home/${image}`)} alt={displayName} />
{/* 进度展示,仅对已开放拼图的故居显示 */}
{hasPuzzle && (
<div className="mb-4 flex justify-center">
<HexagonProgressBar progress={progress} />
</div>
)}
{/* 图标 */}
<div className="flex justify-center gap-6">
{allowPuzzleEntry && (
<Image
src={getStaticImage(`puzzle/home/拼图`)}
alt="拼图"
button
onClick={(e) => {
if (!hasPuzzle) return
e.stopPropagation()
onSelectResidence(residence)
}}
/>
)}
<Image
src={getStaticImage(`puzzle/home/追光入口`)}
alt="导图"
button
onClick={(e) => {
e.stopPropagation()
if (!hasMap || !mapImage) return
setActiveMapImage(mapImage)
}}
/>
</div>
</div>
)
)}
</div>
{/* 导图弹窗 */}
{activeMapImage && (
<div
className="fixed inset-0 z-110 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.75)' }}
onClick={(e) => {
if (e.target === e.currentTarget) {
setActiveMapImage(null)
}
}}
>
<div className="relative w-full h-full">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Image
className="absolute -right-4 -top-8 z-10"
src={getStaticImage('components/close')}
alt="关闭"
button
onClick={() => setActiveMapImage(null)}
/>
<img src={getStaticImage(`puzzle/home/map/${activeMapImage}`)} alt="导图" />
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default MenuPuzzleCenter
+9 -9
View File
@@ -12,14 +12,14 @@ import { Image } from '@/components'
*/ */
const getResidenceDisplayName = (residence: string): string => { const getResidenceDisplayName = (residence: string): string => {
const nameMap: Record<string, string> = { const nameMap: Record<string, string> = {
chenyun: '陈云', chenyun: '陈云故居',
huangyanpei: '黄炎培', huangyanpei: '黄炎培故居',
luxun: '鲁迅', luxun: '鲁迅故居',
sunzhongshan: '孙中山', sunzhongshan: '孙中山故居',
maozedong: '毛泽东', maozedong: '毛泽东旧居',
zhangleping: '张乐平', zhangleping: '张乐平故居',
nieer: '聂耳', nieer: '聂耳旧居',
rongdesheng: '荣德生', rongdesheng: '荣德生旧居',
} }
return nameMap[residence] || residence return nameMap[residence] || residence
@@ -299,7 +299,7 @@ const PuzzleSystem = ({
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]' '[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)} )}
> >
{getResidenceDisplayName(residence)}故居碎片 {getResidenceDisplayName(residence)}碎片
</span> </span>
<img <img
className="absolute -top-10 left-0 -z-10" className="absolute -top-10 left-0 -z-10"
+3 -2
View File
@@ -86,7 +86,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
} }
setContentWidth(calculatedWidth) setContentWidth(calculatedWidth)
}, [fitContent, maxWidth, minWidth, options, placeholder]) }, [fitContent, maxWidth, minWidth, options, placeholder, fontSize])
const updateDropdownPosition = useCallback(() => { const updateDropdownPosition = useCallback(() => {
if (!triggerRef.current) return if (!triggerRef.current) return
@@ -240,7 +240,8 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
}} }}
className={cn( className={cn(
'inline-flex h-[36px] items-center justify-center border-b-2 border-dashed bg-transparent align-baseline outline-none', 'inline-flex h-[36px] items-center justify-center border-b-2 border-dashed bg-transparent align-baseline outline-none',
'mx-2 cursor-pointer whitespace-nowrap px-2 text-center text-[#4e402e]', 'mx-2 cursor-pointer whitespace-nowrap px-2 text-center',
selectedOption ? 'text-[#4e402e]' : 'text-[#999999]',
error ? 'border-red-500' : 'border-[#4e402e]', error ? 'border-red-500' : 'border-[#4e402e]',
disabled && 'cursor-not-allowed opacity-50', disabled && 'cursor-not-allowed opacity-50',
className className
+22
View File
@@ -3,6 +3,15 @@ import { PUZZLE_RESIDENCES } from './puzzleResidences'
export type PuzzleProgress = Record<string, Set<number>> export type PuzzleProgress = Record<string, Set<number>>
const BUILD_UNLOCK_ALL = import.meta.env.VITE_PROGRESS_UNLOCK_ALL === 'true'
const UNLOCK_ALL_STORAGE_KEY = 'debug:progression-unlock-all'
const isUnlockAllEnabled = () => {
if (!BUILD_UNLOCK_ALL) return false
if (typeof window === 'undefined') return true
return window.sessionStorage.getItem(UNLOCK_ALL_STORAGE_KEY) !== 'false'
}
const getResidenceIndex = (residence: string) => const getResidenceIndex = (residence: string) =>
PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence) PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
@@ -24,10 +33,13 @@ export const isResidenceStarted = (residence: string, puzzles: PuzzleProgress) =
} }
export const isResidenceComplete = (residence: string, puzzles: PuzzleProgress) => { export const isResidenceComplete = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
return arePiecesUnlocked(residence, puzzles, getConfiguredPieceIds(residence)) return arePiecesUnlocked(residence, puzzles, getConfiguredPieceIds(residence))
} }
export const isResidenceUnlocked = (residence: string, puzzles: PuzzleProgress) => { export const isResidenceUnlocked = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const index = getResidenceIndex(residence) const index = getResidenceIndex(residence)
if (index === 0) return true if (index === 0) return true
if (index < 0) return false if (index < 0) return false
@@ -50,6 +62,8 @@ export const isMenuModuleStarted = (moduleName: string, puzzles: PuzzleProgress)
} }
export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress) => { export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName) const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
if (residences.length === 0) return false if (residences.length === 0) return false
return residences.every((item) => isResidenceComplete(item.residence, puzzles)) return residences.every((item) => isResidenceComplete(item.residence, puzzles))
@@ -65,6 +79,14 @@ const getPieceIdsByRoute = (residence: string, routePrefix: string) => {
} }
export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => { export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) {
return {
dialogueUnlocked: true,
reflectionUnlocked: true,
internalizationUnlocked: true,
}
}
const residenceUnlocked = isResidenceUnlocked(residence, puzzles) const residenceUnlocked = isResidenceUnlocked(residence, puzzles)
return { return {
+24 -26
View File
@@ -80,7 +80,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
}, },
// 碎片11 // 碎片11
11: { 11: {
title: '陈云起草的关于发展国民 经济"一五"计划报告的手稿', title: '陈云起草的关于发展国民 经济“一五”计划报告的手稿',
detailImage: 'chenyun/details/11', detailImage: 'chenyun/details/11',
route: 'place8', route: 'place8',
}, },
@@ -148,7 +148,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
}, },
// 碎片6 // 碎片6
6: { 6: {
title: '中华职业教育 和中华职业学校', title: '职业教育 和中华职业学校',
detailImage: 'huangyanpei/details/6', detailImage: 'huangyanpei/details/6',
route: 'place4', route: 'place4',
}, },
@@ -184,7 +184,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
}, },
// 碎片12 // 碎片12
12: { 12: {
title: '窑洞对话', title: '窑洞对',
detailImage: 'huangyanpei/details/12', detailImage: 'huangyanpei/details/12',
route: 'place5', route: 'place5',
}, },
@@ -196,7 +196,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
}, },
// 碎片14 // 碎片14
14: { 14: {
title: '黄炎培对浦东中学新生的教导', title: '黄炎培对浦东中学 新生的教导',
detailImage: 'huangyanpei/details/14', detailImage: 'huangyanpei/details/14',
route: 'reflection2', route: 'reflection2',
}, },
@@ -260,7 +260,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3', route: 'place3',
}, },
10: { 10: {
title: '《中国国民党第一次 全国代表大会宣言》', title: '中国国民党第一次 全国代表大会宣言',
detailImage: 'sunzhongshan/details/10', detailImage: 'sunzhongshan/details/10',
route: 'place3', route: 'place3',
}, },
@@ -399,22 +399,22 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place2', route: 'place2',
}, },
5: { 5: {
title: '中共三大会议召开', title: '中共三大召开',
detailImage: 'maozedong/details/5', detailImage: 'maozedong/details/5',
route: 'place3', route: 'place3',
}, },
6: { 6: {
title: '《孙中山手书名单》', title: '孙中山手书名单',
detailImage: 'maozedong/details/6', detailImage: 'maozedong/details/6',
route: 'place3', route: 'place3',
}, },
7: { 7: {
title: '《孙文越飞联合宣言》', title: '孙文越飞联合宣言',
detailImage: 'maozedong/details/7', detailImage: 'maozedong/details/7',
route: 'place3', route: 'place3',
}, },
8: { 8: {
title: '毛泽东签发的两份中共四大通知', title: '毛泽东签发的两份 中共四大通知',
detailImage: 'maozedong/details/8', detailImage: 'maozedong/details/8',
route: 'place4', route: 'place4',
}, },
@@ -424,7 +424,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4', route: 'place4',
}, },
10: { 10: {
title: '毛泽东中国社会分析', title: '中国社会各阶级的分析',
detailImage: 'maozedong/details/10', detailImage: 'maozedong/details/10',
route: 'place4', route: 'place4',
}, },
@@ -501,34 +501,34 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4', route: 'place4',
}, },
9: { 9: {
title: '刘良模指挥数千人齐唱', title: '国歌选定',
detailImage: 'nieer/details/9', detailImage: 'nieer/details/9',
route: 'place4', route: 'place5',
}, },
10: { 10: {
title: '国歌选定', title: '国歌入宪',
detailImage: 'nieer/details/10', detailImage: 'nieer/details/10',
route: 'place5', route: 'place5',
}, },
11: { 11: {
title: '国歌入宪', title: '国歌立法',
detailImage: 'nieer/details/11', detailImage: 'nieer/details/11',
route: 'place5', route: 'place5',
}, },
12: { 12: {
title: '国歌立法', title: '新中国奏响国歌的场景(上)',
detailImage: 'nieer/details/12', detailImage: 'nieer/details/12',
route: 'place5', route: 'place6',
}, },
13: { 13: {
title: '新中国奏响国歌的场景(上)', title: '新中国奏响国歌的场景(下)',
detailImage: 'nieer/details/13', detailImage: 'nieer/details/13',
route: 'place6', route: 'place6',
}, },
14: { 14: {
title: '新中国奏响国歌的场景(下)', title: '唱国家一定要站起来',
detailImage: 'nieer/details/14', detailImage: 'nieer/details/14',
route: 'place6', route: 'reflection',
}, },
15: { 15: {
title: '聂耳「人民音乐家」', title: '聂耳「人民音乐家」',
@@ -578,7 +578,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3', route: 'place3',
}, },
8: { 8: {
title: '三毛新生(《向日葵》张乐平,1950-1965)', title: '三毛新生',
detailImage: 'zhangleping/details/8', detailImage: 'zhangleping/details/8',
route: 'place3', route: 'place3',
}, },
@@ -608,17 +608,17 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place5', route: 'place5',
}, },
14: { 14: {
title: '画自己', title: '创作心路',
detailImage: 'zhangleping/details/14', detailImage: 'zhangleping/details/14',
route: 'place5', route: 'reflection',
}, },
15: { 15: {
title: '平民立场', title: '艺术来源于生活',
detailImage: 'zhangleping/details/15', detailImage: 'zhangleping/details/15',
route: 'internalization', route: 'internalization',
}, },
16: { 16: {
title: '笔耕不辍', title: '核心创作理念',
detailImage: 'zhangleping/details/16', detailImage: 'zhangleping/details/16',
route: 'internalization', route: 'internalization',
}, },
@@ -705,8 +705,6 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'internalization', route: 'internalization',
}, },
}, },
// 后续可以添加其他故居的数据
// sunzhongshan: { ... },
} }
/** /**
+51
View File
@@ -0,0 +1,51 @@
const STORAGE_KEY = 'debug:progression-unlock-all'
const BADGE_ID = 'progression-debug-badge'
const isUnlockAllEnabled = () => {
return window.sessionStorage.getItem(STORAGE_KEY) !== 'false'
}
const getBadgeText = () => {
return isUnlockAllEnabled() ? '测试包:全解锁' : '测试包:真实进度'
}
const updateBadge = () => {
let badge = document.getElementById(BADGE_ID)
if (!badge) {
badge = document.createElement('div')
badge.id = BADGE_ID
badge.style.position = 'fixed'
badge.style.right = '12px'
badge.style.top = '12px'
badge.style.zIndex = '99999'
badge.style.padding = '8px 10px'
badge.style.borderRadius = '8px'
badge.style.background = 'rgba(0, 0, 0, 0.72)'
badge.style.color = '#fff'
badge.style.fontSize = '12px'
badge.style.lineHeight = '1.5'
badge.style.pointerEvents = 'none'
badge.style.whiteSpace = 'pre-line'
document.body.appendChild(badge)
}
badge.textContent = `${getBadgeText()}\nCtrl+Shift+U 切换`
}
const toggleUnlockAll = () => {
const nextValue = isUnlockAllEnabled() ? 'false' : 'true'
window.sessionStorage.setItem(STORAGE_KEY, nextValue)
updateBadge()
window.location.reload()
}
export const setupProgressionDebug = () => {
updateBadge()
window.addEventListener('keydown', (event) => {
if (!event.ctrlKey || !event.shiftKey || event.key.toLowerCase() !== 'u') return
event.preventDefault()
toggleUnlockAll()
})
}
+118 -122
View File
@@ -17,16 +17,16 @@
### 1. 简单点击 ### 1. 简单点击
```tsx ```tsx
import { useTouch } from "@/hooks"; import { useTouch } from '@/hooks'
function MyComponent() { function MyComponent() {
const { handlers } = useTouch({ const { handlers } = useTouch({
onTap: () => { onTap: () => {
console.log("点击了"); console.log('点击了')
}, },
}); })
return <div {...handlers}>点击我</div>; return <div {...handlers}>点击我</div>
} }
``` ```
@@ -34,13 +34,13 @@ function MyComponent() {
```tsx ```tsx
const { handlers } = useTouch({ const { handlers } = useTouch({
onSwipeLeft: () => console.log("向左滑动"), onSwipeLeft: () => console.log('向左滑动'),
onSwipeRight: () => console.log("向右滑动"), onSwipeRight: () => console.log('向右滑动'),
onSwipeUp: () => console.log("向上滑动"), onSwipeUp: () => console.log('向上滑动'),
onSwipeDown: () => console.log("向下滑动"), onSwipeDown: () => console.log('向下滑动'),
}); })
return <div {...handlers}>滑动区域</div>; return <div {...handlers}>滑动区域</div>
``` ```
### 3. 双击 ### 3. 双击
@@ -48,11 +48,11 @@ return <div {...handlers}>滑动区域</div>;
```tsx ```tsx
const { handlers } = useTouch({ const { handlers } = useTouch({
onDoubleTap: () => { onDoubleTap: () => {
console.log("双击触发"); console.log('双击触发')
}, },
}); })
return <div {...handlers}>双击我</div>; return <div {...handlers}>双击我</div>
``` ```
### 4. 长按 ### 4. 长按
@@ -60,12 +60,12 @@ return <div {...handlers}>双击我</div>;
```tsx ```tsx
const { handlers } = useTouch({ const { handlers } = useTouch({
onLongPress: () => { onLongPress: () => {
console.log("长按触发"); console.log('长按触发')
}, },
longPressDelay: 500, // 500ms 触发长按 longPressDelay: 500, // 500ms 触发长按
}); })
return <div {...handlers}>长按我</div>; return <div {...handlers}>长按我</div>
``` ```
## 高级用法 ## 高级用法
@@ -75,11 +75,11 @@ return <div {...handlers}>长按我</div>;
```tsx ```tsx
const { handlers, touchData, isTouching } = useTouch({ const { handlers, touchData, isTouching } = useTouch({
onTouchMove: (data) => { onTouchMove: (data) => {
console.log("移动距离:", data.deltaX, data.deltaY); console.log('移动距离:', data.deltaX, data.deltaY)
console.log("移动速度:", data.velocity); console.log('移动速度:', data.velocity)
console.log("移动方向:", data.direction); console.log('移动方向:', data.direction)
}, },
}); })
return ( return (
<div {...handlers}> <div {...handlers}>
@@ -89,20 +89,20 @@ return (
</div> </div>
)} )}
</div> </div>
); )
``` ```
### 图片查看器示例 ### 图片查看器示例
```tsx ```tsx
function ImageViewer() { function ImageViewer() {
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 })
const { handlers } = useTouch({ const { handlers } = useTouch({
onDoubleTap: () => { onDoubleTap: () => {
// 双击放大/还原 // 双击放大/还原
setScale(scale === 1 ? 2 : 1); setScale(scale === 1 ? 2 : 1)
}, },
onSwipe: (data) => { onSwipe: (data) => {
// 滑动移动图片 // 滑动移动图片
@@ -110,23 +110,23 @@ function ImageViewer() {
setPosition((prev) => ({ setPosition((prev) => ({
x: prev.x + data.deltaX, x: prev.x + data.deltaX,
y: prev.y + data.deltaY, y: prev.y + data.deltaY,
})); }))
} }
}, },
swipeThreshold: 5, swipeThreshold: 5,
}); })
return ( return (
<div {...handlers} style={{ overflow: "hidden" }}> <div {...handlers} style={{ overflow: 'hidden' }}>
<img <img
src="image.jpg" src="image.jpg"
style={{ style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`, transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transition: "transform 0.3s", transition: 'transform 0.3s',
}} }}
/> />
</div> </div>
); )
} }
``` ```
@@ -134,36 +134,36 @@ function ImageViewer() {
```tsx ```tsx
function SwipeableCard() { function SwipeableCard() {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0)
const { handlers } = useTouch({ const { handlers } = useTouch({
onTouchMove: (data) => { onTouchMove: (data) => {
if (data.direction === "left" || data.direction === "right") { if (data.direction === 'left' || data.direction === 'right') {
setOffset(data.deltaX); setOffset(data.deltaX)
} }
}, },
onTouchEnd: (data) => { onTouchEnd: (data) => {
if (Math.abs(data.deltaX) > 100) { if (Math.abs(data.deltaX) > 100) {
// 滑动超过 100px,执行删除 // 滑动超过 100px,执行删除
onDelete(); onDelete()
} else { } else {
// 弹回原位 // 弹回原位
setOffset(0); setOffset(0)
} }
}, },
}); })
return ( return (
<div <div
{...handlers} {...handlers}
style={{ style={{
transform: `translateX(${offset}px)`, transform: `translateX(${offset}px)`,
transition: offset === 0 ? "transform 0.3s" : "none", transition: offset === 0 ? 'transform 0.3s' : 'none',
}} }}
> >
<div>卡片内容</div> <div>卡片内容</div>
</div> </div>
); )
} }
``` ```
@@ -171,42 +171,38 @@ function SwipeableCard() {
```tsx ```tsx
function PullToRefresh() { function PullToRefresh() {
const [pullDistance, setPullDistance] = useState(0); const [pullDistance, setPullDistance] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false)
const { handlers } = useTouch({ const { handlers } = useTouch({
onTouchMove: (data) => { onTouchMove: (data) => {
// 只在向下滑动且在顶部时触发 // 只在向下滑动且在顶部时触发
if (data.direction === "down" && window.scrollY === 0) { if (data.direction === 'down' && window.scrollY === 0) {
setPullDistance(Math.min(data.deltaY, 80)); setPullDistance(Math.min(data.deltaY, 80))
} }
}, },
onTouchEnd: (data) => { onTouchEnd: (data) => {
if (pullDistance > 50) { if (pullDistance > 50) {
setIsRefreshing(true); setIsRefreshing(true)
// 执行刷新逻辑 // 执行刷新逻辑
refresh().then(() => { refresh().then(() => {
setIsRefreshing(false); setIsRefreshing(false)
setPullDistance(0); setPullDistance(0)
}); })
} else { } else {
setPullDistance(0); setPullDistance(0)
} }
}, },
}); })
return ( return (
<div {...handlers}> <div {...handlers}>
<div style={{ height: pullDistance, transition: "height 0.3s" }}> <div style={{ height: pullDistance, transition: 'height 0.3s' }}>
{isRefreshing {isRefreshing ? '刷新中...' : pullDistance > 50 ? '松开刷新' : '下拉刷新'}
? "刷新中..."
: pullDistance > 50
? "松开刷新"
: "下拉刷新"}
</div> </div>
<div>内容区域</div> <div>内容区域</div>
</div> </div>
); )
} }
``` ```
@@ -214,34 +210,34 @@ function PullToRefresh() {
```tsx ```tsx
function Carousel({ images }: { images: string[] }) { function Carousel({ images }: { images: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0)
const { handlers } = useTouch({ const { handlers } = useTouch({
onSwipeLeft: () => { onSwipeLeft: () => {
setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1)); setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1))
}, },
onSwipeRight: () => { onSwipeRight: () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0)); setCurrentIndex((prev) => Math.max(prev - 1, 0))
}, },
swipeThreshold: 50, swipeThreshold: 50,
swipeVelocityThreshold: 0.3, swipeVelocityThreshold: 0.3,
}); })
return ( return (
<div {...handlers} style={{ overflow: "hidden" }}> <div {...handlers} style={{ overflow: 'hidden' }}>
<div <div
style={{ style={{
display: "flex", display: 'flex',
transform: `translateX(-${currentIndex * 100}%)`, transform: `translateX(-${currentIndex * 100}%)`,
transition: "transform 0.3s", transition: 'transform 0.3s',
}} }}
> >
{images.map((img, i) => ( {images.map((img, i) => (
<img key={i} src={img} style={{ width: "100%", flexShrink: 0 }} /> <img key={i} src={img} style={{ width: '100%', flexShrink: 0 }} />
))} ))}
</div> </div>
</div> </div>
); )
} }
``` ```
@@ -250,34 +246,34 @@ function Carousel({ images }: { images: string[] }) {
```typescript ```typescript
interface UseTouchOptions { interface UseTouchOptions {
// 基础配置 // 基础配置
enabled?: boolean; // 是否启用,默认 true enabled?: boolean // 是否启用,默认 true
preventDefault?: boolean; // 是否阻止默认行为,默认 false preventDefault?: boolean // 是否阻止默认行为,默认 false
stopPropagation?: boolean; // 是否阻止事件冒泡,默认 false stopPropagation?: boolean // 是否阻止事件冒泡,默认 false
// 滑动配置 // 滑动配置
swipeThreshold?: number; // 滑动阈值(px),默认 10 swipeThreshold?: number // 滑动阈值(px),默认 10
swipeVelocityThreshold?: number; // 滑动速度阈值(px/ms),默认 0.5 swipeVelocityThreshold?: number // 滑动速度阈值(px/ms),默认 0.5
// 长按配置 // 长按配置
longPressDelay?: number; // 长按延迟(ms),默认 500 longPressDelay?: number // 长按延迟(ms),默认 500
longPressMoveThreshold?: number; // 长按允许移动距离(px),默认 10 longPressMoveThreshold?: number // 长按允许移动距离(px),默认 10
// 双击配置 // 双击配置
doubleTapDelay?: number; // 双击间隔(ms),默认 300 doubleTapDelay?: number // 双击间隔(ms),默认 300
doubleTapMoveThreshold?: number; // 双击允许移动距离(px),默认 10 doubleTapMoveThreshold?: number // 双击允许移动距离(px),默认 10
// 事件回调 // 事件回调
onTouchStart?: (data: TouchEventData) => void; onTouchStart?: (data: TouchEventData) => void
onTouchMove?: (data: TouchEventData) => void; onTouchMove?: (data: TouchEventData) => void
onTouchEnd?: (data: TouchEventData) => void; onTouchEnd?: (data: TouchEventData) => void
onTap?: (data: TouchEventData) => void; onTap?: (data: TouchEventData) => void
onDoubleTap?: (data: TouchEventData) => void; onDoubleTap?: (data: TouchEventData) => void
onLongPress?: (data: TouchEventData) => void; onLongPress?: (data: TouchEventData) => void
onSwipe?: (data: TouchEventData) => void; onSwipe?: (data: TouchEventData) => void
onSwipeLeft?: (data: TouchEventData) => void; onSwipeLeft?: (data: TouchEventData) => void
onSwipeRight?: (data: TouchEventData) => void; onSwipeRight?: (data: TouchEventData) => void
onSwipeUp?: (data: TouchEventData) => void; onSwipeUp?: (data: TouchEventData) => void
onSwipeDown?: (data: TouchEventData) => void; onSwipeDown?: (data: TouchEventData) => void
} }
``` ```
@@ -287,14 +283,14 @@ interface UseTouchOptions {
```typescript ```typescript
interface TouchEventData { interface TouchEventData {
startPoint: TouchPoint; // 起始位置 { x, y, timestamp } startPoint: TouchPoint // 起始位置 { x, y, timestamp }
currentPoint: TouchPoint; // 当前位置 { x, y, timestamp } currentPoint: TouchPoint // 当前位置 { x, y, timestamp }
deltaX: number; // X 轴移动距离 deltaX: number // X 轴移动距离
deltaY: number; // Y 轴移动距离 deltaY: number // Y 轴移动距离
distance: number; // 移动的总距离 distance: number // 移动的总距离
velocity: number; // 移动速度(像素/毫秒) velocity: number // 移动速度(像素/毫秒)
direction: SwipeDirection; // 移动方向: 'left' | 'right' | 'up' | 'down' | null direction: SwipeDirection // 移动方向: 'left' | 'right' | 'up' | 'down' | null
event: React.TouchEvent | React.MouseEvent; // 原始事件 event: React.TouchEvent | React.MouseEvent // 原始事件
} }
``` ```
@@ -302,14 +298,14 @@ interface TouchEventData {
```typescript ```typescript
interface UseTouchReturn { interface UseTouchReturn {
isTouching: boolean; // 是否正在触摸 isTouching: boolean // 是否正在触摸
touchData: TouchEventData | null; // 当前触摸数据 touchData: TouchEventData | null // 当前触摸数据
handlers: { handlers: {
onTouchStart: (e: React.TouchEvent) => void; onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void; onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: (e: React.TouchEvent) => void; onTouchEnd: (e: React.TouchEvent) => void
onMouseDown: (e: React.MouseEvent) => void; onMouseDown: (e: React.MouseEvent) => void
}; }
} }
``` ```
@@ -329,7 +325,7 @@ interface UseTouchReturn {
const { handlers } = useTouch({ const { handlers } = useTouch({
onSwipeLeft: handleSwipe, onSwipeLeft: handleSwipe,
preventDefault: false, // 不阻止默认行为,允许页面滚动 preventDefault: false, // 不阻止默认行为,允许页面滚动
}); })
``` ```
### 2. 性能优化 ### 2. 性能优化
@@ -342,9 +338,9 @@ const touchOptions = useMemo(
swipeThreshold: 50, swipeThreshold: 50,
}), }),
[handleSwipe] [handleSwipe]
); )
const { handlers } = useTouch(touchOptions); const { handlers } = useTouch(touchOptions)
``` ```
### 3. 条件禁用 ### 3. 条件禁用
@@ -353,31 +349,31 @@ const { handlers } = useTouch(touchOptions);
const { handlers } = useTouch({ const { handlers } = useTouch({
enabled: !isLoading, // 加载时禁用触摸 enabled: !isLoading, // 加载时禁用触摸
onTap: handleTap, onTap: handleTap,
}); })
``` ```
## 完整示例 ## 完整示例
```tsx ```tsx
import { useTouch } from "@/hooks"; import { useTouch } from '@/hooks'
import { useState } from "react"; import { useState } from 'react'
function TouchDemo() { function TouchDemo() {
const [log, setLog] = useState<string[]>([]); const [log, setLog] = useState<string[]>([])
const addLog = (message: string) => { const addLog = (message: string) => {
setLog((prev) => [...prev, message].slice(-5)); setLog((prev) => [...prev, message].slice(-5))
}; }
const { handlers, isTouching, touchData } = useTouch({ const { handlers, isTouching, touchData } = useTouch({
onTap: () => addLog("点击"), onTap: () => addLog('点击'),
onDoubleTap: () => addLog("双击"), onDoubleTap: () => addLog('双击'),
onLongPress: () => addLog("长按"), onLongPress: () => addLog('长按'),
onSwipeLeft: () => addLog("向左滑动"), onSwipeLeft: () => addLog('向左滑动'),
onSwipeRight: () => addLog("向右滑动"), onSwipeRight: () => addLog('向右滑动'),
onSwipeUp: () => addLog("向上滑动"), onSwipeUp: () => addLog('向上滑动'),
onSwipeDown: () => addLog("向下滑动"), onSwipeDown: () => addLog('向下滑动'),
}); })
return ( return (
<div> <div>
@@ -386,12 +382,12 @@ function TouchDemo() {
style={{ style={{
width: 300, width: 300,
height: 300, height: 300,
border: "2px solid #ccc", border: '2px solid #ccc',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
userSelect: "none", userSelect: 'none',
background: isTouching ? "#f0f0f0" : "white", background: isTouching ? '#f0f0f0' : 'white',
}} }}
> >
触摸测试区域 触摸测试区域
@@ -410,7 +406,7 @@ function TouchDemo() {
))} ))}
</div> </div>
</div> </div>
); )
} }
``` ```
+128 -134
View File
@@ -1,244 +1,238 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "./styles/input.css"; @import './styles/input.css';
/* 定义字体 */ /* 定义字体 */
@font-face { @font-face {
font-family: "SourceHanSerifCN"; font-family: 'SourceHanSerifCN';
src: url("/fonts/SourceHanSerifCN-Bold-2.otf") format("opentype"); src: url('/fonts/SourceHanSerifCN-Bold-2.otf') format('opentype');
font-weight: bold; font-weight: bold;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
/* *{ */ /* *{ */
/* margin: 0; /* margin: 0;
padding: 0; */ padding: 0; */
/* box-sizing: border-box; */ /* box-sizing: border-box; */
/* } */ /* } */
body{ body {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background-color: #000; background-color: #000;
font-family: "SourceHanSerifCN", serif; font-family: 'SourceHanSerifCN', serif;
} }
#root{ #root {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
button:not([disabled]){ button:not([disabled]) {
cursor: pointer; cursor: pointer;
} }
img, video{ img,
max-width: unset; video {
max-width: unset;
} }
/* 自定义滚动条样式 */ /* 自定义滚动条样式 */
.custom-scrollbar { .custom-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(139, 115, 85, 0.6) transparent; scrollbar-color: rgba(139, 115, 85, 0.6) transparent;
} }
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 8px; width: 8px;
} }
.custom-scrollbar::-webkit-scrollbar-track { .custom-scrollbar::-webkit-scrollbar-track {
background: transparent; background: transparent;
margin: 0; margin: 0;
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgba(139, 115, 85, 0.8), rgba(107, 93, 71, 0.8)); background: linear-gradient(to bottom, rgba(139, 115, 85, 0.8), rgba(107, 93, 71, 0.8));
border-radius: 4px; border-radius: 4px;
border: none; border: none;
transition: background 0.3s ease; transition: background 0.3s ease;
} }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, rgba(139, 115, 85, 1), rgba(107, 93, 71, 1)); background: linear-gradient(to bottom, rgba(139, 115, 85, 1), rgba(107, 93, 71, 1));
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.4); box-shadow: 0 2px 8px rgba(139, 115, 85, 0.4);
} }
.custom-scrollbar::-webkit-scrollbar-thumb:active { .custom-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(to bottom, rgba(107, 93, 71, 1), rgba(91, 79, 61, 1)); background: linear-gradient(to bottom, rgba(107, 93, 71, 1), rgba(91, 79, 61, 1));
} }
/* 多选题面板滚动条样式 - 金色主题 */ /* 多选题面板滚动条样式 - 金色主题 */
.quiz-scrollbar { .quiz-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(212, 197, 169, 0.7) transparent; scrollbar-color: rgba(212, 197, 169, 0.7) transparent;
} }
.quiz-scrollbar::-webkit-scrollbar { .quiz-scrollbar::-webkit-scrollbar {
width: 10px; width: 10px;
} }
.quiz-scrollbar::-webkit-scrollbar-track { .quiz-scrollbar::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
border-radius: 5px; border-radius: 5px;
margin: 8px 0; margin: 8px 0;
} }
.quiz-scrollbar::-webkit-scrollbar-thumb { .quiz-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, background: linear-gradient(to bottom, rgba(212, 197, 169, 0.8), rgba(240, 190, 117, 0.8));
rgba(212, 197, 169, 0.8), border-radius: 5px;
rgba(240, 190, 117, 0.8)); border: 2px solid rgba(0, 0, 0, 0.1);
border-radius: 5px; transition: all 0.3s ease;
border: 2px solid rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
} }
.quiz-scrollbar::-webkit-scrollbar-thumb:hover { .quiz-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, background: linear-gradient(to bottom, rgba(240, 190, 117, 0.9), rgba(212, 197, 169, 0.9));
rgba(240, 190, 117, 0.9), box-shadow: 0 2px 8px rgba(240, 190, 117, 0.5);
rgba(212, 197, 169, 0.9)); border-color: rgba(240, 190, 117, 0.3);
box-shadow: 0 2px 8px rgba(240, 190, 117, 0.5);
border-color: rgba(240, 190, 117, 0.3);
} }
.quiz-scrollbar::-webkit-scrollbar-thumb:active { .quiz-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(to bottom, background: linear-gradient(to bottom, rgba(240, 190, 117, 1), rgba(212, 197, 169, 1));
rgba(240, 190, 117, 1),
rgba(212, 197, 169, 1));
} }
/* 搜索图标动画 */ /* 搜索图标动画 */
@keyframes search-pulse { @keyframes search-pulse {
0%, 100% { 0%,
transform: translate(-50%, -50%) scale(1) rotate(0deg); 100% {
opacity: 1; transform: translate(-50%, -50%) scale(1) rotate(0deg);
} opacity: 1;
50% { }
transform: translate(-50%, -50%) scale(1.1) rotate(5deg); 50% {
opacity: 0.8; transform: translate(-50%, -50%) scale(1.1) rotate(5deg);
} opacity: 0.8;
}
} }
.animate-search { .animate-search {
animation: search-pulse 2s ease-in-out infinite; animation: search-pulse 2s ease-in-out infinite;
} }
/* 三个点加载动画 */ /* 三个点加载动画 */
@keyframes dot-bounce { @keyframes dot-bounce {
0%, 80%, 100% { 0%,
transform: scale(0); 80%,
opacity: 0.5; 100% {
} transform: scale(0);
40% { opacity: 0.5;
transform: scale(1); }
opacity: 1; 40% {
} transform: scale(1);
opacity: 1;
}
} }
.animate-dots > span { .animate-dots > span {
animation: dot-bounce 1.4s ease-in-out infinite both; animation: dot-bounce 1.4s ease-in-out infinite both;
} }
.animate-dots > span:nth-child(1) { .animate-dots > span:nth-child(1) {
animation-delay: -0.32s; animation-delay: -0.32s;
} }
.animate-dots > span:nth-child(2) { .animate-dots > span:nth-child(2) {
animation-delay: -0.16s; animation-delay: -0.16s;
} }
.animate-dots > span:nth-child(3) { .animate-dots > span:nth-child(3) {
animation-delay: 0s; animation-delay: 0s;
} }
/* 页面过渡动画 - 确保初始状态为透明,避免闪烁 */ /* 页面过渡动画 - 确保初始状态为透明,避免闪烁 */
.page-transition-wrapper > div[data-framer-name] { .page-transition-wrapper > div[data-framer-name] {
opacity: 0; opacity: 0;
} }
/* 鲁迅页面滚动条样式 - 古典文化风格 */ /* 鲁迅页面滚动条样式 - 古典文化风格 */
.luxun-scrollbar { .luxun-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(212, 175, 55, 0.85) rgba(250, 245, 235, 0.08); scrollbar-color: rgba(212, 175, 55, 0.85) rgba(250, 245, 235, 0.08);
} }
.luxun-scrollbar::-webkit-scrollbar { .luxun-scrollbar::-webkit-scrollbar {
width: 14px; width: 14px;
} }
.luxun-scrollbar::-webkit-scrollbar-track { .luxun-scrollbar::-webkit-scrollbar-track {
background: linear-gradient( background: linear-gradient(to bottom, rgba(250, 245, 235, 0.06), rgba(240, 235, 225, 0.04));
to bottom, border-radius: 12px;
rgba(250, 245, 235, 0.06), border: 1.5px solid rgba(139, 115, 71, 0.2);
rgba(240, 235, 225, 0.04) margin: 6px 2px;
); box-shadow:
border-radius: 12px; inset 0 0 8px rgba(139, 115, 71, 0.08),
border: 1.5px solid rgba(139, 115, 71, 0.2); inset 0 0 2px rgba(255, 255, 255, 0.1);
margin: 6px 2px;
box-shadow:
inset 0 0 8px rgba(139, 115, 71, 0.08),
inset 0 0 2px rgba(255, 255, 255, 0.1);
} }
.luxun-scrollbar::-webkit-scrollbar-thumb { .luxun-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(240, 190, 117, 0.95), rgba(240, 190, 117, 0.95),
rgba(212, 175, 55, 0.95), rgba(212, 175, 55, 0.95),
rgba(184, 134, 11, 0.95) rgba(184, 134, 11, 0.95)
); );
border-radius: 12px; border-radius: 12px;
border: 2px solid rgba(250, 245, 235, 0.4); border: 2px solid rgba(250, 245, 235, 0.4);
box-shadow: box-shadow:
inset 0 1px 3px rgba(255, 255, 255, 0.25), inset 0 1px 3px rgba(255, 255, 255, 0.25),
inset 0 -1px 2px rgba(139, 115, 71, 0.2), inset 0 -1px 2px rgba(139, 115, 71, 0.2),
0 2px 6px rgba(139, 115, 71, 0.25), 0 2px 6px rgba(139, 115, 71, 0.25),
0 0 10px rgba(212, 175, 55, 0.15); 0 0 10px rgba(212, 175, 55, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
} }
/* 滚动条滑块内部装饰线效果 */ /* 滚动条滑块内部装饰线效果 */
.luxun-scrollbar::-webkit-scrollbar-thumb:hover { .luxun-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(255, 215, 140, 1), rgba(255, 215, 140, 1),
rgba(240, 190, 117, 1), rgba(240, 190, 117, 1),
rgba(212, 175, 55, 1) rgba(212, 175, 55, 1)
); );
box-shadow: box-shadow:
inset 0 1px 4px rgba(255, 255, 255, 0.35), inset 0 1px 4px rgba(255, 255, 255, 0.35),
inset 0 -1px 3px rgba(139, 115, 71, 0.25), inset 0 -1px 3px rgba(139, 115, 71, 0.25),
0 3px 8px rgba(139, 115, 71, 0.35), 0 3px 8px rgba(139, 115, 71, 0.35),
0 0 14px rgba(212, 175, 55, 0.3); 0 0 14px rgba(212, 175, 55, 0.3);
border-color: rgba(250, 245, 235, 0.6); border-color: rgba(250, 245, 235, 0.6);
transform: scaleX(1.05); transform: scaleX(1.05);
} }
.luxun-scrollbar::-webkit-scrollbar-thumb:active { .luxun-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(184, 134, 11, 1), rgba(184, 134, 11, 1),
rgba(139, 115, 71, 1), rgba(139, 115, 71, 1),
rgba(107, 93, 71, 1) rgba(107, 93, 71, 1)
); );
box-shadow: box-shadow:
inset 0 2px 5px rgba(0, 0, 0, 0.4), inset 0 2px 5px rgba(0, 0, 0, 0.4),
inset 0 -1px 2px rgba(139, 115, 71, 0.3), inset 0 -1px 2px rgba(139, 115, 71, 0.3),
0 1px 3px rgba(139, 115, 71, 0.3); 0 1px 3px rgba(139, 115, 71, 0.3);
border-color: rgba(250, 245, 235, 0.2); border-color: rgba(250, 245, 235, 0.2);
} }
/* 点一点图标动画 - 手指点击动画(上下移动模拟点击) */ /* 点一点图标动画 - 手指点击动画(上下移动模拟点击) */
@keyframes finger-bounce { @keyframes finger-bounce {
0%, 100% { 0%,
transform: translateY(0) translateX(0); 100% {
} transform: translateY(0) translateX(0);
50% { }
transform: translateY(-10px) translateX(5px); 50% {
} transform: translateY(-10px) translateX(5px);
}
} }
.animate-finger-bounce { .animate-finger-bounce {
animation: finger-bounce 1.2s ease-in-out infinite; animation: finger-bounce 1.2s ease-in-out infinite;
} }
+6
View File
@@ -4,6 +4,12 @@ import './index.css'
import App from './App' import App from './App'
import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useSunzhongshanStore } from './store' import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useSunzhongshanStore } from './store'
if (import.meta.env.VITE_DEBUG_ENTRY === 'true' && typeof window !== 'undefined') {
import('@/debug/progressionDebug').then(({ setupProgressionDebug }) => {
setupProgressionDebug()
})
}
// 在开发模式的移动端启用 vConsole(需先安装 vconsole 依赖) // 在开发模式的移动端启用 vConsole(需先安装 vconsole 依赖)
if (import.meta.env.DEV && typeof window !== 'undefined') { if (import.meta.env.DEV && typeof window !== 'undefined') {
const ua = window.navigator.userAgent.toLowerCase() const ua = window.navigator.userAgent.toLowerCase()
+2 -15
View File
@@ -2,14 +2,12 @@ import { useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image, PuzzleSystem } from '@/components' import { Image, PuzzleSystem } from '@/components'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter'
import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences' import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { isMenuModuleComplete } from '@/config/progression' import { isMenuModuleComplete } from '@/config/progression'
const Menu = () => { const Menu = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false)
const [activeResidence, setActiveResidence] = useState<string | null>(null) const [activeResidence, setActiveResidence] = useState<string | null>(null)
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
@@ -65,17 +63,6 @@ const Menu = () => {
/> />
</div> </div>
{/* 顶层碎片管理中心:查看故居导图与进度 */}
<MenuPuzzleCenter
visible={isPuzzleCenterVisible}
onClose={() => setIsPuzzleCenterVisible(false)}
onSelectResidence={(residence) => {
setActiveResidence(residence)
setIsPuzzleCenterVisible(false)
}}
allowPuzzleEntry={false}
/>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */} {/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
{activeResidence && ( {activeResidence && (
<PuzzleSystem <PuzzleSystem
@@ -102,7 +89,7 @@ const Menu = () => {
className="relative w-[241px] h-[282px] bg-cover bg-center bg-no-repeat hover:brightness-110 hover:scale-105 transition-all cursor-pointer" className="relative w-[241px] h-[282px] bg-cover bg-center bg-no-repeat hover:brightness-110 hover:scale-105 transition-all cursor-pointer"
style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }} style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }}
key={item.id} key={item.id}
onClick={() => navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)} onClick={() => navigate(`/${encodeURIComponent(item.moduleName)}`)}
> >
<div className="absolute top-15 flex flex-col items-center"> <div className="absolute top-15 flex flex-col items-center">
<Image key={item.id} src={getStaticImage(item.image)} /> <Image key={item.id} src={getStaticImage(item.image)} />
@@ -132,7 +119,7 @@ const Menu = () => {
<Image <Image
src={getStaticImage('menu/导航地图')} src={getStaticImage('menu/导航地图')}
button button
onClick={() => setIsPuzzleCenterVisible(true)} onClick={() => navigate('/learning-map')}
/> />
</div> </div>
</div> </div>
+1 -1
View File
@@ -84,7 +84,7 @@ const Chenyun = () => {
className="absolute right-37 top-22" className="absolute right-37 top-22"
src={getStaticImage('components/革命先驱故居-返回')} src={getStaticImage('components/革命先驱故居-返回')}
button button
onClick={() => navigate(`/entrance/${moduleName}`)} onClick={() => navigate(`/${moduleName}`)}
/> />
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
+24 -13
View File
@@ -55,7 +55,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
value: 'B', value: 'B',
label: '鸟笼比喻', label: '鸟笼比喻',
}, },
{ value: 'C', label: '正确的处理好计划与市场的关系', isCorrect: true }, { value: 'C', label: '正确地处理好计划与市场的关系', isCorrect: true },
{ value: 'D', label: '经济建设的目的论', isCorrect: true }, { value: 'D', label: '经济建设的目的论', isCorrect: true },
], ],
explanation: '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!', explanation: '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!',
@@ -69,14 +69,14 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
options: [ options: [
{ {
value: 'A', value: 'A',
label: '不唯上、不唯书、只唯实,交换、比较、反复', label: '不唯上、不唯书、只唯实,交换、比较、反复',
isCorrect: true, isCorrect: true,
}, },
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true }, { value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true }, { value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
{ {
value: 'D', value: 'D',
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作', label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
isCorrect: true, isCorrect: true,
}, },
], ],
@@ -378,7 +378,7 @@ const ChenyunInternalization = () => {
<Narration <Narration
visible={narrationVisible} visible={narrationVisible}
audioChannelId="chenyun-internalization-narration" audioChannelId="chenyun-internalization-narration"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_35_1.mp3')}
onClose={() => { onClose={() => {
setNarrationVisible(false) setNarrationVisible(false)
setVisible(true) setVisible(true)
@@ -417,11 +417,14 @@ const ChenyunInternalization = () => {
> >
<GuideStepsItem <GuideStepsItem
speaker="student" speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_1.mp3')}
> >
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。 小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="student"> <GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_2.mp3')}
>
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗? 他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
@@ -447,18 +450,26 @@ const ChenyunInternalization = () => {
> >
<GuideStepsItem <GuideStepsItem
speaker="student" speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38_1.mp3')}
> >
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心。 感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持“银元之战”“米棉之战”的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他“不唯上、不唯书、只唯实”的精神,也懂了他“为老百姓做事”的初心。
</GuideStepsItem>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38_2.mp3')}
>
这些都让我特别受触动!
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="student">这些都让我特别受触动!</GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_1.mp3')}
> >
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。 你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_2.mp3')}
>
你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧! 你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧!
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+20 -22
View File
@@ -36,12 +36,11 @@ const ChenyunPlace1 = () => {
// 学习引导弹窗 // 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true) const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导弹窗 // 温馨提示弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false) const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false)
// 学习说明对话框显示状态 // 学习说明对话框显示状态
const [showIntroDialog, setShowIntroDialog] = useState(false) const [showIntroDialog, setShowIntroDialog] = useState(false)
// 引导对话显示状态 // 温馨提示关闭后的场景对话显示状态
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识) // 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null) const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
@@ -169,12 +168,12 @@ const ChenyunPlace1 = () => {
}} }}
/> />
{/* 学习引导词 */} {/* 学习引导 */}
<StudyGuideModal <StudyGuideModal
visible={showGuideModal} visible={showGuideModal}
onContinue={() => { onContinue={() => {
setShowGuideModal(false) setShowGuideModal(false)
setVisible(true) setShowAfterDialogGuide(true)
}} }}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
ailgn="left" ailgn="left"
@@ -198,18 +197,8 @@ const ChenyunPlace1 = () => {
</Overlay> </Overlay>
)} )}
{/* 场景对话 */} {/* 温馨提示关闭后的场景对话 */}
<GuideSteps <GuideSteps visible={visible} onVisibleChange={setVisible} showMask={false}>
visible={visible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true)
}
}}
showMask={false}
>
<GuideStepsItem <GuideStepsItem
speaker="student" speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_03.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_03.mp3')}
@@ -218,22 +207,31 @@ const ChenyunPlace1 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_1.mp3')}
> >
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。 这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_2.mp3')}
>
实际上是陈云舅父母的家,也是少年陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。 实际上是陈云舅父母的家,也是少年陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_3.mp3')}
>
父母去世后,由外婆抚养。六岁时,外婆去世,陈云过继给舅父母当养子。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。 父母去世后,由外婆抚养。六岁时,外婆去世,陈云过继给舅父母当养子。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 对话结束后的学习引导 */} {/* 温馨提示 */}
<StudyGuideModal <StudyGuideModal
visible={showAfterDialogGuide} visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)} onContinue={() => {
setShowAfterDialogGuide(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')}
firstLineIndent="2em" firstLineIndent="2em"
> >
+10 -4
View File
@@ -168,11 +168,14 @@ const ChenyunPlace2 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06_1.mp3')}
> >
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县立乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。 因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县立乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06_2.mp3')}
>
陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。 陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
@@ -195,11 +198,14 @@ const ChenyunPlace2 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08_1.mp3')}
> >
是的,陈云在青浦县立乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。 是的,陈云在青浦县立乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08_2.mp3')}
>
颜安小学实行的新式教育开阔了陈云的视野,五四运动后,颜安小学师生积极投入反帝爱国运动,为陈云日后加入中国共产党埋下了思想的种子。 颜安小学实行的新式教育开阔了陈云的视野,五四运动后,颜安小学师生积极投入反帝爱国运动,为陈云日后加入中国共产党埋下了思想的种子。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+5 -2
View File
@@ -236,11 +236,14 @@ const ChenyunPlace3 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10_1.mp3')}
> >
从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。 从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10_2.mp3')}
>
陈云的珠算能力在商务印书馆工作期间有了很大提高。由于工作认真负责,三年的学徒期,陈云提前两年就破格到商务印书馆虹口分店(现为1925书局)当店员。 陈云的珠算能力在商务印书馆工作期间有了很大提高。由于工作认真负责,三年的学徒期,陈云提前两年就破格到商务印书馆虹口分店(现为1925书局)当店员。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+18 -6
View File
@@ -241,14 +241,20 @@ const ChenyunPlace4 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_1.mp3')}
> >
1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。 1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_2.mp3')}
>
虽然陈云在中央苏区的任务主要是领导工人运动,但是陈云总是能针对一些棘手的经济问题提出较出色的建议。 虽然陈云在中央苏区的任务主要是领导工人运动,但是陈云总是能针对一些棘手的经济问题提出较出色的建议。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_3.mp3')}
>
在中央苏区时期,陈云也关注和积极参与解决了许多经济难题。1934年,在中共六届五中全会上,陈云被选为中央政治局委员、常委,兼任中央白区工作部部长。 在中央苏区时期,陈云也关注和积极参与解决了许多经济难题。1934年,在中共六届五中全会上,陈云被选为中央政治局委员、常委,兼任中央白区工作部部长。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
@@ -271,14 +277,20 @@ const ChenyunPlace4 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_1.mp3')}
> >
中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。 中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_2.mp3')}
>
他强调,当务之急是重新严格审查各个企业、工厂以及商店、大小作坊的实际承受力和经济支付能力。从实际出发,实事求是地制定符合中央苏维埃区域每一个工厂、企业、作坊发展需求的劳动政策。 他强调,当务之急是重新严格审查各个企业、工厂以及商店、大小作坊的实际承受力和经济支付能力。从实际出发,实事求是地制定符合中央苏维埃区域每一个工厂、企业、作坊发展需求的劳动政策。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_3.mp3')}
>
陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。 陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+5 -2
View File
@@ -165,11 +165,14 @@ const ChenyunPlace5 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16_1.mp3')}
> >
1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。 1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16_2.mp3')}
>
国民党顽固派封锁边区,企图“不让一粒粮、一尺布进入边区”,同时千方百计阻止边区食盐出口。 国民党顽固派封锁边区,企图“不让一粒粮、一尺布进入边区”,同时千方百计阻止边区食盐出口。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+5 -2
View File
@@ -134,11 +134,14 @@ const ChenyunPlace6 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18_1.mp3')}
> >
是的,新中国刚成立时,中国共产党接管的是一个民穷财尽、物价飞涨的烂摊子。面对财政经济上的严重困难,陈云在1949年受命担任新成立的中央财政经济委员会(简称中财委)主任,主持领导全国财政经济工作。 是的,新中国刚成立时,中国共产党接管的是一个民穷财尽、物价飞涨的烂摊子。面对财政经济上的严重困难,陈云在1949年受命担任新成立的中央财政经济委员会(简称中财委)主任,主持领导全国财政经济工作。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18_2.mp3')}
>
陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。 陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+12 -7
View File
@@ -44,7 +44,7 @@ const QUIZ1_TITLE =
const QUIZ1_ERROR_HINTS: Record<string, string> = { const QUIZ1_ERROR_HINTS: Record<string, string> = {
A: '淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。请你再想想吧!', A: '淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。请你再想想吧!',
C: '1949年6月新中国政权尚未建立。请你再想想吧', C: '1949年6月新中国政权尚未建立。请你再想想吧!',
} }
const QUIZ1_FALLBACK_HINT = '再想一想,注意题干中的时间和提示信息哦~' const QUIZ1_FALLBACK_HINT = '再想一想,注意题干中的时间和提示信息哦~'
@@ -305,18 +305,24 @@ const ChenyunPlace7 = () => {
speaker="student" speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_19.mp3`)} audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_19.mp3`)}
> >
那"银元之战"是如何取得胜利的呢? 那“银元之战”是如何取得胜利的呢?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20.mp3`)} audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_1.mp3`)}
> >
由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传“大街过三道,物价跳三跳”。 由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传“大街过三道,物价跳三跳”。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_2.mp3`)}
>
有人计算,一百元法币,在一九三七年可买两头牛,一九四五年只能买两个鸡蛋,一九四七年只能买一个煤球,到一九四九年五月连一粒米也买不到。可见,当时通货膨胀多么严重。 有人计算,一百元法币,在一九三七年可买两头牛,一九四五年只能买两个鸡蛋,一九四七年只能买一个煤球,到一九四九年五月连一粒米也买不到。可见,当时通货膨胀多么严重。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_3.mp3`)}
>
打击投机资本,稳定金融物价,陈云代中央起草《中共中央关于打击银元使人民币占领阵地的指示》,提出以强硬手段打击投机势力。 打击投机资本,稳定金融物价,陈云代中央起草《中共中央关于打击银元使人民币占领阵地的指示》,提出以强硬手段打击投机势力。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
@@ -364,7 +370,7 @@ const ChenyunPlace7 = () => {
speaker="student" speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_21.mp3`)} audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_21.mp3`)}
> >
那"两白一黑"战役是如何打赢的呢? 那“两白一黑”战役是如何打赢的呢?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
@@ -395,7 +401,6 @@ const ChenyunPlace7 = () => {
title="恭喜你, 选择正确!" title="恭喜你, 选择正确!"
content={successContent} content={successContent}
onClose={handleSuccessDismiss} onClose={handleSuccessDismiss}
onContinue={handleSuccessDismiss}
/> />
</div> </div>
)} )}
+14 -5
View File
@@ -189,11 +189,14 @@ const ChenyunPlace8 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26_1.mp3')}
> >
不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的“统购统销”。 不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的“统购统销”。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26_2.mp3')}
>
陈云说:“只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。” 陈云说:“只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。”
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
@@ -231,14 +234,20 @@ const ChenyunPlace8 = () => {
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_1.mp3')}
> >
一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。 一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_2.mp3')}
>
这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。 这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_3.mp3')}
>
第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过“统购统销”稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。 第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过“统购统销”稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+9 -3
View File
@@ -147,14 +147,20 @@ const ChenyunPlace9 = () => {
> >
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27.mp3')} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_1.mp3')}
> >
我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他“适合老百姓的要求,做了一点事”的初心。 我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他“适合老百姓的要求,做了一点事”的初心。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_2.mp3')}
>
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的“银元之战”和“米棉之战”之中去。 接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的“银元之战”和“米棉之战”之中去。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_3.mp3')}
>
在实践决策中体会陈云“多谋善断”“敢闯务实”的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。 在实践决策中体会陈云“多谋善断”“敢闯务实”的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
@@ -9,7 +9,7 @@ import type { ClueData } from '../clue/types'
// 银元之战视频配置 // 银元之战视频配置
export const YINYUAN_VIDEO_CONFIG = { export const YINYUAN_VIDEO_CONFIG = {
src: getPublicPath('video/chenyun/chenyun_15.mp4'), src: getPublicPath('video/chenyun/chenyun_15.mp4'),
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战。你将化身陈云同志的特派员,直面这场“银元之战”。"`, narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战”。”你将化身陈云同志的特派员,直面这场“银元之战”。`,
alert: { alert: {
title: '第一幕:“银元之战”--人民币的阵地争夺战', title: '第一幕:“银元之战”--人民币的阵地争夺战',
subtitle: '上海目前出现大肆贩卖银元的情况,请你了解情况!', subtitle: '上海目前出现大肆贩卖银元的情况,请你了解情况!',
@@ -36,10 +36,10 @@ export const YINYUAN_CLUE_DATA: Record<
title: '线索二', title: '线索二',
content: ( content: (
<div className="flex flex-col items-start text-left gap-2 px-14"> <div className="flex flex-col items-start text-left gap-2 px-14">
<div className="mb-8"> <div className="mb-8 indent-14">
南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。 南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。
</div> </div>
<div>其他商户纷纷仿效,人民币在市场交易中地位下降。</div> <div className="indent-14">其他商户纷纷仿效,人民币在市场交易中地位下降。</div>
</div> </div>
), ),
}, },
+34 -4
View File
@@ -2,7 +2,7 @@
* 陈云反思页面主入口 * 陈云反思页面主入口
* 整合银元之战和米棉之战两个模块 * 整合银元之战和米棉之战两个模块
*/ */
import { Image, StudyGuideModal } from '@/components' import { Image, StudyGuideModal, Video } from '@/components'
import ErrorAlert from '@/components/layout/alert/error-alert' import ErrorAlert from '@/components/layout/alert/error-alert'
import TitleAlert from '@/components/layout/alert/title-alert' import TitleAlert from '@/components/layout/alert/title-alert'
import ClueOverlay from '@/pages/chenyun/reflection/clue' import ClueOverlay from '@/pages/chenyun/reflection/clue'
@@ -63,10 +63,10 @@ const ChenyunReflection = () => {
/** 是否显示线索弹窗 */ /** 是否显示线索弹窗 */
const [showClueOverlay, setShowClueOverlay] = useState(false) const [showClueOverlay, setShowClueOverlay] = useState(false)
const [showOpeningVideo, setShowOpeningVideo] = useState(true)
/** 欢迎语弹窗(使用 StudyGuideModal 展示入口引导) */ /** 欢迎语弹窗(使用 StudyGuideModal 展示入口引导) */
const [showWelcomeGuide, setShowWelcomeGuide] = useState( const [showWelcomeGuide, setShowWelcomeGuide] = useState(false)
() => !useChenyunStore.getState().isYinyuanSolved
)
/** 第一幕银元之战 - 学习引导弹窗 */ /** 第一幕银元之战 - 学习引导弹窗 */
const [showYinyuanGuide, setShowYinyuanGuide] = useState(false) const [showYinyuanGuide, setShowYinyuanGuide] = useState(false)
@@ -219,6 +219,36 @@ const ChenyunReflection = () => {
backgroundImage: getBackgroundImage(`chenyun/reflection/底图`), backgroundImage: getBackgroundImage(`chenyun/reflection/底图`),
}} }}
> >
{showOpeningVideo && (
<div className="fixed inset-0 z-10000 w-full h-full bg-black">
<Video
src={getPublicPath('video/reflection/chenyun_tiwu_00.mp4')}
autoPlay
playsInline
showSkipButton
skipText="跳过"
onEnded={() => {
setShowOpeningVideo(false)
if (!useChenyunStore.getState().isYinyuanSolved) {
setShowWelcomeGuide(true)
}
}}
onSkip={() => {
setShowOpeningVideo(false)
if (!useChenyunStore.getState().isYinyuanSolved) {
setShowWelcomeGuide(true)
}
}}
onError={() => {
setShowOpeningVideo(false)
if (!useChenyunStore.getState().isYinyuanSolved) {
setShowWelcomeGuide(true)
}
}}
/>
</div>
)}
{/* 离开按钮 */} {/* 离开按钮 */}
{expandedFile == null && ( {expandedFile == null && (
<Image <Image
@@ -139,7 +139,7 @@ export const YinyuanBattle = ({
// 关闭多选题1,显示旁白文字(图2) // 关闭多选题1,显示旁白文字(图2)
state.setShowQuiz1(false) state.setShowQuiz1(false)
state.setNarration2Text( state.setNarration2Text(
`六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了"反对银元投机,保障人民生活"的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商"赶快洗手不干,否则勿谓言之不预"。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:"单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。"` `六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了“反对银元投机,保障人民生活”的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商“赶快洗手不干,否则勿谓言之不预”。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:“单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。”`
) )
state.setShowNarration2(true) state.setShowNarration2(true)
} }
@@ -77,7 +77,7 @@ export const YinyuanGuides = ({
<ExhibitOverlay <ExhibitOverlay
visible={showTrendChartDetail} visible={showTrendChartDetail}
src="chenyun/reflection/银元价格走势图" src="chenyun/reflection/银元价格走势图"
description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`} description={`以上海为中心,代表“人民币信用”的绿色区域迅速覆盖全市,并向外辐射。`}
onClick={onTrendChartClick} onClick={onTrendChartClick}
onClose={onTrendChartDetailClose} onClose={onTrendChartDetailClose}
/> />
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more