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
+7271 -1811

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>=380 && NR<=540' src/config/puzzleData.ts)",
"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": [
"Read(public/**)",
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
+4 -2
View File
@@ -8,8 +8,10 @@
"bun": ">=1.0.0"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"dev": "vite --mode development",
"dev:test": "vite --mode test",
"build": "tsc && vite build --mode production",
"build:test": "tsc && vite build --mode test",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
+4901 -1
View File
File diff suppressed because it is too large. Load diff
+281 -231
View File
@@ -8,145 +8,157 @@
* http://en.wikipedia.org/wiki/Base64
*/
(function(global) {
'use strict';
;(function (global) {
'use strict'
// existing version for noConflict()
var _Base64 = global.Base64;
var version = "2.1.5";
var _Base64 = global.Base64
var version = '2.1.5'
// if node.js, we use Buffer
var buffer;
var buffer
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;
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) {
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)));
var cc = c.charCodeAt(0)
return cc < 0x80
? c
: cc < 0x800
? fromCharCode(0xc0 | (cc >>> 6)) + fromCharCode(0x80 | (cc & 0x3f))
: fromCharCode(0xe0 | ((cc >>> 12) & 0x0f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
} else {
var cc = 0x10000
+ (c.charCodeAt(0) - 0xD800) * 0x400
+ (c.charCodeAt(1) - 0xDC00);
return (fromCharCode(0xf0 | ((cc >>> 18) & 0x07))
+ fromCharCode(0x80 | ((cc >>> 12) & 0x3f))
+ fromCharCode(0x80 | ((cc >>> 6) & 0x3f))
+ fromCharCode(0x80 | ( cc & 0x3f)));
var cc = 0x10000 + (c.charCodeAt(0) - 0xd800) * 0x400 + (c.charCodeAt(1) - 0xdc00)
return (
fromCharCode(0xf0 | ((cc >>> 18) & 0x07)) +
fromCharCode(0x80 | ((cc >>> 12) & 0x3f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
)
}
};
var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g;
var utob = function(u) {
return u.replace(re_utob, cb_utob);
};
var cb_encode = function(ccc) {
}
var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g
var utob = function (u) {
return u.replace(re_utob, cb_utob)
}
var cb_encode = function (ccc) {
var padlen = [0, 2, 1][ccc.length % 3],
ord = ccc.charCodeAt(0) << 16
| ((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8)
| ((ccc.length > 2 ? ccc.charCodeAt(2) : 0)),
ord =
(ccc.charCodeAt(0) << 16) |
((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8) |
(ccc.length > 2 ? ccc.charCodeAt(2) : 0),
chars = [
b64chars.charAt( ord >>> 18),
b64chars.charAt(ord >>> 18),
b64chars.charAt((ord >>> 12) & 63),
padlen >= 2 ? '=' : b64chars.charAt((ord >>> 6) & 63),
padlen >= 1 ? '=' : b64chars.charAt(ord & 63)
];
return chars.join('');
};
var btoa = global.btoa ? function(b) {
return global.btoa(b);
} : function(b) {
return b.replace(/[\s\S]{1,3}/g, cb_encode);
};
padlen >= 1 ? '=' : b64chars.charAt(ord & 63),
]
return chars.join('')
}
var btoa = global.btoa
? function (b) {
return global.btoa(b)
}
: function (b) {
return b.replace(/[\s\S]{1,3}/g, cb_encode)
}
var _encode = buffer
? function (u) { return (new buffer(u)).toString('base64') }
: function (u) { return btoa(utob(u)) }
;
var encode = function(u, urisafe) {
? function (u) {
return new buffer(u).toString('base64')
}
: function (u) {
return btoa(utob(u))
}
var encode = function (u, urisafe) {
return !urisafe
? _encode(u)
: _encode(u).replace(/[+\/]/g, function(m0) {
return m0 == '+' ? '-' : '_';
}).replace(/=/g, '');
};
var encodeURI = function(u) { return encode(u, true) };
: _encode(u)
.replace(/[+\/]/g, function (m0) {
return m0 == '+' ? '-' : '_'
})
.replace(/=/g, '')
}
var encodeURI = function (u) {
return encode(u, true)
}
// decoder stuff
var re_btou = new RegExp([
'[\xC0-\xDF][\x80-\xBF]',
'[\xE0-\xEF][\x80-\xBF]{2}',
'[\xF0-\xF7][\x80-\xBF]{3}'
].join('|'), 'g');
var cb_btou = function(cccc) {
switch(cccc.length) {
var re_btou = new RegExp(
['[\xC0-\xDF][\x80-\xBF]', '[\xE0-\xEF][\x80-\xBF]{2}', '[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),
'g'
)
var cb_btou = function (cccc) {
switch (cccc.length) {
case 4:
var cp = ((0x07 & cccc.charCodeAt(0)) << 18)
| ((0x3f & cccc.charCodeAt(1)) << 12)
| ((0x3f & cccc.charCodeAt(2)) << 6)
| (0x3f & cccc.charCodeAt(3)),
offset = cp - 0x10000;
return (fromCharCode((offset >>> 10) + 0xD800)
+ fromCharCode((offset & 0x3FF) + 0xDC00));
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))
);
((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))
);
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 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);
};
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){
? 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 == '-' ? '+' : '/' })
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;
};
)
}
var noConflict = function () {
var Base64 = global.Base64
global.Base64 = _Base64
return Base64
}
// export Base64
global.Base64 = {
VERSION: version,
@@ -159,57 +171,66 @@
encodeURI: encodeURI,
btou: btou,
decode: decode,
noConflict: noConflict
};
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};
};
var noEnum = function (v) {
return { value: v, enumerable: false, writable: true, configurable: true }
}
global.Base64.extendString = function () {
Object.defineProperty(
String.prototype, 'fromBase64', noEnum(function () {
String.prototype,
'fromBase64',
noEnum(function () {
return decode(this)
}));
})
)
Object.defineProperty(
String.prototype, 'toBase64', noEnum(function (urisafe) {
String.prototype,
'toBase64',
noEnum(function (urisafe) {
return encode(this, urisafe)
}));
})
)
Object.defineProperty(
String.prototype, 'toBase64URI', noEnum(function () {
String.prototype,
'toBase64URI',
noEnum(function () {
return encode(this, true)
}));
};
})
)
}
}
// that's it!
})(this);
})(this)
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...
var host = '/';
var host = '/'
this._setHost=function(hoststr) {
host = hoststr;
};
this._getHost = function(){
return host;
this._setHost = function (hoststr) {
host = hoststr
}
this._getHost = function () {
return host
}
}
};
$.extend(videoajax.prototype,{
$.extend(videoajax.prototype, {
/**
* 设置主域名
* @param hosts {String} default: '/' 主域名字符串
*/
setHost :function(hosts){
this._setHost(hosts);
setHost: function (hosts) {
this._setHost(hosts)
},
//user_id,course_id,lesson_id,break_point
/**
@@ -221,15 +242,18 @@ if (this['Meteor']) {
* 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) : '';
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
@@ -240,16 +264,19 @@ if (this['Meteor']) {
* 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) : '';
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) : '';
}
);
)
},
/**
*用户答题答案
@@ -261,16 +288,18 @@ if (this['Meteor']) {
* 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) : '';
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) : ''
}
);
)
},
/**
*播放结束
@@ -279,14 +308,17 @@ if (this['Meteor']) {
* 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) : '';
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) : '';
}
);
)
},
/**
*课程进度获取
@@ -299,21 +331,24 @@ if (this['Meteor']) {
* 其中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) : '';
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);
fromBaseCode: function (basecode) {
return getBreakPoint(basecode)
},
/**
*通过key值和课时值获取自定的数据
@@ -322,14 +357,18 @@ if (this['Meteor']) {
* 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) : '';
});
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获取自定的数据
@@ -338,14 +377,18 @@ if (this['Meteor']) {
* 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) : '';
});
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) : ''
}
)
},
/**
*增加自定数据
@@ -355,53 +398,60 @@ if (this['Meteor']) {
* 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) : '';
});
addUserDefine: function (data, callback) {
var paramfiled = ['lessonId', 'key', 'content']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addUserDefine',
{
addDefineDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
})
var getDate = function(url,data,callback){
$.get(url,data,function(data){
callback(data);
},'json')
};
var isFunction = function(fn){
if(typeof fn === 'function')return true;
return false;
}
var verfiyData = function(field,data){
var arr=[];
console.log(field);
console.log('<br/>');
console.log(data);
for(var i in field){
if(!data[field[i]]){
throw new Error('data is error');
}
arr.push(field[i]+':'+data[field[i]]);
}
if(root.JSON){
return JSON.stringify(data);
}else{
return '{'+arr.join(',')+'}';
var getDate = function (url, data, callback) {
$.get(
url,
data,
function (data) {
callback(data)
},
'json'
)
}
var isFunction = function (fn) {
if (typeof fn === 'function') return true
return false
}
var verfiyData = function (field, data) {
var arr = []
console.log(field)
console.log('<br/>')
console.log(data)
for (var i in field) {
if (!data[field[i]]) {
throw new Error('data is error')
}
arr.push(field[i] + ':' + data[field[i]])
}
if (root.JSON) {
return JSON.stringify(data)
} else {
return '{' + arr.join(',') + '}'
}
}
var getBreakPoint = function (base64point) {
var newbc = base64point.split('').reverse().join('');
return Base64.fromBase64(newbc);
var newbc = base64point.split('').reverse().join('')
return Base64.fromBase64(newbc)
}
root.videoAjax = function(){
return new videoajax();
root.videoAjax = function () {
return new videoajax()
}
}(this));
})(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. 首页
- `鲁迅-首页.png` - 首页背景图(放在 luxun 根目录)
### 2. place1 - 风雨前夕的思想淬炼(探索鲁迅故居一楼客厅)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
@@ -18,6 +20,7 @@
- `展品5.png` - 展品5图片
### 3. place2 - 杂文集里的匕首与投枪(探索卧室兼书房)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
@@ -25,6 +28,7 @@
- `展品4.png` - 展品4图片
### 4. place3 - 历史新编中的现实呐喊(探索二楼卧室兼书房)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
@@ -32,21 +36,25 @@
- `展品4.png` - 展品4图片
### 5. place4 - 为亡友编纂的精神丰碑(探索二楼卧室兼书房的书桌)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
- `展品3.png` - 展品3图片
### 6. place5 - 6平方米的红色庇护所(探索鲁迅故居的客房)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
- `展品3.png` - 展品3图片
### 7. reflection - 体悟模块
- `底图.png` - 体悟模块背景图
### 8. internalization - 内化模块
- `底图.png` - 内化模块背景图
## 注意事项
@@ -54,4 +62,3 @@
1. 所有图片文件请使用 PNG 格式
2. 文件名请严格按照上述命名,区分大小写
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
import React, {
forwardRef,
useRef,
useState,
useImperativeHandle,
} from "react";
import { cn } from "@/lib/utils";
import React, { forwardRef, useRef, useState, useImperativeHandle } from 'react'
import { cn } from '@/lib/utils'
/** 组件配置项 */
export interface ComponentNameProps {
/** 必填属性说明 */
requiredProp: string;
requiredProp: string
/** 可选属性说明 */
optionalProp?: boolean;
optionalProp?: boolean
/** 子元素 */
children?: React.ReactNode;
children?: React.ReactNode
/** 自定义类名 */
className?: string;
className?: string
}
/** 组件 Ref 暴露的方法 */
export interface ComponentNameRef {
/** 方法说明 */
methodName: () => void;
methodName: () => void
}
/**
@@ -84,33 +79,33 @@ export interface ComponentNameRef {
const ComponentName = forwardRef<ComponentNameRef, ComponentNameProps>(
({ requiredProp, optionalProp = false, className, children }, ref) => {
// 状态定义
const [state, setState] = useState<string>("");
const [state, setState] = useState<string>('')
// Ref 定义
const innerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null)
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
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 导出模板
```ts
export { default } from "./component-name";
export { default as ComponentName } from "./component-name";
export type { ComponentNameProps, ComponentNameRef } from "./component-name";
export { default } from './component-name'
export { default as ComponentName } 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 viewElRef = useRef<HTMLDivElement | 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 lastDistance = useRef<number | null>(null)
// 拖拽初始状态:记录 pointerDown 时的状态
@@ -137,10 +140,25 @@ const ExhibitOverlay = ({
}, [applyTransformStyle])
useEffect(() => {
if (visible) {
onOpen?.()
onOpenRef.current = 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(() => {
+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 content = (
@@ -204,7 +211,7 @@ export const StudyGuideModal = ({
src={getStaticImage('components/guide/继续')}
alt="继续"
button
onClick={onContinue}
onClick={handleContinue}
/>
</div>
</div>
+10 -5
View File
@@ -23,6 +23,7 @@ const RESIDENCES_WITH_PUZZLE = [
'maozedong',
'nieer',
'zhangleping',
'rongdesheng',
] as const
/**
@@ -186,27 +187,30 @@ const ResidenceLayoutContent = () => {
// 处理碎片动画触发(仅支持拼图功能的故居)
const handleTriggerPuzzleAnimation = useCallback(
(pieceId: number, options?: { onComplete?: () => void }) => {
return new Promise<void>((resolve) => {
window.setTimeout(() => {
if (!residence || !hasPuzzleFeature) {
options?.onComplete?.()
return Promise.resolve()
resolve()
return
}
// 防止重复执行动画
if (animationInProgressRef.current) {
options?.onComplete?.()
return Promise.resolve()
resolve()
return
}
// 检查碎片是否已解锁
if (isPuzzleUnlocked(residence, pieceId)) {
options?.onComplete?.()
return Promise.resolve()
resolve()
return
}
// 标记动画正在进行
animationInProgressRef.current = true
return new Promise<void>((resolve) => {
animationCompletionRef.current = resolve
animationCompletionCallbackRef.current = options?.onComplete ?? null
@@ -228,6 +232,7 @@ const ResidenceLayoutContent = () => {
await waitForElementsAndAnimate(pieceId)
})
})
}, 0)
})
},
[
+9 -1
View File
@@ -4,6 +4,8 @@ import { createPortal } from 'react-dom'
import { useScale } from '@/contexts/ScaleContext'
export interface ScaledPortalProps {
/** 是否显示 */
show?: boolean
/** 是否启用(不启用则直接渲染 children) */
enabled?: boolean
/** z-index 层级(建议 > ExhibitOverlay 的 9999) */
@@ -17,7 +19,12 @@ export interface ScaledPortalProps {
* - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住
* - 使用 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 { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
@@ -25,6 +32,7 @@ const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortal
setMounted(true)
}, [])
if (!show) return null
if (!enabled) return <>{children}</>
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="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>
{/* 继续按钮 */}
@@ -29,7 +29,7 @@ export interface SuccessAlertProps {
* 使用 components/正确版 作为背景
*/
const SuccessAlert = ({
title = '恭喜你, 选择正确!',
title = '恭喜你,选择正确!',
content,
visible = true,
audioSrc,
@@ -108,16 +108,12 @@ const SuccessAlert = ({
if (audioSrc && currentSrc === audioSrc) {
stop()
}
// 只调用 onContinue,它会处理关闭逻辑
if (onContinue) {
onContinue()
} else if (onClose) {
onClose()
}
onContinue?.()
onClose?.()
}
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
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',
'rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]',
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]'
)}
>
@@ -111,11 +111,11 @@ const DragQuizPanel = ({
'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
isUsed
? '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 && (
<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)]">
{option.label}
+44 -44
View File
@@ -22,45 +22,43 @@
### 使用示例
```tsx
import { SelectFillQuizPanel } from "@/components";
import { SelectFillQuizPanel } from '@/components'
function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false);
const [showQuiz, setShowQuiz] = useState(false)
return (
<SelectFillQuizPanel
visible={showQuiz}
titlePrefix="【填空】毛泽东主席认为,到了"
titleSuffix="年夏天,在理论上,而且在某种程度的行动上,他已成为一个马克思主义者。"
blanks={[
{ id: "year", prefix: "____" },
]}
blanks={[{ id: 'year', prefix: '____' }]}
options={[
{ value: "1919", label: "1919" },
{ value: "1920", label: "1920" },
{ value: "1921", label: "1921" },
{ value: '1919', label: '1919' },
{ value: '1920', label: '1920' },
{ value: '1921', label: '1921' },
]}
correctAnswers={{ year: "1920" }}
correctAnswers={{ year: '1920' }}
errorMessage="回想一下 1920 年 5 月到 7 月在上海居住的这段经历~"
onSubmit={(results, isCorrect) => {
console.log("选择结果:", results, "是否正确:", isCorrect);
console.log('选择结果:', results, '是否正确:', isCorrect)
if (isCorrect) {
setShowQuiz(false);
setShowQuiz(false)
}
}}
onError={(error) => {
console.log("错误信息:", error);
console.log('错误信息:', error)
}}
submitText="确定"
/>
);
)
}
```
### API
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| -------------- | --------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------- |
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| titlePrefix | string | - | 整体题干前缀文本(可选) |
@@ -98,44 +96,44 @@ function MyComponent() {
### 使用方法
```tsx
import { SingleChoiceQuizPanel } from "@/components";
import { SingleChoiceQuizPanel } from '@/components'
function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false);
const [showQuiz, setShowQuiz] = useState(false)
return (
<SingleChoiceQuizPanel
visible={showQuiz}
title="这是一道单选题:以下哪个选项是正确的?"
options={[
{ value: "A", label: "选项A" },
{ value: "B", label: "选项B" },
{ value: "C", label: "选项C" },
{ value: 'A', label: '选项A' },
{ value: 'B', label: '选项B' },
{ value: 'C', label: '选项C' },
]}
correctAnswer="B"
errorMessage="再想一想,注意题干中的时间和提示信息哦~"
onSubmit={(value, isCorrect) => {
console.log("用户选择:", value);
console.log("是否正确:", isCorrect);
console.log('用户选择:', value)
console.log('是否正确:', isCorrect)
if (isCorrect) {
// 选择正确,可以关闭面板或进入下一步
setShowQuiz(false);
setShowQuiz(false)
}
}}
onError={(error) => {
console.log("错误信息:", error);
console.log('错误信息:', error)
}}
/>
);
)
}
```
### API
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| ------------- | ------------------------------------------- | -------------------------------------- | ------------------------------------ |
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| ------ | ------ | -------- | ------ |
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) |
@@ -168,48 +166,48 @@ function MyComponent() {
### 使用方法
```tsx
import { MultipleChoiceQuizPanel } from "@/components";
import { MultipleChoiceQuizPanel } from '@/components'
function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false);
const [showQuiz, setShowQuiz] = useState(false)
return (
<MultipleChoiceQuizPanel
visible={showQuiz}
title="这是一道多选题:以下哪些选项是正确的?(可多选)"
options={[
{ value: "A", label: "选项A" },
{ value: "B", label: "选项B" },
{ value: "C", label: "选项C" },
{ value: "D", label: "选项D" },
{ value: 'A', label: '选项A' },
{ value: 'B', label: '选项B' },
{ value: 'C', label: '选项C' },
{ value: 'D', label: '选项D' },
]}
correctAnswer={["A", "B"]}
correctAnswer={['A', 'B']}
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="请至少选择 1 项才能提交"
submitText="确认提交"
onSubmit={(values, isCorrect) => {
console.log("用户选择:", values);
console.log("是否正确:", isCorrect);
console.log('用户选择:', values)
console.log('是否正确:', isCorrect)
if (isCorrect) {
setShowQuiz(false);
setShowQuiz(false)
}
}}
onError={(error) => {
console.log("错误信息:", error);
console.log('错误信息:', error)
}}
/>
);
)
}
```
### API
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| ------------------------- | ---------------------------------------------- | -------------------------------------- | ------------------------------------ |
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| ------ | ------ | -------- | ------ |
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) |
@@ -237,6 +235,7 @@ function MyComponent() {
### 从旧版 QuizPanel 迁移
**旧版用法:**
```tsx
<QuizPanel
visible={showQuiz}
@@ -245,17 +244,18 @@ function MyComponent() {
value={quizAnswer}
error={quizError}
onChange={(val) => {
setQuizAnswer(val);
if (val === "B") {
setQuizError("");
setQuizAnswer(val)
if (val === 'B') {
setQuizError('')
} else {
setQuizError("很抱歉,你选错了!");
setQuizError('很抱歉,你选错了!')
}
}}
/>
```
**新版用法:**
```tsx
<SingleChoiceQuizPanel
visible={showQuiz}
@@ -159,7 +159,6 @@ const SingleChoiceQuizPanel = ({
<SuccessAlert
{...successAlertProps}
visible={successAlertVisible}
onContinue={handleSuccessContinue}
onClose={handleSuccessContinue}
/>
)}
+7 -5
View File
@@ -8,9 +8,9 @@ import { showHint } from '@/components/hint'
const formatCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0')
const day = `${now.getDate()}`.padStart(2, '0')
return `${year}.${month}.${day}`
const month = now.getMonth() + 1
const day = now.getDate()
return `${year}年${month}月${day}日`
}
export type LetterFieldType = 'text' | 'input' | 'select'
@@ -305,12 +305,14 @@ const LetterTemplate = ({
}
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
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' }}
autoComplete="off"
placeholder="请郑重签下你的姓名"
value={signatureName}
maxLength={12}
onChange={(e) => handleSignatureNameChange(e.target.value)}
@@ -82,7 +82,7 @@ const LetterToHuangyanpei = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给黄炎培的信"
title="写给黄炎培先生"
letterContent={letterContent}
fields={fields}
contentClassName="!text-[25px]"
+2 -2
View File
@@ -63,7 +63,7 @@ const fields: Record<number, LetterFieldConfig> = {
correctValue: '横眉冷对',
readOnlyFallback: '横眉冷对',
hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
error: '“横眉冷对千夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
},
},
4: {
@@ -73,7 +73,7 @@ const fields: Record<number, LetterFieldConfig> = {
correctValue: '孺子牛',
readOnlyFallback: '孺子牛',
hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
error: '“横眉冷对千夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
},
},
5: {
@@ -19,7 +19,7 @@ const commonSelectField: LetterFieldConfig = {
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '天下为公', label: '天下为公' },
{ value: '敢为天下先', label: '敢为天下先' },
{ value: '与时俱进', label: '与时俱进' },
{ value: '振兴中华', label: '振兴中华' },
],
}
@@ -27,7 +27,7 @@ const commonSelectField: LetterFieldConfig = {
const letterContent = `敬爱的孙中山先生:
您好!
今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在李教授的解读中,我终于明白:您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在这场跨越时空的对话中,我终于明白,您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
@@ -52,11 +52,11 @@ const fields: Record<number, LetterFieldConfig> = {
},
2: {
...commonSelectField,
correctValue: '敢为天下先',
correctValue: '与时俱进',
hints: {
error: '再想想哦,这里对应的是孙中山先生敢于探索新道路的胆识。',
},
readOnlyFallback: '敢为天下先',
readOnlyFallback: '与时俱进',
},
3: {
type: 'input',
+4
View File
@@ -21,6 +21,8 @@ export interface NarrationProps {
autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string
/** 署名/出处,主文本动画结束后显示在右下方 */
attribution?: string
}
/**
@@ -41,6 +43,7 @@ const Narration = ({
animationSpeed = 100,
autoPlayAudio = true,
audioChannelId,
attribution,
}: NarrationProps) => {
const [displayedText, setDisplayedText] = useState('')
const [isAnimating, setIsAnimating] = useState(true)
@@ -146,6 +149,7 @@ const Narration = ({
<div className="text-white text-3xl leading-relaxed space-y-8">
<p className="whitespace-pre-wrap">{displayedText}</p>
{attribution && !isAnimating && <p className="text-right">{attribution}</p>}
</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 nameMap: Record<string, string> = {
chenyun: '陈云',
huangyanpei: '黄炎培',
luxun: '鲁迅',
sunzhongshan: '孙中山',
maozedong: '毛泽东',
zhangleping: '张乐平',
nieer: '聂耳',
rongdesheng: '荣德生',
chenyun: '陈云故居',
huangyanpei: '黄炎培故居',
luxun: '鲁迅故居',
sunzhongshan: '孙中山故居',
maozedong: '毛泽东旧居',
zhangleping: '张乐平故居',
nieer: '聂耳旧居',
rongdesheng: '荣德生旧居',
}
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)]'
)}
>
{getResidenceDisplayName(residence)}故居碎片
{getResidenceDisplayName(residence)}碎片
</span>
<img
className="absolute -top-10 left-0 -z-10"
+3 -2
View File
@@ -86,7 +86,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
}
setContentWidth(calculatedWidth)
}, [fitContent, maxWidth, minWidth, options, placeholder])
}, [fitContent, maxWidth, minWidth, options, placeholder, fontSize])
const updateDropdownPosition = useCallback(() => {
if (!triggerRef.current) return
@@ -240,7 +240,8 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
}}
className={cn(
'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]',
disabled && 'cursor-not-allowed opacity-50',
className
+22
View File
@@ -3,6 +3,15 @@ import { PUZZLE_RESIDENCES } from './puzzleResidences'
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) =>
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) => {
if (isUnlockAllEnabled()) return true
return arePiecesUnlocked(residence, puzzles, getConfiguredPieceIds(residence))
}
export const isResidenceUnlocked = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const index = getResidenceIndex(residence)
if (index === 0) return true
if (index < 0) return false
@@ -50,6 +62,8 @@ export const isMenuModuleStarted = (moduleName: string, puzzles: PuzzleProgress)
}
export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
if (residences.length === 0) return false
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) => {
if (isUnlockAllEnabled()) {
return {
dialogueUnlocked: true,
reflectionUnlocked: true,
internalizationUnlocked: true,
}
}
const residenceUnlocked = isResidenceUnlocked(residence, puzzles)
return {
+24 -26
View File
@@ -80,7 +80,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片11
11: {
title: '陈云起草的关于发展国民 经济"一五"计划报告的手稿',
title: '陈云起草的关于发展国民 经济“一五”计划报告的手稿',
detailImage: 'chenyun/details/11',
route: 'place8',
},
@@ -148,7 +148,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片6
6: {
title: '中华职业教育 和中华职业学校',
title: '职业教育 和中华职业学校',
detailImage: 'huangyanpei/details/6',
route: 'place4',
},
@@ -184,7 +184,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片12
12: {
title: '窑洞对话',
title: '窑洞对',
detailImage: 'huangyanpei/details/12',
route: 'place5',
},
@@ -196,7 +196,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片14
14: {
title: '黄炎培对浦东中学新生的教导',
title: '黄炎培对浦东中学 新生的教导',
detailImage: 'huangyanpei/details/14',
route: 'reflection2',
},
@@ -260,7 +260,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3',
},
10: {
title: '《中国国民党第一次 全国代表大会宣言》',
title: '中国国民党第一次 全国代表大会宣言',
detailImage: 'sunzhongshan/details/10',
route: 'place3',
},
@@ -399,22 +399,22 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place2',
},
5: {
title: '中共三大会议召开',
title: '中共三大召开',
detailImage: 'maozedong/details/5',
route: 'place3',
},
6: {
title: '《孙中山手书名单》',
title: '孙中山手书名单',
detailImage: 'maozedong/details/6',
route: 'place3',
},
7: {
title: '《孙文越飞联合宣言》',
title: '孙文越飞联合宣言',
detailImage: 'maozedong/details/7',
route: 'place3',
},
8: {
title: '毛泽东签发的两份中共四大通知',
title: '毛泽东签发的两份 中共四大通知',
detailImage: 'maozedong/details/8',
route: 'place4',
},
@@ -424,7 +424,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4',
},
10: {
title: '毛泽东中国社会分析',
title: '中国社会各阶级的分析',
detailImage: 'maozedong/details/10',
route: 'place4',
},
@@ -501,34 +501,34 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4',
},
9: {
title: '刘良模指挥数千人齐唱',
title: '国歌选定',
detailImage: 'nieer/details/9',
route: 'place4',
route: 'place5',
},
10: {
title: '国歌选定',
title: '国歌入宪',
detailImage: 'nieer/details/10',
route: 'place5',
},
11: {
title: '国歌入宪',
title: '国歌立法',
detailImage: 'nieer/details/11',
route: 'place5',
},
12: {
title: '国歌立法',
title: '新中国奏响国歌的场景(上)',
detailImage: 'nieer/details/12',
route: 'place5',
route: 'place6',
},
13: {
title: '新中国奏响国歌的场景(上)',
title: '新中国奏响国歌的场景(下)',
detailImage: 'nieer/details/13',
route: 'place6',
},
14: {
title: '新中国奏响国歌的场景(下)',
title: '唱国家一定要站起来',
detailImage: 'nieer/details/14',
route: 'place6',
route: 'reflection',
},
15: {
title: '聂耳「人民音乐家」',
@@ -578,7 +578,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3',
},
8: {
title: '三毛新生(《向日葵》张乐平,1950-1965)',
title: '三毛新生',
detailImage: 'zhangleping/details/8',
route: 'place3',
},
@@ -608,17 +608,17 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place5',
},
14: {
title: '画自己',
title: '创作心路',
detailImage: 'zhangleping/details/14',
route: 'place5',
route: 'reflection',
},
15: {
title: '平民立场',
title: '艺术来源于生活',
detailImage: 'zhangleping/details/15',
route: 'internalization',
},
16: {
title: '笔耕不辍',
title: '核心创作理念',
detailImage: 'zhangleping/details/16',
route: 'internalization',
},
@@ -705,8 +705,6 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
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. 简单点击
```tsx
import { useTouch } from "@/hooks";
import { useTouch } from '@/hooks'
function MyComponent() {
const { handlers } = useTouch({
onTap: () => {
console.log("点击了");
console.log('点击了')
},
});
})
return <div {...handlers}>点击我</div>;
return <div {...handlers}>点击我</div>
}
```
@@ -34,13 +34,13 @@ function MyComponent() {
```tsx
const { handlers } = useTouch({
onSwipeLeft: () => console.log("向左滑动"),
onSwipeRight: () => console.log("向右滑动"),
onSwipeUp: () => console.log("向上滑动"),
onSwipeDown: () => console.log("向下滑动"),
});
onSwipeLeft: () => console.log('向左滑动'),
onSwipeRight: () => console.log('向右滑动'),
onSwipeUp: () => console.log('向上滑动'),
onSwipeDown: () => console.log('向下滑动'),
})
return <div {...handlers}>滑动区域</div>;
return <div {...handlers}>滑动区域</div>
```
### 3. 双击
@@ -48,11 +48,11 @@ return <div {...handlers}>滑动区域</div>;
```tsx
const { handlers } = useTouch({
onDoubleTap: () => {
console.log("双击触发");
console.log('双击触发')
},
});
})
return <div {...handlers}>双击我</div>;
return <div {...handlers}>双击我</div>
```
### 4. 长按
@@ -60,12 +60,12 @@ return <div {...handlers}>双击我</div>;
```tsx
const { handlers } = useTouch({
onLongPress: () => {
console.log("长按触发");
console.log('长按触发')
},
longPressDelay: 500, // 500ms 触发长按
});
})
return <div {...handlers}>长按我</div>;
return <div {...handlers}>长按我</div>
```
## 高级用法
@@ -75,11 +75,11 @@ return <div {...handlers}>长按我</div>;
```tsx
const { handlers, touchData, isTouching } = useTouch({
onTouchMove: (data) => {
console.log("移动距离:", data.deltaX, data.deltaY);
console.log("移动速度:", data.velocity);
console.log("移动方向:", data.direction);
console.log('移动距离:', data.deltaX, data.deltaY)
console.log('移动速度:', data.velocity)
console.log('移动方向:', data.direction)
},
});
})
return (
<div {...handlers}>
@@ -89,20 +89,20 @@ return (
</div>
)}
</div>
);
)
```
### 图片查看器示例
```tsx
function ImageViewer() {
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const { handlers } = useTouch({
onDoubleTap: () => {
// 双击放大/还原
setScale(scale === 1 ? 2 : 1);
setScale(scale === 1 ? 2 : 1)
},
onSwipe: (data) => {
// 滑动移动图片
@@ -110,23 +110,23 @@ function ImageViewer() {
setPosition((prev) => ({
x: prev.x + data.deltaX,
y: prev.y + data.deltaY,
}));
}))
}
},
swipeThreshold: 5,
});
})
return (
<div {...handlers} style={{ overflow: "hidden" }}>
<div {...handlers} style={{ overflow: 'hidden' }}>
<img
src="image.jpg"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transition: "transform 0.3s",
transition: 'transform 0.3s',
}}
/>
</div>
);
)
}
```
@@ -134,36 +134,36 @@ function ImageViewer() {
```tsx
function SwipeableCard() {
const [offset, setOffset] = useState(0);
const [offset, setOffset] = useState(0)
const { handlers } = useTouch({
onTouchMove: (data) => {
if (data.direction === "left" || data.direction === "right") {
setOffset(data.deltaX);
if (data.direction === 'left' || data.direction === 'right') {
setOffset(data.deltaX)
}
},
onTouchEnd: (data) => {
if (Math.abs(data.deltaX) > 100) {
// 滑动超过 100px,执行删除
onDelete();
onDelete()
} else {
// 弹回原位
setOffset(0);
setOffset(0)
}
},
});
})
return (
<div
{...handlers}
style={{
transform: `translateX(${offset}px)`,
transition: offset === 0 ? "transform 0.3s" : "none",
transition: offset === 0 ? 'transform 0.3s' : 'none',
}}
>
<div>卡片内容</div>
</div>
);
)
}
```
@@ -171,42 +171,38 @@ function SwipeableCard() {
```tsx
function PullToRefresh() {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
const { handlers } = useTouch({
onTouchMove: (data) => {
// 只在向下滑动且在顶部时触发
if (data.direction === "down" && window.scrollY === 0) {
setPullDistance(Math.min(data.deltaY, 80));
if (data.direction === 'down' && window.scrollY === 0) {
setPullDistance(Math.min(data.deltaY, 80))
}
},
onTouchEnd: (data) => {
if (pullDistance > 50) {
setIsRefreshing(true);
setIsRefreshing(true)
// 执行刷新逻辑
refresh().then(() => {
setIsRefreshing(false);
setPullDistance(0);
});
setIsRefreshing(false)
setPullDistance(0)
})
} else {
setPullDistance(0);
setPullDistance(0)
}
},
});
})
return (
<div {...handlers}>
<div style={{ height: pullDistance, transition: "height 0.3s" }}>
{isRefreshing
? "刷新中..."
: pullDistance > 50
? "松开刷新"
: "下拉刷新"}
<div style={{ height: pullDistance, transition: 'height 0.3s' }}>
{isRefreshing ? '刷新中...' : pullDistance > 50 ? '松开刷新' : '下拉刷新'}
</div>
<div>内容区域</div>
</div>
);
)
}
```
@@ -214,34 +210,34 @@ function PullToRefresh() {
```tsx
function Carousel({ images }: { images: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [currentIndex, setCurrentIndex] = useState(0)
const { handlers } = useTouch({
onSwipeLeft: () => {
setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1));
setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1))
},
onSwipeRight: () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0));
setCurrentIndex((prev) => Math.max(prev - 1, 0))
},
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
});
})
return (
<div {...handlers} style={{ overflow: "hidden" }}>
<div {...handlers} style={{ overflow: 'hidden' }}>
<div
style={{
display: "flex",
display: 'flex',
transform: `translateX(-${currentIndex * 100}%)`,
transition: "transform 0.3s",
transition: 'transform 0.3s',
}}
>
{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>
);
)
}
```
@@ -250,34 +246,34 @@ function Carousel({ images }: { images: string[] }) {
```typescript
interface UseTouchOptions {
// 基础配置
enabled?: boolean; // 是否启用,默认 true
preventDefault?: boolean; // 是否阻止默认行为,默认 false
stopPropagation?: boolean; // 是否阻止事件冒泡,默认 false
enabled?: boolean // 是否启用,默认 true
preventDefault?: boolean // 是否阻止默认行为,默认 false
stopPropagation?: boolean // 是否阻止事件冒泡,默认 false
// 滑动配置
swipeThreshold?: number; // 滑动阈值(px),默认 10
swipeVelocityThreshold?: number; // 滑动速度阈值(px/ms),默认 0.5
swipeThreshold?: number // 滑动阈值(px),默认 10
swipeVelocityThreshold?: number // 滑动速度阈值(px/ms),默认 0.5
// 长按配置
longPressDelay?: number; // 长按延迟(ms),默认 500
longPressMoveThreshold?: number; // 长按允许移动距离(px),默认 10
longPressDelay?: number // 长按延迟(ms),默认 500
longPressMoveThreshold?: number // 长按允许移动距离(px),默认 10
// 双击配置
doubleTapDelay?: number; // 双击间隔(ms),默认 300
doubleTapMoveThreshold?: number; // 双击允许移动距离(px),默认 10
doubleTapDelay?: number // 双击间隔(ms),默认 300
doubleTapMoveThreshold?: number // 双击允许移动距离(px),默认 10
// 事件回调
onTouchStart?: (data: TouchEventData) => void;
onTouchMove?: (data: TouchEventData) => void;
onTouchEnd?: (data: TouchEventData) => void;
onTap?: (data: TouchEventData) => void;
onDoubleTap?: (data: TouchEventData) => void;
onLongPress?: (data: TouchEventData) => void;
onSwipe?: (data: TouchEventData) => void;
onSwipeLeft?: (data: TouchEventData) => void;
onSwipeRight?: (data: TouchEventData) => void;
onSwipeUp?: (data: TouchEventData) => void;
onSwipeDown?: (data: TouchEventData) => void;
onTouchStart?: (data: TouchEventData) => void
onTouchMove?: (data: TouchEventData) => void
onTouchEnd?: (data: TouchEventData) => void
onTap?: (data: TouchEventData) => void
onDoubleTap?: (data: TouchEventData) => void
onLongPress?: (data: TouchEventData) => void
onSwipe?: (data: TouchEventData) => void
onSwipeLeft?: (data: TouchEventData) => void
onSwipeRight?: (data: TouchEventData) => void
onSwipeUp?: (data: TouchEventData) => void
onSwipeDown?: (data: TouchEventData) => void
}
```
@@ -287,14 +283,14 @@ interface UseTouchOptions {
```typescript
interface TouchEventData {
startPoint: TouchPoint; // 起始位置 { x, y, timestamp }
currentPoint: TouchPoint; // 当前位置 { x, y, timestamp }
deltaX: number; // X 轴移动距离
deltaY: number; // Y 轴移动距离
distance: number; // 移动的总距离
velocity: number; // 移动速度(像素/毫秒)
direction: SwipeDirection; // 移动方向: 'left' | 'right' | 'up' | 'down' | null
event: React.TouchEvent | React.MouseEvent; // 原始事件
startPoint: TouchPoint // 起始位置 { x, y, timestamp }
currentPoint: TouchPoint // 当前位置 { x, y, timestamp }
deltaX: number // X 轴移动距离
deltaY: number // Y 轴移动距离
distance: number // 移动的总距离
velocity: number // 移动速度(像素/毫秒)
direction: SwipeDirection // 移动方向: 'left' | 'right' | 'up' | 'down' | null
event: React.TouchEvent | React.MouseEvent // 原始事件
}
```
@@ -302,14 +298,14 @@ interface TouchEventData {
```typescript
interface UseTouchReturn {
isTouching: boolean; // 是否正在触摸
touchData: TouchEventData | null; // 当前触摸数据
isTouching: boolean // 是否正在触摸
touchData: TouchEventData | null // 当前触摸数据
handlers: {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
onMouseDown: (e: React.MouseEvent) => void;
};
onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: (e: React.TouchEvent) => void
onMouseDown: (e: React.MouseEvent) => void
}
}
```
@@ -329,7 +325,7 @@ interface UseTouchReturn {
const { handlers } = useTouch({
onSwipeLeft: handleSwipe,
preventDefault: false, // 不阻止默认行为,允许页面滚动
});
})
```
### 2. 性能优化
@@ -342,9 +338,9 @@ const touchOptions = useMemo(
swipeThreshold: 50,
}),
[handleSwipe]
);
)
const { handlers } = useTouch(touchOptions);
const { handlers } = useTouch(touchOptions)
```
### 3. 条件禁用
@@ -353,31 +349,31 @@ const { handlers } = useTouch(touchOptions);
const { handlers } = useTouch({
enabled: !isLoading, // 加载时禁用触摸
onTap: handleTap,
});
})
```
## 完整示例
```tsx
import { useTouch } from "@/hooks";
import { useState } from "react";
import { useTouch } from '@/hooks'
import { useState } from 'react'
function TouchDemo() {
const [log, setLog] = useState<string[]>([]);
const [log, setLog] = useState<string[]>([])
const addLog = (message: string) => {
setLog((prev) => [...prev, message].slice(-5));
};
setLog((prev) => [...prev, message].slice(-5))
}
const { handlers, isTouching, touchData } = useTouch({
onTap: () => addLog("点击"),
onDoubleTap: () => addLog("双击"),
onLongPress: () => addLog("长按"),
onSwipeLeft: () => addLog("向左滑动"),
onSwipeRight: () => addLog("向右滑动"),
onSwipeUp: () => addLog("向上滑动"),
onSwipeDown: () => addLog("向下滑动"),
});
onTap: () => addLog('点击'),
onDoubleTap: () => addLog('双击'),
onLongPress: () => addLog('长按'),
onSwipeLeft: () => addLog('向左滑动'),
onSwipeRight: () => addLog('向右滑动'),
onSwipeUp: () => addLog('向上滑动'),
onSwipeDown: () => addLog('向下滑动'),
})
return (
<div>
@@ -386,12 +382,12 @@ function TouchDemo() {
style={{
width: 300,
height: 300,
border: "2px solid #ccc",
display: "flex",
alignItems: "center",
justifyContent: "center",
userSelect: "none",
background: isTouching ? "#f0f0f0" : "white",
border: '2px solid #ccc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
background: isTouching ? '#f0f0f0' : 'white',
}}
>
触摸测试区域
@@ -410,7 +406,7 @@ function TouchDemo() {
))}
</div>
</div>
);
)
}
```
+23 -29
View File
@@ -1,39 +1,40 @@
@import "tailwindcss";
@import "./styles/input.css";
@import 'tailwindcss';
@import './styles/input.css';
/* 定义字体 */
@font-face {
font-family: "SourceHanSerifCN";
src: url("/fonts/SourceHanSerifCN-Bold-2.otf") format("opentype");
font-family: 'SourceHanSerifCN';
src: url('/fonts/SourceHanSerifCN-Bold-2.otf') format('opentype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
/* *{ */
/* margin: 0;
/* margin: 0;
padding: 0; */
/* box-sizing: border-box; */
/* box-sizing: border-box; */
/* } */
body{
body {
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #000;
font-family: "SourceHanSerifCN", serif;
font-family: 'SourceHanSerifCN', serif;
}
#root{
#root {
width: 100%;
height: 100%;
}
button:not([disabled]){
button:not([disabled]) {
cursor: pointer;
}
img, video{
img,
video {
max-width: unset;
}
@@ -85,31 +86,26 @@ img, video{
}
.quiz-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom,
rgba(212, 197, 169, 0.8),
rgba(240, 190, 117, 0.8));
background: linear-gradient(to bottom, rgba(212, 197, 169, 0.8), rgba(240, 190, 117, 0.8));
border-radius: 5px;
border: 2px solid rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.quiz-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom,
rgba(240, 190, 117, 0.9),
rgba(212, 197, 169, 0.9));
background: linear-gradient(to bottom, rgba(240, 190, 117, 0.9), rgba(212, 197, 169, 0.9));
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 {
background: linear-gradient(to bottom,
rgba(240, 190, 117, 1),
rgba(212, 197, 169, 1));
background: linear-gradient(to bottom, rgba(240, 190, 117, 1), rgba(212, 197, 169, 1));
}
/* 搜索图标动画 */
@keyframes search-pulse {
0%, 100% {
0%,
100% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 1;
}
@@ -125,7 +121,9 @@ img, video{
/* 三个点加载动画 */
@keyframes dot-bounce {
0%, 80%, 100% {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
@@ -166,11 +164,7 @@ img, video{
}
.luxun-scrollbar::-webkit-scrollbar-track {
background: linear-gradient(
to bottom,
rgba(250, 245, 235, 0.06),
rgba(240, 235, 225, 0.04)
);
background: linear-gradient(to bottom, rgba(250, 245, 235, 0.06), rgba(240, 235, 225, 0.04));
border-radius: 12px;
border: 1.5px solid rgba(139, 115, 71, 0.2);
margin: 6px 2px;
@@ -230,7 +224,8 @@ img, video{
/* 点一点图标动画 - 手指点击动画(上下移动模拟点击) */
@keyframes finger-bounce {
0%, 100% {
0%,
100% {
transform: translateY(0) translateX(0);
}
50% {
@@ -241,4 +236,3 @@ img, video{
.animate-finger-bounce {
animation: finger-bounce 1.2s ease-in-out infinite;
}
+6
View File
@@ -4,6 +4,12 @@ import './index.css'
import App from './App'
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 依赖)
if (import.meta.env.DEV && typeof window !== 'undefined') {
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 { Image, PuzzleSystem } from '@/components'
import { useNavigate } from 'react-router-dom'
import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter'
import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
import { isMenuModuleComplete } from '@/config/progression'
const Menu = () => {
const navigate = useNavigate()
const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false)
const [activeResidence, setActiveResidence] = useState<string | null>(null)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
@@ -65,17 +63,6 @@ const Menu = () => {
/>
</div>
{/* 顶层碎片管理中心:查看故居导图与进度 */}
<MenuPuzzleCenter
visible={isPuzzleCenterVisible}
onClose={() => setIsPuzzleCenterVisible(false)}
onSelectResidence={(residence) => {
setActiveResidence(residence)
setIsPuzzleCenterVisible(false)
}}
allowPuzzleEntry={false}
/>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
{activeResidence && (
<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"
style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }}
key={item.id}
onClick={() => navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)}
onClick={() => navigate(`/${encodeURIComponent(item.moduleName)}`)}
>
<div className="absolute top-15 flex flex-col items-center">
<Image key={item.id} src={getStaticImage(item.image)} />
@@ -132,7 +119,7 @@ const Menu = () => {
<Image
src={getStaticImage('menu/导航地图')}
button
onClick={() => setIsPuzzleCenterVisible(true)}
onClick={() => navigate('/learning-map')}
/>
</div>
</div>
+1 -1
View File
@@ -84,7 +84,7 @@ const Chenyun = () => {
className="absolute right-37 top-22"
src={getStaticImage('components/革命先驱故居-返回')}
button
onClick={() => navigate(`/entrance/${moduleName}`)}
onClick={() => navigate(`/${moduleName}`)}
/>
<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',
label: '鸟笼比喻',
},
{ value: 'C', label: '正确的处理好计划与市场的关系', isCorrect: true },
{ value: 'C', label: '正确地处理好计划与市场的关系', isCorrect: true },
{ value: 'D', label: '经济建设的目的论', isCorrect: true },
],
explanation: '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!',
@@ -69,14 +69,14 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
options: [
{
value: 'A',
label: '不唯上、不唯书、只唯实,交换、比较、反复',
label: '不唯上、不唯书、只唯实,交换、比较、反复',
isCorrect: true,
},
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
{
value: 'D',
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
isCorrect: true,
},
],
@@ -378,7 +378,7 @@ const ChenyunInternalization = () => {
<Narration
visible={narrationVisible}
audioChannelId="chenyun-internalization-narration"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_35_1.mp3')}
onClose={() => {
setNarrationVisible(false)
setVisible(true)
@@ -417,11 +417,14 @@ const ChenyunInternalization = () => {
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_1.mp3')}
>
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
</GuideStepsItem>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_2.mp3')}
>
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
</GuideStepsItem>
<GuideStepsItem
@@ -447,18 +450,26 @@ const ChenyunInternalization = () => {
>
<GuideStepsItem
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 speaker="student">这些都让我特别受触动!</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_1.mp3')}
>
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_2.mp3')}
>
你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧!
</GuideStepsItem>
</GuideSteps>
+20 -22
View File
@@ -36,12 +36,11 @@ const ChenyunPlace1 = () => {
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导弹窗
// 温馨提示弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false)
// 学习说明对话框显示状态
const [showIntroDialog, setShowIntroDialog] = useState(false)
// 引导对话显示状态
// 温馨提示关闭后的场景对话显示状态
const [visible, setVisible] = useState(false)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
@@ -169,12 +168,12 @@ const ChenyunPlace1 = () => {
}}
/>
{/* 学习引导词 */}
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
setVisible(true)
setShowAfterDialogGuide(true)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
ailgn="left"
@@ -198,18 +197,8 @@ const ChenyunPlace1 = () => {
</Overlay>
)}
{/* 场景对话 */}
<GuideSteps
visible={visible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true)
}
}}
showMask={false}
>
{/* 温馨提示关闭后的场景对话 */}
<GuideSteps visible={visible} onVisibleChange={setVisible} showMask={false}>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_03.mp3')}
@@ -218,22 +207,31 @@ const ChenyunPlace1 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_1.mp3')}
>
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_2.mp3')}
>
实际上是陈云舅父母的家,也是少年陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_3.mp3')}
>
父母去世后,由外婆抚养。六岁时,外婆去世,陈云过继给舅父母当养子。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
{/* 温馨提示 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
onContinue={() => {
setShowAfterDialogGuide(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')}
firstLineIndent="2em"
>
+10 -4
View File
@@ -168,11 +168,14 @@ const ChenyunPlace2 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06_1.mp3')}
>
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县立乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06_2.mp3')}
>
陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。
</GuideStepsItem>
</GuideSteps>
@@ -195,11 +198,14 @@ const ChenyunPlace2 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08_1.mp3')}
>
是的,陈云在青浦县立乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08_2.mp3')}
>
颜安小学实行的新式教育开阔了陈云的视野,五四运动后,颜安小学师生积极投入反帝爱国运动,为陈云日后加入中国共产党埋下了思想的种子。
</GuideStepsItem>
</GuideSteps>
+5 -2
View File
@@ -236,11 +236,14 @@ const ChenyunPlace3 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10_1.mp3')}
>
从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10_2.mp3')}
>
陈云的珠算能力在商务印书馆工作期间有了很大提高。由于工作认真负责,三年的学徒期,陈云提前两年就破格到商务印书馆虹口分店(现为1925书局)当店员。
</GuideStepsItem>
</GuideSteps>
+18 -6
View File
@@ -241,14 +241,20 @@ const ChenyunPlace4 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_1.mp3')}
>
1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_2.mp3')}
>
虽然陈云在中央苏区的任务主要是领导工人运动,但是陈云总是能针对一些棘手的经济问题提出较出色的建议。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_3.mp3')}
>
在中央苏区时期,陈云也关注和积极参与解决了许多经济难题。1934年,在中共六届五中全会上,陈云被选为中央政治局委员、常委,兼任中央白区工作部部长。
</GuideStepsItem>
</GuideSteps>
@@ -271,14 +277,20 @@ const ChenyunPlace4 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_1.mp3')}
>
中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_2.mp3')}
>
他强调,当务之急是重新严格审查各个企业、工厂以及商店、大小作坊的实际承受力和经济支付能力。从实际出发,实事求是地制定符合中央苏维埃区域每一个工厂、企业、作坊发展需求的劳动政策。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_3.mp3')}
>
陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。
</GuideStepsItem>
</GuideSteps>
+5 -2
View File
@@ -165,11 +165,14 @@ const ChenyunPlace5 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16_1.mp3')}
>
1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16_2.mp3')}
>
国民党顽固派封锁边区,企图“不让一粒粮、一尺布进入边区”,同时千方百计阻止边区食盐出口。
</GuideStepsItem>
</GuideSteps>
+5 -2
View File
@@ -134,11 +134,14 @@ const ChenyunPlace6 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18_1.mp3')}
>
是的,新中国刚成立时,中国共产党接管的是一个民穷财尽、物价飞涨的烂摊子。面对财政经济上的严重困难,陈云在1949年受命担任新成立的中央财政经济委员会(简称中财委)主任,主持领导全国财政经济工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18_2.mp3')}
>
陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。
</GuideStepsItem>
</GuideSteps>
+12 -7
View File
@@ -44,7 +44,7 @@ const QUIZ1_TITLE =
const QUIZ1_ERROR_HINTS: Record<string, string> = {
A: '淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。请你再想想吧!',
C: '1949年6月新中国政权尚未建立。请你再想想吧',
C: '1949年6月新中国政权尚未建立。请你再想想吧!',
}
const QUIZ1_FALLBACK_HINT = '再想一想,注意题干中的时间和提示信息哦~'
@@ -305,18 +305,24 @@ const ChenyunPlace7 = () => {
speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_19.mp3`)}
>
那"银元之战"是如何取得胜利的呢?
那“银元之战”是如何取得胜利的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20.mp3`)}
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_1.mp3`)}
>
由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传“大街过三道,物价跳三跳”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_2.mp3`)}
>
有人计算,一百元法币,在一九三七年可买两头牛,一九四五年只能买两个鸡蛋,一九四七年只能买一个煤球,到一九四九年五月连一粒米也买不到。可见,当时通货膨胀多么严重。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_3.mp3`)}
>
打击投机资本,稳定金融物价,陈云代中央起草《中共中央关于打击银元使人民币占领阵地的指示》,提出以强硬手段打击投机势力。
</GuideStepsItem>
</GuideSteps>
@@ -364,7 +370,7 @@ const ChenyunPlace7 = () => {
speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_21.mp3`)}
>
那"两白一黑"战役是如何打赢的呢?
那“两白一黑”战役是如何打赢的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
@@ -395,7 +401,6 @@ const ChenyunPlace7 = () => {
title="恭喜你, 选择正确!"
content={successContent}
onClose={handleSuccessDismiss}
onContinue={handleSuccessDismiss}
/>
</div>
)}
+14 -5
View File
@@ -189,11 +189,14 @@ const ChenyunPlace8 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26_1.mp3')}
>
不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的“统购统销”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26_2.mp3')}
>
陈云说:“只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。”
</GuideStepsItem>
</GuideSteps>
@@ -231,14 +234,20 @@ const ChenyunPlace8 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_1.mp3')}
>
一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_2.mp3')}
>
这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_3.mp3')}
>
第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过“统购统销”稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。
</GuideStepsItem>
</GuideSteps>
+9 -3
View File
@@ -147,14 +147,20 @@ const ChenyunPlace9 = () => {
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_1.mp3')}
>
我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他“适合老百姓的要求,做了一点事”的初心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_2.mp3')}
>
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的“银元之战”和“米棉之战”之中去。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_3.mp3')}
>
在实践决策中体会陈云“多谋善断”“敢闯务实”的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
</GuideStepsItem>
</GuideSteps>
@@ -9,7 +9,7 @@ import type { ClueData } from '../clue/types'
// 银元之战视频配置
export const YINYUAN_VIDEO_CONFIG = {
src: getPublicPath('video/chenyun/chenyun_15.mp4'),
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战。你将化身陈云同志的特派员,直面这场“银元之战”。"`,
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战”。”你将化身陈云同志的特派员,直面这场“银元之战”。`,
alert: {
title: '第一幕:“银元之战”--人民币的阵地争夺战',
subtitle: '上海目前出现大肆贩卖银元的情况,请你了解情况!',
@@ -36,10 +36,10 @@ export const YINYUAN_CLUE_DATA: Record<
title: '线索二',
content: (
<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 className="indent-14">其他商户纷纷仿效,人民币在市场交易中地位下降。</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 TitleAlert from '@/components/layout/alert/title-alert'
import ClueOverlay from '@/pages/chenyun/reflection/clue'
@@ -63,10 +63,10 @@ const ChenyunReflection = () => {
/** 是否显示线索弹窗 */
const [showClueOverlay, setShowClueOverlay] = useState(false)
const [showOpeningVideo, setShowOpeningVideo] = useState(true)
/** 欢迎语弹窗(使用 StudyGuideModal 展示入口引导) */
const [showWelcomeGuide, setShowWelcomeGuide] = useState(
() => !useChenyunStore.getState().isYinyuanSolved
)
const [showWelcomeGuide, setShowWelcomeGuide] = useState(false)
/** 第一幕银元之战 - 学习引导弹窗 */
const [showYinyuanGuide, setShowYinyuanGuide] = useState(false)
@@ -219,6 +219,36 @@ const ChenyunReflection = () => {
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 && (
<Image
@@ -139,7 +139,7 @@ export const YinyuanBattle = ({
// 关闭多选题1,显示旁白文字(图2)
state.setShowQuiz1(false)
state.setNarration2Text(
`六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了"反对银元投机,保障人民生活"的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商"赶快洗手不干,否则勿谓言之不预"。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:"单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。"`
`六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了“反对银元投机,保障人民生活”的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商“赶快洗手不干,否则勿谓言之不预”。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:“单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。”`
)
state.setShowNarration2(true)
}
@@ -77,7 +77,7 @@ export const YinyuanGuides = ({
<ExhibitOverlay
visible={showTrendChartDetail}
src="chenyun/reflection/银元价格走势图"
description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`}
description={`以上海为中心,代表“人民币信用”的绿色区域迅速覆盖全市,并向外辐射。`}
onClick={onTrendChartClick}
onClose={onTrendChartDetailClose}
/>
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more