├── .gitignore ├── README.md ├── config ├── env.js ├── fsExistsSync.js ├── paths.js ├── polyfills.js ├── webpack.config.dev.js └── webpack.config.prod.js ├── package.json ├── public ├── favicon.ico └── index.html ├── scripts ├── build.js └── start.js └── src ├── appconfig.js ├── components ├── AppBottomNav │ ├── index.css │ └── index.js ├── CardListCreator.js ├── Loading │ ├── index.css │ └── index.js ├── MessageCard │ ├── MessageCard.css │ └── index.js ├── MessageList.js ├── PrivateRoute.js ├── PullView.js ├── PullViewWrap.js ├── RecentCard │ ├── index.css │ └── index.js ├── RecentList.js ├── ReplyBox │ ├── index.css │ └── index.js ├── ReplyCard │ ├── ReplyCard.css │ └── index.js ├── ReplyList.js ├── TopicCard │ ├── index.css │ └── index.js └── TopicList.js ├── containers ├── App │ ├── index.css │ └── index.js ├── CollectionPage │ ├── index.css │ └── index.js ├── LoginPage │ ├── index.css │ └── index.js ├── MessagePage │ ├── index.css │ └── index.js ├── NewTopicPage │ ├── index.css │ └── index.js ├── SettingPage │ ├── index.css │ └── index.js ├── TopicPage │ ├── TopicContent.js │ ├── index.css │ └── index.js ├── TopicsPage │ ├── TopicsHeader.css │ ├── TopicsHeader.js │ ├── TopicsPageCreator.js │ ├── index.css │ └── index.js └── UserPage │ ├── index.css │ └── index.js ├── core ├── api │ ├── api-service.js │ └── index.js ├── app │ ├── actions.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ └── selectors.js ├── auth │ ├── actions.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ └── selectors.js ├── collection │ ├── actions.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ └── selectors.js ├── constants.js ├── db │ ├── actions.js │ ├── index.js │ ├── reducers.js │ └── selectors.js ├── epics.js ├── localstore │ ├── actions.js │ ├── epics.js │ └── index.js ├── message │ ├── actions.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ ├── schemas.js │ └── selectors.js ├── reducers.js ├── reply │ ├── actions.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ ├── schemas.js │ └── selectors.js ├── store │ ├── configureStore.dev.js │ ├── configureStore.prod.js │ └── index.js ├── topic │ ├── actions.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ ├── schemas.js │ └── selector.js ├── user │ ├── actions.js │ ├── epics.js │ ├── index.js │ ├── reducers.js │ ├── schemas.js │ └── selectors.js └── utils │ └── index.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log 16 | 17 | # IDE 18 | .idea 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | > 本项目是由[CNode社区](https://cnodejs.org)提供的API,使用React全家桶所开发的数据持久化的单页应用。 3 | 4 | 这里所说数据持久化的单页应用,指的是利用`immutable + normalizr + reselect`这一套组合,在应用中简单实现了类似数据库的数据存储,使数据保持持久且同步,在渲染时优先使用存储的数据,以减少请求,提升应用响应。 5 | 6 | #### 为什么这么做? 7 | 可以参考文章[State 范式化](http://cn.redux.js.org/docs/recipes/reducers/NormalizingStateShape.html)的描述。 8 | 9 | 用本项目举例,在首次拉取主题列表页数据,经过范式化后得到数据如下: 10 | ![拉取主题列表数据后](https://raw.githubusercontent.com/JoV5/blog/master/images/Inked%E6%8B%89%E5%8F%96%E4%B8%BB%E9%A2%98%E5%88%97%E8%A1%A8%E6%95%B0%E6%8D%AE%E5%90%8E_LI.jpg) 11 | 这里我把范式化后的数据都存放于`db`之下,之内包含`topics`、`users`、`replies`等“*数据表*”,`topics`表内的主题以其`id`作为索引,`users`表内的用户以其`loginname`作为索引。主题内的`author`通过索引可以关联到`users`表内对应用户。 12 | 13 | 14 | 这里可以看到,主题内已经包含了主题内容但不包含评论,所以可以直接打开主题详情页而不用去拉取数据,可以留一个加载评论的按钮,在用户需要时加载评论,由于接口限制,不能单独加载主题评论,所以这里实际拉取的是主题完整数据。在用户主动拉取这一篇主题之后的`db`如下: 15 | ![拉取主题详情数据后](https://raw.githubusercontent.com/JoV5/blog/master/images/Inked%E6%8B%89%E5%8F%96%E4%B8%BB%E9%A2%98%E8%AF%A6%E6%83%85%E6%95%B0%E6%8D%AE%E5%90%8E_LI.jpg) 16 | 这里的`replies`表,用来存放评论,评论以其`id`作为索引。而主题内也多了`replies`属性,展开后是主题的`id`列表,可以通过索引关联到`replies`表内对应评论。评论内的`author`通过索引可以关联到`users`表内对应用户。 17 | 18 | 那好处到底是是什么呢?引用文章[State 范式化](http://cn.redux.js.org/docs/recipes/reducers/NormalizingStateShape.html)的话来说: 19 | * 每个数据项只在一个地方定义,如果数据项需要更新的话不用在多处改变 20 | * reducer 逻辑不用处理深层次的嵌套,因此看上去可能会更加简单 21 | * 检索或者更新给定数据项的逻辑变得简单与一致。给定一个数据项的 type 和 ID,不必挖掘其他对象而是通过几个简单的步骤就能查找到它。 22 | 23 | #### 接下来该这么做? 24 | 上面只讲到了范式化,接下来我们需要从`db`中提取出渲染所需数据,这时候就该`reselect`出场了,引用[官方文档的描述](https://github.com/reactjs/reselect): 25 | * Selectors can compute derived data, allowing Redux to store the minimal possible state. 26 | 27 | selectors可以计算衍生数据,使得Redux最小化存储的state。 28 | 29 | * Selectors are efficient. A selector is not recomputed unless one of its arguments change. 30 | 31 | selectors是高效的。一个selector只在arguments改变时进行重计算。 32 | 33 | * Selectors are composable. They can be used as input to other selectors. 34 | 35 | selectors是可组合的,它可作为其他selectors的输入。 36 | 37 | 另外可以参考文章[计算衍生数据](http://cn.redux.js.org/docs/recipes/ComputingDerivedData.html)。 38 | 39 | 由于项目全面使用了`immutable.js`,对于数据的存取还是比较方便。 40 | 41 | ## 涉及技术 42 | * react 43 | * react router 4 44 | * redux 45 | * redux-observable 46 | * rxjs 47 | * immutable.js 48 | * normalizr 49 | * reselect 50 | 51 | ## 开发记录 52 | [用create-react-app定制自己的react项目模板](https://github.com/JoV5/blog/blob/master/前端/React/用create-react-app定制自己的react项目模板.md) 53 | 54 | [用react写一个下拉刷新上滑加载组件](https://github.com/JoV5/blog/blob/master/%E5%89%8D%E7%AB%AF/React/%E7%94%A8react%E5%86%99%E4%B8%80%E4%B8%AA%E4%B8%8B%E6%8B%89%E5%88%B7%E6%96%B0%E4%B8%8A%E6%BB%91%E5%8A%A0%E8%BD%BD%E7%BB%84%E4%BB%B6.md) 55 | 56 | ## TODOS 57 | - [ ] 回到顶部 58 | - [ ] 回复尾巴 59 | - [ ] 已发布主题更新 60 | - [ ] 友好提示 61 | - [ ] 消息页面支持直接回复点赞 62 | - [ ] 收藏页面支持直接取消收藏 63 | - [ ] 优化css 64 | - [ ] 优化reducers 65 | - [ ] 各种动画 66 | - [ ] chunk的重命名 67 | 68 | 69 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 4 | // injected into the application via DefinePlugin in Webpack configuration. 5 | 6 | var REACT_APP = /^REACT_APP_/i; 7 | 8 | function getClientEnvironment(publicUrl) { 9 | var raw = Object 10 | .keys(process.env) 11 | .filter(key => REACT_APP.test(key)) 12 | .reduce((env, key) => { 13 | env[key] = process.env[key]; 14 | return env; 15 | }, { 16 | // Useful for determining whether we’re running in production mode. 17 | // Most importantly, it switches React into the correct mode. 18 | 'NODE_ENV': process.env.NODE_ENV || 'development', 19 | // Useful for resolving the correct path to static assets in `public`. 20 | // For example, . 21 | // This should only be used as an escape hatch. Normally you would put 22 | // images into the `src` and `import` them in code to get their paths. 23 | 'PUBLIC_URL': publicUrl 24 | }); 25 | // Stringify all values so we can feed into Webpack DefinePlugin 26 | var stringified = { 27 | 'process.env': Object 28 | .keys(raw) 29 | .reduce((env, key) => { 30 | env[key] = JSON.stringify(raw[key]); 31 | return env; 32 | }, {}) 33 | }; 34 | 35 | return { raw, stringified }; 36 | } 37 | 38 | module.exports = getClientEnvironment; 39 | -------------------------------------------------------------------------------- /config/fsExistsSync.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | module.exports = function fsExistsSync(path) { 4 | try{ 5 | fs.accessSync(path, fs.F_OK); 6 | }catch(e){ 7 | return false; 8 | } 9 | return true; 10 | }; -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var fs = require('fs'); 5 | var url = require('url'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebookincubator/create-react-app/issues/637 9 | var appDirectory = fs.realpathSync(process.cwd()); 10 | function resolveApp(relativePath) { 11 | return path.resolve(appDirectory, relativePath); 12 | } 13 | 14 | // We support resolving modules according to `NODE_PATH`. 15 | // This lets you use absolute paths in imports inside large monorepos: 16 | // https://github.com/facebookincubator/create-react-app/issues/253. 17 | 18 | // It works similar to `NODE_PATH` in Node itself: 19 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 20 | 21 | // We will export `nodePaths` as an array of absolute paths. 22 | // It will then be used by Webpack configs. 23 | // Jest doesn’t need this because it already handles `NODE_PATH` out of the box. 24 | 25 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 26 | // Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. 27 | // https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 28 | 29 | var nodePaths = (process.env.NODE_PATH || '') 30 | .split(process.platform === 'win32' ? ';' : ':') 31 | .filter(Boolean) 32 | .filter(folder => !path.isAbsolute(folder)) 33 | .map(resolveApp); 34 | 35 | let appname = process.argv.find(arg => arg.indexOf('app=') > -1); 36 | appname = appname ? appname.split('=')[1] : ''; 37 | 38 | var envPublicUrl = process.env.PUBLIC_URL; 39 | 40 | function ensureSlash(path, needsSlash) { 41 | var hasSlash = path.endsWith('/'); 42 | if (hasSlash && !needsSlash) { 43 | return path.substr(path, path.length - 1); 44 | } else if (!hasSlash && needsSlash) { 45 | return path + '/'; 46 | } else { 47 | return path; 48 | } 49 | } 50 | 51 | function getPublicUrl(appPackageJson) { 52 | return envPublicUrl || require(appPackageJson).homepage; 53 | } 54 | 55 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 56 | // "public path" at which the app is served. 57 | // Webpack needs to know it to put the right 193 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.NODE_ENV = 'production'; 5 | 6 | // Load environment variables from .env file. Suppress warnings using silent 7 | // if this file is missing. dotenv will never modify any environment variables 8 | // that have already been set. 9 | // https://github.com/motdotla/dotenv 10 | require('dotenv').config({silent: true}); 11 | 12 | var chalk = require('chalk'); 13 | var fs = require('fs-extra'); 14 | var path = require('path'); 15 | var url = require('url'); 16 | var webpack = require('webpack'); 17 | var config = require('../config/webpack.config.prod'); 18 | var paths = require('../config/paths'); 19 | var checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); 20 | var FileSizeReporter = require('react-dev-utils/FileSizeReporter'); 21 | var measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild; 22 | var printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; 23 | 24 | var useYarn = fs.existsSync(paths.yarnLockFile); 25 | 26 | // Warn and crash if required files are missing 27 | if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { 28 | process.exit(1); 29 | } 30 | 31 | // First, read the current file sizes in build directory. 32 | // This lets us display how much they changed later. 33 | measureFileSizesBeforeBuild(paths.appBuild).then(previousFileSizes => { 34 | // Remove all content but keep the directory so that 35 | // if you're in it, you don't end up in Trash 36 | fs.emptyDirSync(paths.appBuild); 37 | 38 | // Start the webpack build 39 | build(previousFileSizes); 40 | 41 | // Merge with the public folder 42 | copyPublicFolder(); 43 | }); 44 | 45 | // Print out errors 46 | function printErrors(summary, errors) { 47 | console.log(chalk.red(summary)); 48 | console.log(); 49 | errors.forEach(err => { 50 | console.log(err.message || err); 51 | console.log(); 52 | }); 53 | } 54 | 55 | // Create the production build and print the deployment instructions. 56 | function build(previousFileSizes) { 57 | console.log('Creating an optimized production build...'); 58 | webpack(config).run((err, stats) => { 59 | if (err) { 60 | printErrors('Failed to compile.', [err]); 61 | process.exit(1); 62 | } 63 | 64 | if (stats.compilation.errors.length) { 65 | printErrors('Failed to compile.', stats.compilation.errors); 66 | process.exit(1); 67 | } 68 | 69 | if (process.env.CI && stats.compilation.warnings.length) { 70 | printErrors('Failed to compile. When process.env.CI = true, warnings are treated as failures. Most CI servers set this automatically.', stats.compilation.warnings); 71 | process.exit(1); 72 | } 73 | 74 | console.log(chalk.green('Compiled successfully.')); 75 | console.log(); 76 | 77 | console.log('File sizes after gzip:'); 78 | console.log(); 79 | printFileSizesAfterBuild(stats, previousFileSizes); 80 | console.log(); 81 | 82 | var appPackage = require(paths.appPackageJson); 83 | var publicUrl = paths.publicUrl; 84 | var publicPath = config.output.publicPath; 85 | var publicPathname = url.parse(publicPath).pathname; 86 | if (publicUrl && publicUrl.indexOf('.github.io/') !== -1) { 87 | // "homepage": "http://user.github.io/project" 88 | console.log('The project was built assuming it is hosted at ' + chalk.green(publicPathname) + '.'); 89 | console.log('You can control this with the ' + chalk.green('homepage') + ' field in your ' + chalk.cyan('package.json') + '.'); 90 | console.log(); 91 | console.log('The ' + chalk.cyan('build') + ' folder is ready to be deployed.'); 92 | console.log('To publish it at ' + chalk.green(publicUrl) + ', run:'); 93 | // If script deploy has been added to package.json, skip the instructions 94 | if (typeof appPackage.scripts.deploy === 'undefined') { 95 | console.log(); 96 | if (useYarn) { 97 | console.log(' ' + chalk.cyan('yarn') + ' add --dev gh-pages'); 98 | } else { 99 | console.log(' ' + chalk.cyan('npm') + ' install --save-dev gh-pages'); 100 | } 101 | console.log(); 102 | console.log('Add the following script in your ' + chalk.cyan('package.json') + '.'); 103 | console.log(); 104 | console.log(' ' + chalk.dim('// ...')); 105 | console.log(' ' + chalk.yellow('"scripts"') + ': {'); 106 | console.log(' ' + chalk.dim('// ...')); 107 | console.log(' ' + chalk.yellow('"predeploy"') + ': ' + chalk.yellow('"npm run build",')); 108 | console.log(' ' + chalk.yellow('"deploy"') + ': ' + chalk.yellow('"gh-pages -d build"')); 109 | console.log(' }'); 110 | console.log(); 111 | console.log('Then run:'); 112 | } 113 | console.log(); 114 | console.log(' ' + chalk.cyan(useYarn ? 'yarn' : 'npm') + ' run deploy'); 115 | console.log(); 116 | } else if (publicPath !== '/') { 117 | // "homepage": "http://mywebsite.com/project" 118 | console.log('The project was built assuming it is hosted at ' + chalk.green(publicPath) + '.'); 119 | console.log('You can control this with the ' + chalk.green('homepage') + ' field in your ' + chalk.cyan('package.json') + '.'); 120 | console.log(); 121 | console.log('The ' + chalk.cyan('build') + ' folder is ready to be deployed.'); 122 | console.log(); 123 | } else { 124 | if (publicUrl) { 125 | // "homepage": "http://mywebsite.com" 126 | console.log('The project was built assuming it is hosted at ' + chalk.green(publicUrl) + '.'); 127 | console.log('You can control this with the ' + chalk.green('homepage') + ' field in your ' + chalk.cyan('package.json') + '.'); 128 | console.log(); 129 | } else { 130 | // no homepage 131 | console.log('The project was built assuming it is hosted at the server root.'); 132 | console.log('To override this, specify the ' + chalk.green('homepage') + ' in your ' + chalk.cyan('package.json') + '.'); 133 | console.log('For example, add this to build it for GitHub Pages:') 134 | console.log(); 135 | console.log(' ' + chalk.green('"homepage"') + chalk.cyan(': ') + chalk.green('"http://myname.github.io/myapp"') + chalk.cyan(',')); 136 | console.log(); 137 | } 138 | var build = path.relative(process.cwd(), paths.appBuild); 139 | console.log('The ' + chalk.cyan(build) + ' folder is ready to be deployed.'); 140 | console.log('You may serve it with a static server:'); 141 | console.log(); 142 | if (useYarn) { 143 | console.log(` ${chalk.cyan('yarn')} global add serve`); 144 | } else { 145 | console.log(` ${chalk.cyan('npm')} install -g serve`); 146 | } 147 | console.log(` ${chalk.cyan('serve')} -s build`); 148 | console.log(); 149 | } 150 | }); 151 | } 152 | 153 | function copyPublicFolder() { 154 | fs.copySync(paths.appPublic, paths.appBuild, { 155 | dereference: true, 156 | filter: file => file !== paths.appHtml 157 | }); 158 | } 159 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.NODE_ENV = 'development'; 4 | 5 | // Load environment variables from .env file. Suppress warnings using silent 6 | // if this file is missing. dotenv will never modify any environment variables 7 | // that have already been set. 8 | // https://github.com/motdotla/dotenv 9 | require('dotenv').config({silent: true}); 10 | 11 | var chalk = require('chalk'); 12 | var webpack = require('webpack'); 13 | var WebpackDevServer = require('webpack-dev-server'); 14 | var historyApiFallback = require('connect-history-api-fallback'); 15 | var httpProxyMiddleware = require('http-proxy-middleware'); 16 | var detect = require('detect-port'); 17 | var clearConsole = require('react-dev-utils/clearConsole'); 18 | var checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); 19 | var formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); 20 | var getProcessForPort = require('react-dev-utils/getProcessForPort'); 21 | var openBrowser = require('react-dev-utils/openBrowser'); 22 | var prompt = require('react-dev-utils/prompt'); 23 | var fs = require('fs'); 24 | var config = require('../config/webpack.config.dev'); 25 | var paths = require('../config/paths'); 26 | 27 | var useYarn = fs.existsSync(paths.yarnLockFile); 28 | var cli = useYarn ? 'yarn' : 'npm'; 29 | var isInteractive = process.stdout.isTTY; 30 | 31 | // Warn and crash if required files are missing 32 | if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { 33 | process.exit(1); 34 | } 35 | 36 | // Tools like Cloud9 rely on this. 37 | var DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; 38 | var compiler; 39 | var handleCompile; 40 | 41 | // You can safely remove this after ejecting. 42 | // We only use this block for testing of Create React App itself: 43 | var isSmokeTest = process.argv.some(arg => arg.indexOf('--smoke-test') > -1); 44 | if (isSmokeTest) { 45 | handleCompile = function (err, stats) { 46 | if (err || stats.hasErrors() || stats.hasWarnings()) { 47 | process.exit(1); 48 | } else { 49 | process.exit(0); 50 | } 51 | }; 52 | } 53 | 54 | function setupCompiler(host, port, protocol) { 55 | // "Compiler" is a low-level interface to Webpack. 56 | // It lets us listen to some events and provide our own custom messages. 57 | compiler = webpack(config, handleCompile); 58 | 59 | // "invalid" event fires when you have changed a file, and Webpack is 60 | // recompiling a bundle. WebpackDevServer takes care to pause serving the 61 | // bundle, so if you refresh, it'll wait instead of serving the old one. 62 | // "invalid" is short for "bundle invalidated", it doesn't imply any errors. 63 | compiler.plugin('invalid', function() { 64 | if (isInteractive) { 65 | clearConsole(); 66 | } 67 | console.log('Compiling...'); 68 | }); 69 | 70 | var isFirstCompile = true; 71 | 72 | // "done" event fires when Webpack has finished recompiling the bundle. 73 | // Whether or not you have warnings or errors, you will get this event. 74 | compiler.plugin('done', function(stats) { 75 | if (isInteractive) { 76 | clearConsole(); 77 | } 78 | 79 | // We have switched off the default Webpack output in WebpackDevServer 80 | // options so we are going to "massage" the warnings and errors and present 81 | // them in a readable focused way. 82 | var messages = formatWebpackMessages(stats.toJson({}, true)); 83 | var isSuccessful = !messages.errors.length && !messages.warnings.length; 84 | var showInstructions = isSuccessful && (isInteractive || isFirstCompile); 85 | 86 | if (isSuccessful) { 87 | console.log(chalk.green('Compiled successfully!')); 88 | } 89 | 90 | if (showInstructions) { 91 | console.log(); 92 | console.log('The app is running at:'); 93 | console.log(); 94 | console.log(' ' + chalk.cyan(protocol + '://' + host + ':' + port + '/')); 95 | console.log(); 96 | console.log('Note that the development build is not optimized.'); 97 | console.log('To create a production build, use ' + chalk.cyan(cli + ' run build') + '.'); 98 | console.log(); 99 | isFirstCompile = false; 100 | } 101 | 102 | // If errors exist, only show errors. 103 | if (messages.errors.length) { 104 | console.log(chalk.red('Failed to compile.')); 105 | console.log(); 106 | messages.errors.forEach(message => { 107 | console.log(message); 108 | console.log(); 109 | }); 110 | return; 111 | } 112 | 113 | // Show warnings if no errors were found. 114 | if (messages.warnings.length) { 115 | console.log(chalk.yellow('Compiled with warnings.')); 116 | console.log(); 117 | messages.warnings.forEach(message => { 118 | console.log(message); 119 | console.log(); 120 | }); 121 | // Teach some ESLint tricks. 122 | console.log('You may use special comments to disable some warnings.'); 123 | console.log('Use ' + chalk.yellow('// eslint-disable-next-line') + ' to ignore the next line.'); 124 | console.log('Use ' + chalk.yellow('/* eslint-disable */') + ' to ignore all warnings in a file.'); 125 | } 126 | }); 127 | } 128 | 129 | // We need to provide a custom onError function for httpProxyMiddleware. 130 | // It allows us to log custom error messages on the console. 131 | function onProxyError(proxy) { 132 | return function(err, req, res){ 133 | var host = req.headers && req.headers.host; 134 | console.log( 135 | chalk.red('Proxy error:') + ' Could not proxy request ' + chalk.cyan(req.url) + 136 | ' from ' + chalk.cyan(host) + ' to ' + chalk.cyan(proxy) + '.' 137 | ); 138 | console.log( 139 | 'See https://nodejs.org/api/errors.html#errors_common_system_errors for more information (' + 140 | chalk.cyan(err.code) + ').' 141 | ); 142 | console.log(); 143 | 144 | // And immediately send the proper error response to the client. 145 | // Otherwise, the request will eventually timeout with ERR_EMPTY_RESPONSE on the client side. 146 | if (res.writeHead && !res.headersSent) { 147 | res.writeHead(500); 148 | } 149 | res.end('Proxy error: Could not proxy request ' + req.url + ' from ' + 150 | host + ' to ' + proxy + ' (' + err.code + ').' 151 | ); 152 | } 153 | } 154 | 155 | function addMiddleware(devServer) { 156 | // `proxy` lets you to specify a fallback server during development. 157 | // Every unrecognized request will be forwarded to it. 158 | var proxy = require(paths.appPackageJson).proxy; 159 | devServer.use(historyApiFallback({ 160 | // Paths with dots should still use the history fallback. 161 | // See https://github.com/facebookincubator/create-react-app/issues/387. 162 | disableDotRule: true, 163 | // For single page apps, we generally want to fallback to /index.html. 164 | // However we also want to respect `proxy` for API calls. 165 | // So if `proxy` is specified, we need to decide which fallback to use. 166 | // We use a heuristic: if request `accept`s text/html, we pick /index.html. 167 | // Modern browsers include text/html into `accept` header when navigating. 168 | // However API calls like `fetch()` won’t generally accept text/html. 169 | // If this heuristic doesn’t work well for you, don’t use `proxy`. 170 | htmlAcceptHeaders: proxy ? 171 | ['text/html'] : 172 | ['text/html', '*/*'] 173 | })); 174 | if (proxy) { 175 | if (typeof proxy !== 'string') { 176 | console.log(chalk.red('When specified, "proxy" in package.json must be a string.')); 177 | console.log(chalk.red('Instead, the type of "proxy" was "' + typeof proxy + '".')); 178 | console.log(chalk.red('Either remove "proxy" from package.json, or make it a string.')); 179 | process.exit(1); 180 | } 181 | 182 | // Otherwise, if proxy is specified, we will let it handle any request. 183 | // There are a few exceptions which we won't send to the proxy: 184 | // - /index.html (served as HTML5 history API fallback) 185 | // - /*.hot-update.json (WebpackDevServer uses this too for hot reloading) 186 | // - /sockjs-node/* (WebpackDevServer uses this for hot reloading) 187 | // Tip: use https://jex.im/regulex/ to visualize the regex 188 | var mayProxy = /^(?!\/(index\.html$|.*\.hot-update\.json$|sockjs-node\/)).*$/; 189 | 190 | // Pass the scope regex both to Express and to the middleware for proxying 191 | // of both HTTP and WebSockets to work without false positives. 192 | var hpm = httpProxyMiddleware(pathname => mayProxy.test(pathname), { 193 | target: proxy, 194 | logLevel: 'silent', 195 | onProxyReq: function(proxyReq) { 196 | // Browers may send Origin headers even with same-origin 197 | // requests. To prevent CORS issues, we have to change 198 | // the Origin to match the target URL. 199 | if (proxyReq.getHeader('origin')) { 200 | proxyReq.setHeader('origin', proxy); 201 | } 202 | }, 203 | onError: onProxyError(proxy), 204 | secure: false, 205 | changeOrigin: true, 206 | ws: true, 207 | xfwd: true 208 | }); 209 | devServer.use(mayProxy, hpm); 210 | 211 | // Listen for the websocket 'upgrade' event and upgrade the connection. 212 | // If this is not done, httpProxyMiddleware will not try to upgrade until 213 | // an initial plain HTTP request is made. 214 | devServer.listeningApp.on('upgrade', hpm.upgrade); 215 | } 216 | 217 | // Finally, by now we have certainly resolved the URL. 218 | // It may be /index.html, so let the dev server try serving it again. 219 | devServer.use(devServer.middleware); 220 | } 221 | 222 | function runDevServer(host, port, protocol) { 223 | var devServer = new WebpackDevServer(compiler, { 224 | // Enable gzip compression of generated files. 225 | compress: true, 226 | // Silence WebpackDevServer's own logs since they're generally not useful. 227 | // It will still show compile warnings and errors with this setting. 228 | clientLogLevel: 'none', 229 | // By default WebpackDevServer serves physical files from current directory 230 | // in addition to all the virtual build products that it serves from memory. 231 | // This is confusing because those files won’t automatically be available in 232 | // production build folder unless we copy them. However, copying the whole 233 | // project directory is dangerous because we may expose sensitive files. 234 | // Instead, we establish a convention that only files in `public` directory 235 | // get served. Our build script will copy `public` into the `build` folder. 236 | // In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%: 237 | // 238 | // In JavaScript code, you can access it with `process.env.PUBLIC_URL`. 239 | // Note that we only recommend to use `public` folder as an escape hatch 240 | // for files like `favicon.ico`, `manifest.json`, and libraries that are 241 | // for some reason broken when imported through Webpack. If you just want to 242 | // use an image, put it in `src` and `import` it from JavaScript instead. 243 | contentBase: paths.appPublic, 244 | // Enable hot reloading server. It will provide /sockjs-node/ endpoint 245 | // for the WebpackDevServer client so it can learn when the files were 246 | // updated. The WebpackDevServer client is included as an entry point 247 | // in the Webpack development configuration. Note that only changes 248 | // to CSS are currently hot reloaded. JS changes will refresh the browser. 249 | hot: true, 250 | // It is important to tell WebpackDevServer to use the same "root" path 251 | // as we specified in the config. In development, we always serve from /. 252 | publicPath: config.output.publicPath, 253 | // WebpackDevServer is noisy by default so we emit custom message instead 254 | // by listening to the compiler events with `compiler.plugin` calls above. 255 | quiet: true, 256 | // Reportedly, this avoids CPU overload on some systems. 257 | // https://github.com/facebookincubator/create-react-app/issues/293 258 | watchOptions: { 259 | ignored: /node_modules/ 260 | }, 261 | // Enable HTTPS if the HTTPS environment variable is set to 'true' 262 | https: protocol === "https", 263 | host: host 264 | }); 265 | 266 | // Our custom middleware proxies requests to /index.html or a remote API. 267 | addMiddleware(devServer); 268 | 269 | // Launch WebpackDevServer. 270 | devServer.listen(port, err => { 271 | if (err) { 272 | return console.log(err); 273 | } 274 | 275 | if (isInteractive) { 276 | clearConsole(); 277 | } 278 | console.log(chalk.cyan('Starting the development server...')); 279 | console.log(); 280 | 281 | openBrowser(protocol + '://' + host + ':' + port + '/'); 282 | }); 283 | } 284 | 285 | function run(port) { 286 | var protocol = process.env.HTTPS === 'true' ? "https" : "http"; 287 | var host = process.env.HOST || 'localhost'; 288 | setupCompiler(host, port, protocol); 289 | runDevServer(host, port, protocol); 290 | } 291 | 292 | // We attempt to use the default port but if it is busy, we offer the user to 293 | // run on a different port. `detect()` Promise resolves to the next free port. 294 | detect(DEFAULT_PORT).then(port => { 295 | if (port === DEFAULT_PORT) { 296 | run(port); 297 | return; 298 | } 299 | 300 | if (isInteractive) { 301 | clearConsole(); 302 | var existingProcess = getProcessForPort(DEFAULT_PORT); 303 | var question = 304 | chalk.yellow('Something is already running on port ' + DEFAULT_PORT + '.' + 305 | ((existingProcess) ? ' Probably:\n ' + existingProcess : '')) + 306 | '\n\nWould you like to run the app on another port instead?'; 307 | 308 | prompt(question, true).then(shouldChangePort => { 309 | if (shouldChangePort) { 310 | run(port); 311 | } 312 | }); 313 | } else { 314 | console.log(chalk.red('Something is already running on port ' + DEFAULT_PORT + '.')); 315 | } 316 | }); 317 | -------------------------------------------------------------------------------- /src/appconfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { 3 | react: ['react', 'react-dom'], 4 | vendors: ['rxjs', 'immutable', 'axios', 'react-redux', 'react-router-dom', 'redux', 'redux-observable'] 5 | } 6 | }; -------------------------------------------------------------------------------- /src/components/AppBottomNav/index.css: -------------------------------------------------------------------------------- 1 | .app_bottom_nav { 2 | display: flex; 3 | position: fixed; 4 | bottom: -0.5rem; 5 | height: 0.5rem; 6 | line-height: 0.5rem; 7 | width: 100%; 8 | background-color: #444; 9 | flex-direction: row; 10 | border-top: 0.01rem solid #d5dbdb; 11 | transition: bottom 0.5s ease; 12 | } 13 | 14 | .app_bottom_nav.show { 15 | bottom: 0; 16 | } 17 | 18 | .app_bottom_nav_item { 19 | width: 100%; 20 | text-align: center; 21 | color: white; 22 | } 23 | .app_bottom_nav_item.iconfont { 24 | font-size: 0.24rem; 25 | } 26 | 27 | .app_bottom_nav_item.active_item { 28 | color: #80bd01; 29 | } -------------------------------------------------------------------------------- /src/components/AppBottomNav/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from "react"; 2 | import {NavLink} from 'react-router-dom'; 3 | 4 | import './index.css'; 5 | 6 | export default class AppBottomNav extends Component { 7 | 8 | render() { 9 | const {show, selectedTab} = this.props; 10 | 11 | return ( 12 | 18 | ); 19 | } 20 | } 21 | 22 | AppBottomNav.propTypes = { 23 | logout: PropTypes.func, 24 | show: PropTypes.bool 25 | }; -------------------------------------------------------------------------------- /src/components/CardListCreator.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from "react"; 2 | import {is} from 'immutable'; 3 | 4 | export default (Card) => { 5 | class CardList extends Component { 6 | 7 | shouldComponentUpdate(nextProps) { 8 | return !is(nextProps.data, this.props.data); 9 | } 10 | 11 | render() { 12 | const {data} = this.props; 13 | 14 | return ( 15 |
16 | {data.map((topic, i) => )} 17 |
18 | ); 19 | } 20 | } 21 | 22 | CardList.propTypes = { 23 | data: PropTypes.object.isRequired 24 | }; 25 | 26 | return CardList; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Loading/index.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | margin: auto; 3 | width: 0.7rem; 4 | text-align: center; 5 | } 6 | 7 | .spinner > div { 8 | width: 0.18rem; 9 | height: 0.18rem; 10 | background-color: #333; 11 | 12 | border-radius: 100%; 13 | display: inline-block; 14 | -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both; 15 | animation: sk-bouncedelay 1.4s infinite ease-in-out both; 16 | } 17 | 18 | .spinner .bounce1 { 19 | -webkit-animation-delay: -0.32s; 20 | animation-delay: -0.32s; 21 | } 22 | 23 | .spinner .bounce2 { 24 | -webkit-animation-delay: -0.16s; 25 | animation-delay: -0.16s; 26 | } 27 | 28 | @-webkit-keyframes sk-bouncedelay { 29 | 0%, 80%, 100% { -webkit-transform: scale(0) } 30 | 40% { -webkit-transform: scale(1.0) } 31 | } 32 | 33 | @keyframes sk-bouncedelay { 34 | 0%, 80%, 100% { 35 | -webkit-transform: scale(0); 36 | transform: scale(0); 37 | } 40% { 38 | -webkit-transform: scale(1.0); 39 | transform: scale(1.0); 40 | } 41 | } -------------------------------------------------------------------------------- /src/components/Loading/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import './index.css'; 4 | 5 | const Loading = () => ( 6 |
7 |
8 |
9 |
10 |
11 | ); 12 | 13 | export default Loading; -------------------------------------------------------------------------------- /src/components/MessageCard/MessageCard.css: -------------------------------------------------------------------------------- 1 | .message_card { 2 | width: 100%; 3 | padding: 0.1rem 0.1rem; 4 | border-bottom: 0.01rem solid #d5dbdb; 5 | display: flex; 6 | flex-direction: row; 7 | font-size: 0.16rem; 8 | } 9 | 10 | .message_card a{ 11 | color: #626262; 12 | } 13 | 14 | .message_card_avatar { 15 | width: 0.3rem; 16 | height: 0.3rem; 17 | margin-right: 0.1rem; 18 | border-radius: 50%; 19 | } 20 | 21 | .message_card_read { 22 | display: block; 23 | width: 0.15rem; 24 | height: 0.15rem; 25 | border-radius: 50%; 26 | background-color: #df3e3e; 27 | margin-left: 0.07rem; 28 | margin-top: 0.2rem; 29 | } 30 | 31 | .message_card_info { 32 | display: flex; 33 | flex-direction: column; 34 | flex: 1; 35 | } 36 | 37 | .message_card_other{ 38 | font-size: 0.14rem; 39 | } 40 | 41 | .message_card_time { 42 | font-size: 0.13rem; 43 | float: right; 44 | color: #80bd01; 45 | } 46 | 47 | .message_card_title { 48 | color: #217dad; 49 | font-size: 0.14rem; 50 | } 51 | 52 | .message_card_content { 53 | background-color: #eee; 54 | padding: 0.05rem; 55 | font-size: 0.14rem; 56 | } -------------------------------------------------------------------------------- /src/components/MessageCard/index.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from "react"; 2 | import {Link} from 'react-router-dom'; 3 | import marked from 'marked'; 4 | 5 | import {timeago} from '../../core/utils'; 6 | 7 | import './MessageCard.css'; 8 | 9 | const MessageCard = ({data}) => { 10 | const author = data.get('author'); 11 | const loginname = author.get('loginname'); 12 | const avatar_url = author.get('avatar_url'); 13 | const content = data.getIn(['reply', 'content']); 14 | const topic = data.get('topic'); 15 | const title = topic.get('title'); 16 | const id = topic.get('id'); 17 | const create_at = data.get('create_at'); 18 | const has_read = data.get('has_read'); 19 | const type = data.get('type'); 20 | 21 | return ( 22 |
23 |
24 | 25 | {loginname}/ 26 | 27 | {!has_read && } 28 |
29 |
30 | 31 | {loginname} {type === 'at' ? ' 在话题中@了您 ' : ' 回复了您的话题:'} 32 | {timeago(create_at)} 33 | 34 | {title} 35 |
36 |
37 |
38 | ) 39 | }; 40 | 41 | MessageCard.propTypes = { 42 | data: PropTypes.object.isRequired 43 | }; 44 | 45 | export default MessageCard; -------------------------------------------------------------------------------- /src/components/MessageList.js: -------------------------------------------------------------------------------- 1 | import MessageCard from './MessageCard'; 2 | import CardListCreator from './CardListCreator'; 3 | 4 | export default CardListCreator(MessageCard); -------------------------------------------------------------------------------- /src/components/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from "react"; 2 | import {Route, Redirect} from "react-router-dom"; 3 | 4 | const PrivateRoute = ({component, hasLogin, ...rest}) => { 5 | return ( 6 | ( 7 | hasLogin ? 8 | React.createElement(component, props) : 9 | 15 | )}/> 16 | ) 17 | }; 18 | 19 | PrivateRoute.propTypes = { 20 | component: PropTypes.func.isRequired, 21 | hasLogin: PropTypes.bool.isRequired 22 | }; 23 | 24 | export default PrivateRoute; -------------------------------------------------------------------------------- /src/components/PullView.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent, PropTypes} from 'react'; 2 | 3 | export default class PullView extends PureComponent { 4 | 5 | static propTypes = { 6 | onPulling: PropTypes.func, // 状态非0且正在下拉时的事件,返回pull的距离 7 | onPullEnd: PropTypes.func, // 下拉结束即从状态2切换到状态3时的事件 8 | onScrollToBottom: PropTypes.func, // 滚动到底部事件,当滚动到距离底部toBottom位置时触发,可用于下滑加载更多 9 | onScrollUp: PropTypes.func, // 向上滚动事件,可用于上滚隐藏AppHeader等 10 | onScrollDown: PropTypes.func, // 向下滚动事件,可用于下滚显示AppHeader等 11 | onPullViewUnmount: PropTypes.func, // 在PullView将要Unmount时调用,可用于记录当前滚动位置,在下次Mount时作为下面的mountScrollTop参数传入,回到上次滚动位置 12 | onStatusChange: PropTypes.func, // 当status变化时的事件,返回改变后的状态,结合个人需要对不同状态做出相应视图改变,比如比如下拉时顶部显示相应的提示 13 | onPauseStopped: PropTypes.func, // 当toStopPause传递为true后,stopPause完成后的事件 14 | mountScrollTop: PropTypes.number,// 初始化时的滚动位置 15 | toBottom: PropTypes.number, // 当滚动到距离底部toBottom位置时将触发onScrollToBottom事件 16 | pulledPauseY: PropTypes.number, // 处于pause状态即status为3时的Y方向应所在的位置 17 | toStopPause: PropTypes.bool, // 是否需要终止暂停状态 18 | scaleY: PropTypes.number, // 下拉距离缩放比例,将会影响能够下拉的距离 19 | unit: PropTypes.string // 单位,在移动端用的单位可能不是px,pulledPauseY和state中的pulledY都将使用这一单位,在使用px之外的单位时,需要设置好scaleY 20 | }; 21 | 22 | static defaultProps = { 23 | scaleY: 0.2, 24 | toBottom: 0, 25 | pulledPauseY: 40, 26 | mountScrollTop: 0, 27 | toStopPause: false, 28 | unit: 'px' 29 | }; 30 | 31 | state = { 32 | pulledY: 0 // 下拉的距离 33 | }; 34 | 35 | touching = false; // 是否处于touch状态,其实是用于兼容PC端,在mousedown之后才允许mousemove的逻辑 36 | startY = undefined; // 记录pull起始位置 37 | endY = undefined; // 记录pull当前位置 38 | status = 0; // 0. 未touchstart 1.pulling但未达到pulledPauseY 2.pulling达到pulledPauseY 3.进入pause状态 39 | lastScrollTop = undefined; // 上次scrollTop的位置,用于和当前滚动位置比较,判断是向上滚还是向下滚 40 | container = document.body; // pull的对象 41 | 42 | constructor() { 43 | super(...arguments); 44 | this._onTouchStart = this._onTouchStart.bind(this); 45 | this._onTouchMove = this._onTouchMove.bind(this); 46 | this._onTouchEnd = this._onTouchEnd.bind(this); 47 | this._onScroll = this._onScroll.bind(this); 48 | this._onPulling = this._onPulling.bind(this); 49 | this._onPullEnd = this._onPullEnd.bind(this); 50 | this._changeStatus = this._changeStatus.bind(this); 51 | } 52 | 53 | componentWillReceiveProps({toStopPause, onPauseStopped}) { 54 | // 当状态为3且接受到参数toStopPause为true时,状态回到0 55 | if (toStopPause && this.status === 3) { 56 | this.setState({ 57 | pulledY: 0 58 | }); 59 | this._changeStatus(0); 60 | onPauseStopped && onPauseStopped(); 61 | } 62 | } 63 | 64 | componentDidMount() { 65 | const {props: {mountScrollTop}, container} = this; 66 | 67 | // 滚动到初始位置 68 | container.scrollTop = mountScrollTop; 69 | this.lastScrollTop = mountScrollTop; 70 | 71 | // 绑定事件 72 | container.addEventListener('touchstart', this._onTouchStart); 73 | container.addEventListener('touchmove', this._onTouchMove, {passive: false}); 74 | container.addEventListener('touchend', this._onTouchEnd); 75 | container.addEventListener('mousedown', this._onTouchStart); 76 | container.addEventListener('mousemove', this._onTouchMove, {passive: false}); 77 | container.addEventListener('mouseup', this._onTouchEnd); 78 | window.addEventListener('scroll', this._onScroll); 79 | } 80 | 81 | componentWillUnmount() { 82 | const {props: {onPullViewUnmount}, container} = this; 83 | 84 | onPullViewUnmount && onPullViewUnmount(container.scrollTop); 85 | 86 | // 解绑事件 87 | container.removeEventListener('touchstart', this._onTouchStart); 88 | container.removeEventListener('touchmove', this._onTouchMove); 89 | container.removeEventListener('touchend', this._onTouchEnd); 90 | container.removeEventListener('mousedown', this._onTouchStart); 91 | container.removeEventListener('mousemove', this._onTouchMove); 92 | container.removeEventListener('mouseup', this._onTouchEnd); 93 | window.removeEventListener('scroll', this._onScroll); 94 | } 95 | 96 | _onTouchStart() { 97 | this.touching = true; 98 | } 99 | 100 | _onTouchMove(e) { 101 | if (!this.touching) return; 102 | 103 | const { 104 | props: {onPulling, scaleY}, 105 | container, startY, status, _onPulling 106 | } = this; 107 | const eTouchScreenY = e.touches ? e.touches[0].screenY : e.screenY; 108 | 109 | if (status) { // 状态非0时 110 | const pulledY = (eTouchScreenY - startY) * scaleY; // 用scaleY对pull的距离进行缩放 111 | 112 | if (pulledY >= 0) { // 进行下拉 113 | this.endY = eTouchScreenY; 114 | this.setState({ 115 | pulledY: pulledY 116 | }); 117 | 118 | if (status !== 3) { // 在状态不为3时,即状态为1或2时 119 | _onPulling && _onPulling(pulledY); 120 | } 121 | 122 | onPulling && onPulling(pulledY); // 始终触发外部的onPulling事件 123 | 124 | e.preventDefault(); 125 | } else { // 上滑,其实只有状态为3时才会进入该逻辑,回到状态0 126 | this._changeStatus(0); 127 | this.setState({ 128 | pulledY: 0 129 | }); 130 | } 131 | } else { // 状态为0时 132 | if (container.scrollTop === 0) { // 当scrollTop为0时进入状态1 133 | this.startY = eTouchScreenY; 134 | this._changeStatus(1); 135 | } 136 | } 137 | } 138 | 139 | _onScroll() { 140 | const {container, props: {toBottom, onScrollToBottom, onScrollUp, onScrollDown}} = this; 141 | const scrollTop = Math.ceil(container.scrollTop); 142 | const clientHeight = window.innerHeight; 143 | const scrollHeight = container.scrollHeight; 144 | 145 | // 当距离底部toBottom距离,触发onScrollToBottom 146 | if (scrollTop + clientHeight + toBottom >= scrollHeight) { 147 | onScrollToBottom && onScrollToBottom(); 148 | } 149 | 150 | // 与上次滚动位置比较,判断当前是向上滚还是向下滚 151 | if (scrollTop > this.lastScrollTop) { 152 | onScrollUp && onScrollUp(); 153 | } else { 154 | onScrollDown && onScrollDown(); 155 | } 156 | 157 | this.lastScrollTop = scrollTop; 158 | } 159 | 160 | _onTouchEnd() { 161 | const {props: {pulledPauseY}, state: {pulledY}, status, _onPullEnd} = this; 162 | 163 | if (status) { 164 | const isPause = _onPullEnd ? _onPullEnd(pulledY) : false; 165 | 166 | this.setState({ 167 | pulledY: isPause ? pulledPauseY : 0 168 | }); 169 | } 170 | 171 | this.touching = false; 172 | } 173 | 174 | /** 175 | * 在未处于状态3时触发,进行状态切换1、2间的切换 176 | * @param pulledY 177 | * @private 178 | */ 179 | _onPulling(pulledY) { 180 | const {props: {pulledPauseY}, status} = this; 181 | 182 | if (pulledY > pulledPauseY) { 183 | if (status !== 2) { 184 | this._changeStatus(2); 185 | } 186 | } else { 187 | if (status !== 1) { 188 | this._changeStatus(1); 189 | } 190 | } 191 | } 192 | 193 | /** 194 | * 根据pulledY的位置与pulledPauseY比较,判断是否进入状态3还是回到状态0 195 | * @param pulledY 196 | * @returns {boolean} 197 | * @private 198 | */ 199 | _onPullEnd(pulledY) { 200 | const {pulledPauseY, onPullEnd} = this.props; 201 | 202 | if (pulledY > pulledPauseY) { 203 | this._changeStatus(3); 204 | onPullEnd && onPullEnd(); 205 | return true; 206 | } else { 207 | this._changeStatus(0); 208 | return false; 209 | } 210 | } 211 | 212 | /** 213 | * 进行状态切换 214 | * @param status 215 | * @private 216 | */ 217 | _changeStatus(status) { 218 | const {onStatusChange} = this.props; 219 | 220 | this.status = status; 221 | onStatusChange && onStatusChange(this.status); 222 | } 223 | 224 | render() { 225 | const {props: {children, unit}, state: {pulledY}} = this; 226 | 227 | return ( 228 |
233 | {children} 234 |
235 | ) 236 | } 237 | } -------------------------------------------------------------------------------- /src/components/PullViewWrap.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent, PropTypes} from 'react'; 2 | 3 | import PullView from './PullView'; 4 | 5 | export default class PullViewWrap extends PureComponent { 6 | 7 | static defaultProps = { 8 | statusText: ['↓ 下拉刷新', '↓ 下拉刷新', '↑ 释放更新', '加载中...'], // 文字对应状态 9 | unit: 'px' 10 | }; 11 | 12 | // 大部分同PullView的props 13 | static propTypes = { 14 | onScrollToBottom: PropTypes.func, 15 | onScrollUp: PropTypes.func, 16 | onScrollDown: PropTypes.func, 17 | onPullViewUnmount: PropTypes.func, 18 | onPauseStopped: PropTypes.func, 19 | mountScrollTop: PropTypes.number, 20 | toBottom: PropTypes.number, 21 | toStopPause: PropTypes.bool, 22 | pulledPauseY: PropTypes.number, 23 | scaleY: PropTypes.number, 24 | statusDivStyleClass: PropTypes.string, // 状态变更div的className 25 | LoadingComponent: PropTypes.func, // 加载中显示的组件 26 | unit: PropTypes.string, 27 | styleClass: PropTypes.string // wrap的className 28 | }; 29 | 30 | constructor() { 31 | super(...arguments); 32 | this.onPulling = this.onPulling.bind(this); 33 | this.onStatusChange = this.onStatusChange.bind(this); 34 | } 35 | 36 | state = { 37 | pulledY: 0, // 下拉的距离 38 | status: 0 // 当前状态 39 | }; 40 | 41 | // PullView状态变更逻辑 42 | onStatusChange(status) { 43 | const {pulledPauseY} = this.props; 44 | 45 | switch (status) { 46 | case 0: 47 | this.setState({ 48 | status, 49 | pulledY: 0 50 | }); 51 | break; 52 | case 3: 53 | this.setState({ 54 | status, 55 | pulledY: pulledPauseY 56 | }); 57 | break; 58 | default: 59 | this.setState({ 60 | status 61 | }); 62 | break; 63 | } 64 | } 65 | 66 | // PullView触发onPulling逻辑 67 | onPulling(pulledY) { 68 | this.setState({ 69 | pulledY 70 | }); 71 | } 72 | 73 | render() { 74 | const {props, state: {pulledY, status}, onPulling, onStatusChange} = this; 75 | const {statusDivStyleClass, LoadingComponent, statusText, unit, styleClass} = props; 76 | 77 | return ( 78 |
79 |
85 | {status === 3 && LoadingComponent ? : statusText[status]} 86 |
87 | 92 |
93 | ) 94 | } 95 | } -------------------------------------------------------------------------------- /src/components/RecentCard/index.css: -------------------------------------------------------------------------------- 1 | .recent_card { 2 | width: 100%; 3 | padding: 0.1rem 0.1rem; 4 | border-bottom: 0.01rem solid #d5dbdb; 5 | } 6 | 7 | .recent_card a { 8 | color: #2c3e50; 9 | } 10 | 11 | .recent_card_avatar { 12 | width: 0.46rem; 13 | height: 0.46rem; 14 | margin-right: 0.1rem; 15 | border-radius: 50%; 16 | float: left; 17 | } 18 | 19 | .recent_card_title { 20 | line-height: 1.5; 21 | margin: 0 0 0.1rem; 22 | font-size: 0.15rem; 23 | white-space: nowrap; 24 | text-overflow: ellipsis; 25 | overflow: hidden; 26 | } 27 | 28 | .recent_card_other { 29 | font-size: 0.13rem; 30 | } 31 | 32 | .recent_card_other b{ 33 | color: #80bd01; 34 | font-weight: 400; 35 | } -------------------------------------------------------------------------------- /src/components/RecentCard/index.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from "react"; 2 | import {Link} from 'react-router-dom'; 3 | 4 | import {timeago} from '../../core/utils'; 5 | 6 | import './index.css'; 7 | 8 | const RecentCard = ({data}) => { 9 | const author = data.get('author'); 10 | const avatar_url = author.get('avatar_url'); 11 | const loginname = author.get('loginname'); 12 | const title = data.get('title'); 13 | const last_reply_at = data.get('last_reply_at'); 14 | const id = data.get('id'); 15 | 16 | return ( 17 |
18 | 19 | {loginname}/ 20 | 21 | 22 |

23 | {title} 24 |

25 |
26 | {loginname} 27 | {timeago(last_reply_at)} 28 |
29 | 30 |
31 | ) 32 | }; 33 | 34 | RecentCard.propTypes = { 35 | data: PropTypes.object.isRequired 36 | }; 37 | 38 | export default RecentCard; -------------------------------------------------------------------------------- /src/components/RecentList.js: -------------------------------------------------------------------------------- 1 | import RecentCard from './RecentCard'; 2 | import CardListCreator from './CardListCreator'; 3 | 4 | export default CardListCreator(RecentCard); -------------------------------------------------------------------------------- /src/components/ReplyBox/index.css: -------------------------------------------------------------------------------- 1 | .reply_box { 2 | position: fixed; 3 | top: 100%; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | z-index: 10; 8 | overflow: hidden; 9 | transition: top 0.5s ease; 10 | } 11 | 12 | .reply_box.show { 13 | top: 0; 14 | } 15 | 16 | .reply_box_back { 17 | width: 100%; 18 | height: 100%; 19 | background-color: rgba(0, 0, 0, 0.5); 20 | z-index: 10; 21 | } 22 | 23 | .reply_box_send { 24 | position: absolute; 25 | bottom: 50%; 26 | width: 100%; 27 | background-color: #333; 28 | padding-right: 0.15rem; 29 | } 30 | 31 | .reply_box_input { 32 | resize: none; 33 | position: absolute; 34 | width: 100%; 35 | top: 50%; 36 | bottom: 0; 37 | left: 0; 38 | right: 0; 39 | font-size: 0.16rem; 40 | } 41 | 42 | .reply_box .iconfont { 43 | color: #80bd01; 44 | font-size: 0.24rem; 45 | height: 0.4rem; 46 | line-height: 0.4rem; 47 | } -------------------------------------------------------------------------------- /src/components/ReplyBox/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from "react"; 2 | 3 | import './index.css'; 4 | 5 | export default class ReplyBox extends Component { 6 | 7 | constructor(){ 8 | super(...arguments); 9 | this.sendReply = this.sendReply.bind(this); 10 | this.hideReplyBox = this.hideReplyBox.bind(this); 11 | } 12 | 13 | static propTypes = { 14 | postReply: PropTypes.func.isRequired, 15 | reply: PropTypes.object.isRequired, 16 | toggleReplyBox: PropTypes.func.isRequired, 17 | accesstoken: PropTypes.string.isRequired, 18 | author: PropTypes.string.isRequired, 19 | }; 20 | 21 | sendReply() { 22 | const {postReply, reply, accesstoken, author} = this.props; 23 | const topic_id = reply.get('topic_id'); 24 | const reply_id = reply.get('reply_id'); 25 | const content = this.content.value; 26 | 27 | postReply({ 28 | accesstoken, 29 | topic_id, 30 | content, 31 | reply_id, 32 | author 33 | }); 34 | } 35 | 36 | hideReplyBox() { 37 | const {toggleReplyBox} = this.props; 38 | 39 | toggleReplyBox({ 40 | show: false 41 | }); 42 | 43 | this.content.value = ''; 44 | } 45 | 46 | render() { 47 | const {reply} = this.props; 48 | const show = reply.get('show'); 49 | const replyTo = reply.get('replyTo'); 50 | 51 | if (replyTo) { 52 | this.content.value = `@${replyTo} `; 53 | } 54 | 55 | return ( 56 |
57 |
58 |
59 | 60 |
61 |